Compare commits

..
Author SHA1 Message Date
hsliuandClaude Opus 4.7 3bda7ebd8b build: bump docker base images and install docs to v0.22.0
Update Dockerfiles, quickstart, and installation guides to reference
vLLM v0.22.0 (CUDA 12.9, ROCm 7.2.2) instead of v0.20.0/v0.21.0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 06:13:06 +08:00
Yueqian Lin 969c9b5a61 docs: add Higgs-Audio v2 to supported models for v0.22.0
Higgs-Audio v2 (HiggsAudioV2ForConditionalGeneration, #3762) is included
in the v0.22.0 release but was missing from the supported models table.
Higgs-Audio v3 (#4169) landed after the v0.22.0 cut and is intentionally
not listed here.

Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
2026-06-08 08:26:36 -07:00
Hongsheng Liu e592d29f4c docs: update README and supported models for v0.22.0 release
- Add v0.22.0 to Latest News section in README with Cosmos3 recipe link
- Add new models to supported_models.md: MiniCPM-o 4.5, OmniVoice, VoxCPM2,
  MOSS-TTS Nano, MOSS-TTS Delay, MOSS-TTS Realtime, and Lance
- Remove Higgs-Audio v2/v3 (not in 0.22 release)

Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-06-08 16:43:49 +08:00
186 changed files with 2671 additions and 15631 deletions

No files matched your search

+4 -4
View File
@@ -1,14 +1,14 @@
# 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 for the nightly
# exception in upload_pipeline.py (nightly-test label or main NIGHTLY=1); otherwise upload-nightly
# exception in upload_pipeline_with_skip_ci.sh (nightly-test label or main NIGHTLY=1); otherwise upload-nightly
# can be skipped together with test-ready/test-merge. Weekly uploads also depend on image-build.
# Document 2: appended after `---`; same file, read by upload_pipeline.py (not evaluated as a second pipeline by Buildkite). Child uploads include test-ready.yml, test-merge.yml, test-nightly.yml, and test-weekly.yml.
# Document 2: appended after `---`; same file, read by upload_pipeline_with_skip_ci.sh (not evaluated as a second pipeline by Buildkite). Child uploads include test-ready.yml, test-merge.yml, test-nightly.yml, and test-weekly.yml.
steps:
- label: ":github: Resolve skip-ci & upload pipeline"
key: upload-ci-pipeline
commands:
- "python3 .buildkite/scripts/upload_pipeline.py --upload .buildkite/pipeline.yml"
- "bash .buildkite/scripts/upload_pipeline_with_skip_ci.sh"
agents:
queue: "cpu_queue_premerge"
@@ -41,7 +41,7 @@ steps:
key: upload-merge-pipeline
if: __UPLOAD_MERGE_IF__
commands:
- python3 .buildkite/scripts/upload_pipeline.py --upload .buildkite/test-merge.yml
- buildkite-agent pipeline upload .buildkite/test-merge.yml
agents:
queue: "cpu_queue_premerge"
+1 -1
View File
@@ -23,7 +23,7 @@ steps:
depends_on: image-build
key: upload-merge-pipeline
commands:
- python3 .buildkite/scripts/upload_pipeline.py --upload .buildkite/test-merge.yml
- buildkite-agent pipeline upload .buildkite/test-merge.yml
agents:
queue: "cpu_queue_premerge"
-308
View File
@@ -1,308 +0,0 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""Render and optionally upload Buildkite pipeline YAML with diff-aware logic.
Bootstrap mode (pipeline.yml with __IMAGE_BUILD_IF__ placeholders):
- Detect docs-only PR/main changes and substitute skip-ci ``if`` expressions.
Test pipeline mode (e.g. test-merge.yml):
- Drop steps or groups whose ``source_file_dependencies`` do not match changed files.
- ``source_file_dependencies`` may be set on a leaf step or on a group step.
- Always strip ``source_file_dependencies`` from uploaded YAML (Buildkite does not
recognize this uploader-only key).
Usage:
python3 upload_pipeline.py [--upload] <pipeline.yml>
# Bootstrap (replaces upload_pipeline_with_skip_ci.sh):
python3 upload_pipeline.py --upload .buildkite/pipeline.yml
# Test pipeline (replaces upload_test_pipeline_with_diff_skip.py):
python3 upload_pipeline.py --upload .buildkite/test-merge.yml
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
try:
import yaml
except ImportError:
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "-q", "pyyaml"],
)
import yaml
LOG = "upload_pipeline"
ROOT = Path(__file__).resolve().parent.parent.parent
DOC_SEP = "\n---\n"
BOOTSTRAP_MARKER = "__IMAGE_BUILD_IF__"
def _log(message: str) -> None:
print(f"{LOG}: {message}", file=sys.stderr)
def _git(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *args],
check=False,
capture_output=True,
text=True,
)
def resolve_changed_files() -> list[str] | None:
"""Return changed file paths, or None when diff cannot be resolved."""
is_pr = os.environ.get("BUILDKITE_PULL_REQUEST", "false") != "false" and os.environ.get(
"BUILDKITE_PULL_REQUEST", ""
)
commit = os.environ.get("BUILDKITE_COMMIT", "")
if is_pr:
base_branch = os.environ.get("BUILDKITE_PULL_REQUEST_BASE_BRANCH", "main")
base_ref = f"origin/{base_branch}"
if _git("rev-parse", "--verify", base_ref).returncode != 0:
_log(f"origin/{base_branch} not found locally; trying fetch")
_git("fetch", "--depth=200", "origin", base_branch)
if _git("rev-parse", "--verify", base_ref).returncode != 0:
if _git("rev-parse", "--verify", base_branch).returncode == 0:
base_ref = base_branch
else:
_log(f"cannot resolve PR base {base_branch}; using safe defaults")
return None
diff_range = f"{base_ref}...{commit}"
elif os.environ.get("BUILDKITE_BRANCH", "") == "main":
if _git("rev-parse", "--verify", f"{commit}^").returncode != 0:
_log("main commit has no parent; using safe defaults")
return None
diff_range = f"{commit}^..{commit}"
else:
_log("not PR/main build; using safe defaults")
return None
result = _git("diff", "--name-only", diff_range)
if result.returncode != 0:
_log(f"git diff failed ({diff_range}); using safe defaults")
return None
files = [line for line in result.stdout.splitlines() if line.strip()]
_log(f"{len(files)} changed file(s)")
return files
def is_docs_only_change(changed_files: list[str]) -> bool:
has_any = False
for file_path in changed_files:
if not file_path:
continue
has_any = True
if file_path.startswith("docs/"):
continue
if file_path.endswith(".md"):
continue
if file_path == "mkdocs.yml":
continue
return False
return has_any
def resolve_skip_ci(changed_files: list[str] | None) -> bool:
if changed_files is None:
_log("skip-ci=0 (could not resolve changed files)")
return False
if is_docs_only_change(changed_files):
_log("docs-only change detected; skip-ci=1")
return True
_log("non-doc changes detected; skip-ci=0")
return False
def render_bootstrap_pipeline(text: str, *, skip_ci: bool) -> str:
if DOC_SEP in text:
_, continuation = text.split(DOC_SEP, 1)
else:
continuation = text
nightly_only = (
'(build.pull_request.labels includes "nightly-test") || (build.branch == "main" && build.env("NIGHTLY") == "1")'
)
if skip_ci:
image_if = f"'{nightly_only}'"
ready_if = "'false'"
merge_if = "'false'"
else:
image_if = "'true'"
ready_if = '\'build.branch != "main" && build.pull_request.labels includes "ready"\''
merge_if = (
'\'(build.branch == "main" && build.env("NIGHTLY") != "1" '
'&& build.env("WEEKLY") != "1") || '
'(build.branch != "main" && build.pull_request.labels includes "merge-test")\''
)
return (
continuation.replace("__IMAGE_BUILD_IF__", image_if)
.replace("__UPLOAD_READY_IF__", ready_if)
.replace("__UPLOAD_MERGE_IF__", merge_if)
)
def _matches_dependencies(changed_files: list[str], prefixes: list[str]) -> bool:
for path in changed_files:
for prefix in prefixes:
normalized = prefix.rstrip("/")
if path == normalized or path.startswith(f"{normalized}/"):
return True
return False
def _strip_source_file_dependencies(step: dict[str, Any]) -> dict[str, Any]:
return {key: value for key, value in step.items() if key != "source_file_dependencies"}
def _step_label(step: dict[str, Any]) -> str:
return str(step.get("group") or step.get("label") or "<step>")
def _filter_steps(steps: list[Any], changed_files: list[str]) -> list[Any]:
filtered: list[Any] = []
for step in steps:
if not isinstance(step, dict):
filtered.append(step)
continue
deps = step.get("source_file_dependencies")
if deps is not None and not isinstance(deps, list):
raise ValueError(
f"source_file_dependencies must be a list in step {_step_label(step)!r}",
)
if deps is not None and not _matches_dependencies(changed_files, deps):
_log(
f"skip {_step_label(step)!r} (no changes under {deps})",
)
continue
nested = step.get("steps")
if nested is not None:
kept_nested = _filter_steps(nested, changed_files)
if not kept_nested:
_log(f"omit empty group {_step_label(step)!r}")
continue
new_step = _strip_source_file_dependencies(step)
new_step["steps"] = kept_nested
filtered.append(new_step)
continue
if deps is not None:
filtered.append(_strip_source_file_dependencies(step))
else:
filtered.append(step)
return filtered
def _strip_uploader_metadata_from_steps(steps: list[Any]) -> list[Any]:
"""Remove uploader-only keys while keeping all steps (no diff filtering)."""
stripped: list[Any] = []
for step in steps:
if not isinstance(step, dict):
stripped.append(step)
continue
deps = step.get("source_file_dependencies")
if deps is not None and not isinstance(deps, list):
raise ValueError(
f"source_file_dependencies must be a list in step {_step_label(step)!r}",
)
nested = step.get("steps")
new_step = _strip_source_file_dependencies(step)
if nested is not None:
new_step["steps"] = _strip_uploader_metadata_from_steps(nested)
stripped.append(new_step)
return stripped
def render_test_pipeline(
doc: dict[str, Any],
changed_files: list[str] | None,
) -> dict[str, Any]:
steps = doc.get("steps")
if not isinstance(steps, list):
return doc
if changed_files is not None:
steps = _filter_steps(steps, changed_files)
else:
steps = _strip_uploader_metadata_from_steps(steps)
return {**doc, "steps": steps}
def resolve_pipeline_path(arg: str) -> Path:
path = Path(arg)
if path.is_absolute():
return path
return ROOT / path
def render_pipeline(path: Path) -> str:
text = path.read_text(encoding="utf-8")
changed_files = resolve_changed_files()
if BOOTSTRAP_MARKER in text:
rendered = render_bootstrap_pipeline(text, skip_ci=resolve_skip_ci(changed_files))
return rendered
doc = yaml.safe_load(text)
if not isinstance(doc, dict):
raise ValueError(f"invalid pipeline YAML: {path}")
doc = render_test_pipeline(doc, changed_files)
return yaml.safe_dump(doc, sort_keys=False)
def upload_to_buildkite(content: str) -> None:
subprocess.run(
["buildkite-agent", "pipeline", "upload"],
input=content,
text=True,
check=True,
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"pipeline",
nargs="?",
default=".buildkite/pipeline.yml",
help="Pipeline YAML path (default: .buildkite/pipeline.yml)",
)
parser.add_argument(
"--upload",
action="store_true",
help="Pipe rendered YAML to buildkite-agent pipeline upload",
)
args = parser.parse_args()
path = resolve_pipeline_path(args.pipeline)
if not path.is_file():
_log(f"missing pipeline file: {path}")
return 1
rendered = render_pipeline(path)
if args.upload:
upload_to_buildkite(rendered)
else:
sys.stdout.write(rendered)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,146 @@
#!/usr/bin/env bash
# Evaluate docs-only skip-ci and upload continuation steps from the given pipeline YAML
# (YAML document after the first `---`). Buildkite `if` is evaluated at upload time.
#
# Usage: upload_pipeline_with_skip_ci.sh [pipeline_yaml]
# pipeline_yaml: path relative to repo root or absolute (default: .buildkite/pipeline.yml)
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
PIPELINE_ARG="${1:-.buildkite/pipeline.yml}"
if [[ "${PIPELINE_ARG}" = /* ]]; then
PIPELINE_YML="${PIPELINE_ARG}"
else
PIPELINE_YML="${ROOT}/${PIPELINE_ARG}"
fi
# 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.yml" ]]; 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"
# Two supported layouts:
# - multi-doc: doc 1 = bootstrap (loaded by Buildkite), doc 2 (after `---`) = uploaded steps.
# - single-doc: whole file is the uploaded steps; caller (UI init step) is the bootstrap.
if sep in text:
_, continuation = text.split(sep, 1)
else:
continuation = text
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\" && build.env(\"WEEKLY\") != \"1\") || (build.branch != \"main\" && build.pull_request.labels includes \"merge-test\")'"
rendered = (
continuation
.replace("__IMAGE_BUILD_IF__", rep)
.replace("__UPLOAD_READY_IF__", ready_rep)
.replace("__UPLOAD_MERGE_IF__", merge_rep)
)
print(rendered, end="")
PY
+148 -159
View File
@@ -1,111 +1,54 @@
env:
VLLM_WORKER_MULTIPROC_METHOD: spawn
HF_HUB_DOWNLOAD_TIMEOUT: 300
HF_HUB_ETAG_TIMEOUT: 60
steps:
- group: ":card_index_dividers: Simple Test"
steps:
- label: "Simple · Diffusion & Model Executor Test"
agent_pool: mi325_1
depends_on: amd-build
timeout_in_minutes: 50
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- export VLLM_ROCM_USE_AITER=0
# ignore test_teacache_extractors.py because it use rocm gemm kernel from vLLM
# that is not supported on CPU
- "timeout 40m pytest -sv tests/diffusion tests/model_executor -m 'core_model and cpu' --ignore=tests/diffusion/cache/test_teacache_extractors.py --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml"
- label: "Simple · Diffusion & Model Executor Test"
agent_pool: mi325_1
depends_on: amd-build
timeout_in_minutes: 50
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- export VLLM_ROCM_USE_AITER=0
# ignore test_teacache_extractors.py because it use rocm gemm kernel from vLLM
# that is not supported on CPU
- "timeout 40m pytest -sv tests/diffusion tests/model_executor -m 'core_model and cpu' --ignore=tests/diffusion/cache/test_teacache_extractors.py --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml"
- label: "Simple · Other Test"
agent_pool: mi325_1
depends_on: amd-build
timeout_in_minutes: 50
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- export VLLM_ROCM_USE_AITER=0
# ignore test_teacache_extractors.py because it use rocm gemm kernel from vLLM
# that is not supported on CPU
- "timeout 40m pytest -sv tests/diffusion tests/model_executor -m 'core_model and cpu' --ignore=tests/diffusion/cache/test_teacache_extractors.py --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml"
- label: "Simple · Other Test"
agent_pool: mi325_1
depends_on: amd-build
timeout_in_minutes: 50
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- export VLLM_ROCM_USE_AITER=0
# ignore test_teacache_extractors.py because it use rocm gemm kernel from vLLM
# that is not supported on CPU
- "timeout 40m pytest -sv tests/diffusion tests/model_executor -m 'core_model and cpu' --ignore=tests/diffusion/cache/test_teacache_extractors.py --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml"
- group: ":card_index_dividers: Diffusion Test"
steps:
- label: "Diffusion · LoRA Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- timeout 20m pytest -s -v tests/diffusion/lora/test_images_generations_lora.py
- label: "Diffusion Images API LoRA E2E"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- timeout 20m pytest -s -v tests/diffusion/lora/test_images_generations_lora.py
- label: "Diffusion Model CPU offloading Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- export VLLM_LOGGING_LEVEL=DEBUG
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- |
timeout 20m bash -c '
set +e
pytest -s -v tests/diffusion/offloader/test_diffusion_cpu_offload.py
EXIT1=\$?
pytest -s -v tests/diffusion/offloader/test_diffusion_layerwise_offload.py
EXIT2=\$?
exit \$((EXIT1 | EXIT2))
'
- label: "Diffusion Sequence Parallelism Test (Need 4 GPUs)"
agent_pool: mi325_4
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- export VLLM_LOGGING_LEVEL=DEBUG
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- timeout 20m pytest -s -v tests/diffusion/distributed/test_sequence_parallel.py
- timeout 20m pytest -s -v tests/diffusion/distributed/test_ulysses_uaa_perf.py
# merge-only tests
- label: "Diffusion Tensor Parallelism Test"
agent_pool: mi325_2
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- pytest -s -v tests/diffusion/distributed/test_zimage_parallelism.py
- label: "Diffusion Model Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- timeout 30m pytest -s -v tests/e2e/offline_inference/test_z_image.py
- label: "Diffusion GPU Worker Test"
agent_pool: mi325_2
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- timeout 20m pytest -s -v tests/diffusion/test_diffusion_worker.py
- label: "Diffusion · Wan22 Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- |
timeout 40m bash -c "
export VLLM_IMAGE_FETCH_TIMEOUT=60
pytest -s -v tests/e2e/offline_inference/test_wan22_t2v.py tests/e2e/online_serving/test_wan22_t2v.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
"
- label: "Diffusion Model CPU offloading Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- export VLLM_LOGGING_LEVEL=DEBUG
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- |
timeout 20m bash -c '
set +e
pytest -s -v tests/diffusion/offloader/test_diffusion_cpu_offload.py
EXIT1=\$?
pytest -s -v tests/diffusion/offloader/test_diffusion_layerwise_offload.py
EXIT2=\$?
exit \$((EXIT1 | EXIT2))
'
## ISSUE depends on `diffusers` package: https://github.com/huggingface/diffusers/issues/13274
# - label: "Audio Generation Model Test"
@@ -119,6 +62,42 @@ steps:
# - export VLLM_WORKER_MULTIPROC_METHOD=spawn
# - timeout 20m pytest -s -v tests/e2e/offline_inference/test_stable_audio_expansion.py -m "advanced_model and diffusion and L4" --run-level advanced_model
- label: "Diffusion Sequence Parallelism Test (Need 4 GPUs)"
agent_pool: mi325_4
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- export VLLM_LOGGING_LEVEL=DEBUG
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- timeout 20m pytest -s -v tests/diffusion/distributed/test_sequence_parallel.py
- timeout 20m pytest -s -v tests/diffusion/distributed/test_ulysses_uaa_perf.py
# merge-only tests
- label: "Diffusion Tensor Parallelism Test"
agent_pool: mi325_2
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- pytest -s -v tests/diffusion/distributed/test_zimage_parallelism.py
- label: "Diffusion Model Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- timeout 30m pytest -s -v tests/e2e/offline_inference/test_z_image.py
- label: "Diffusion GPU Worker Test"
agent_pool: mi325_2
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- timeout 20m pytest -s -v tests/diffusion/test_diffusion_worker.py
- label: "Engine Test"
agent_pool: mi325_1
depends_on: amd-build
@@ -132,65 +111,50 @@ steps:
# This model seems to be important, so I am trying to run it here.
# Since this is from test-nightly.yml,
# I keep this non-blocking.
- group: ":card_index_dividers: E2E Test"
steps:
- label: "Omni Model Test Qwen2-5-Omni"
agent_pool: mi325_4
depends_on: amd-build
mirror_hardwares: [amdproduction]
commands:
- export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
- pytest -s -v tests/e2e/*/test_qwen2_5_omni_expansion.py --run-level "core_model"
- label: "Omni Model Test Qwen2-5-Omni"
agent_pool: mi325_4
depends_on: amd-build
mirror_hardwares: [amdproduction]
commands:
- export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
- pytest -s -v tests/e2e/*/test_qwen2_5_omni_expansion.py --run-level "core_model"
- label: "Omni Model Test Qwen3-Omni"
agent_pool: mi325_2
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- pytest -s -v tests/e2e/offline_inference/test_qwen3_omni.py tests/e2e/online_serving/test_qwen3_omni.py -m 'advanced_model and cuda' --run-level 'advanced_model'
- label: "Omni Model Test Qwen3-Omni"
agent_pool: mi325_2
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- pytest -s -v tests/e2e/offline_inference/test_qwen3_omni.py tests/e2e/online_serving/test_qwen3_omni.py -m 'advanced_model and cuda' --run-level 'advanced_model'
- label: "Qwen3-TTS CustomVoice E2E Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- |
timeout 20m bash -c '
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py tests/e2e/offline_inference/test_qwen3_tts_customvoice.py -m "advanced_model" --run-level "advanced_model"
'
- label: "Qwen3-TTS CustomVoice E2E Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- |
timeout 20m bash -c '
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py tests/e2e/offline_inference/test_qwen3_tts_customvoice.py -m "advanced_model" --run-level "advanced_model"
'
- label: "Qwen3-TTS Base E2E Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- |
timeout 30m bash -c '
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py tests/e2e/offline_inference/test_qwen3_tts_base.py -m "advanced_model" --run-level "advanced_model"
'
- label: "Voxtral-TTS E2E Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- |
timeout 20m bash -c '
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
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"
'
- label: "Qwen3-TTS Base E2E Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- |
timeout 30m bash -c '
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py tests/e2e/offline_inference/test_qwen3_tts_base.py -m "advanced_model" --run-level "advanced_model"
'
# TODO: Bagel test on ROCm is very unstable. @tjtanaa
# Need to debug before reneable numerical changes across large PRs
@@ -231,3 +195,28 @@ steps:
# - export VLLM_WORKER_MULTIPROC_METHOD=spawn
# - export VLLM_ROCM_USE_AITER_RMSNORM=0
# - timeout 40m pytest -s -v tests/e2e/online_serving/test_bagel.py -m "advanced_model" --run-level "advanced_model" -k "rocm"
- label: "Diffusion · Wan22 Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- |
timeout 40m bash -c "
export VLLM_IMAGE_FETCH_TIMEOUT=60
pytest -s -v tests/e2e/offline_inference/test_wan22_t2v.py tests/e2e/online_serving/test_wan22_t2v.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
"
- label: "Voxtral-TTS E2E Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- |
timeout 20m bash -c '
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
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"
'
+142 -154
View File
@@ -1,42 +1,26 @@
env:
VLLM_WORKER_MULTIPROC_METHOD: spawn
HF_HUB_DOWNLOAD_TIMEOUT: 300
HF_HUB_ETAG_TIMEOUT: 60
steps:
- group: ":card_index_dividers: Simple Test"
steps:
- label: "Simple · Diffusion & Model Executor Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- export VLLM_ROCM_USE_AITER=0
# ignore test_teacache_extractors.py because it use rocm gemm kernel from vLLM
# that is not supported on CPU
- "timeout 40m pytest -sv tests/diffusion tests/model_executor -m 'core_model and cpu' --ignore=tests/diffusion/cache/test_teacache_extractors.py --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml"
- label: "Simple · Other Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- export VLLM_ROCM_USE_AITER=0
# ignore test_teacache_extractors.py because it use rocm gemm kernel from vLLM
# that is not supported on CPU
- "timeout 40m pytest -sv tests/diffusion tests/model_executor -m 'core_model and cpu' --ignore=tests/diffusion/cache/test_teacache_extractors.py --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml"
- label: "Custom Pipeline Test"
- label: "Simple · Diffusion & Model Executor Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- timeout 20m pytest -s -v tests/e2e/offline_inference/custom_pipeline/ -m "core_model"
- export VLLM_ROCM_USE_AITER=0
# ignore test_teacache_extractors.py because it use rocm gemm kernel from vLLM
# that is not supported on CPU
- "timeout 40m pytest -sv tests/diffusion tests/model_executor -m 'core_model and cpu' --ignore=tests/diffusion/cache/test_teacache_extractors.py --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml"
- label: "Simple · Other Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- export VLLM_ROCM_USE_AITER=0
# ignore test_teacache_extractors.py because it use rocm gemm kernel from vLLM
# that is not supported on CPU
- "timeout 40m pytest -sv tests/diffusion tests/model_executor -m 'core_model and cpu' --ignore=tests/diffusion/cache/test_teacache_extractors.py --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml"
- label: "Voxtral TTS CUDA Unit Test"
agent_pool: mi325_1
@@ -46,83 +30,39 @@ steps:
commands:
- timeout 10m pytest -s -v tests/model_executor/models/voxtral_tts/test_cuda_graph_acoustic_transformer.py
- group: ":card_index_dividers: Diffusion Test"
steps:
- label: "Diffusion · Batch Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- timeout 20m pytest -sv tests/diffusion/batching -m 'core_model and cuda' --run-level "core_model"
- label: "Diffusion · Batch Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- timeout 20m pytest -sv tests/diffusion/batching -m 'core_model and cuda' --run-level "core_model"
- label: "Diffusion · Offloader Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- export VLLM_LOGGING_LEVEL=DEBUG
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- |
timeout 20m bash -c '
set +e
pytest -s -v tests/diffusion/offloader/test_diffusion_cpu_offload.py
EXIT1=\$?
pytest -s -v tests/diffusion/offloader/test_diffusion_layerwise_offload.py
EXIT2=\$?
exit \$((EXIT1 | EXIT2))
'
- label: "Custom Pipeline Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- timeout 20m pytest -s -v tests/e2e/offline_inference/custom_pipeline/ -m "core_model"
- label: "Diffusion · Cache Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- export GPU_ARCHS=gfx942
- export VLLM_LOGGING_LEVEL=DEBUG
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- timeout 15m pytest -s -v -m "core_model and cache and diffusion and not distributed_cuda and L4"
- label: "Diffusion Sequence Parallelism Test"
agent_pool: mi325_2
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- export VLLM_LOGGING_LEVEL=DEBUG
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- timeout 20m pytest -s -v tests/diffusion/distributed/test_sequence_parallel.py -m core_model
- label: "Diffusion GPU Worker Test"
agent_pool: mi325_2
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- timeout 20m pytest -s -v tests/diffusion/test_diffusion_worker.py
- label: "Diffusion · Model Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- timeout 15m pytest -sv tests/diffusion/models/ -m 'core_model and cuda' --run-level "core_model"
- label: "Diffusion · Wan22 Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- |
timeout 40m bash -c "
export VLLM_TEST_CLEAN_GPU_MEMORY=1
export VLLM_IMAGE_FETCH_TIMEOUT=60
pytest -s -v tests/e2e/online_serving/test_wan22_t2v.py -m 'core_model' --run-level 'core_model'
"
- label: "Diffusion Model CPU offloading Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- export VLLM_LOGGING_LEVEL=DEBUG
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- |
timeout 20m bash -c '
set +e
pytest -s -v tests/diffusion/offloader/test_diffusion_cpu_offload.py
EXIT1=\$?
pytest -s -v tests/diffusion/offloader/test_diffusion_layerwise_offload.py
EXIT2=\$?
exit \$((EXIT1 | EXIT2))
'
## ISSUE depends on `diffusers` package: https://github.com/huggingface/diffusers/issues/13274
# - label: "Audio Generation Model Test"
@@ -136,6 +76,56 @@ steps:
# - export VLLM_WORKER_MULTIPROC_METHOD=spawn
# - timeout 20m pytest -s -v tests/e2e/offline_inference/test_stable_audio_expansion.py -m "advanced_model and diffusion and L4" --run-level advanced_model
- label: "Diffusion Cache Backend Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- export GPU_ARCHS=gfx942
- export VLLM_LOGGING_LEVEL=DEBUG
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- timeout 15m pytest -s -v -m "core_model and cache and diffusion and not distributed_cuda and L4"
- label: "Diffusion Sequence Parallelism Test"
agent_pool: mi325_2
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- export VLLM_LOGGING_LEVEL=DEBUG
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- timeout 20m pytest -s -v tests/diffusion/distributed/test_sequence_parallel.py -m core_model
- label: "Diffusion GPU Worker Test"
agent_pool: mi325_2
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- timeout 20m pytest -s -v tests/diffusion/test_diffusion_worker.py
- label: "Diffusion · Model Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- timeout 15m pytest -sv tests/diffusion/models/ -m 'core_model and cuda' --run-level "core_model"
- label: "Diffusion · Wan22 Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- |
timeout 40m bash -c "
export VLLM_TEST_CLEAN_GPU_MEMORY=1
export VLLM_IMAGE_FETCH_TIMEOUT=60
pytest -s -v tests/e2e/online_serving/test_wan22_t2v.py -m 'core_model' --run-level 'core_model'
"
- label: "Engine Test"
agent_pool: mi325_1
depends_on: amd-build
@@ -159,56 +149,43 @@ steps:
# - timeout 10m pytest -s -v tests/e2e/offline_inference/test_qwen3_omni.py
# - timeout 20m pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py -m "core_model" --run-level "core_model"
- group: ":card_index_dividers: E2E Test"
steps:
- label: "MiMo-Audio E2E Test with H100"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- |
timeout 30m bash -c '
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
pytest -s -v tests/e2e/online_serving/test_mimo_audio.py -m "core_model" --run-level "core_model"
'
- label: "MiMo-Audio E2E Test with H100"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- |
timeout 30m bash -c '
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
pytest -s -v tests/e2e/online_serving/test_mimo_audio.py -m "core_model" --run-level "core_model"
'
- label: "Qwen3-TTS E2E Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- export VLLM_LOGGING_LEVEL=DEBUG
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
- timeout 30m pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py -m "core_model" --run-level "core_model"
- label: "Qwen3-TTS E2E Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- export VLLM_LOGGING_LEVEL=DEBUG
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
- timeout 30m pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py -m "core_model" --run-level "core_model"
- label: "Voxtral-TTS E2E Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- |
timeout 20m bash -c '
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
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"
'
- label: "CosyVoice3-TTS E2E Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- |
timeout 20m bash -c '
pytest -s -v tests/e2e/online_serving/test_cosyvoice3_tts_expansion.py -m "full_model" --run-level "core_model"
'
- label: "Voxtral-TTS E2E Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- |
timeout 20m bash -c '
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
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"
'
# TODO: Bagel test on ROCm is very unstable. @tjtanaa
# Need to debug before reneable numerical changes across large PRs
@@ -248,3 +225,14 @@ steps:
# - export VLLM_WORKER_MULTIPROC_METHOD=spawn
# - export VLLM_ROCM_USE_AITER_RMSNORM=0
# - timeout 40m pytest -s -v tests/e2e/online_serving/test_bagel.py -m "core_model" --run-level "core_model" -k "rocm"
- label: "CosyVoice3-TTS E2E Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- |
timeout 20m bash -c '
pytest -s -v tests/e2e/online_serving/test_cosyvoice3_tts_expansion.py -m "full_model" --run-level "core_model"
'
+25 -119
View File
@@ -44,9 +44,6 @@ steps:
depends_on: upload-merge-pipeline
steps:
- label: "Diffusion · LoRA Test"
source_file_dependencies:
- tests/diffusion/lora/
- vllm_omni/diffusion/lora/
timeout_in_minutes: 30
commands:
- pytest -sv tests/diffusion/lora/ -m 'advanced_model and cuda' --run-level "advanced_model"
@@ -64,9 +61,6 @@ steps:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "Distributed Test"
source_file_dependencies:
- tests/distributed/
- vllm_omni/distributed/
timeout_in_minutes: 60
commands:
- |
@@ -111,9 +105,6 @@ steps:
- group: ":card_index_dividers: Entrypoints Test"
depends_on: upload-merge-pipeline
source_file_dependencies:
- tests/entrypoints/
- vllm_omni/entrypoints/
steps:
- label: "Entrypoint Test with H100"
timeout_in_minutes: 60
@@ -174,13 +165,6 @@ steps:
depends_on: upload-merge-pipeline
steps:
- label: "TTS · Qwen3-TTS CustomVoice Test"
source_file_dependencies:
- tests/e2e/online_serving/test_qwen3_tts_customvoice.py
- tests/e2e/offline_inference/test_qwen3_tts_customvoice.py
- vllm_omni/model_executor/models/qwen3_tts/
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
- vllm_omni/deploy/qwen3_tts.yaml
- vllm_omni/model_executor/stage_configs/qwen3_tts_uniproc.yaml
commands:
- |
timeout 20m bash -c "
@@ -202,12 +186,6 @@ steps:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "TTS · Qwen3-TTS Base Test"
source_file_dependencies:
- tests/e2e/online_serving/test_qwen3_tts_base.py
- tests/e2e/offline_inference/test_qwen3_tts_base.py
- vllm_omni/model_executor/models/qwen3_tts/
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
- vllm_omni/deploy/qwen3_tts.yaml
commands:
- |
timeout 20m bash -c "
@@ -228,34 +206,7 @@ steps:
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "TTS · Ming-Omni-TTS Test"
timeout_in_minutes: 20
commands:
- |
timeout 20m bash -c "
export VLLM_LOGGING_LEVEL=DEBUG
pytest -s -v tests/e2e/online_serving/test_ming_tts.py tests/e2e/offline_inference/test_ming_tts.py -m 'advanced_model and cuda' --run-level 'advanced_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: "TTS · Voxtral-TTS Test"
source_file_dependencies:
- tests/e2e/offline_inference/test_voxtral_tts.py
- tests/e2e/online_serving/test_voxtral_tts.py
- vllm_omni/model_executor/models/voxtral_tts/
- vllm_omni/model_executor/stage_input_processors/voxtral_tts.py
- vllm_omni/deploy/voxtral_tts.yaml
timeout_in_minutes: 20
commands:
- |
@@ -298,12 +249,6 @@ steps:
type: DirectoryOrCreate
- label: "TTS · GLM-TTS Test"
source_file_dependencies:
- tests/e2e/online_serving/test_glm_tts.py
- tests/e2e/offline_inference/test_glm_tts.py
- vllm_omni/model_executor/models/glm_tts/
- vllm_omni/model_executor/stage_input_processors/glm_tts.py
- vllm_omni/deploy/glm_tts.yaml
timeout_in_minutes: 20
commands:
- |
@@ -346,12 +291,6 @@ steps:
type: DirectoryOrCreate
- label: "Omni · Qwen3-Omni Test"
source_file_dependencies:
- tests/e2e/offline_inference/test_qwen3_omni.py
- tests/e2e/online_serving/test_qwen3_omni.py
- vllm_omni/model_executor/models/qwen3_omni/
- vllm_omni/model_executor/stage_input_processors/qwen3_omni.py
- vllm_omni/deploy/qwen3_omni_moe.yaml
timeout_in_minutes: 30
commands:
- pytest -s -v tests/e2e/offline_inference/test_qwen3_omni.py tests/e2e/online_serving/test_qwen3_omni.py -m 'advanced_model and cuda' --run-level 'advanced_model'
@@ -390,11 +329,6 @@ steps:
type: DirectoryOrCreate
- label: "Omni · Mimo Audio Test"
source_file_dependencies:
- tests/e2e/online_serving/test_mimo_audio.py
- vllm_omni/model_executor/models/mimo_audio/
- vllm_omni/model_executor/stage_input_processors/mimo_audio.py
- vllm_omni/deploy/mimo_audio.yaml
timeout_in_minutes: 30
commands:
- pytest -s -v tests/e2e/online_serving/test_mimo_audio.py -m 'advanced_model and cuda' --run-level 'advanced_model'
@@ -432,19 +366,37 @@ steps:
path: /mnt/hf-cache
type: DirectoryOrCreate
- label: "TTS · VoxCPM2 Test"
source_file_dependencies:
- tests/e2e/offline_inference/test_voxcpm2_tts.py
- tests/e2e/online_serving/test_voxcpm2_tts.py
- vllm_omni/model_executor/models/voxcpm2/
- vllm_omni/deploy/voxcpm2.yaml
- label: "Omni · VoxCPM2 Test"
timeout_in_minutes: 20
commands:
- |
timeout 20m bash -c "
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
pytest -s -v tests/e2e/offline_inference/test_voxcpm2.py -m 'advanced_model and cuda' --run-level 'advanced_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"
- "HF_TOKEN"
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "TTS · VoxCPM2 Online Test"
timeout_in_minutes: 30
commands:
- |
timeout 30m bash -c "
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
pytest -s -v tests/e2e/offline_inference/test_voxcpm2_tts.py tests/e2e/online_serving/test_voxcpm2_tts.py -m 'advanced_model and cuda' --run-level 'advanced_model'
pytest -s -v tests/e2e/online_serving/test_voxcpm2_tts.py -m 'advanced_model and cuda' --run-level 'advanced_model'
"
agents:
queue: "gpu_1_queue"
@@ -461,12 +413,6 @@ steps:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "Omni · Higgs-Audio V2 Test"
source_file_dependencies:
- tests/e2e/online_serving/test_higgs_audio_v2.py
- tests/e2e/offline_inference/test_higgs_audio_v2.py
- vllm_omni/model_executor/models/higgs_audio_v2/
- vllm_omni/model_executor/stage_input_processors/higgs_audio_v2.py
- vllm_omni/deploy/higgs_audio_v2.yaml
timeout_in_minutes: 30
commands:
- |
@@ -492,9 +438,6 @@ steps:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "Diffusion · Qwen Image Edit Test"
source_file_dependencies:
- tests/e2e/online_serving/test_qwen_image_edit.py
- vllm_omni/diffusion/models/qwen_image/
timeout_in_minutes: 20
commands:
- pytest -s -v tests/e2e/online_serving/test_qwen_image_edit.py -m 'advanced_model and cuda' --run-level 'advanced_model'
@@ -533,9 +476,6 @@ steps:
type: DirectoryOrCreate
- label: "Diffusion · Longcat Image Edit Test"
source_file_dependencies:
- tests/e2e/online_serving/test_longcat_image_edit.py
- vllm_omni/diffusion/models/longcat_image/
timeout_in_minutes: 20
commands:
- pytest -s -v tests/e2e/online_serving/test_longcat_image_edit.py -m 'advanced_model and cuda' --run-level 'advanced_model'
@@ -574,9 +514,6 @@ steps:
type: DirectoryOrCreate
- label: "Diffusion · Longcat Image Test"
source_file_dependencies:
- tests/e2e/online_serving/test_longcat_image.py
- vllm_omni/diffusion/models/longcat_image/
timeout_in_minutes: 20
commands:
- pytest -s -v tests/e2e/online_serving/test_longcat_image.py -m 'advanced_model and cuda' --run-level 'advanced_model'
@@ -615,13 +552,6 @@ steps:
type: DirectoryOrCreate
- label: "Diffusion · Bagel Test"
source_file_dependencies:
- tests/e2e/offline_inference/test_bagel.py
- tests/e2e/online_serving/test_bagel.py
- vllm_omni/model_executor/models/bagel/
- vllm_omni/diffusion/models/bagel/
- vllm_omni/model_executor/stage_input_processors/bagel.py
- vllm_omni/deploy/bagel.yaml
timeout_in_minutes: 20
commands:
- pytest -s -v tests/e2e/offline_inference/test_bagel.py tests/e2e/online_serving/test_bagel.py -m 'advanced_model and cuda' --run-level 'advanced_model'
@@ -661,9 +591,6 @@ steps:
- label: "Diffusion · Qwen Image Test"
source_file_dependencies:
- tests/e2e/online_serving/test_qwen_image.py
- vllm_omni/diffusion/models/qwen_image/
commands:
- |
timeout 40m bash -c '
@@ -705,9 +632,6 @@ steps:
type: DirectoryOrCreate
- label: "Diffusion · Qwen Image Layered Test"
source_file_dependencies:
- tests/e2e/online_serving/test_qwen_image_layered.py
- vllm_omni/diffusion/models/qwen_image/
commands:
- |
timeout 40m bash -c "
@@ -749,10 +673,6 @@ steps:
type: DirectoryOrCreate
- label: "Diffusion · Wan22 Test"
source_file_dependencies:
- tests/e2e/offline_inference/test_wan22_t2v.py
- tests/e2e/online_serving/test_wan22_t2v.py
- vllm_omni/diffusion/models/wan2_2/
commands:
- |
timeout 40m bash -c "
@@ -794,9 +714,6 @@ steps:
type: DirectoryOrCreate
- label: "Diffusion · HunyuanVideo 1.5 Test"
source_file_dependencies:
- tests/e2e/online_serving/test_hunyuan_video_15.py
- vllm_omni/diffusion/models/hunyuan_video/
timeout_in_minutes: 40
commands:
- |
@@ -838,10 +755,6 @@ steps:
type: DirectoryOrCreate
- label: "Diffusion · Z Image Test"
source_file_dependencies:
- tests/e2e/offline_inference/test_z_image.py
- tests/e2e/online_serving/test_z_image.py
- vllm_omni/diffusion/models/z_image/
timeout_in_minutes: 30
commands:
- pytest -s -v tests/e2e/offline_inference/test_z_image.py tests/e2e/online_serving/test_z_image.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
@@ -859,10 +772,6 @@ steps:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "Diffusion · FLUX.2 Klein Test"
source_file_dependencies:
- tests/e2e/offline_inference/test_flux2_klein.py
- tests/e2e/online_serving/test_flux2_klein.py
- vllm_omni/diffusion/models/flux2_klein/
timeout_in_minutes: 30
commands:
- pytest -s -v tests/e2e/offline_inference/test_flux2_klein.py tests/e2e/online_serving/test_flux2_klein.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
@@ -880,9 +789,6 @@ steps:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "Diffusion · SD3 Test"
source_file_dependencies:
- tests/e2e/online_serving/test_sd3.py
- vllm_omni/diffusion/models/sd3/
timeout_in_minutes: 45
commands:
- |
-1
View File
@@ -591,7 +591,6 @@ steps:
tests/e2e/online_serving/test_longcat_image_edit_expansion.py
tests/e2e/online_serving/test_flux_2_dev_expansion.py
tests/e2e/online_serving/test_bagel_expansion.py
tests/e2e/offline_inference/test_sensenova_u1_expansion.py
-m "full_model and diffusion and H100 and not distributed_cuda"
--run-level "full_model"
agents:
+25 -2
View File
@@ -309,14 +309,37 @@ steps:
path: /mnt/hf-cache
type: DirectoryOrCreate
- label: "TTS · VoxCPM2 Test"
- label: "Omni · VoxCPM2 Test"
timeout_in_minutes: 20
commands:
- |
timeout 20m bash -c "
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
pytest -s -v tests/e2e/offline_inference/test_voxcpm2.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"
- "HF_TOKEN"
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "TTS · VoxCPM2 Online Test"
timeout_in_minutes: 30
commands:
- |
timeout 30m bash -c "
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
pytest -s -v tests/e2e/offline_inference/test_voxcpm2_tts.py tests/e2e/online_serving/test_voxcpm2_tts.py -m 'core_model' --run-level 'core_model'
pytest -s -v tests/e2e/online_serving/test_voxcpm2_tts.py -m 'core_model and tts' --run-level 'core_model'
"
agents:
queue: "gpu_1_queue"
+3 -79
View File
@@ -6,13 +6,6 @@
{% set docker_image_amd = "rocm/vllm-omni:$BUILDKITE_COMMIT" %}
{% set default_working_dir = "/app/vllm-omni" %}
{% if env %}
env:
{% for name in env %}
{{ name }}: "{{ env[name] }}"
{% endfor %}
{% endif %}
steps:
- group: "AMD Tests"
depends_on: ~
steps:
@@ -38,15 +31,8 @@ steps:
agents:
queue: amd-cpu
{% for step_group in steps %}
{% if step_group.group %}
- group: "{{ step_group.group }}"
{% if step_group.depends_on %}
depends_on: {{ step_group.depends_on }}
{% endif %}
steps:
{% for step in step_group.steps %}
{% if step.mirror_hardwares and mirror_hw in step.mirror_hardwares %}
{% for step in steps %}
{% if step.mirror_hardwares and mirror_hw in step.mirror_hardwares %}
- label: "{{ step.agent_pool }}: {{ step.label }}"
depends_on: amd-build
agents:
@@ -60,23 +46,8 @@ steps:
command: bash .buildkite/scripts/hardware_ci/run-amd-test.sh
env:
DOCKER_BUILDKIT: "1"
{% if step.env %}
{% for name in step.env %}
{{ name }}: "{{ step.env[name] }}"
{% endfor %}
{% endif %}
TEST_COMMAND: |-
(command rocm-smi || true) && cd {{ (step.working_dir or default_working_dir) | safe }}
{% if env %}
{% for name in env %}
export {{ name }}="{{ env[name] }}"
{% endfor %}
{% endif %}
{% if step.env %}
{% for name in step.env %}
export {{ name }}="{{ step.env[name] }}"
{% endfor %}
{% endif %}
{% if "mi250" in step.agent_pool %}
python3 -m pip uninstall -y amd-aiter
{% endif %}
@@ -90,52 +61,5 @@ steps:
{% else %}
soft_fail: true
{% endif%}
{% endif %}
{% endfor %}
{% elif step_group.mirror_hardwares and mirror_hw in step_group.mirror_hardwares %}
{% set step = step_group %}
- label: "{{ step.agent_pool }}: {{ step.label }}"
depends_on: amd-build
agents:
{% if step.agent_pool %}
queue: amd_{{ step.agent_pool }}
{% else %}
queue: amd_mi325_1
{% endif %}
{% set cmd_body = (step.command or (step.commands | join("\n"))) | trim %}
{% set indented_cmd = cmd_body | replace("\n", "\n ") %}
command: bash .buildkite/scripts/hardware_ci/run-amd-test.sh
env:
DOCKER_BUILDKIT: "1"
{% if step.env %}
{% for name in step.env %}
{{ name }}: "{{ step.env[name] }}"
{% endfor %}
{% endif %}
TEST_COMMAND: |-
(command rocm-smi || true) && cd {{ (step.working_dir or default_working_dir) | safe }}
{% if env %}
{% for name in env %}
export {{ name }}="{{ env[name] }}"
{% endfor %}
{% endif %}
{% if step.env %}
{% for name in step.env %}
export {{ name }}="{{ step.env[name] }}"
{% endfor %}
{% endif %}
{% if "mi250" in step.agent_pool %}
python3 -m pip uninstall -y amd-aiter
{% endif %}
{{ indented_cmd | safe }}
priority: 100
{% if step.timeout_in_minutes %}
timeout_in_minutes: {{ step.timeout_in_minutes }}
{% endif %}
{% if step.grade and step.grade == "Blocking" %}
soft_fail: false
{% else %}
soft_fail: true
{% endif%}
{% endif %}
{% endfor %}
{% endfor %}
-97
View File
@@ -1,97 +0,0 @@
---
name: precheck-pr
description: Self-check your branch before creating a PR — catch dead code, verify accuracy/perf claims, validate PR title format, and confirm merge readiness. Use when the user says "precheck", "self review", "pre-submit check", or "check my PR before I open it." Never posts to GitHub.
---
# PR Pre-Check
Self-review your branch before creating a PR against `vllm-project/vllm-omni`. Two modes: **quick** catches showstoppers, **full** does a thorough maintainer-grade review. Never posts to GitHub; the report is for the contributor's terminal only.
## Mode Selection
| Mode | When | Time |
|------|------|------|
| **Quick** | About to push, final sanity check | ~3 min |
| **Full** | Ready for review, want maintainer-level scan | ~10 min |
Default to quick if unsure. Run full before marking a PR "ready for review."
## Workflow
### Step 1: Detect Base Branch
```bash
BASE_SHA=$(git merge-base HEAD origin/main 2>/dev/null \
|| git merge-base HEAD main 2>/dev/null \
|| echo origin/main)
echo "diffing against ${BASE_SHA}"
git diff --name-only ${BASE_SHA}...HEAD
```
### Step 2: Validate PR Title
Check the most recent commit message (or branch name if no commit yet) against the project convention. Valid prefixes:
| Prefix | Applies to |
|--------|-----------|
| `[Bugfix]` | Bug fixes |
| `[CI/Build]` | Build or CI improvements |
| `[Doc]` | Documentation changes |
| `[Model]` | New/improved models (include model name) |
| `[Frontend]` | Frontend changes (API server, OmniLLM class, etc.) |
| `[Kernel]` | CUDA/kernel changes |
| `[Core]` | Core logic changes (OmniProcessor, OmniARScheduler, etc.) |
| `[Hardware][Vendor]` | Hardware-specific (e.g., `[Hardware][Ascend]`) |
| `[Misc]` | Other changes (use sparingly) |
✗ if: missing prefix, wrong case (`[bugfix]`), or WIP/Draft in title.
⚠ if: `[Model]` prefix without the model identifier (e.g., `[Model] Add new model` — should be `[Model] Add <ModelName> ...`).
### Step 3: Categorize the PR
| Diff contains | PR type |
|---------------|---------|
| New files under `vllm_omni/model_executor/models/<name>/` | **New Model** |
| Changes to `vllm_omni/diffusion/` | **Diffusion Model** |
| `[Bugfix]` prefix or single-file fix | **Bug Fix** |
| Perf/benchmark/throughput claims in commit msg or diff | **Performance** |
| Everything else | **General** |
If multiple rows apply (e.g., a diffusion model is also a new model), union the checklists.
### Step 4: Run Checklist
Ask: "Quick mode or full mode?" Then walk the checklist for the detected PR type from [references/checklists.md](references/checklists.md). Each item produces ✓, ✗, or ⚠.
### Step 5: Print Report
```
Pre-check report for <branch>
Mode: quick | full
Type: <new-model | diffusion-model | bug-fix | perf | general>
Dimension Result
───────────────── ──────
PR title format ✓
PR desc integrity ✓
Registry/config ✓
Dead code ⚠ 2 warnings
Accuracy ✓
Benchmark ✗ missing software versions
Verdict: 1 blocking | 2 warnings | recommend fixing ✗ before PR
```
**Severity:**
| Mark | Meaning |
|------|---------|
| ✗ | Blocking — fix before opening PR |
| ⚠ | Warning — consider fixing |
| ✓ | Pass |
| — | Skipped (not applicable) |
### Stop Here
Do not post comments, open PRs, or modify files. The report is for the contributor's terminal only.
@@ -1,135 +0,0 @@
# Pre-Check Checklists
Each item is a concrete check to run against the diff. ✗ = fix before PR. ⚠ = consider fixing.
---
## All PRs: Title Format (check first)
PR title must follow the project convention documented in `docs/contributing/README.md`:
| Prefix | Applies to |
|--------|-----------|
| `[Bugfix]` | Bug fixes |
| `[CI/Build]` | Build or CI improvements |
| `[Doc]` | Documentation changes |
| `[Model]` | New/improved models (include model name) |
| `[Frontend]` | Frontend changes (API server, OmniLLM class, etc.) |
| `[Kernel]` | CUDA/kernel changes |
| `[Core]` | Core logic changes (OmniProcessor, OmniARScheduler, etc.) |
| `[Hardware][Vendor]` | Hardware-specific (e.g., `[Hardware][Ascend]`) |
| `[Misc]` | Other changes (use sparingly) |
**Examples:**
```
✓ [Model] Add MiniCPM-o 4.5 support
✓ [Bugfix] Fix Ovis image text encoder dtype
✓ [Core] Qwen3-Omni performance optimization
✓ [Hardware][Ascend] Fix VoxCPM2 on Ascend
✗ Fix Ovis image text encoder dtype ← missing prefix
✗ [bugfix] Fix Ovis image text encoder ← wrong case
✗ [WIP] Add model ← WIP in title
```
- [ ] **Title has valid prefix** — ✗ if missing prefix, wrong case, or WIP/Draft in title. For `[Model]` PRs, the title MUST also include the model identifier (e.g., `[Model] Add <ModelName> ...`). ⚠ if missing the model name.
---
## Bug Fix PRs
### Quick
- [ ] **PR body has 4 sections:** what broke (error message), repro steps, root cause, fix description
- [ ] **Fix is minimal:** diff touches only files directly related to the bug. No unrelated refactors or style changes.
- [ ] **Repro is runnable:** the PR body includes a copy-paste command that reproduces the bug
- [ ] **Branch is rebased:** `git merge-base HEAD origin/main` is recent, no merge conflicts
### Full (adds)
- [ ] **Regression test exists:** `git diff --name-only` includes at least one `tests/` file
- [ ] **Regression test reproduces the original error:** the test would fail on main, pass on this branch
- [ ] **Fix matches root cause exactly:** no "fix the symptom + something else" — if the root cause is dtype, the fix is dtype, not dtype + reformatting
- [ ] **No silent failure risk:** no bare `except: pass`, no `try: ... except: return None`, no empty fallback added
- [ ] **Upstream pattern match:** the fix follows the same pattern as existing code for similar cases (grep for analogous `from_pretrained` calls, etc.)
- [ ] **Environment documented:** torch/transformers/vllm versions listed if the bug is version-dependent
---
## Performance PRs
### Quick
- [ ] **Before/after numbers in PR body:** at minimum, one metric (RTF, RPS, latency, VRAM) with main vs PR values
- [ ] **Hardware specified:** GPU model, count, VRAM. "L4" or "H20" is sufficient. "tested on GPU" is not.
- [ ] **Concurrency scaling:** if the optimization claims better throughput, numbers at 2+ concurrency levels
- [ ] **No unexplained regressions:** if any metric goes down at any concurrency level, explain why
- [ ] **Branch is rebased:** `git merge-base HEAD origin/main` is recent, no merge conflicts
### Full (adds)
- [ ] **Software versions:** torch, CUDA, vllm, vllm-omni versions in PR body
- [ ] **Warmup stated:** "1 warmup + 3 measured" or equivalent
- [ ] **Stddev or range:** not just a single best run. "average of 2 rounds" is minimum.
- [ ] **VRAM measured:** peak GPU memory before and after
- [ ] **Benchmark script checked in:** under `tests/` or `examples/`, runnable by reviewer
- [ ] **Exact command line in PR:** not pseudocode, not "run the benchmark"
- [ ] **Pytest summary included:** `36 passed, 3 skipped, 17 warnings in 2897.66s` — proves the whole suite passes
- [ ] **Config changes documented:** if `max_num_seqs`, `gpu_memory_utilization`, or deploy YAML changed, old and new values stated with reasoning
---
## New Model PRs
### Quick
- [ ] **PR body file list matches `git diff --name-only`:** every file path in the description exists in the diff
- [ ] **Registry entries match claimed count:** count `_OMNI_MODELS` or `_DIFFUSION_MODELS` entries added vs claimed in body
- [ ] **Pipeline config `custom_process_input_func` paths resolve:** each referenced module.function exists
- [ ] **`__init__.py` exports match registry:** each registered class appears in `__init__.py` and `__all__`
- [ ] **Shell script modes match README:** `run_curl.sh` supports every mode the README documents
- [ ] **Branch is rebased:** `git merge-base HEAD origin/main` is recent, no merge conflicts
### Full (adds)
- [ ] **Dead code scan (5 patterns):**
1. Dead `forward()` — defined but never called through the pipeline path
2. Dead factory/builder functions — instantiated nowhere
3. Dead wrappers — thin wrappers with no callers
4. Dead branch guards — `if version > X` where X is always satisfied
5. Unused parameters — `__init__` args never accessed
- [ ] **Copy-paste detection:** no string constant defined in 3+ files, no cross-module validation duplication, no near-identical shape coercion fns
- [ ] **Import hygiene:** no re-export-only imports, no module-level side effects without docs, no `import os` in function body when already at top. Function-body imports are acceptable when the import is only reachable from a closure or factory return value (i.e., `import` would otherwise execute at module load even though the user may never trigger that path). Either lift to top-of-file or annotate with `# noqa: PLC0415`.
- [ ] **Accuracy:** at least one test compares output against reference; audio = valid WAV at correct sample rate; determinism verified if seed claimed. ⚠ if missing and not explicitly deferred to a follow-up PR or RFC section; — if explicitly deferred (the deferral target SHOULD be linked in the PR body).
- [ ] **Performance:** hardware, software versions, warmup, and metrics (RTF, VRAM, RPS, latency) stated. ⚠ if missing and not explicitly deferred; — if explicitly deferred.
- [ ] **Benchmark settings:** model config (TP, PP, max_model_len, enforce_eager, quant), runtime config (batch, input/output spec), environment versions
- [ ] **Benchmark script checked in + exact command line + pytest summary**
---
## Diffusion Model PRs
Diffusion models live under `vllm_omni/diffusion/`. In addition to the New Model checklist above, verify:
- [ ] **Transformer adapter follows contract:** inherits from `nn.Module`, implements `load_weights()`, uses `vllm_omni.diffusion.attention.layer.Attention`
- [ ] **Pipeline has both offline and online paths:** `forward()` for batch generation, streaming path for online serving
- [ ] **Cache-DiT integration:** at least one acceleration (TeaCache, FBCache, etc.) is wired up. ✗ for new **top-level** pipelines. For **subclass** pipelines that inherit `__call__` from a parent where Cache-DiT is already absent, downgrade to ⚠ and recommend filing a follow-up issue against the parent pipeline family.
- [ ] **Parallelism config:** TP/SP/USP/CFG-Parallel options exposed in pipeline config
- [ ] **Docs table updated:** model added to `docs/models/supported_models.md` with correct metadata
- [ ] **E2E test exists:** at least one test exercises the full generate path
---
## General PRs
### Quick
- [ ] **PR body matches diff:** description doesn't claim changes not in the diff
- [ ] **Branch is rebased:** `git merge-base HEAD origin/main` is recent, no merge conflicts
- [ ] **CI gates passing:** DCO, pre-commit, build — check `gh pr view --json statusCheckRollup` if a PR already exists
### Full (adds)
- [ ] **No new dead code in changed files:** scan for unreachable paths, unused imports
- [ ] **Test coverage:** if core code changed (`engine/`, `stages/`, `connectors/`), tests are added or existing tests cover the path
- [ ] **Import hygiene:** no redundant imports added
- [ ] **No unrelated changes:** diff scope matches PR description
-5
View File
@@ -25,12 +25,7 @@ include:
- `add-tts-model`: covers integration of new TTS models and related serving
workflows
- `generate-release-note`: helps prepare release notes for repository changes
- `precheck-pr`: self-check a branch before creating a PR — validates PR title
format, catches dead code, verifies accuracy/perf claims, and confirms merge
readiness
- `review-pr`: provides a structured workflow for reviewing pull requests
- `vllm-omni-npu-model-runner-upgrade`: upgrades NPU model runners to align with the
latest vllm-ascend NPUModelRunner
## Maintenance Guidelines
+13 -7
View File
@@ -1,15 +1,21 @@
<!-- markdownlint-disable -->
PLEASE FILL IN THE PR DESCRIPTION HERE.
PLEASE FILL IN THE PR DESCRIPTION HERE ENSURING ALL CHECKLIST ITEMS (AT THE BOTTOM) HAVE BEEN CONSIDERED.
## Purpose
## Test Plan
**vLLM Version:**
**vLLM-Omni Commit:**
## Test Result
**BEFORE SUBMITTING:** read [CONTRIBUTING.md](https://github.com/vllm-project/vllm-omni/blob/main/CONTRIBUTING.md) and run the [precheck-pr skill](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/precheck-pr/SKILL.md) with the code agent for a self-check against project conventions.
(anything written below this line will be removed by GitHub Actions)
---
<details>
<summary> Essential Elements of an Effective PR Description Checklist </summary>
- [ ] The purpose of the PR, such as "Fix some issue (link existing issues this PR will resolve)".
- [ ] The test plan. Please provide the test scripts & test commands. Please state the reasons if your codes don't require additional test scripts. For test file guidelines, please check the [test style doc](https://docs.vllm.ai/projects/vllm-omni/en/latest/contributing/ci/tests_style/)
- [ ] The test results. Please paste the results comparison before and after, or the e2e results.
- [ ] (Optional) The necessary documentation update, such as updating `supported_models.md` and `examples` for a new model. **Please run `mkdocs serve` to sync the documentation editions to `./docs`.**
- [ ] (Optional) Release notes update. If your change is user-facing, please update the release notes draft.
</details>
**BEFORE SUBMITTING, PLEASE READ <https://github.com/vllm-project/vllm-omni/blob/main/CONTRIBUTING.md>** (anything written below this line will be removed by GitHub Actions)
-2
View File
@@ -1,5 +1,3 @@
# Contributing to vLLM-Omni
You may find information about contributing to vLLM-Omni on [Contributing](https://vllm-omni.readthedocs.io/en/latest/contributing/)
Before submitting a PR, run the [precheck-pr skill](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/precheck-pr/SKILL.md) with the code agent for a self-check against project conventions.
+3 -4
View File
@@ -16,7 +16,7 @@ Easy, fast, and cheap omni-modality model serving for everyone
---
*Latest News* 🔥
- [2026/06] We released [0.22.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.22.0) - an **omnimodal world-model** release aligned with vLLM 0.22, featuring [Nvidia Cosmos3](recipes/cosmos3/Cosmos3-Nano.md)/DreamZero world model support, expanded quantization coverage across Blackwell/NPU/XPU, TTS production improvements, new models including MiniCPM-o 4.5, MOSS-TTS, and Lance, plus RL integration with [VeRL-Omni](https://github.com/verl-project/verl-omni).
- [2026/06] We released [0.22.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.22.0) - an **omnimodal world-model** release aligned with vLLM 0.22, featuring [Nvidia Cosmos3](recipes/cosmos3/Cosmos3-Nano.md)/DreamZero world model support, expanded quantization coverage across Blackwell/NPU/XPU, TTS production improvements, new models including MiniCPM-o 4.5, MOSS-TTS, and Lance, plus RL integration with veRL-Omni.
- [2026/05] We released [0.20.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.20.0) - refreshes the serving/runtime stack for large-scale omni workloads, and improves diffusion model performance, quantization, and hardware readiness across CUDA, ROCm, MUSA, NPU, and XPU backends.
- [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!
@@ -57,9 +57,8 @@ vLLM-Omni is flexible and easy to use with:
vLLM-Omni seamlessly supports most popular open-source models on HuggingFace, including:
- **Omni-modality models** (e.g. Qwen3-Omni, Cosmos, HunyuanImage, BAGEL)
- **TTS models** (e.g. Qwen3-TTS, VoxCPM2, Ming-Omni-TTS, CosyVoice3)
- **Diffusion models** — image, video, and audio generation (e.g. Qwen-Image, Wan2.2, FLUX)
- Omni-modality models (e.g. Qwen-Omni)
- Multi-modality generation models (e.g. Qwen-Image)
## Getting Started
+1
View File
@@ -92,6 +92,7 @@ Core scheduling and caching components.
Configuration classes.
- [vllm_omni.config.model.OmniModelConfig][]
- [vllm_omni.config.stage_config.ModelPipeline][]
- [vllm_omni.config.stage_config.StageConfig][]
- [vllm_omni.config.stage_config.StageConfigFactory][]
- [vllm_omni.config.stage_config.StageType][]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 403 KiB

After

Width:  |  Height:  |  Size: 408 KiB

-10
View File
@@ -116,16 +116,6 @@ Only specific types of PRs will be reviewed. The PR title is prefixed appropriat
!!! note
If the PR spans more than one category, please include all relevant prefixes.
### Pre-Check Before Submitting
Before submitting a PR, run the [precheck-pr skill](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/precheck-pr/SKILL.md) with the code agent for a self-review against project conventions:
The skill offers two modes:
- **Quick (~3 min):** catches showstoppers — PR title format, missing benchmark claims, rebase status
- **Full (~10 min):** thorough maintainer-grade review — dead code scan, copy-paste detection, import hygiene
The precheck covers five PR types: Bug Fix, Performance, New Model, Diffusion Model, and General. Each type has a tailored checklist that validates evidence quality (repro steps, A/B benchmarks, registry entries, etc.). See the [precheck-pr skill](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/precheck-pr/SKILL.md) for the full checklist.
### Local Test
Please run the L1 and L2 test cases locally first and attach the results before contacting us to add the "ready" label. Please refer to the [test instructions](./ci/test_guide.md) for running the test cases.
@@ -8,7 +8,6 @@ Backend-specific setup lives in separate docs:
- [SharedMemoryConnector](omni_connectors/shared_memory_connector.md)
- [MooncakeStoreConnector](omni_connectors/mooncake_store_connector.md)
- [MooncakeTransferEngineConnector](omni_connectors/mooncake_transfer_engine_connector.md)
- [MoriTransferEngineConnector](omni_connectors/mori_transfer_engine_connector.md)
- [YuanrongConnector](omni_connectors/yuanrong_connector.md)
- [YuanrongTransferEngineConnector](omni_connectors/yuanrong_transfer_engine_connector.md)
@@ -24,7 +23,6 @@ Current connectors operate in D2H2D (device to host to device) mode.
| Single node | SharedMemoryConnector | Auto-configured if no connector is specified. |
| Multi node (Mooncake Store) | MooncakeStoreConnector | TCP-based, requires Mooncake Master + metadata server. |
| Multi node (Mooncake RDMA) | MooncakeTransferEngineConnector | RDMA/TCP direct transfer with managed memory pool. Fastest. |
| Multi node (Mori RDMA) | MoriTransferEngineConnector | RDMA direct transfer via Mori IOEngine. |
| Multi node (Yuanrong) | YuanrongConnector | Requires Yuanrong Datasystem + etcd. |
| Ascend NPU P2P (Yuanrong TE) | YuanrongTransferEngineConnector | Uses Yuanrong TransferEngine directly. Configure NPU device IPv4 and `memory_pool_device: "npu"`. |
@@ -1,76 +0,0 @@
# MoriTransferEngineConnector
## When to Use
Currently supports intra-node deployment with Mori.
As noted in #1742, inter-node support will be added back in a future
refactor.
## Mechanism
Uses Mori's `IOEngine` / `MemoryDesc` API for zero-copy RDMA transfers.
- Data Plane: RDMA (InfiniBand/RoCE) with managed memory pool.
- Control Plane: ZMQ for pull-request handshake and async completion.
## Installation
See the [Mori repository](https://github.com/ROCm/mori) for installation instructions.
## Configuration
Mori is configured through the new deploy-config schema (see
[`docs/configuration/stage_configs.md`](../../../configuration/stage_configs.md)).
Define the connector at the top level of the deploy YAML and reference it
by name from each stage's `input_connectors` / `output_connectors`:
```yaml
connectors:
mori_connector:
name: MoriTransferEngineConnector
extra:
host: "auto"
zmq_port: 50051
device_name: ""
memory_pool_size: 536870912
memory_pool_device: "cuda"
stages:
- stage_id: 0
output_connectors:
to_stage_1: mori_connector
- stage_id: 1
input_connectors:
from_stage_0: mori_connector
```
A ready-to-run intra-node example for Qwen3-Omni-MoE on AMD MI300X lives
at `vllm_omni/deploy/qwen3_omni_moe_mori_intranode.yaml` and can be loaded
with:
```bash
vllm-omni serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --log-stats \
--deploy-config vllm_omni/deploy/qwen3_omni_moe_mori_intranode.yaml
```
The yaml wires `MoriTransferEngineConnector` (with `backend_type: xgmi`) to
the chunk_transfer_adapter path (`async_chunk: true`) so stage-to-stage
hidden-state and codec-frame streams ship GPU-to-GPU over AMD Infinity
Fabric instead of SHM. Qwen2.5-Omni + Mori is not yet functional on
the chunk path: it needs `thinker2talker_async_chunk` /
`talker2code2wav_async_chunk` input processors that do not exist yet
(the orchestrator-level path the upstream PR originally targeted was
lost during the entrypoints → engine refactor and removed in #1742).
Parameters:
- host: local RDMA IP (`"auto"` for auto-detect).
- zmq_port: ZMQ base port for control-plane communication.
- device_name: RDMA device (e.g., `"mlx5_0"`), empty for auto-detect.
- memory_pool_size: RDMA memory pool size in bytes.
- memory_pool_device: `"cpu"` (pinned) or `"cuda"` (GPUDirect / XGMI RDMA).
For more details, refer to the
[Mori repository](https://github.com/ROCm/mori).
+2 -2
View File
@@ -305,8 +305,8 @@ class GPUWorker:
)
# Load model
model_loader = DiffusersPipelineLoader(load_config, self.od_config)
self.pipeline = model_loader.load_model(load_device=f"cuda:{rank}")
model_loader = DiffusersPipelineLoader(load_config)
self.pipeline = model_loader.load_model(od_config, load_device=f"cuda:{rank}")
# Setup cache backend
from vllm_omni.diffusion.cache.selector import get_cache_backend
+1 -2
View File
@@ -33,7 +33,7 @@ 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` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `Cosmos3OmniDiffusersPipeline` | Cosmos3 T2I, T2V, I2V, V2V, T2V with sound, action policy | `nvidia/Cosmos3-Nano` | ✅︎ | | | |
| `Cosmos3OmniDiffusersPipeline` | Cosmos3 T2I, T2V, I2V, T2V with sound, action policy | `nvidia/Cosmos3-Nano` | ✅︎ | | | |
| `WanSpeechToVideoPipeline` | Wan2.2-S2V | `Wan-AI/Wan2.2-S2V-14B` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `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` | ✅︎ | ✅︎ | | |
@@ -63,7 +63,6 @@ th {
| `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-CustomVoice | `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-VoiceDesign | `Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-Base | `Qwen/Qwen3-TTS-12Hz-0.6B-Base` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `MingTTSForConditionalGeneration` | Ming-omni-tts dense 0.5B | `inclusionAI/Ming-omni-tts-0.5B` | ✅︎ | | | |
| `GLMTTSForConditionalGeneration` | GLM-TTS | `zai-org/GLM-TTS` | ✅︎ | | | |
| `MossTTSNanoForCausalLM` | MOSS-TTS-Nano | `OpenMOSS-Team/MOSS-TTS-Nano` | ✅︎ | | | |
| `MossTTSDelayModel` | MOSS-TTS, MOSS-TTSD, MOSS-SoundEffect, MOSS-VoiceGenerator | `OpenMOSS-Team/MOSS-VoiceGenerator` | ✅︎ | | | |
+1 -1
View File
@@ -116,7 +116,7 @@ Content-Type: application/json
| `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:** Only applicable to Qwen3-TTS. Derived from the model configuration (`talker_config.codec_language_id` in the checkpoint's `config.json`), plus `Auto`, which is always accepted. Official Qwen3-TTS checkpoints support: Auto, Chinese, English, Japanese, Korean, German, French, Russian, Portuguese, Spanish, Italian.
**Supported languages:** Auto, Chinese, English, Japanese, Korean, German, French, Russian, Portuguese, Spanish, Italian
#### Voice Clone Parameters (Base task)
+3 -44
View File
@@ -67,9 +67,8 @@ curl -L "http://localhost:8091/v1/videos/${video_id}/content" -o output.mp4
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `input_reference` | file | null | Uploaded reference image or video for image-to-video/video-to-video requests |
| `image_reference` | string | null | JSON-encoded reference image payload; do not combine with `input_reference` or `video_reference` |
| `video_reference` | string | null | JSON-encoded reference video payload; do not combine with `input_reference` or `image_reference` |
| `input_reference` | file | null | Uploaded reference image for image-to-video requests |
| `image_reference` | string | null | JSON-encoded reference image payload; do not combine with `input_reference` |
| `width` | integer | model default | Output video width |
| `height` | integer | model default | Output video height |
| `num_frames` | integer | model default | Number of generated frames |
@@ -81,8 +80,6 @@ curl -L "http://localhost:8091/v1/videos/${video_id}/content" -o output.mp4
| `flow_shift` | number | null | Scheduler flow-shift value |
| `true_cfg_scale` | number | null | True CFG scale when supported by the model |
| `seed` | integer | null | Random seed for reproducibility |
| `generate_sound` | boolean | false | Request model-generated audio for video models that support sound generation |
| `sound_duration` | number | null | Duration in seconds for generated audio; defaults to generated video duration |
| `negative_prompt` | string | null | Text describing what to avoid in the generated video |
| `enable_frame_interpolation` | boolean | null | Enable post-generation frame interpolation |
| `frame_interpolation_exp` | integer | null | Interpolation exponent; `1=2x`, `2=4x`, and so on |
@@ -126,41 +123,6 @@ curl -s http://localhost:8091/v1/videos \
-F "fps=16"
```
### Video-to-Video
For models that support video conditioning, upload the reference video with
`input_reference`:
```bash
curl -s http://localhost:8091/v1/videos \
-F "prompt=continue this motion with consistent subjects and lighting" \
-F "input_reference=@input.mp4;type=video/mp4" \
-F "width=1280" \
-F "height=720" \
-F "num_frames=80" \
-F "fps=16"
```
You can also pass a JSON-safe video URL or `data:video/...;base64,...` payload
through `video_reference`. Do not send `video_reference` together with
`input_reference` or `image_reference`.
```bash
curl -s http://localhost:8091/v1/videos \
-F "prompt=continue this motion with consistent subjects and lighting" \
-F 'video_reference={"video_url":"https://example.com/input.mp4"}' \
-F "width=1280" \
-F "height=720" \
-F "num_frames=80" \
-F "fps=16"
```
JSON references currently support `image_url`/`video_url`; `file_id` references
are not implemented yet. Models may expose additional V2V controls through
`extra_params`. For example, Cosmos3 supports
`condition_frame_indexes_vision` and `condition_video_keep` to select which
decoded reference frames are used as clean conditioning.
### Synchronous Generation
```bash
@@ -184,10 +146,7 @@ export VLLM_OMNI_STORAGE_PATH=/var/tmp/vllm-omni-videos
## Model-Specific Examples
For complete text-to-video, image-to-video, and model-specific video-to-video
walkthroughs, see:
For complete text-to-video and image-to-video walkthroughs, see:
- [Text-to-Video](../user_guide/examples/online_serving/text_to_video.md)
- [Image-to-Video](../user_guide/examples/online_serving/image_to_video.md)
- [Cosmos3 recipes](https://github.com/vllm-project/vllm-omni/blob/main/recipes/cosmos3/Cosmos3-Nano.md)
for model-specific video-to-video examples and conditioning controls
+1 -1
View File
@@ -148,7 +148,7 @@ The following tables show which models support each feature:
| **Wan2.2-S2V** | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ (encode/decode) | ❌ | ❌ |
| **Wan2.1-VACE** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **LTX-2** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| **LTX-2.3** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ (decode) | ❌ | ❌ |
| **LTX-2.3** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Helios** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (encode/decode) | ✅ | ❌ |
| **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
-44
View File
@@ -28,50 +28,6 @@ in deep DiT blocks.
Legend: `✅` supported, `❌` unsupported, `⭕` not verified in this
guide. FP8 on Ampere may use a weight-only path where available.
### Faster FP8 GEMM on Blackwell (quack)
On Blackwell (SM 100+), vLLM runs FP8 linears through the FlashInfer kernel, which
applies the bias as a separate kernel after the GEMM. On the small GEMMs in video
DiTs this bias add is a significant overhead. Installing the optional `quack` kernel
lets vLLM-Omni fuse `alpha * (A @ B) + bias` into a single CuteDSL GEMM, recovering
that overhead (e.g. HunyuanVideo-1.5 FP8 goes from slower-than-BF16 to faster).
```bash
# CUDA 12.9
pip install vllm-omni[quack]
# CUDA 13.x
pip install 'quack-kernels[cu13]' --extra-index-url https://download.pytorch.org/whl/cu130
```
It is enabled automatically once installed (no flag needed) and is **Blackwell-only**:
on Hopper/Ada the CUTLASS FP8 kernel already fuses bias, so quack is not used there.
Set `VLLM_OMNI_USE_QUACK_FP8=0` to force the FlashInfer path. If `quack-kernels` is
not installed, FP8 still works — it just keeps the unfused FlashInfer path.
#### Compile cache and warmup
quack JIT-compiles its kernel once per distinct GEMM shape (tens of seconds, longer
the first time across all autotuned configs). The compiled `.o` files are cached on
disk and reused on later runs, so this is a one-time cost — **not** per request.
vLLM-Omni points that cache at `~/.cache/vllm_omni/quack` (override with
`QUACK_CACHE_DIR`) instead of quack's default under `/tmp`, so it survives restarts.
In containers, set `QUACK_CACHE_DIR` to a mounted/persistent path — or bake it into
the image — so the first cold start does not recompile. The engine's startup dummy
run already exercises the kernels, so with a warm cache the first real request is fast.
To pre-warm specific shapes (e.g. at image build time):
```python
from vllm_omni.quantization.quack_fp8 import warmup_quack_fp8
# (M, K, N) per linear; M = number of tokens for your resolution/frame count
warmup_quack_fp8([(14040, 2048, 6144), (14040, 2048, 2048)])
```
> The PyPI package is `quack-kernels` (imported as `quack`); plain `pip install
> quack` is an unrelated statistics library. Requires CUDA 12.9+ and Python 3.12.
## Model Type Support
### Diffusion Model (Qwen-Image, Wan2.2)
@@ -1,14 +0,0 @@
{
"prompt": "A robotic arm, primarily white with black joints and cables, is shown in a clean, modern indoor setting with a white tabletop. The arm, equipped with a gripper holding a small, light green pitcher, is positioned above a clear glass containing a reddish-brown liquid and a spoon. The robotic arm is in the process of pouring a transparent liquid into the glass. To the left of the pitcher, there is an opened jar with a similar reddish-brown substance visible through its transparent body. In the background, a vase with white flowers and a brown couch are partially visible, adding to the contemporary ambiance. The lighting is bright, casting soft shadows on the table. The robotic arm's movements are smooth and controlled, demonstrating precision in its task. As the video progresses, the robotic arm completes the pour, leaving the glass half-filled with the reddish-brown liquid. The jar remains untouched throughout the sequence, and the spoon inside the glass remains stationary. The other robotic arm on the right side also stays stationary throughout the video. The final frame captures the robotic arm with the pitcher finishing the pour, with the glass now filled to a higher level, while the pitcher is slightly tilted but still held securely by the gripper.",
"negative_prompt": "",
"vision_path": "https://github.com/nvidia-cosmos/cosmos-dependencies/raw/refs/heads/assets/cosmos3/inputs/vision/robot_pouring.mp4",
"height": 720,
"width": 1280,
"num_frames": 189,
"num_inference_steps": 35,
"guidance_scale": 6.0,
"flow_shift": 10.0,
"fps": 24,
"condition_frame_indexes_vision": [0, 1],
"condition_video_keep": "first"
}
@@ -143,20 +143,6 @@ def parse_args() -> argparse.Namespace:
default=None,
required=False,
)
parser.add_argument(
"--width",
type=int,
default=None,
metavar="W",
help="Output image width in pixels. Default: None (pipeline's default).",
)
parser.add_argument(
"--height",
type=int,
default=None,
metavar="H",
help="Output image height in pixels. Default: None (pipeline's default).",
)
parser.add_argument(
"--seed",
type=int,
@@ -250,8 +236,8 @@ def parse_args() -> argparse.Namespace:
parser.add_argument(
"--resolution",
type=int,
default=None,
help="Bucket in (640, 1024) to determine the condition and output resolution. If width and height are not provided, this will be set to default 640.",
default=640,
help="Bucket in (640, 1024) to determine the condition and output resolution",
)
parser.add_argument(
@@ -398,15 +384,6 @@ def parse_args() -> argparse.Namespace:
def main():
args = parse_args()
if args.resolution and (args.width or args.height):
raise ValueError("--resolution and --width/--height cannot be specified together")
if args.width is not None and args.width <= 0:
raise ValueError("--width must be a positive integer")
if args.height is not None and args.height <= 0:
raise ValueError("--height must be a positive integer")
if not args.width and not args.height and not args.resolution:
args.resolution = 640
# Validate input images exist and load them
input_images = []
for image_path in args.image:
@@ -487,7 +464,6 @@ def main():
print(
f" Parallel configuration: ulysses_degree={args.ulysses_degree}, ring_degree={args.ring_degree}, cfg_parallel_size={args.cfg_parallel_size}, tensor_parallel_size={args.tensor_parallel_size}, enable_expert_parallel: {args.enable_expert_parallel}"
)
print(f" Output image size: {args.width or 'default'}x{args.height or 'default'}")
print(f"{'=' * 60}\n")
generation_start = time.perf_counter()
@@ -497,26 +473,22 @@ def main():
omni.start_profile()
# Generate edited image
sampling_params = OmniDiffusionSamplingParams(
width=args.width,
height=args.height,
generator=generator,
true_cfg_scale=args.cfg_scale,
guidance_scale=args.guidance_scale,
guidance_scale_2=args.guidance_scale_2,
num_inference_steps=args.num_inference_steps,
num_outputs_per_prompt=args.num_outputs_per_prompt,
layers=args.layers,
)
if args.resolution:
sampling_params.resolution = args.resolution
outputs = omni.generate(
{
"prompt": args.prompt,
"negative_prompt": args.negative_prompt,
"multi_modal_data": {"image": input_image},
},
sampling_params,
OmniDiffusionSamplingParams(
generator=generator,
true_cfg_scale=args.cfg_scale,
guidance_scale=args.guidance_scale,
guidance_scale_2=args.guidance_scale_2,
num_inference_steps=args.num_inference_steps,
num_outputs_per_prompt=args.num_outputs_per_prompt,
layers=args.layers,
resolution=args.resolution,
),
)
generation_end = time.perf_counter()
generation_time = generation_end - generation_start
@@ -17,7 +17,6 @@ list of supported architectures across all modalities, see
| CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | 2 (talker + code2wav) | ✓ | ✓ | — | 24 kHz |
| Fish Speech S2 Pro | `fishaudio/s2-pro` | dual-AR | ✓ | ✓ | — | 44.1 kHz |
| GLM-TTS | `zai-org/GLM-TTS` | 2 (AR + DiT) | ✓ (required) | ✓ | — | 24 kHz |
| Ming-omni-tts | `inclusionAI/Ming-omni-tts-0.5B` | 2 (AR + audio VAE) | ✓ | ✓ | style / IP / dialect / TTA / podcast | 44.1 kHz |
| Ming-flash-omni-TTS | `Jonathan1909/Ming-flash-omni-2.0` | single (talker only) | — (caption-controlled) | — | style / IP / basic captions | 44.1 kHz |
| MOSS-TTS-Nano | `OpenMOSS-Team/MOSS-TTS-Nano` | single (AR + codec) | ✓ (required) | ✓ | voice_clone, continuation | 48 kHz |
| OmniVoice | `k2-fsa/OmniVoice` | 2 (gen + dec) | ✓ | — | voice design, language hint | 24 kHz |
@@ -160,46 +159,6 @@ Streaming requires `async_chunk: true` in the stage config.
---
## Ming-omni-tts
Dense 0.5B two-stage TTS pipeline (`AR + flow` + audio VAE) at 44.1 kHz. The example covers style, IP voice, music-only generation, text-to-audio events, emotion, dialect, zero-shot cloning, podcast, speech+BGM, and speech+environment-sound cases.
### Quick start
```bash
python examples/offline_inference/text_to_speech/ming_tts/end2end.py \
--case style \
--deploy-config vllm_omni/deploy/ming_tts.yaml \
--enforce-eager
```
### Voice cloning
```bash
python examples/offline_inference/text_to_speech/ming_tts/end2end.py \
--case zero_shot \
--ref-audio /path/to/reference.wav \
--ref-text "在此奉劝大家别乱打美白针。" \
--deploy-config vllm_omni/deploy/ming_tts.yaml \
--enforce-eager
```
### Streaming
```bash
python examples/offline_inference/text_to_speech/ming_tts/end2end.py \
--case basic \
--ref-audio /path/to/reference.wav \
--streaming \
--deploy-config vllm_omni/deploy/ming_tts.yaml \
--enforce-eager
```
### Notes
- `style`, `ip`, `bgm`, and `tta` do not require reference audio.
- Reference-audio cases use `--ref-audio`; `zero_shot` also requires `--ref-text`.
- `podcast` uses multiple references via `--ref-audio-paths`.
- Full case details live in [`ming_tts/README.md`](ming_tts/README.md).
---
## Ming-flash-omni-TTS
Standalone talker-only deployment of Ming-flash-omni-2.0 at 44.1 kHz. Voice is controlled through caption fields (`风格` / `IP` / `语速`/`基频`/`音量`) rather than reference audio.
@@ -1,248 +0,0 @@
# Ming-omni-tts Offline Inference
`end2end.py` runs Ming dense 0.5B end to end with vLLM-Omni. It uses the in-repo Ming prompt assembly helper directly, so the example request shape matches the real integration instead of a simplified wrapper.
## Files
| File | Purpose |
|---|---|
| `end2end.py` | Driver: CLI, case loading, prompt construction, orchestration (~150 lines) |
| `cases.yaml` | All 11 built-in case definitions (prompt, text, instruction, ref-audio flags, flow controls) |
| `runner.py` | Engine management and audio output (streaming + blocking paths) |
## Model Overview
Ming dense 0.5B is exposed here as a two-stage offline pipeline:
- **Stage 0**: Qwen2-based AR generation with Ming prompt formatting and inline flow controls
- **Stage 1**: audio VAE decode to mono 44.1 kHz waveform
`config_ming_tts.py` adapts the checkpoint's HuggingFace config fields
(LLM, DiT, aggregator, AudioVAE, token ids). `vllm_omni/deploy/ming_tts.yaml`
selects the vLLM-Omni pipeline and stage runtime topology, including
connectors, async chunking, memory limits, and sampling defaults.
The example supports both:
- **Blocking eager** via `vllm_omni/deploy/ming_tts.yaml`
- **Async chunk eager** via `vllm_omni/deploy/ming_tts.yaml` (default `async_chunk: true`)
## Setup
Install vLLM-Omni with the platform requirements for your accelerator:
```bash
uv pip install -e .
```
The Ming offline example does not require a separate upstream Ming package.
Reference-audio cases use the repo dependencies for audio loading,
resampling, and CampPlus speaker extraction, including `soundfile`,
`torchaudio`, and `onnxruntime-rocm` in the documented ROCm environment.
The tested ROCm environment is summarized in the
[Ming recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/inclusionAI/Ming-omni-tts-0.5B.md).
## Supported Cases
These cases cover the upstream dense 0.5B cookbook surface that maps cleanly onto the current vLLM-Omni example:
- `style`: zero-speaker style-conditioned speech
- `ip`: zero-speaker IP voice generation
- `bgm`: music-only generation
- `tta`: text-to-audio event generation with FlowLoss controls
- `emotion`: reference-audio speech with emotion control
- `basic`: reference-audio speech with speed / pitch / volume control
- `dialect`: reference-audio speech with dialect control
- `zero_shot`: reference-audio cloning with explicit transcript
- `podcast`: multi-reference dialogue generation with automatic speaker embedding extraction
- `speech_bgm`: speech with background music conditioning
- `speech_sound`: speech with environmental sound conditioning
## Quick Start
Run the zero-speaker style example:
```bash
python examples/offline_inference/text_to_speech/ming_tts/end2end.py \
--case style \
--deploy-config vllm_omni/deploy/ming_tts.yaml \
--enforce-eager
```
Run zero-shot cloning with a transcript:
```bash
python examples/offline_inference/text_to_speech/ming_tts/end2end.py \
--case zero_shot \
--ref-audio /path/to/10002287-00000094.wav \
--ref-text "在此奉劝大家别乱打美白针。" \
--deploy-config vllm_omni/deploy/ming_tts.yaml \
--enforce-eager
```
Run emotion-controlled speech:
```bash
python examples/offline_inference/text_to_speech/ming_tts/end2end.py \
--case emotion \
--ref-audio /path/to/emotion_prompt.wav \
--deploy-config vllm_omni/deploy/ming_tts.yaml \
--enforce-eager
```
Run podcast generation with two reference clips:
```bash
python examples/offline_inference/text_to_speech/ming_tts/end2end.py \
--case podcast \
--ref-audio-paths /path/to/CTS-CN-F2F-2019-11-11-423-012-A.wav /path/to/CTS-CN-F2F-2019-11-11-423-012-B.wav \
--deploy-config vllm_omni/deploy/ming_tts.yaml \
--enforce-eager
```
The script automatically extracts one 192-d speaker embedding per reference WAV using the Ming model's `campplus.onnx`.
If you already have precomputed multi-speaker embeddings, you can override extraction with:
```bash
--speaker-embedding /path/to/podcast_speaker_embeddings.json
```
where the JSON is a list of speaker embeddings, one 192-d vector per speaker.
Run text-to-audio event generation:
```bash
python examples/offline_inference/text_to_speech/ming_tts/end2end.py \
--case tta \
--deploy-config vllm_omni/deploy/ming_tts.yaml \
--enforce-eager
```
Use async_chunk streaming:
```bash
python examples/offline_inference/text_to_speech/ming_tts/end2end.py \
--case basic \
--ref-audio /path/to/10002287-00000095.wav \
--streaming \
--deploy-config vllm_omni/deploy/ming_tts.yaml \
--enforce-eager
```
`--streaming` uses `AsyncOmni` and the async_chunk stage config. It currently
supports one prompt per process invocation; use blocking mode for
`--num-prompts > 1`.
Collect runtime stats and a manifest:
```bash
python examples/offline_inference/text_to_speech/ming_tts/end2end.py \
--case style \
--deploy-config vllm_omni/deploy/ming_tts.yaml \
--enforce-eager \
--enable-stats \
--stats-log-file output_audio/ming_style_pipeline.log \
--metadata-json output_audio/ming_style_manifest.json
```
## Reference Fixtures
The upstream Ming cookbook uses these public audio fixtures from `inclusionAI/Ming-omni-tts/data/wavs`:
- `10002287-00000094.wav` for zero-shot cloning
- `10002287-00000095.wav` for `basic`
- `emotion_prompt.wav` for `emotion`
- `yue_prompt.wav` for `dialect`
- `00000309-00000300.wav` for `speech_bgm` and `speech_sound`
- `CTS-CN-F2F-2019-11-11-423-012-A.wav` and `CTS-CN-F2F-2019-11-11-423-012-B.wav` for `podcast`
## Validation Matrix
The repo-facing example is intended to cover the same dense TTS workflows used
by the local Ming validation script:
| Case | Blocking `deploy/ming_tts.yaml` | Async chunk `deploy/ming_tts.yaml` | Extra inputs |
|---|---:|---:|---|
| `style` | Yes | Optional smoke test | none |
| `ip` | Yes | Optional smoke test | none |
| `bgm` | Yes | Optional smoke test | none |
| `tta` | Yes | Optional smoke test | none |
| `emotion` | Yes | Yes | `--ref-audio emotion_prompt.wav` |
| `basic` | Yes | Yes | `--ref-audio 10002287-00000095.wav` |
| `dialect` | Yes | Yes | `--ref-audio yue_prompt.wav` |
| `zero_shot` | Yes | Yes | `--ref-audio 10002287-00000094.wav --ref-text ...` |
| `podcast` | Yes | Yes | two `--ref-audio-paths` |
| `speech_bgm` | Yes | Yes | `--ref-audio 00000309-00000300.wav` |
| `speech_sound` | Yes | Yes | `--ref-audio 00000309-00000300.wav` |
## Validated Outputs
The following measurements are retained from an earlier L4 CUDA validation;
they are not ROCm benchmark results. Default async_chunk matched blocking
output frame counts and Stage-1 patch counts for every case:
| Case | Blocking frames / patches / sec | Async chunk frames / patches / sec |
|---|---:|---:|
| `style` | 409248 / 29 / 9.28 | 409248 / 29 / 9.28 |
| `ip` | 183456 / 13 / 4.16 | 183456 / 13 / 4.16 |
| `bgm` | 1326528 / 94 / 30.08 | 1326528 / 94 / 30.08 |
| `tta` | 465696 / 33 / 10.56 | 465696 / 33 / 10.56 |
| `emotion` | 324576 / 23 / 7.36 | 324576 / 23 / 7.36 |
| `basic` | 211680 / 15 / 4.80 | 211680 / 15 / 4.80 |
| `dialect` | 239904 / 17 / 5.44 | 239904 / 17 / 5.44 |
| `zero_shot` | 409248 / 29 / 9.28 | 409248 / 29 / 9.28 |
| `podcast` | 437472 / 31 / 9.92 | 437472 / 31 / 9.92 |
| `speech_bgm` | 296352 / 21 / 6.72 | 296352 / 21 / 6.72 |
| `speech_sound` | 352800 / 25 / 8.00 | 352800 / 25 / 8.00 |
## Key Arguments
| Argument | Description |
|---|---|
| `--model` | Hugging Face repo or local Ming checkpoint path |
| `--deploy-config` | Deploy config YAML. Use `vllm_omni/deploy/ming_tts.yaml` |
| `--case` | Built-in demo case |
| `--ref-audio` | Single reference wav path for cloning-style cases |
| `--ref-audio-paths` | Multiple reference wav paths, used by `podcast` |
| `--ref-text` | Reference transcript. Required for `zero_shot` |
| `--instructions` | Free-form Ming instruction string |
| `--instruction-json` | Structured Ming instruction JSON |
| `--speaker-embedding` | JSON file containing a 192-d speaker embedding |
| `--extract-speaker-embeddings` | Force CampPlus speaker extraction from the provided reference audio paths |
| `--max-decode-steps` | Override `ming_max_decode_steps` |
| `--num-prompts` | Repeat the same case N times. Outputs are indexed when `N > 1` |
| `--streaming` | Use `AsyncOmni` and async_chunk transport |
| `--enforce-eager` | Recommended for Ming dense; non-eager is out of scope |
| `--enable-stats` / `--log-stats` | Enable vLLM-Omni per-request stats logging |
| `--stats-log-file` | Optional path for the stats log |
| `--metadata-json` | Optional path for the run manifest JSON |
| `--stage-init-timeout` | Per-stage initialization timeout in seconds |
| `--init-timeout` | Total initialization timeout in seconds |
| `--batch-timeout` | Batch timeout in seconds |
| `--worker-backend` | `multi_process` or `ray` |
| `--ray-address` | Ray cluster address when using `--worker-backend ray` |
## Output
- The script writes one mono 44.1 kHz WAV file per run
- Default output directory: `output_audio/`
- Default filename: `ming_<case>.wav`
- When `--num-prompts > 1`, outputs are indexed as `ming_<case>_00000.wav`, `..._00001.wav`, etc.
- When stats are enabled, the script can also write:
- a stats log file such as `ming_style_pipeline.log`
- a manifest JSON with per-output metadata, stage durations, peak memory info,
and streaming client latency metrics when `--streaming` is used
## Notes
- `style` and `ip` are zero-speaker paths and do not require a reference clip
- `emotion`, `basic`, `dialect`, `speech_bgm`, and `speech_sound` require one reference clip
- `zero_shot` requires both `--ref-audio` and `--ref-text`
- `podcast` requires at least two reference clips via `--ref-audio-paths`
- `podcast` automatically extracts one speaker embedding per reference clip
- `--speaker-embedding` may contain either one 192-d vector or a list of 192-d vectors
- `--enforce-eager` was used for the validated runs
- The earlier L4 validation used SDPA for the Ming audio VAE instead of
FlashAttention2, which is the preferred default when available.
@@ -1,110 +0,0 @@
style:
prompt: "Please generate speech based on the following description.\n"
text: "我会一直在这里陪着你,直到你慢慢、慢慢地沉入那个最温柔的梦里……好吗?"
instruction:
风格: >-
这是一种ASMR耳语,属于一种旨在引发特殊感官体验的创意风格。这个女性使用轻柔的普通话进行耳语,声音气音成分重。音量极低,紧贴麦克风,语速极慢,旨在制造触发听者颅内快感的声学刺激。
use_zero_spk_emb: true
max_decode_steps: 200
ip:
prompt: "Please generate speech based on the following description.\n"
text: "这款产品的名字,叫变态坑爹牛肉丸。"
instruction:
IP: "灵小甄"
use_zero_spk_emb: true
max_decode_steps: 200
bgm:
prompt: "Please generate music based on the following description.\n"
text: " Genre: 电子舞曲. Mood: 自信 / 坚定. Instrument: 架子鼓. Theme: 节日. Duration: 30s."
instruction: null
use_zero_spk_emb: false
max_decode_steps: 400
tta:
prompt: "Please generate audio events based on given text.\n"
text: "Thunder and a gentle rain"
instruction: null
use_zero_spk_emb: false
max_decode_steps: 200
cfg: 4.5
sigma: 0.3
temperature: 2.5
emotion:
prompt: "Please generate speech based on the following description.\n"
text: "我竟然抢到了陈奕迅的演唱会门票!太棒了!终于可以现场听一听他的歌声了!"
instruction:
情感: "高兴"
requires_ref_audio: true
auto_extract_speaker_embeddings: true
max_decode_steps: 200
basic:
prompt: "Please generate speech based on the following description.\n"
text: "简单地说,这相当于惠普把消费领域市场拱手相让了。"
instruction:
语速: "快速"
基频: "中"
音量: "中"
requires_ref_audio: true
auto_extract_speaker_embeddings: true
max_decode_steps: 200
dialect:
prompt: "Please generate speech based on the following description.\n"
text: "我觉得社会企业同个人都有责任"
instruction:
方言: "广粤话"
requires_ref_audio: true
auto_extract_speaker_embeddings: true
max_decode_steps: 200
zero_shot:
prompt: "Please generate speech based on the following description.\n"
text: "我们的愿景是构建未来服务业的数字化基础设施,为世界带来更多微小而美好的改变。"
instruction: null
requires_ref_audio: true
requires_ref_text: true
auto_extract_speaker_embeddings: true
max_decode_steps: 200
podcast:
prompt: "Please generate speech based on the following description.\n"
text: " speaker_1:你可以说一下,就大概说一下,可能虽然我也不知道,我看过那部电影没有。\n speaker_2:就是那个叫什么,变相一节课的嘛。\n speaker_1:嗯。\n speaker_2:一部搞笑的电影。\n speaker_1:一部搞笑的。\n"
instruction: null
prompt_text: " speaker_1:并且我们还要进行每个月还要考核 笔试的话还要进行笔试,做个,当服务员还要去笔试了\n speaker_2:对啊,这真的很奇怪,就是 单纯的因,单纯自己工资不高,只是因为可能人家那个店比较出名一点,就对你苛刻要求\n"
requires_ref_audio_count: 2
auto_extract_speaker_embeddings: true
max_decode_steps: 200
speech_bgm:
prompt: "Please generate speech based on the following description.\n"
text: "此次业绩下滑原因,可归结为企业停止服务某些品牌,而带来的负面影响。"
instruction:
BGM:
Genre: "当代古典音乐."
Mood: "温暖 / 友善."
Instrument: "电吉他"
Theme: "节日."
SNR: 10.0
ENV: null
requires_ref_audio: true
auto_extract_speaker_embeddings: true
max_decode_steps: 200
speech_sound:
prompt: "Please generate speech based on the following description.\n"
text: "此次业绩下滑原因,可归结为企业停止服务某些品牌,而带来的负面影响。"
instruction:
BGM:
ENV: "Birds chirping"
SNR: 10.0
Genre: null
Mood: null
Instrument: null
Theme: null
requires_ref_audio: true
auto_extract_speaker_embeddings: true
max_decode_steps: 200
@@ -1,200 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Offline inference demo for Ming-omni-tts via vLLM Omni."""
import json
import os
from pathlib import Path
import soundfile as sf
import torch
import torchaudio
import yaml
from transformers import AutoTokenizer
from vllm.utils.argparse_utils import FlexibleArgumentParser
from vllm_omni.model_executor.models.ming_tts.config_ming_tts import (
KEY_CFG,
KEY_MAX_DECODE_STEPS,
KEY_SIGMA,
KEY_TEMPERATURE,
SAMPLE_RATE,
)
from vllm_omni.model_executor.models.ming_tts.prompt_assembly import build_ming_dense_prompt
from vllm_omni.model_executor.models.ming_tts.speaker_extractor import MingSpeakerEmbeddingExtractor
try:
from .runner import (
build_manifest,
build_sampling_params,
resolve_metadata_json,
resolve_stats_log_file,
run_generation,
)
except ImportError:
from runner import (
build_manifest,
build_sampling_params,
resolve_metadata_json,
resolve_stats_log_file,
run_generation,
)
_DEFAULT_MODEL = "inclusionAI/Ming-omni-tts-0.5B"
_REPO_ROOT = Path(__file__).resolve().parents[4]
_DEFAULT_DEPLOY_CONFIG = str(_REPO_ROOT / "vllm_omni/deploy/ming_tts.yaml")
_CASES_FILE = Path(__file__).with_name("cases.yaml")
CASE_DEFAULTS = yaml.safe_load(_CASES_FILE.read_text(encoding="utf-8")) or {}
if not CASE_DEFAULTS:
raise RuntimeError(f"Empty or missing case definitions in {_CASES_FILE}")
def _build_parser() -> FlexibleArgumentParser:
p = FlexibleArgumentParser(description="Offline Ming-omni-tts example")
p.add_argument("--model", default=_DEFAULT_MODEL, help="Model name or local path")
p.add_argument("--deploy-config", default=None, help="Deploy config YAML; auto-selected when omitted")
p.add_argument("--case", choices=sorted(CASE_DEFAULTS), default="style", help="Built-in demo case")
p.add_argument("--text", default=None, help="Override case text")
p.add_argument("--prompt", default=None, help="Override the system prompt prefix")
p.add_argument("--instructions", default=None, help="Free-form Ming instruction string")
p.add_argument(
"--instruction-json", default=None, help='Structured Ming instruction JSON, e.g. \'{"方言":"广粤话"}\''
)
p.add_argument("--ref-audio", default=None, help="Single reference audio path for cloning-style cases")
p.add_argument("--ref-audio-paths", nargs="+", default=None, help="Multiple reference audio paths (podcast)")
p.add_argument("--ref-text", default=None, help="Reference transcript; required for zero_shot")
p.add_argument("--speaker-embedding", default=None, help="Path to a JSON speaker embedding file")
p.add_argument(
"--extract-speaker-embeddings",
action="store_true",
help="Extract speaker embeddings from ref audio via campplus.onnx",
)
p.add_argument("--max-decode-steps", type=int, default=None, help="Override ming_max_decode_steps")
p.add_argument("--output-dir", default="output_audio", help="Directory for output wav files")
p.add_argument("--output-name", default=None, help="Output wav filename")
p.add_argument("--num-prompts", type=int, default=1, help="Repeat the same prompt N times")
p.add_argument("--streaming", action="store_true", help="Use AsyncOmni with async_chunk streaming")
p.add_argument("--trust-remote-code", action="store_true")
p.add_argument("--enforce-eager", action="store_true")
p.add_argument(
"--log-stats", "--enable-stats", dest="log_stats", action="store_true", help="Enable Omni stats logging"
)
p.add_argument("--stats-log-file", default=None, help="Path for the Omni stats log file")
p.add_argument("--metadata-json", default=None, help="Path for the run manifest JSON")
p.add_argument("--stage-init-timeout", type=int, default=300, help="Per-stage init timeout (s)")
p.add_argument("--init-timeout", type=int, default=600, help="Total init timeout (s)")
p.add_argument("--batch-timeout", type=int, default=5, help="Batch timeout (s)")
p.add_argument("--shm-threshold-bytes", type=int, default=65536)
p.add_argument("--worker-backend", default="multi_process", choices=["multi_process", "ray"])
p.add_argument("--ray-address", default=None, help="Ray cluster address (--worker-backend ray)")
return p
def _finalize_args(args) -> None:
if args.instructions is not None and args.instruction_json is not None:
raise RuntimeError("Use either --instructions or --instruction-json, not both")
if args.num_prompts < 1:
raise RuntimeError("--num-prompts must be at least 1")
if args.streaming and args.num_prompts != 1:
raise RuntimeError("--streaming currently supports exactly one prompt")
if args.deploy_config is None:
args.deploy_config = _DEFAULT_DEPLOY_CONFIG
def _load_waveform(path: str) -> torch.Tensor:
samples, sr = sf.read(path, dtype="float32")
wav = torch.as_tensor(samples, dtype=torch.float32)
if wav.ndim == 2:
wav = wav.mean(dim=1)
wav = wav.reshape(1, -1)
if int(sr) != SAMPLE_RATE:
wav = torchaudio.functional.resample(wav, int(sr), SAMPLE_RATE)
return wav
def _load_speaker_embedding(path: str) -> torch.Tensor:
return torch.as_tensor(json.loads(Path(path).read_text(encoding="utf-8")), dtype=torch.float32)
def _ref_audio_paths(args) -> list[str]:
if args.ref_audio is not None and args.ref_audio_paths is not None:
raise RuntimeError("Use either --ref-audio or --ref-audio-paths, not both")
if args.ref_audio_paths is not None:
return list(args.ref_audio_paths)
return [args.ref_audio] if args.ref_audio else []
def _resolve_reference_inputs(args, case: dict, paths: list[str]):
required = int(case.get("requires_ref_audio_count", 0))
if required > 0 and len(paths) < required:
raise RuntimeError(f"Case '{args.case}' needs {required} ref audio paths via --ref-audio-paths")
if required <= 0 and case.get("requires_ref_audio") and not paths:
raise RuntimeError(f"--ref-audio required for case '{args.case}'")
if not paths:
return None
wavs = [_load_waveform(p) for p in paths]
return wavs[0] if len(wavs) == 1 else wavs
def _resolve_speaker_embedding(args, case: dict, paths: list[str]):
if args.speaker_embedding:
return _load_speaker_embedding(args.speaker_embedding)
if not (case.get("auto_extract_speaker_embeddings") or args.extract_speaker_embeddings) or not paths:
return None
embs = MingSpeakerEmbeddingExtractor(args.model).extract_many(paths)
if not embs:
raise RuntimeError("Speaker extraction produced no embeddings")
return embs[0] if len(embs) == 1 else torch.stack(embs, dim=0)
def _build_prompt_payload(tokenizer, args):
case = CASE_DEFAULTS[args.case]
paths = _ref_audio_paths(args)
prompt_text = args.ref_text if args.ref_text is not None else case.get("prompt_text")
if case.get("requires_ref_text") and not prompt_text:
raise RuntimeError(f"--ref-text required for case '{args.case}'")
prompt_waveform = _resolve_reference_inputs(args, case, paths) if prompt_text is not None else None
speaker_embedding = _resolve_speaker_embedding(args, case, paths)
use_zero_spk_emb = bool(case.get("use_zero_spk_emb")) and prompt_waveform is None and speaker_embedding is None
runtime_controls = {KEY_MAX_DECODE_STEPS: args.max_decode_steps or case["max_decode_steps"]}
for key, field in [(KEY_CFG, "cfg"), (KEY_SIGMA, "sigma"), (KEY_TEMPERATURE, "temperature")]:
if field in case:
runtime_controls[key] = case[field]
instruction = (
json.loads(args.instruction_json) if args.instruction_json else (args.instructions or case.get("instruction"))
)
return build_ming_dense_prompt(
tokenizer,
prompt=args.prompt or case["prompt"],
text=args.text or case["text"],
runtime_controls=runtime_controls,
instruction=instruction,
prompt_text=prompt_text,
prompt_waveform=prompt_waveform,
speaker_embedding=speaker_embedding,
use_zero_spk_emb=use_zero_spk_emb,
)
def main():
args = _build_parser().parse_args()
_finalize_args(args)
tokenizer = AutoTokenizer.from_pretrained(args.model, trust_remote_code=False)
prompt_payload = _build_prompt_payload(tokenizer, args)
case = CASE_DEFAULTS[args.case]
sampling_params_list = build_sampling_params(args.max_decode_steps or case["max_decode_steps"])
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
stats_log_file = resolve_stats_log_file(args)
summaries = run_generation(args, prompt_payload, sampling_params_list, output_dir, stats_log_file)
metadata_json = resolve_metadata_json(args)
manifest = build_manifest(args, prompt_payload, stats_log_file, summaries)
if metadata_json is not None:
Path(metadata_json).write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"Saved run manifest to {metadata_json}")
if __name__ == "__main__":
os.environ.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
main()
@@ -1,277 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import asyncio
import json
import time
import uuid
import wave
from pathlib import Path
import torch
from vllm import SamplingParams
from vllm_omni import AsyncOmni, Omni
from vllm_omni.model_executor.models.ming_tts.config_ming_tts import (
KEY_SPEAKER_EMBEDDING,
SAMPLE_RATE,
TEXT_EOS_TOKEN_ID,
)
def coerce_audio_tensor(audio, *, async_chunk: bool) -> torch.Tensor:
if isinstance(audio, list):
parts = []
for item in audio:
tensor = torch.as_tensor(item, dtype=torch.float32).reshape(-1)
if tensor.numel() > 0:
parts.append(tensor)
if not parts:
return torch.zeros((0,), dtype=torch.float32)
return torch.cat(parts, dim=0)
return torch.as_tensor(audio, dtype=torch.float32).reshape(-1)
def resolve_sr(sr) -> int:
if isinstance(sr, list):
sr = sr[-1]
if hasattr(sr, "item"):
return int(sr.item())
return int(sr)
def extract_sample_rate(multimodal_output: dict) -> int:
sr = multimodal_output.get("sr")
if sr is None:
raise RuntimeError("Expected multimodal_output['sr']")
return resolve_sr(sr)
def write_wav(path: str, audio: torch.Tensor, sample_rate: int) -> None:
audio = audio.clamp(-1.0, 1.0)
pcm16 = (audio * 32767.0).round().to(torch.int16).cpu().numpy()
with wave.open(path, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(int(sample_rate))
wav_file.writeframes(pcm16.tobytes())
def request_index(request_id: str | None, fallback: int) -> int:
try:
return int(request_id)
except (TypeError, ValueError):
if isinstance(request_id, str):
head = request_id.split("_", 1)[0]
if head.isdigit():
return int(head)
return fallback
def audio_summary(audio: torch.Tensor, sample_rate: int) -> dict:
waveform = audio.detach().cpu().reshape(-1).to(torch.float32)
return {
"sample_rate": int(sample_rate),
"num_samples": int(waveform.numel()),
"duration_seconds": float(waveform.numel()) / float(sample_rate),
"max_abs_amplitude": float(waveform.abs().max().item()) if waveform.numel() > 0 else 0.0,
}
def resolve_output_name(output_name: str | None, case: str, index: int, total: int) -> str:
if total == 1:
return output_name or f"ming_{case}.wav"
base = Path(output_name or f"ming_{case}.wav")
return f"{base.stem}_{index:05d}{base.suffix or '.wav'}"
def resolve_stats_log_file(args) -> str | None:
if not args.log_stats:
return None
if args.stats_log_file:
return args.stats_log_file
base = Path(args.output_name or f"ming_{args.case}.wav").stem
return str(Path(args.output_dir) / f"{base}_pipeline.log")
def resolve_metadata_json(args) -> str | None:
if args.metadata_json:
return args.metadata_json
if args.log_stats:
base = Path(args.output_name or f"ming_{args.case}.wav").stem
return str(Path(args.output_dir) / f"{base}_manifest.json")
return None
def build_manifest(args, prompt_payload, stats_log_file: str | None, outputs: list[dict]) -> dict:
additional_information = {}
if isinstance(prompt_payload, dict):
additional_information = dict(prompt_payload.get("additional_information", {}))
return {
"model": args.model,
"case": args.case,
"streaming": bool(args.streaming),
"deploy_config": args.deploy_config,
"enforce_eager": bool(args.enforce_eager),
"num_prompts": int(args.num_prompts),
"log_stats": bool(args.log_stats),
"stats_log_file": stats_log_file,
"prompt_text": additional_information.get("prompt_text"),
"instruction": additional_information.get("instruction"),
"speaker_embedding_shape": (
list(additional_information[KEY_SPEAKER_EMBEDDING].shape)
if KEY_SPEAKER_EMBEDDING in additional_information
and hasattr(additional_information[KEY_SPEAKER_EMBEDDING], "shape")
else None
),
"outputs": outputs,
"generated_at_unix": time.time(),
}
def build_engine_kwargs(args, stats_log_file: str | None) -> dict:
kwargs = {
"model": args.model,
"deploy_config": args.deploy_config,
"enforce_eager": args.enforce_eager,
"trust_remote_code": args.trust_remote_code,
"log_stats": args.log_stats,
"stage_init_timeout": args.stage_init_timeout,
"init_timeout": args.init_timeout,
"batch_timeout": args.batch_timeout,
"shm_threshold_bytes": args.shm_threshold_bytes,
"worker_backend": args.worker_backend,
}
if stats_log_file is not None:
kwargs["log_file"] = stats_log_file
if args.ray_address is not None:
kwargs["ray_address"] = args.ray_address
return kwargs
def build_sampling_params(max_decode_steps: int) -> list[SamplingParams]:
return [
SamplingParams(
temperature=0.0,
max_tokens=max_decode_steps + 1,
stop_token_ids=[int(TEXT_EOS_TOKEN_ID)],
),
SamplingParams(temperature=0.0, max_tokens=1),
]
async def run_streaming(args, prompt_payload, sampling_params_list, output_dir: Path, stats_log_file: str | None):
engine = AsyncOmni(**build_engine_kwargs(args, stats_log_file))
try:
all_audio_chunks = []
accumulated_samples = 0
chunk_idx = 0
start_time = time.time()
chunk_times = []
ttfp_seconds = None
final_stage_output = None
async for stage_output in engine.generate(
prompt=prompt_payload,
request_id=str(uuid.uuid4()),
sampling_params_list=sampling_params_list,
):
final_stage_output = stage_output
multimodal_output = stage_output.multimodal_output or {}
audio = multimodal_output.get("audio")
if audio is None:
continue
finished = stage_output.finished
if isinstance(audio, torch.Tensor):
if finished:
audio_chunk = audio[accumulated_samples:].float().detach().cpu()
else:
audio_chunk = audio.float().detach().cpu()
elif isinstance(audio, list):
audio_chunk = torch.as_tensor(audio[chunk_idx], dtype=torch.float32).reshape(-1).cpu()
else:
audio_chunk = torch.as_tensor(audio, dtype=torch.float32).reshape(-1).cpu()
accumulated_samples += int(audio_chunk.numel())
chunk_idx += 1
if audio_chunk.numel() > 0:
now = time.time()
if ttfp_seconds is None:
ttfp_seconds = now - start_time
chunk_times.append(now)
all_audio_chunks.append(audio_chunk)
if not all_audio_chunks:
raise RuntimeError("Streaming Ming example produced no audio chunks")
waveform = torch.cat(all_audio_chunks, dim=0)
output_name = resolve_output_name(args.output_name, args.case, 0, 1)
output_path = str(output_dir / output_name)
write_wav(output_path, waveform, SAMPLE_RATE)
summary = {
"request_id": getattr(final_stage_output, "request_id", None),
"stage_id": getattr(final_stage_output, "stage_id", None),
"output_path": output_path,
"stage_durations": getattr(final_stage_output, "stage_durations", {}),
"peak_memory_mb": getattr(final_stage_output, "peak_memory_mb", 0.0),
"ttfp_seconds": ttfp_seconds,
"mean_inter_chunk_seconds": (
sum(t1 - t0 for t0, t1 in zip(chunk_times, chunk_times[1:])) / (len(chunk_times) - 1)
if len(chunk_times) > 1
else None
),
}
summary.update(audio_summary(waveform, SAMPLE_RATE))
print(f"Saved streaming output to {output_path}")
print(json.dumps(summary, ensure_ascii=False, indent=2))
return [summary]
finally:
engine.shutdown()
def run_non_streaming(args, prompt_payload, sampling_params_list, output_dir: Path, stats_log_file: str | None):
engine = Omni(**build_engine_kwargs(args, stats_log_file))
try:
outputs = engine.generate(
prompts=[prompt_payload for _ in range(args.num_prompts)],
sampling_params_list=sampling_params_list,
py_generator=False,
)
summaries = []
for fallback_index, output in enumerate(outputs):
if output.final_output_type != "audio":
continue
multimodal_output = output.multimodal_output or {}
waveform = coerce_audio_tensor(multimodal_output.get("audio"), async_chunk=False)
sample_rate = extract_sample_rate(multimodal_output)
output_name = resolve_output_name(
args.output_name,
args.case,
request_index(output.request_id, fallback_index),
args.num_prompts,
)
output_path = str(output_dir / output_name)
write_wav(output_path, waveform, sample_rate)
summary = {
"request_id": output.request_id,
"stage_id": output.stage_id,
"output_path": output_path,
"stage_durations": output.stage_durations,
"peak_memory_mb": output.peak_memory_mb,
}
summary.update(audio_summary(waveform, sample_rate))
summaries.append(summary)
print(f"Saved output to {output_path}")
print(json.dumps(summary, ensure_ascii=False, indent=2))
if not summaries:
raise RuntimeError("Non-streaming Ming example produced no audio outputs")
return summaries
finally:
engine.close()
def run_generation(args, prompt_payload, sampling_params_list, output_dir: Path, stats_log_file: str | None):
if args.streaming:
return asyncio.run(run_streaming(args, prompt_payload, sampling_params_list, output_dir, stats_log_file))
return run_non_streaming(args, prompt_payload, sampling_params_list, output_dir, stats_log_file)
@@ -212,7 +212,7 @@ def run_non_streaming(inputs, sampling_params_list, model_name, args, output_dir
output_audio_dur = 0.0
for batch_idx, o in enumerate(outputs):
audio_tensor = torch.cat((o.multimodal_output["audio"],))
audio_tensor = torch.cat(o.multimodal_output["audio"])
audio_array = audio_tensor.tolist()
output_audio_dur += float(len(audio_array)) / 24000
if args.write_audio:
@@ -16,7 +16,6 @@ For the full list of supported architectures across all modalities, see
|---|---|---|---|---|---|
| Fish Speech S2 Pro | `fishaudio/s2-pro` | ✓ (`ref_audio`+`ref_text`) | ✓ (PCM stream) | — | ✓ |
| GLM-TTS | `zai-org/GLM-TTS` | ✓ (`ref_audio`+`ref_text`, required) | ✓ (PCM stream) | — | ✓ |
| Ming-omni-tts | `inclusionAI/Ming-omni-tts-0.5B` | ✓ (`ref_audio` / `speaker_embedding`) | ✓ (PCM stream) | IP labels + structured `instructions` | — |
| Ming-flash-omni-TTS | `Jonathan1909/Ming-flash-omni-2.0` | — (caption-controlled) | — | caption fields (`instructions`) | — |
| MOSS-TTS-Nano | `OpenMOSS-Team/MOSS-TTS-Nano` | ✓ (`ref_audio` required) | ✓ (PCM stream) | — | ✓ |
| OmniVoice | `k2-fsa/OmniVoice` | ✓ | — | — | — |
@@ -203,51 +202,6 @@ python fish_speech/gradio_demo.py --api-base http://localhost:8091 # if server
---
## Ming-omni-tts
Dense 0.5B two-stage TTS served through `/v1/audio/speech`. Ming uses the standard speech endpoint plus structured controls in `instructions`, `voice`, `language`, `ref_audio`, `ref_text`, and `speaker_embedding`.
### Launch
```bash
bash examples/online_serving/text_to_speech/ming_tts/run_server.sh
```
Equivalent manual command:
```bash
vllm-omni serve inclusionAI/Ming-omni-tts-0.5B \
--deploy-config vllm_omni/deploy/ming_tts.yaml \
--host 0.0.0.0 --port 8091 \
--enforce-eager --omni
```
### Sending requests
```bash
python examples/online_serving/text_to_speech/ming_tts/openai_speech_client.py \
--text "你好,这是 Ming 在线语音合成测试。"
```
Structured dialect control:
```bash
python examples/online_serving/text_to_speech/ming_tts/openai_speech_client.py \
--text "我觉得社会企业同个人都有责任" \
--instruction-json '{"方言":"广粤话"}' \
--ref-audio /path/to/yue_prompt.wav
```
Zero-shot cloning:
```bash
python examples/online_serving/text_to_speech/ming_tts/openai_speech_client.py \
--text "我们的愿景是构建未来服务业的数字化基础设施,为世界带来更多微小而美好的改变。" \
--ref-audio /path/to/10002287-00000094.wav \
--ref-text "在此奉劝大家别乱打美白针。"
```
### Notes
- `run_curl.sh` keeps a small sanity subset; use the Ming README for the broader request cookbook.
- Online serving is speech-shaped today; music-only `bgm` and text-to-audio `tta` remain offline examples.
- Full request details live in [`ming_tts/README.md`](ming_tts/README.md).
---
## Ming-flash-omni-TTS
Standalone talker-only deployment of Ming-flash-omni-2.0. Voice is controlled through caption text passed via `instructions`.
@@ -51,18 +51,13 @@ def build_payload(
response_format: str = "wav",
stream: bool = False,
) -> dict:
"""Build the /v1/audio/speech request payload.
Fish Speech has no built-in speakers; omit ``voice`` so the model
generates with a random timbre (controllable via text tags like
``[cool]``, ``[warm]``, etc.). When ``ref_audio`` is supplied we
enter voice-cloning mode instead.
"""
"""Build the /v1/audio/speech request payload."""
if not text or not text.strip():
raise gr.Error("Please enter text to synthesize.")
payload: dict = {
"input": text.strip(),
"voice": "default",
"response_format": "pcm" if stream else response_format,
"stream": stream,
}
@@ -198,8 +193,7 @@ def build_interface(api_base: str, stream_chunk_seconds: float = 0.5):
with gr.Accordion("Voice Cloning (optional)", open=False):
gr.Markdown(
"Upload or link a short reference audio (10-30s) and provide its transcript to clone the voice. "
"Leave empty for random timbre; use text tags like `[cool]`, `[warm]`, `[happy]` to steer the output."
"Upload or link a short reference audio (10-30s) and provide its transcript to clone the voice."
)
ref_audio = gr.Audio(
label="Reference Audio",
@@ -39,6 +39,7 @@ def run_tts(args) -> None:
payload = {
"model": args.model,
"input": args.text,
"voice": "default",
"response_format": args.response_format,
}
@@ -1,113 +0,0 @@
# Ming-omni-tts Online Serving
Serve the dense `inclusionAI/Ming-omni-tts-0.5B` two-stage TTS model through
the OpenAI-compatible `/v1/audio/speech` endpoint.
## Start Server
```bash
vllm-omni serve inclusionAI/Ming-omni-tts-0.5B \
--deploy-config vllm_omni/deploy/ming_tts.yaml \
--omni \
--port 8091 \
--enforce-eager
```
Or:
```bash
cd examples/online_serving/text_to_speech/ming_tts
./run_server.sh
```
The tested ROCm environment is summarized in the
[Ming recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/inclusionAI/Ming-omni-tts-0.5B.md).
## Send Requests
The Python client targets `http://localhost:8091/v1` with `api_key=EMPTY`; it
does not call OpenAI's hosted API.
```bash
python openai_speech_client.py \
--text "你好,这是 Ming 在线语音合成测试。" \
--max-new-tokens 200
```
Style or dialect controls can be plain text or Ming JSON. The upstream
dialect example also uses `yue_prompt.wav` for speaker conditioning:
```bash
python openai_speech_client.py \
--text "我觉得社会企业同个人都有责任" \
--instruction-json '{"方言":"广粤话"}' \
--ref-audio /path/to/yue_prompt.wav \
--max-new-tokens 200
```
When `--ref-audio` is supplied without `--ref-text`, the server extracts the
Ming speaker embedding, matching upstream `use_spk_emb=True`, without using the
audio as a zero-shot prompt.
Reference-audio cloning:
```bash
python openai_speech_client.py \
--text "我们的愿景是构建未来服务业的数字化基础设施,为世界带来更多微小而美好的改变。" \
--ref-audio /path/to/10002287-00000094.wav \
--ref-text "在此奉劝大家别乱打美白针。" \
--max-new-tokens 200
```
Podcast-style multi-speaker prompt:
```bash
python openai_speech_client.py \
--text " speaker_1:你可以说一下,就大概说一下,可能虽然我也不知道,我看过那部电影没有。
speaker_2:就是那个叫什么,变相一节课的嘛。
speaker_1:嗯。
speaker_2:一部搞笑的电影。
speaker_1:一部搞笑的。" \
--ref-audio /path/to/CTS-CN-F2F-2019-11-11-423-012-A.wav \
--ref-audio /path/to/CTS-CN-F2F-2019-11-11-423-012-B.wav \
--ref-text " speaker_1:并且我们还要进行每个月还要考核 笔试的话还要进行笔试,做个,当服务员还要去笔试了
speaker_2:对啊,这真的很奇怪,就是 单纯的因,单纯自己工资不高,只是因为可能人家那个店比较出名一点,就对你苛刻要求"
```
Streaming PCM:
```bash
python openai_speech_client.py \
--text "你好,这是流式输出测试。" \
--stream \
--output ming_output.pcm
```
`run_curl.sh` keeps small smoke checks:
```bash
./run_curl.sh basic
REF_AUDIO=/path/to/reference.wav REF_TEXT="在此奉劝大家别乱打美白针。" ./run_curl.sh zero_shot
./run_curl.sh stream
```
## Request Fields
| Field | Ming meaning |
|-------|--------------|
| `input` | target text |
| `instructions` | plain style text, or JSON object for structured Ming controls |
| `voice` | Ming IP voice label unless it resolves to an uploaded speaker |
| `language` | Ming `方言` control |
| `ref_audio` | speaker reference; with `ref_text`, also supplies the prompt waveform |
| `ref_text` | transcript enabling zero-shot or podcast prompt-latent conditioning |
| `speaker_embedding` | 192-d Ming speaker embedding |
| `max_new_tokens` | Ming `max_decode_steps` |
## Notes
- `ref_audio` accepts local paths through the client, remote URLs, `file://`,
or `data:` URLs.
- Non-streaming responses return WAV bytes; streaming responses return PCM.
- Music-only `bgm` generation is offline-only until the API exposes Ming
prompt-mode selection.
@@ -1,225 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""OpenAI-compatible client for Ming-omni-tts via /v1/audio/speech.
Examples:
python openai_speech_client.py --text "你好,世界"
python openai_speech_client.py --text "我觉得社会企业同个人都有责任" \
--instruction-json '{"方言":"广粤话"}' --ref-audio yue_prompt.wav --max-new-tokens 200
python openai_speech_client.py \
--text "我们的愿景是构建未来服务业的数字化基础设施,为世界带来更多微小而美好的改变。" \
--ref-audio 10002287-00000094.wav --ref-text "在此奉劝大家别乱打美白针。" \
--max-new-tokens 200
python openai_speech_client.py --text "speaker_1:你好。 speaker_2:你好。" \
--ref-audio speaker_1.wav --ref-audio speaker_2.wav --ref-text "speaker_1:你好。 speaker_2:你好。"
python openai_speech_client.py --text "你好,这是流式输出测试。" \
--stream --output ming_output.pcm
"""
import argparse
import base64
import json
import os
import httpx
DEFAULT_API_BASE = "http://localhost:8091"
DEFAULT_API_KEY = "EMPTY"
DEFAULT_MODEL = "inclusionAI/Ming-omni-tts-0.5B"
EXPECTED_SPEAKER_EMBEDDING_DIM = 192
def encode_audio_to_base64(audio_path: str) -> str:
"""Encode a local audio file to a base64 data URL."""
if not os.path.exists(audio_path):
raise FileNotFoundError(f"Audio file not found: {audio_path}")
ext = audio_path.lower().rsplit(".", 1)[-1]
mime_map = {
"wav": "audio/wav",
"mp3": "audio/mpeg",
"flac": "audio/flac",
"ogg": "audio/ogg",
"aac": "audio/aac",
}
mime_type = mime_map.get(ext, "audio/wav")
with open(audio_path, "rb") as f:
audio_b64 = base64.b64encode(f.read()).decode("utf-8")
return f"data:{mime_type};base64,{audio_b64}"
def load_speaker_embedding(path: str) -> list[float]:
"""Load and validate a 192-d Ming speaker embedding JSON file."""
with open(path, encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, list):
raise ValueError("speaker_embedding file must contain a JSON list")
if len(data) != EXPECTED_SPEAKER_EMBEDDING_DIM:
raise ValueError(
f"Ming dense speaker_embedding must have {EXPECTED_SPEAKER_EMBEDDING_DIM} values, got {len(data)}"
)
values = []
for index, value in enumerate(data):
try:
values.append(float(value))
except (TypeError, ValueError) as exc:
raise ValueError(f"speaker_embedding[{index}] must be a number, got {value!r}") from exc
return values
def build_instruction_payload(args) -> str | None:
"""Return a string payload for the API `instructions` field."""
if args.instructions and args.instruction_json:
raise ValueError("Use either --instructions or --instruction-json, not both")
if args.instruction_json:
parsed = json.loads(args.instruction_json)
return json.dumps(parsed, ensure_ascii=False)
return args.instructions
def validate_args(args) -> None:
"""Fail fast on invalid combinations before hitting the server."""
if args.ref_text and not args.ref_audio:
raise ValueError("--ref-audio is required when --ref-text is provided")
if args.speaker_embedding and args.ref_audio and len(args.ref_audio) > 1:
raise ValueError("--speaker-embedding cannot be combined with multiple --ref-audio values")
def run_tts(args) -> None:
"""Generate speech via the OpenAI-compatible /v1/audio/speech API."""
validate_args(args)
payload = {
"model": args.model,
"input": args.text,
"response_format": args.response_format,
}
if args.voice:
payload["voice"] = args.voice
if args.task_type:
payload["task_type"] = args.task_type
if args.dialect:
payload["language"] = args.dialect
instructions = build_instruction_payload(args)
if instructions:
payload["instructions"] = instructions
if args.ref_audio:
ref_audio = []
for audio in args.ref_audio:
if audio.startswith(("http://", "https://", "data:")):
ref_audio.append(audio)
else:
ref_audio.append(encode_audio_to_base64(audio))
payload["ref_audio"] = ref_audio[0] if len(ref_audio) == 1 else ref_audio
if args.ref_text:
payload["ref_text"] = args.ref_text
if args.speaker_embedding:
payload["speaker_embedding"] = load_speaker_embedding(args.speaker_embedding)
if args.max_new_tokens:
payload["max_new_tokens"] = args.max_new_tokens
if args.stream:
payload["stream"] = True
payload["response_format"] = "pcm"
api_url = f"{args.api_base}/v1/audio/speech"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {args.api_key}",
}
print(f"Model: {args.model}")
print(f"Text: {args.text}")
print(f"Payload keys: {sorted(payload)}")
if args.stream:
output_path = args.output or "ming_output.pcm"
with httpx.Client(timeout=300.0) as client:
with client.stream("POST", api_url, json=payload, headers=headers) as response:
if response.status_code != 200:
print(f"Error: {response.status_code}")
print(response.read().decode())
return
with open(output_path, "wb") as f:
for chunk in response.iter_bytes():
f.write(chunk)
print(f"Streamed PCM audio to: {output_path}")
return
with httpx.Client(timeout=300.0) as client:
response = client.post(api_url, json=payload, headers=headers)
if response.status_code != 200:
print(f"Error: {response.status_code}")
print(response.text)
return
try:
text = response.content.decode("utf-8")
if text.startswith('{"error"'):
print(f"Error: {text}")
return
except UnicodeDecodeError:
pass
output_path = args.output or "ming_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="OpenAI-compatible client for Ming-omni-tts via /v1/audio/speech")
parser.add_argument("--api-base", default=DEFAULT_API_BASE, help="API base URL")
parser.add_argument("--api-key", default=DEFAULT_API_KEY, help="API key")
parser.add_argument("--model", "-m", default=DEFAULT_MODEL, help="Model name or path")
parser.add_argument("--text", required=True, help="Text to synthesize")
parser.add_argument(
"--task-type",
default=None,
choices=["CustomVoice", "VoiceDesign", "Base"],
help="Optional compatibility task type. Ming accepts the same field but primarily uses prompt metadata.",
)
parser.add_argument(
"--voice",
default=None,
help="Maps to Ming `IP` when using built-in character voices, or to an uploaded voice sample name",
)
parser.add_argument("--dialect", default=None, help="Maps to Ming `方言`")
parser.add_argument("--instructions", default=None, help="Free-form Ming instruction string")
parser.add_argument(
"--instruction-json",
default=None,
help='Structured Ming instruction JSON, for example \'{"情感":"高兴"}\'',
)
parser.add_argument(
"--ref-audio",
action="append",
default=None,
help="Reference audio path, URL, or data URL. Repeat for podcast-style multi-speaker prompts.",
)
parser.add_argument("--ref-text", default=None, help="Reference transcript for cloning")
parser.add_argument(
"--speaker-embedding", default=None, help="Path to a JSON file containing a 192-d speaker embedding"
)
parser.add_argument("--max-new-tokens", type=int, default=None, help="Override ming_max_decode_steps")
parser.add_argument("--stream", action="store_true", help="Enable streaming PCM output")
parser.add_argument(
"--response-format",
default="wav",
choices=["wav", "mp3", "flac", "pcm", "aac", "opus"],
help="Audio format when not streaming",
)
parser.add_argument("--output", "-o", default=None, help="Output file path")
args = parser.parse_args()
try:
run_tts(args)
except (FileNotFoundError, ValueError, json.JSONDecodeError) as exc:
raise SystemExit(f"Error: {exc}") from exc
if __name__ == "__main__":
main()
@@ -1,76 +0,0 @@
#!/bin/bash
set -euo pipefail
MODE="${1:-basic}"
HOST="${HOST:-localhost}"
PORT="${PORT:-8091}"
MODEL="${MODEL:-inclusionAI/Ming-omni-tts-0.5B}"
API_URL="http://${HOST}:${PORT}/v1/audio/speech"
TEXT="${TEXT:-你好,这是 Ming 在线语音合成测试。}"
OUTPUT="${OUTPUT:-ming_output.wav}"
STREAM_OUTPUT="${STREAM_OUTPUT:-ming_output.pcm}"
REF_AUDIO="${REF_AUDIO:-}"
REF_TEXT="${REF_TEXT:-}"
post_json() {
local payload="$1"
local output_path="$2"
curl -X POST "$API_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer EMPTY" \
-d "$payload" \
--output "$output_path"
}
case "$MODE" in
basic)
post_json "{
\"model\": \"${MODEL}\",
\"input\": \"${TEXT}\",
\"response_format\": \"wav\"
}" "$OUTPUT"
;;
zero_shot)
if [ -z "$REF_AUDIO" ] || [ -z "$REF_TEXT" ]; then
echo "zero_shot requires REF_AUDIO and REF_TEXT" >&2
exit 1
fi
python - <<'PY' > /tmp/ming_zero_shot_payload.json
import base64
import json
import mimetypes
import os
from pathlib import Path
path = Path(os.environ["REF_AUDIO"])
mime_type = mimetypes.guess_type(path.name)[0] or "audio/wav"
payload = {
"model": os.environ["MODEL"],
"input": os.environ["TEXT"],
"ref_audio": f"data:{mime_type};base64,{base64.b64encode(path.read_bytes()).decode('utf-8')}",
"ref_text": os.environ["REF_TEXT"],
"response_format": "wav",
}
print(json.dumps(payload, ensure_ascii=False))
PY
curl -X POST "$API_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer EMPTY" \
--data-binary @/tmp/ming_zero_shot_payload.json \
--output "$OUTPUT"
rm -f /tmp/ming_zero_shot_payload.json
;;
stream)
post_json "{
\"model\": \"${MODEL}\",
\"input\": \"${TEXT}\",
\"stream\": true,
\"response_format\": \"pcm\"
}" "$STREAM_OUTPUT"
;;
*)
echo "Unknown mode: $MODE" >&2
echo "Supported sanity checks: basic, zero_shot, stream" >&2
exit 1
;;
esac
@@ -1,25 +0,0 @@
#!/bin/bash
# Launch vLLM-Omni server for Ming-omni-tts.
#
# Usage:
# ./run_server.sh
# PORT=8000 ./run_server.sh
set -e
DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$DIR/../../../.." && pwd)"
MODEL="${MODEL:-inclusionAI/Ming-omni-tts-0.5B}"
PORT="${PORT:-8091}"
DEPLOY_CONFIG="${DEPLOY_CONFIG:-$ROOT/vllm_omni/deploy/ming_tts.yaml}"
echo "Starting Ming-omni-tts server with model: $MODEL"
echo "Deploy config: $DEPLOY_CONFIG"
vllm-omni serve "$MODEL" \
--deploy-config "$DEPLOY_CONFIG" \
--host 0.0.0.0 \
--port "$PORT" \
--enforce-eager \
--omni
@@ -1,247 +0,0 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Verify a ModelOpt FP8 diffusers checkpoint exported by
quantize_wan2_2_modelopt_fp8.py / quantize_hunyuanvideo_15_modelopt_fp8.py.
Three checks:
A. transformer/config.json has a sane quantization_config block.
B. transformer/*.safetensors contains FP8 (float8_e4m3fn) quantized tensors.
C. transformer disk size is materially smaller than a BF16 baseline.
Example:
python examples/quantization/check_modelopt_fp8_export.py \\
--output ./hv15-480p-modelopt-fp8
# Optional: compare disk size against a local or HF BF16 baseline.
python examples/quantization/check_modelopt_fp8_export.py \\
--output ./hv15-480p-modelopt-fp8 \\
--baseline hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v
"""
from __future__ import annotations
import argparse
import json
import sys
from collections import Counter
from pathlib import Path
SUPPORTED_ALGOS = ("FP8",)
def _check_config(transformer_dir: Path) -> tuple[int, str | None]:
"""Returns (status, quant_algo). status: 0 pass, 1 fail, 2 warn."""
cfg_path = transformer_dir / "config.json"
if not cfg_path.exists():
print(f"[FAIL] {cfg_path} missing.")
return 1, None
with cfg_path.open(encoding="utf-8") as f:
cfg = json.load(f)
qc = cfg.get("quantization_config")
if not isinstance(qc, dict):
print(f"[FAIL] No `quantization_config` block in {cfg_path}.")
return 1, None
print(f"[A] quantization_config from {cfg_path}:")
print(json.dumps(qc, indent=2))
quant_algo = qc.get("quant_algo")
issues = []
if qc.get("quant_method") != "modelopt":
issues.append(f"quant_method={qc.get('quant_method')!r} (expected 'modelopt')")
if quant_algo not in SUPPORTED_ALGOS:
issues.append(
f"quant_algo={quant_algo!r} (expected one of {SUPPORTED_ALGOS} — vllm-omni adapter may not auto-detect)"
)
if issues:
print("[A] WARN — config looks incomplete:")
for issue in issues:
print(f" - {issue}")
return 2, quant_algo
print(f"[A] PASS — config looks correct (quant_algo={quant_algo}).")
return 0, quant_algo
def _read_safetensors_header(path: Path) -> dict:
"""Read the JSON header of a safetensors file. Bypass-safe — doesn't materialize tensors.
Returns {tensor_name: {'dtype': 'F8_E4M3', 'shape': [...], 'data_offsets': [...]}}.
Header dtype strings: F8_E4M3, F8_E5M2, BF16, F16, F32, F64, I8, I16, I32, I64, BOOL, U8, ...
"""
import struct
with open(path, "rb") as f:
header_len = struct.unpack("<Q", f.read(8))[0]
header = json.loads(f.read(header_len))
header.pop("__metadata__", None)
return header
def _classify_weight_scale_granularity(weight_scale_shapes: list[list[int]]) -> str:
"""Infer per-tensor vs per-channel vs per-block from sample weight_scale shapes.
ModelOpt block-wise produces shapes like `[16, 1, 16, 1]` (broadcasting dims of 1
interleaved with block-count dims). We count "meaningful" dims — ones with size > 1 —
and classify: 0 meaningful dims = per-tensor (scalar), 1 = per-channel, 2+ = per-block.
"""
if not weight_scale_shapes:
return "no weight_scale tensors found"
def meaningful_dims(shape: list[int]) -> int:
return sum(1 for d in shape if d > 1)
per_tensor = sum(1 for s in weight_scale_shapes if meaningful_dims(s) == 0)
per_channel = sum(1 for s in weight_scale_shapes if meaningful_dims(s) == 1)
per_block = sum(1 for s in weight_scale_shapes if meaningful_dims(s) >= 2)
total = len(weight_scale_shapes)
if per_tensor == total:
return "per-tensor (all scalar scales)"
if per_channel == total:
return "per-channel (1 meaningful dim)"
if per_block == total:
return "per-block (2+ meaningful dims — e.g. [M//bm, 1, N//bn, 1] for tiles)"
return f"mixed: per-tensor={per_tensor}, per-channel={per_channel}, per-block={per_block} of {total}"
def _check_safetensors(transformer_dir: Path) -> int:
"""Returns 0 on pass, 1 on fail. Reads on-disk dtype from the safetensors header."""
files = sorted(transformer_dir.glob("*.safetensors"))
if not files:
print(f"[FAIL] No *.safetensors in {transformer_dir}.")
return 1
header_dtype_counts: Counter[str] = Counter()
sample_quant_weight_keys: list[str] = []
sample_scale_keys: list[str] = []
weight_scale_shapes: list[list[int]] = []
sample_weight_scale_entries: list[tuple[str, list[int]]] = []
for f in files:
try:
header = _read_safetensors_header(f)
except Exception as exc:
print(f"[B] WARN — could not parse header of {f}: {exc}")
continue
for k, info in header.items():
dtype = info.get("dtype", "?")
header_dtype_counts[dtype] += 1
# FP8 stores weights as F8_E4M3 directly.
if dtype.startswith("F8") and k.endswith(".weight") and len(sample_quant_weight_keys) < 5:
sample_quant_weight_keys.append(k)
if k.endswith(("_scale", ".weight_scale", ".input_scale", "_scale_inv")) and len(sample_scale_keys) < 5:
sample_scale_keys.append(k)
if k.endswith(".weight_scale"):
weight_scale_shapes.append(info.get("shape", []))
if len(sample_weight_scale_entries) < 5:
sample_weight_scale_entries.append((k, info.get("shape", [])))
print(f"\n[B] On-disk dtype counts across {len(files)} safetensors file(s) (from header, not get_tensor):")
for dtype, count in sorted(header_dtype_counts.items(), key=lambda kv: -kv[1]):
marker = " <-- FP8" if dtype.startswith("F8") else ""
print(f" {dtype:10s} {count:>6d}{marker}")
quant_count = sum(c for d, c in header_dtype_counts.items() if d.startswith("F8"))
if quant_count == 0:
print("[B] FAIL — no FP8 tensors on disk. Calibration likely did not actually quantize the weights.")
return 1
print(f"[B] PASS — {quant_count} FP8 tensors stored on disk.")
if sample_quant_weight_keys:
print(f" sample quantized weight tensors: {sample_quant_weight_keys[:3]}")
if sample_scale_keys:
print(f" sample scale tensors: {sample_scale_keys[:3]}")
print(" (Note: torch's get_tensor() may return these as bf16 views on some versions —")
print(" irrelevant; vLLM's loader uses native FP8 ops.)")
# Weight-scale granularity — per-tensor (scalar) vs per-channel (1-D) vs per-block (N-D).
print(f"\n weight_scale granularity: {_classify_weight_scale_granularity(weight_scale_shapes)}")
for key, shape in sample_weight_scale_entries[:3]:
print(f" {key}: shape {shape}")
return 0
def _disk_size_gib(p: Path) -> float:
return sum(f.stat().st_size for f in p.rglob("*") if f.is_file()) / (1024**3)
def _check_size_vs_baseline(transformer_dir: Path, baseline: str | None) -> int:
"""Returns 0 always (informational only)."""
quant_size = _disk_size_gib(transformer_dir)
print(f"\n[C] FP8 transformer disk size: {quant_size:.2f} GiB")
if baseline is None:
print("[C] SKIP — pass --baseline <path or HF id> to compare against BF16.")
return 0
baseline_path = Path(baseline)
if not baseline_path.exists():
# Try HF download.
try:
from huggingface_hub import snapshot_download
except ImportError:
print("[C] SKIP — huggingface_hub not installed and baseline not a local path.")
return 0
print(f" Downloading baseline transformer from HF: {baseline}")
baseline_path = Path(snapshot_download(baseline, allow_patterns=["transformer/*"]))
bf16_dir = baseline_path / "transformer" if (baseline_path / "transformer").exists() else baseline_path
bf16_size = _disk_size_gib(bf16_dir)
if bf16_size == 0:
print(f"[C] WARN — baseline transformer dir empty: {bf16_dir}")
return 0
# Expected reduction for FP8: ~50%.
min_reduction = 30
reduction = (1 - quant_size / bf16_size) * 100
print(f"[C] BF16 baseline transformer disk size: {bf16_size:.2f} GiB ({bf16_dir})")
print(f"[C] Disk reduction: {reduction:.1f}% (FP8 transformer is {quant_size / bf16_size:.0%} of BF16)")
if reduction < min_reduction:
print(
f"[C] WARN — FP8 should typically reduce disk by ~40-50%; <{min_reduction}% suggests partial quantization."
)
return 0
def main() -> None:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--output", required=True, help="Path to the exported ModelOpt FP8 checkpoint root.")
p.add_argument(
"--baseline",
default=None,
help="Optional BF16 baseline (local diffusers dir or HF id) for disk-size comparison.",
)
args = p.parse_args()
out_root = Path(args.output).expanduser().resolve()
transformer_dir = out_root / "transformer"
if not transformer_dir.exists():
print(f"[FAIL] {transformer_dir} does not exist.")
sys.exit(1)
print(f"Checking: {out_root}\n")
fail = 0
config_status, _quant_algo = _check_config(transformer_dir)
fail |= config_status
fail |= _check_safetensors(transformer_dir)
_check_size_vs_baseline(transformer_dir, args.baseline)
print()
if fail == 0:
print("=" * 60)
print("ALL CHECKS PASSED — checkpoint looks ready for vllm-omni serving.")
elif fail == 1:
print("=" * 60)
print("FAILURES detected — calibration may need to be re-run.")
sys.exit(1)
else:
print("=" * 60)
print("WARNINGS only — checkpoint may serve but with caveats. See [A] above.")
if __name__ == "__main__":
main()
@@ -1,448 +0,0 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Quantize HunyuanVideo-1.5 (480p T2V) to a ModelOpt FP8 Hugging Face checkpoint.
Calibrates the DiT transformer using a small video prompt set and exports a
diffusers-style directory whose transformer carries ModelOpt FP8 metadata.
The exported checkpoint is consumable by vllm-omni's ModelOpt FP8 adapter
(see vllm_omni/diffusion/model_loader/checkpoint_adapters/modelopt.py).
Layers kept full precision match the #2728 / #2795 pattern: modulation,
AdaLayerNorm, entry/exit projections, embeddings, the token refiner path,
and final proj_out. MHA quantizers are off by default; HV-1.5 self-attention
empirically degrades under FP8 (see #2920 ablation).
Example:
python examples/quantization/quantize_hunyuanvideo_15_modelopt_fp8.py \\
--model hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v \\
--output ./hv15-480p-modelopt-fp8 \\
--overwrite
"""
from __future__ import annotations
import argparse
import copy
import json
import re
import shutil
import sys
from pathlib import Path
from typing import Any
import torch
from diffusers import DiffusionPipeline
DEFAULT_PROMPTS = [
"A dog running across a field of golden wheat.",
"An astronaut riding a horse across the surface of Mars, red dust swirling, cinematic wide shot.",
"A hummingbird hovering in front of a vibrant red flower, slow motion, macro shot.",
"A crackling campfire at night under a starry sky, sparks rising into the dark.",
"An underwater shot of a coral reef with tropical fish swimming by, sun rays piercing the water.",
"A close-up of a blooming rose covered in morning dew, soft natural light.",
"A peaceful mountain village at dawn, mist rolling over the rooftops, cinematic establishing shot.",
"A skateboarder doing a kickflip in an urban plaza, slow motion, golden hour lighting.",
]
def _build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--model", required=True, help="Input HV-1.5 diffusers directory or HF id.")
p.add_argument("--output", required=True, help="Output directory for the ModelOpt FP8 checkpoint.")
p.add_argument("--dtype", choices=("bfloat16", "float16"), default="bfloat16")
p.add_argument("--height", type=int, default=480)
p.add_argument("--width", type=int, default=832)
p.add_argument(
"--num-frames",
type=int,
default=33,
help="Frames per calibration sample. 33 matches the typical short benchmark.",
)
p.add_argument("--guidance-scale", type=float, default=6.0)
p.add_argument(
"--calib-steps",
type=int,
default=10,
help="Denoising steps per calibration prompt (10 is enough for amax statistics).",
)
p.add_argument("--calib-size", type=int, default=8, help="How many prompts to use for calibration.")
p.add_argument("--seed", type=int, default=42)
p.add_argument(
"--prompt",
action="append",
default=[],
help="Custom calibration prompt. Repeat to provide multiple.",
)
p.add_argument(
"--quantize-mha",
action="store_true",
help="Enable FP8 attention K/V/softmax quantizers. Off by default — empirically degrades HV-1.5 video output.",
)
p.add_argument(
"--weight-block-size",
type=str,
default=None,
help="Per-block weight quantization as 'M,N' (e.g. '128,128' for 128x128 tiles). "
"Default: per-tensor (one scale per linear). Block-wise typically gives tighter quality at "
"negligible memory cost. Static FP8 is exempt from upstream vLLM's online block-wise gate.",
)
p.add_argument("--overwrite", action="store_true", help="Replace an existing output directory.")
return p
def _parse_block_size(spec: str | None) -> list[int] | None:
if spec is None:
return None
parts = [int(x) for x in spec.split(",") if x.strip()]
if len(parts) != 2:
raise SystemExit(f"--weight-block-size must be 'M,N' (2 ints), got {spec!r}")
return parts
def _require_modelopt() -> Any:
try:
import modelopt.torch.quantization as mtq
except ModuleNotFoundError as exc:
raise SystemExit(
"NVIDIA ModelOpt is not installed. Install with:\n"
" pip install 'nvidia-modelopt[all]'\n"
f"Original error: {exc}"
) from exc
return mtq
def _ensure_paths(args: argparse.Namespace) -> tuple[str, Path]:
model_path = args.model
output_dir = Path(args.output).expanduser().resolve()
if output_dir.exists():
if not args.overwrite:
raise SystemExit(f"Output directory already exists: {output_dir}\nPass --overwrite to replace it.")
shutil.rmtree(output_dir)
return model_path, output_dir
def _select_dtype(name: str) -> torch.dtype:
return {"bfloat16": torch.bfloat16, "float16": torch.float16}[name]
def _build_prompts(args: argparse.Namespace) -> list[str]:
prompts = args.prompt or DEFAULT_PROMPTS
if args.calib_size <= 0:
raise SystemExit("--calib-size must be positive.")
if len(prompts) < args.calib_size:
repeats = (args.calib_size + len(prompts) - 1) // len(prompts)
prompts = (prompts * repeats)[: args.calib_size]
return prompts[: args.calib_size]
# Layers to KEEP at full precision (mirror of the #2920 wiring + #2728/#2795 skip pattern).
# - x_embedder, image_embedder, context_embedder*, time_embed*, cond_type_embed: entry/embedding
# - norm_out, norm1*.linear, norm1_context*.linear, norm2*, norm2_context*: AdaLayerNorm modulation
# - proj_out: final output projection
# - token_refiner*: text-encoder refinement uses diffusers raw nn.Linear
def _filter_func_hv15(name: str) -> bool:
pattern = re.compile(
r"(proj_out.*|"
r".*(x_embedder|image_embedder|context_embedder|context_embedder_2|"
r"time_embed|cond_type_embed|"
r"norm_out|norm1\.linear|norm1_context\.linear|norm2|norm2_context|"
r"token_refiner).*)"
)
return pattern.match(name) is not None
def _mha_filter_func(name: str) -> bool:
pattern = re.compile(
r".*(q_bmm_quantizer|k_bmm_quantizer|v_bmm_quantizer|softmax_quantizer|bmm2_output_quantizer).*"
)
return pattern.match(name) is not None
def _disable_known_problematic_quantizers(mtq: Any, backbone: torch.nn.Module, *, quantize_mha: bool) -> None:
if not hasattr(mtq, "disable_quantizer"):
return
mtq.disable_quantizer(backbone, _filter_func_hv15)
if not quantize_mha:
mtq.disable_quantizer(backbone, _mha_filter_func)
def _load_pipeline(model_path: str, dtype: torch.dtype) -> DiffusionPipeline:
pipe = DiffusionPipeline.from_pretrained(model_path, torch_dtype=dtype)
if hasattr(pipe, "set_progress_bar_config"):
pipe.set_progress_bar_config(disable=True)
pipe.to("cuda")
return pipe
def _build_forward_loop(pipe: DiffusionPipeline, args: argparse.Namespace, prompts: list[str]):
generator = torch.Generator(device="cuda")
# Try to set guidance on the pipeline's guider object up front (modern
# diffusers HV-1.5 uses a Guider abstraction, not a per-call kwarg). Falls
# back silently — calibration uses whatever default the pipeline ships with.
guider = getattr(pipe, "guider", None)
if guider is not None and hasattr(guider, "guidance_scale"):
try:
guider.guidance_scale = args.guidance_scale
except Exception:
pass
base_kwargs = dict(
height=args.height,
width=args.width,
num_frames=args.num_frames,
num_inference_steps=args.calib_steps,
output_type="latent",
)
def forward_loop(*_unused_args, **_unused_kwargs) -> None:
with torch.inference_mode():
for idx, prompt in enumerate(prompts):
generator.manual_seed(args.seed + idx)
# Try with guidance_scale first; fall back without on TypeError
# for pipelines (like HV-1.5) that take CFG via guider config.
try:
pipe(prompt=prompt, generator=generator, guidance_scale=args.guidance_scale, **base_kwargs)
except TypeError as exc:
if "guidance_scale" not in str(exc):
raise
pipe(prompt=prompt, generator=generator, **base_kwargs)
return forward_loop
def _summarize_export(output_dir: Path) -> None:
cfg_path = output_dir / "transformer" / "config.json"
if not cfg_path.exists():
print(f"[warn] {cfg_path} missing.", file=sys.stderr)
return
with cfg_path.open(encoding="utf-8") as f:
cfg = json.load(f)
qc = cfg.get("quantization_config")
if not isinstance(qc, dict):
print("[warn] No quantization_config in transformer/config.json.", file=sys.stderr)
return
print("Export summary:")
print(f" quant_method: {qc.get('quant_method')}")
print(f" quant_algo: {qc.get('quant_algo')}")
producer = qc.get("producer")
if isinstance(producer, dict):
print(f" producer: {producer.get('name')} {producer.get('version')}")
print(f" config path: {cfg_path}")
def _force_export_quantized_weights(backbone: torch.nn.Module, dtype: torch.dtype) -> int:
"""Convert in-memory weights of quantized modules to actual FP8 storage.
`export_hf_checkpoint` skips this step for unknown model types (HV-1.5 isn't
in ModelOpt's recognized-model registry), so we must call the per-weight
export helper ourselves. Same workaround as the HunyuanImage-3 calibration
helper.
"""
from modelopt.torch.export.quant_utils import (
QUANTIZATION_NONE,
get_quantization_format,
quantizer_attr_names,
weight_attr_names,
)
from modelopt.torch.export.unified_export_hf import _export_quantized_weight
exported = 0
for name, module in backbone.named_modules():
try:
quantization_format = get_quantization_format(module)
except Exception as exc:
print(f"[warn] Could not inspect quantization format for {name}: {exc}", file=sys.stderr)
continue
if quantization_format == QUANTIZATION_NONE:
continue
for weight_name in weight_attr_names(module):
quantizer_attrs = quantizer_attr_names(weight_name)
weight_quantizer = getattr(module, quantizer_attrs.weight_quantizer, None)
if weight_quantizer is None or not getattr(weight_quantizer, "is_enabled", False):
continue
_export_quantized_weight(module, dtype, weight_name)
exported += 1
return exported
def _hv15_quant_config_block(weight_block_size: list[int] | None = None) -> dict:
"""Mirror ModelOpt FP8 metadata expected by vllm-omni's adapter (#2913).
Same shape as the HunyuanImage-3 author's _hunyuan_quant_config(). When
`weight_block_size` is given, advertise block-wise weight quantization in
the saved metadata (so consumers know to expect multi-element scale tensors).
"""
weights_cfg: dict = {"dynamic": False, "num_bits": 8, "type": "float"}
if weight_block_size is not None:
weights_cfg["strategy"] = "block"
weights_cfg["block_structure"] = f"{weight_block_size[0]}x{weight_block_size[1]}"
return {
"config_groups": {
"group_0": {
"input_activations": {"dynamic": False, "num_bits": 8, "type": "float"},
"weights": weights_cfg,
"targets": ["Linear"],
}
},
"ignore": [
"context_embedder*",
"context_embedder_2*",
"cond_type_embed*",
"image_embedder*",
"norm1.linear*",
"norm1_context.linear*",
"norm2*",
"norm2_context*",
"norm_out*",
"proj_out*",
"time_embed*",
"token_refiner*",
"x_embedder*",
],
"producer": {"name": "modelopt"},
"quant_algo": "FP8",
"quant_method": "modelopt",
}
def _patch_quant_config(output_dir: Path, weight_block_size: list[int] | None = None) -> None:
"""Inject quant_algo: FP8 + config_groups into transformer/config.json so
vllm-omni's adapter (#2913) recognises the checkpoint as ModelOpt FP8."""
cfg_path = output_dir / "transformer" / "config.json"
with cfg_path.open(encoding="utf-8") as f:
cfg = json.load(f)
new_qc = _hv15_quant_config_block(weight_block_size=weight_block_size)
existing = cfg.get("quantization_config")
if isinstance(existing, dict):
producer = existing.get("producer")
if isinstance(producer, dict):
new_qc["producer"] = producer
cfg["quantization_config"] = new_qc
with cfg_path.open("w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2)
def _save_pipeline_with_fp8_transformer(
pipe: DiffusionPipeline,
model_path: str,
output_dir: Path,
max_shard_size: str = "5GB",
) -> None:
"""Save the pipeline with the (now FP8) transformer.
Copies the source directory verbatim except for `transformer/`, then
saves the transformer with quantizers hidden so the state dict contains
only the FP8 weights + scale tensors.
"""
from modelopt.torch.export.diffusers_utils import hide_quantizers_from_state_dict
src = Path(model_path)
if not src.exists():
from huggingface_hub import snapshot_download
src = Path(snapshot_download(model_path))
if output_dir.exists():
shutil.rmtree(output_dir)
shutil.copytree(src, output_dir, ignore=shutil.ignore_patterns("transformer"))
transformer_out = output_dir / "transformer"
# `hide_quantizers_from_state_dict` walks named_modules(); pass the actual
# nn.Module (transformer), not the diffusers Pipeline wrapper.
with hide_quantizers_from_state_dict(pipe.transformer):
pipe.transformer.save_pretrained(
str(transformer_out),
safe_serialization=True,
max_shard_size=max_shard_size,
)
def main() -> None:
args = _build_parser().parse_args()
if not torch.cuda.is_available():
raise SystemExit("CUDA is required for ModelOpt FP8 quantization.")
mtq = _require_modelopt()
model_path, output_dir = _ensure_paths(args)
dtype = _select_dtype(args.dtype)
prompts = _build_prompts(args)
print("Quantization plan:")
weight_block_size = _parse_block_size(args.weight_block_size)
print(f" input: {args.model}")
print(f" output: {output_dir}")
print(f" dtype: {dtype}")
print(f" height/width: {args.height}x{args.width}")
print(f" num_frames: {args.num_frames}")
print(f" calib_size: {len(prompts)}")
print(f" calib_steps: {args.calib_steps}")
print(f" quantize_mha: {args.quantize_mha}")
print(
f" weight strategy: {'block-wise ' + str(weight_block_size) if weight_block_size else 'per-tensor (default)'}"
)
pipe = _load_pipeline(model_path, dtype)
backbone = pipe.transformer
quant_config = copy.deepcopy(mtq.FP8_DEFAULT_CFG)
if weight_block_size is not None:
# Switch from per-tensor (default) to block-wise weight quantization.
# ModelOpt's wildcard "*weight_quantizer" matches every linear's weight quantizer.
quant_config["quant_cfg"]["*weight_quantizer"] = {
"num_bits": (4, 3), # E4M3 (FP8 weights, same as default)
"block_sizes": {-1: weight_block_size[1], -2: weight_block_size[0]},
}
print(
f" -> overriding weight quantizer with block_sizes={weight_block_size} "
f"({weight_block_size[0]}x{weight_block_size[1]} tiles)"
)
forward_loop = _build_forward_loop(pipe, args, prompts)
quantized = mtq.quantize(backbone, quant_config, forward_loop)
if quantized is not None:
pipe.transformer = quantized
backbone = quantized
_disable_known_problematic_quantizers(mtq, backbone, quantize_mha=args.quantize_mha)
print("\nForcing FP8 weight serialization (HV-1.5 isn't in ModelOpt's recognized-model registry,")
print("so we have to call the per-weight export helper ourselves)...")
exported = _force_export_quantized_weights(backbone, dtype)
print(f" -> {exported} weights converted to FP8 in memory")
if exported == 0:
raise SystemExit(
"No quantized weights were exported. Calibration may have skipped every layer "
"(check the disable_quantizer regex) or `mtq.quantize` did not actually wrap any "
"weight quantizers."
)
print("\nSaving pipeline with FP8 transformer...")
_save_pipeline_with_fp8_transformer(pipe, model_path, output_dir)
_patch_quant_config(output_dir, weight_block_size=weight_block_size)
print(f"Saved to: {output_dir}")
_summarize_export(output_dir)
print("\nNext: validate the checkpoint with vllm-omni:")
print(
" python examples/offline_inference/text_to_video/text_to_video.py \\\n"
f" --model {output_dir} \\\n"
" --quantization fp8 \\\n"
" --prompt 'A dog running across a field of golden wheat.' \\\n"
f" --height {args.height} --width {args.width} --num-frames {args.num_frames} \\\n"
" --num-inference-steps 30 --guidance-scale 6.0 --seed 42 \\\n"
" --output outputs/hv15_modelopt_fp8.mp4 \\\n"
" --enforce-eager"
)
print(
"\n (--quantization fp8 is auto-upgraded to ModelOpt FP8 at runtime because the "
"checkpoint's config.json has modelopt metadata.)"
)
if __name__ == "__main__":
main()
@@ -1,471 +0,0 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Quantize Wan2.2 (TI2V-5B, 704x1280 T2V) to a ModelOpt FP8 Hugging Face checkpoint.
Calibrates the DiT transformer using a small video prompt set and exports a
diffusers-style directory whose transformer carries ModelOpt FP8 metadata.
The exported checkpoint is consumable by vllm-omni's ModelOpt FP8 adapter
(see vllm_omni/diffusion/model_loader/checkpoint_adapters/modelopt.py).
Layers kept full precision match the #2728 / #2795 pattern: condition embedder
(time/text/image), patch embedding, modulation (scale_shift_table), final
norm + proj_out, and sequence-parallel helpers. All attention + FFN linears
are quantized — static calibration handles the numerics that online FP8
couldn't (see #2920 ablation).
Default target is `Wan-AI/Wan2.2-TI2V-5B-Diffusers`, the dense 5B variant that
fits 80GB BF16. The A14B MoE variants need 2+ GPUs and are out of scope here.
Example:
python examples/quantization/quantize_wan2_2_modelopt_fp8.py \\
--model Wan-AI/Wan2.2-TI2V-5B-Diffusers \\
--output ./wan22-ti2v-modelopt-fp8 \\
--overwrite
"""
from __future__ import annotations
import argparse
import copy
import json
import re
import shutil
import sys
from pathlib import Path
from typing import Any
import torch
from diffusers import DiffusionPipeline
DEFAULT_PROMPTS = [
"A dog running across a field of golden wheat.",
"An astronaut riding a horse across the surface of Mars, red dust swirling, cinematic wide shot.",
"A hummingbird hovering in front of a vibrant red flower, slow motion, macro shot.",
"A crackling campfire at night under a starry sky, sparks rising into the dark.",
"An underwater shot of a coral reef with tropical fish swimming by, sun rays piercing the water.",
"A close-up of a blooming rose covered in morning dew, soft natural light.",
"A peaceful mountain village at dawn, mist rolling over the rooftops, cinematic establishing shot.",
"A skateboarder doing a kickflip in an urban plaza, slow motion, golden hour lighting.",
]
def _build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--model", required=True, help="Input Wan2.2 diffusers directory or HF id.")
p.add_argument("--output", required=True, help="Output directory for the ModelOpt FP8 checkpoint.")
p.add_argument("--dtype", choices=("bfloat16", "float16"), default="bfloat16")
p.add_argument("--height", type=int, default=704, help="Calibration video height (Wan2.2 TI2V-5B native: 704).")
p.add_argument("--width", type=int, default=1280, help="Calibration video width (Wan2.2 TI2V-5B native: 1280).")
p.add_argument(
"--num-frames",
type=int,
default=49,
help="Frames per calibration sample. 49 matches the typical short benchmark; "
"use 17 to reduce memory pressure during calibration.",
)
p.add_argument("--guidance-scale", type=float, default=5.0)
p.add_argument(
"--calib-steps",
type=int,
default=10,
help="Denoising steps per calibration prompt (10 is enough for amax statistics).",
)
p.add_argument("--calib-size", type=int, default=8, help="How many prompts to use for calibration.")
p.add_argument("--seed", type=int, default=42)
p.add_argument(
"--prompt",
action="append",
default=[],
help="Custom calibration prompt. Repeat to provide multiple.",
)
p.add_argument(
"--quantize-mha",
action="store_true",
help="Enable FP8 attention K/V/softmax quantizers. Off by default — Wan2.2's long attention "
"sequences amplified FP8 drift in the online ablation (see #2920).",
)
p.add_argument(
"--weight-block-size",
type=str,
default=None,
help="Per-block weight quantization as 'M,N' (e.g. '128,128'). Default per-tensor. "
"Note: vllm-omni's ModelOpt adapter may not yet dispatch block-wise scales — check #2924 "
"for the HV-1.5 investigation status before relying on this.",
)
p.add_argument("--overwrite", action="store_true", help="Replace an existing output directory.")
return p
def _parse_block_size(spec: str | None) -> list[int] | None:
if spec is None:
return None
parts = [int(x) for x in spec.split(",") if x.strip()]
if len(parts) != 2:
raise SystemExit(f"--weight-block-size must be 'M,N' (2 ints), got {spec!r}")
return parts
def _require_modelopt() -> Any:
try:
import modelopt.torch.quantization as mtq
except ModuleNotFoundError as exc:
raise SystemExit(
"NVIDIA ModelOpt is not installed. Install with:\n"
" pip install 'nvidia-modelopt[all]'\n"
f"Original error: {exc}"
) from exc
return mtq
def _ensure_paths(args: argparse.Namespace) -> tuple[str, Path]:
model_path = args.model
output_dir = Path(args.output).expanduser().resolve()
if output_dir.exists():
if not args.overwrite:
raise SystemExit(f"Output directory already exists: {output_dir}\nPass --overwrite to replace it.")
shutil.rmtree(output_dir)
return model_path, output_dir
def _select_dtype(name: str) -> torch.dtype:
return {"bfloat16": torch.bfloat16, "float16": torch.float16}[name]
def _build_prompts(args: argparse.Namespace) -> list[str]:
prompts = args.prompt or DEFAULT_PROMPTS
if args.calib_size <= 0:
raise SystemExit("--calib-size must be positive.")
if len(prompts) < args.calib_size:
repeats = (args.calib_size + len(prompts) - 1) // len(prompts)
prompts = (prompts * repeats)[: args.calib_size]
return prompts[: args.calib_size]
# Layers to KEEP at full precision. Wan2.2's module naming:
# - condition_embedder: time_embedder, time_proj, text_embedder, image_embedder (I2V)
# - patch_embedding: Conv3dLayer (already not Linear, belt-and-suspenders skip)
# - scale_shift_table: nn.Parameter modulation (not Linear, but pattern guard)
# - norm_out: AdaLayerNorm final
# - proj_out: final nn.Linear
# - timestep_proj_prepare / output_scale_shift_prepare: SP helpers
def _filter_func_wan22(name: str) -> bool:
pattern = re.compile(
r"(proj_out.*|"
r".*(condition_embedder|patch_embedding|"
r"norm_out|scale_shift_table|"
r"timestep_proj_prepare|output_scale_shift_prepare).*)"
)
return pattern.match(name) is not None
def _mha_filter_func(name: str) -> bool:
pattern = re.compile(
r".*(q_bmm_quantizer|k_bmm_quantizer|v_bmm_quantizer|softmax_quantizer|bmm2_output_quantizer).*"
)
return pattern.match(name) is not None
def _disable_known_problematic_quantizers(mtq: Any, backbone: torch.nn.Module, *, quantize_mha: bool) -> None:
if not hasattr(mtq, "disable_quantizer"):
return
mtq.disable_quantizer(backbone, _filter_func_wan22)
if not quantize_mha:
mtq.disable_quantizer(backbone, _mha_filter_func)
def _load_pipeline(model_path: str, dtype: torch.dtype) -> DiffusionPipeline:
pipe = DiffusionPipeline.from_pretrained(model_path, torch_dtype=dtype)
if hasattr(pipe, "set_progress_bar_config"):
pipe.set_progress_bar_config(disable=True)
pipe.to("cuda")
return pipe
def _build_forward_loop(pipe: DiffusionPipeline, args: argparse.Namespace, prompts: list[str]):
generator = torch.Generator(device="cuda")
# Try setting guidance on the pipeline's guider if present (newer diffusers APIs).
guider = getattr(pipe, "guider", None)
if guider is not None and hasattr(guider, "guidance_scale"):
try:
guider.guidance_scale = args.guidance_scale
except Exception:
pass
base_kwargs = dict(
height=args.height,
width=args.width,
num_frames=args.num_frames,
num_inference_steps=args.calib_steps,
output_type="latent",
)
def forward_loop(*_unused_args, **_unused_kwargs) -> None:
with torch.inference_mode():
for idx, prompt in enumerate(prompts):
generator.manual_seed(args.seed + idx)
# Try with guidance_scale first; fall back without on TypeError
# for pipelines that take CFG via guider config only.
try:
pipe(prompt=prompt, generator=generator, guidance_scale=args.guidance_scale, **base_kwargs)
except TypeError as exc:
if "guidance_scale" not in str(exc):
raise
pipe(prompt=prompt, generator=generator, **base_kwargs)
return forward_loop
def _summarize_export(output_dir: Path) -> None:
cfg_path = output_dir / "transformer" / "config.json"
if not cfg_path.exists():
print(f"[warn] {cfg_path} missing.", file=sys.stderr)
return
with cfg_path.open(encoding="utf-8") as f:
cfg = json.load(f)
qc = cfg.get("quantization_config")
if not isinstance(qc, dict):
print("[warn] No quantization_config in transformer/config.json.", file=sys.stderr)
return
print("Export summary:")
print(f" quant_method: {qc.get('quant_method')}")
print(f" quant_algo: {qc.get('quant_algo')}")
producer = qc.get("producer")
if isinstance(producer, dict):
print(f" producer: {producer.get('name')} {producer.get('version')}")
print(f" config path: {cfg_path}")
def _force_export_quantized_weights(backbone: torch.nn.Module, dtype: torch.dtype) -> int:
"""Convert in-memory weights of quantized modules to actual FP8 storage.
`export_hf_checkpoint` skips this step for unknown model types (Wan2.2 isn't
in ModelOpt's recognized-model registry), so we must call the per-weight
export helper ourselves. Same workaround as the HunyuanVideo-1.5 / HunyuanImage-3
calibration helpers.
"""
from modelopt.torch.export.quant_utils import (
QUANTIZATION_NONE,
get_quantization_format,
quantizer_attr_names,
weight_attr_names,
)
from modelopt.torch.export.unified_export_hf import _export_quantized_weight
exported = 0
for name, module in backbone.named_modules():
try:
quantization_format = get_quantization_format(module)
except Exception as exc:
print(f"[warn] Could not inspect quantization format for {name}: {exc}", file=sys.stderr)
continue
if quantization_format == QUANTIZATION_NONE:
continue
for weight_name in weight_attr_names(module):
quantizer_attrs = quantizer_attr_names(weight_name)
weight_quantizer = getattr(module, quantizer_attrs.weight_quantizer, None)
if weight_quantizer is None or not getattr(weight_quantizer, "is_enabled", False):
continue
_export_quantized_weight(module, dtype, weight_name)
exported += 1
return exported
def _wan22_quant_config_block(weight_block_size: list[int] | None = None) -> dict:
"""Mirror ModelOpt FP8 metadata expected by vllm-omni's adapter (#2913)."""
weights_cfg: dict = {"dynamic": False, "num_bits": 8, "type": "float"}
if weight_block_size is not None:
weights_cfg["strategy"] = "block"
weights_cfg["block_structure"] = f"{weight_block_size[0]}x{weight_block_size[1]}"
return {
"config_groups": {
"group_0": {
"input_activations": {"dynamic": False, "num_bits": 8, "type": "float"},
"weights": weights_cfg,
"targets": ["Linear"],
}
},
"ignore": [
"condition_embedder*",
"norm_out*",
"output_scale_shift_prepare*",
"patch_embedding*",
"proj_out*",
"scale_shift_table*",
"timestep_proj_prepare*",
],
"producer": {"name": "modelopt"},
"quant_algo": "FP8",
"quant_method": "modelopt",
}
def _patch_quant_config(output_dir: Path, weight_block_size: list[int] | None = None) -> None:
"""Inject quant_algo: FP8 + config_groups into each transformer's config.json
so vllm-omni's adapter (#2913) recognises the checkpoint as ModelOpt FP8.
Patches both ``transformer`` and ``transformer_2`` (A14B) when present."""
for sub in ("transformer", "transformer_2"):
cfg_path = output_dir / sub / "config.json"
if not cfg_path.exists():
continue
with cfg_path.open(encoding="utf-8") as f:
cfg = json.load(f)
new_qc = _wan22_quant_config_block(weight_block_size=weight_block_size)
existing = cfg.get("quantization_config")
if isinstance(existing, dict):
producer = existing.get("producer")
if isinstance(producer, dict):
new_qc["producer"] = producer
cfg["quantization_config"] = new_qc
with cfg_path.open("w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2)
def _list_transformers(pipe: DiffusionPipeline) -> list[tuple[str, torch.nn.Module]]:
"""Return [(attr_name, module)] for every DiT on the pipeline.
Wan2.2 A14B has both ``transformer`` (low-noise) and ``transformer_2``
(high-noise). TI2V-5B has only ``transformer``.
"""
out = []
for attr in ("transformer", "transformer_2"):
mod = getattr(pipe, attr, None)
if mod is not None:
out.append((attr, mod))
return out
def _save_pipeline_with_fp8_transformer(
pipe: DiffusionPipeline,
model_path: str,
output_dir: Path,
max_shard_size: str = "5GB",
) -> None:
"""Copy source dir verbatim minus transformer dirs, then save each quantized DiT.
Handles A14B's dual transformer (``transformer`` + ``transformer_2``) as well
as the single-DiT TI2V-5B.
"""
from modelopt.torch.export.diffusers_utils import hide_quantizers_from_state_dict
src = Path(model_path)
if not src.exists():
from huggingface_hub import snapshot_download
src = Path(snapshot_download(model_path))
if output_dir.exists():
shutil.rmtree(output_dir)
shutil.copytree(src, output_dir, ignore=shutil.ignore_patterns("transformer", "transformer_2"))
for attr, backbone in _list_transformers(pipe):
transformer_out = output_dir / attr
# Pass the nn.Module (transformer), not the Pipeline wrapper.
with hide_quantizers_from_state_dict(backbone):
backbone.save_pretrained(
str(transformer_out),
safe_serialization=True,
max_shard_size=max_shard_size,
)
def main() -> None:
args = _build_parser().parse_args()
if not torch.cuda.is_available():
raise SystemExit("CUDA is required for ModelOpt FP8 quantization.")
mtq = _require_modelopt()
model_path, output_dir = _ensure_paths(args)
dtype = _select_dtype(args.dtype)
prompts = _build_prompts(args)
weight_block_size = _parse_block_size(args.weight_block_size)
print("Quantization plan:")
print(f" input: {args.model}")
print(f" output: {output_dir}")
print(f" dtype: {dtype}")
print(f" height/width: {args.height}x{args.width}")
print(f" num_frames: {args.num_frames}")
print(f" calib_size: {len(prompts)}")
print(f" calib_steps: {args.calib_steps}")
print(f" quantize_mha: {args.quantize_mha}")
print(
f" weight strategy: {'block-wise ' + str(weight_block_size) if weight_block_size else 'per-tensor (default)'}"
)
pipe = _load_pipeline(model_path, dtype)
transformers = _list_transformers(pipe)
if not transformers:
raise SystemExit("Pipeline has no transformer or transformer_2 attribute.")
print(f" found {len(transformers)} transformer(s): {', '.join(a for a, _ in transformers)}")
quant_config = copy.deepcopy(mtq.FP8_DEFAULT_CFG)
if weight_block_size is not None:
quant_config["quant_cfg"]["*weight_quantizer"] = {
"num_bits": (4, 3),
"block_sizes": {-1: weight_block_size[1], -2: weight_block_size[0]},
}
print(
f" -> overriding weight quantizer with block_sizes={weight_block_size} "
f"({weight_block_size[0]}x{weight_block_size[1]} tiles)"
)
forward_loop = _build_forward_loop(pipe, args, prompts)
# Quantize each DiT. The forward_loop runs the full pipeline, so quantizers
# on every attached transformer (A14B: transformer + transformer_2) get
# calibrated during the same passes.
for attr, backbone in transformers:
print(f"\nQuantizing {attr}...")
quantized = mtq.quantize(backbone, quant_config, forward_loop)
if quantized is not None:
setattr(pipe, attr, quantized)
_disable_known_problematic_quantizers(mtq, getattr(pipe, attr), quantize_mha=args.quantize_mha)
# Export weights to FP8 only AFTER all transformers are calibrated. Exporting
# inside the loop would convert transformer's weights to real FP8, and the
# next transformer's forward_loop (full pipeline) would then fake-quantize an
# already-FP8 weight -> "fake_e4m3fy not implemented for Float8_e4m3fn".
print("\nForcing FP8 weight serialization (Wan2.2 isn't in ModelOpt's recognized-model registry,")
print("so we have to call the per-weight export helper ourselves)...")
total_exported = 0
for attr, backbone in _list_transformers(pipe):
exported = _force_export_quantized_weights(backbone, dtype)
print(f" -> {exported} weights converted to FP8 in {attr}")
total_exported += exported
if total_exported == 0:
raise SystemExit(
"No quantized weights were exported. Calibration may have skipped every layer "
"(check the disable_quantizer regex) or `mtq.quantize` did not actually wrap any "
"weight quantizers."
)
print("\nSaving pipeline with FP8 transformer(s)...")
_save_pipeline_with_fp8_transformer(pipe, model_path, output_dir)
_patch_quant_config(output_dir, weight_block_size=weight_block_size)
print(f"Saved to: {output_dir}")
_summarize_export(output_dir)
print("\nNext: validate the checkpoint with vllm-omni:")
print(
" python examples/offline_inference/text_to_video/text_to_video.py \\\n"
f" --model {output_dir} \\\n"
" --quantization fp8 \\\n"
" --prompt 'A dog running across a field of golden wheat.' \\\n"
f" --height {args.height} --width {args.width} --num-frames {args.num_frames} \\\n"
" --num-inference-steps 30 --guidance-scale 5.0 --seed 42 \\\n"
" --output outputs/wan22_modelopt_fp8.mp4"
)
print(
"\n (--quantization fp8 is auto-upgraded to ModelOpt FP8 at runtime because the "
"checkpoint's config.json has modelopt metadata.)"
)
if __name__ == "__main__":
main()
-6
View File
@@ -90,12 +90,6 @@ minicpmo = [
"stepaudio2-minicpmo",
]
# Optional Blackwell-only fused-bias FP8 GEMM; package is `quack-kernels` (imported
# as `quack`), not the unrelated `quack`. See docs/user_guide/quantization/fp8.md.
quack = [
"quack-kernels>=0.3.11",
]
docs = [
"mkdocs>=1.5.0",
"mkdocs-api-autonav",
+2 -4
View File
@@ -33,12 +33,11 @@ recipes/
| [`fishaudio/Fish-Speech-S2-Pro.md`](./fishaudio/Fish-Speech-S2-Pro.md) | Online serving for TTS | 1x A800 80GB |
| [`Helios/Helios.md`](./Helios/Helios.md) | Text-to-video, image-to-video, and video-to-video generation | 1x NVIDIA H20 |
| [`inclusionAI/Ming-flash-omni-2.0.md`](./inclusionAI/Ming-flash-omni-2.0.md) | Online serving for multimodal chat + standalone TTS | 4x H100 / 1x H100 80GB |
| [`inclusionAI/Ming-omni-tts-0.5B.md`](./inclusionAI/Ming-omni-tts-0.5B.md) | Offline + online dense Ming TTS/audio generation | 1x AMD `gfx942` (ROCm 7.2) |
| [`LTX/LTX-2.md`](./LTX/LTX-2.md) | Text-to-video and image-to-video serving | 1x H200 141GB |
| [`LTX/LTX-2.3.md`](./LTX/LTX-2.3.md) | Text-to-video with audio generation (22B) | 1x GPU (96GB VRAM) |
| [`mistralai/Voxtral-TTS.md`](./mistralai/Voxtral-TTS.md) | Online serving for TTS | 1x RTX 4090 24GB |
| [`cosmos3/Cosmos3-Nano.md`](./cosmos3/Cosmos3-Nano.md) | Text-to-image, text-to-video, image-to-video, video-to-video generation, text to video with sound, action policy | 1x H200 141GB / B300 |
| [`cosmos3/Cosmos3-Super.md`](./cosmos3/Cosmos3-Super.md) | 64B T2I / T2V / I2V / V2V generation (+ optional audio) / Action policy | 8x H200/H100/A100 / 2x H200 / B300 |
| [`cosmos3/Cosmos3-Nano.md`](./cosmos3/Cosmos3-Nano.md) | Text-to-image, text-to-video, image-to-video generation, text to video with sound, action policy | 1x H200 141GB / B300 |
| [`cosmos3/Cosmos3-Super.md`](./cosmos3/Cosmos3-Super.md) | 64B T2I / T2V / I2V generation (+ optional audio) / Action policy | 8x H200/H100/A100 / 2x H200 / B300 |
| [`OpenBMB/MiniCPM-o-4_5.md`](./OpenBMB/MiniCPM-o-4_5.md) | Online serving for omni multimodal chat (text / image / audio / video → text + 24 kHz speech) | 2x A100/H100 80GB / 3x mid-tier GPU / 8x RTX 4090 24GB |
| [`OpenBMB/VoxCPM2.md`](./OpenBMB/VoxCPM2.md) | Online + offline TTS with native AR pipeline (48 kHz, 30+ languages) | 1x RTX 4090 24GB |
| [`Qwen/Qwen-Image.md`](./Qwen/Qwen-Image.md) | Text-to-image serving with step-wise continuous batching replay and ModelOpt mixed FP8/NVFP4 | 1x A100 80GB / 2x B200 |
@@ -51,7 +50,6 @@ recipes/
| [`Tencent/HunyuanImage-3.0-Instruct.md`](./Tencent/HunyuanImage-3.0-Instruct.md) | DiT-only text-to-image serving and benchmark, including ModelOpt mixed FP8/NVFP4 | 4x H100/H800 80GB / 2x B200 |
| [`Wan-AI/Wan2.2-I2V.md`](./Wan-AI/Wan2.2-I2V.md) | Image-to-video serving (Wan2.2 14B) | 8x Ascend NPU (A2/A3) |
| [`Wan-AI/Wan2.2-S2V.md`](./Wan-AI/Wan2.2-S2V.md) | Speech-to-video serving (Wan2.2 14B) | 2x A100/H100 80GB |
| [`StabilityAI/Stable-Diffusion-3.5.md`](./StabilityAI/Stable-Diffusion-3.5.md) | Text-to-image serving (SD 3.5-medium and SD 3.5-large) | 1x RTX A6000 48GB |
| [`zai-org/GLM-TTS.md`](./zai-org/GLM-TTS.md) | Online serving for Chinese/English zero-shot voice-cloned TTS | 1x A40 48GB |
| [`GLM/GLM-Image.md`](./GLM/GLM-Image.md) | Online serving for image generation | 1x A800 80GB / 2x A800 80GB |
|
-139
View File
@@ -1,139 +0,0 @@
# Stable Diffusion 3.5
> Text-to-image serving with Stability AI's SD 3.5 family
## Summary
- Vendor: Stability AI
- Model: `stabilityai/stable-diffusion-3.5-medium`, `stabilityai/stable-diffusion-3.5-large`
- Task: Text-to-image generation
- Mode: Online serving with the OpenAI-compatible images API
- Maintainer: Community
## When to use this recipe
Use this recipe when you want a lightweight, fast text-to-image model that
fits on a single consumer or workstation GPU. SD 3.5-medium requires only
~17 GiB VRAM and generates 1024×1024 images in ~4 seconds, making it one of
the most accessible diffusion models in vLLM-Omni. SD 3.5-large produces
noticeably sharper images at the cost of ~34 GiB VRAM and ~21 seconds per
image.
## References
- Model (medium): <https://huggingface.co/stabilityai/stable-diffusion-3.5-medium>
- Model (large): <https://huggingface.co/stabilityai/stable-diffusion-3.5-large>
- Related example under `examples/`:
[`examples/online_serving/text_to_image/README.md`](../../examples/online_serving/text_to_image/README.md)
- User guide:
[`docs/user_guide/examples/online_serving/text_to_image.md`](../../docs/user_guide/examples/online_serving/text_to_image.md)
## Hardware Support
This recipe documents two CUDA GPU configurations on a single RTX A6000 48 GB,
one for each model variant. Extend it with more hardware sections as community
validation lands.
## GPU
### 1x NVIDIA RTX A6000 48GB — stable-diffusion-3.5-medium
#### Environment
- OS: Ubuntu 22.04
- Python: 3.12
- Driver / runtime: NVIDIA driver 580.126.09, CUDA 13.0
- vLLM version: 0.22.0
- vLLM-Omni version or commit: 0.22.0rc2.dev8+g41a795b5d
#### Command
```bash
vllm serve stabilityai/stable-diffusion-3.5-medium --omni --port 8091
```
#### Verification
```bash
curl -X POST http://localhost:8091/v1/images/generations \
-H "Content-Type: application/json" \
-d '{
"model": "stabilityai/stable-diffusion-3.5-medium",
"prompt": "a cute cat sitting on a windowsill, highly detailed, sharp focus",
"negative_prompt": "blurry, out of focus",
"guidance_scale": 4.5,
"n": 1,
"size": "1024x1024"
}' | python3 -c "
import json, base64, sys
data = json.load(sys.stdin)
with open('output.png', 'wb') as f:
f.write(base64.b64decode(data['data'][0]['b64_json']))
print('saved output.png')
"
```
Or use the example client:
```bash
python examples/online_serving/text_to_image/openai_chat_client.py \
--server http://localhost:8091 \
--prompt "a cute cat sitting on a windowsill" \
--output output.png
```
#### Notes
- Memory usage: Model weights ~15.6 GiB, peak VRAM ~17.0 GiB (no CFG), ~20.9 GiB with `guidance_scale > 1` (CFG enabled).
- Generation time: ~4 seconds without CFG, ~8 seconds with `guidance_scale=4.5`.
- Model loading time: ~55 seconds (including weight download on first run).
- `negative_prompt` requires `guidance_scale > 1` to take effect; SD3 defaults to 1.0 (no CFG).
### 1x NVIDIA RTX A6000 48GB — stable-diffusion-3.5-large
#### Environment
- OS: Ubuntu 22.04
- Python: 3.12
- Driver / runtime: NVIDIA driver 580.126.09, CUDA 13.0
- vLLM version: 0.22.0
- vLLM-Omni version or commit: 0.22.0rc2.dev8+g41a795b5d
#### Command
```bash
vllm serve stabilityai/stable-diffusion-3.5-large --omni --port 8091
```
> **Note**: `stable-diffusion-3.5-large` is a gated model. Accept the license
> on [Hugging Face](https://huggingface.co/stabilityai/stable-diffusion-3.5-large)
> and set `HF_TOKEN` before running.
#### Verification
```bash
curl -X POST http://localhost:8091/v1/images/generations \
-H "Content-Type: application/json" \
-d '{
"model": "stabilityai/stable-diffusion-3.5-large",
"prompt": "a cute cat sitting on a windowsill, highly detailed, sharp focus",
"negative_prompt": "blurry, out of focus",
"guidance_scale": 4.5,
"n": 1,
"size": "1024x1024"
}' | python3 -c "
import json, base64, sys
data = json.load(sys.stdin)
with open('output.png', 'wb') as f:
f.write(base64.b64decode(data['data'][0]['b64_json']))
print('saved output.png')
"
```
#### Notes
- Memory usage: Peak VRAM ~31.6 GiB at 1024×1024, ~45.5 GiB at 2048×2048 with `guidance_scale=4.5`.
- Generation time: ~19 seconds at 1024×1024, ~102 seconds at 2048×2048.
- Image quality is noticeably better than medium, with sharper details.
- `negative_prompt` requires `guidance_scale > 1` to take effect; SD3 defaults to 1.0 (no CFG).
- 2048×2048 fits within 48 GB but leaves little headroom (~45.5 GiB used out of 49 GiB).
+20 -79
View File
@@ -1,43 +1,34 @@
# Cosmos3-Nano
> Text-to-image, text-to-video, image-to-video, and video-to-video serving
> Text-to-image, text-to-video, and image-to-video serving
## Summary
- Vendor: NVIDIA
- Model: `nvidia/Cosmos3-Nano`
- Task: Text-to-image (T2I), text-to-video (T2V), image-to-video (I2V), and video-to-video (V2V) generation, with optional synchronized audio (video + sound), action policy
- Task: Text-to-image (T2I), text-to-video (T2V), and image-to-video (I2V) generation, with optional synchronized audio (video + sound), action policy
- Mode: Online serving with the OpenAI-compatible image/video APIs, plus offline generation via the `Omni` API
- Maintainer: Community
## When to use this recipe
Use this recipe to deploy `nvidia/Cosmos3-Nano` for image and video generation.
A single pipeline class (`Cosmos3OmniDiffusersPipeline`) serves these modes; the
mode is selected per request:
A single pipeline class (`Cosmos3OmniDiffusersPipeline`) serves all three modes;
the mode is selected per request:
- **T2I** — `POST /v1/images/generations` (or a prompt carrying `modalities=["image"]`).
- **T2V** — `POST /v1/videos/sync` with `num_frames > 1` and no reference image/video.
- **T2V** — `POST /v1/videos/sync` with `num_frames > 1` and no reference image.
- **I2V** — `POST /v1/videos/sync` with a reference image (`input_reference` file
upload, or `image_reference` JSON).
- **V2V** — `POST /v1/videos/sync` with a reference video (`input_reference`
video upload, or `video_reference` JSON). Cosmos3 conditions on selected
reference-video latent frames; use `extra_params.condition_frame_indexes_vision`
and `extra_params.condition_video_keep` to choose which prefix/tail frames guide
generation.
- **T2VS / I2VS** — add `generate_sound=true` (and optional `sound_duration`) to a
T2V/I2V `/v1/videos/sync` request to also generate synchronized audio, muxed into
the mp4 as AAC 48 kHz stereo. See the official model card's "Video + Audio" examples.
- **Action** — pass `extra_params={"action_mode": ...}` to drive Physical-AI tasks:
- `forward_dynamics` — given a first frame or video **and** an action trajectory,
roll out the resulting video. Synchronous: `POST /v1/videos/sync`.
- `policy` — given a first frame or video and a language instruction,
**predict** the action trajectory (and a rollout video). Use the async
`POST /v1/videos` endpoint and read the predicted action from the top-level
`action` field.
- `inverse_dynamics` — given a video, **recover** the action trajectory. Use
the async `POST /v1/videos` endpoint and read the recovered action from
the top-level `action` field
- `forward_dynamics` — given a first frame **and** an action trajectory, roll out
the resulting video. Synchronous: `POST /v1/videos/sync`.
- `policy` — given a first frame and a language instruction, **predict** the action
trajectory (and a rollout video). Use the async `POST /v1/videos` endpoint and
read the predicted action from the top-level `action` field
(`{data, shape, dtype, raw_action_dim, domain_id}`).
Action requests also take `domain_name` (e.g. `av`, `bridge_orig_lerobot`,
@@ -47,9 +38,8 @@ mode is selected per request:
**`nvidia/Cosmos3-Nano-Policy-DROID`** is served the same way
(`domain_name=droid_lerobot`).
Action requests can use `input_reference` or `video_reference` for video input.
`policy` and `forward_dynamics` can also use an image reference; `inverse_dynamics`
requires a video reference.
`inverse_dynamics` (recover the action from a given video) is supported by the
pipeline; **online inference of inverse dynamics will be added in a follow-up MR.**
## References
@@ -152,36 +142,6 @@ curl -sS -X POST http://localhost:8000/v1/videos/sync \
-F "input_reference=@/path/to/reference.jpg;type=image/jpeg" \
-o cosmos3_i2v.mp4
# Video-to-video -> /v1/videos/sync with an uploaded reference video.
# By default Cosmos3 conditions on latent indexes [0, 1]. For the default
# temporal VAE stride this decodes only the first 5 input frames.
# The model works best when the prompt describes the actual situation happening in the video.
# Generic prompts may create sub-standard generations.
curl -sS -X POST http://localhost:8000/v1/videos/sync \
-H "Accept: video/mp4" \
-F "model=nvidia/Cosmos3-Nano" \
-F "prompt=Continue the same scene with smooth natural motion and consistent subjects." \
-F "negative_prompt=blurry, distorted, low quality, jittery, deformed" \
-F "size=1280x720" -F "num_frames=189" -F "fps=24" \
-F "num_inference_steps=35" -F "guidance_scale=6.0" \
-F "max_sequence_length=4096" -F "flow_shift=10.0" \
-F 'extra_params={"use_resolution_template":false,"use_duration_template":false,"guardrails":true,"condition_frame_indexes_vision":[0,1],"condition_video_keep":"first"}' \
-F "seed=2222" \
-F "input_reference=@/path/to/reference.mp4;type=video/mp4" \
-o cosmos3_v2v.mp4
# V2V can also use a JSON-safe URL/data-URL video reference. Do not combine
# video_reference with input_reference or image_reference.
curl -sS -X POST http://localhost:8000/v1/videos/sync \
-H "Accept: video/mp4" \
-F "model=nvidia/Cosmos3-Nano" \
-F "prompt=Continue the same scene with smooth natural motion and consistent subjects." \
-F "size=1280x720" -F "num_frames=189" -F "fps=24" \
-F "num_inference_steps=35" -F "guidance_scale=6.0" \
-F "max_sequence_length=4096" -F "flow_shift=10.0" \
-F 'extra_params={"condition_frame_indexes_vision":[0,1],"condition_video_keep":"last"}' \
-F 'video_reference={"video_url":"https://example.com/reference.mp4"}' \
-o cosmos3_v2v_from_url.mp4
# Text-to-video-with-sound
curl -sS -X POST http://localhost:8000/v1/videos/sync \
@@ -230,21 +190,6 @@ VIDEO_ID=$(curl -sS -X POST http://localhost:8000/v1/videos \
# poll until status == completed, then:
curl -sS "http://localhost:8000/v1/videos/$VIDEO_ID" | jq '.action | {shape, dtype, raw_action_dim, domain_id}'
curl -sS -L "http://localhost:8000/v1/videos/$VIDEO_ID/content" -o cosmos3_policy.mp4
# Action — inverse dynamics (video -> recovered action trajectory).
# Asynchronous: use the job metadata to read the recovered action.
VIDEO_ID=$(curl -sS -X POST http://localhost:8000/v1/videos \
-H "Accept: application/json" \
--form-string "model=nvidia/Cosmos3-Nano" \
--form-string "prompt=Recover the robot action trajectory from this clip." \
-F "input_reference=@motion_clip.mp4;type=video/mp4" \
-F "size=640x480" -F "num_frames=17" -F "fps=5" \
-F "num_inference_steps=30" -F "guidance_scale=1.0" -F "flow_shift=5.0" \
--form-string 'extra_params={"action_mode":"inverse_dynamics","domain_name":"bridge_orig_lerobot","raw_action_dim":10,"action_chunk_size":16}' \
-F "seed=0" | jq -r '.id')
# poll until status == completed, then:
curl -sS "http://localhost:8000/v1/videos/$VIDEO_ID" | jq '.action | {shape, dtype, raw_action_dim, domain_id}'
curl -sS -L "http://localhost:8000/v1/videos/$VIDEO_ID/content" -o cosmos3_inverse_dynamics.mp4
```
#### Notes
@@ -261,27 +206,24 @@ curl -sS -L "http://localhost:8000/v1/videos/$VIDEO_ID/content" -o cosmos3_inver
- **Determinism:** identical seed reproduces identical output on the same
hardware; outputs are not bit-identical across different GPU types.
- **Supported sizes (per model card):** 256p / 480p / 720p at 16:9, 4:3, 1:1,
3:4, 9:16. Defaults: T2I 1024², 50 steps, guidance 7.0; T2V/I2V/V2V
1280×720, 189 frames, 35 steps, guidance 6.0, `flow_shift=10.0`.
3:4, 9:16. Defaults: T2I 1024², 50 steps, guidance 7.0; T2V/I2V 1280×720,
189 frames, 35 steps, guidance 6.0, `flow_shift=10.0`.
- **Key flags / params:** `--no-guardrails` (server) or
`extra_params={"guardrails":false}` (per request) toggles safety. The
per-request flag only takes effect when the server was launched **with**
guardrails enabled (it cannot re-enable them on a `--no-guardrails` server).
`use_resolution_template` / `use_duration_template` are off by default and only
needed when not using upsampled prompts that already encode resolution/duration.
For V2V, `condition_frame_indexes_vision` selects the clean conditioned latent
frame indexes (default `[0, 1]`), and `condition_video_keep` selects whether the
API decodes the first or last needed reference frames (`"first"` by default).
- **Known limitations:**
- Guardrails-on requires `cosmos-guardrail` **and** access to the gated
`nvidia/Cosmos-1.0-Guardrail` repo (accept license + `HF_TOKEN`); otherwise
the server fails at pipeline build with a gated-repo / safety-checker error.
- A guardrail-blocked prompt currently returns HTTP 500
(`"Guardrail blocked prompt"`).
- Action `forward_dynamics`, `policy`, and `inverse_dynamics` are supported
online. Use async `POST /v1/videos` when you need the predicted/recovered
action payload under the top-level `action` field; sync `/v1/videos/sync`
returns raw MP4 bytes and does not expose action metadata in the response body.
- Action `forward_dynamics` (sync `/v1/videos/sync`) and `policy` (async
`/v1/videos`, returns the predicted action under the top-level `action`
field) are supported online. **Online inference of inverse dynamics will be
added in a follow-up MR.**
### 1x GPU (Offline generation)
@@ -345,9 +287,8 @@ python -c "from PIL import Image; im=Image.open('cosmos3_t2i.png'); print('image
- Same `Cosmos3OmniDiffusersPipeline` as online; mode is chosen by
`prompt["modalities"]` (`["image"]` → T2I, `["video"]` → T2V) plus
`num_frames`/`fps`, `multi_modal_data={"image": ...}` for I2V, and
`multi_modal_data={"video": [<PIL frames>]}` or a video tensor/array for V2V.
For video, frames are returned in `outputs[0].request_output.images` as an
`num_frames`/`fps`, and `multi_modal_data={"image": ...}` for I2V. For video,
frames are returned in `outputs[0].request_output.images` as an
`(B, F, H, W, 3)` array.
- The offline entry must be guarded by `if __name__ == "__main__":` — the engine
spawns workers with the `spawn` start method.
+13 -25
View File
@@ -1,12 +1,12 @@
# Cosmos3-Super
> Frontier 64B world model: text-to-image, text-to-video, image-to-video, video-to-video (+ optional audio)
> Frontier 64B world model: text-to-image, text-to-video, image-to-video (+ optional audio)
## Summary
- Vendor: NVIDIA
- Model: `nvidia/Cosmos3-Super` (64B; also `Cosmos3-Super-Text2Image`, `Cosmos3-Super-Image2Video`)
- Task: T2I, T2V, I2V, V2V generation, with optional synchronized audio (video + sound)
- Task: T2I, T2V, I2V generation, with optional synchronized audio (video + sound)
- Mode: Online serving with the OpenAI-compatible image/video APIs
- Maintainer: Community
@@ -16,8 +16,8 @@ Use this recipe to deploy the 64B `nvidia/Cosmos3-Super` for the highest-quality
Cosmos3 generation. It shares the same `Cosmos3OmniDiffusersPipeline` and request
formats as [Cosmos3-Nano](./Cosmos3-Nano.md) — only the checkpoint size and the
recommended parallelism differ. Mode is selected per request (T2I →
`/v1/images/generations`; T2V/I2V/V2V → `/v1/videos/sync`; add
`generate_sound=true` for audio).
`/v1/images/generations`; T2V/I2V → `/v1/videos/sync`; add `generate_sound=true`
for audio).
## References
@@ -62,9 +62,8 @@ disable. `--enable-layerwise-offload` reduces VRAM on smaller GPUs.
#### Verification
Requests are identical to Nano (see [`Cosmos3-Nano.md`](./Cosmos3-Nano.md) for full
T2I/T2V/I2V/V2V/T2VS curls); official params: `size=1280x720, num_frames=189,
fps=24, num_inference_steps=35, guidance_scale=6.0, flow_shift=10.0,
max_sequence_length=4096`.
T2I/T2V/I2V/T2VS curls); official params: `size=1280x720, num_frames=189, fps=24,
num_inference_steps=35, guidance_scale=6.0, flow_shift=10.0, max_sequence_length=4096`.
```bash
curl http://localhost:8000/v1/models
@@ -85,15 +84,6 @@ curl -sS -X POST http://localhost:8000/v1/videos/sync -H "Accept: video/mp4" \
-F "seed=1111" -F "input_reference=@/path/to/reference.jpg;type=image/jpeg" \
-o cosmos3_super_i2v.mp4
# V2V — add an uploaded reference video. condition_video_keep can be "first" or "last".
curl -sS -X POST http://localhost:8000/v1/videos/sync -H "Accept: video/mp4" \
-F "model=nvidia/Cosmos3-Super" -F "prompt=Continue the same scene with smooth natural motion." \
-F "size=1280x720" -F "num_frames=189" -F "fps=24" -F "num_inference_steps=35" \
-F "guidance_scale=6.0" -F "max_sequence_length=4096" -F "flow_shift=10.0" \
-F 'extra_params={"condition_frame_indexes_vision":[0,1],"condition_video_keep":"first"}' \
-F "seed=2222" -F "input_reference=@/path/to/reference.mp4;type=video/mp4" \
-o cosmos3_super_v2v.mp4
# T2V + sound — add generate_sound/sound_duration (output muxes AAC 48 kHz stereo)
curl -sS -X POST http://localhost:8000/v1/videos/sync -H "Accept: video/mp4" \
-F "model=nvidia/Cosmos3-Super" -F "prompt=A robot arm is cleaning a plate in the kitchen" \
@@ -113,12 +103,10 @@ curl -sS -X POST http://localhost:8000/v1/videos/sync -H "Accept: video/mp4" \
- T2V + sound (189 frames, 35 steps) → **~198 s**, output muxes **AAC 48 kHz stereo**
- (NVIDIA's reference: 8×H200 @ 50 steps ≈ 55 s/video; 2×H200 @ 35 steps ≈ 3 min/video.)
- **Memory:** ~61.5 GiB per GPU when sharded across 2 GPUs (HSDP shard 2); repo ~135 GB on disk.
- Same generation defaults, supported sizes, V2V reference-video controls
(`condition_frame_indexes_vision`, `condition_video_keep`), and
`generate_sound`/`sound_duration`
semantics as Nano, including the **action** modality: `forward_dynamics`,
`policy`, and `inverse_dynamics` — see the Cosmos3-Nano recipe for the request
shapes. Use async `/v1/videos` when you need predicted/recovered action metadata
under the top-level `action` field. Verified on the 64B Super under
`--cfg-parallel-size 2`: async `policy` returns the predicted action (`[16, 10]`)
and the rollout video reliably.
- Same generation defaults, supported sizes, and `generate_sound`/`sound_duration`
semantics as Nano, including the **action** modality: `forward_dynamics`
(sync `/v1/videos/sync`) and `policy` (async `/v1/videos`, predicted action under
the top-level `action` field) — see the Cosmos3-Nano recipe for the request shape.
Online inference of inverse dynamics will be added in a follow-up MR. Verified on
the 64B Super under `--cfg-parallel-size 2`: async `policy` returns the predicted
action (`[16, 10]`) and the rollout video reliably.
-82
View File
@@ -1,82 +0,0 @@
# Ming-omni-tts 0.5B
> Offline and online dense Ming text-to-speech on ROCm
## Summary
- Vendor: inclusionAI
- Model: `inclusionAI/Ming-omni-tts-0.5B`
- Task: Text-to-speech with style, dialect, cloning, and multi-speaker controls
- Mode: Online serving via the OpenAI-compatible `/v1/audio/speech` API; offline inference
- Maintainer: Community
## References
- [Model card](https://huggingface.co/inclusionAI/Ming-omni-tts-0.5B)
- [Upstream repository](https://github.com/inclusionAI/Ming-omni-tts)
- [Offline example](../../examples/offline_inference/text_to_speech/ming_tts/)
- [Online example](../../examples/online_serving/text_to_speech/ming_tts/)
## Hardware Support
## ROCm
### 1x AMD `gfx942`
#### Environment
- OS: Ubuntu 22.04.5 LTS, x86_64
- Python: 3.12.13
- ROCm / HIP: 7.2.53211
- vLLM version: 0.22.0
- vLLM-Omni version or commit: 0.1.dev1873 / `99c35c410`
- Docker image: `vllm/vllm-omni-rocm:v0.22.0`
#### Command
From the vLLM-Omni repository root:
```bash
docker run --rm \
--group-add=video \
--ipc=host \
--cap-add=SYS_PTRACE \
--security-opt seccomp=unconfined \
--device /dev/kfd \
--device /dev/dri \
-v ~/.cache/huggingface:/root/.cache/huggingface \
-v "$PWD":/app/vllm-omni \
-w /app/vllm-omni \
-e VLLM_ROCM_USE_AITER=0 \
-p 8091:8091 \
vllm/vllm-omni-rocm:v0.22.0 \
--model inclusionAI/Ming-omni-tts-0.5B \
--deploy-config vllm_omni/deploy/ming_tts.yaml \
--omni \
--port 8091 \
--enforce-eager
```
#### Verification
```bash
python examples/online_serving/text_to_speech/ming_tts/openai_speech_client.py \
--text "我觉得社会企业同个人都有责任" \
--instruction-json '{"方言":"广粤话"}' \
--ref-audio /path/to/yue_prompt.wav \
--max-new-tokens 200 \
--output dialect.wav
```
`--ref-audio` matches upstream `use_spk_emb=True`; do not add `--ref-text`
for the dialect case.
## Notes
- The official ROCm image includes the platform dependencies.
- See the [ROCm installation guide](../../docs/getting_started/installation/gpu.md)
for interactive and source-build workflows.
- The [offline example](../../examples/offline_inference/text_to_speech/ming_tts/)
can be run from an interactive container.
- The tested environment uses `--enforce-eager`.
- Output is mono 44.1 kHz audio.
+1
View File
@@ -22,3 +22,4 @@ prettytable>=3.8.0
aenum==3.1.16
pyzmq>=25.0.0
janus>=1.0.0
pydub
-1
View File
@@ -1,3 +1,2 @@
-r common.txt
onnxruntime>=1.23.2
auto-round-lib
@@ -20,7 +20,6 @@ from vllm.sampling_params import SamplingParams
from vllm.v1.engine import EngineCoreEventType
from vllm.v1.request import Request, RequestStatus, StreamingUpdate
from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler
from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin
# isort: on
@@ -143,300 +142,3 @@ class TestReplaceSessionWithStreamingUpdate:
assert session.events
assert session.events[-1].type == EngineCoreEventType.QUEUED
class _RealignSchedulerStub(OmniSchedulerMixin):
"""Minimal scheduler surface for ``_realign_request_status_to_queues``.
Pins ``self.requests`` / ``self.running`` / ``self.waiting`` to the
same shapes the upstream ``Scheduler.finish_requests`` reads, so the
helper sees realistic state without spinning a real scheduler.
"""
def __init__(
self,
*,
requests: dict,
running: list,
waiting: list,
) -> None:
self.requests = requests
self.running = running
self.waiting = waiting
class TestRealignRequestStatusToQueues:
"""Regression for the residual hang described in
https://github.com/vllm-project/vllm-omni/pull/3774 -- chunk-transfer
adapter's ``requests_origin_status`` table goes stale on the
``waiting → running`` admit transition, and an abort that lands
before the next deque round-trip stomps stale ``WAITING`` onto a
request that actually lives in ``self.running``. After
``max_num_seqs`` such aborts every ``input_batch`` slot is leaked and
new requests hang at ``chunks=0``.
"""
def test_running_with_stale_waiting_status_is_realigned_to_running(
self,
) -> None:
"""The exact race the hang reproduces: a request lives in
``self.running`` but its ``status`` is ``WAITING``. After
realign, status must be ``RUNNING`` so upstream
``Scheduler.finish_requests`` removes it from ``self.running``.
"""
req = _make_request(request_id="req-stale")
req.status = RequestStatus.WAITING # stale -- actually in running
sched = _RealignSchedulerStub(
requests={req.request_id: req},
running=[req],
waiting=[],
)
sched._realign_request_status_to_queues([req.request_id])
assert req.status == RequestStatus.RUNNING
def test_waiting_with_stale_running_status_is_realigned_to_waiting(
self,
) -> None:
"""Symmetric case: request lives in ``self.waiting`` but status
is ``RUNNING``. Upstream's else branch should run, so realign to
``WAITING``.
"""
req = _make_request(request_id="req-stale-2")
req.status = RequestStatus.RUNNING # stale -- actually in waiting
sched = _RealignSchedulerStub(
requests={req.request_id: req},
running=[],
waiting=[req],
)
sched._realign_request_status_to_queues([req.request_id])
assert req.status == RequestStatus.WAITING
def test_already_aligned_status_is_left_unchanged(self) -> None:
"""Healthy case: status matches actual queue. No status mutation
means no spurious side effects.
"""
req_running = _make_request(request_id="req-r")
req_running.status = RequestStatus.RUNNING
req_waiting = _make_request(request_id="req-w")
req_waiting.status = RequestStatus.WAITING
sched = _RealignSchedulerStub(
requests={
req_running.request_id: req_running,
req_waiting.request_id: req_waiting,
},
running=[req_running],
waiting=[req_waiting],
)
sched._realign_request_status_to_queues([req_running.request_id, req_waiting.request_id])
assert req_running.status == RequestStatus.RUNNING
assert req_waiting.status == RequestStatus.WAITING
def test_unknown_request_id_is_skipped_silently(self) -> None:
"""Aligning ids that aren't in ``self.requests`` (already freed
upstream, or never existed) must be a no-op.
"""
sched = _RealignSchedulerStub(requests={}, running=[], waiting=[])
sched._realign_request_status_to_queues(["never-existed"]) # no raise
def test_request_in_neither_queue_is_left_unchanged(self) -> None:
"""If a tracked request is in neither queue (e.g. parked in a
chunk-transfer deque), realign must not invent a status. The
adapter / deque purge owns that surface; the realign helper is
only for the admit-transition staleness.
"""
req = _make_request(request_id="req-parked")
req.status = RequestStatus.WAITING_FOR_CHUNK
sched = _RealignSchedulerStub(
requests={req.request_id: req},
running=[],
waiting=[],
)
sched._realign_request_status_to_queues([req.request_id])
assert req.status == RequestStatus.WAITING_FOR_CHUNK
def test_request_ids_str_is_treated_as_single_id(self) -> None:
"""Match upstream ``Scheduler.finish_requests`` resolution: a
bare string is treated as one id, not iterated as characters.
"""
req = _make_request(request_id="req-s")
req.status = RequestStatus.WAITING
sched = _RealignSchedulerStub(
requests={req.request_id: req},
running=[req],
waiting=[],
)
sched._realign_request_status_to_queues(req.request_id)
assert req.status == RequestStatus.RUNNING
def test_request_ids_none_aligns_every_known_request(self) -> None:
"""``request_ids=None`` matches upstream's "all requests" path.
Realign must walk every entry in ``self.requests`` and fix any
stale status it finds.
"""
stale = _make_request(request_id="req-stale-none")
stale.status = RequestStatus.WAITING # but actually in running
clean = _make_request(request_id="req-clean-none")
clean.status = RequestStatus.RUNNING
sched = _RealignSchedulerStub(
requests={
stale.request_id: stale,
clean.request_id: clean,
},
running=[stale, clean],
waiting=[],
)
sched._realign_request_status_to_queues(None)
assert stale.status == RequestStatus.RUNNING
assert clean.status == RequestStatus.RUNNING
def test_finished_request_is_skipped(self) -> None:
"""Already-finished requests must not be touched -- they may
have legitimate finished statuses (FINISHED_STOPPED etc.) that
a status flip would corrupt.
"""
req = _make_request(request_id="req-finished")
req.status = RequestStatus.FINISHED_STOPPED
sched = _RealignSchedulerStub(
requests={req.request_id: req},
running=[req], # not realistic, but we want to prove the guard fires
waiting=[],
)
sched._realign_request_status_to_queues([req.request_id])
assert req.status == RequestStatus.FINISHED_STOPPED
class TestPurgeFinishedFromRunning:
"""Regression for the residual ``self.running`` slot leak surface
paired with ``_realign_request_status_to_queues``: even after
realign + ``super().finish_requests`` runs, corner cases can leave
already-finished or untracked entries in ``self.running`` -- e.g.
a connector cleanup that pops from ``self.requests`` without
unwinding ``self.running``, or a ``status`` mid-transition when
finish ran. The defensive post-finish purge sweeps those residues
so the worker's ``input_batch`` slot never pins a freed request.
See https://github.com/vllm-project/vllm-omni/pull/3774 discussion
and the residual-hang reproduction in that PR.
"""
def test_finished_request_is_purged_from_running(self) -> None:
"""``is_finished()`` request lingering in ``self.running`` must
be swept so its ``input_batch`` slot is freed."""
finished = _make_request(request_id="req-finished")
finished.status = RequestStatus.FINISHED_STOPPED # is_finished() True
sched = _RealignSchedulerStub(
requests={finished.request_id: finished},
running=[finished],
waiting=[],
)
sched._purge_finished_from_running()
assert sched.running == []
def test_untracked_request_is_purged_from_running(self) -> None:
"""Request lingering in ``self.running`` but no longer present
in ``self.requests`` (already freed by upstream / connector)
must be swept."""
untracked = _make_request(request_id="req-untracked")
untracked.status = RequestStatus.RUNNING
sched = _RealignSchedulerStub(
requests={}, # already deleted from self.requests
running=[untracked],
waiting=[],
)
sched._purge_finished_from_running()
assert sched.running == []
def test_healthy_running_is_left_unchanged(self) -> None:
"""Live tracked running requests must not be touched -- the
purge is defensive, not aggressive."""
alive = _make_request(request_id="req-alive")
alive.status = RequestStatus.RUNNING
sched = _RealignSchedulerStub(
requests={alive.request_id: alive},
running=[alive],
waiting=[],
)
sched._purge_finished_from_running()
assert sched.running == [alive]
def test_empty_running_is_noop(self) -> None:
"""Empty ``self.running`` must short-circuit cleanly."""
sched = _RealignSchedulerStub(requests={}, running=[], waiting=[])
sched._purge_finished_from_running()
assert sched.running == []
def test_mixed_alive_and_dead_keeps_only_alive_in_order(self) -> None:
"""Mixed ``self.running`` -- some alive, some finished, some
untracked. Sweep keeps only the alive ones and preserves their
relative order."""
alive_a = _make_request(request_id="req-alive-a")
alive_a.status = RequestStatus.RUNNING
finished_b = _make_request(request_id="req-finished-b")
finished_b.status = RequestStatus.FINISHED_STOPPED
untracked_c = _make_request(request_id="req-untracked-c")
untracked_c.status = RequestStatus.RUNNING
alive_d = _make_request(request_id="req-alive-d")
alive_d.status = RequestStatus.RUNNING
sched = _RealignSchedulerStub(
requests={
alive_a.request_id: alive_a,
finished_b.request_id: finished_b, # tracked but is_finished
# untracked_c is NOT in self.requests
alive_d.request_id: alive_d,
},
running=[alive_a, finished_b, untracked_c, alive_d],
waiting=[],
)
sched._purge_finished_from_running()
assert sched.running == [alive_a, alive_d]
def test_in_place_mutation_keeps_helper_local_list_identity(self) -> None:
"""The slice-assign form (``self.running[:] = ...``) avoids an
extra rebind inside this helper. Note that across a full
``finish_requests`` call upstream ``Scheduler.finish_requests``
already rebinds ``self.running``, so global identity is not a
contract -- this test only pins the local helper behavior so a
future refactor away from in-place mutation is intentional.
"""
finished = _make_request(request_id="req-finished")
finished.status = RequestStatus.FINISHED_STOPPED
alive = _make_request(request_id="req-alive")
alive.status = RequestStatus.RUNNING
sched = _RealignSchedulerStub(
requests={
finished.request_id: finished,
alive.request_id: alive,
},
running=[finished, alive],
waiting=[],
)
held_ref = sched.running
sched._purge_finished_from_running()
assert held_ref is sched.running
assert held_ref == [alive]
@@ -1,158 +0,0 @@
"""Integration tests for the post-finish defensive purge of
``self.running`` placed in
``OmniARScheduler.finish_requests`` /
``OmniGenerationScheduler.finish_requests``.
Pins *placement* — that ``_purge_finished_from_running`` runs AFTER
``super().finish_requests`` so it catches entries upstream's removal
missed (e.g. status mid-transition, connector cleanup popping
``self.requests`` without unwinding ``self.running``). Direct helper
unit tests live in ``test_omni_scheduler_mixin.py``; these tests
exercise the full ``finish_requests`` path end-to-end.
Regression for the residual self.running slot leak surface paired
with ``_realign_request_status_to_queues`` (vllm-omni#3774).
"""
from __future__ import annotations
import pytest
from vllm.v1.request import RequestStatus
import vllm_omni.core.sched.omni_ar_scheduler as ar_sched_mod
import vllm_omni.core.sched.omni_generation_scheduler as gen_sched_mod
from vllm_omni.core.sched.omni_ar_scheduler import OmniARScheduler
from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _StubRequest:
"""Minimal Request stub with the surface the helpers exercise."""
def __init__(self, request_id: str, status: RequestStatus) -> None:
self.request_id = request_id
self.status = status
def is_finished(self) -> bool:
return RequestStatus.is_finished(self.status)
_SCHEDULER_PARAMS = [
pytest.param(OmniARScheduler, ar_sched_mod, id="ar"),
pytest.param(OmniGenerationScheduler, gen_sched_mod, id="generation"),
]
def _make_scheduler(scheduler_cls, *, requests, running, waiting):
"""Bare scheduler with just the surface ``finish_requests`` reads."""
scheduler = scheduler_cls.__new__(scheduler_cls)
scheduler.chunk_transfer_adapter = None
scheduler.input_coordinator = None
scheduler.requests = requests
scheduler.running = running
scheduler.waiting = waiting
return scheduler
@pytest.mark.parametrize(("scheduler_cls", "scheduler_mod"), _SCHEDULER_PARAMS)
def test_finish_requests_purges_finished_request_from_running(
monkeypatch: pytest.MonkeyPatch,
scheduler_cls,
scheduler_mod,
) -> None:
"""A request whose ``status`` is a finished value but still lives
in ``self.running`` after ``super().finish_requests`` returns must
be swept by the post-finish purge. Mocks ``super()`` to simulate
the corner case where upstream's status-driven removal missed the
entry.
"""
finished = _StubRequest("req-finished", RequestStatus.FINISHED_STOPPED)
scheduler = _make_scheduler(
scheduler_cls,
requests={finished.request_id: finished},
running=[finished],
waiting=[],
)
def fake_finish_requests(self, request_ids, finished_status):
# super() returning the abort tuple but leaving self.running untouched
return [(finished.request_id, 0)]
monkeypatch.setattr(scheduler_mod.VLLMScheduler, "finish_requests", fake_finish_requests)
result = scheduler_cls.finish_requests(
scheduler,
[finished.request_id],
RequestStatus.FINISHED_STOPPED,
)
assert scheduler.running == []
assert result == [(finished.request_id, 0)]
@pytest.mark.parametrize(("scheduler_cls", "scheduler_mod"), _SCHEDULER_PARAMS)
def test_finish_requests_purges_untracked_request_from_running(
monkeypatch: pytest.MonkeyPatch,
scheduler_cls,
scheduler_mod,
) -> None:
"""A request that has been deleted from ``self.requests`` (e.g.
after upstream's ``_free_request`` ran but a status mismatch
caused the ``self.running`` removal branch to no-op) must be
swept by the post-finish purge.
"""
untracked = _StubRequest("req-untracked", RequestStatus.RUNNING)
scheduler = _make_scheduler(
scheduler_cls,
requests={}, # already deleted from self.requests
running=[untracked],
waiting=[],
)
def fake_finish_requests(self, request_ids, finished_status):
return [(untracked.request_id, 0)]
monkeypatch.setattr(scheduler_mod.VLLMScheduler, "finish_requests", fake_finish_requests)
result = scheduler_cls.finish_requests(
scheduler,
[untracked.request_id],
RequestStatus.FINISHED_STOPPED,
)
assert scheduler.running == []
assert result == [(untracked.request_id, 0)]
@pytest.mark.parametrize(("scheduler_cls", "scheduler_mod"), _SCHEDULER_PARAMS)
def test_finish_requests_leaves_healthy_running_intact(
monkeypatch: pytest.MonkeyPatch,
scheduler_cls,
scheduler_mod,
) -> None:
"""A live ``RUNNING`` request that is not in the finish set must
not be swept by the defensive purge -- this pins that the predicate
is conservative and won't aggressively drop healthy entries.
"""
alive = _StubRequest("req-alive", RequestStatus.RUNNING)
leaving_id = "req-leaving"
scheduler = _make_scheduler(
scheduler_cls,
requests={alive.request_id: alive},
running=[alive], # leaving already removed by super() (typical happy path)
waiting=[],
)
def fake_finish_requests(self, request_ids, finished_status):
return [(leaving_id, 0)]
monkeypatch.setattr(scheduler_mod.VLLMScheduler, "finish_requests", fake_finish_requests)
scheduler_cls.finish_requests(
scheduler,
[leaving_id],
RequestStatus.FINISHED_STOPPED,
)
assert scheduler.running == [alive]
@@ -46,9 +46,6 @@ def test_finish_requests_cleans_input_coordinator_for_finished_ids(
scheduler = scheduler_cls.__new__(scheduler_cls)
scheduler.chunk_transfer_adapter = None
scheduler.input_coordinator = coordinator
scheduler.requests = {}
scheduler.running = []
scheduler.waiting = []
def fake_finish_requests(self, request_ids, finished_status):
assert request_ids == ["req-a", "req-b"]
-61
View File
@@ -339,66 +339,5 @@
}
}
]
},
{
"test_name": "test_voxtral_tts",
"server_params": {
"model": "mistralai/Voxtral-4B-TTS-2603"
},
"benchmark_params": [
{
"task": "default_voice",
"eval_phase": "latency",
"dataset_name": "seed-tts-text",
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "benchmarks/build_dataset/seed_tts_smoke",
"num_prompts": [20],
"max_concurrency": [1],
"seed_tts_locale": "en",
"extra_body": {"voice": "casual_male"},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [150],
"median_audio_rtf": [0.15]
}
},
{
"task": "default_voice",
"eval_phase": "throughput",
"dataset_name": "seed-tts-text",
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "benchmarks/build_dataset/seed_tts_smoke",
"num_prompts": [80],
"max_concurrency": [8],
"seed_tts_locale": "en",
"extra_body": {"voice": "casual_male"},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [1500],
"median_audio_rtf": [0.30],
"audio_throughput": [30.0]
}
},
{
"task": "default_voice",
"eval_phase": "stress",
"dataset_name": "seed-tts-text",
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "benchmarks/build_dataset/seed_tts_smoke",
"num_prompts": [100],
"request_rate": [2.0],
"seed_tts_locale": "en",
"extra_body": {"voice": "casual_male"},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [3000],
"median_audio_rtf": [0.25],
"audio_throughput": [4.0]
}
}
]
}
]
@@ -319,7 +319,6 @@ QWEN_PARAMS = create_reliability_omni_server_params(RELIABILITY_SCENARIOS, DEPLO
@pytest.mark.slow
@pytest.mark.skip(reason="issue#4285")
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
def test_reliability_fault_gpu_oom_error_contract_consistent_chat_speech(
@@ -388,7 +387,6 @@ def test_reliability_fault_gpu_oom_error_contract_consistent_chat_speech(
@pytest.mark.slow
@pytest.mark.skip(reason="issue#4285")
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
def test_reliability_fault_gpu_oom_chat_large_payload_failure(omni_server_function, openai_client_function) -> None:
@@ -431,7 +429,6 @@ def test_reliability_fault_gpu_oom_chat_large_payload_failure(omni_server_functi
@pytest.mark.slow
@pytest.mark.skip(reason="issue#4285")
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
def test_reliability_fault_gpu_oom_concurrent_pressure_failure(omni_server_function, openai_client_function) -> None:
@@ -774,7 +771,7 @@ def test_reliability_fault_process_kill_tree_with_load_fast_fail_and_cleanup(
@pytest.mark.slow
@pytest.mark.skip(reason="issue#4285")
@pytest.mark.skip(reason="OOM injection causes the serving instance to exit")
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
def test_reliability_fault_gpu_oom_state_converges_after_fault_removed(
@@ -1,46 +1,19 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Tests for the DiffusersPipelineLoader.
"""
from types import SimpleNamespace
import pytest
import torch
import torch.nn as nn
from huggingface_hub import snapshot_download
from vllm.config.load import LoadConfig
from vllm_omni.diffusion.config import get_current_diffusion_config, get_current_diffusion_config_or_none
from vllm_omni.diffusion.data import OmniDiffusionConfig
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
from vllm_omni.diffusion.model_loader.gguf_adapters import get_gguf_adapter
from vllm_omni.diffusion.models.helios import HeliosPipeline
from vllm_omni.diffusion.registry import initialize_model
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
model_path = "hf-internal-testing/tiny-helios-modular-pipe"
@pytest.fixture(scope="module")
def prefetch_helios_model():
"""Downloads the tiny helios model prior to running a test."""
snapshot_download(model_path)
@pytest.fixture(scope="function")
def mock_tp_group(mocker):
"""Mocks the tensor parallel group; this is needed to initialize the Helios model."""
mocker.patch("vllm.model_executor.layers.linear.get_tensor_model_parallel_world_size", return_value=1)
mocker.patch("vllm.model_executor.layers.linear.get_tensor_model_parallel_rank", return_value=0)
mock_group = mocker.MagicMock()
mock_group.world_size = 1
mock_group.rank_in_group = 0
mocker.patch("vllm.distributed.parallel_state.get_tp_group", return_value=mock_group)
class _DummyPipelineModel(nn.Module):
def __init__(self, *, source_prefix: str):
@@ -69,13 +42,7 @@ class _DummyPipelineModel(nn.Module):
def _make_loader_with_weights(weight_names: list[str]) -> DiffusersPipelineLoader:
od_config = SimpleNamespace(
dtype=torch.float32,
parallel_config=SimpleNamespace(use_hsdp=False),
quantization_config=None,
)
loader = DiffusersPipelineLoader(LoadConfig(), od_config)
loader = object.__new__(DiffusersPipelineLoader)
loader.counter_before_loading_weights = 0.0
loader.counter_after_loading_weights = 0.0
@@ -183,15 +150,16 @@ def test_load_model_custom_pipeline_sets_current_diffusion_config(monkeypatch):
quantization_config=None,
)
loader = DiffusersPipelineLoader(LoadConfig(), od_config)
loader = object.__new__(DiffusersPipelineLoader)
loader.load_weights = lambda model: None # type: ignore[assignment]
loader._process_weights_after_loading = lambda model, target_device: None # type: ignore[assignment]
loader._is_gguf_quantization = lambda: False # type: ignore[assignment]
loader._is_gguf_quantization = lambda _od_config: False # type: ignore[assignment]
monkeypatch.setattr(loader_mod, "resolve_obj_by_qualname", lambda _name: _ConfigAwareModel)
monkeypatch.setattr(loader_mod.torch, "device", lambda _name: _DeviceContext("cpu"))
model = loader.load_model(
od_config,
load_device="cpu",
load_format="custom_pipeline",
custom_pipeline_name="tests.dummy.ConfigAwarePipeline",
@@ -200,33 +168,3 @@ def test_load_model_custom_pipeline_sets_current_diffusion_config(monkeypatch):
assert model.captured_config is od_config
assert model.seen_config_during_init is od_config
assert get_current_diffusion_config_or_none() is None
def test_get_all_weights(prefetch_helios_model, mock_tp_group):
"""Ensure that get all weights on a tiny model resolves to nonempty weights."""
od_config = OmniDiffusionConfig(
model_class_name="HeliosPipeline",
model=model_path,
)
loader = DiffusersPipelineLoader(
load_config=LoadConfig(),
od_config=od_config,
)
pipeline = HeliosPipeline(od_config=od_config)
weights = list(loader.get_all_weights(pipeline))
assert len(weights) > 0
def test_load_model(prefetch_helios_model, mock_tp_group):
"""Ensure that load model creates an instance of the expected pipeline class."""
od_config = OmniDiffusionConfig(
model_class_name="HeliosPipeline",
model=model_path,
)
loader = DiffusersPipelineLoader(
load_config=LoadConfig(),
od_config=od_config,
)
model = loader.load_model(load_device="cpu")
assert isinstance(model, HeliosPipeline)
@@ -62,7 +62,6 @@ def _make_loader() -> DiffusersPipelineLoader:
ignore_patterns=["*.tmp"],
)
loader.od_config = None
loader.quant_config = None
loader.counter_before_loading_weights = 0.0
loader.counter_after_loading_weights = 0.0
return loader
@@ -70,47 +69,53 @@ def _make_loader() -> DiffusersPipelineLoader:
def test_is_gguf_quantization_accepts_dict_config():
loader = _make_loader()
loader.quant_config = {
"method": "gguf",
"gguf_model": "weights.gguf",
}
od_config = SimpleNamespace(
quantization_config={
"method": "gguf",
"gguf_model": "weights.gguf",
}
)
assert loader._is_gguf_quantization() is True
assert loader._is_gguf_quantization(od_config) is True
def test_is_gguf_quantization_rejects_non_gguf_dict_config():
loader = _make_loader()
loader.quant_config = {
"method": "fp8",
"gguf_model": "weights.gguf",
}
od_config = SimpleNamespace(
quantization_config={
"method": "fp8",
"gguf_model": "weights.gguf",
}
)
assert loader._is_gguf_quantization() is False
assert loader._is_gguf_quantization(od_config) is False
def test_is_gguf_quantization_requires_gguf_model_for_dict_config():
loader = _make_loader()
loader.quant_config = {"method": "gguf"}
od_config = SimpleNamespace(quantization_config={"method": "gguf"})
with pytest.raises(ValueError, match="gguf_model"):
loader._is_gguf_quantization()
loader._is_gguf_quantization(od_config)
def test_is_gguf_quantization_accepts_object_config():
loader = _make_loader()
loader.quant_config = SimpleNamespace(
get_name=lambda: "gguf",
gguf_model="weights.gguf",
od_config = SimpleNamespace(
quantization_config=SimpleNamespace(
get_name=lambda: "gguf",
gguf_model="weights.gguf",
)
)
assert loader._is_gguf_quantization() is True
assert loader._is_gguf_quantization(od_config) is True
def test_is_gguf_quantization_uses_fallback_object_without_get_name():
loader = _make_loader()
loader.quant_config = SimpleNamespace(gguf_model="weights.gguf")
od_config = SimpleNamespace(quantization_config=SimpleNamespace(gguf_model="weights.gguf"))
assert loader._is_gguf_quantization() is True
assert loader._is_gguf_quantization(od_config) is True
def test_get_model_loadable_names_collects_parameters_and_buffers():
@@ -166,14 +171,16 @@ def test_resolve_gguf_model_path_downloads_by_quant_type(monkeypatch: pytest.Mon
def test_get_gguf_weights_iterator_prefixes_source_names(monkeypatch: pytest.MonkeyPatch):
loader = _make_loader()
loader.quant_config = SimpleNamespace(gguf_model="weights.gguf")
loader.od_config = SimpleNamespace(revision="main")
source = DiffusersPipelineLoader.ComponentSource(
model_or_path="dummy",
subfolder="transformer",
revision=None,
prefix="transformer.",
)
od_config = SimpleNamespace(
revision="main",
quantization_config=SimpleNamespace(gguf_model="weights.gguf"),
)
class _Adapter:
def weights_iterator(self):
@@ -183,7 +190,7 @@ def test_get_gguf_weights_iterator_prefixes_source_names(monkeypatch: pytest.Mon
monkeypatch.setattr(loader, "_resolve_gguf_model_path", lambda gguf_model, revision: "resolved.gguf")
monkeypatch.setattr(loader_module, "get_gguf_adapter", lambda gguf_file, model, source, od_config: _Adapter())
weights = list(loader._get_gguf_weights_iterator(source, object()))
weights = list(loader._get_gguf_weights_iterator(source, object(), od_config))
assert weights[0][0] == "transformer.weight"
assert torch.equal(weights[0][1], torch.ones((2, 2)))
@@ -194,12 +201,13 @@ def test_get_gguf_weights_iterator_prefixes_source_names(monkeypatch: pytest.Mon
def test_load_weights_with_gguf_falls_back_only_for_missing_transformer_weights(monkeypatch: pytest.MonkeyPatch):
loader = _make_loader()
model = _DummyModel()
od_config = SimpleNamespace()
monkeypatch.setattr(loader, "_get_weight_sources", lambda _model: tuple(model.weights_sources))
monkeypatch.setattr(
loader,
"_get_gguf_weights_iterator",
lambda source, model: iter([("transformer.weight", torch.ones((2, 2), dtype=torch.float32))]),
lambda source, model, od_config: iter([("transformer.weight", torch.ones((2, 2), dtype=torch.float32))]),
)
def _hf_iter(source):
@@ -214,7 +222,7 @@ def test_load_weights_with_gguf_falls_back_only_for_missing_transformer_weights(
lambda _model: {"transformer.weight", "transformer.bias", "vae.weight"},
)
loaded = loader._load_weights_with_gguf(model)
loaded = loader._load_weights_with_gguf(model, od_config)
assert loaded == {"transformer.weight", "transformer.bias", "vae.weight"}
assert model.calls[0] == ["transformer.weight"]
@@ -225,12 +233,13 @@ def test_load_weights_with_gguf_falls_back_only_for_missing_transformer_weights(
def test_load_weights_with_gguf_skips_transformer_fallback_when_gguf_is_complete(monkeypatch: pytest.MonkeyPatch):
loader = _make_loader()
model = _DummyModel()
od_config = SimpleNamespace()
monkeypatch.setattr(loader, "_get_weight_sources", lambda _model: tuple(model.weights_sources))
monkeypatch.setattr(
loader,
"_get_gguf_weights_iterator",
lambda source, model: iter(
lambda source, model, od_config: iter(
[
("transformer.weight", torch.ones((2, 2), dtype=torch.float32)),
("transformer.bias", torch.zeros(2, dtype=torch.float32)),
@@ -253,7 +262,7 @@ def test_load_weights_with_gguf_skips_transformer_fallback_when_gguf_is_complete
lambda _model: {"transformer.weight", "transformer.bias", "vae.weight"},
)
loaded = loader._load_weights_with_gguf(model)
loaded = loader._load_weights_with_gguf(model, od_config)
assert loaded == {"transformer.weight", "transformer.bias", "vae.weight"}
assert hf_calls == ["vae"]
@@ -374,19 +374,6 @@ def test_preprocess_i2v_image_and_action_video_inputs() -> None:
assert tuple(additional["preprocessed_image"].shape) == (1, 3, 16, 32)
assert tuple(additional["preprocessed_video"].shape) == (1, 3, 3, 16, 32)
frames = [Image.new("RGB", (8, 4), color) for color in ("red", "green", "blue", "yellow", "purple", "black")]
v2v = SimpleNamespace(
prompts=[{"prompt": "Continue.", "multi_modal_data": {"video": frames}}],
sampling_params=SimpleNamespace(
height=16,
width=32,
extra_args={"condition_frame_indexes_vision": [0, 1], "condition_video_keep": "last"},
),
)
additional = preprocess(v2v).prompts[0]["additional_information"]
assert tuple(additional["preprocessed_video"].shape) == (1, 3, 5, 16, 32)
assert additional["condition_frame_indexes_vision"] == [0, 1]
def test_postprocess_handles_image_video_audio_and_validation() -> None:
from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import get_cosmos3_post_process_func
@@ -477,19 +464,6 @@ def test_prepare_latents_for_video_image_sound_and_action(make_cosmos3_pipeline)
assert velocity_mask.tolist() == [[[[[0.0]], [[1.0]]]]]
assert image_latent.shape == (1, 2, 1, 2, 3)
pipeline._encode_video_tensor = lambda *args, **kwargs: torch.full((1, 2, 3, 2, 3), 6.0)
v2v_latents, v2v_velocity_mask, v2v_condition = pipeline._prepare_latents_v2v(
torch.zeros(1, 3, 5, 16, 24),
16,
24,
9,
torch.Generator(device="cpu").manual_seed(0),
[0, 1],
)
torch.testing.assert_close(v2v_latents[:, :, 0:2], torch.full((1, 2, 2, 2, 3), 6.0))
assert v2v_velocity_mask.tolist() == [[[[[0.0]], [[0.0]], [[1.0]]]]]
assert v2v_condition.shape == (1, 2, 3, 2, 3)
pipeline.transformer = pipeline.transformer.__class__(latent_channel_size=2, sound_gen=True, sound_dim=3)
pipeline._sound_tokenizer = SimpleNamespace(
sample_rate=10,
@@ -696,34 +670,6 @@ class TestForwardRouting:
)
assert captured["diffuse_calls"][-1]["shared_kwargs"]["noisy_frame_mask"] is velocity_mask
video_tensor = torch.zeros(1, 3, 5, 16, 16)
v2v_condition = torch.full((1, 2, 2, 1, 1), 4.0)
v2v_mask = torch.tensor([[[[[0.0]], [[1.0]]]]])
pipeline._prepare_latents_v2v = lambda *args, **kwargs: (
torch.zeros(1, 2, 2, 1, 1),
v2v_mask,
v2v_condition,
)
pipeline.forward(
SimpleNamespace(
prompts=[
{
"prompt": "continue",
"modalities": ["video"],
"additional_information": {
"preprocessed_video": video_tensor,
"condition_frame_indexes_vision": [0],
},
}
],
sampling_params=make_sampling_params(height=16, width=16, num_frames=5),
)
)
assert captured["flow_shifts"][-1] == 10.0
assert captured["format"]["negative_prompt"] == ""
assert captured["diffuse_calls"][-1]["shared_kwargs"]["noisy_frame_mask"] is v2v_mask
assert captured["diffuse_calls"][-1]["condition_latents"] is v2v_condition
pipeline.transformer = pipeline.transformer.__class__(latent_channel_size=2, sound_gen=True, sound_dim=3)
sound_latents = torch.zeros(1, 3, 4)
pipeline._resolve_sound_target_samples = lambda *args: (20, 2.0, 10)
@@ -1,2 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
@@ -1,160 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for HunyuanVideo-1.5 quant_config propagation through transformer creation.
Tests cover:
- HunyuanVideo15Pipeline passes quant_config to HunyuanVideo15Transformer3DModel
- HunyuanVideo15I2VPipeline passes quant_config to HunyuanVideo15Transformer3DModel
"""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
import torch
import vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5 as t2v_module
import vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5_i2v as i2v_module
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
class TestHunyuanVideoQuantConfigPropagation:
"""Verify quant_config is propagated to the transformer model in HunyuanVideo-1.5 pipelines."""
@patch(
"vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5.get_local_device",
return_value="cpu",
)
@patch("vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5.prefetch_subfolders")
@patch(
"vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5.from_pretrained_with_prefetch",
return_value=MagicMock(),
)
@patch(
"vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5.Qwen2Tokenizer.from_pretrained",
return_value=MagicMock(),
)
@patch(
"vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5.ByT5Tokenizer.from_pretrained",
return_value=MagicMock(),
)
@patch(
"vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5.AutoConfig.from_pretrained",
return_value=MagicMock(),
)
@patch(
"vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5.T5EncoderModel",
return_value=MagicMock(),
)
@patch(
"vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5.FlowMatchEulerDiscreteScheduler.from_pretrained",
return_value=MagicMock(),
)
@patch(
"vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5.get_transformer_config_kwargs",
return_value={},
)
def test_t2v_quant_config_passed_through(
self,
mock_get_kwargs,
mock_scheduler,
mock_t5_encoder,
mock_auto_config,
mock_byt5_tok,
mock_qwen2_tok,
mock_from_pretrained,
mock_prefetch,
mock_get_device,
):
captured_kwargs = {}
class FakeTransformer:
def __init__(self, **kwargs):
captured_kwargs.update(kwargs)
with patch.object(t2v_module, "HunyuanVideo15Transformer3DModel", FakeTransformer):
fake_qc = MagicMock()
od_config = SimpleNamespace(
model="fake-t2v-model",
dtype=torch.bfloat16,
flow_shift=None,
tf_model_config=SimpleNamespace(),
quantization_config=fake_qc,
enable_diffusion_pipeline_profiler=False,
)
t2v_module.HunyuanVideo15Pipeline(od_config=od_config)
assert captured_kwargs.get("quant_config") is fake_qc
@patch(
"vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5_i2v.get_local_device",
return_value="cpu",
)
@patch("vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5_i2v.prefetch_subfolders")
@patch(
"vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5_i2v.from_pretrained_with_prefetch",
return_value=MagicMock(),
)
@patch(
"vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5_i2v.Qwen2Tokenizer.from_pretrained",
return_value=MagicMock(),
)
@patch(
"vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5_i2v.ByT5Tokenizer.from_pretrained",
return_value=MagicMock(),
)
@patch(
"vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5_i2v.AutoConfig.from_pretrained",
return_value=MagicMock(),
)
@patch(
"vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5_i2v.T5EncoderModel",
return_value=MagicMock(),
)
@patch(
"vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5_i2v.SiglipImageProcessor.from_pretrained",
return_value=MagicMock(),
)
@patch(
"vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5_i2v.FlowMatchEulerDiscreteScheduler.from_pretrained",
return_value=MagicMock(),
)
@patch(
"vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5_i2v.get_transformer_config_kwargs",
return_value={},
)
def test_i2v_quant_config_passed_through(
self,
mock_get_kwargs,
mock_scheduler,
mock_siglip_processor,
mock_t5_encoder,
mock_auto_config,
mock_byt5_tok,
mock_qwen2_tok,
mock_from_pretrained,
mock_prefetch,
mock_get_device,
):
captured_kwargs = {}
class FakeTransformer:
def __init__(self, **kwargs):
captured_kwargs.update(kwargs)
with patch.object(i2v_module, "HunyuanVideo15Transformer3DModel", FakeTransformer):
fake_qc = MagicMock()
od_config = SimpleNamespace(
model="fake-i2v-model",
dtype=torch.bfloat16,
flow_shift=None,
tf_model_config=SimpleNamespace(),
quantization_config=fake_qc,
enable_diffusion_pipeline_profiler=False,
)
i2v_module.HunyuanVideo15I2VPipeline(od_config=od_config)
assert captured_kwargs.get("quant_config") is fake_qc
@@ -72,285 +72,6 @@ class TestPipelineIndependence:
assert issubclass(LTX23Pipeline, CFGParallelMixin)
def test_ltx23_pipeline_declares_offload_components(self):
"""LTX23Pipeline must expose LTX-2.3-specific modules to offload discovery."""
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline
from vllm_omni.diffusion.offloader.module_collector import ModuleDiscovery
pipe = object.__new__(LTX23Pipeline)
torch.nn.Module.__init__(pipe)
pipe.transformer = torch.nn.Linear(1, 1)
pipe.text_encoder = torch.nn.Linear(1, 1)
pipe.connectors = torch.nn.Linear(1, 1)
pipe.vae = torch.nn.Linear(1, 1)
pipe.audio_vae = torch.nn.Linear(1, 1)
pipe.vocoder = torch.nn.Linear(1, 1)
modules = ModuleDiscovery.discover(pipe)
assert LTX23Pipeline._dit_modules == ["transformer"]
assert LTX23Pipeline._encoder_modules == ["text_encoder", "connectors"]
assert LTX23Pipeline._vae_modules == ["vae", "audio_vae"]
assert LTX23Pipeline._resident_modules == ["vocoder"]
assert modules.dit_names == ["transformer"]
assert modules.encoder_names == ["text_encoder", "connectors"]
assert modules.resident_names == ["vocoder"]
assert len(modules.vaes) == 2
def test_ltx23_pipeline_has_diffusion_pipeline_profiler_mixin(self):
"""LTX23Pipeline must support lightweight stage timing."""
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline
from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin
assert issubclass(LTX23Pipeline, DiffusionPipelineProfilerMixin)
class TestLTX23VaeDecodeParallel:
"""Test LTX-2.3 video VAE tiled parallel helpers without loading weights."""
def test_ltx23_video_vae_is_distributed_tile_only_class(self):
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_ltx2 import (
DistributedAutoencoderKLLTX2Video,
)
from vllm_omni.diffusion.distributed.autoencoders.distributed_vae_executor import DistributedVaeMixin
assert issubclass(DistributedAutoencoderKLLTX2Video, DistributedVaeMixin)
assert not hasattr(DistributedAutoencoderKLLTX2Video, "patch_split")
def test_ltx23_video_vae_tile_split_uses_native_ltx23_tile_geometry(self):
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_ltx2 import (
DistributedAutoencoderKLLTX2Video,
)
vae = SimpleNamespace(
spatial_compression_ratio=32,
tile_sample_min_height=512,
tile_sample_min_width=512,
tile_sample_stride_height=448,
tile_sample_stride_width=448,
temporal_compression_ratio=8,
dtype=torch.float32,
)
z = torch.zeros(1, 2, 5, 16, 24)
tasks, grid_spec = DistributedAutoencoderKLLTX2Video.tile_split(vae, z)
assert grid_spec.grid_shape == (2, 2)
assert grid_spec.split_dims == (3, 4)
assert grid_spec.tile_spec["sample_height"] == 512
assert grid_spec.tile_spec["sample_width"] == 768
assert grid_spec.tile_spec["blend_height"] == 64
assert grid_spec.tile_spec["blend_width"] == 64
assert grid_spec.tile_spec["max_tile_output_shape"] == (1, 3, 33, 512, 512)
assert grid_spec.tile_spec["tile_output_shapes"] == {
0: (1, 3, 33, 512, 512),
1: (1, 3, 33, 512, 320),
2: (1, 3, 33, 64, 512),
3: (1, 3, 33, 64, 320),
}
assert [task.grid_coord for task in tasks] == [(0, 0), (0, 1), (1, 0), (1, 1)]
assert [tuple(task.tensor.shape) for task in tasks] == [
(1, 2, 5, 16, 16),
(1, 2, 5, 16, 10),
(1, 2, 5, 2, 16),
(1, 2, 5, 2, 10),
]
assert [task.workload for task in tasks] == [5 * 16 * 16, 5 * 16 * 10, 5 * 2 * 16, 5 * 2 * 10]
def test_ltx23_video_vae_tile_merge_blends_and_crops_like_tiled_decode(self):
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_ltx2 import (
DistributedAutoencoderKLLTX2Video,
)
from vllm_omni.diffusion.distributed.autoencoders.distributed_vae_executor import GridSpec
class FakeVae:
def __init__(self):
self.blend_calls = []
def clear_cache(self):
pass
def blend_v(self, _previous, current, blend_height):
self.blend_calls.append(("v", blend_height))
return current
def blend_h(self, _previous, current, blend_width):
self.blend_calls.append(("h", blend_width))
return current
fake_vae = FakeVae()
grid_spec = GridSpec(
split_dims=(3, 4),
grid_shape=(2, 2),
tile_spec={
"sample_height": 10,
"sample_width": 10,
"blend_height": 1,
"blend_width": 2,
"tile_sample_stride_height": 5,
"tile_sample_stride_width": 5,
},
)
tiles = {
(0, 0): torch.full((1, 3, 2, 6, 6), 1.0),
(0, 1): torch.full((1, 3, 2, 6, 6), 2.0),
(1, 0): torch.full((1, 3, 2, 6, 6), 3.0),
(1, 1): torch.full((1, 3, 2, 6, 6), 4.0),
}
merged = DistributedAutoencoderKLLTX2Video.tile_merge(fake_vae, tiles, grid_spec)
assert merged.shape == (1, 3, 2, 10, 10)
assert fake_vae.blend_calls == [("h", 2), ("v", 1), ("v", 1), ("h", 2)]
torch.testing.assert_close(merged[:, :, :, :5, :5], torch.ones(1, 3, 2, 5, 5))
torch.testing.assert_close(merged[:, :, :, :5, 5:], torch.full((1, 3, 2, 5, 5), 2.0))
torch.testing.assert_close(merged[:, :, :, 5:, :5], torch.full((1, 3, 2, 5, 5), 3.0))
torch.testing.assert_close(merged[:, :, :, 5:, 5:], torch.full((1, 3, 2, 5, 5), 4.0))
def test_ltx23_video_vae_tiled_decode_dispatches_to_tile_operator(self):
from vllm_omni.diffusion.distributed.autoencoders import autoencoder_kl_ltx2
z = torch.zeros(1, 2, 1, 16, 24)
expected = torch.ones(1, 3, 1, 512, 768)
seen = {}
class FakeExecutor:
def execute(self, tensor, operator, broadcast_result=True):
seen["tensor"] = tensor
seen["operator"] = operator
seen["broadcast_result"] = broadcast_result
return expected
vae = SimpleNamespace(distributed_executor=FakeExecutor(), is_distributed_enabled=lambda: True)
vae.tile_split = autoencoder_kl_ltx2.DistributedAutoencoderKLLTX2Video.tile_split.__get__(vae)
vae.tile_exec = autoencoder_kl_ltx2.DistributedAutoencoderKLLTX2Video.tile_exec.__get__(vae)
vae.tile_merge = autoencoder_kl_ltx2.DistributedAutoencoderKLLTX2Video.tile_merge.__get__(vae)
output = autoencoder_kl_ltx2.DistributedAutoencoderKLLTX2Video.tiled_decode(
vae,
z,
temb=torch.tensor(0.5),
return_dict=False,
)
assert len(output) == 1
assert output[0] is expected
assert seen["tensor"] is z
assert seen["broadcast_result"] is False
assert seen["operator"].split.__name__ == "tile_split"
assert seen["operator"].merge.__name__ == "tile_merge"
def test_ltx23_vae_executor_gathers_known_tile_shapes_and_returns_empty_on_non_rank0(self):
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_ltx2 import LTX2VaeExecutor
from vllm_omni.diffusion.distributed.autoencoders.distributed_vae_executor import (
DistributedOperator,
GridSpec,
TileTask,
)
z = torch.zeros(1, 1, 1, 1, 1)
tile_output_shapes = {
0: (1, 1, 1, 2, 2),
1: (1, 1, 1, 2, 1),
2: (1, 1, 1, 1, 2),
3: (1, 1, 1, 1, 1),
}
tasks = [
TileTask(0, (0, 0), z, workload=4),
TileTask(1, (0, 1), z, workload=2),
TileTask(2, (1, 0), z, workload=2),
TileTask(3, (1, 1), z, workload=1),
]
grid_spec = GridSpec(
split_dims=(3, 4),
grid_shape=(2, 2),
tile_spec={
"max_tile_output_shape": (1, 1, 1, 2, 2),
"tile_output_shapes": tile_output_shapes,
},
output_dtype=torch.float32,
)
seen = {}
def exec_tile(task):
return torch.full(tile_output_shapes[task.tile_id], float(task.tile_id + 1))
def merge_tiles(coord_tensor_map, passed_grid_spec):
seen["merged_shapes"] = {coord: tuple(tile.shape) for coord, tile in coord_tensor_map.items()}
assert passed_grid_spec is grid_spec
return torch.stack(
[
coord_tensor_map[(0, 0)].flatten()[0],
coord_tensor_map[(0, 1)].flatten()[0],
coord_tensor_map[(1, 0)].flatten()[0],
coord_tensor_map[(1, 1)].flatten()[0],
]
)
operator = DistributedOperator(split=lambda _z: (tasks, grid_spec), exec=exec_tile, merge=merge_tiles)
rank0_executor = object.__new__(LTX2VaeExecutor)
rank0_executor.parallel_size = 2
rank0_executor.world_size = 2
rank0_executor.rank = 0
def gather_rank0(local_tile_tensor):
assigned = rank0_executor._balance_tasks(tasks, 2)
rank1_results = [(task.tile_id, exec_tile(task)) for task in assigned[1]]
rank1_tile_tensor = rank0_executor._pack_local_tiles_without_meta(
rank1_results,
list(local_tile_tensor.shape),
z.device,
torch.float32,
)
seen["rank0_gather_shape"] = tuple(local_tile_tensor.shape)
return [local_tile_tensor, rank1_tile_tensor]
def fail_final_sync(*_args, **_kwargs):
raise AssertionError("broadcast_result=False should not sync the final result")
rank0_executor.gather_tensors = gather_rank0
rank0_executor._sync_final_result = fail_final_sync
rank0_result = rank0_executor.execute(z, operator, broadcast_result=False)
torch.testing.assert_close(rank0_result, torch.tensor([1.0, 2.0, 3.0, 4.0]))
assert seen["rank0_gather_shape"] == (2, 1, 1, 1, 2, 2)
assert seen["merged_shapes"] == {
(0, 0): (1, 1, 1, 2, 2),
(0, 1): (1, 1, 1, 2, 1),
(1, 0): (1, 1, 1, 1, 2),
(1, 1): (1, 1, 1, 1, 1),
}
non_rank0_executor = object.__new__(LTX2VaeExecutor)
non_rank0_executor.parallel_size = 2
non_rank0_executor.world_size = 2
non_rank0_executor.rank = 1
def gather_rank1(local_tile_tensor):
seen["rank1_gather_shape"] = tuple(local_tile_tensor.shape)
return None
def fail_non_rank0_merge(*_args, **_kwargs):
raise AssertionError("non-rank0 should not merge gathered tiles")
non_rank0_executor.gather_tensors = gather_rank1
non_rank0_executor._sync_final_result = fail_final_sync
empty_result = non_rank0_executor.execute(
z,
DistributedOperator(
split=lambda _z: (tasks, grid_spec),
exec=exec_tile,
merge=fail_non_rank0_merge,
),
broadcast_result=False,
)
assert tuple(empty_result.shape) == (0,)
assert seen["rank1_gather_shape"] == (2, 1, 1, 1, 2, 2)
class TestCFGParallelHelpers:
"""Test LTX-2.3 CFG helper math without loading model weights."""
@@ -1,22 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from torch import nn
from vllm_omni.diffusion.models.ltx2.ltx2_transformer import _make_rms_norm
def test_ltx_rms_norm_no_affine_identity_weight_is_non_persistent_buffer():
norm = _make_rms_norm(8, eps=1e-6, elementwise_affine=False)
assert "weight" not in dict(norm.named_parameters())
assert "weight" in dict(norm.named_buffers())
assert "weight" not in norm.state_dict()
def test_ltx_rms_norm_affine_weight_remains_parameter():
norm = _make_rms_norm(8, eps=1e-6, elementwise_affine=True)
assert isinstance(dict(norm.named_parameters())["weight"], nn.Parameter)
assert "weight" not in dict(norm.named_buffers())
assert "weight" in norm.state_dict()
+1 -1
View File
@@ -41,7 +41,7 @@ def mock_od_config(mocker: MockerFixture):
config.cache_backend = None
config.cache_config = None
config.model = "test-model"
config.diffusion_load_format = "default"
config.diffusion_load_format = None
config.dtype = "float32"
config.max_cpu_loras = 0
config.lora_path = None
@@ -387,15 +387,14 @@ def test_process_and_restore_queues(build_adapter):
running_req = _req("r1", RequestStatus.RUNNING)
waiting_queue = DummyWaitingQueue([waiting_req])
running_queue = [running_req]
scheduler_requests = {waiting_req.request_id: waiting_req, running_req.request_id: running_req}
adapter.process_pending_chunks(waiting_queue, running_queue, scheduler_requests=scheduler_requests)
adapter.process_pending_chunks(waiting_queue, running_queue)
assert waiting_req.status == RequestStatus.WAITING_FOR_CHUNK
assert running_req.status == RequestStatus.WAITING_FOR_CHUNK
assert waiting_queue == []
assert running_queue == []
adapter.restore_queues(waiting_queue, running_queue, scheduler_requests=scheduler_requests)
adapter.restore_queues(waiting_queue, running_queue)
assert waiting_queue == [waiting_req]
assert running_queue == [running_req]
assert adapter.waiting_for_chunk_waiting_requests == deque()
@@ -898,8 +897,6 @@ def test_omni_ar_scheduler_finish_requests(mocker: MockerFixture):
sched = OmniARScheduler.__new__(OmniARScheduler)
sched.chunk_transfer_adapter = adapter
sched.requests = {}
sched.running = []
sched.waiting = []
with patch.object(VLLMScheduler, "finish_requests", _super_finish):
OmniARScheduler.finish_requests(sched, ["r1"], RequestStatus.FINISHED_ABORTED)
@@ -1173,164 +1170,3 @@ def test_deferred_finish_not_finished_still_emits_output(mocker: MockerFixture):
assert eco.outputs[0].finish_reason == "stop"
assert eco.outputs[0].kv_transfer_params is None
assert scheduler._pending_finish_reqs == []
# ---------------------------------------------------------------
# Zombie purge tests (regression for vllm-project/vllm-omni#3736)
# ---------------------------------------------------------------
def test_process_pending_chunks_purges_zombies_in_running_deque(
build_adapter,
):
"""A request aborted while parked in ``waiting_for_chunk_running_requests``
is no longer tracked by ``scheduler.requests`` once
``Scheduler._free_request`` runs. ``process_pending_chunks`` must drop
that zombie *before* ``restore_queues`` would re-inject it onto the
running queue.
Regression for https://github.com/vllm-project/vllm-omni/issues/3736
(engine-core ``KeyError`` on aborts under chunk-transfer pressure).
"""
adapter, _ = build_adapter(stage_id=1, max_num_seqs=8)
live_req = _req("live-1", RequestStatus.WAITING_FOR_CHUNK)
zombie_req = _req("zombie-1", RequestStatus.WAITING_FOR_CHUNK)
adapter.waiting_for_chunk_running_requests.append(live_req)
adapter.waiting_for_chunk_running_requests.append(zombie_req)
# Mirror state the adapter would carry for an in-flight request so we can
# later assert ``cleanup_receiver`` actually fired against the zombie.
adapter.requests_origin_status[zombie_req.request_id] = RequestStatus.RUNNING
adapter.requests_origin_status[live_req.request_id] = RequestStatus.RUNNING
waiting_queue = DummyWaitingQueue()
running_queue: list = []
# Simulate the post-abort scheduler: only ``live-1`` is still tracked.
scheduler_requests = {live_req.request_id: live_req}
adapter.process_pending_chunks(waiting_queue, running_queue, scheduler_requests=scheduler_requests)
# 1. Zombie was popped from the deque.
assert zombie_req not in adapter.waiting_for_chunk_running_requests
# 2. Live request is still in the deque.
assert live_req in adapter.waiting_for_chunk_running_requests
# 3. ``cleanup_receiver`` ran for the zombie (drops origin-status mapping
# and registers the id as cancelled so a late load/poll is dropped too).
assert zombie_req.request_id not in adapter.requests_origin_status
assert zombie_req.request_id in adapter._cancelled_load_reqs
# 4. Live request's bookkeeping is untouched.
assert adapter.requests_origin_status[live_req.request_id] == RequestStatus.RUNNING
assert live_req.request_id not in adapter._cancelled_load_reqs
# 5. ``restore_queues`` (which the scheduler runs in its ``finally``
# clause) now only re-injects the live request -- the zombie is
# gone, so the worker's ``_update_states`` cannot crash on it.
adapter.restore_queues(waiting_queue, running_queue, scheduler_requests=scheduler_requests)
assert running_queue == [live_req]
def test_process_pending_chunks_purges_zombies_in_waiting_deque(build_adapter):
"""Zombie purge applies symmetrically to the waiting-side deque.
Regression for https://github.com/vllm-project/vllm-omni/issues/3736 --
aborted requests that landed in ``waiting_for_chunk_waiting_requests``
must also be removed before ``restore_queues`` re-injects them, since
``restore_queues`` uses ``waiting_queue.add_request`` rather than
``running_queue.extend`` for that path.
"""
adapter, _ = build_adapter(stage_id=1, max_num_seqs=8)
live_req = _req("live-w", RequestStatus.WAITING_FOR_CHUNK)
zombie_req = _req("zombie-w", RequestStatus.WAITING_FOR_CHUNK)
adapter.waiting_for_chunk_waiting_requests.append(live_req)
adapter.waiting_for_chunk_waiting_requests.append(zombie_req)
waiting_queue = DummyWaitingQueue()
running_queue: list = []
scheduler_requests = {live_req.request_id: live_req}
adapter.process_pending_chunks(waiting_queue, running_queue, scheduler_requests=scheduler_requests)
assert live_req in adapter.waiting_for_chunk_waiting_requests
assert zombie_req not in adapter.waiting_for_chunk_waiting_requests
assert zombie_req.request_id in adapter._cancelled_load_reqs
adapter.restore_queues(waiting_queue, running_queue, scheduler_requests=scheduler_requests)
assert waiting_queue == [live_req]
def test_purge_preserves_live_order_with_interleaved_zombies(build_adapter):
"""Interleaved live/zombie ordering -- pin the ``popleft`` + append-live
in-place filter at chunk_transfer_adapter._purge_untracked_chunk_requests:
survivor order must match insertion order, and ``cleanup_receiver`` must
run exactly once per zombie.
"""
adapter, _ = build_adapter(stage_id=1, max_num_seqs=8)
live1 = _req("live-1", RequestStatus.WAITING_FOR_CHUNK)
zombie1 = _req("zombie-1", RequestStatus.WAITING_FOR_CHUNK)
live2 = _req("live-2", RequestStatus.WAITING_FOR_CHUNK)
zombie2 = _req("zombie-2", RequestStatus.WAITING_FOR_CHUNK)
for req in (live1, zombie1, live2, zombie2):
adapter.waiting_for_chunk_running_requests.append(req)
waiting_queue = DummyWaitingQueue()
running_queue: list = []
scheduler_requests = {live1.request_id: live1, live2.request_id: live2}
adapter.process_pending_chunks(waiting_queue, running_queue, scheduler_requests=scheduler_requests)
assert list(adapter.waiting_for_chunk_running_requests) == [live1, live2]
assert zombie1.request_id in adapter._cancelled_load_reqs
assert zombie2.request_id in adapter._cancelled_load_reqs
def test_restore_queues_purges_late_aborts_after_process_pending_chunks(
build_adapter,
):
"""Race window guard: an abort can fire between ``process_pending_chunks``
and the scheduler's ``finally``-clause ``restore_queues`` call. The
second purge inside ``restore_queues`` must drop the now-untracked
request so it does not get re-injected onto ``running_queue``.
"""
adapter, _ = build_adapter(stage_id=1, max_num_seqs=8)
req = _req("late-abort", RequestStatus.WAITING_FOR_CHUNK)
adapter.waiting_for_chunk_running_requests.append(req)
waiting_queue = DummyWaitingQueue()
running_queue: list = []
# Tick N: process_pending_chunks runs while req is still tracked.
scheduler_requests: dict = {req.request_id: req}
adapter.process_pending_chunks(waiting_queue, running_queue, scheduler_requests=scheduler_requests)
assert req in adapter.waiting_for_chunk_running_requests
# Mid-tick: abort fires and the scheduler's free path deletes the entry.
del scheduler_requests[req.request_id]
# finally: restore_queues sees the now-untracked req and must drop it
# instead of blindly extending it onto running_queue.
adapter.restore_queues(waiting_queue, running_queue, scheduler_requests=scheduler_requests)
assert running_queue == []
assert req.request_id in adapter._cancelled_load_reqs
def test_purge_is_noop_on_empty_deques(build_adapter):
"""Empty deques short-circuit -- guards against any accidental
``IndexError`` from ``popleft`` on an empty deque if a future caller
shadows the empty check.
"""
adapter, _ = build_adapter(stage_id=1, max_num_seqs=8)
assert len(adapter.waiting_for_chunk_waiting_requests) == 0
assert len(adapter.waiting_for_chunk_running_requests) == 0
waiting_queue = DummyWaitingQueue()
running_queue: list = []
adapter.process_pending_chunks(waiting_queue, running_queue, scheduler_requests={})
assert len(adapter.waiting_for_chunk_waiting_requests) == 0
assert len(adapter.waiting_for_chunk_running_requests) == 0
adapter.restore_queues(waiting_queue, running_queue, scheduler_requests={})
assert running_queue == []
assert waiting_queue == []
+1 -45
View File
@@ -29,46 +29,7 @@ from tests.e2e.accuracy.helpers import (
)
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner, OmniServer
from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import (
build_prompt_tokens,
resolve_stop_token_ids,
)
# CoT structural markers that MUST appear in AR output text.
# The AR trajectory under think_recaption mode is:
# <think> ... </think> <recaption> ... <relation_1> ... </relation_1>
# <relation_2> ... </relation_2> </recaption>
# Test deploy config sets include_stop_str_in_output=True and
# skip_special_tokens=False, so ALL opening and closing markers
# should be present in the detokenized text.
_THINK_CLOSE = "</" + "think>" # avoid Python slash-t-as-TAB pitfall
_COT_REQUIRED_MARKERS = (
"<recaption>",
"</recaption>",
"<relation_1>",
"</relation_1>",
"<relation_2>",
"</relation_2>",
_THINK_CLOSE,
)
def _assert_cot_structural_markers(cot_text: str, label: str) -> None:
"""Assert that the AR CoT output contains all required structural tags.
Guards against regressions where detokenizer stop-token stripping
or truncation logic loses CoT content. Under the test deploy config
(include_stop_str_in_output=True, skip_special_tokens=False),
ALL markers should be present in the detokenized text.
"""
missing = [m for m in _COT_REQUIRED_MARKERS if m not in cot_text]
assert not missing, (
f"[{label}] CoT text missing required structural markers: {missing}. "
f"This typically indicates that stop-token stripping or "
f"_truncate_at_cot_end truncation removed CoT content. "
f"CoT text length={len(cot_text)}, first 100 chars: {repr(cot_text[:100])}"
)
from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import build_prompt_tokens, resolve_stop_token_ids
os.environ["DIFFUSION_ATTENTION_BACKEND"] = "TORCH_SDPA"
@@ -168,7 +129,6 @@ _DEPLOY_CONFIG = {
"stop_token_ids": [128025],
"detokenize": True,
"skip_special_tokens": False,
"include_stop_str_in_output": True,
},
},
{
@@ -440,8 +400,6 @@ def test_image_to_image_alignment_online(accuracy_artifact_root: Path, accuracy_
]
print("[ONLINE] " + tabulate(table, headers=["Metric", "Value", "L20x Reference"], tablefmt="grid"))
_assert_cot_structural_markers(online_cot, "ONLINE")
assert cot_results["cot_semantic_sim"] >= THRESHOLDS["cot_semantic_sim"], (
f"[ONLINE] COT semantic similarity {cot_results['cot_semantic_sim']:.4f} below threshold {THRESHOLDS['cot_semantic_sim']}"
)
@@ -550,8 +508,6 @@ def test_image_to_image_alignment(accuracy_artifact_root: Path, accuracy_assets_
print(tabulate(table, headers=["Metric", "Value", "L20x Reference"], tablefmt="grid"))
_assert_cot_structural_markers(omni_cot, "OFFLINE")
assert cot_results["cot_semantic_sim"] >= THRESHOLDS["cot_semantic_sim"], (
f"COT semantic similarity {cot_results['cot_semantic_sim']:.4f} is below threshold {THRESHOLDS['cot_semantic_sim']}"
)
@@ -1,273 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""End-to-end ModelOpt FP8 quality accuracy for video-gen DiTs.
Modeled on ``test_hunyuan_image3.py``'s ``test_quantized_dit_matches_bf16_accuracy``:
for each calibrated FP8 checkpoint the test generates one video with BF16 and one
with the FP8 DiT under the same seed, scores prompt-faithfulness with CLIP on the
middle frame, then asserts both ``CLIP >= absolute_floor`` and
``CLIP_drop <= drop_threshold``. The test is gated by an opt-in env var (model
paths are local) so it never runs in generic CI.
To run::
VIDEOGEN_RUN_QUANT_ACCURACY=1 \\
WAN22_A14B_BF16_MODEL=Wan-AI/Wan2.2-T2V-A14B-Diffusers \\
WAN22_A14B_FP8_MODEL=/path/to/wan22-a14b-modelopt-fp8 \\
HV15_BF16_MODEL=hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v \\
HV15_FP8_MODEL=/path/to/hv15-modelopt-fp8 \\
pytest -s -v tests/e2e/accuracy/test_videogen_modelopt_quant.py
"""
from __future__ import annotations
import gc
import json
import os
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
import pytest
import torch
from PIL import Image
from tests.e2e.accuracy.helpers import CLIPScorer, model_output_dir
pytestmark = [pytest.mark.local_model, pytest.mark.diffusion]
# ---------------------------------------------------------------------------
# Tunables (override via env to suit local hardware / calibration budget)
# ---------------------------------------------------------------------------
SEED = 42
HEIGHT = int(os.environ.get("VIDEOGEN_QUANT_HEIGHT", "480"))
WIDTH = int(os.environ.get("VIDEOGEN_QUANT_WIDTH", "832"))
NUM_FRAMES = int(os.environ.get("VIDEOGEN_QUANT_NUM_FRAMES", "17"))
NUM_INFERENCE_STEPS = int(os.environ.get("VIDEOGEN_QUANT_STEPS", "20"))
WAN_PROMPT = "A red fox running through a snowy forest at sunrise, cinematic."
HV_PROMPT = "A red fox running through a snowy forest at sunrise, cinematic."
# CLIP gates: absolute floor + max drop from the BF16 reference.
CLIP_ABSOLUTE_FLOOR = float(os.environ.get("VIDEOGEN_QUANT_CLIP_FLOOR", "20.0"))
CLIP_DROP_FP8 = float(os.environ.get("VIDEOGEN_QUANT_CLIP_DROP_FP8", "7.0"))
RUN_ENV = "VIDEOGEN_RUN_QUANT_ACCURACY"
_TRUE = {"1", "true", "yes", "on"}
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class _Spec:
"""A model under test (BF16 + its FP8 variant)."""
family: str
bf16_env: str
fp8_env: str
prompt: str
guidance_scale: float
guidance_scale_2: float | None = None
boundary_ratio: float | None = None
WAN_SPEC = _Spec(
family="wan22_a14b_t2v",
bf16_env="WAN22_A14B_BF16_MODEL",
fp8_env="WAN22_A14B_FP8_MODEL",
prompt=WAN_PROMPT,
guidance_scale=3.5,
guidance_scale_2=4.0,
boundary_ratio=0.875,
)
HV_SPEC = _Spec(
family="hunyuanvideo15_t2v",
bf16_env="HV15_BF16_MODEL",
fp8_env="HV15_FP8_MODEL",
prompt=HV_PROMPT,
guidance_scale=6.0,
)
@dataclass(frozen=True)
class _QuantCase:
name: str
spec: _Spec
clip_drop: float
def _cases() -> list[pytest.ParameterSet]:
cases = [
_QuantCase("wan_fp8", WAN_SPEC, CLIP_DROP_FP8),
_QuantCase("hv_fp8", HV_SPEC, CLIP_DROP_FP8),
]
params: list[pytest.ParameterSet] = []
opted_in = os.environ.get(RUN_ENV, "").lower() in _TRUE
for case in cases:
marks: list[Any] = []
if not opted_in:
marks.append(pytest.mark.skip(reason=f"Set {RUN_ENV}=1 to run video-gen quant accuracy."))
if not os.environ.get(case.spec.bf16_env):
marks.append(pytest.mark.skip(reason=f"Set {case.spec.bf16_env} to run {case.name}."))
if not os.environ.get(case.spec.fp8_env):
marks.append(pytest.mark.skip(reason=f"Set {case.spec.fp8_env} to run {case.name}."))
params.append(pytest.param(case, id=case.name, marks=marks))
return params
# ---------------------------------------------------------------------------
def _middle_frame(frames: list[Image.Image] | list[np.ndarray] | np.ndarray) -> Image.Image:
if isinstance(frames, np.ndarray):
idx = frames.shape[0] // 2
arr = frames[idx]
if arr.dtype != np.uint8:
arr = (arr * 255).clip(0, 255).astype(np.uint8)
return Image.fromarray(arr).convert("RGB")
if not frames:
raise AssertionError("Empty video output")
mid = frames[len(frames) // 2]
if isinstance(mid, np.ndarray):
if mid.dtype != np.uint8:
mid = (mid * 255).clip(0, 255).astype(np.uint8)
return Image.fromarray(mid).convert("RGB")
return mid.convert("RGB")
def _extract_frames(result) -> list[Image.Image]:
"""Pull a frame list out of an Omni text-to-video result."""
from vllm_omni.entrypoints.omni import OmniRequestOutput
if isinstance(result, list):
result = result[0] if result else None
if isinstance(result, OmniRequestOutput) and result.is_pipeline_output and result.request_output is not None:
result = result.request_output
images = getattr(result, "images", None)
if not images:
raise AssertionError("Pipeline output had no images")
if isinstance(images, list) and images and isinstance(images[0], tuple):
# (frames, fps)
images = images[0][0]
return list(images)
def _generate_video(
*,
model: str,
spec: _Spec,
quantization: str | None,
output_path: Path,
) -> tuple[Image.Image, float]:
"""Generate one video and return its middle frame + elapsed wall time."""
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
kwargs: dict[str, Any] = dict(
model=model,
parallel_config=DiffusionParallelConfig(),
enforce_eager=True,
vae_use_tiling=True,
)
if quantization:
kwargs["quantization"] = quantization
if spec.boundary_ratio is not None:
kwargs["boundary_ratio"] = spec.boundary_ratio
omni = Omni(**kwargs)
try:
gen = torch.Generator(device=current_omni_platform.device_type).manual_seed(SEED)
sampling = dict(
height=HEIGHT,
width=WIDTH,
generator=gen,
guidance_scale=spec.guidance_scale,
num_inference_steps=NUM_INFERENCE_STEPS,
num_frames=NUM_FRAMES,
seed=SEED,
)
if spec.guidance_scale_2 is not None:
sampling["guidance_scale_2"] = spec.guidance_scale_2
import time as _time
t0 = _time.perf_counter()
out = omni.generate({"prompt": spec.prompt}, OmniDiffusionSamplingParams(**sampling))
elapsed = _time.perf_counter() - t0
frames = _extract_frames(out)
mid = _middle_frame(frames)
mid.save(output_path)
return mid, elapsed
finally:
del omni
gc.collect()
if torch.cuda.is_available():
torch.accelerator.empty_cache()
@pytest.mark.parametrize("case", _cases())
@pytest.mark.skipif(
not torch.cuda.is_available() or torch.accelerator.device_count() < 1,
reason="Needs at least 1 GPU.",
)
def test_quantized_videogen_dit_matches_bf16_clip(case: _QuantCase, accuracy_artifact_root: Path) -> None:
"""FP8 DiT should preserve prompt-aligned video quality vs BF16."""
out_dir = model_output_dir(accuracy_artifact_root, case.spec.family + "-quant")
with tempfile.TemporaryDirectory():
bf16_model = os.environ[case.spec.bf16_env]
quant_model = os.environ[case.spec.fp8_env]
bf16_frame, bf16_time = _generate_video(
model=bf16_model,
spec=case.spec,
quantization=None,
output_path=out_dir / "bf16.png",
)
quant_frame, quant_time = _generate_video(
model=quant_model,
spec=case.spec,
quantization="fp8",
output_path=out_dir / f"{case.name}.png",
)
clip = CLIPScorer()
bf16_clip = clip.score(bf16_frame, case.spec.prompt)
quant_clip = clip.score(quant_frame, case.spec.prompt)
clip_drop = bf16_clip - quant_clip
speedup = (bf16_time / quant_time) if quant_time else float("nan")
metrics = {
"case": case.name,
"family": case.spec.family,
"quantization": "fp8",
"bf16_model": bf16_model,
"quant_model": quant_model,
"prompt": case.spec.prompt,
"height": HEIGHT,
"width": WIDTH,
"num_frames": NUM_FRAMES,
"num_inference_steps": NUM_INFERENCE_STEPS,
"bf16_elapsed_s": bf16_time,
"quant_elapsed_s": quant_time,
"speedup_vs_bf16": speedup,
"bf16_clip_score": bf16_clip,
"quant_clip_score": quant_clip,
"clip_score_drop": clip_drop,
}
metrics_path = out_dir / f"{case.name}_metrics.json"
metrics_path.write_text(json.dumps(metrics, indent=2, sort_keys=True), encoding="utf-8")
print(f"\n[{case.name}] BF16 {bf16_time:.2f}s | FP8 {quant_time:.2f}s (x{speedup:.2f})")
print(f" CLIP BF16={bf16_clip:.2f} FP8={quant_clip:.2f} drop={clip_drop:.2f}")
print(f" metrics={metrics_path}")
assert quant_clip >= CLIP_ABSOLUTE_FLOOR, (
f"{case.name} CLIP below floor: got {quant_clip:.2f}, expected >= {CLIP_ABSOLUTE_FLOOR:.2f}"
)
assert clip_drop <= case.clip_drop, (
f"{case.name} CLIP drop too large: got {clip_drop:.2f}, expected <= {case.clip_drop:.2f}"
)
@@ -51,7 +51,7 @@ test_params = [(m, c) for m in models for c in [get_eager_config()]]
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=4)
@hardware_test(res={"cuda": "H100"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", test_params_thinker, indirect=True)
def test_text_to_text(omni_runner, omni_runner_handler) -> None:
"""
@@ -67,7 +67,7 @@ def test_text_to_text(omni_runner, omni_runner_handler) -> None:
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=4)
@hardware_test(res={"cuda": "H100"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", test_params_thinker, indirect=True)
def test_image_to_text(omni_runner, omni_runner_handler) -> None:
"""
@@ -84,7 +84,7 @@ def test_image_to_text(omni_runner, omni_runner_handler) -> None:
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=4)
@hardware_test(res={"cuda": "H100"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", test_params_thinker, indirect=True)
def test_audio_to_text(omni_runner, omni_runner_handler) -> None:
"""
@@ -103,7 +103,7 @@ def test_audio_to_text(omni_runner, omni_runner_handler) -> None:
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=4)
@hardware_test(res={"cuda": "H100"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", test_params_thinker, indirect=True)
def test_video_to_text(omni_runner, omni_runner_handler) -> None:
"""
@@ -120,7 +120,7 @@ def test_video_to_text(omni_runner, omni_runner_handler) -> None:
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=4)
@hardware_test(res={"cuda": "H100"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", test_params_thinker, indirect=True)
def test_mixed_to_text(omni_runner, omni_runner_handler) -> None:
"""
@@ -140,7 +140,7 @@ def test_mixed_to_text(omni_runner, omni_runner_handler) -> None:
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=4)
@hardware_test(res={"cuda": "H100"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_text_to_audio(omni_runner, omni_runner_handler) -> None:
"""
@@ -156,7 +156,7 @@ def test_text_to_audio(omni_runner, omni_runner_handler) -> None:
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=4)
@hardware_test(res={"cuda": "H100"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_image_to_audio(omni_runner, omni_runner_handler) -> None:
"""
@@ -1,295 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""End-to-end offline inference tests for Ming-omni-tts."""
import asyncio
import uuid
import numpy as np
import pytest
import torch
from transformers import AutoTokenizer
from vllm import SamplingParams
from vllm.inputs import tokens_input
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from tests.helpers.stage_config import get_deploy_config_path
from vllm_omni import AsyncOmni
from vllm_omni.model_executor.models.ming_tts.config_ming_tts import (
KEY_MAX_DECODE_STEPS,
SAMPLE_RATE,
TEXT_EOS_TOKEN_ID,
)
from vllm_omni.model_executor.models.ming_tts.prompt_assembly import DEFAULT_PROMPT, build_ming_dense_prompt
MODEL = "inclusionAI/Ming-omni-tts-0.5B"
DEPLOY_CONFIG = get_deploy_config_path("ming_tts.yaml")
TEST_TEXT = "我会一直在这里陪着你,直到你慢慢地沉入那个最温柔的梦里。"
TEST_INSTRUCTION = "轻柔的ASMR耳语,慢速,贴近麦克风"
MIN_AUDIO_SAMPLES = 1000
@pytest.fixture(scope="module")
def ming_tokenizer():
return AutoTokenizer.from_pretrained(MODEL, trust_remote_code=False)
@pytest.fixture
def ming_engine():
with OmniRunner(
MODEL,
deploy_config=DEPLOY_CONFIG,
stage_init_timeout=300,
enforce_eager=True,
) as runner:
yield runner.omni
@pytest.fixture
def async_omni_engine():
engine = AsyncOmni(
model=MODEL,
deploy_config=DEPLOY_CONFIG,
stage_init_timeout=300,
enforce_eager=True,
)
yield engine
engine.shutdown()
def _build_prompt(
tokenizer,
*,
text: str = TEST_TEXT,
instruction=TEST_INSTRUCTION,
use_zero_spk_emb: bool = True,
) -> dict:
prompt_dict = build_ming_dense_prompt(
tokenizer,
prompt=DEFAULT_PROMPT,
text=text,
instruction=instruction,
runtime_controls={KEY_MAX_DECODE_STEPS: 200},
use_zero_spk_emb=use_zero_spk_emb,
)
prompt = tokens_input(prompt_token_ids=prompt_dict["prompt_token_ids"])
prompt["prompt"] = prompt_dict["prompt"]
prompt["text"] = prompt_dict["text"]
prompt["additional_information"] = prompt_dict["additional_information"]
return prompt
def _sampling_params_list() -> list[SamplingParams]:
return [
SamplingParams(
temperature=0.0,
max_tokens=201,
stop_token_ids=[int(TEXT_EOS_TOKEN_ID)],
),
SamplingParams(temperature=0.0, max_tokens=1),
]
def _flatten_audio(audio) -> torch.Tensor:
if isinstance(audio, list):
parts = [torch.as_tensor(item, dtype=torch.float32).reshape(-1).cpu() for item in audio]
parts = [item for item in parts if item.numel() > 0]
if not parts:
return torch.zeros((0,), dtype=torch.float32)
return torch.cat(parts, dim=0)
return torch.as_tensor(audio, dtype=torch.float32).reshape(-1).cpu()
def _extract_audio(multimodal_output: dict) -> torch.Tensor:
assert isinstance(multimodal_output, dict), f"Expected dict, got {type(multimodal_output)}"
audio = multimodal_output.get("audio", multimodal_output.get("model_outputs"))
assert audio is not None, f"No audio output found, keys={list(multimodal_output.keys())}"
waveform = _flatten_audio(audio)
if waveform.numel() == 0:
raise RuntimeError("Generated audio waveform is empty")
return waveform
def _extract_sample_rate(multimodal_output: dict) -> int:
sample_rate = multimodal_output.get("sr")
if sample_rate is None:
raise RuntimeError("Expected multimodal_output['sr']")
if isinstance(sample_rate, list):
sample_rate = sample_rate[-1]
if hasattr(sample_rate, "item"):
sample_rate = sample_rate.item()
return int(sample_rate)
def _extract_final_audio_outputs(outputs):
final_outputs = []
for item in outputs:
if getattr(item, "final_output_type", None) == "audio":
final_outputs.append(item)
continue
request_output = getattr(item, "request_output", None)
if request_output is None:
continue
multimodal_output = getattr(request_output, "multimodal_output", None)
if isinstance(multimodal_output, dict):
final_outputs.append(item)
continue
completions = getattr(request_output, "outputs", None) or []
if any(isinstance(getattr(completion, "multimodal_output", None), dict) for completion in completions):
final_outputs.append(item)
return final_outputs
def _extract_multimodal_output(output) -> dict:
multimodal_output = getattr(output, "multimodal_output", None)
if isinstance(multimodal_output, dict):
return multimodal_output
request_output = getattr(output, "request_output", None)
if request_output is not None:
multimodal_output = getattr(request_output, "multimodal_output", None)
if isinstance(multimodal_output, dict):
return multimodal_output
completions = getattr(request_output, "outputs", None) or []
for completion in completions:
multimodal_output = getattr(completion, "multimodal_output", None)
if isinstance(multimodal_output, dict):
return multimodal_output
raise AssertionError("No multimodal audio output found in Ming generate results")
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_ming_tts_offline_basic(ming_engine, ming_tokenizer) -> None:
"""Test blocking Ming generation through Omni."""
outputs = ming_engine.generate(
prompts=[_build_prompt(ming_tokenizer)],
sampling_params_list=_sampling_params_list(),
py_generator=False,
)
final_outputs = _extract_final_audio_outputs(outputs)
assert len(final_outputs) == 1, f"Expected one final audio output, got {len(final_outputs)}"
multimodal_output = _extract_multimodal_output(final_outputs[0])
waveform = _extract_audio(multimodal_output)
sample_rate = _extract_sample_rate(multimodal_output)
assert waveform.ndim == 1
assert waveform.shape[0] == waveform.numel()
assert waveform.numel() > MIN_AUDIO_SAMPLES
assert np.max(np.abs(waveform.numpy())) > 0.01, "Audio appears silent"
assert sample_rate == SAMPLE_RATE, f"Expected Ming output sample rate {SAMPLE_RATE}, got {sample_rate}"
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_ming_tts_speaker_conditioning_differs(ming_engine, ming_tokenizer) -> None:
"""Test that different Ming speaker controls produce different waveform outputs."""
style_outputs = ming_engine.generate(
prompts=[_build_prompt(ming_tokenizer)],
sampling_params_list=_sampling_params_list(),
py_generator=False,
)
ip_outputs = ming_engine.generate(
prompts=[_build_prompt(ming_tokenizer, text=TEST_TEXT, instruction={"IP": "灵小甄"}, use_zero_spk_emb=True)],
sampling_params_list=_sampling_params_list(),
py_generator=False,
)
style_final_outputs = _extract_final_audio_outputs(style_outputs)
ip_final_outputs = _extract_final_audio_outputs(ip_outputs)
assert len(style_final_outputs) == 1, "No style audio output produced"
assert len(ip_final_outputs) == 1, "No IP audio output produced"
style_waveform = _extract_audio(_extract_multimodal_output(style_final_outputs[0]))
ip_waveform = _extract_audio(_extract_multimodal_output(ip_final_outputs[0]))
assert style_waveform.numel() > MIN_AUDIO_SAMPLES
assert ip_waveform.numel() > MIN_AUDIO_SAMPLES
assert np.max(np.abs(style_waveform.numpy())) > 0.01, "Style audio appears silent"
assert np.max(np.abs(ip_waveform.numpy())) > 0.01, "IP audio appears silent"
overlap = min(int(style_waveform.numel()), int(ip_waveform.numel()))
mean_abs_diff = torch.mean(torch.abs(style_waveform[:overlap] - ip_waveform[:overlap])).item()
assert style_waveform.shape != ip_waveform.shape or mean_abs_diff > 1e-4, (
"Speaker-conditioned outputs should differ, but style and IP waveforms were effectively identical"
)
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_ming_tts_multiple_prompts_queued(ming_engine, ming_tokenizer) -> None:
"""Regression: supported max_num_seqs=1 config must still drain queued prompts."""
prompts = [
_build_prompt(
ming_tokenizer,
text="第一条语音用于验证 Ming dense 队列中的长请求可以完成。",
instruction="平静自然的旁白,语速中等",
),
_build_prompt(
ming_tokenizer,
text="第二条短语音也必须生成完成。",
instruction="清晰明亮的女声",
),
]
outputs = ming_engine.generate(
prompts=prompts,
sampling_params_list=_sampling_params_list(),
py_generator=False,
)
final_outputs = _extract_final_audio_outputs(outputs)
assert len(final_outputs) == len(prompts), f"Expected {len(prompts)} audio outputs, got {len(final_outputs)}"
for i, output in enumerate(final_outputs):
waveform = _extract_audio(_extract_multimodal_output(output))
duration_s = waveform.numel() / SAMPLE_RATE
assert 0.1 < duration_s < 30.0, f"Request {i} audio duration out of range: {duration_s:.2f}s"
assert np.max(np.abs(waveform.numpy())) > 0.01, f"Request {i} audio appears silent"
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_ming_tts_offline_streaming(async_omni_engine, ming_tokenizer) -> None:
"""Test async_chunk streaming Ming generation through AsyncOmni."""
async def _run() -> None:
all_audio_chunks = []
accumulated_samples = 0
chunk_idx = 0
sample_rate = None
async for stage_output in async_omni_engine.generate(
prompt=_build_prompt(ming_tokenizer),
request_id=str(uuid.uuid4()),
sampling_params_list=_sampling_params_list(),
):
multimodal_output = stage_output.multimodal_output or {}
audio = multimodal_output.get("audio")
if "sr" in multimodal_output:
sample_rate = _extract_sample_rate(multimodal_output)
if audio is None:
continue
finished = stage_output.finished
if isinstance(audio, torch.Tensor):
if finished:
audio_chunk = audio[accumulated_samples:].float().detach().cpu()
else:
audio_chunk = audio.float().detach().cpu()
elif isinstance(audio, list):
audio_chunk = _flatten_audio(audio)
else:
audio_chunk = torch.as_tensor(audio, dtype=torch.float32).reshape(-1).cpu()
accumulated_samples += int(audio_chunk.numel())
chunk_idx += 1
if audio_chunk.numel() > 0:
all_audio_chunks.append(audio_chunk)
assert all_audio_chunks, "No streaming audio chunks received"
waveform = torch.cat(all_audio_chunks, dim=0)
assert waveform.numel() > MIN_AUDIO_SAMPLES
assert np.max(np.abs(waveform.numpy())) > 0.01, "Audio appears silent"
assert sample_rate is not None, "Streaming path did not return a sample rate"
assert sample_rate == SAMPLE_RATE, f"Expected Ming output sample rate {SAMPLE_RATE}, got {sample_rate}"
asyncio.run(_run())
@@ -1,160 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Expanded end-to-end test for SenseNova-U1 in offline mode.
This test file primarily covers end-to-end tests for LoRA support.
Validates that LoRA adapters are correctly loaded, applied with controllable
scale, and cleanly deactivated. Uses a synthetic rank-4 adapter targeting the
first decoder layer's gen-path (``_mot_gen``) output projection, so the
perturbation is exercised during the denoising loop.
Assertions:
(a) LoRA at scale=1.0 visibly changes the output (diff > 0.5)
(b) scale=2.0 produces a different delta than scale=1.0 (scale sensitivity)
(c) The delta is bounded (not corrupted)
(d) Deactivating LoRA exactly restores the baseline (diff == 0)
Observed on H100 (seed=42, 8 steps, 512x512): diff_1x~62.2, diff_2x~76.9,
diff_restored=0.0. Thresholds match test_bagel_expansion.py and hold with margin.
"""
import json
import os
from pathlib import Path
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
import numpy as np
import pytest
import torch
from PIL import Image
from safetensors.torch import save_file
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.lora.request import LoRARequest
from vllm_omni.lora.utils import stable_lora_int_id
MODEL = "SenseNova/SenseNova-U1-8B-MoT"
DEFAULT_PROMPT = "a photo of a cat sitting on a laptop keyboard"
SIZE = (512, 512) # (width, height); small for fast E2E
# SenseNova-U1-8B-MoT hidden_size (Qwen3-based). o_proj is square (hidden x hidden)
# regardless of GQA, so this is the robust LoRA target. Verify against config.json.
_LORA_DIM = 4096
_LORA_MODULE = "language_model.model.layers.0.self_attn.o_proj_mot_gen"
_LORA_RANK = 4
# ---------------------------------------------------------------------------
# Helpers (reused from test_sensenova_u1_text2img.py patterns)
# ---------------------------------------------------------------------------
def _build_sampling_params(
lora_request: LoRARequest | None = None,
lora_scale: float = 1.0,
) -> OmniDiffusionSamplingParams:
return OmniDiffusionSamplingParams(
height=SIZE[1],
width=SIZE[0],
seed=42,
num_inference_steps=8,
extra_args={
"cfg_scale": 4.0,
"cfg_norm": "none",
"timestep_shift": 3.0,
"cfg_interval": (0.0, 1.0),
"batch_size": 1,
"think": False,
"t_eps": 0.02,
},
lora_request=lora_request,
lora_scale=lora_scale,
)
def _extract_generated_image(omni_outputs: list) -> Image.Image | None:
for req_output in omni_outputs:
if images := getattr(req_output, "images", None):
return images[0]
return None
def _generate(omni: Omni, lora_request: LoRARequest | None = None, lora_scale: float = 1.0) -> Image.Image:
outputs = list(
omni.generate(
prompts={"prompt": DEFAULT_PROMPT, "modalities": ["image"]},
sampling_params_list=_build_sampling_params(lora_request, lora_scale),
)
)
img = _extract_generated_image(outputs)
assert img is not None, "No image generated"
return img
def _make_file_lora_request(adapter_dir: Path) -> LoRARequest:
"""Write synthetic adapter to disk and return a file-backed LoRARequest."""
adapter_dir.mkdir(parents=True, exist_ok=True)
gen = torch.Generator().manual_seed(42)
lora_a = torch.randn((_LORA_RANK, _LORA_DIM), dtype=torch.float32, generator=gen) * 0.1
lora_b = torch.randn((_LORA_DIM, _LORA_RANK), dtype=torch.float32, generator=gen) * 0.5
save_file(
{
f"base_model.model.{_LORA_MODULE}.lora_A.weight": lora_a,
f"base_model.model.{_LORA_MODULE}.lora_B.weight": lora_b,
},
str(adapter_dir / "adapter_model.safetensors"),
)
(adapter_dir / "adapter_config.json").write_text(
json.dumps({"r": _LORA_RANK, "lora_alpha": _LORA_RANK, "target_modules": [_LORA_MODULE]}),
encoding="utf-8",
)
lora_dir = str(adapter_dir)
return LoRARequest(lora_name="test_file", lora_int_id=stable_lora_int_id(lora_dir), lora_path=lora_dir)
# ---------------------------------------------------------------------------
# Test
# ---------------------------------------------------------------------------
@pytest.mark.full_model
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100"})
def test_sensenova_u1_lora_scale_and_deactivation(tmp_path) -> None:
"""Validate LoRA effect, scale sensitivity, bounded perturbation, and clean deactivation."""
lora_request = _make_file_lora_request(tmp_path / "sensenova_lora")
with OmniRunner(MODEL, stage_configs_path=None) as runner:
omni = runner.omni
baseline = _generate(omni)
img_1x = _generate(omni, lora_request, lora_scale=1.0)
img_2x = _generate(omni, lora_request, lora_scale=2.0)
restored = _generate(omni)
baseline_arr = np.array(baseline, dtype=np.int16)
diff_1x = np.abs(baseline_arr - np.array(img_1x, dtype=np.int16)).mean()
diff_2x = np.abs(baseline_arr - np.array(img_2x, dtype=np.int16)).mean()
diff_restored = np.abs(baseline_arr - np.array(restored, dtype=np.int16)).mean()
# (a) Adapter has visible effect at both scales
assert diff_1x > 0.5, f"LoRA scale=1.0 had no visible effect: diff={diff_1x}"
assert diff_2x > 0.5, f"LoRA scale=2.0 had no visible effect: diff={diff_2x}"
# (b) Different scales produce different outputs
assert not np.isclose(diff_1x, diff_2x, atol=1.0), (
f"LoRA scale has no effect: diff_1x={diff_1x:.2f}, diff_2x={diff_2x:.2f}"
)
# (c) Output is not corrupted
assert diff_1x < 80, f"LoRA output looks corrupted: diff_1x={diff_1x}"
assert diff_2x < 120, f"LoRA output looks corrupted: diff_2x={diff_2x}"
# (d) Deactivation fully restores base model
assert diff_restored == 0.0, f"Base model not restored after LoRA deactivation: diff={diff_restored}"
-92
View File
@@ -1,92 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
E2E online serving tests for Ming-omni-tts (dense) model.
Tests text-to-audio via /v1/audio/speech endpoint.
"""
import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
import pytest
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniServerParams
from tests.helpers.stage_config import get_deploy_config_path
pytestmark = [pytest.mark.advanced_model, pytest.mark.omni]
MODEL = "inclusionAI/Ming-omni-tts-0.5B"
DEPLOY_CONFIG = get_deploy_config_path("ming_tts.yaml")
no_async_chunk_params = [
pytest.param(
OmniServerParams(
model=MODEL,
stage_config_path=DEPLOY_CONFIG,
server_args=["--enforce-eager", "--no-async-chunk"],
),
id="no_async_chunk",
)
]
async_chunk_params = [
pytest.param(
OmniServerParams(
model=MODEL,
stage_config_path=DEPLOY_CONFIG,
server_args=["--enforce-eager"],
),
id="async_chunk",
)
]
def get_prompt(prompt_type="zh"):
prompts = {
"zh": "今天天气真不错,适合出去散散步。",
"zh_short": "这款产品的名字,叫变态坑爹牛肉丸。",
}
return prompts.get(prompt_type, prompts["zh"])
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", no_async_chunk_params, indirect=True)
def test_text_to_audio_non_streaming_001(omni_server, openai_client) -> None:
"""
Deploy Setting: ming_tts.yaml with --no-async-chunk
Input Modal: text
Output Modal: audio
Input Setting: stream=False
Datasets: two concurrent requests
"""
request_config = {
"model": omni_server.model,
"input": get_prompt("zh"),
"stream": False,
"response_format": "wav",
"timeout": 300.0,
}
openai_client.send_audio_speech_request(request_config, request_num=2)
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", async_chunk_params, indirect=True)
def test_text_to_audio_streaming_001(omni_server, openai_client) -> None:
"""
Deploy Setting: ming_tts.yaml (async_chunk=true)
Input Modal: text + voice
Output Modal: audio (streamed)
Input Setting: stream=True
Datasets: single request
"""
request_config = {
"model": omni_server.model,
"input": get_prompt("zh_short"),
"voice": "灵小甄",
"stream": True,
"response_format": "wav",
"timeout": 300.0,
}
openai_client.send_audio_speech_request(request_config)
@@ -91,90 +91,3 @@ class TestVoxtralTTSFixedVoice:
"timeout": 120.0,
}
)
@pytest.mark.advanced_model
@pytest.mark.tts
@hardware_test(res={"cuda": "H100"}, num_cards=1)
def test_speech_speed(self, omni_server, openai_client) -> None:
"""Request with speed parameters"""
speeds = [0.5, 1, 1.5, 2, 2.5]
for speed in speeds:
openai_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": "The boy was there when the sun rose.",
"voice": "casual_female",
"language": "English",
"response_format": "wav",
"timeout": 120.0,
"speed": speed,
}
)
@pytest.mark.advanced_model
@pytest.mark.tts
@hardware_test(res={"cuda": "H100"}, num_cards=1)
def test_speech_instructions(self, omni_server, openai_client) -> None:
"""Request with instructions parameters"""
instructions = [
"Speak formally",
"Speak angrily",
"Deliver with a sad voice",
"Speak with a chirpy happy voice",
]
for instruction in instructions:
openai_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": "The boy was there when the sun rose.",
"voice": "casual_female",
"language": "English",
"response_format": "wav",
"timeout": 120.0,
"instructions": instruction,
}
)
@pytest.mark.advanced_model
@pytest.mark.tts
@hardware_test(res={"cuda": "H100"}, num_cards=1)
def test_speech_response_formats(self, omni_server, openai_client) -> None:
"""Test TTS with different response formats"""
response_formats = ["wav", "mp3"]
for response_format in response_formats:
openai_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": "Testing various response formats.",
"voice": "casual_male",
"language": "English",
"response_format": response_format,
"timeout": 120.0,
}
)
@pytest.mark.advanced_model
@pytest.mark.tts
@hardware_test(res={"cuda": "H100"}, num_cards=1)
def test_speech_batches(self, omni_server, openai_client) -> None:
"""Test TTS batches"""
items = [
{"input": "The birch canoe slid on the smooth planks."},
{"input": "Glue the sheet to the dark blue background."},
{"input": "It's easy to tell the depth of a well."},
{"input": "These days a chicken leg is a rare dish."},
{"input": "Rice is often served in round bowls."},
]
openai_client.send_audio_speech_batch_http_request(
{
"json": {
"model": omni_server.model,
"items": items,
"voice": "casual_male",
"language": "English",
"response_format": "wav",
},
"timeout": 120.0,
}
)
@@ -33,7 +33,6 @@ from vllm_omni.entrypoints.openai.protocol.audio import (
SpeechBatchItem,
)
from vllm_omni.entrypoints.openai.serving_speech import (
_TTS_LANGUAGES,
OmniOpenAIServingSpeech,
_create_wav_header,
)
@@ -1258,67 +1257,6 @@ class TestTTSMethods:
# Verify speakers are normalized to lowercase
assert server.supported_speakers == {"ryan", "vivian", "aiden"}
def test_load_supported_languages_from_config(self, speech_server):
"""Languages/dialects from codec_language_id are loaded title-cased; 'Auto' is added."""
speech_server._tts_model_type = "qwen3_tts"
speech_server.engine_client.model_config = SimpleNamespace(
hf_config=SimpleNamespace(
talker_config=SimpleNamespace(
codec_language_id={"chinese": 2055, "english": 2050, "beijing_dialect": 2074}
)
)
)
assert speech_server._load_supported_languages() == {
"Chinese",
"English",
"Beijing_Dialect",
"Auto",
}
def test_load_supported_languages_from_dict_config(self, speech_server):
"""talker_config provided as a plain dict is handled the same as an object."""
speech_server._tts_model_type = "qwen3_tts"
speech_server.engine_client.model_config = SimpleNamespace(
hf_config=SimpleNamespace(talker_config={"codec_language_id": {"chinese": 2055, "english": 2050}})
)
assert speech_server._load_supported_languages() == {"Chinese", "English", "Auto"}
def test_validate_language_custom_dialect_accepted(self, speech_server):
"""A language present in the model config passes validation, case-insensitively."""
speech_server.supported_languages = {"Chinese", "English", "Beijing_Dialect", "Auto"}
for language in ("Beijing_Dialect", "beijing_dialect", "English", "english", "Auto", "AUTO"):
req = OpenAICreateSpeechRequest(input="Hello", language=language)
result = speech_server._validate_tts_request(req)
assert result is None or "Invalid language" not in result
# Language is normalized to the title-cased config form.
assert req.language == language.title()
def test_validate_language_unknown_rejected(self, speech_server):
"""A language not in the configured set is rejected."""
speech_server.supported_languages = {"Chinese", "English", "Auto"}
for language in ("Klingon", "klingon"):
req = OpenAICreateSpeechRequest(input="Hello", language=language)
assert "Invalid language" in speech_server._validate_tts_request(req)
def test_load_supported_languages_default_when_no_config(self, speech_server):
"""Empty/missing codec_language_id on a Qwen3-TTS model falls back to the default list."""
speech_server._tts_model_type = "qwen3_tts"
speech_server.engine_client.model_config = SimpleNamespace(
hf_config=SimpleNamespace(talker_config=SimpleNamespace(codec_language_id={}))
)
assert speech_server._load_supported_languages() == _TTS_LANGUAGES
def test_load_supported_languages_default_on_config_error(self, speech_server):
"""If the model config cannot be read, fall back to the default list."""
speech_server._tts_model_type = "qwen3_tts"
speech_server.engine_client = SimpleNamespace() # no model_config -> AttributeError
assert speech_server._load_supported_languages() == _TTS_LANGUAGES
def test_load_supported_languages_default_for_non_qwen(self, speech_server):
"""Non-qwen3_tts model types get the default language set."""
speech_server._tts_model_type = None
assert speech_server._load_supported_languages() == _TTS_LANGUAGES
def test_build_tts_params_with_uploaded_voice(self, speech_server, mocker: MockerFixture):
"""Test _build_tts_params auto-sets ref_audio for uploaded voices (x_vector only)."""
speech_server.uploaded_speakers = {
@@ -13,14 +13,12 @@ import threading
import time
from types import SimpleNamespace
import numpy as np
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from PIL import Image
from pytest_mock import MockerFixture
from vllm_omni.diffusion.utils.media_utils import mux_video_audio_bytes
from vllm_omni.entrypoints.openai import api_server
from vllm_omni.entrypoints.openai.api_server import router
from vllm_omni.entrypoints.openai.protocol.videos import (
@@ -86,8 +84,7 @@ class BlockingVideoHandler:
if self.stage_configs is None:
self.stage_configs = stage_configs
async def generate_video_bytes(self, request, reference_id, *, reference_image=None, reference_video=None):
del request, reference_id, reference_image, reference_video
async def generate_video_bytes(self, request, reference_id, *, reference_image=None):
self.started.set()
try:
await asyncio.Future()
@@ -133,30 +130,6 @@ def _make_test_image_data_url(size=(64, 64)) -> str:
return f"data:image/png;base64,{encoded}"
def _make_test_video_bytes(size=(32, 24), num_frames=3) -> bytes:
width, height = size
frames = np.zeros((num_frames, height, width, 3), dtype=np.uint8)
for idx in range(num_frames):
frames[idx, :, :, 0] = idx * 40
frames[idx, :, :, 1] = 128
frames[idx, :, :, 2] = 255 - idx * 40
return mux_video_audio_bytes(frames, fps=8, video_codec_options={"preset": "ultrafast", "threads": "0"})
def _make_test_video_data_url(size=(32, 24), num_frames=3) -> str:
encoded = base64.b64encode(_make_test_video_bytes(size, num_frames)).decode("utf-8")
return f"data:video/mp4;base64,{encoded}"
def _cosmos3_stage_configs():
return [
SimpleNamespace(
stage_type="diffusion",
engine_args=SimpleNamespace(model_class_name="Cosmos3OmniDiffusersPipeline"),
)
]
def _wait_for_status(client: TestClient, video_id: str, status: str, timeout_s: float = 2.0):
deadline = time.time() + timeout_s
last_payload = None
@@ -359,104 +332,6 @@ def test_i2v_video_generation_with_image_reference_form(test_client, mocker: Moc
assert input_image.size == (40, 24)
def test_v2v_video_generation_form(test_client, mocker: MockerFixture):
video_bytes = _make_test_video_bytes((32, 24), num_frames=3)
mocker.patch(
"vllm_omni.entrypoints.openai.serving_video._encode_video_bytes",
return_value=b"fake-video",
)
response = test_client.post(
"/v1/videos",
data={"prompt": "Continue this motion."},
files={"input_reference": ("input.mp4", video_bytes, "video/mp4")},
)
assert response.status_code == 200
video_id = response.json()["id"]
_wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
engine = test_client.app.state.openai_serving_video._engine_client
prompt = engine.captured_prompt
assert "multi_modal_data" in prompt
assert "video" in prompt["multi_modal_data"]
input_video = prompt["multi_modal_data"]["video"]
assert len(input_video) == 3
assert all(isinstance(frame, Image.Image) for frame in input_video)
assert input_video[0].size == (32, 24)
def test_v2v_video_generation_with_video_reference_form(test_client, mocker: MockerFixture):
mocker.patch(
"vllm_omni.entrypoints.openai.serving_video._encode_video_bytes",
return_value=b"fake-video",
)
response = test_client.post(
"/v1/videos",
data={
"prompt": "Continue this motion.",
"video_reference": json.dumps({"video_url": _make_test_video_data_url((32, 24), 2)}),
},
)
assert response.status_code == 200
video_id = response.json()["id"]
_wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
engine = test_client.app.state.openai_serving_video._engine_client
input_video = engine.captured_prompt["multi_modal_data"]["video"]
assert len(input_video) == 2
assert input_video[0].size == (32, 24)
def test_decode_video_bytes_can_keep_last_frames():
from vllm_omni.entrypoints.openai.video_api_utils import _decode_video_bytes
frames = _decode_video_bytes(
_make_test_video_bytes((32, 24), num_frames=6),
source="input_reference",
max_frames=2,
keep="last",
)
assert len(frames) == 2
red_means = [np.asarray(frame)[:, :, 0].mean() for frame in frames]
assert red_means[0] > 100
assert red_means[1] > red_means[0]
def test_cosmos3_reference_video_limit_uses_v2v_condition_frames():
request = VideoGenerationRequest(
prompt="Continue this motion.",
num_frames=189,
extra_params={"condition_frame_indexes_vision": [0, 2]},
)
spec = api_server._reference_video_decode_spec(request, _cosmos3_stage_configs())
assert spec.max_frames == 9
assert spec.keep == "first"
def test_cosmos3_reference_video_limit_preserves_action_frames():
request = VideoGenerationRequest(
prompt="Predict the action.",
num_frames=17,
extra_params={"action_mode": "inverse_dynamics", "action_chunk_size": 16},
)
assert api_server._reference_video_decode_spec(request, _cosmos3_stage_configs()).max_frames == 17
def test_cosmos3_reference_video_limit_caps_condition_frames_to_output_frames():
request = VideoGenerationRequest(
prompt="Continue this motion.",
num_frames=5,
extra_params={"condition_frame_indexes_vision": [0, 20]},
)
assert api_server._reference_video_decode_spec(request, _cosmos3_stage_configs()).max_frames == 5
def test_seconds_defaults_fps_and_frames(test_client, mocker: MockerFixture):
fps_values = []
@@ -919,20 +794,7 @@ def test_rejects_input_reference_and_image_reference_together(test_client):
files={"input_reference": ("input.png", _make_test_image_bytes(), "image/png")},
)
assert response.status_code == 400
assert "only one of input_reference, image_reference, or video_reference" in response.json()["detail"].lower()
def test_rejects_image_reference_and_video_reference_together(test_client):
response = test_client.post(
"/v1/videos",
data={
"prompt": "bad refs",
"image_reference": '{"image_url": "https://example.com/cat.png"}',
"video_reference": '{"video_url": "https://example.com/cat.mp4"}',
},
)
assert response.status_code == 400
assert "only one of input_reference, image_reference, or video_reference" in response.json()["detail"].lower()
assert "either input_reference or image_reference" in response.json()["detail"].lower()
def test_invalid_seconds_returns_422(test_client):
@@ -994,18 +856,6 @@ def test_unsupported_image_reference_file_id_returns_400(test_client):
assert response.json()["detail"] == "Invalid image_reference: file_id is not supported yet."
def test_unsupported_video_reference_file_id_returns_400(test_client):
response = test_client.post(
"/v1/videos",
data={
"prompt": "unsupported ref",
"video_reference": '{"file_id": "file-123"}',
},
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid video_reference: file_id is not supported yet."
def test_invalid_uploaded_input_reference_returns_400(test_client):
response = test_client.post(
"/v1/videos",
@@ -1013,7 +863,7 @@ def test_invalid_uploaded_input_reference_returns_400(test_client):
files={"input_reference": ("input.png", b"not-an-image", "image/png")},
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid input_reference: provided content is not a valid image or video."
assert response.json()["detail"] == "Invalid input_reference: provided content is not a valid image."
def test_video_request_validation():
@@ -1030,8 +880,6 @@ def test_video_request_validation():
with pytest.raises(ValueError):
VideoGenerationRequest(prompt="test", image_reference={"file_id": "file-1", "image_url": "https://example.com"})
with pytest.raises(ValueError):
VideoGenerationRequest(prompt="test", video_reference={"file_id": "file-1", "video_url": "https://example.com"})
with pytest.raises(ValueError):
VideoGenerationRequest(prompt="test", frame_interpolation_exp=0)
with pytest.raises(ValueError):
@@ -1417,23 +1265,6 @@ def test_sync_i2v_with_image_reference(test_client, mocker: MockerFixture):
assert response.content == b"ref-video"
def test_sync_v2v_returns_video_bytes(test_client, mocker: MockerFixture):
video_bytes = _make_test_video_bytes((32, 24), num_frames=3)
_mock_encode_video_bytes(mocker, b"v2v-video-data")
response = test_client.post(
"/v1/videos/sync",
data={"prompt": "Continue this motion."},
files={"input_reference": ("input.mp4", video_bytes, "video/mp4")},
)
assert response.status_code == 200
assert response.content == b"v2v-video-data"
engine = test_client.app.state.openai_serving_video._engine_client
input_video = engine.captured_prompt["multi_modal_data"]["video"]
assert len(input_video) == 3
assert input_video[0].size == (32, 24)
def test_sync_missing_handler_returns_503():
app = FastAPI()
app.include_router(router)
@@ -1466,7 +1297,7 @@ def test_sync_rejects_both_references(test_client):
files={"input_reference": ("input.png", _make_test_image_bytes(), "image/png")},
)
assert response.status_code == 400
assert "only one of input_reference, image_reference, or video_reference" in response.json()["detail"].lower()
assert "either input_reference or image_reference" in response.json()["detail"].lower()
def test_sync_generation_error_returns_500(test_client, mocker: MockerFixture):
+1 -4
View File
@@ -532,10 +532,7 @@ async def test_pure_diffusion_scenario(tp_size: int):
try:
await engine.sleep(level=1)
await engine.wake_up()
async for _ in engine.generate(
"test",
sampling_params=OmniDiffusionSamplingParams(num_inference_steps=2, height=256, width=256),
):
async for _ in engine.generate("test", sampling_params=SamplingParams()):
pass
logger.info("Pure diffusion OK (TP=%s)", tp_size)
finally:
+6 -23
View File
@@ -588,32 +588,15 @@ def cosine_similarity_text(text1, text2, n: int = 3):
return cosine * length_harmony
class _AudioBuffer:
"""Minimal replacement for pydub.AudioSegment used by test helpers."""
def _merge_base64_audio_to_segment(base64_list: list[str]):
from pydub import AudioSegment
def __init__(self, data: np.ndarray, sample_rate: int):
self.data = data
self.sample_rate = sample_rate
def export(self, buf: io.BytesIO, format: str = "wav"):
sf.write(buf, self.data, self.sample_rate, format=format.upper())
buf.seek(0)
def _merge_base64_audio_to_segment(base64_list: list[str]) -> _AudioBuffer:
from vllm.multimodal.media.audio import AudioMediaIO
io_ = AudioMediaIO()
chunks: list[np.ndarray] = []
sample_rate: int | None = None
merged = None
for b64 in base64_list:
raw = base64.b64decode(b64.split(",", 1)[-1])
waveform, sr = io_.load_bytes(raw)
if sample_rate is None:
sample_rate = int(sr)
chunks.append(waveform)
merged = np.concatenate(chunks) if chunks else np.array([], dtype=np.float32)
return _AudioBuffer(merged, sample_rate or 16000)
seg = AudioSegment.from_file(io.BytesIO(raw))
merged = seg if merged is None else merged + seg
return merged
@contextmanager
+1 -10
View File
@@ -1767,16 +1767,7 @@ class OpenAIClientHandler:
# Qwen3-TTS custom fields, forwarded via extra_body.
extra_body: dict[str, Any] = {}
# Keep this list aligned with vllm_omni.entrypoints.openai.protocol.audio params.
for key in (
"task_type",
"ref_text",
"ref_audio",
"language",
"max_new_tokens",
"seed",
"instructions",
"speed",
):
for key in ("task_type", "ref_text", "ref_audio", "language", "max_new_tokens", "seed"):
if key in request_config:
extra_body[key] = request_config[key]
@@ -84,11 +84,6 @@ def _make_code2wav_model(
token_mel_ratio=2 if with_stride_cfg else 0,
)
model.code2wav = _DummyCode2Wav(vocab_size=4, num_samples=num_samples, outputs=outputs)
# Short-circuit the lazy TensorRT estimator swap: these tests exercise the
# forward audio logic, not the TRT path. On a GPU CI runner the swap would
# otherwise run and dereference ``self.model_dir`` (only set in __init__,
# which this fixture bypasses via object.__new__).
model._code2wav_trt_done = True
model.source_cache_len = 4
model.speech_window = torch.hamming_window(8, periodic=False)
model._stream_audio_cache_by_req = {}
@@ -1,185 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from types import SimpleNamespace
import pytest
import torch
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
from vllm_omni.model_executor.models.common.ming.audio_vae import AudioVAEConfig
from vllm_omni.model_executor.models.common.ming.fm import Solver
from vllm_omni.model_executor.models.ming_tts.constants import (
AGGREGATOR_HIDDEN_SIZE,
HISTORY_PATCH_SIZE,
LATENT_DIM,
LLM_HIDDEN_SIZE,
LLM_VOCAB_SIZE,
PATCH_SIZE,
SAMPLE_RATE,
VAE_PATCH_SIZE,
)
from vllm_omni.model_executor.models.ming_tts.flowloss_head import FlowLoss
from vllm_omni.model_executor.models.ming_tts.ming_tts_llm import MingLLMModel
from vllm_omni.model_executor.models.ming_tts.validation import validate_ming_tts_config
from vllm_omni.model_executor.models.output_templates import OmniOutput
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.tts]
def test_ming_tts_audio_vae_uses_common_config():
"""AudioVAEConfig is shared by Ming dense and Ming flash modules."""
cfg = AudioVAEConfig(sample_rate=16000, patch_size=-1)
assert cfg.sample_rate == 16000
assert cfg.patch_size == -1
def test_ming_tts_cfm_solver_uses_common_implementation():
"""Ming dense imports the shared solver implementation directly."""
assert Solver.__module__ == "vllm_omni.model_executor.models.common.ming.fm"
def test_ming_tts_flowloss_preserves_checkpoint_prefix():
flowloss = FlowLoss(z_channels=4, llm_cond_dim=8, hidden_size=16, depth=1, num_heads=2)
assert any(name.startswith("cfm.model.") for name in flowloss.state_dict())
def test_ming_dense_validation_rejects_semantic_audio_vae_config():
"""Dense 0.5B validation rejects semantic AudioVAE configs."""
cfg = SimpleNamespace(
audio_dummy_token_id=151705,
audio_eos_token_id=151704,
text_eos_token_id=151669,
audio_tokenizer_config=AudioVAEConfig(
sample_rate=SAMPLE_RATE,
patch_size=VAE_PATCH_SIZE,
semantic_module_kwargs={"whisper_encoder": {}},
enc_kwargs={"latent_dim": LATENT_DIM, "input_dim": 882, "hop_size": 882},
dec_kwargs={"latent_dim": LATENT_DIM, "output_dim": 882},
),
latent_dim=LATENT_DIM,
patch_size=PATCH_SIZE,
history_patch_size=HISTORY_PATCH_SIZE,
llm_hidden_size=LLM_HIDDEN_SIZE,
llm_vocab_size=LLM_VOCAB_SIZE,
sample_rate=SAMPLE_RATE,
vae_patch_size=VAE_PATCH_SIZE,
llm_config={"hidden_size": LLM_HIDDEN_SIZE},
aggregator_config={"hidden_size": AGGREGATOR_HIDDEN_SIZE},
ditar_config={"hidden_size": AGGREGATOR_HIDDEN_SIZE},
latent_chunk_size=1,
latent_left_context=0,
max_decode_steps=1,
stop_head_threshold=0.5,
stop_head_min_steps=0,
)
with pytest.raises(ValueError, match="semantic_module_kwargs"):
validate_ming_tts_config(cfg)
def test_ming_instruction_parser_preserves_dense_and_flash_defaults():
"""Ming dense and Ming flash keep distinct instruction defaults."""
serving = object.__new__(OmniOpenAIServingSpeech)
serving.uploaded_speakers = {"uploaded": {}}
dense_plain = serving._parse_ming_instruction(SimpleNamespace(instructions="calm", language=None, voice=None))
assert dense_plain == "calm"
dense_with_fields = serving._parse_ming_instruction(
SimpleNamespace(instructions="calm", language="Auto", voice="灵小甄")
)
assert dense_with_fields == {"IP": "灵小甄", "风格": "calm"}
flash_fields = serving._parse_ming_instruction_fields(
SimpleNamespace(instructions="calm", language="粤语", voice="灵小甄")
)
assert flash_fields == {"风格": "calm"}
def _make_ming_logits_model(vocab_size=8):
model = object.__new__(MingLLMModel)
model.ming_config = SimpleNamespace(
llm_vocab_size=vocab_size,
max_decode_steps=1,
stop_head_min_steps=0,
text_eos_token_id=7,
)
model._last_text_mode = False
model._last_ming_next_token_ids = None
return model
def test_ming_compute_logits_uses_cached_forced_next_token_ids():
model = _make_ming_logits_model()
model._last_ming_next_token_ids = [2, 5]
logits = MingLLMModel.compute_logits(model, torch.zeros((2, 4)), SimpleNamespace())
assert logits.shape == (2, 8)
assert logits[0, 2].item() == 0.0
assert logits[1, 5].item() == 0.0
assert torch.isneginf(logits[0, [0, 1, 3, 4, 5, 6, 7]]).all()
assert torch.isneginf(logits[1, [0, 1, 2, 3, 4, 6, 7]]).all()
assert model._last_ming_next_token_ids is None
def test_ming_compute_logits_falls_back_to_dummy_token_id():
model = _make_ming_logits_model()
logits = MingLLMModel.compute_logits(model, torch.zeros((1, 4)), SimpleNamespace())
assert logits.shape == (1, 8)
assert logits[0, 7].item() == 0.0
assert torch.isneginf(logits[0, [0, 1, 2, 3, 4, 5, 6]]).all()
def test_ming_forward_non_decode_return_clears_cached_forced_next_token_ids():
class FakeBackbone:
def __call__(self, **kwargs):
return kwargs["inputs_embeds"]
model = _make_ming_logits_model()
model.model = FakeBackbone()
model._last_ming_next_token_ids = [2]
output = MingLLMModel.forward(
model,
input_ids=torch.tensor([1]),
positions=torch.tensor([0]),
inputs_embeds=torch.zeros((1, 4)),
model_intermediate_buffer=[],
)
assert isinstance(output, OmniOutput)
assert output.multimodal_outputs is None
assert model._last_ming_next_token_ids is None
def test_ming_compute_logits_rejects_forced_token_batch_mismatch():
model = _make_ming_logits_model()
model._last_ming_next_token_ids = [2]
with pytest.raises(RuntimeError, match="batch mismatch"):
MingLLMModel.compute_logits(model, torch.zeros((2, 4)), SimpleNamespace())
def test_ming_compute_logits_text_mode_delegates_to_backbone():
class FakeBackbone:
def __init__(self):
self.hidden_states = None
def compute_logits(self, hidden_states):
self.hidden_states = hidden_states
return torch.ones((hidden_states.shape[0], 3))
model = _make_ming_logits_model(vocab_size=3)
model._last_text_mode = True
model.model = FakeBackbone()
hidden_states = torch.zeros((2, 4))
logits = MingLLMModel.compute_logits(model, hidden_states, SimpleNamespace())
assert torch.equal(model.model.hidden_states, hidden_states)
assert torch.equal(logits, torch.ones((2, 3)))
@@ -921,7 +921,6 @@ def test_cosyvoice3_full_payload_replace_keys_present() -> None:
def test_ming_flash_omni_thinker2talker_token_only_smoke() -> None:
"""Smoke: ming_flash_omni token-only carries voice metadata."""
from vllm_omni.model_executor.stage_input_processors.ming_flash_omni import (
thinker2talker,
thinker2talker_token_only,
)
@@ -939,15 +938,14 @@ def test_ming_flash_omni_thinker2talker_token_only_smoke() -> None:
src = [_Wrap("hello world")]
prompt = _Prompt({"voice_name": "ZH_FEMALE", "prompt_text": "ref text"})
for func in (thinker2talker, thinker2talker_token_only):
out = func(src, prompt=prompt)
assert len(out) == 1
assert out[0]["prompt_token_ids"] == [0]
info = out[0]["additional_information"]
assert info["text"] == "hello world"
assert info["voice_name"] == "ZH_FEMALE"
assert info["prompt_text"] == "ref text"
assert info["ming_task"] == "omni"
out = thinker2talker_token_only(src, prompt=prompt)
assert len(out) == 1
assert out[0]["prompt_token_ids"] == [0] # talker self-tokenizes; dummy id
info = out[0]["additional_information"]
assert info["text"] == "hello world"
assert info["voice_name"] == "ZH_FEMALE"
assert info["prompt_text"] == "ref text"
assert info["ming_task"] == "omni"
def test_qwen2_5_omni_thinker2talker_token_only_smoke() -> None:
+386
View File
@@ -17,6 +17,7 @@ from tests.helpers.stage_config import get_deploy_config_path
from vllm_omni.config.stage_config import (
_PIPELINE_REGISTRY,
DeployConfig,
ModelPipeline,
PipelineConfig,
StageConfig,
StageConfigFactory,
@@ -350,6 +351,93 @@ class TestStageConfig:
assert "parallel_config" not in omega_config.engine_args
class TestModelPipeline:
"""Tests for ModelPipeline class."""
def test_valid_linear_dag(self):
"""Test validation of a valid linear DAG."""
stages = [
StageConfig(stage_id=0, model_stage="thinker", input_sources=[]),
StageConfig(stage_id=1, model_stage="talker", input_sources=[0]),
StageConfig(stage_id=2, model_stage="code2wav", input_sources=[1]),
]
pipeline = ModelPipeline(model_type="test", stages=stages)
errors = pipeline.validate_pipeline()
assert errors == [], f"Unexpected errors: {errors}"
def test_valid_branching_dag(self):
"""Test validation of a valid branching DAG."""
stages = [
StageConfig(stage_id=0, model_stage="input", input_sources=[]),
StageConfig(stage_id=1, model_stage="branch_a", input_sources=[0]),
StageConfig(stage_id=2, model_stage="branch_b", input_sources=[0]),
]
pipeline = ModelPipeline(model_type="test", stages=stages)
errors = pipeline.validate_pipeline()
assert errors == [], f"Unexpected errors: {errors}"
def test_missing_entry_point(self):
"""Test that missing entry point is detected."""
stages = [
StageConfig(stage_id=0, model_stage="stage_a", input_sources=[1]),
StageConfig(stage_id=1, model_stage="stage_b", input_sources=[0]),
]
pipeline = ModelPipeline(model_type="test", stages=stages)
errors = pipeline.validate_pipeline()
assert any("entry point" in e.lower() for e in errors)
def test_missing_dependency(self):
"""Test that missing stage reference is detected."""
stages = [
StageConfig(stage_id=0, model_stage="input", input_sources=[]),
StageConfig(stage_id=1, model_stage="output", input_sources=[99]), # Invalid
]
pipeline = ModelPipeline(model_type="test", stages=stages)
errors = pipeline.validate_pipeline()
assert any("non-existent" in e.lower() for e in errors)
def test_duplicate_stage_ids(self):
"""Test that duplicate stage IDs are detected."""
stages = [
StageConfig(stage_id=0, model_stage="stage_a", input_sources=[]),
StageConfig(stage_id=0, model_stage="stage_b", input_sources=[]), # Duplicate
]
pipeline = ModelPipeline(model_type="test", stages=stages)
errors = pipeline.validate_pipeline()
assert any("duplicate" in e.lower() for e in errors)
def test_self_reference(self):
"""Test that self-references are detected."""
stages = [
StageConfig(stage_id=0, model_stage="entry", input_sources=[]),
StageConfig(stage_id=1, model_stage="self_ref", input_sources=[1]), # Self
]
pipeline = ModelPipeline(model_type="test", stages=stages)
errors = pipeline.validate_pipeline()
assert any("itself" in e.lower() for e in errors)
def test_get_stage_by_id(self):
"""Test getting stage by ID."""
stages = [
StageConfig(stage_id=0, model_stage="thinker", input_sources=[]),
StageConfig(stage_id=1, model_stage="talker", input_sources=[0]),
]
pipeline = ModelPipeline(model_type="test", stages=stages)
stage = pipeline.get_stage(1)
assert stage is not None
assert stage.model_stage == "talker"
missing = pipeline.get_stage(99)
assert missing is None
def test_empty_pipeline(self):
"""Test validation of empty pipeline."""
pipeline = ModelPipeline(model_type="test", stages=[])
errors = pipeline.validate_pipeline()
assert any("no stages" in e.lower() for e in errors)
class TestStageConfigFactory:
"""Tests for StageConfigFactory class."""
@@ -510,6 +598,304 @@ class TestStageResolutionHelpers:
assert overridden == []
class TestPipelineYamlParsing:
"""Tests for pipeline YAML file parsing (@ZJY0516)."""
def test_parse_qwen3_omni_moe_yaml(self, tmp_path):
"""Test parsing the qwen3_omni_moe pipeline YAML."""
yaml_content = """\
model_type: qwen3_omni_moe
async_chunk: false
stages:
- stage_id: 0
model_stage: thinker
stage_type: llm
input_sources: []
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
hf_config_name: thinker_config
final_output: true
final_output_type: text
is_comprehension: true
- stage_id: 1
model_stage: talker
stage_type: llm
input_sources: [0]
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
hf_config_name: talker_config
custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker
- stage_id: 2
model_stage: code2wav
stage_type: llm
input_sources: [1]
worker_type: generation
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
hf_config_name: thinker_config
custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav
final_output: true
final_output_type: audio
"""
yaml_file = tmp_path / "qwen3_omni_moe.yaml"
yaml_file.write_text(yaml_content)
pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "qwen3_omni_moe")
assert pipeline.model_type == "qwen3_omni_moe"
assert len(pipeline.stages) == 3
# Stage 0: thinker
s0 = pipeline.stages[0]
assert s0.stage_id == 0
assert s0.model_stage == "thinker"
assert s0.stage_type == StageType.LLM
assert s0.input_sources == []
assert s0.worker_type == "ar"
assert s0.final_output is True
assert s0.final_output_type == "text"
assert s0.is_comprehension is True
# Stage 1: talker
s1 = pipeline.stages[1]
assert s1.stage_id == 1
assert s1.input_sources == [0]
assert s1.custom_process_input_func == (
"vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker"
)
assert s1.final_output is False
# Stage 2: code2wav
s2 = pipeline.stages[2]
assert s2.stage_id == 2
assert s2.input_sources == [1]
assert s2.worker_type == "generation"
assert s2.final_output_type == "audio"
def test_parse_yaml_with_legacy_engine_input_source(self, tmp_path):
"""Test backward compatibility with engine_input_source field."""
yaml_content = """\
model_type: legacy_model
stages:
- stage_id: 0
model_stage: entry
stage_type: llm
- stage_id: 1
model_stage: downstream
stage_type: llm
engine_input_source: [0]
"""
yaml_file = tmp_path / "legacy.yaml"
yaml_file.write_text(yaml_content)
pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "legacy_model")
assert pipeline.stages[1].input_sources == [0]
def test_parse_yaml_with_connectors_and_edges(self, tmp_path):
"""Test parsing pipeline with optional connectors and edges."""
yaml_content = """\
model_type: test_model
stages:
- stage_id: 0
model_stage: entry
stage_type: llm
input_sources: []
connectors:
type: ray
edges:
- from: 0
to: 1
"""
yaml_file = tmp_path / "with_connectors.yaml"
yaml_file.write_text(yaml_content)
pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "test_model")
assert pipeline.connectors == {"type": "ray"}
assert pipeline.edges == [{"from": 0, "to": 1}]
def test_parsed_pipeline_passes_validation(self, tmp_path):
"""Test that a well-formed YAML produces a valid pipeline."""
yaml_content = """\
model_type: valid_model
stages:
- stage_id: 0
model_stage: entry
stage_type: llm
input_sources: []
final_output: true
final_output_type: text
- stage_id: 1
model_stage: next
stage_type: llm
input_sources: [0]
"""
yaml_file = tmp_path / "valid.yaml"
yaml_file.write_text(yaml_content)
pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "valid_model")
errors = pipeline.validate_pipeline()
assert errors == [], f"Unexpected validation errors: {errors}"
def test_parse_yaml_migrates_runtime_max_batch_size(self, tmp_path):
"""Test that runtime.max_batch_size is migrated to engine_args.max_num_seqs."""
yaml_content = """\
model_type: legacy_batch
stages:
- stage_id: 0
model_stage: entry
stage_type: llm
input_sources: []
runtime:
devices: "0"
max_batch_size: 16
engine_args:
model_arch: SomeModel
"""
yaml_file = tmp_path / "legacy_batch.yaml"
yaml_file.write_text(yaml_content)
pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "legacy_batch")
s0 = pipeline.stages[0]
assert "max_batch_size" not in s0.yaml_runtime
assert s0.yaml_engine_args.get("max_num_seqs") == 16
def test_parse_diffusion_stage_type(self, tmp_path):
"""Test parsing a diffusion stage type from YAML."""
yaml_content = """\
model_type: diff_model
stages:
- stage_id: 0
model_stage: dit
stage_type: diffusion
input_sources: []
final_output: true
final_output_type: image
"""
yaml_file = tmp_path / "diffusion.yaml"
yaml_file.write_text(yaml_content)
pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "diff_model")
assert pipeline.stages[0].stage_type == StageType.DIFFUSION
def test_parse_mimo_audio_yaml(self, tmp_path):
"""Test parsing the MiMo Audio pipeline YAML."""
yaml_content = """\
model_type: mimo_audio
stages:
- stage_id: 0
model_stage: fused_thinker_talker
stage_type: llm
input_sources: []
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
is_comprehension: true
final_output: true
final_output_type: text
- stage_id: 1
model_stage: code2wav
stage_type: llm
input_sources: [0]
worker_type: generation
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
custom_process_input_func: vllm_omni.model_executor.stage_input_processors.mimo_audio.llm2code2wav
final_output: true
final_output_type: audio
"""
yaml_file = tmp_path / "mimo_audio.yaml"
yaml_file.write_text(yaml_content)
pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "mimo_audio")
assert pipeline.model_type == "mimo_audio"
assert len(pipeline.stages) == 2
# Stage 0: fused_thinker_talker
s0 = pipeline.stages[0]
assert s0.stage_id == 0
assert s0.model_stage == "fused_thinker_talker"
assert s0.input_sources == []
assert s0.worker_type == "ar"
assert s0.is_comprehension is True
assert s0.final_output is True
assert s0.final_output_type == "text"
# Stage 1: code2wav
s1 = pipeline.stages[1]
assert s1.stage_id == 1
assert s1.input_sources == [0]
assert s1.worker_type == "generation"
assert s1.custom_process_input_func == (
"vllm_omni.model_executor.stage_input_processors.mimo_audio.llm2code2wav"
)
assert s1.final_output is True
assert s1.final_output_type == "audio"
# Pipeline validation
errors = pipeline.validate_pipeline()
assert errors == [], f"Unexpected errors: {errors}"
class TestAsyncChunk:
"""Tests for async_chunk pipeline flag."""
def test_model_pipeline_async_chunk_default(self):
"""Test ModelPipeline defaults async_chunk to False."""
stages = [StageConfig(stage_id=0, model_stage="entry", input_sources=[])]
pipeline = ModelPipeline(model_type="test", stages=stages)
assert pipeline.async_chunk is False
def test_model_pipeline_async_chunk_set(self):
"""Test ModelPipeline with async_chunk=True."""
stages = [StageConfig(stage_id=0, model_stage="entry", input_sources=[])]
pipeline = ModelPipeline(model_type="test", stages=stages, async_chunk=True)
assert pipeline.async_chunk is True
def test_parse_async_chunk_from_yaml(self, tmp_path):
"""Test that async_chunk is parsed from pipeline YAML."""
yaml_content = """\
model_type: test_async
async_chunk: true
stages:
- stage_id: 0
model_stage: entry
stage_type: llm
input_sources: []
"""
yaml_file = tmp_path / "async.yaml"
yaml_file.write_text(yaml_content)
pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "test_async")
assert pipeline.async_chunk is True
def test_parse_missing_async_chunk_defaults_false(self, tmp_path):
"""Test that missing async_chunk defaults to False."""
yaml_content = """\
model_type: test_no_async
stages:
- stage_id: 0
model_stage: entry
stage_type: llm
input_sources: []
"""
yaml_file = tmp_path / "no_async.yaml"
yaml_file.write_text(yaml_content)
pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "test_no_async")
assert pipeline.async_chunk is False
class TestPipelineDiscovery:
"""Tests for the central pipeline registry (``pipeline_registry._OMNI_PIPELINES``)."""
-182
View File
@@ -1,182 +0,0 @@
"""Functional tests for pydub removal.
Verifies that the new av/numpy/torchaudio replacements produce
identical results to the old pydub-based code for all three paths:
1. WAV PCM → duration & frame count (pure arithmetic)
2. Non-WAV (mp3/ogg) → duration & frame count (av.open)
3. TTS WER PCM export: resample to 24000Hz mono s16
"""
import io
import wave
import av
import numpy as np
import soundfile as sf
def _make_wav_bytes(sample_rate=16000, channels=1, sample_width=2, duration_sec=1.0):
"""Generate a WAV file with a sine wave, return (wav_bytes, pcm_bytes, params)."""
n_samples = int(sample_rate * duration_sec)
t = np.linspace(0, duration_sec, n_samples, endpoint=False)
samples = (np.sin(2 * np.pi * 440 * t) * 32767 * 0.5).astype(np.int16)
if channels == 2:
samples = np.column_stack([samples, samples]).flatten()
buf = io.BytesIO()
with wave.open(buf, "wb") as wf:
wf.setnchannels(channels)
wf.setsampwidth(sample_width)
wf.setframerate(sample_rate)
wf.writeframes(samples.tobytes())
wav_bytes = buf.getvalue()
# Extract raw PCM (skip WAV header)
with wave.open(io.BytesIO(wav_bytes), "rb") as wr:
pcm_bytes = wr.readframes(wr.getnframes())
params = (wr.getnchannels(), wr.getsampwidth(), wr.getframerate())
return wav_bytes, pcm_bytes, params
def _make_ogg_bytes(sample_rate=16000, channels=1, duration_sec=1.0):
"""Generate an OGG Vorbis file with a sine wave, return bytes."""
n_samples = int(sample_rate * duration_sec)
t = np.linspace(0, duration_sec, n_samples, endpoint=False)
samples = (np.sin(2 * np.pi * 440 * t) * 0.5).astype(np.float32)
buf = io.BytesIO()
sf.write(buf, samples, sample_rate, format="OGG", subtype="VORBIS")
return buf.getvalue()
def test_wav_path_duration_and_frames():
"""Path 1: WAV PCM buffer → duration & frames via arithmetic."""
for sr in [16000, 24000, 44100]:
for ch in [1, 2]:
for dur in [0.5, 1.0, 2.5]:
_, pcm_bytes, (channels, sample_width, frame_rate) = _make_wav_bytes(
sample_rate=sr, channels=ch, duration_sec=dur
)
# New code logic (from patch.py)
frame_width = sample_width * channels
audio_frames = len(pcm_bytes) // frame_width
audio_duration_sec = audio_frames / frame_rate
assert abs(audio_duration_sec - dur) < 0.001, f"sr={sr} ch={ch} dur={dur}: got {audio_duration_sec}"
expected_frames = int(sr * dur)
assert audio_frames == expected_frames, (
f"sr={sr} ch={ch} dur={dur}: frames {audio_frames} != {expected_frames}"
)
print("[PASS] test_wav_path_duration_and_frames")
def test_non_wav_path_duration_av():
"""Path 2: non-WAV audio → duration & frames via av."""
for dur in [0.5, 1.0, 2.0]:
ogg_bytes = _make_ogg_bytes(sample_rate=16000, duration_sec=dur)
container = av.open(io.BytesIO(ogg_bytes))
stream = container.streams.audio[0]
audio_frames = sum(f.samples for f in container.decode(stream))
audio_duration_sec = audio_frames / stream.rate if stream.rate else 0.0
container.close()
# OGG encoding may add padding, allow small tolerance
assert abs(audio_duration_sec - dur) < 0.05, f"dur={dur}: got {audio_duration_sec}"
print("[PASS] test_non_wav_path_duration_av")
def test_tts_pcm_export_wav_path():
"""Path 3a: WAV PCM → resample to 24000Hz mono s16."""
import torch
import torchaudio
_, pcm_bytes, (channels, sample_width, frame_rate) = _make_wav_bytes(
sample_rate=16000, channels=2, sample_width=2, duration_sec=1.0
)
# New code logic
pcm_array = np.frombuffer(pcm_bytes, dtype=np.int16)
if channels > 1:
pcm_array = pcm_array.reshape(-1, channels).mean(axis=1).astype(np.int16)
if frame_rate != 24000:
t = torch.from_numpy(pcm_array.astype(np.float32)).unsqueeze(0)
t = torchaudio.functional.resample(t, frame_rate, 24000)
pcm_array = t.squeeze(0).to(torch.int16).numpy()
result_bytes = pcm_array.tobytes()
# Should be mono, 24000Hz, 2 bytes per sample
expected_samples = int(24000 * 1.0)
actual_samples = len(result_bytes) // 2
assert abs(actual_samples - expected_samples) < 10, f"expected ~{expected_samples} samples, got {actual_samples}"
assert len(pcm_array.shape) == 1, "should be mono (1D array)"
print("[PASS] test_tts_pcm_export_wav_path")
def test_tts_pcm_export_non_wav_path():
"""Path 3b: non-WAV → resample to 24000Hz mono s16 via av.AudioResampler."""
ogg_bytes = _make_ogg_bytes(sample_rate=16000, channels=1, duration_sec=1.0)
container = av.open(io.BytesIO(ogg_bytes))
resampler = av.AudioResampler(format="s16", layout="mono", rate=24000)
frames = []
for frame in container.decode(audio=0):
for rf in resampler.resample(frame):
frames.append(rf.to_ndarray().flatten())
container.close()
result = np.concatenate(frames)
result_bytes = result.tobytes()
expected_samples = int(24000 * 1.0)
actual_samples = len(result_bytes) // 2
assert abs(actual_samples - expected_samples) < 500, f"expected ~{expected_samples} samples, got {actual_samples}"
print("[PASS] test_tts_pcm_export_non_wav_path")
def test_media_merge_and_export():
"""media.py: _merge_base64_audio_to_segment decode + concat + export WAV."""
import base64 as b64mod
import sys
sys.path.insert(0, ".")
from tests.helpers.media import _merge_base64_audio_to_segment
# Create two WAV chunks as base64
chunks_b64 = []
for dur in [0.5, 0.3]:
wav_bytes, _, _ = _make_wav_bytes(sample_rate=16000, channels=1, sample_width=2, duration_sec=dur)
chunks_b64.append(b64mod.b64encode(wav_bytes).decode())
merged = _merge_base64_audio_to_segment(chunks_b64)
# Test export to WAV
buf = io.BytesIO()
merged.export(buf, format="wav")
wav_out = buf.getvalue()
assert len(wav_out) > 44, f"WAV output too small: {len(wav_out)} bytes"
# Verify merged duration (~0.8s)
out_data, out_sr = sf.read(io.BytesIO(wav_out))
actual_dur = len(out_data) / out_sr
assert abs(actual_dur - 0.8) < 0.05, f"expected ~0.8s merged duration, got {actual_dur}"
print(f"[PASS] test_media_merge_and_export (duration={actual_dur:.3f}s, wav_size={len(wav_out)} bytes)")
if __name__ == "__main__":
test_wav_path_duration_and_frames()
test_non_wav_path_duration_av()
import importlib.util
if importlib.util.find_spec("torch") and importlib.util.find_spec("torchaudio"):
test_tts_pcm_export_wav_path()
else:
print("[SKIP] test_tts_pcm_export_wav_path (torch/torchaudio not available)")
test_tts_pcm_export_non_wav_path()
test_media_merge_and_export()
print("\nAll tests passed.")
@@ -410,8 +410,6 @@ class TestAudioFeedback:
model = type("M", (), {"embed_tokens": lambda self, ids: torch.zeros(ids.shape[0], 16)})()
t = FakeTalker()
t._decode_last_codes = torch.zeros(256, 8, dtype=torch.long)
t._decode_has_codes = torch.zeros(256, dtype=torch.bool)
t._apply_audio_feedback = mod.HiggsAudioV3TalkerForConditionalGeneration._apply_audio_feedback.__get__(t)
return t
@@ -429,8 +427,6 @@ class TestAudioFeedback:
t = self._make_feedback_talker()
audio_id = t._audio_continuation_id
t._audio_state[1] = {"last_codes": torch.zeros(8, dtype=torch.long)}
t._decode_last_codes[1] = torch.zeros(8, dtype=torch.long)
t._decode_has_codes[1] = True
input_ids = torch.tensor([1, audio_id, 3])
hidden = torch.zeros(3, 16)
result = t._apply_audio_feedback(hidden, input_ids)
@@ -444,7 +440,7 @@ class TestAudioFeedback:
"""Audio position without state should not be replaced."""
t = self._make_feedback_talker()
audio_id = t._audio_continuation_id
# No state for row 0 — _decode_has_codes[0] defaults to False
# No state for row 0
input_ids = torch.tensor([audio_id])
hidden = torch.zeros(1, 16)
result = t._apply_audio_feedback(hidden, input_ids)
@@ -912,300 +908,3 @@ class TestPromptBuilder:
# Should not contain ref_audio or ref_text token IDs
assert 151703 not in ids # <|ref_audio|>
assert 151704 not in ids # <|ref_text|>
# ---- Regression: ramp-down done-before-emit off-by-one ----
def _simulate_delay_state_machine(
sampled_codes_per_step: list[torch.Tensor],
) -> list[torch.Tensor]:
"""Reproduce the delay-pattern state machine from sample().
Feeds ``sampled_codes_per_step`` (each [Q]) through the exact same
logic as HiggsAudioV3TalkerForConditionalGeneration.sample() and
returns the list of emitted frames (non -1 codes).
"""
from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import (
BOC_ID,
EOC_ID,
)
num_codebooks = 8
bos = BOC_ID
eos_stream = EOC_ID
state: dict = {
"num_delay": 0,
"num_remaining_delays": None,
"audio_out_ids": None,
"last_codes": torch.full((num_codebooks,), bos, dtype=torch.long),
"should_terminate": False,
}
emitted: list[torch.Tensor] = []
for step_codes in sampled_codes_per_step:
if state["should_terminate"]:
break
num_delay = state["num_delay"]
num_remaining_delays = state["num_remaining_delays"]
this_codes = step_codes.clone()
if num_delay + 1 < num_codebooks:
this_codes[num_delay + 1 :] = bos
num_delay += 1
if num_remaining_delays is not None:
this_codes[: num_codebooks - num_remaining_delays] = eos_stream
num_remaining_delays -= 1
else:
eos_positions = (this_codes == eos_stream).nonzero(as_tuple=False).reshape(-1)
if eos_positions.numel() > 0:
last_eos_idx = int(eos_positions[-1].item())
this_codes[: last_eos_idx + 1] = eos_stream
num_remaining_delays = num_codebooks - last_eos_idx - 1
if num_remaining_delays is not None and num_remaining_delays <= 0:
state["num_delay"] = 0
state["num_remaining_delays"] = None
state["should_terminate"] = True
continue
state["num_delay"] = num_delay
state["num_remaining_delays"] = num_remaining_delays
state["last_codes"] = this_codes.clone()
emitted.append(this_codes)
return emitted
class TestRampDownOffByOne:
"""Regression tests for the done-before-emit off-by-one bug.
When EOC fires early, the ramp-down must emit enough frames so that
Stage1's _revert_delay_pattern receives T >= Q = 8.
"""
def test_eoc_at_step0_terminates(self):
"""EOC on cb0 at step 0 terminates; T < Q is handled by Stage1 defensive try/except."""
from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import EOC_ID
Q = 8
steps = []
for s in range(20):
codes = torch.randint(0, 1024, (Q,))
if s == 0:
codes[0] = EOC_ID
steps.append(codes)
emitted = _simulate_delay_state_machine(steps)
assert len(emitted) == Q - 1, f"EOC at step 0: expected {Q - 1} emitted frames, got {len(emitted)}"
def test_eoc_at_step1_emits_enough_frames(self):
"""EOC on cb0 at step 1 must produce >= Q emitted frames."""
from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import EOC_ID
Q = 8
steps = []
for s in range(20):
codes = torch.randint(0, 1024, (Q,))
if s == 1:
codes[0] = EOC_ID
steps.append(codes)
emitted = _simulate_delay_state_machine(steps)
assert len(emitted) >= Q, f"EOC at step 1: expected >= {Q} emitted frames, got {len(emitted)}"
def test_eoc_after_full_rampup_emits_enough(self):
"""EOC after all codebooks are active produces T >> Q."""
from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import EOC_ID
Q = 8
eoc_step = 10
steps = []
for s in range(30):
codes = torch.randint(0, 1024, (Q,))
if s == eoc_step:
codes[0] = EOC_ID
steps.append(codes)
emitted = _simulate_delay_state_machine(steps)
assert len(emitted) >= Q, f"EOC at step {eoc_step}: expected >= {Q} emitted frames, got {len(emitted)}"
def test_last_rampdown_frame_not_all_negative(self):
"""The last emitted ramp-down frame must contain real codes, not -1."""
from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import EOC_ID
Q = 8
steps = []
for s in range(20):
codes = torch.randint(0, 1024, (Q,))
if s == 1:
codes[0] = EOC_ID
steps.append(codes)
emitted = _simulate_delay_state_machine(steps)
last = emitted[-1]
assert (last >= 0).all(), f"Last ramp-down frame contains negative values: {last.tolist()}"
def test_emitted_frames_revertible_by_stage1(self):
"""Emitted frames from EOC-at-step-1 must be revertible by _revert_delay_pattern."""
from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import EOC_ID
from vllm_omni.model_executor.stage_input_processors.higgs_audio_v3 import (
_revert_delay_pattern,
)
Q = 8
steps = []
for s in range(20):
codes = torch.randint(0, 1024, (Q,))
if s == 1:
codes[0] = EOC_ID
steps.append(codes)
emitted = _simulate_delay_state_machine(steps)
# Stack to [T, Q], transpose to [Q, T]
codes_qt = torch.stack(emitted, dim=0).t().contiguous()
assert codes_qt.shape[0] == Q
assert codes_qt.shape[1] >= Q
# Must not raise
reverted = _revert_delay_pattern(codes_qt)
assert reverted.shape[0] == Q
assert reverted.shape[1] == codes_qt.shape[1] - Q + 1
class TestStage1DefensiveHandling:
"""Stage1 must not crash the server when receiving T < Q frames."""
def test_revert_delay_pattern_raises_on_insufficient_frames(self):
"""_revert_delay_pattern correctly raises ValueError for T < Q."""
from vllm_omni.model_executor.stage_input_processors.higgs_audio_v3 import (
_revert_delay_pattern,
)
codes = torch.zeros(8, 7) # T=7 < Q=8
with pytest.raises(ValueError, match="Not enough frames"):
_revert_delay_pattern(codes)
def test_talker2code2wav_survives_insufficient_frames(self):
"""talker2code2wav must not crash when audio_codes has T < Q frames."""
from vllm_omni.model_executor.stage_input_processors.higgs_audio_v3 import (
talker2code2wav,
)
# Build a fake talker output with only 7 frames (T=7 < Q=8)
class FakeOutput:
def __init__(self):
self.multimodal_output = {
"codes": {"audio": torch.zeros(7, 8, dtype=torch.long)},
}
class FakeTalkerOutput:
finished = True
outputs = [FakeOutput()]
# Must not raise — should return gracefully
result = talker2code2wav([FakeTalkerOutput()])
assert len(result) == 1
# Result is an OmniTokensPrompt with empty prompt_token_ids
out = result[0]
token_ids = out.prompt_token_ids if hasattr(out, "prompt_token_ids") else out.get("prompt_token_ids", [])
assert token_ids == []
# ---- Regression: buffer growth + prefill guard ----
class TestBufferGrowthPreservesState:
"""Bug #1: buffer growth in _apply_audio_feedback must not wipe existing state."""
def _make_feedback_talker_with_buffers(self):
from vllm_omni.model_executor.models.higgs_audio_v3 import higgs_audio_v3_talker as mod
embed = mod.HiggsFusedMultiTextEmbedding(num_codebooks=8, vocab_size=1026, hidden_size=16)
torch.nn.init.ones_(embed.weight)
class FakeTalker:
num_codebooks = 8
codebook_size = 1026
multimodal_embedding = embed
t = FakeTalker()
t._decode_last_codes = torch.zeros(4, 8, dtype=torch.long)
t._decode_has_codes = torch.zeros(4, dtype=torch.bool)
t._apply_audio_feedback = mod.HiggsAudioV3TalkerForConditionalGeneration._apply_audio_feedback.__get__(t)
return t
def test_buffer_growth_preserves_existing_codes(self):
"""Growing the buffer must copy old _decode_last_codes data."""
t = self._make_feedback_talker_with_buffers()
# Set state for slot 2
t._decode_last_codes[2] = torch.tensor([100, 200, 300, 400, 500, 600, 700, 800])
t._decode_has_codes[2] = True
# Trigger growth by calling with bs=8 > current capacity 4
hidden = torch.randn(8, 16)
t._apply_audio_feedback(hidden, torch.zeros(8, dtype=torch.long))
# After growth, slot 2 data must still be present
assert t._decode_has_codes[2].item() is True
assert t._decode_last_codes[2, 0].item() == 100
def test_buffer_growth_new_slots_are_inactive(self):
"""New slots from buffer growth must have has_codes=False."""
t = self._make_feedback_talker_with_buffers()
t._decode_has_codes[1] = True
hidden = torch.randn(8, 16)
t._apply_audio_feedback(hidden, torch.zeros(8, dtype=torch.long))
# Slots 4-7 (new) must be inactive
assert not t._decode_has_codes[4].item()
assert not t._decode_has_codes[7].item()
# Slot 1 (old) must still be active
assert t._decode_has_codes[1].item()
class TestPrefillGuard:
"""Bug #2: _apply_audio_feedback must NOT run during prefill.
The prefill detection uses attn_metadata.max_query_len (== 1 for decode,
> 1 for prefill). This correctly handles concurrent decode (N requests,
each contributing 1 token → numel=N but max_query_len=1).
"""
@staticmethod
def _is_prefill_via_max_query_len(max_query_len: int | None, input_ids: torch.Tensor) -> bool:
"""Mirror the actual detection logic in forward()."""
if max_query_len is not None:
return int(max_query_len) > 1
return input_ids is not None and int(input_ids.numel()) > 1
def test_prefill_detected_via_max_query_len(self):
"""Prefill: max_query_len > 1 → is_prefill=True."""
input_ids = torch.tensor([1, 2, 3, 4, 5])
assert self._is_prefill_via_max_query_len(5, input_ids) is True
def test_single_decode_detected_via_max_query_len(self):
"""Single-request decode: max_query_len=1 → is_prefill=False."""
input_ids = torch.tensor([42])
assert self._is_prefill_via_max_query_len(1, input_ids) is False
def test_concurrent_decode_not_misclassified(self):
"""Multi-request decode: numel=4 but max_query_len=1 → is_prefill=False.
This is the regression caught by reviewer: with c=4 concurrent decode
requests, input_ids.numel()==4 but each request contributes exactly 1
token, so max_query_len==1 and audio feedback must still apply.
"""
input_ids = torch.tensor([42, 43, 44, 45]) # 4 concurrent decode
assert self._is_prefill_via_max_query_len(1, input_ids) is False
def test_fallback_when_max_query_len_unavailable(self):
"""When attn_metadata is unavailable, fall back to numel heuristic."""
prefill_ids = torch.tensor([1, 2, 3])
assert self._is_prefill_via_max_query_len(None, prefill_ids) is True
decode_ids = torch.tensor([42])
assert self._is_prefill_via_max_query_len(None, decode_ids) is False
@@ -22,7 +22,6 @@ class DummyInputBatch:
def __init__(self, req_ids):
self.req_ids = req_ids
self.req_id_to_index = {r: i for i, r in enumerate(req_ids)}
class DummyReqState:
+21 -44
View File
@@ -16,8 +16,8 @@ from datetime import datetime
from typing import Any, Literal
import aiohttp
import numpy as np
import pybase64 as base64
from pydub import AudioSegment
from tqdm.asyncio import tqdm
from vllm.benchmarks import datasets
from vllm.benchmarks.datasets import SampleRequest
@@ -653,8 +653,9 @@ async def async_request_openai_chat_omni_completions(
# Reset per-attempt state so that retries do not mix partial
# outputs or metrics from previous attempts.
generated_text = ""
generated_audio = None
# For wav responses, accumulate decoded PCM bytes per chunk
# to avoid repeated decode/concat.
# to avoid repeated AudioSegment decode/concat.
wav_pcm_buffer = bytearray()
wav_audio_params: tuple[int, int, int] | None = None
wav_inconsistent_chunk_count = 0
@@ -794,33 +795,30 @@ async def async_request_openai_chat_omni_completions(
output.latency = timestamp - st
output.generated_text = generated_text
audio_duration_sec = 0.0
audio_frames = 0
if response_format == "wav" and wav_pcm_buffer and wav_audio_params is not None:
channels, sample_width, frame_rate = wav_audio_params
frame_width = sample_width * channels
if frame_width > 0:
audio_frames = len(wav_pcm_buffer) // frame_width
audio_duration_sec = audio_frames / frame_rate
else:
logger.warning("Audio frame width is zero")
generated_audio = AudioSegment(
data=bytes(wav_pcm_buffer),
sample_width=sample_width,
frame_rate=frame_rate,
channels=channels,
)
elif audio_bytes_buffer:
try:
from vllm.multimodal.audio import get_audio_duration
from vllm.multimodal.media.audio import load_audio
waveform, sr = load_audio(
generated_audio = AudioSegment.from_file(
io.BytesIO(bytes(audio_bytes_buffer)),
sr=None,
mono=False,
format=response_format,
)
audio_duration_sec = get_audio_duration(y=waveform, sr=sr)
audio_frames = int(audio_duration_sec * sr)
except Exception as ex:
logger.warning("Failed to decode accumulated audio bytes: %s", ex)
if audio_duration_sec > 0 or audio_frames > 0:
output.audio_duration = audio_duration_sec
output.audio_frames = audio_frames
if generated_audio is not None:
output.audio_duration = len(generated_audio) / 1000.0
frame_width = generated_audio.frame_width
if frame_width > 0:
output.audio_frames = len(generated_audio.raw_data) // frame_width
else:
output.audio_frames = 0
logger.warning("Audio frame width is zero")
audio_duration = output.audio_duration
if audio_duration > 0:
output.audio_rtf = audio_generate_time / output.audio_duration
@@ -829,29 +827,8 @@ async def async_request_openai_chat_omni_completions(
logger.warning("Audio duration is zero")
if _seed_tts_capture_pcm_for_wer() and getattr(request_func_input, "seed_tts_row", False):
try:
if response_format == "wav" and wav_pcm_buffer and wav_audio_params is not None:
from vllm.multimodal.audio import AudioResampler
pcm_channels, pcm_sw, pcm_rate = wav_audio_params
pcm = np.frombuffer(
bytes(wav_pcm_buffer), dtype=np.int16 if pcm_sw == 2 else np.float32
)
if pcm_channels > 1:
pcm = pcm.reshape(-1, pcm_channels).mean(axis=1).astype(pcm.dtype)
pcm_f32 = pcm.astype(np.float32) / 32767.0 if pcm.dtype == np.int16 else pcm
if pcm_rate != 24000:
resampler = AudioResampler(target_sr=24000)
pcm_f32 = resampler.resample(pcm_f32, orig_sr=pcm_rate)
output.tts_output_pcm_bytes = (pcm_f32 * 32767).astype(np.int16).tobytes()
elif audio_bytes_buffer:
from vllm.multimodal.media.audio import load_audio
waveform, _ = load_audio(
io.BytesIO(bytes(audio_bytes_buffer)),
sr=24000,
mono=True,
)
output.tts_output_pcm_bytes = (waveform * 32767).astype(np.int16).tobytes()
seg = generated_audio.set_frame_rate(24000).set_channels(1).set_sample_width(2)
output.tts_output_pcm_bytes = bytes(seg.raw_data)
except Exception as ex:
logger.warning("seed_tts WER PCM export failed: %s", ex)
output.success = True
+2
View File
@@ -6,6 +6,7 @@ from vllm_omni.config.lora import LoRAConfig
from vllm_omni.config.model import OmniModelConfig
from vllm_omni.config.stage_config import (
DeployConfig,
ModelPipeline,
PipelineConfig,
StageConfig,
StageConfigFactory,
@@ -29,6 +30,7 @@ __all__ = [
"LoRAConfig",
"StageConfig",
"StageConfigFactory",
"ModelPipeline",
"StageType",
"StageExecutionType",
"StagePipelineConfig",
-4
View File
@@ -101,10 +101,6 @@ _OMNI_PIPELINES: dict[str, tuple[str, str]] = {
"vllm_omni.model_executor.models.mimo_audio.pipeline",
"MIMO_AUDIO_PIPELINE",
),
"ming_tts": (
"vllm_omni.model_executor.models.ming_tts.pipeline",
"MING_TTS_PIPELINE",
),
"voxtral_tts": (
"vllm_omni.model_executor.models.voxtral_tts.pipeline",
"VOXTRAL_TTS_PIPELINE",
+291 -15
View File
@@ -20,6 +20,13 @@ from vllm_omni.config.yaml_util import create_config, load_yaml_config, to_dict
from vllm_omni.core.sched.omni_ar_scheduler import OmniARAsyncScheduler, OmniARScheduler
from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler
_MODELS_DIR = Path(__file__).resolve().parent.parent / "model_executor" / "models"
def get_pipeline_path(model_dir: str, filename: str) -> Path:
return _MODELS_DIR / model_dir / filename
logger = init_logger(__name__)
@@ -1054,6 +1061,77 @@ class StageConfig:
return create_config(config_dict)
@dataclass
class ModelPipeline:
"""Complete pipeline definition for a multi-stage model (legacy).
TODO(@lishunyang12): remove once all models migrate to PipelineConfig.
"""
model_type: str
stages: list[StageConfig]
# Pipeline-wide behavior flags
async_chunk: bool = False
# Optional distributed configuration
connectors: dict[str, Any] | None = None
edges: list[dict[str, Any]] | None = None
def get_stage(self, stage_id: int) -> StageConfig | None:
"""Look up a stage by its ID.
Args:
stage_id: The stage ID to search for.
Returns:
The matching StageConfig, or None if not found.
"""
for stage in self.stages:
if stage.stage_id == stage_id:
return stage
return None
def validate_pipeline(self) -> list[str]:
"""Validate pipeline topology at model integration time (not runtime).
Checks:
- All stage IDs are unique
- All input_sources reference valid stage IDs
- At least one entry point (stage with empty input_sources)
Returns:
List of validation error messages. Empty list if valid.
"""
errors: list[str] = []
if not self.stages:
errors.append("Topology has no stages defined")
return errors
# Check for unique stage IDs
stage_ids = [s.stage_id for s in self.stages]
if len(stage_ids) != len(set(stage_ids)):
errors.append("Duplicate stage IDs found")
stage_id_set = set(stage_ids)
# Check input_sources reference valid stages
for stage in self.stages:
for source_id in stage.input_sources:
if source_id not in stage_id_set:
errors.append(f"Stage {stage.stage_id} references non-existent input source {source_id}")
if source_id == stage.stage_id:
errors.append(f"Stage {stage.stage_id} references itself as input source")
# Check for at least one entry point
entry_points = [s for s in self.stages if not s.input_sources]
if not entry_points:
errors.append("No entry point found (stage with empty input_sources)")
return errors
class StageConfigFactory:
"""Factory that loads pipeline YAML and merges CLI overrides.
@@ -1128,22 +1206,33 @@ class StageConfigFactory:
continue
return cls._create_from_registry(registered.model_type, cli_overrides, deploy_config_path)
# An explicit deploy config can select a pipeline when the model's HF
# metadata is too generic for registry auto-detection.
if deploy_config_path is not None:
deploy_path = Path(deploy_config_path)
if deploy_path.exists():
deploy_config = load_deploy_config(deploy_path)
if deploy_config.pipeline in _PIPELINE_REGISTRY:
return cls._create_from_registry(
deploy_config.pipeline,
cli_overrides,
deploy_config_path,
)
# --- Legacy path: load from pipeline YAML ---
pipeline = cls._load_pipeline(model, trust_remote_code=trust_remote_code)
# Not in the pipeline registry — let the caller fall back to the
# legacy ``stage_configs/*.yaml`` path (resolve_model_config_path).
return None
if pipeline is None:
return None
errors = pipeline.validate_pipeline()
if errors:
logger.warning(f"Pipeline validation warnings for {model}: {errors}")
# Materialize the resolved pipeline-wide async_chunk value into every
# stage so build_engine_args_dict() can inject the stage connector
# spec and explicit False overrides are preserved.
resolved_async_chunk = cli_overrides.get("async_chunk")
if resolved_async_chunk is None:
resolved_async_chunk = bool(pipeline.async_chunk)
for stage in pipeline.stages:
stage.yaml_engine_args["async_chunk"] = bool(resolved_async_chunk)
# Apply CLI overrides
result: list[StageConfig] = []
for stage in pipeline.stages:
# Merge global CLI overrides
stage.runtime_overrides = cls._merge_cli_overrides(stage, cli_overrides)
result.append(stage)
return result
@classmethod
def _create_from_registry(
@@ -1266,6 +1355,193 @@ class StageConfigFactory:
return [config_dict]
@classmethod
def _load_pipeline(cls, model: str, trust_remote_code: bool = True) -> ModelPipeline | None:
"""Load a legacy ``pipeline.yaml`` for the model.
Searches ``model_executor/models/<dir>/pipeline.yaml`` by trying
(a) the raw ``model_type`` as the directory name, then
(b) ``model_type`` with hyphens replaced by underscores,
and finally (c) scanning every ``pipeline.yaml`` for one that
declares a matching ``model_type`` or ``hf_architectures``.
Returns None if no pipeline.yaml is found — caller handles the
``resolve_model_config_path`` fallback via stage_configs/ YAMLs.
"""
model_type, hf_config = cls._auto_detect_model_type(model, trust_remote_code=trust_remote_code)
if model_type is None:
return None
# Direct lookups by convention
candidates = [model_type, model_type.replace("-", "_")]
for dir_name in candidates:
pipeline_path = get_pipeline_path(dir_name, "pipeline.yaml")
if pipeline_path.exists():
return cls._parse_pipeline_yaml(pipeline_path, model_type)
# Scan fallback: read every pipeline.yaml and match on declared fields
hf_archs = set(getattr(hf_config, "architectures", []) or []) if hf_config else set()
if _MODELS_DIR.exists():
for subdir in sorted(_MODELS_DIR.iterdir()):
if not subdir.is_dir():
continue
pipeline_path = subdir / "pipeline.yaml"
if not pipeline_path.exists():
continue
try:
cfg = load_yaml_config(pipeline_path)
except Exception as exc:
logger.debug("Skip %s: %s", pipeline_path, exc)
continue
declared_type = getattr(cfg, "model_type", None)
declared_archs = set(getattr(cfg, "hf_architectures", None) or [])
if declared_type == model_type or (hf_archs and hf_archs.intersection(declared_archs)):
return cls._parse_pipeline_yaml(pipeline_path, declared_type or model_type)
logger.debug("No pipeline.yaml found for model_type %s (archs=%s)", model_type, sorted(hf_archs))
return None
# Keys consumed as explicit StageConfig fields — everything else is
# passed through via yaml_extras.
_KNOWN_STAGE_KEYS: set[str] = {
"stage_id",
"model_stage",
"stage_type",
"input_sources",
"engine_input_source",
"custom_process_input_func",
"final_output",
"final_output_type",
"worker_type",
"scheduler_cls",
"hf_config_name",
"is_comprehension",
"engine_args",
"runtime",
}
@classmethod
def _parse_pipeline_yaml(cls, path: Path, model_type: str) -> ModelPipeline:
"""Parse a pipeline YAML file.
Args:
path: Path to the YAML file.
model_type: Model type identifier.
Returns:
ModelPipeline object.
"""
config_data = load_yaml_config(path)
stages: list[StageConfig] = []
for stage_data in config_data.stages:
# Use .get() for optional fields — idiomatic for OmegaConf DictConfig
stage_type_str = stage_data.get("stage_type", "llm")
stage_type = StageType(stage_type_str) if stage_type_str else StageType.LLM
# Handle both 'input_sources' (new) and 'engine_input_source' (legacy)
input_sources = stage_data.get("input_sources", None)
if input_sources is None:
input_sources = stage_data.get("engine_input_source", [])
if input_sources is None:
input_sources = []
input_sources = list(input_sources)
# Extract per-stage engine_args and runtime dicts
raw_ea = stage_data.get("engine_args", None)
yaml_engine_args = to_dict(raw_ea) if raw_ea is not None else {}
raw_rt = stage_data.get("runtime", None)
yaml_runtime = to_dict(raw_rt) if raw_rt is not None else {}
# Migrate legacy runtime.max_batch_size → engine_args.max_num_seqs
if "max_batch_size" in yaml_runtime:
mbs = yaml_runtime.pop("max_batch_size")
yaml_engine_args.setdefault("max_num_seqs", int(mbs))
logger.debug(
"Stage %s: migrated runtime.max_batch_size=%s to engine_args.max_num_seqs",
stage_data.get("stage_id", "?"),
mbs,
)
# Topology-level fields that also live inside engine_args in legacy
# YAMLs (worker_type, scheduler_cls, etc.) — read from both places.
worker_type = stage_data.get("worker_type", None) or yaml_engine_args.pop("worker_type", None)
scheduler_cls = stage_data.get("scheduler_cls", None) or yaml_engine_args.pop("scheduler_cls", None)
if scheduler_cls:
async_sched = yaml_engine_args.get("async_scheduling")
if async_sched is not None:
logger.warning(
"Stage %s: async_scheduling=%r and scheduler_cls=%r "
"should not be set together. scheduler_cls will take "
"precedence for which scheduler is used.",
stage_data.stage_id,
async_sched,
scheduler_cls,
)
else:
logger.warning(
"Stage %s: scheduler_cls=%r is deprecated. Use async_scheduling instead.",
stage_data.stage_id,
scheduler_cls,
)
hf_config_name = stage_data.get("hf_config_name", None) or yaml_engine_args.pop("hf_config_name", None)
model_stage = getattr(stage_data, "model_stage", None) or yaml_engine_args.pop("model_stage", None)
# Collect pass-through fields (default_sampling_params,
# output_connectors, input_connectors, tts_args, etc.)
yaml_extras: dict[str, Any] = {}
for key in stage_data:
if key not in cls._KNOWN_STAGE_KEYS:
val = stage_data[key]
try:
yaml_extras[key] = to_dict(val)
except ValueError:
yaml_extras[key] = val
stage = StageConfig(
stage_id=stage_data.stage_id,
model_stage=model_stage or "",
stage_type=stage_type,
input_sources=input_sources,
custom_process_input_func=stage_data.get("custom_process_input_func", None),
final_output=stage_data.get("final_output", False),
final_output_type=stage_data.get("final_output_type", None),
worker_type=worker_type,
scheduler_cls=scheduler_cls,
hf_config_name=hf_config_name,
is_comprehension=stage_data.get("is_comprehension", False),
yaml_engine_args=yaml_engine_args,
yaml_runtime=yaml_runtime,
yaml_extras=yaml_extras,
)
stages.append(stage)
# Get pipeline-wide flags
async_chunk = config_data.get("async_chunk", False)
# Get optional connector config — check both top-level and nested
# under ``runtime`` (legacy stage_configs format).
connectors = None
edges = None
if hasattr(config_data, "connectors"):
connectors = to_dict(config_data.connectors)
if hasattr(config_data, "edges"):
edges = to_dict(config_data.edges)
if hasattr(config_data, "runtime") and config_data.runtime is not None:
top_runtime = config_data.runtime
if connectors is None and hasattr(top_runtime, "connectors"):
connectors = to_dict(top_runtime.connectors)
if edges is None and hasattr(top_runtime, "edges"):
edges = to_dict(top_runtime.edges)
return ModelPipeline(
model_type=getattr(config_data, "model_type", model_type),
stages=stages,
async_chunk=async_chunk,
connectors=connectors,
edges=edges,
)
@classmethod
def _auto_detect_model_type(cls, model: str, trust_remote_code: bool = True) -> tuple[str | None, Any]:
"""Auto-detect model_type from model directory.
+1 -30
View File
@@ -218,9 +218,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
self._process_pending_input_timeouts()
if self.chunk_transfer_adapter:
self.chunk_transfer_adapter.process_pending_chunks(
self.waiting, self.running, scheduler_requests=self.requests
)
self.chunk_transfer_adapter.process_pending_chunks(self.waiting, self.running)
try:
scheduler_output = super().schedule()
@@ -628,34 +626,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
if self.chunk_transfer_adapter:
self.chunk_transfer_adapter.finish_requests(request_ids, finished_status, self.requests)
# Realign stale ``request.status`` (chunk-transfer-adapter's
# ``requests_origin_status`` table doesn't follow the
# ``waiting → running`` admit transition; without this, an abort
# arriving between admit and the next deque round-trip leaves
# the request in ``self.running`` with ``status=WAITING`` and
# upstream ``Scheduler.finish_requests`` silently fails to
# release the worker's ``input_batch`` slot -- after
# ``max_num_seqs`` such aborts new requests hang at
# ``chunks=0``). Only the ``async_chunk`` path triggers the
# staleness; with ``async_chunk`` disabled this is a cheap O(n)
# no-op over an already-aligned set, kept unconditional so the
# abort path stays uniform across configurations. See
# ``OmniSchedulerMixin._realign_request_status_to_queues`` and
# #3774 discussion.
self._realign_request_status_to_queues(request_ids)
finished = super().finish_requests(request_ids, finished_status)
# Defensive post-finish purge: belt-and-suspenders to the
# realignment above. Even after realign + ``super()``, corner
# cases (mid-transition status, connector cleanups that pop
# from ``self.requests`` without unwinding ``self.running``)
# can leave already-finished or untracked entries in
# ``self.running``. Sweep them now so the worker's
# ``input_batch`` slot never pins a freed request and starves
# new admissions. See ``OmniSchedulerMixin._purge_finished_from_running``.
self._purge_finished_from_running()
input_coordinator = getattr(self, "input_coordinator", None)
if input_coordinator is not None:
for request_id, _ in finished:
@@ -87,9 +87,7 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
self._consume_pending_connector_output(model_mode="generation")
self._process_pending_input_timeouts()
if self.chunk_transfer_adapter:
self.chunk_transfer_adapter.process_pending_chunks(
self.waiting, self.running, scheduler_requests=self.requests
)
self.chunk_transfer_adapter.process_pending_chunks(self.waiting, self.running)
# OMNI: Track requests that are already finished (e.g., marked by connector)
# These should be removed from running and not scheduled
@@ -366,23 +364,7 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
if self.chunk_transfer_adapter:
self.chunk_transfer_adapter.finish_requests(request_ids, finished_status, self.requests)
# See ``OmniSchedulerMixin._realign_request_status_to_queues`` --
# closes the residual hang on the
# ``waiting → running → abort-before-next-deque-round-trip`` race
# surfaced by #3774's reproduction matrix. Only the
# ``async_chunk`` path actually triggers the staleness; with
# ``async_chunk`` disabled this is a cheap O(n) no-op, kept
# unconditional so the abort path stays uniform.
self._realign_request_status_to_queues(request_ids)
finished = super().finish_requests(request_ids, finished_status)
# See ``OmniSchedulerMixin._purge_finished_from_running`` --
# defensive belt-and-suspenders sweep paired with the realign
# above. Closes residual ``self.running`` slot leaks if any
# corner case slips past upstream's status-driven removal.
self._purge_finished_from_running()
if self.input_coordinator is not None:
for request_id, _ in finished:
self._free_input_coordinator_request(request_id)
@@ -2,7 +2,6 @@ from __future__ import annotations
import os
import time
from collections.abc import Iterable
from typing import Any
from vllm.logger import init_logger
@@ -157,122 +156,3 @@ class OmniSchedulerMixin:
return None
self._last_stats_time = now
return super().make_stats(*args, **kwargs)
def _realign_request_status_to_queues(
self,
request_ids: str | Iterable[str] | None,
) -> None:
"""Realign ``request.status`` to actual queue membership.
``OmniChunkTransferAdapter._process_chunk_queue`` stamps
``requests_origin_status[req.id] = WAITING`` (or ``RUNNING``) when
first parking a request in a chunk-transfer deque. On the next
tick, when the chunk arrives, ``_process_chunk_queue`` sets
``request.status = target_status`` and continues, but
``requests_origin_status`` is left at its first-park value -- no
hook updates it on the ``waiting → running`` admit transition
that ``super().schedule()`` later performs. The table stays
stale until the request makes another deque round-trip.
If an abort lands in the gap between admit and the next deque
round-trip, ``chunk_transfer_adapter.finish_requests`` reads the
stale ``WAITING`` from ``requests_origin_status``, stomps it
onto ``request.status``, and the upstream
``Scheduler.finish_requests`` else branch silently fails to
remove from ``self.running`` -- the request stays alive in
``self.running`` and the worker's ``input_batch`` slot leaks.
After ``max_num_seqs`` such aborts every new request hangs at
``chunks=0`` until the client times out.
Realign here: if a request lives in ``self.running`` but its
status is not ``RUNNING``, set it to ``RUNNING``; symmetrically
flip ``RUNNING → WAITING`` when the request is actually in
``self.waiting``. This is a localized safety net for
``requests_origin_status`` staleness on the admit transition;
it does not touch the adapter's invariants and is complementary
to the chunk-transfer-adapter deque purge that already runs
inside ``process_pending_chunks`` / ``restore_queues``.
Note on scope: only the ``async_chunk`` path actually triggers
the ``requests_origin_status`` staleness this helper repairs.
When ``async_chunk`` is disabled, no chunk-transfer round-trip
occurs between admit and finish, so the realignment walk is a
cheap O(n) no-op over an already-aligned set. The call is kept
unconditional in ``finish_requests`` to (a) keep the abort path
uniform and (b) defend any future configuration that re-enables
chunk transfer from rediscovering the same regression.
See https://github.com/vllm-project/vllm-omni/pull/3774 and the
residual-hang reproduction discussed in that PR.
"""
# Mirror the upstream Scheduler.finish_requests resolution of
# ``request_ids`` so realignment touches exactly the set that
# ``super().finish_requests`` will then walk.
if isinstance(request_ids, str):
ids_to_align: Iterable[str] = (request_ids,)
elif request_ids is None:
ids_to_align = list(self.requests.keys())
else:
ids_to_align = list(request_ids)
if not ids_to_align:
return
running_ids = {r.request_id for r in self.running}
waiting_ids = {r.request_id for r in self.waiting}
for rid in ids_to_align:
req = self.requests.get(rid)
if req is None or req.is_finished():
continue
if rid in running_ids and req.status != RequestStatus.RUNNING:
req.status = RequestStatus.RUNNING
elif rid in waiting_ids and req.status == RequestStatus.RUNNING:
req.status = RequestStatus.WAITING
def _purge_finished_from_running(self) -> None:
"""Defensive post-finish sweep of ``self.running``.
Belt-and-suspenders to ``_realign_request_status_to_queues``:
even after status realignment lets upstream
``Scheduler.finish_requests`` pick the right removal branch,
a future regression or an unexpected ``status`` mid-transition
could still leave already-finished entries in ``self.running``.
Sweeping here guarantees the worker's ``input_batch`` slot is
not pinned by a freed request.
Complementary to ``_realign_request_status_to_queues``: realign
is preventive (fix ``status`` before ``super().finish_requests``
so the right branch fires); this purge is defensive (sweep the
residue after ``super().finish_requests`` so any stale entries
are reclaimed).
Scope of the predicate. ``is_finished()`` covers entries the
upstream ``finish_requests`` already drained from ``self.requests``
but failed to remove from ``self.running``; the
``request_id not in self.requests`` arm catches the same surface
from a different angle and is the post-cleanup mirror of the
deque purge ``_purge_untracked_chunk_requests`` already runs at
the chunk-transfer-adapter layer. It does **not** by itself make
arbitrary direct deletions of ``self.requests`` safe -- callers
that pop ``self.requests`` outside the standard finish path
still have to go through ``_free_request`` (or equivalent) for
block / connector / coordinator cleanup. This sweep only
reclaims the ``self.running`` slot reference.
In-place via ``self.running[:] = ...`` for minor consistency
with idiomatic vLLM scheduler mutation; upstream
``Scheduler.finish_requests`` itself rebinds ``self.running``,
so list identity across the whole call is not preserved -- the
slice form is just to avoid an extra rebind inside this helper.
Assumes the upstream V1 invariant that scheduler ticks are
serialized on a single thread; in-place mutation here is no more
racy than the rest of the scheduler under that assumption.
See https://github.com/vllm-project/vllm-omni/pull/3774
discussion.
"""
if not self.running:
return
self.running[:] = [req for req in self.running if not req.is_finished() and req.request_id in self.requests]
-1
View File
@@ -128,7 +128,6 @@ class EmbeddingsStruct(_StructBase):
voice: torch.Tensor | None = None
speech_feat: torch.Tensor | None = None
speech_token: torch.Tensor | None = None
speech_token_len: torch.Tensor | None = None
embedding: torch.Tensor | None = None
thinker_reply: torch.Tensor | None = None
+7 -20
View File
@@ -7,22 +7,9 @@
# references stay in the yaml unconditionally; the runtime only activates
# them when ``async_chunk: true``.
#
# enforce_eager:
# * Stage 0 (talker) — enforce_eager: false (CUDA graphs enabled).
# * Stage 1 (code2wav) — enforce_eager: true (CUDA graphs don't work with
# dynamic conv shapes).
#
# Streaming continuity is GPU-class sensitive — TUNE PER GPU.
# The defaults below (max_num_seqs: 8, codec_chunk_frames: 15) are tuned for
# H100, where streaming continuity at concurrency 4 stays ~100%. They push
# more audio per scheduler step through the SharedMemoryConnector than a
# slower GPU can keep up with: on H20-3e the same config regressed continuity
# to ~78% at c=4 (vs ~86% with smaller batch/chunk), because code2wav can't
# produce mel chunks fast enough to feed the streaming output → buffer
# underruns → audible gaps.
# On a slower GPU class, lower max_num_seqs (e.g. 4) and/or codec_chunk_frames
# first. Raising connector_get_sleep_s also relaxes the consumer's polling
# cadence, but trades against TTFP — keep it as a last resort.
# enforce_eager=true everywhere:
# * Stage 0 (talker) — cudagraph not verified on this checkpoint.
# * Stage 1 (code2wav) — CUDA graphs don't work with dynamic conv shapes.
async_chunk: true
dtype: float32
@@ -34,16 +21,16 @@ connectors:
connector_get_sleep_s: 0.01
connector_get_max_wait_first_chunk: 3000
connector_get_max_wait: 300
codec_chunk_frames: 15
codec_chunk_frames: 25
codec_left_context_frames: 25
codec_vocab_size: 6561
stages:
- stage_id: 0
max_num_batched_tokens: 32768
max_num_seqs: 8
max_num_seqs: 1
gpu_memory_utilization: 0.4
enforce_eager: false
enforce_eager: true
trust_remote_code: true
enable_prefix_caching: false
devices: "0"
@@ -62,7 +49,7 @@ stages:
- stage_id: 1
max_num_batched_tokens: 32768
max_num_seqs: 8
max_num_seqs: 1
gpu_memory_utilization: 0.2
enforce_eager: true
trust_remote_code: true
@@ -62,7 +62,6 @@ stages:
max_tokens: 8192
detokenize: true
skip_special_tokens: false
include_stop_str_in_output: true,
- stage_id: 1
max_num_seqs: 1
-55
View File
@@ -1,55 +0,0 @@
# Ming-omni-tts deploy: stage-0 LLM+flow -> stage-1 audio VAE.
# Verified legacy settings migrated from ming_tts.yaml and ming_tts_async_chunk.yaml.
# Default mode is async-chunk streaming on a single GPU.
# pipeline: required — HF config reports model_type="dense" with no architectures,
# so auto-detection falls through. This key routes the deploy-config probe in
# StageConfigFactory.create_from_model to the correct registry entry.
pipeline: ming_tts
async_chunk: true
trust_remote_code: false
dtype: bfloat16
enable_prefix_caching: false
connectors:
connector_of_shared_memory:
name: SharedMemoryConnector
extra:
latent_chunk_size: 25
latent_left_context: 0
stages:
- stage_id: 0
max_num_seqs: 1
gpu_memory_utilization: 0.45
enforce_eager: true
enable_prefix_caching: false
async_scheduling: false
max_num_batched_tokens: 8192
max_model_len: 8192
devices: "0"
output_connectors:
to_stage_1: connector_of_shared_memory
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
max_tokens: 512
detokenize: true
- stage_id: 1
max_num_seqs: 1
gpu_memory_utilization: 0.25
enforce_eager: true
enable_prefix_caching: false
async_scheduling: false
max_num_batched_tokens: 8192
max_model_len: 8192
devices: "0"
input_connectors:
from_stage_0: connector_of_shared_memory
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
max_tokens: 1
detokenize: false
@@ -1,107 +0,0 @@
# Qwen3-Omni-MoE intra-node deploy using Mori XGMI transfer.
#
# Targets a single AMD Instinct MI300X OAM node (8x GPU, 192GB HBM3,
# gfx942). Each stage occupies one dedicated GPU (3 GPUs total) and
# stage-to-stage streaming chunks are shipped via Mori's RDMA IOEngine
# (with ``backend_type: xgmi`` selecting the AMD Infinity Fabric
# GPU-to-GPU direct path for intranode transfers).
#
# Pipeline wiring (stage_id, execution_type, model_stage, input_sources,
# custom processors, async-chunk builders) lives in
# ``vllm_omni/model_executor/models/qwen3_omni/pipeline.py`` and is
# merged into the stages below by the deploy loader.
#
# Why this yaml exists:
# * On CUDA/H100 Qwen3-Omni-MoE ships via ``SharedMemoryConnector``
# (see ``qwen3_omni_moe.yaml``). On MI300X the SHM path still works
# but XGMI RDMA can substantially reduce stage-transition latency
# for the thinker->talker hidden-state stream and the talker->code2wav
# codec-frame stream, both of which run in async-chunk mode.
# * This config swaps the connector definition to
# ``MoriTransferEngineConnector`` while keeping the pipeline topology,
# sampling params, and async_chunk=true gating identical to the CUDA
# deploy, so the chunk_transfer_adapter path (scheduler-owned
# background put/get threads) is fully exercised.
#
# Usage:
# vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --log-stats \
# --deploy-config vllm_omni/deploy/qwen3_omni_moe_mori_intranode.yaml
#
# Dependencies: mori (``pip install mori``), torch-rocm>=2.9, vllm-omni
# with #2383 deploy schema, ibverbs/RDMA NICs exposed via /dev/infiniband.
#
# Limitation: TP=1-only. Connector raises NotImplementedError on
# tp_world_size > 1 (chunk-path endpoint derivation isn't rank-aware yet).
async_chunk: true
connectors:
mori_connector:
name: MoriTransferEngineConnector
extra:
host: "auto"
zmq_port: 50051
backend_type: "xgmi" # AMD Infinity Fabric GPU-to-GPU direct
device_name: "" # leave empty to honour $MORI_RDMA_DEVICES
memory_pool_size: 536870912 # 512 MB, matches Qwen2.5-Omni Mori deploy
memory_pool_device: "cuda" # GPU memory required for XGMI RDMA transfers
xgmi_num_streams: 64
xgmi_num_events: 64
qp_per_transfer: 1
num_worker_threads: 1
post_batch_size: -1
# Chunk-transfer knobs consumed by talker2code2wav_async_chunk:
# 25 decoded codec frames per connector.put (~40 ms at 625 Hz
# codec rate) with a 25-frame left context to satisfy the
# code2wav receptive field. Kept identical to the SHM deploy so
# accuracy stays comparable when swapping backends.
codec_chunk_frames: 25
codec_left_context_frames: 25
stages:
- stage_id: 0
gpu_memory_utilization: 0.9
enforce_eager: true # ROCm override: avoid flashinfer autotune path
devices: "0"
output_connectors:
to_stage_1: mori_connector
default_sampling_params:
temperature: 0.4
top_p: 0.9
top_k: 1
max_tokens: 2048
seed: 42
repetition_penalty: 1.05
- stage_id: 1
gpu_memory_utilization: 0.9
enforce_eager: true
devices: "1"
input_connectors:
from_stage_0: mori_connector
output_connectors:
to_stage_2: mori_connector
default_sampling_params:
temperature: 0.9
top_k: 50
max_tokens: 4096
seed: 42
repetition_penalty: 1.05
- stage_id: 2
gpu_memory_utilization: 0.3
max_num_seqs: 1
enforce_eager: true
async_scheduling: false
# Codec prefill length (Q * num_frames) exceeds 32k default;
# matches qwen3_omni_moe.yaml code2wav sizing.
max_num_batched_tokens: 51200
devices: "2"
input_connectors:
from_stage_1: mori_connector
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
max_tokens: 65536
seed: 42
repetition_penalty: 1.1
+1 -12
View File
@@ -1,6 +1,5 @@
# VoxCPM2 deploy: single-stage AR pipeline with per-request state batching.
# Runs on large (H20 141GB) and small (24GB L4) cards alike: the KV cache is
# pinned to a right-sized budget instead of ballooning to fill the card.
# Verified on 1x H20 141GB.
#
# VoxCPM2-local runtime knobs are passed through hf_overrides.
async_chunk: false
@@ -10,16 +9,6 @@ stages:
- stage_id: 0
max_num_seqs: 32
gpu_memory_utilization: 0.9
# Right-size the KV cache instead of letting it fill gpu_memory_utilization.
# max_num_seqs(32) * max_model_len(4096) ~= 131k tokens; 6 GiB holds ~175k
# (42x concurrency, above the 32-seq scheduler cap, so no throughput loss).
# The remaining VRAM stays free for the VoxCPM2 diffusion side-path
# (LocDiT / AudioVAE / code2wav + batch CUDA-graph pools), which allocates
# outside vLLM's KV accounting during inference. Keeps peak ~13 GiB on any
# card and avoids per-rebase OOM tuning on small CI GPUs.
# Assumes prefix caching off and max_num_seqs*max_model_len <= ~175k tokens;
# raise this cap if either grows or enable_prefix_caching is turned on.
kv_cache_memory_bytes: 6442450944 # 6 GiB
enforce_eager: true
trust_remote_code: true
enable_prefix_caching: false
Loaded 100 of 186 files, more files were not shown because too many files have changed in this diff. Show more