Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9678ae58cf | ||
|
|
cead615288 | ||
|
|
17cf60a63d | ||
|
|
2d0b1d8a29 | ||
|
|
e70822ed54 | ||
|
|
6ff246bf3c | ||
|
|
951ae28eb0 | ||
|
|
7e152ca64b | ||
|
|
208932210f | ||
|
|
f05e03aea8 | ||
|
|
63ba11b8b1 | ||
|
|
37a442447e | ||
|
|
13e9094be0 | ||
|
|
1228a109f0 | ||
|
|
8f28b969e9 | ||
|
|
2d2258890b | ||
|
|
0383fde38a | ||
|
|
f75c437a60 | ||
|
|
dff8cda844 | ||
|
|
c782746133 | ||
|
|
db8bc2c71d | ||
|
|
14d70d9870 | ||
|
|
77d395150b | ||
|
|
f195e44bc1 | ||
|
|
2f64c712fd | ||
|
|
65bc737fd4 | ||
|
|
d744940b18 | ||
|
|
e957d16f8d | ||
|
|
8fa0394d29 | ||
|
|
ed37d8e7a2 | ||
|
|
d78a92ec54 | ||
|
|
679cbe226a | ||
|
|
dff757da9c | ||
|
|
890803ca06 | ||
|
|
64a5bb92de | ||
|
|
83e3b129f0 | ||
|
|
1761253005 | ||
|
|
d897852258 | ||
|
|
b46c5af06f | ||
|
|
f200e988a6 | ||
|
|
e3621ec633 | ||
|
|
e7f0db1067 | ||
|
|
864d22164c | ||
|
|
5f5b77aa9c | ||
|
|
3fa8817fc5 | ||
|
|
a62420450c | ||
|
|
5cce797c03 | ||
|
|
d8a72be206 | ||
|
|
ba4932c364 | ||
|
|
dc14380796 | ||
|
|
3bce5e2e3a | ||
|
|
46e0b71423 | ||
|
|
c58a02c873 | ||
|
|
a86c333c7e | ||
|
|
aff0af9754 | ||
|
|
b1a4b08a7e | ||
|
|
e2e79177db | ||
|
|
ae937320ff | ||
|
|
11829ccd75 | ||
|
|
a693ae67f6 | ||
|
|
3c62c53f59 | ||
|
|
f8340d078e | ||
|
|
c9d5545b52 | ||
|
|
10404e4c5b | ||
|
|
6c6fde3e6d |
No files matched your search
@@ -1,11 +1,12 @@
|
||||
# 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
|
||||
# can be skipped together with test-ready/test-merge. Weekly uploads also depend on image-build.
|
||||
# Document 1: Buildkite loads only this block on first parse. The next step resolves docs-only,
|
||||
# pytest skip-mark-only, or combined skip-ci from git diff, then uploads document 2. When skip-ci
|
||||
# applies, image-build still runs for the nightly exception in upload_pipeline.py (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.
|
||||
steps:
|
||||
- label: ":github: Resolve skip-ci & upload pipeline"
|
||||
- label: ":github: Resolve skip-ci (docs / skip marks) & upload pipeline"
|
||||
key: upload-ci-pipeline
|
||||
commands:
|
||||
- "python3 .buildkite/scripts/upload_pipeline.py --upload .buildkite/pipeline.yml"
|
||||
|
||||
@@ -14,7 +14,7 @@ steps:
|
||||
depends_on: image-build
|
||||
key: upload-ready-pipeline
|
||||
commands:
|
||||
- python3 .buildkite/scripts/upload_pipeline.py --upload .buildkite/test-ready.yml
|
||||
- buildkite-agent pipeline upload .buildkite/test-ready.yml
|
||||
agents:
|
||||
queue: "cpu_queue_premerge"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -64,7 +64,8 @@ container_name="rocm_${BUILDKITE_COMMIT}_$(tr -dc A-Za-z0-9 < /dev/urandom | hea
|
||||
docker pull "${image_name}"
|
||||
|
||||
remove_docker_container() {
|
||||
docker rm -f "${container_name}" || docker image rm -f "${image_name}" || true
|
||||
docker ps -aq --filter "name=${container_name}" | xargs -r docker rm -f || true
|
||||
docker image rm -f "${image_name}" || true
|
||||
}
|
||||
trap remove_docker_container EXIT
|
||||
|
||||
@@ -107,7 +108,6 @@ if [[ $commands == *"--shard-id="* ]]; then
|
||||
--network=host \
|
||||
--shm-size=16gb \
|
||||
--group-add "$render_gid" \
|
||||
--rm \
|
||||
-e MIOPEN_DEBUG_CONV_DIRECT=0 \
|
||||
-e MIOPEN_DEBUG_CONV_GEMM=0 \
|
||||
-e VLLM_ROCM_USE_AITER=0 \
|
||||
@@ -140,7 +140,6 @@ else
|
||||
--network=host \
|
||||
--shm-size=16gb \
|
||||
--group-add "$render_gid" \
|
||||
--rm \
|
||||
-e MIOPEN_DEBUG_CONV_DIRECT=0 \
|
||||
-e MIOPEN_DEBUG_CONV_GEMM=0 \
|
||||
-e VLLM_ROCM_USE_AITER=0 \
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"""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.
|
||||
- Detect docs-only, pytest skip-mark-only, or combined docs+skip-mark 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.
|
||||
@@ -25,6 +26,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -57,8 +59,8 @@ def _git(*args: str) -> subprocess.CompletedProcess[str]:
|
||||
)
|
||||
|
||||
|
||||
def resolve_changed_files() -> list[str] | None:
|
||||
"""Return changed file paths, or None when diff cannot be resolved."""
|
||||
def resolve_diff_range() -> str | None:
|
||||
"""Return a git diff range for PR or main builds, or None when unavailable."""
|
||||
is_pr = os.environ.get("BUILDKITE_PULL_REQUEST", "false") != "false" and os.environ.get(
|
||||
"BUILDKITE_PULL_REQUEST", ""
|
||||
)
|
||||
@@ -76,14 +78,22 @@ def resolve_changed_files() -> list[str] | None:
|
||||
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":
|
||||
return f"{base_ref}...{commit}"
|
||||
|
||||
if 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 f"{commit}^..{commit}"
|
||||
|
||||
_log("not PR/main build; using safe defaults")
|
||||
return None
|
||||
|
||||
|
||||
def resolve_changed_files() -> list[str] | None:
|
||||
"""Return changed file paths, or None when diff cannot be resolved."""
|
||||
diff_range = resolve_diff_range()
|
||||
if diff_range is None:
|
||||
return None
|
||||
|
||||
result = _git("diff", "--name-only", diff_range)
|
||||
@@ -96,30 +106,207 @@ def resolve_changed_files() -> list[str] | None:
|
||||
return files
|
||||
|
||||
|
||||
def _is_doc_file(file_path: str) -> bool:
|
||||
if not file_path:
|
||||
return False
|
||||
if file_path.startswith("docs/"):
|
||||
return True
|
||||
if file_path.endswith(".md"):
|
||||
return True
|
||||
return file_path == "mkdocs.yml"
|
||||
|
||||
|
||||
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/"):
|
||||
if not _is_doc_file(file_path):
|
||||
return False
|
||||
return has_any
|
||||
|
||||
|
||||
def _is_test_python_file(file_path: str) -> bool:
|
||||
path = Path(file_path)
|
||||
return path.suffix == ".py" and bool(path.parts) and path.parts[0] == "tests"
|
||||
|
||||
|
||||
_SKIP_MARK_RE = re.compile(r"pytest\.mark\.skip(?:if)?\b|pytest\.skip\s*\(")
|
||||
_PYTESTMARK_SKIP_RE = re.compile(r"pytest\.mark\.skip\b")
|
||||
_PYTEST_MARK_ONLY_RE = re.compile(r"pytest\.mark\.\w+")
|
||||
|
||||
|
||||
def _paren_balance(line: str) -> int:
|
||||
return line.count("(") - line.count(")")
|
||||
|
||||
|
||||
def _is_skip_mark_related_content(line: str) -> bool:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
return True
|
||||
if stripped.startswith("#"):
|
||||
return True
|
||||
return _SKIP_MARK_RE.search(stripped) is not None
|
||||
|
||||
|
||||
def _is_pytestmark_adjacent_content(line: str) -> bool:
|
||||
"""Allow pytestmark list refactors that only add skip/skipif alongside existing marks."""
|
||||
stripped = line.strip().rstrip(",")
|
||||
if not stripped:
|
||||
return True
|
||||
if stripped in {"[", "]"}:
|
||||
return True
|
||||
if stripped.startswith("#"):
|
||||
return True
|
||||
if stripped.startswith("pytestmark"):
|
||||
return True
|
||||
return _PYTEST_MARK_ONLY_RE.search(stripped) is not None
|
||||
|
||||
|
||||
def diff_only_contains_skip_mark_changes(diff_text: str) -> bool:
|
||||
"""True when diff only edits skip marks or reformats pytestmark to add them."""
|
||||
pending_depth = 0
|
||||
saw_change = False
|
||||
has_skip_mark_edit = False
|
||||
for raw_line in diff_text.splitlines():
|
||||
if raw_line.startswith("@@"):
|
||||
pending_depth = 0
|
||||
continue
|
||||
if file_path.endswith(".md"):
|
||||
if not (raw_line.startswith("+") or raw_line.startswith("-")):
|
||||
continue
|
||||
if file_path == "mkdocs.yml":
|
||||
if raw_line.startswith("+++") or raw_line.startswith("---"):
|
||||
continue
|
||||
|
||||
saw_change = True
|
||||
content = raw_line[1:]
|
||||
|
||||
if pending_depth > 0:
|
||||
pending_depth += _paren_balance(content)
|
||||
if pending_depth < 0:
|
||||
pending_depth = 0
|
||||
continue
|
||||
|
||||
if _is_skip_mark_related_content(content):
|
||||
has_skip_mark_edit = True
|
||||
pending_depth = max(0, _paren_balance(content))
|
||||
continue
|
||||
|
||||
if _is_pytestmark_adjacent_content(content):
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
return saw_change and has_skip_mark_edit
|
||||
|
||||
|
||||
def _git_file_diff(file_path: str, diff_range: str) -> str | None:
|
||||
result = _git("diff", diff_range, "--", file_path)
|
||||
if result.returncode != 0:
|
||||
_log(f"skip-mark-only: git diff failed for {file_path}")
|
||||
return None
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _is_new_file_diff(diff_text: str) -> bool:
|
||||
return "new file mode" in diff_text or "--- /dev/null" in diff_text
|
||||
|
||||
|
||||
def _file_has_module_level_pytest_skip(content: str) -> bool:
|
||||
"""True when ``pytestmark`` applies unconditional ``pytest.mark.skip`` to the module."""
|
||||
match = re.search(r"^pytestmark\s*=\s*\[(.*?)^\s*\]", content, re.MULTILINE | re.DOTALL)
|
||||
if match is not None:
|
||||
return _PYTESTMARK_SKIP_RE.search(match.group(1)) is not None
|
||||
match = re.search(r"^pytestmark\s*=\s*pytest\.mark\.skip\b", content, re.MULTILINE)
|
||||
return match is not None
|
||||
|
||||
|
||||
def _file_content_at_commit(file_path: str, commit: str) -> str | None:
|
||||
result = _git("show", f"{commit}:{file_path}")
|
||||
if result.returncode != 0:
|
||||
_log(f"skip-mark-only: cannot read {file_path} at {commit}")
|
||||
return None
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _qualifies_as_skip_mark_test_change(file_path: str, diff_range: str) -> bool:
|
||||
diff_text = _git_file_diff(file_path, diff_range)
|
||||
if diff_text is None:
|
||||
return False
|
||||
if not diff_text.strip():
|
||||
_log(f"skip-mark-only: empty diff for {file_path}")
|
||||
return False
|
||||
|
||||
if diff_only_contains_skip_mark_changes(diff_text):
|
||||
return True
|
||||
|
||||
if not _is_new_file_diff(diff_text):
|
||||
_log(f"skip-mark-only: non-skip changes in {file_path}")
|
||||
return False
|
||||
|
||||
commit = os.environ.get("BUILDKITE_COMMIT", "")
|
||||
if not commit:
|
||||
return False
|
||||
content = _file_content_at_commit(file_path, commit)
|
||||
if content is None:
|
||||
return False
|
||||
if _file_has_module_level_pytest_skip(content):
|
||||
_log(f"skip-mark-only: new test file with module-level pytest.mark.skip: {file_path}")
|
||||
return True
|
||||
|
||||
_log(f"skip-mark-only: new test file without module-level pytest.mark.skip: {file_path}")
|
||||
return False
|
||||
|
||||
|
||||
def is_skip_mark_only_change(changed_files: list[str], *, diff_range: str) -> bool:
|
||||
"""True when every changed file is a tests/ module with only skip-mark edits."""
|
||||
has_any = False
|
||||
for file_path in changed_files:
|
||||
if not file_path:
|
||||
continue
|
||||
has_any = True
|
||||
if not _is_test_python_file(file_path):
|
||||
return False
|
||||
if not _qualifies_as_skip_mark_test_change(file_path, diff_range):
|
||||
return False
|
||||
return has_any
|
||||
|
||||
|
||||
def is_docs_or_skip_mark_only_change(changed_files: list[str], *, diff_range: str) -> bool:
|
||||
"""True when each changed file is a doc path or a qualifying skip-mark test change."""
|
||||
has_any = False
|
||||
for file_path in changed_files:
|
||||
if not file_path:
|
||||
continue
|
||||
has_any = True
|
||||
if _is_doc_file(file_path):
|
||||
continue
|
||||
if _is_test_python_file(file_path) and _qualifies_as_skip_mark_test_change(file_path, diff_range):
|
||||
continue
|
||||
_log(f"docs/skip-mark-only: rejecting {file_path}")
|
||||
return False
|
||||
return has_any
|
||||
|
||||
|
||||
def resolve_skip_ci(changed_files: list[str] | None) -> bool:
|
||||
def resolve_skip_ci(changed_files: list[str] | None, *, diff_range: str | None = 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):
|
||||
|
||||
if diff_range is not None and is_docs_or_skip_mark_only_change(changed_files, diff_range=diff_range):
|
||||
if is_docs_only_change(changed_files):
|
||||
_log("docs-only change detected; skip-ci=1")
|
||||
elif is_skip_mark_only_change(changed_files, diff_range=diff_range):
|
||||
_log("pytest skip-mark-only change detected; skip-ci=1")
|
||||
else:
|
||||
_log("docs + pytest skip-mark-only change detected; skip-ci=1")
|
||||
return True
|
||||
|
||||
if diff_range is None and is_docs_only_change(changed_files):
|
||||
_log("docs-only change detected; skip-ci=1")
|
||||
return True
|
||||
_log("non-doc changes detected; skip-ci=0")
|
||||
|
||||
_log("non-doc/non-skip-mark changes detected; skip-ci=0")
|
||||
return False
|
||||
|
||||
|
||||
@@ -252,10 +439,14 @@ def resolve_pipeline_path(arg: str) -> Path:
|
||||
|
||||
def render_pipeline(path: Path) -> str:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
diff_range = resolve_diff_range()
|
||||
changed_files = resolve_changed_files()
|
||||
|
||||
if BOOTSTRAP_MARKER in text:
|
||||
rendered = render_bootstrap_pipeline(text, skip_ci=resolve_skip_ci(changed_files))
|
||||
rendered = render_bootstrap_pipeline(
|
||||
text,
|
||||
skip_ci=resolve_skip_ci(changed_files, diff_range=diff_range),
|
||||
)
|
||||
return rendered
|
||||
|
||||
doc = yaml.safe_load(text)
|
||||
|
||||
@@ -179,17 +179,30 @@ steps:
|
||||
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"
|
||||
# It is the whisper ASR validation step that is slow, not the Voxtral TTS generation.
|
||||
# whisper ASR runs on CPU.
|
||||
- label: "Voxtral-TTS E2E Test (Online Serving)"
|
||||
agent_pool: mi325_1
|
||||
depends_on: amd-build
|
||||
mirror_hardwares: [amdproduction]
|
||||
grade: Blocking
|
||||
commands:
|
||||
- |
|
||||
timeout 20m bash -c '
|
||||
export VLLM_LOGGING_LEVEL=DEBUG
|
||||
timeout 40m bash -c '
|
||||
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"
|
||||
pytest -s -v tests/e2e/online_serving/test_voxtral_tts.py -m "advanced_model" --run-level "advanced_model"
|
||||
'
|
||||
|
||||
- label: "Voxtral-TTS E2E Test (Offline Inference)"
|
||||
agent_pool: mi325_1
|
||||
depends_on: amd-build
|
||||
mirror_hardwares: [amdproduction]
|
||||
grade: Blocking
|
||||
commands:
|
||||
- |
|
||||
timeout 30m bash -c '
|
||||
export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
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
|
||||
|
||||
@@ -185,20 +185,6 @@ steps:
|
||||
- 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
|
||||
|
||||
@@ -61,7 +61,7 @@ steps:
|
||||
steps:
|
||||
- label: "Diffusion · Offloader Test"
|
||||
commands:
|
||||
- timeout 10m pytest -s -v tests/diffusion/offloader -m "core_model and cuda" --run-level "core_model"
|
||||
- timeout 40m pytest -s -v tests/diffusion/offloader -m "core_model and cuda" --run-level "core_model"
|
||||
agents:
|
||||
queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU
|
||||
plugins:
|
||||
|
||||
@@ -288,9 +288,36 @@ Required updates:
|
||||
5. `examples/offline_inference/xxx/README.md` — offline example docs
|
||||
6. `examples/online_serve/xxx/README.md` — online serve docs
|
||||
|
||||
### Step 8: Add E2E Tests (Recommended)
|
||||
### Step 8: Add E2E Tests
|
||||
|
||||
Create `tests/e2e/online_serving/test_your_model_expansion.py`.
|
||||
**Follow the [vllm-omni-test skill](../vllm-omni-test/SKILL.md)** for markers, file naming, Buildkite wiring, and run commands. Also read [l4_functionality_tests.inc.md](https://github.com/vllm-project/vllm-omni/blob/main/docs/contributing/ci/test_examples/l4_functionality_tests.inc.md) and [CI_5levels.md](https://github.com/vllm-project/vllm-omni/blob/main/docs/contributing/ci/CI_5levels.md).
|
||||
|
||||
Classify the model's **CI priority** first:
|
||||
|
||||
| Priority | Required test levels | Files & markers |
|
||||
|----------|---------------------|-----------------|
|
||||
| **High** (listed in [#1832](https://github.com/vllm-project/vllm-omni/issues/1832) or on the diffusion hot path) | **L1** · **L2** online · **L3** online + offline · **L4** feature + performance | See table below |
|
||||
| **Medium** (*normal priority* in L4 docs) | **L3** online + offline · **L4** feature only | Fewer L4 parametrized rows |
|
||||
| **Low** | **L4** feature only | One or two `*_expansion.py` cases |
|
||||
|
||||
**Per-level deliverables (diffusion / `pytest.mark.diffusion`):**
|
||||
|
||||
| Level | Location | Marker | CI pipeline | Notes |
|
||||
|-------|----------|--------|-------------|-------|
|
||||
| **L1** | `tests/diffusion/models/{slug}/`, `tests/diffusion/cache/`, transformer unit tests | `core_model` + `cpu` | `test-ready.yml` | Weight remap, `_sp_plan`, cache enabler registration, shape contracts |
|
||||
| **L2** | `tests/e2e/online_serving/test_{slug}.py` (and offline if the category is offline-first) | **`core_model` + `advanced_model`** (both on baseline smoke) + `diffusion` + `@hardware_test` / `hardware_marks` | `test-ready.yml` | Default deploy smoke — minimal `num_inference_steps`, single prompt |
|
||||
| **L3** | `tests/e2e/online_serving/test_{slug}.py` **and** `tests/e2e/offline_inference/test_{slug}.py` when offline matters | Baseline smoke: **`core_model` + `advanced_model`**; heavier cases: `advanced_model` only (+ `diffusion`) | `test-merge.yml` **or** merged into nightly diffusion function job | Real weights, streaming/API paths, LoRA/offload smoke |
|
||||
| **L4** | `tests/e2e/online_serving/test_{slug}_expansion.py` (+ offline expansion if needed) | `full_model` + `diffusion` | `test-nightly.yml` (X2I / X2V / X2A function groups) | Feature combos per [#1832](https://github.com/vllm-project/vllm-omni/issues/1832); perf → `tests/dfx/perf/tests/test_{model}_vllm_omni.json` |
|
||||
|
||||
**L2 & L3 online — same file, dual marks on the baseline smoke:** The **first / simplest** case in `test_{slug}.py` (default deploy, minimal steps, single prompt) should carry **both** `@pytest.mark.core_model` and `@pytest.mark.advanced_model` on the **same** function so L2 (`test-ready.yml`) and L3 (`test-merge.yml`) share one smoke test. Heavier deploy variants or API paths in the same file use **`advanced_model` only**. When L3 moves to nightly, migrate those heavier cases into `test_{slug}_expansion.py` with `full_model` and remove the dedicated `test-merge.yml` job (see `test_longcat_image_expansion.py`, `test_qwen_image_expansion.py`).
|
||||
|
||||
**L4 design (high priority):** Combine multiple supported features (Cache-DiT, TP, USP, CFG, HSDP, CPU offload, quantization) into **few parametrized** `OmniServerParams` rows so each feature appears in at least one case without exploding GPU jobs. Shard single-GPU vs multi-GPU cases across the nightly X2I/X2V function steps (1-GPU vs `distributed_cuda`).
|
||||
|
||||
**L4 design (medium / low):** One or two parametrized rows covering the best quality/perf trade-off; skip perf JSON unless the model is high priority.
|
||||
|
||||
**Reference implementations:** `tests/e2e/online_serving/test_qwen_image_edit_expansion.py`, `tests/e2e/online_serving/test_longcat_image_expansion.py`, `tests/e2e/online_serving/test_hunyuan_video_15_expansion.py`.
|
||||
|
||||
**Keep model-specific code inside test modules — not `tests/helpers/{slug}.py`:** deploy constants, prompts, sampling dicts, and inline `request_config` / `form_data` belong in each `test_{slug}.py` and `test_{slug}_expansion.py`. Do not add per-model files under `tests/helpers/`; reuse only repo-wide harness (`mark`, `media`, `runtime`, `stage_config`, `assertions`). **L2+ online/offline e2e:** reuse or add `send_*_request` in `tests/helpers/runtime.py` — tests call the handler, not raw `omni.generate` / HTTP. See [vllm-omni-test skill](../vllm-omni-test/SKILL.md) § **Runtime send helpers**.
|
||||
|
||||
### Step 9: Add Cache-DiT Acceleration
|
||||
|
||||
@@ -568,6 +595,7 @@ See the [Profiling Single-Stage Diffusion](../../../docs/contributing/profiling.
|
||||
|
||||
## Reference Files
|
||||
|
||||
- [vllm-omni-test skill](../vllm-omni-test/SKILL.md) — L1–L4 markers, naming, Buildkite wiring, run commands
|
||||
- [Transformer Adaptation](references/transformer-adaptation.md) — porting transformers from diffusers
|
||||
- [Custom Model Patterns](references/custom-model-patterns.md) — patterns for non-diffusers models
|
||||
- [Parallelism Patterns](references/parallelism-patterns.md) — TP, SP/USP, CFG parallel, HSDP implementation details
|
||||
|
||||
@@ -305,13 +305,72 @@ def build_voice_clone_prompt(ref_audio_path: str, text: str, codec) -> list:
|
||||
]
|
||||
```
|
||||
|
||||
### Test Case Writing (CI Levels)
|
||||
|
||||
**Follow the [vllm-omni-test skill](../vllm-omni-test/SKILL.md)** for markers, file naming (`test_{slug}.py` / `test_{slug}_expansion.py`), Buildkite wiring, and copy-paste run commands. Also read [CI_5levels.md](https://github.com/vllm-project/vllm-omni/blob/main/docs/contributing/ci/CI_5levels.md) and [tests_style.md](https://github.com/vllm-project/vllm-omni/blob/main/docs/contributing/ci/tests_style.md).
|
||||
|
||||
Classify the model's **CI priority** first (high / medium / low). High-priority TTS models are typically those on the integration hot path or listed in tracking issues such as [#1832](https://github.com/vllm-project/vllm-omni/issues/1832); medium and low tiers cover the long tail. When unsure, ask the reviewer which tier applies.
|
||||
|
||||
| Priority | Required test levels | Files & markers |
|
||||
|----------|---------------------|-----------------|
|
||||
| **High** | **L1** unit/logic · **L2** online smoke · **L3** online + offline integration · **L4** feature + performance | See table below |
|
||||
| **Medium** | **L3** online + offline · **L4** feature only | Skip dedicated L1/L2 unless fixing a logic bug |
|
||||
| **Low** | **L4** feature only | One or two `*_expansion.py` parametrized cases |
|
||||
|
||||
**Per-level deliverables (TTS / `pytest.mark.tts`):**
|
||||
|
||||
| Level | Location | Marker | CI pipeline | Notes |
|
||||
|-------|----------|--------|-------------|-------|
|
||||
| **L1** | `tests/model_executor/…`, `tests/entrypoints/openai_api/…`, stage-processor tests | `core_model` + `cpu` | `test-ready.yml` | Prompt assembly, async_chunk helpers, `serving_speech` validation — no GPU |
|
||||
| **L2** | `tests/e2e/online_serving/test_{slug}.py` | **`core_model` + `advanced_model`** (both on baseline smoke) + `tts` + `@hardware_test(...)` | `test-ready.yml` (`ready` label) | Default deploy smoke: single `/v1/audio/speech` or offline `OmniRunner` path |
|
||||
| **L3** | `tests/e2e/online_serving/test_{slug}.py` **and** `tests/e2e/offline_inference/test_{slug}.py` | Baseline smoke: **`core_model` + `advanced_model`**; heavier cases: `advanced_model` only (+ `tts`) | `test-merge.yml` **or** merged into nightly TTS function job | Streaming, voice clone, batch/queue, async_chunk |
|
||||
| **L4** | `tests/e2e/online_serving/test_{slug}_expansion.py`, optional offline expansion | `full_model` + `tts` | `test-nightly.yml` (`:full_moon: TTS · Function Test with L4`) | Feature matrix; perf → `tests/dfx/perf/tests/test_tts.json` |
|
||||
|
||||
**L2 & L3 online — same file, dual marks on the baseline smoke:** The **first / simplest** case in `test_{slug}.py` (default deploy, single non-streaming `/v1/audio/speech` or equivalent offline path) should carry **both** `@pytest.mark.core_model` **and** `@pytest.mark.advanced_model` on the **same** function so it runs in L2 (`test-ready.yml`, `--run-level core_model`, basic validation) and L3 (`test-merge.yml`, `--run-level advanced_model`, deeper validation) without duplicating the test. In-tree examples: `test_voxcpm2_tts.py::test_text_to_audio_001`, `test_qwen3_tts_customvoice.py::test_text_to_audio_001`.
|
||||
|
||||
Heavier scenarios in the same file use **`advanced_model` only** (streaming, extra languages, concurrency, async_chunk, batch). Example: `test_voice_clone_en_streaming_001` → `advanced_model` only. When migrating L3 to nightly, move those heavier cases into `test_{slug}_expansion.py` with `full_model` and drop the dedicated merge job (see `test_ming_tts_expansion.py`, `test_glm_tts_expansion.py`).
|
||||
|
||||
```python
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
def test_voice_clone_en_non_streaming_001(omni_server, openai_client) -> None:
|
||||
openai_client.send_audio_speech_request({...})
|
||||
```
|
||||
|
||||
**L4 consolidation:** Prefer parametrized `OmniServerParams` rows (`default`, `async_chunk`, feature flags) in one expansion module rather than many merge-only files ([#1832](https://github.com/vllm-project/vllm-omni/issues/1832)).
|
||||
|
||||
**L4 performance (high-priority models):** Add latency / throughput / stress rows in `tests/dfx/perf/tests/test_tts.json`, or a dedicated `tests/dfx/perf/tests/test_{slug}.json` when the model must not join the shared nightly server matrix before integration lands (see VoxCPM2 / Coqui XTTS pattern). Register the model in `benchmarks/tts/model_configs.yaml` for local `bench_tts.py`. Wire a **separate** `test-nightly.yml` Perf Test step when the JSON is not merged into `test_tts.json` yet.
|
||||
|
||||
**Keep model-specific code inside test modules — not `tests/helpers/{slug}.py`:**
|
||||
|
||||
- Put `MODEL`, deploy path, vendored `REF_AUDIO_URL`, `get_prompt()`, and inline `request_config` dicts **in each** `test_{slug}.py`, `test_{slug}_expansion.py`, offline `test_{slug}.py`, and L1 `test_{slug}_*.py` as needed.
|
||||
- **Do not** add `tests/helpers/{slug}.py` (or `tests/helpers/{model_name}.py`) to deduplicate constants or request builders across those files. A little duplication is intentional; follow in-tree references such as `tests/e2e/online_serving/test_glm_tts.py` and `tests/e2e/online_serving/test_cosyvoice3_tts_expansion.py`.
|
||||
- `tests/helpers/` is for **repo-wide** harness code only (`mark.py`, `media.py`, `runtime.py`, `stage_config.py`, `assertions.py`, `fixtures/`). Import those; do not extend the tree with per-model modules.
|
||||
|
||||
**Runtime send helpers (`tests/helpers/runtime.py`) — online and offline e2e:**
|
||||
|
||||
| Path | Fixture | Call |
|
||||
|------|---------|------|
|
||||
| Online `/v1/*` | `openai_client` | `openai_client.send_*_request(request_config)` |
|
||||
| Offline inference | `omni_runner_handler` | `omni_runner_handler.send_*_request(request_config)` |
|
||||
|
||||
1. **Grep `runtime.py` first** — reuse `send_omni_request`, `send_diffusion_request`, `send_audio_speech_request` (online + offline Qwen-style TTS), `send_single_stage_tts_request` (Coqui XTTS / MOSS-TTS-Nano offline), etc.
|
||||
2. **No matching helper** → add `send_<feature>_request` (or `send_<route>_http_request` for negative/dfx) in **`runtime.py`** with general `assert_*` bundled inside, **then** call it from the test.
|
||||
3. **Test file** holds `request_config` dicts only — not `omni.generate`, not `_collect_audio()`, not raw HTTP/SDK.
|
||||
4. Wire `tests/helpers/runtime.py` into Buildkite `source_file_dependencies` when you add helpers.
|
||||
|
||||
See [vllm-omni-test skill](../vllm-omni-test/SKILL.md) § **Runtime send helpers** for full tables and exceptions.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Updated `serving_speech.py` with all 5 integration points (single commit)
|
||||
- Client scripts and server launcher under `examples/online_serving/text_to_speech/<model>/`
|
||||
- Gradio demo with streaming and voice cloning UI in the same dir
|
||||
- E2E online serving test (`tests/e2e/online_serving/test_<model>.py`)
|
||||
- Buildkite CI entry in `.buildkite/test-merge.yml`
|
||||
- E2E tests per **Test Case Writing (CI Levels)** above (priority tier determines L1–L4 scope)
|
||||
- Buildkite wired per level: `test-ready.yml` (L1/L2), `test-merge.yml` or nightly function job (L3), `test-nightly.yml` (L4) — see [vllm-omni-test skill](../vllm-omni-test/SKILL.md)
|
||||
- New section in `examples/online_serving/text_to_speech/README.md` (table row + per-model section). Do **not** create a top-level `examples/online_serving/<model>/` dir or a per-model `README.md` inside `text_to_speech/<model>/`.
|
||||
|
||||
### E2E test pitfalls to avoid
|
||||
@@ -326,9 +385,9 @@ def build_voice_clone_prompt(ref_audio_path: str, text: str, codec) -> list:
|
||||
- **Use the harness readiness gate.** The fixture waits for HTTP 200 on
|
||||
`/health`; don't add `time.sleep` in tests. If warmup is incomplete, make
|
||||
`/health` return non-200 until you're actually ready.
|
||||
- **Mark with `@pytest.mark.core_model` + `hardware_test(res={"cuda": "H100"})`**
|
||||
so the test lands in `test-ready.yml` (triggered by the `ready` label) rather
|
||||
than only nightly.
|
||||
- **Mark tests per the CI Levels table** — baseline smoke: `core_model` + `advanced_model`; heavier cases: `advanced_model` only; L4 expansion: `full_model`
|
||||
- **No per-model helper modules** — do not create `tests/helpers/{slug}.py`; keep constants and `request_config` payloads in the test file
|
||||
- **Online and offline e2e go through `runtime.py`** — `openai_client.send_audio_speech_request` (online); `omni_runner_handler.send_audio_speech_request` (Qwen-style offline) or `send_single_stage_tts_request` (single-stage offline). Add a new `send_*_request` in `runtime.py` when none fits; do not embed `omni.generate` or HTTP in tests
|
||||
|
||||
## Phase 4: Async Chunk (Streaming)
|
||||
|
||||
@@ -461,8 +520,8 @@ Use this checklist when integrating a new TTS model:
|
||||
- [ ] Voice cloning works (if supported)
|
||||
- [ ] All response formats work (wav, mp3, flac, pcm)
|
||||
- [ ] Client scripts and server launcher created
|
||||
- [ ] E2E online serving test written (`tests/e2e/online_serving/test_<model>.py`)
|
||||
- [ ] Buildkite CI entry added to `.buildkite/test-merge.yml`
|
||||
- [ ] E2E tests added per model priority tier (see **Test Case Writing (CI Levels)**)
|
||||
- [ ] Buildkite entries match level: `test-ready.yml` / `test-merge.yml` or nightly TTS job / `test-nightly.yml`
|
||||
- [ ] Gradio demo working
|
||||
- [ ] Documentation added (offline + online docs, nav, supported models)
|
||||
|
||||
@@ -500,5 +559,6 @@ Project docs and adjacent skills:
|
||||
- [TTS audio skill](../vllm-omni-audio-tts/SKILL.md) — supported models and usage
|
||||
- [Fish Speech integration](../vllm-omni-audio-tts/references/fish-speech.md) — complete example of Phases 1–3
|
||||
- [Qwen3-TTS reference](../vllm-omni-audio-tts/references/qwen-tts.md) — complete example of all 5 phases
|
||||
- [vllm-omni-test skill](../vllm-omni-test/SKILL.md) — L1–L4 markers, naming, Buildkite wiring, run commands
|
||||
- [Adding a TTS model (developer guide)](https://github.com/vllm-project/vllm-omni/blob/main/docs/contributing/model/adding_tts_model.md)
|
||||
- `plan/voxcpm2_native_ar_design.md` — VoxCPM2's vLLM-native AR + side-computation pattern (distinct from the generator-based single-stage described above)
|
||||
@@ -30,6 +30,12 @@ include:
|
||||
- `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
|
||||
- `vllm-omni-test`: guides generation and execution of CI-aligned tests (L1–L4),
|
||||
pytest marker selection (`core_model` / `advanced_model` / `full_model`,
|
||||
`omni` / `tts` / `diffusion`), Buildkite wiring (`test-ready.yml`,
|
||||
`test-merge.yml`, `test-nightly.yml`, `test-weekly.yml`), and copy-paste
|
||||
local plus CI-like `pytest` commands; see `references/test-routing.md` for
|
||||
level-to-command mapping
|
||||
- `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
|
||||
|
||||
@@ -0,0 +1,890 @@
|
||||
---
|
||||
name: vllm-omni-test
|
||||
description: Generate and run tests for vllm-project/vllm-omni with CI-aligned levels and markers; wire new tests into Buildkite (test-ready.yml for L1/L2, test-merge.yml for L3, test-nightly.yml for L4). On completion, always provide copy-paste local and CI-like pytest commands plus prerequisites. Use when creating regression tests, adding L1-L4 coverage, selecting pytest markers, or validating fixes from issues/PRs.
|
||||
---
|
||||
|
||||
# vLLM-Omni Test Generator & Runner
|
||||
|
||||
## Purpose
|
||||
|
||||
Use this skill to generate minimal, stable test cases and run them with the correct marker/level strategy for [vllm-project/vllm-omni](https://github.com/vllm-project/vllm-omni).
|
||||
|
||||
**Link convention:** Paths such as `.buildkite/` and `docs/contributing/` live at the **vllm-omni repo root**. Markdown links use repo-relative paths from this skill file (e.g. `../../../.buildkite/test-ready.yml`, `../../../docs/contributing/ci/CI_5levels.md`).
|
||||
|
||||
Default priorities:
|
||||
|
||||
1. Reproducible regression coverage for bug fixes
|
||||
2. Correct test level and marker selection
|
||||
3. Low flake, low dependency tests first
|
||||
4. CI-compatible run commands
|
||||
5. **Actionable run commands for the human**: whenever you add or change tests, always finish with **copy-paste-ready** `pytest` lines (local: single file and/or single test; CI-like: markers + `--run-level`), plus short **prerequisites** (GPU tier, HF cache, optional `model_prefix`). Do not assume the reader will infer commands from `test-routing.md` alone.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Issue/PR link and summary
|
||||
- Changed files or suspected code path
|
||||
- Whether the user wants local quick validation or CI-equivalent validation
|
||||
- Hardware constraints (CPU only / CUDA / ROCm / NPU)
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Classify Test Goal
|
||||
|
||||
- **Bugfix regression**: start from a minimal failing scenario and add assertions that prevent recurrence. Before writing tests, output **`required` / `recommended` / `not_needed`**: **`required`** — stable logic/contract bug that should have been caught; **`recommended`** — environment-sensitive but a small regression still helps; **`not_needed`** — one-off external/config failure or existing tests already cover the path. Prefer the narrowest stable L1 (CPU) case; escalate to L2/L3 only when the bug needs real weights or serving.
|
||||
- **Feature coverage**: verify new behavior and one negative/boundary case.
|
||||
- **Perf/benchmark claim**: require benchmark-oriented tests and explicit metrics.
|
||||
|
||||
### Step 2: Select Test Level
|
||||
|
||||
- **L1**: unit/logic, deterministic, CPU-friendly, fastest feedback.
|
||||
- **L2**: basic e2e/integration and platform-dependent checks.
|
||||
- **L3/L4**: advanced model/integration/perf validation.
|
||||
|
||||
Use [references/test-routing.md](references/test-routing.md) for level-to-marker and command mapping.
|
||||
|
||||
### Step 3: Pick Markers
|
||||
|
||||
Always attach markers deliberately:
|
||||
|
||||
- **Level**: `core_model` (L1/L2) and/or `advanced_model` (L3) and/or `full_model` (L4 nightly)
|
||||
- **Model type** (required on model-centric e2e — pick exactly one):
|
||||
- `omni` — end-to-end multimodal LLM pipelines (thinker/talker/stages; Qwen-Omni family)
|
||||
- `tts` — speech synthesis / TTS-only models (`/v1/audio/speech`, voice clone, etc.)
|
||||
- `diffusion` — generative diffusion models (image / audio / text / video from noise)
|
||||
- **Cross-cutting area** (when relevant): `parallel`, `cache`, `example`, `benchmark`
|
||||
- **Hardware**: `cpu`, `gpu`, `cuda`, `rocm`, `npu`, `L4`, `H100`, `distributed_cuda`, …
|
||||
- Optional: `slow`, distributed markers when multi-card is required
|
||||
|
||||
**Baseline smoke (L2 + L3):** The simplest e2e case per model — default deploy, minimal request — should usually carry **both** `@pytest.mark.core_model` and `@pytest.mark.advanced_model` on the **same** test function so `test-ready.yml` and `test-merge.yml` share one test. `send_*_request` picks validation depth from `--run-level`. References: `test_voxcpm2_tts.py::test_text_to_audio_001`, `test_qwen3_tts_customvoice.py::test_text_to_audio_001`. Heavier scenarios use **`advanced_model` only**; L4 expansion uses **`full_model`**.
|
||||
|
||||
For hardware-aware tests, prefer `@hardware_test(...)` or `hardware_marks(...)` in `tests/helpers/mark.py`.
|
||||
|
||||
**Diffusion nightly split** (`test-nightly.yml`): all diffusion tests use `pytest.mark.diffusion`, but CI groups them by **output modality**, not by separate pytest markers:
|
||||
|
||||
| Nightly group | Scope | Typical models / paths |
|
||||
|---------------|--------|-------------------------|
|
||||
| **Diffusion X2I(&A&T) Model Test** | x2**i** (image), x2**a** (audio), x2**t** (text) and other **non-video** diffusion | Qwen-Image*, BAGEL, FLUX, SD3, Z-Image, LongCat, DreamZero, … — `test_*_expansion.py` under X2I shards; L4 sweep uses `-k "not test_wan and not test_bagel_expansion and not hunyuan"` for L4 |
|
||||
| **Diffusion X2V Model Test** | x2**v** (video) only | Wan2.2, HunyuanVideo 1.5, Wan VACE, … — e.g. `test_wan22_expansion.py`, `test_hunyuan_video_15_expansion.py` |
|
||||
|
||||
Wire new **video** diffusion expansion tests into the **X2V** group; wire **image/audio/text** diffusion expansion tests into **X2I(&A&T)**. Do not place x2v modules in X2I shards (see comment in `test-nightly.yml` above the X2I group).
|
||||
|
||||
### Naming: generated test module files (L2–L4, model-centric e2e)
|
||||
|
||||
When adding **new** pytest modules whose primary scope is a **specific model** (typical under `tests/e2e/offline_inference/` or `tests/e2e/online_serving/`), use this filename pattern:
|
||||
|
||||
| Level | Filename pattern | Example (model `Qwen/Qwen2.5-Omni-7B`) |
|
||||
|-------|------------------|----------------------------------------|
|
||||
| **L2**, **L3** | `test_{lowercase_model_slug}.py` | `test_qwen2_5_omni.py` |
|
||||
| **L4** | `test_{lowercase_model_slug}_expansion.py` | `test_qwen2_5_omni_expansion.py` |
|
||||
|
||||
**Slug rules for `{lowercase_model_slug}`:**
|
||||
|
||||
1. Start from the HuggingFace-style id (e.g. `Qwen/Qwen2.5-Omni-7B`), but **do not** put the org into the filename: use the **repo segment only** (`Qwen2.5-Omni-7B`), not `Qwen_...` / `qwen_qwen2_5_...`.
|
||||
2. **Lowercase**; replace `.`, `-`, and whitespace with a single `_` (e.g. `Qwen2.5-Omni-7B` → `qwen2_5_omni`). **Omit** trailing size tokens such as `7b` / `30b` in the basename when a single file covers that model line in the directory (matches `test_qwen2_5_omni.py` in-tree).
|
||||
3. If two checkpoints in the same folder need separate modules, add a **minimal** disambiguator (e.g. `_7b` vs `_3b`) only then.
|
||||
4. **L1** unit tests are **not** bound to this pattern; use `tests/<area>/test_<feature>.py` as today.
|
||||
|
||||
Routing tables and commands: [references/test-routing.md](references/test-routing.md) § *Model-centric e2e filename convention*.
|
||||
|
||||
Existing references: `tests/e2e/offline_inference/test_qwen2_5_omni.py` (L2-style omni), `tests/e2e/offline_inference/test_qwen3_5_9b.py` (L2-style omni, single-stage VL), `tests/e2e/online_serving/test_qwen3_omni_expansion.py` (L4-style omni), `tests/e2e/online_serving/test_qwen_image_edit_expansion.py` / `test_qwen_image_expansion.py` (L4-style diffusion).
|
||||
|
||||
### Step 4: Generate Test Case Skeleton
|
||||
|
||||
**1. Pick the functional scenario** (then choose directory, fixtures, and markers):
|
||||
|
||||
| Scenario | Typical location | Fixtures / runner pattern | Baseline markers & level |
|
||||
|----------|------------------|---------------------------|---------------------------|
|
||||
| **Offline inference e2e** | `tests/e2e/offline_inference/` | **Module (default):** `omni_runner` + `omni_runner_handler`. **Function (isolation only):** `omni_runner_function` + `omni_runner_handler_function`. Diffusion/TTS may use `Omni(...).generate` directly | L2: `core_model` + **one of** `omni` / `tts` / `diffusion`; `@hardware_test(...)` when GPU/NPU is required |
|
||||
| **Online serving e2e** | `tests/e2e/online_serving/` | **Module (default):** `omni_server` + `openai_client`. **Function (isolation only):** `omni_server_function` + `openai_client_function`. Clients: `send_omni_request` (omni), `send_audio_speech_request` (tts), `send_diffusion_request` / `send_video_diffusion_request` / `send_images_generations_request` (diffusion) | Baseline smoke: **`core_model` + `advanced_model`**; heavier paths: `advanced_model` only; L4 expansion: `full_model` |
|
||||
| **Documentation / runnable examples** | `tests/examples/offline_inference/`, `tests/examples/online_serving/` | **Offline docs (preferred):** extract Python/Bash blocks from the doc README (e.g. `ReadmeSnippet.extract_readme_snippets`), `pytest.mark.parametrize` each snippet, run via `example_runner.run` with a stable `output_subfolder`. **Online docs:** copy client/request scripts into dedicated tests and keep them in sync with the doc page. | Usually **L4**: `advanced_model`, often `example` plus hardware marks matching the nightly docs-example job (see `.buildkite/test-nightly.yml`). Full conventions: [docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md](../../../docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md) (introduced in [PR #1910](https://github.com/vllm-project/vllm-omni/pull/1910): naming, output directory layout, skip rules, avoid trimming `num_inference_steps` without a strong CI reason). |
|
||||
| **Performance / benchmark** | `tests/dfx/perf/tests/*.json` + `run_*_benchmark.py` | JSON or script-driven server + load config; assert explicit metrics / baselines | L4 Perf: `full_model` + `benchmark`; wire `test-nightly.yml` Perf steps |
|
||||
| **Invalid parameter / negative HTTP validation** | `tests/dfx/reliability/invalid_param_test/` | Live `omni_server` + low-level `send_*_http_request` with `err_code` / `err_message` | `pytest.mark.slow` + `omni` / `tts` / `diffusion` + `@hardware_marks` (`H100` or `L4`); CI in **`test-weekly.yml`** (not ready/merge/nightly) |
|
||||
|
||||
**If the user’s test plan includes invalid parameter validation / invalid params / negative HTTP / 400 validation:** do **not** add those `test_*` functions to `tests/e2e/online_serving/test_*.py` or `*_expansion.py`. **Move or author them** under `tests/dfx/reliability/invalid_param_test/` in the **endpoint-matching script** (see **Invalid parameter validation** below). Success-path e2e and invalid-param dfx tests must stay in separate modules.
|
||||
|
||||
**1b. Model type — `omni` vs `tts` vs `diffusion`**
|
||||
|
||||
After choosing offline/online/docs/perf, classify the **product under test** and attach **exactly one** model-type marker. All three can live under the same `tests/e2e/` trees; conventions diverge:
|
||||
|
||||
| Dimension | **Omni** (`pytest.mark.omni`) | **TTS** (`pytest.mark.tts`) | **Diffusion** (`pytest.mark.diffusion`) |
|
||||
|-----------|------------------------------|----------------------------|----------------------------------------|
|
||||
| **What it is** | Multimodal LLM pipeline (thinker/talker/stages; text + vision + audio I/O) | Speech synthesis / voice models | Generative diffusion (noise → image, audio, text, or video) |
|
||||
| **Examples** | Qwen2.5-Omni, Qwen3-Omni | Qwen3-TTS, VoxCPM2, Higgs-Audio, Step-Audio2 | Qwen-Image, BAGEL, Wan2.2, HunyuanVideo |
|
||||
| **Offline runner** | `omni_runner` + `omni_runner_handler`, `generate_multimodal` | `Omni(...).generate` or stage YAML + TTS params | `Omni(...).generate` + `OmniDiffusionSamplingParams` |
|
||||
| **Online client** | `openai_client.send_omni_request` (chat completions, modalities) | `openai_client.send_audio_speech_request` (`/v1/audio/speech`) | `send_diffusion_request` (chat/T2I), `send_video_diffusion_request` (`/v1/videos`, X2V), or `send_images_generations_http_request` / `send_images_edits_http_request` (DALL-E routes) |
|
||||
| **Typical assertions** | Stage outputs, text/audio keywords via `OmniRunnerHandler` / response handler | WAV bytes, stream chunks, speech endpoint contract | Image/video dimensions, `final_output_type`, `assert_diffusion_response` |
|
||||
| **Stage / deploy YAML** | Per-model omni stage configs (`ci/qwen3_omni_moe.yaml`, …) | `qwen3_tts.yaml`, `voxcpm2.yaml`, … | Often default serve; parallel/offload YAML for heavy DiT |
|
||||
| **Nightly group** (`test-nightly.yml`) | **Omni Model Test** — `-m "full_model and omni"` | **TTS Model Test** — `-m "full_model and tts"` | **Diffusion X2I(&A&T)** *or* **Diffusion X2V** (see Step 3 table; same `diffusion` marker, different YAML group / file shard) |
|
||||
| **L4 pressure** | Expansion per modality/model as needed | Expansion + accuracy/perf in TTS group | X2I: merge feature combos per [#1832](https://github.com/vllm-project/vllm-omni/issues/1832); X2V: separate nightly group |
|
||||
|
||||
Do **not** mix fixtures across types (e.g. do not use `omni_runner` layout for a pure diffusion or TTS model without mirroring an in-tree test in that family).
|
||||
|
||||
**Diffusion only — X2I(&A&T) vs X2V (nightly routing, not extra markers):**
|
||||
|
||||
- **X2I(&A&T)**: image / audio / text generation — Qwen-Image*, FLUX, SD3, Z-Image, BAGEL (expansion), LongCat, audio diffusion, etc.
|
||||
- **X2V**: video generation only — Wan2.2, HunyuanVideo 1.5, Wan VACE, LTX video similarity paths.
|
||||
|
||||
When adding a new `test_*_expansion.py`, place it in the matching **nightly group** step (explicit file list in `test-nightly.yml`), not only by marker expression.
|
||||
|
||||
**2. Use the narrowest deterministic skeleton for the scenario**
|
||||
|
||||
*L1 unit / logic (CPU-first):*
|
||||
|
||||
**Mocking rule (L1 only):** use **pytest** integration — **`mocker`** (`pytest-mock`) or **`monkeypatch`** (built-in). **Do not** import or call **`unittest.mock`** (`patch`, `MagicMock`, `@patch`, `with patch(...)`, etc.) in L1 tests; patches must auto-revert with the test lifecycle.
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_<scenario_name>(mocker):
|
||||
# Prefer mocker.patch / mocker.spy / mocker.Mock — not unittest.mock.patch
|
||||
fake_fn = mocker.patch("vllm_omni.some.module.expensive_call", return_value=...)
|
||||
# Act
|
||||
# Assert
|
||||
fake_fn.assert_called_once()
|
||||
|
||||
|
||||
def test_<env_or_attr>(monkeypatch):
|
||||
# For simple env / attribute substitution without a Mock object
|
||||
monkeypatch.setenv("SOME_FLAG", "1")
|
||||
monkeypatch.setattr("vllm_omni.some.module.CONST", 42)
|
||||
...
|
||||
```
|
||||
|
||||
**Avoid in L1:**
|
||||
|
||||
```python
|
||||
# BAD — do not use in L1 unit tests
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
@patch("vllm_omni.some.module.fn")
|
||||
def test_bad(mock_fn): ...
|
||||
|
||||
def test_also_bad():
|
||||
with patch("...") as m: ...
|
||||
```
|
||||
|
||||
See **L1 unit test constraints (mocking)** below for the full do/don't list.
|
||||
|
||||
*Offline multimodal e2e — **Omni** (representative):*
|
||||
|
||||
```python
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(...)
|
||||
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
|
||||
def test_<scenario>(omni_runner, omni_runner_handler) -> None:
|
||||
request_config = {"prompts": ..., "modalities": [...]} # optional: images, videos, audios
|
||||
omni_runner_handler.send_omni_request(request_config)
|
||||
```
|
||||
|
||||
*Offline generative e2e — **Diffusion** (representative):*
|
||||
|
||||
```python
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.diffusion
|
||||
@hardware_test(...)
|
||||
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
|
||||
def test_text_to_image_001(omni_runner_handler) -> None:
|
||||
omni_runner_handler.send_diffusion_request({"prompt": "...", "extra_body": {"num_inference_steps": 4, ...}})
|
||||
```
|
||||
|
||||
*Offline TTS e2e — **Qwen3-TTS** (two-stage; representative):*
|
||||
|
||||
```python
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(...)
|
||||
@pytest.mark.parametrize("omni_runner", tts_server_params, indirect=True)
|
||||
def test_text_to_audio_001(omni_runner, omni_runner_handler) -> None:
|
||||
omni_runner_handler.send_audio_speech_request({
|
||||
"input": "...",
|
||||
"task_type": "Base",
|
||||
"ref_audio": REF_AUDIO_URL,
|
||||
"ref_text": REF_TEXT,
|
||||
})
|
||||
```
|
||||
|
||||
*Offline TTS e2e — **single-stage** (Coqui XTTS, MOSS-TTS-Nano; representative):*
|
||||
|
||||
```python
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(...)
|
||||
@pytest.mark.parametrize("omni_runner", tts_server_params, indirect=True)
|
||||
def test_voice_clone_001(omni_runner_handler) -> None:
|
||||
omni_runner_handler.send_single_stage_tts_request({
|
||||
"input": "...",
|
||||
"language": "en",
|
||||
"prompt_audio_path": REF_AUDIO_PATH,
|
||||
"response_format": "wav",
|
||||
"run_level": "advanced_model",
|
||||
})
|
||||
```
|
||||
|
||||
*Online serving e2e — **Omni** (representative):*
|
||||
|
||||
```python
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(...)
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_text_to_text_001(omni_server, openai_client) -> None:
|
||||
request_config = {"model": omni_server.model, "messages": ..., "modalities": ["text"]}
|
||||
openai_client.send_omni_request(request_config)
|
||||
```
|
||||
|
||||
*Online serving e2e — **TTS** (representative):*
|
||||
|
||||
```python
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(...)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
def test_text_to_audio_001(omni_server, openai_client) -> None:
|
||||
request_config = {"model": omni_server.model, "input": "...", "response_format": "wav", ...}
|
||||
openai_client.send_audio_speech_request(request_config)
|
||||
```
|
||||
|
||||
*Online serving e2e — **Diffusion** X2I (representative):*
|
||||
|
||||
```python
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parametrize("omni_server", _get_default_case(MODEL), indirect=True)
|
||||
def test_text_to_image_001(omni_server, openai_client) -> None:
|
||||
openai_client.send_diffusion_request({...}) # chat completions + extra_body
|
||||
```
|
||||
|
||||
*Online serving e2e — **Diffusion** X2V (representative):*
|
||||
|
||||
```python
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.diffusion
|
||||
def test_text_to_video_001(omni_server, openai_client) -> None:
|
||||
openai_client.send_video_diffusion_request({"model": ..., "form_data": {...}}) # /v1/videos
|
||||
```
|
||||
|
||||
*Documentation example tests:* follow the **Preferred Test Strategy** in [l4_doc_example_tests.inc.md](../../../docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md): dynamic extraction for offline READMEs; explicit copied client code for online pages until extraction is justified; use the documented **naming**, **output directory** (page folder + case id), and **skipping** rules (e.g. Gradio-only scripts).
|
||||
|
||||
*Performance tests:* add or extend entries under `tests/perf/` (and JSON configs where the project uses them), with **explicit baselines** and the same marker/run-level pairing as the CI step that will execute them.
|
||||
|
||||
**3. Cross-cutting rules**
|
||||
|
||||
- Reuse existing fixtures for the chosen scenario; do not mix “online client” assumptions into offline `OmniRunner` tests without a clear reason.
|
||||
- Avoid external network dependency in assertions unless the scenario is explicitly “online serving” or doc examples that require a model hub (then align with CI secrets/cache).
|
||||
- Keep **one test function = one intent** (one modality combo, one endpoint contract, or one acceleration combo).
|
||||
- **E2E test function layout**: **one case → one `test_<scenario>` function** with a name that states what is validated (endpoint, size/`n`, server flag, or route). **Do not** merge multiple cases into a single test that branches on `request.node.callspec.id`, `param.id`, or `if case_id == ...`. Use `@pytest.mark.parametrize("omni_server", [...], indirect=True)` per function (usually **one** `OmniServerParams` per test). A loop inside one test is OK only when it serves **that function’s single intent** (e.g. three standard sizes in `test_*_sizes_256_512_1024`).
|
||||
- **Runtime fixture scope** (`tests/helpers/fixtures/runtime.py`): default **`omni_server` / `omni_runner`** (module) + matching client/handler; use **`omni_server_function` / `omni_runner_function`** only when each `test_*` must start a fresh instance (see below).
|
||||
- **L1 mocks**: never `unittest.mock`; use `mocker` or `monkeypatch` only (see below).
|
||||
- **API calls (L2+ e2e, online and offline)**: **reuse** `send_*_request` in `tests/helpers/runtime.py` when it exists; **otherwise add** the helper in `runtime.py` first, then call it from the test. General `assert_*` inside `send_*_request`; special `assert_*` only in the test. See **Runtime send helpers** below — do not call `omni.generate`, raw HTTP, or SDK clients from `test_*.py`.
|
||||
- **Response assertions**: reusable checks on API bodies / decoded media belong in **`tests/helpers/assertions.py`** — not as private `_assert_*` helpers inside `test_*.py` (see below).
|
||||
- **Model-specific payloads stay in test modules** — per-model `MODEL`, deploy path, `REF_AUDIO_URL`, `get_prompt()`, `_build_request()`, and inline `request_config` dicts live in `test_{slug}.py` / `test_{slug}_expansion.py` (and offline/L1 siblings). **Do not** create `tests/helpers/{slug}.py` to deduplicate them; a little copy across files is preferred (see `test_glm_tts.py`, `test_cosyvoice3_tts_expansion.py`). `tests/helpers/` is repo-wide harness only (`mark`, `media`, `runtime`, `stage_config`, `assertions`, `fixtures/`).
|
||||
|
||||
#### L1 unit test constraints (mocking)
|
||||
|
||||
L1 tests (`core_model and cpu`, under `tests/diffusion/`, `tests/engine/`, `tests/model_executor/`, etc.) must follow the repo’s **pytest-mock** convention (`pytest-mock>=3.10.0` in `pyproject.toml` `[project.optional-dependencies] dev`).
|
||||
|
||||
| Do | Don't |
|
||||
|----|-------|
|
||||
| `def test_foo(mocker):` + `mocker.patch(...)`, `mocker.spy(...)`, `mocker.Mock()`, `mocker.MagicMock()`, `mocker.AsyncMock()` | `from unittest.mock import patch, MagicMock, Mock, AsyncMock` |
|
||||
| `def test_bar(monkeypatch):` + `monkeypatch.setattr` / `setenv` / `delenv` / `setitem` | `@patch(...)` decorator |
|
||||
| Let `mocker` auto-stop patches after the test | `with patch(...):` / `patch.object(...)` context managers |
|
||||
| Mirror neighboring L1 tests in the same directory | `unittest.mock.create_autospec` unless an existing file already documents an exception |
|
||||
|
||||
**Rationale:** `mocker` ties patch lifecycle to pytest fixtures (no leaked patches across tests). `unittest.mock` decorators/context managers are easy to compose incorrectly with parametrized or async tests and are inconsistent with in-tree L1 style.
|
||||
|
||||
**Minimal patterns:**
|
||||
|
||||
```python
|
||||
def test_returns_cached_config(mocker):
|
||||
loader = mocker.patch(
|
||||
"vllm_omni.foo.load_yaml",
|
||||
return_value={"stages": []},
|
||||
)
|
||||
result = get_deploy_config("ci/foo.yaml")
|
||||
assert result["stages"] == []
|
||||
loader.assert_called_once_with("ci/foo.yaml")
|
||||
|
||||
|
||||
def test_skips_when_env_unset(monkeypatch):
|
||||
monkeypatch.delenv("VLLM_OMNI_FEATURE", raising=False)
|
||||
assert should_enable_feature() is False
|
||||
```
|
||||
|
||||
E2E levels (L2+) generally **avoid mocks**; if a rare L2 stub is unavoidable, still prefer `mocker` over `unittest.mock` for consistency.
|
||||
|
||||
#### Runtime fixtures — scope (`tests/helpers/fixtures/runtime.py`)
|
||||
|
||||
vLLM-Omni e2e tests start a real **OmniServer** (online) or **OmniRunner** (offline). Pick scope by **how often the process must be recreated**, not by test level alone.
|
||||
|
||||
| Scope | Online fixtures | Offline fixtures | When to use |
|
||||
|-------|-----------------|------------------|-------------|
|
||||
| **Module** (default) | `omni_server` → `openai_client` | `omni_runner` → `omni_runner_handler` | **Default for L2/L3/L4 expansion** — amortize model/server init across `test_*` in the same module. Same `OmniServerParams` / runner config can be reused by multiple tests. |
|
||||
| **Function** | `omni_server_function` → `openai_client_function` | `omni_runner_function` → `omni_runner_handler_function` | **Only when required** — each `test_*` must get a **clean** server/runner (no shared engine/GPU state). Typical: `tests/dfx/reliability/`, sleep/wakeup, crash/restart, tests that mutate global server state. |
|
||||
|
||||
**Rules:**
|
||||
|
||||
1. **Default to module scope** (`omni_server` / `omni_runner`) unless the scenario **explicitly** needs a fresh instance per test function.
|
||||
2. **Indirect parametrize name must match the fixture name**: `@pytest.mark.parametrize("omni_server", ...)` with `omni_server` + `openai_client`; `@pytest.mark.parametrize("omni_server_function", ...)` with `omni_server_function` + `openai_client_function`. Do not mix module fixture with function client (or vice versa).
|
||||
3. Different `OmniServerParams` per `test_*` (e.g. default vs `--enable-cpu-offload`) is still OK with **module** `omni_server` — pytest parametrizes per test node; only switch to `_function` when isolation between tests matters, not merely because `server_args` differ.
|
||||
4. One `test_*` with **many** server configs (expansion matrix) → single function + `@pytest.mark.parametrize("omni_server", [...], indirect=True)` + module `omni_server` (see in-tree `test_qwen_image_expansion.py`).
|
||||
|
||||
```python
|
||||
# Default — module-scoped server (L4 expansion)
|
||||
@pytest.mark.parametrize("omni_server", [pytest.param(OmniServerParams(model=MODEL), marks=H100)], indirect=True)
|
||||
def test_foo_images_generations_default_1024(omni_server, openai_client) -> None:
|
||||
openai_client.send_images_generations_request({...})
|
||||
|
||||
# Function-scoped — reliability / per-test clean state only
|
||||
@pytest.mark.parametrize(
|
||||
"omni_server_function",
|
||||
[pytest.param(OmniServerParams(model=MODEL), marks=H100)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_foo_sleep_wakeup_cycle(omni_server_function, openai_client_function) -> None:
|
||||
openai_client_function.send_omni_sleep_http_request({...})
|
||||
openai_client_function.send_omni_wakeup_http_request({...})
|
||||
```
|
||||
|
||||
#### Runtime send helpers — online **and** offline (`tests/helpers/runtime.py`)
|
||||
|
||||
**L2+ e2e (online serving and offline inference) must call APIs through `tests/helpers/runtime.py`.** Fixtures live in `tests/helpers/fixtures/runtime.py`; **send/assert implementation** lives in `tests/helpers/runtime.py` + `tests/helpers/assertions.py`.
|
||||
|
||||
| Principle | Action |
|
||||
|-----------|--------|
|
||||
| **Reuse first** | Grep `runtime.py` for an existing **`send_*_request`** on `OpenAIClientHandler` (online) or `OmniRunnerHandler` (offline) that matches the endpoint / pipeline shape. |
|
||||
| **Extend when close** | If an existing `send_*_request` almost fits (e.g. missing one optional field), extend it in `runtime.py` — do not fork logic in the test file. |
|
||||
| **Add when missing** | No suitable helper → add **`send_<feature>_request`** (high-level: call + general `assert_*`) or **`send_<route>_<verb>_http_request`** (low-level HTTP for negative/dfx) in **`runtime.py` first**, then call it from tests. |
|
||||
| **Test module owns payload only** | In `test_*.py`: `MODEL`, deploy path, vendored media, `get_prompt()`, and inline **`request_config` dicts** only. **No** `omni.generate(...)`, raw `requests.post`, OpenAI SDK calls, or `_collect_audio()` / `_process_output()` in e2e tests. |
|
||||
|
||||
**Online** (`openai_client` from `omni_server`): `OpenAIClientHandler.send_*_request`.
|
||||
|
||||
**Offline** (`omni_runner_handler` from `omni_runner`): `OmniRunnerHandler.send_*_request`.
|
||||
|
||||
| Do | Don't |
|
||||
|----|-------|
|
||||
| Online: `openai_client.send_omni_request`, `send_diffusion_request`, `send_audio_speech_request`, `send_video_diffusion_request`, `send_images_generations_request`, … | `requests.post(f"{base_url}/v1/...", json=…)` or `client.chat.completions.create(...)` inside a test |
|
||||
| Offline: `omni_runner_handler.send_omni_request`, `send_diffusion_request`, `send_audio_speech_request`, `send_single_stage_tts_request`, `send_single_stage_tts_batch_request`, … | `omni_runner.omni.generate(...)` + hand-rolled tensor/WAV extraction in `test_*.py` |
|
||||
| Add missing `send_*` to **`runtime.py`** first; bundle general `assert_*` inside the send helper | A one-off `def _post_*` / `def _collect_audio` at the bottom of a test module |
|
||||
| Mirror naming/style of neighboring `send_*` (docstring, `request_config` dict, optional `run_level`, `err_code` / `err_message` for negative cases) | Different parameter shapes per test file for the same endpoint |
|
||||
|
||||
**Workflow when generating online/offline e2e tests:**
|
||||
|
||||
1. Decide whether the needed check is **general** (every success call of this `send_*`) or **special** (one case / one parameter combo only). See **General vs special assert placement** below.
|
||||
2. **Search `runtime.py`** for a matching **`send_*_request`**; if missing, add it (with general `assert_*` inside) before writing the test body.
|
||||
3. In the test module: build `request_config` → call **`send_*_request` only** for the general contract; call **`assert_*` in the test file only** for special, case-specific checks (import from `assertions.py`).
|
||||
4. Reserve **low-level** `send_*_http_request` for negative/dfx tests (`err_code` / `err_message`) — not for ordinary L2+ success-path e2e.
|
||||
5. When wiring Buildkite `source_file_dependencies`, include **`tests/helpers/runtime.py`** and/or **`tests/helpers/assertions.py`** when new helpers were added.
|
||||
|
||||
**Exceptions (document in the test docstring why):** models whose offline prompt path cannot go through existing handlers yet (e.g. `test_higgs_audio_v2.py`, `test_voxtral_tts.py` with custom tokenizer compose) may call `omni.generate` directly until a `send_*_request` is added to `runtime.py` — treat as **debt**, not the default for new TTS/diffusion/omni e2e.
|
||||
|
||||
**General vs special assert placement:**
|
||||
|
||||
| Kind | Definition | Where it lives | Test file calls |
|
||||
|------|------------|----------------|-----------------|
|
||||
| **General** | Default success contract for this endpoint/client method — e.g. HTTP 200, `data[]` shape, `n` count, `size` dimensions, decodable image/audio, bundled omni/diffusion fields | Implement in `assertions.py`, invoke **inside** `send_*_request` in `runtime.py` (after low-level send / SDK call, when `err_code` is not set) | **`send_*_request` only** — do **not** repeat the same `assert_*` |
|
||||
| **Special** | Extra check tied to **this test case only** — e.g. seed byte-identical replay, accuracy/CLIP threshold, perf ceiling, model-specific optional field | Add or reuse `assert_*` in `assertions.py` (never inline in test) | `send_*_request` **then** `assert_<special>(...)` **once** for that case |
|
||||
|
||||
Changing `request_config` fields (`size`, `n`, `seed`, server flags) is **not** special validation — the general `assert_*` should read those from `request_config` inside `send_*`.
|
||||
|
||||
**`send_*` ↔ `assert_*` pairing (online `OpenAIClientHandler`):**
|
||||
|
||||
| High-level `send_*` (prefer in L2+ e2e) | Assert already invoked inside `runtime.py` | Low-level HTTP-only sibling |
|
||||
|----------------------------------------|--------------------------------------------|-----------------------------|
|
||||
| `send_omni_request` | `assert_omni_response` | `send_chat_completions_http_request` → `assert_http_error` only |
|
||||
| `send_diffusion_request` | `assert_diffusion_response` | — |
|
||||
| `send_audio_speech_request` | `assert_audio_speech_response` | `send_audio_speech_http_request` → `assert_http_error` only |
|
||||
| `send_video_diffusion_request` | `assert_diffusion_response` | `send_videos_*_http_request` → `assert_http_error` only |
|
||||
| `send_images_generations_request` *(add when needed)* | **`assert_images_generations_response`** *(general — inside send)* | `send_images_generations_http_request` → `assert_http_error` only |
|
||||
| `send_images_edits_request` *(add when needed)* | **`assert_images_edits_response`** *(general — inside send)* | `send_images_edits_http_request` → `assert_http_error` only |
|
||||
|
||||
**Rule:** Tests call high-level `send_*_request` for the general contract. **Never** call the same bundled `assert_*` again. Call an extra `assert_*` in the test **only** for special, case-specific validation.
|
||||
|
||||
**Common `OpenAIClientHandler` entry points** (non-exhaustive — grep `runtime.py` before adding):
|
||||
|
||||
| Area | High-level (SDK + assert) | Low-level HTTP (`*_http_request`) |
|
||||
|------|---------------------------|-----------------------------------|
|
||||
| Omni chat | `send_omni_request` | `send_chat_completions_http_request` |
|
||||
| Diffusion T2I (chat route) | `send_diffusion_request` | — |
|
||||
| Diffusion X2V | `send_video_diffusion_request` | `send_videos_create_http_request`, `send_video_content_http_request`, … |
|
||||
| DALL-E T2I / edit | `send_images_generations_request`, `send_images_edits_request` | `send_images_generations_http_request`, `send_images_edits_http_request` |
|
||||
| TTS (online) | `send_audio_speech_request` | `send_audio_speech_http_request`, `send_audio_generate_http_request`, … |
|
||||
| Ops / meta | — | `send_health_http_request`, `send_models_http_request`, `send_omni_sleep_http_request`, … |
|
||||
|
||||
**Common `OmniRunnerHandler` entry points** (offline — grep `runtime.py` before adding):
|
||||
|
||||
| Area | High-level (`send_*_request` + assert) | Notes |
|
||||
|------|----------------------------------------|-------|
|
||||
| Omni multimodal | `send_omni_request` | `generate_multimodal` path |
|
||||
| Diffusion offline | `send_diffusion_request` | chat-route / `OmniTextPrompt` offline |
|
||||
| Qwen-style TTS | `send_audio_speech_request` | two-stage, `generate_multimodal` + `mm_processor_kwargs` |
|
||||
| Single-stage TTS (Coqui XTTS, MOSS-TTS-Nano, …) | `send_single_stage_tts_request`, `send_single_stage_tts_batch_request` | `prompt` + `additional_information` + `omni.generate` — **do not** duplicate in tests |
|
||||
| New model family | **Add** `send_<family>_request` here first | Then call from `tests/e2e/offline_inference/test_*.py` |
|
||||
|
||||
L1 tests under `tests/entrypoints/` may use **FastAPI `TestClient`** or direct handler calls with mocks; they do **not** need `OpenAIClientHandler` / `OmniRunnerHandler`. **L2+ online and offline e2e** must use `runtime.py` `send_*` helpers.
|
||||
|
||||
#### Invalid parameter validation (`tests/dfx/reliability/invalid_param_test/`)
|
||||
|
||||
When the user asks for **invalid parameter validation**, **invalid request bodies**, **HTTP 4xx contract tests**, or any case that sends **malformed / out-of-range / mismatched** API payloads against a **live** server:
|
||||
|
||||
1. **Do not** place these in `tests/e2e/online_serving/` or `*_expansion.py`. If already drafted there, **move** the `test_*` into the correct `invalid_param_test` script and delete the duplicate from e2e.
|
||||
2. **Pick the script by HTTP route** (extend an existing file; add a new `test_invalid_<area>.py` only when no in-tree script covers that route family):
|
||||
|
||||
| API / area | Script |
|
||||
|------------|--------|
|
||||
| Omni chat completions, WebSocket video/realtime paths | `test_invalid_omni_chat.py` |
|
||||
| `POST /v1/audio/speech`, stream, batch, voices | `test_invalid_audio_speech.py` |
|
||||
| Audio diffusion endpoints | `test_invalid_audio_diffusion.py` |
|
||||
| `POST /v1/images/generations` | `test_invalid_image_generation.py` |
|
||||
| `POST /v1/images/edits` | `test_invalid_image_editing.py` |
|
||||
| `POST/GET/DELETE /v1/videos*` | `test_invalid_video_generation.py` |
|
||||
| Sleep / wakeup / server control | `test_invalid_server_control.py` |
|
||||
|
||||
3. **Match in-tree style** in the chosen script:
|
||||
|
||||
| Element | Convention |
|
||||
|---------|------------|
|
||||
| **Module markers** | `pytestmark = [pytest.mark.slow, pytest.mark.<omni or tts or diffusion>]` |
|
||||
| **Server fixture** | `_PARAMS` / `_QWEN3_TTS_SPEECH`-style list of `pytest.param(OmniServerParams(...), id="...", marks=hardware_marks(...))`; `@pytest.mark.parametrize("omni_server", _PARAMS, indirect=True)` |
|
||||
| **Hardware** | `hardware_marks(res={"cuda": "H100"})` for heavy diffusion/omni/video; `hardware_marks(res={"cuda": "L4"})` for smaller TTS models (must match weekly `-m "slow and L4"` step) |
|
||||
| **HTTP client** | **Low-level** `openai_client.send_*_http_request({..., "err_code": 400, "err_message": (...)} )` — **not** `send_*_request` (success path) |
|
||||
| **Case shape** | Prefer **one `test_*` per route family** + `@pytest.mark.parametrize("body_spec, err_message", [...])` with stable `id=` per case; or dedicated `test_<route>_malformed_json` when not parametrized |
|
||||
| **Body helpers** | `_minimal_<endpoint>_json(omni_server)` / `_minimal_*_form_data()`; merge overrides with `body.update(body_spec)` |
|
||||
| **Known gaps** | `pytest.mark.skip(reason="…#3649")` as `_SKIP_ISSUE_3649` when server validation is not yet strict (mirror neighboring cases) |
|
||||
| **Sections** | Route banner comments (`# ─── POST /v1/images/generations ───`) like existing files |
|
||||
| **Shared fixtures** | Reuse `tests/dfx/reliability/invalid_param_test/conftest.py` (`tiny_png_bytes`, env defaults) |
|
||||
|
||||
4. **Adding a new model** to invalid-param coverage: append a `pytest.param(OmniServerParams(model="...", stage_config_path=..., server_args=...), ...)` entry to the script’s `_PARAMS` list — do **not** create `test_invalid_<model>.py` unless the route is new.
|
||||
|
||||
5. **CI — `.buildkite/test-weekly.yml` only** (not `test-ready.yml` / `test-merge.yml` / `test-nightly.yml`):
|
||||
|
||||
| Weekly step | Command | When your cases run |
|
||||
|-------------|---------|---------------------|
|
||||
| **Invalid parameters Test · H100** | `pytest -s -v tests/dfx/reliability/invalid_param_test/ -m "slow and H100"` | Diffusion / omni / video invalid-param tests with `H100` hardware mark |
|
||||
| **Invalid parameters Test · L4** | `pytest -s -v tests/dfx/reliability/invalid_param_test/ -m "slow and L4"` | TTS / lighter models marked `L4` |
|
||||
|
||||
- **Trigger:** `build.env("WEEKLY") == "1"` or PR label `weekly-test`.
|
||||
- **Default:** extending an existing `invalid_param_test` script needs **no YAML edit** — the weekly steps already sweep the whole directory.
|
||||
- **Edit YAML only** when adding a **new hardware queue**, a **new top-level script** that must run in isolation, or a **model-specific weekly shard** (mirror neighboring reliability steps).
|
||||
- Weekly steps **do not** use `source_file_dependencies`.
|
||||
|
||||
**Example — append to `test_invalid_image_generation.py`:**
|
||||
|
||||
```python
|
||||
@pytest.mark.parametrize(
|
||||
"body_spec, err_message",
|
||||
[
|
||||
pytest.param({"seed": -1}, ("seed", "greater_than_equal", "0"), id="seed_negative"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("omni_server", _PARAMS, indirect=True)
|
||||
def test_images_generations_invalid_requests(
|
||||
omni_server: OmniServer,
|
||||
openai_client: OpenAIClientHandler,
|
||||
body_spec: dict[str, object],
|
||||
err_message: str | tuple[str, ...],
|
||||
) -> None:
|
||||
body = _minimal_images_gen_json(omni_server)
|
||||
body.update(body_spec)
|
||||
openai_client.send_images_generations_http_request(
|
||||
{"json": body, "timeout": 300, "err_code": 400, "err_message": err_message}
|
||||
)
|
||||
```
|
||||
|
||||
See [references/test-routing.md](references/test-routing.md) **Invalid parameter / weekly CI**.
|
||||
|
||||
#### Assertion helpers (`tests/helpers/assertions.py`)
|
||||
|
||||
**Do not** add module-local helpers such as `_assert_images_generations_payload` or `_send_and_assert_*` in e2e test files. Response/media validation belongs in **`tests/helpers/assertions.py`**, grouped by **category**:
|
||||
|
||||
| Category | Existing anchors | When to extend vs add |
|
||||
|----------|------------------|------------------------|
|
||||
| **Image** (chat diffusion + DALL-E JSON) | `assert_image_diffusion_response`, `assert_image_valid` | DALL-E `/v1/images/generations` JSON → add **`assert_images_generations_response`** beside image helpers (reuses `assert_image_valid`); do not fork decode logic into tests |
|
||||
| **Video** | `assert_video_diffusion_response`, `assert_video_valid` | Extend these for new video contracts |
|
||||
| **Audio** | `assert_audio_diffusion_response`, `assert_audio_speech_response`, `assert_audio_valid` | Extend for new TTS/audio endpoints |
|
||||
| **Omni multimodal** | `assert_omni_response` | Extend for new modality combos |
|
||||
| **HTTP errors** | `assert_http_error`, `assert_err_message_in_text` | Used inside low-level `send_*_http_request` and negative tests |
|
||||
|
||||
| Do | Don't |
|
||||
|----|-------|
|
||||
| Put **general** `assert_*` inside `send_*_request` in `runtime.py`; tests call `send_*_request` only | Call `assert_diffusion_response` after `send_diffusion_request` (general assert already bundled) |
|
||||
| Put **special** `assert_*` in `assertions.py` and call it **in the test** after `send_*_request` when that case needs extra checks | Put general decode/count/size logic in the test because “this case uses `n=4`” (that belongs in general assert reading `request_config`) |
|
||||
| Extend the matching category function, or add `assert_<endpoint>_response` next to its category | `_send_and_assert_*` or PIL/base64 loops in `test_*.py` |
|
||||
| Use low-level `send_*_http_request` in dfx/negative tests (`err_code`) | Use low-level HTTP send + manual general assert in every L2+ expansion file |
|
||||
|
||||
**Workflow when generating tests:**
|
||||
|
||||
1. Implement or reuse **general** `assert_*` in `assertions.py` (by category).
|
||||
2. Wire it into **`send_*_request`** in `runtime.py` if not already bundled.
|
||||
3. In the test: `request_config` → `send_*_request`. Add a separate `assert_*` import/call **only** for special case validation.
|
||||
4. Update `source_file_dependencies` when `runtime.py` or `assertions.py` changes.
|
||||
|
||||
**Example — DALL-E `/v1/images/generations`:**
|
||||
|
||||
```python
|
||||
# tests/helpers/assertions.py — general contract (image category)
|
||||
def assert_images_generations_response(resp_body: dict, request_config: dict, *, run_level: str | None = None) -> None:
|
||||
... # data[], n, size → assert_image_valid
|
||||
|
||||
# tests/helpers/runtime.py — general assert INSIDE send
|
||||
def send_images_generations_request(self, request_config: dict[str, Any], ...) -> list[HttpResponse]:
|
||||
responses = self.send_images_generations_http_request(request_config, ...)
|
||||
if request_config.get("err_code") is None:
|
||||
body = responses[0].json_body
|
||||
assert isinstance(body, dict)
|
||||
assert_images_generations_response(body, request_config, run_level=self.run_level)
|
||||
return responses
|
||||
|
||||
# tests/e2e/.../test_foo_expansion.py — one case per test_* (no case_id branching)
|
||||
@pytest.mark.parametrize("omni_server", [pytest.param(OmniServerParams(model=MODEL), marks=SINGLE_L4)], indirect=True)
|
||||
def test_foo_images_generations_default_1024(omni_server, openai_client) -> None:
|
||||
openai_client.send_images_generations_request({"json": body, "timeout": 300})
|
||||
|
||||
@pytest.mark.parametrize("omni_server", [pytest.param(OmniServerParams(model=MODEL), marks=SINGLE_L4)], indirect=True)
|
||||
def test_foo_images_generations_sizes_256_512_1024(omni_server, openai_client) -> None:
|
||||
for size in ("256x256", "512x512", "1024x1024"):
|
||||
openai_client.send_images_generations_request({"json": {**body, "size": size}, "timeout": 300})
|
||||
|
||||
# chat diffusion — separate test; send_diffusion_request bundles assert_diffusion_response
|
||||
@pytest.mark.parametrize("omni_server", [pytest.param(OmniServerParams(model=MODEL), marks=SINGLE_L4)], indirect=True)
|
||||
def test_foo_chat_completions_t2i_fallback(omni_server, openai_client) -> None:
|
||||
openai_client.send_diffusion_request({...})
|
||||
```
|
||||
|
||||
L1 tests under `tests/entrypoints/` may keep **minimal** asserts next to the handler under test when validating a single branch; still prefer `assertions.py` when the same JSON/media check appears in more than one file or level (e.g. L1 protocol test + L4 e2e).
|
||||
|
||||
#### Omni Test Writing Guidance (L1-L4 Layering)
|
||||
|
||||
When the goal is a general Omni/multimodal test case, prioritize mapping the test to the correct purpose, directory, and resource assumptions aligned with the layers (see [CI_5levels.md](../../../docs/contributing/ci/CI_5levels.md)):
|
||||
|
||||
- **L1**: Unit/logic validation on CPU (`core_model and cpu`). Cover input validation, branches, and exception paths (`tests/<component>/test_*.py`). **Mock with `mocker` / `monkeypatch` only — not `unittest.mock`.**
|
||||
- **L2**: Basic e2e (online/offline basic scenarios). Prefer dummy/lightweight models to validate the end-to-end request-to-output-structure/streaming chain (typically `tests/e2e/online_serving/` and `tests/e2e/offline_inference/`).
|
||||
- **L3/L4**: Important integration, performance, and accuracy validation. L4 emphasizes “full functional scenarios + performance/stress + runnable doc examples” (typically `*_expansion.py` plus related expansion cases).
|
||||
|
||||
Also keep markers consistent with the run level: use `core_model` for L1/L2 and `advanced_model` for L3/L4, and pair with `--run-level` to select the intended CI strategy.
|
||||
|
||||
#### Diffusion Test Writing Guidance (L4 Coverage Combinations)
|
||||
|
||||
When the task involves diffusion models/features, organize L4 test cases following [`#1832`](https://github.com/vllm-project/vllm-omni/issues/1832): combine multiple diffusion features into as few test cases as possible to fit limited CI GPU resources.
|
||||
|
||||
Implementation strategy:
|
||||
|
||||
1. If “full L4 is too heavy”: first provide a **reduced local validation case in L1/L2** (so the key assertion and the contract fix point are covered deterministically).
|
||||
2. Then provide **CI/nightly-ready L3/L4 high-marked cases** (e.g. `advanced_model`) to broaden coverage under resource constraints.
|
||||
|
||||
#### L4 nightly sub-pillars — Function vs Accuracy vs Perf
|
||||
|
||||
**L4 is not only `test_*_expansion.py`.** In `test-nightly.yml`, each model-type group typically runs **separate jobs**:
|
||||
|
||||
| Sub-pillar | Nightly step label pattern | What you add | Typical paths |
|
||||
|------------|----------------------------|--------------|---------------|
|
||||
| **Function** | `· Function Test with …` | E2e expansion / feature matrix | `tests/e2e/online_serving/test_<model>_expansion.py` (or offline) |
|
||||
| **Accuracy** | `· Accuracy Test` | Quality / similarity vs baseline | `tests/e2e/accuracy/test_<model>*.py` |
|
||||
| **Perf** | `· Perf Test · <Model>` | Throughput / latency / memory benchmarks | `tests/dfx/perf/tests/test_<model>_vllm_omni.json` + runner script |
|
||||
| **Doc** (optional) | `· Doc Test` | Runnable doc examples | `tests/examples/*/test_text_to_image.py`, … |
|
||||
|
||||
**Default when the user asks for “L4 functional cases”:** deliver **Function** pillar only (`*_expansion.py` + Function Test shard in `test-nightly.yml`). **Do not** silently add Perf or Accuracy unless the user also asks for **performance** / **benchmark** / **accuracy** / **full L4** / **full L4 coverage**.
|
||||
|
||||
**When the user explicitly asks for L4 perf (or full L4 including perf):**
|
||||
|
||||
1. **Do not** put throughput/latency assertions inside `test_*_expansion.py` — perf uses the **dfx benchmark harness**, not `omni_server` e2e fixtures.
|
||||
2. Add or extend a **JSON config** under `tests/dfx/perf/tests/` (mirror in-tree names: `test_qwen_image_vllm_omni.json`, `test_bagel_vllm_omni.json`).
|
||||
3. Each JSON entry: `test_name`, `server_params` (`model`, `serve_args`), `benchmark_params[]` with explicit **`baseline`** (`throughput_qps`, `latency_mean`, `peak_memory_mb_mean`, …).
|
||||
4. Run locally via the matching script (model type → runner):
|
||||
|
||||
| Model type | Runner script | Example config |
|
||||
|------------|---------------|----------------|
|
||||
| **Diffusion X2I/X2V** | `tests/dfx/perf/scripts/run_diffusion_benchmark.py` | `tests/dfx/perf/tests/test_qwen_image_vllm_omni.json` |
|
||||
| **TTS** | `tests/dfx/perf/scripts/run_benchmark.py` | `tests/dfx/perf/tests/test_tts.json` (shared) or `test_{slug}.json` (dedicated; use when the model must not join the shared nightly matrix before integration — e.g. `test_voxcpm2.json`, `test_coqui_tts.json`) |
|
||||
| **Omni** | (see existing Omni Perf steps — `run_benchmark.py` / dedicated omni configs in-tree) | `tests/dfx/perf/tests/test_omni*.json` |
|
||||
|
||||
5. Wire **`test-nightly.yml` · Perf Test** step for that model (separate from Function Test): export `DIFFUSION_BENCHMARK_DIR` / `BENCHMARK_DIR`, run pytest on the script with `--test-config-file`, **upload artifacts** (`buildkite-agent artifact upload`), often **multi-GPU H100** for diffusion perf.
|
||||
6. **One benchmark scenario → one `test_name` block** in JSON (same spirit as one `test_*` per function case). Combine server `serve_args` + workload in one entry; use multiple `benchmark_params` rows for size/step sweeps under the same server config.
|
||||
|
||||
**Perf local command (diffusion example):**
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results
|
||||
export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN
|
||||
pytest -s -v dfx/perf/scripts/run_diffusion_benchmark.py \
|
||||
--test-config-file dfx/perf/tests/test_<model>_vllm_omni.json
|
||||
```
|
||||
|
||||
**Clarify in the test plan** which L4 pillars you deliver: `Function only` | `Function + Perf` | `Function + Accuracy + Perf`.
|
||||
|
||||
See [references/test-routing.md](references/test-routing.md) **L4 nightly pillars** for Buildkite step patterns.
|
||||
|
||||
### Step 5: Wire Buildkite (when CI must run the new test)
|
||||
|
||||
If the test is not already collected by an existing pipeline command (for example, L1 tests marked `core_model and cpu` are already covered by the **Simple Unit Test** step in ready/merge), update the appropriate pipeline under `.buildkite/`:
|
||||
|
||||
| Test level | Edit this file | Typical trigger / intent |
|
||||
|------------|----------------|---------------------------|
|
||||
| **L1** and **L2** | [`.buildkite/test-ready.yml`](../../../.buildkite/test-ready.yml) | PR **ready** label; L1 CPU + L2 GPU/basic e2e (steps labeled **Omni ·**, **TTS ·**, **Diffusion ·**) |
|
||||
| **L3** | [`.buildkite/test-merge.yml`](../../../.buildkite/test-merge.yml) | Post-merge; `advanced_model` integration per model type |
|
||||
| **L4** | [`.buildkite/test-nightly.yml`](../../../.buildkite/test-nightly.yml) | Nightly; grouped by model type (see below) |
|
||||
| **Invalid param / reliability (weekly)** | [`.buildkite/test-weekly.yml`](../../../.buildkite/test-weekly.yml) | Weekly; `tests/dfx/reliability/invalid_param_test/` — **not** L1–L4 e2e pipelines |
|
||||
|
||||
**Level-specific Buildkite delivery (follow the user’s requested level):**
|
||||
|
||||
| User asks for | Document / edit **only** | Do **not** add by default |
|
||||
|---------------|---------------------------|---------------------------|
|
||||
| **L4** (`*_expansion.py`, `full_model`) | `test-nightly.yml` — append file to the matching nightly shard (X2I / X2V / Omni / TTS) **or** note it is already collected by an existing `-m` / `-k` sweep | `test-merge.yml` (L3 / `advanced_model`) or `test-ready.yml` (L2) |
|
||||
| **L3** | `test-merge.yml` with `advanced_model` + `source_file_dependencies` | `test-nightly.yml` unless user also wants nightly |
|
||||
| **L2** | `test-ready.yml` with `core_model` + `source_file_dependencies` | merge / nightly pipelines |
|
||||
| **Invalid param** | `test-weekly.yml` — **Invalid parameters Test** group (`-m "slow and H100"` / `-m "slow and L4"`). Usually **no YAML edit** when appending cases to existing `invalid_param_test` scripts | `test-ready.yml`, `test-merge.yml`, `test-nightly.yml` |
|
||||
|
||||
`test-nightly.yml` shards use **explicit pytest file paths** in `commands` (and PR labels like `diffusion-x2iat-test`); they generally **do not** use `source_file_dependencies`. Only suggest merge/ready YAML when the requested level is L3/L2 (or the user explicitly asks for multi-level CI).
|
||||
|
||||
**`test-nightly.yml` top-level groups** (each uses `full_model` + type marker in pytest `-m`):
|
||||
|
||||
| Group | Type marker | Notes |
|
||||
|-------|-------------|--------|
|
||||
| **Omni Model Test** | `omni` | Function / doc / accuracy / **perf** for Qwen-Omni family |
|
||||
| **TTS Model Test** | `tts` | `-m "full_model and L4 and tts"` (and H100 variants when enabled); **Perf Test** uses `run_benchmark.py` + `test_tts.json` |
|
||||
| **Diffusion X2I(&A&T) Model Test** | `diffusion` | Image/audio/text diffusion; explicit file shards for **Function**; **Perf Test · <Model>** uses `run_diffusion_benchmark.py` + JSON config |
|
||||
| **Diffusion X2V Model Test** | `diffusion` | Video-only (Wan, HunyuanVideo, …); PR label `diffusion-x2v-test`; separate **Perf** steps when in-tree |
|
||||
|
||||
Guidelines when editing YAML:
|
||||
|
||||
- **Match markers and `--run-level`** to the test: L1/L2 in `test-ready.yml` use `core_model` + `omni` / `tts` / `diffusion` as appropriate; L3 merge uses `advanced_model`; L4 nightly uses `full_model` with the same type marker.
|
||||
- **Diffusion L4**: add x2i/x2a/x2t expansion files to an **X2I(&A&T)** step; add x2v expansion files to **X2V** — never rely on a broad `tests/e2e/` sweep alone (shards exist to save GPU and avoid wrong queue).
|
||||
- **`source_file_dependencies` (required for new E2E jobs in `test-ready.yml` and `test-merge.yml`)**: every step under the **`:card_index_dividers: E2E Test`** group **must** declare `source_file_dependencies` so Buildkite only schedules the job when relevant paths change. Omitting it breaks path-based CI skipping for GPU e2e jobs.
|
||||
- **Reuse or extend an existing step** when the new test shares the same marker expression, queue, and timeout; otherwise add a new `steps:` entry with the correct `agents.queue`, `timeout_in_minutes`, and docker/kubernetes plugin blocks consistent with neighboring jobs.
|
||||
- **Job timeout — use `timeout_in_minutes` on the step, not `timeout` before pytest**: when adding or documenting a new E2E job in `test-ready.yml` / `test-merge.yml`, set `timeout_in_minutes` on the step and run `pytest` directly in `commands`. **Do not** wrap pytest in `timeout 40m bash -c "..."` / `timeout 20m bash -c "..."`; Buildkite already enforces the step deadline via `timeout_in_minutes`.
|
||||
- **Never omit `plugins` in skill examples or PR YAML snippets**: H100 E2E steps on `mithril-h100-pool` use the full `kubernetes` block (`resources`, `volumeMounts`, `env` with `HF_TOKEN` secret, `nodeSelector: gpu-h100-sxm`, `devshm` + `hf-cache` volumes). L4/docker steps on `gpu_1_queue` use the full `docker#v5.2.0` block (`shm-size`, `HF_HOME`, volumes). Do not write `# ... plugins unchanged` — paste the complete block from a sibling step.
|
||||
- **Platform forks** (e.g. AMD-ready / AMD-merge) live alongside these files; apply the same level → file mapping for those pipelines when the test is platform-specific.
|
||||
|
||||
#### `source_file_dependencies` for E2E Test jobs (`test-ready.yml`, `test-merge.yml`)
|
||||
|
||||
When adding or splitting a step inside the **E2E Test** group, **always** include `source_file_dependencies` on that step. List paths the job actually depends on — at minimum:
|
||||
|
||||
1. **Test module(s)** — every `tests/e2e/...` file the `pytest` command runs (online and offline if both are in one step).
|
||||
2. **Model implementation** — `vllm_omni/model_executor/models/<family>/` and/or `vllm_omni/diffusion/models/<family>/` as applicable.
|
||||
3. **Stage / input plumbing** (omni & multi-stage TTS) — `vllm_omni/model_executor/stage_input_processors/<family>.py` when present.
|
||||
4. **Deploy / stage YAML** — `vllm_omni/deploy/<config>.yaml` or `vllm_omni/deploy/ci/<config>.yaml` referenced by the test’s `OmniServerParams` / `get_deploy_config_path(...)`.
|
||||
5. **Test helpers** (when changed in the same PR) — `tests/helpers/runtime.py` for new `send_*` client methods; `tests/helpers/assertions.py` for new shared `assert_*` helpers.
|
||||
|
||||
Use **repo-relative paths** (no leading `./`). Prefer **directory** entries (`.../models/bagel/`) when the whole package matters; use **file** entries for single test modules and YAML configs. Mirror a neighboring step for the same model family (e.g. **Diffusion · Bagel Test** in `test-ready.yml`).
|
||||
|
||||
**Example** (L2 ready — diffusion online smoke, H100 kubernetes):
|
||||
|
||||
```yaml
|
||||
- label: "Diffusion · Bagel Test"
|
||||
source_file_dependencies:
|
||||
- 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: 40
|
||||
commands:
|
||||
- |
|
||||
export VLLM_IMAGE_FETCH_TIMEOUT=60
|
||||
pytest -s -v tests/e2e/online_serving/test_bagel.py -m 'core_model' --run-level 'core_model'
|
||||
agents:
|
||||
queue: "mithril-h100-pool"
|
||||
plugins:
|
||||
- kubernetes:
|
||||
podSpec:
|
||||
containers:
|
||||
- image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
|
||||
resources:
|
||||
limits:
|
||||
nvidia.com/gpu: 1
|
||||
volumeMounts:
|
||||
- name: devshm
|
||||
mountPath: /dev/shm
|
||||
- name: hf-cache
|
||||
mountPath: /root/.cache/huggingface
|
||||
env:
|
||||
- name: HF_HOME
|
||||
value: /root/.cache/huggingface
|
||||
- name: HF_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: hf-token-secret
|
||||
key: token
|
||||
nodeSelector:
|
||||
node.kubernetes.io/instance-type: gpu-h100-sxm
|
||||
volumes:
|
||||
- name: devshm
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
- name: hf-cache
|
||||
hostPath:
|
||||
path: /mnt/hf-cache
|
||||
type: DirectoryOrCreate
|
||||
```
|
||||
|
||||
**Example** (L3 merge — TTS online + offline, L4 docker):
|
||||
|
||||
```yaml
|
||||
- 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
|
||||
timeout_in_minutes: 20
|
||||
commands:
|
||||
- |
|
||||
export VLLM_LOGGING_LEVEL=DEBUG
|
||||
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 and cuda' --run-level 'advanced_model'
|
||||
agents:
|
||||
queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU
|
||||
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"
|
||||
```
|
||||
|
||||
**Example** (L3 merge — diffusion online + offline, H100 kubernetes):
|
||||
|
||||
```yaml
|
||||
- label: "Diffusion · Qwen Image Test"
|
||||
source_file_dependencies:
|
||||
- tests/e2e/online_serving/test_qwen_image.py
|
||||
- tests/e2e/offline_inference/test_qwen_image.py
|
||||
- vllm_omni/diffusion/models/qwen_image/
|
||||
timeout_in_minutes: 40
|
||||
commands:
|
||||
- |
|
||||
export VLLM_IMAGE_FETCH_TIMEOUT=60
|
||||
pytest -s -v \
|
||||
tests/e2e/online_serving/test_qwen_image.py \
|
||||
tests/e2e/offline_inference/test_qwen_image.py \
|
||||
-m 'advanced_model and cuda' --run-level 'advanced_model'
|
||||
agents:
|
||||
queue: "mithril-h100-pool"
|
||||
plugins:
|
||||
- kubernetes:
|
||||
podSpec:
|
||||
containers:
|
||||
- image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
|
||||
resources:
|
||||
limits:
|
||||
nvidia.com/gpu: 1
|
||||
volumeMounts:
|
||||
- name: devshm
|
||||
mountPath: /dev/shm
|
||||
- name: hf-cache
|
||||
mountPath: /root/.cache/huggingface
|
||||
env:
|
||||
- name: HF_HOME
|
||||
value: /root/.cache/huggingface
|
||||
- name: HF_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: hf-token-secret
|
||||
key: token
|
||||
nodeSelector:
|
||||
node.kubernetes.io/instance-type: gpu-h100-sxm
|
||||
volumes:
|
||||
- name: devshm
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
- name: hf-cache
|
||||
hostPath:
|
||||
path: /mnt/hf-cache
|
||||
type: DirectoryOrCreate
|
||||
```
|
||||
|
||||
When extending an **existing** E2E step to run an additional test file, **append** that file (and any new model/deploy paths) to `source_file_dependencies` on the same step. When authoring a PR, mention the dependency list in the **Buildkite change** section of your test plan.
|
||||
|
||||
#### L3 / L4: validating Buildkite from a feature branch (root `pipeline.yml`)
|
||||
|
||||
Production behavior is driven by [`.buildkite/pipeline.yml`](../../../.buildkite/pipeline.yml): it builds the CI image, then uploads **L2** (`test-ready.yml` on non-`main`), **L3** (`test-merge.yml` when `build.branch == "main"`), and **L4** (`test-nightly.yml` when `build.env("NIGHTLY") == "1"`).
|
||||
|
||||
When you add or debug **L3** or **L4** tests and need those child pipelines to run **off `main`** or **without** `NIGHTLY=1`, apply **temporary** edits (revert before merge):
|
||||
|
||||
1. **Point the upload at the right file (optional shortcut)**
|
||||
In the **Upload Ready Pipeline** step, the command is `buildkite-agent pipeline upload .buildkite/test-ready.yml`. For a one-off run you may change that path to `.buildkite/test-merge.yml` (L3) or `.buildkite/test-nightly.yml` (L4), and adjust the step’s `if:` so it does not conflict with the other upload steps (avoid double-uploading unless intentional).
|
||||
|
||||
2. **L3 — merge pipeline**
|
||||
The gate `if: build.branch == "main"` lives on the **Upload Merge Pipeline** step in `pipeline.yml` (not inside `test-merge.yml`). **Comment out that `if` line** so `test-merge.yml` is uploaded on your feature branch. Alternatively rely on step (1) instead of the dedicated merge upload step.
|
||||
|
||||
3. **L4 — nightly pipeline**
|
||||
**Comment out** `if: build.env("NIGHTLY") == "1"` on the **Upload Nightly Pipeline** step in `pipeline.yml` so the nightly definition is uploaded without setting the env var.
|
||||
Child steps in [`test-nightly.yml`](../../../.buildkite/test-nightly.yml) often repeat `if: build.env("NIGHTLY") == "1"`; **comment out those lines on the steps you need to run**, otherwise they will still be skipped after upload.
|
||||
|
||||
### Step 6: Run Tests
|
||||
|
||||
Pick one command path:
|
||||
|
||||
- **Quick local regression (preferred first)**: single file or `file.py::test_name`
|
||||
- **CI-like level run**: marker expression + `--run-level`
|
||||
|
||||
Full templates live in [references/test-routing.md](references/test-routing.md). **After authoring tests, always emit concrete commands** (see **Output Format**).
|
||||
|
||||
**Typical copy-paste examples** (run from repo `tests/` directory; requires matching vLLM/vllm-omni install and hardware):
|
||||
|
||||
| Area | Example |
|
||||
|------|---------|
|
||||
| **Omni offline L2** | `pytest -s -v e2e/offline_inference/test_qwen2_5_omni.py -m "core_model and omni and not cpu" --run-level=core_model` |
|
||||
| **Omni online L2** | `pytest -s -v e2e/online_serving/test_qwen3_omni.py -m "core_model and omni" --run-level=core_model` |
|
||||
| **TTS online L2** | `pytest -s -v e2e/online_serving/test_qwen3_tts_base.py -m "core_model and tts" --run-level=core_model` |
|
||||
| **Diffusion X2I L2 online** | `pytest -s -v e2e/online_serving/test_qwen_image.py -m "core_model and diffusion" --run-level=core_model` |
|
||||
| **Diffusion X2V L2 online** | `pytest -s -v e2e/online_serving/test_wan22_t2v.py -m "core_model and diffusion" --run-level=core_model` |
|
||||
| **Diffusion X2I L4 nightly** | `pytest -s -v e2e/online_serving/test_qwen_image_expansion.py -m "full_model and diffusion and H100" --run-level=full_model` |
|
||||
| **Diffusion X2I L4 perf (nightly)** | `pytest -s -v dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file dfx/perf/tests/test_qwen_image_vllm_omni.json` |
|
||||
| **Diffusion X2V L4 nightly** | `pytest -s -v e2e/online_serving/test_wan22_expansion.py -m "full_model and cuda" --run-level=full_model` |
|
||||
| **Invalid param (weekly H100)** | `pytest -s -v dfx/reliability/invalid_param_test/ -m "slow and H100"` |
|
||||
| **Invalid param (weekly L4)** | `pytest -s -v dfx/reliability/invalid_param_test/ -m "slow and L4"` |
|
||||
| **L1 CPU** | `pytest -s -v -m "core_model and cpu"` |
|
||||
|
||||
**Prerequisites to mention when relevant**: GPU model (e.g. L4 vs H100), `HF_HOME` / token for hub weights, module-level `skipif` (NPU/XPU-only gaps), and whether CI already collects the path (e.g. `test_*_expansion.py` glob in `test-nightly.yml`).
|
||||
|
||||
### Step 7: Validate Result Quality
|
||||
|
||||
Before finishing:
|
||||
|
||||
- Is the new assertion directly tied to the bug/feature contract?
|
||||
- Are API calls made via **`runtime.py` `send_*_request`** (not inline HTTP/SDK in the test file)?
|
||||
- Is the **general** `assert_*` bundled inside `send_*_request` (not duplicated in the test)?
|
||||
- Is each scenario a **separate `test_*` function** (no `case_id` / `if param.id` branching across cases)?
|
||||
- **Fixture scope**: default **`omni_server` / `omni_runner`** (module); **`_function`** variants only when each test must restart the instance?
|
||||
- Is the test deterministic (no fragile timing/network coupling)?
|
||||
- Is runtime appropriate for the selected level?
|
||||
- Are markers and `--run-level` consistent?
|
||||
- **Invalid-param cases** in `tests/dfx/reliability/invalid_param_test/` (not e2e), using `send_*_http_request` + `err_code`, with `pytest.mark.slow` and correct `H100` / `L4` hardware mark?
|
||||
|
||||
## Output Format
|
||||
|
||||
When completing a request, return:
|
||||
|
||||
1. **Test plan** (level, markers, file target, **L4 pillar(s)** if applicable: Function / Accuracy / Perf / Doc; **or** **Invalid param** pillar with target `invalid_param_test/test_invalid_<area>.py`; module basename for e2e: `test_{slug}.py` / `test_{slug}_expansion.py`)
|
||||
2. **Generated/updated test file(s)** — model-specific constants and request payloads stay **inside** those test modules (**never** `tests/helpers/{slug}.py`). Extend **`tests/helpers/runtime.py`** / **`tests/helpers/assertions.py`** only when new **repo-wide** `send_*` or shared assert helpers are required (do not leave ad-hoc HTTP or `_assert_*` in test modules). **If L4 Perf was requested**, also list **`tests/dfx/perf/tests/*.json`**. **If invalid-param cases were requested**, list the **`invalid_param_test/` script** and parametrized `test_*` / new `id=` rows — **not** e2e paths.
|
||||
3. **Buildkite change** — **match the requested level and pillar**:
|
||||
- **L4 Function**: `test-nightly.yml` **Function Test** shard (file list / note existing sweep). **No** `test-merge.yml` unless user also asked for L3.
|
||||
- **L4 Perf**: `test-nightly.yml` **Perf Test · <Model>** step (new step or extend commands); include env exports + artifact upload pattern from a sibling perf job.
|
||||
- **L4 Accuracy**: `test-nightly.yml` **Accuracy Test** step under the same model-type group.
|
||||
- **L3**: `test-merge.yml` + full `source_file_dependencies` + `agents` + `plugins`.
|
||||
- **L2**: `test-ready.yml` + same E2E block requirements.
|
||||
- **Invalid param**: `test-weekly.yml` — note **Invalid parameters Test · H100/L4** group; usually **no YAML change** when only extending existing scripts.
|
||||
If the user asked only for **L4 functional cases**, state explicitly that **Perf / Accuracy / Invalid param** were not included (offer to add if needed). If the plan mixes **success e2e** and **invalid param**, split deliverables across e2e vs `invalid_param_test/` and call out both CI files.
|
||||
4. **Run commands (required)** — always include, in fenced `bash` blocks:
|
||||
- **Local — whole file**: `cd tests` then `pytest -s -v <path> …`
|
||||
- **Local — single test** (optional but preferred when the change is one function): `pytest -s -v path::test_func …`
|
||||
- **CI-like** (when not L1 CPU): the same **marker + `--run-level`** pairing the level uses (see [references/test-routing.md](references/test-routing.md) and **Step 6** table above)
|
||||
- **Prerequisites** (one line): e.g. “Requires CUDA L4 + weights cached locally”; “Requires H100 + `HF_TOKEN`”; “Syntax-only validation when vllm is not installed locally”
|
||||
5. **Result summary** (pass/fail if you executed tests; if not executed, state that explicitly and what the user should run)
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- Marker and command routing: [references/test-routing.md](references/test-routing.md)
|
||||
- CI pipelines (vllm-omni): [test-ready.yml](../../../.buildkite/test-ready.yml) (L1/L2), [test-merge.yml](../../../.buildkite/test-merge.yml) (L3), [test-nightly.yml](../../../.buildkite/test-nightly.yml) (L4), [test-weekly.yml](../../../.buildkite/test-weekly.yml) (invalid param / reliability)
|
||||
- L4 documentation example tests (naming, extraction vs copied scripts, output dirs, skips): [docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md](../../../docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md) — see also [PR #1910](https://github.com/vllm-project/vllm-omni/pull/1910)
|
||||
@@ -0,0 +1,372 @@
|
||||
# Test Routing Reference
|
||||
|
||||
Use this reference to map testing goals to levels, markers, and runnable commands.
|
||||
|
||||
**Repo paths** (`.buildkite/`, `docs/contributing/…`): link with repo-relative paths from this file (e.g. `../../../../.buildkite/test-ready.yml`, `../../../../docs/contributing/ci/CI_5levels.md`).
|
||||
|
||||
## Model-centric e2e filename convention (L2–L4)
|
||||
|
||||
When generating a **new** test module tied to a **specific model** under `tests/e2e/offline_inference/` or `tests/e2e/online_serving/`:
|
||||
|
||||
| Level | Pattern | Example (`Qwen/Qwen2.5-Omni-7B`) |
|
||||
|-------|---------|----------------------------------|
|
||||
| **L2**, **L3** | `test_{lowercase_model_slug}.py` | `test_qwen2_5_omni.py` |
|
||||
| **L4** | `test_{lowercase_model_slug}_expansion.py` | `test_qwen2_5_omni_expansion.py` |
|
||||
|
||||
**Slug rules for `{lowercase_model_slug}`** (canonical: [SKILL.md](../SKILL.md) § *Naming: generated test module files*):
|
||||
|
||||
1. Start from the HuggingFace-style id (e.g. `Qwen/Qwen2.5-Omni-7B`), but **do not** put the org into the filename: use the **repo segment only** (`Qwen2.5-Omni-7B`), not `Qwen_...` / `qwen_qwen2_5_...`.
|
||||
2. **Lowercase**; replace `.`, `-`, and whitespace with a single `_` (e.g. `Qwen2.5-Omni-7B` → `qwen2_5_omni`). **Omit** trailing size tokens such as `7b` / `30b` in the basename when a single file covers that model line in the directory (matches `test_qwen2_5_omni.py` in-tree).
|
||||
3. If two checkpoints in the same folder need separate modules, add a **minimal** disambiguator (e.g. `_7b` vs `_3b`) **only then**.
|
||||
4. **L1** unit tests are **not** bound to this pattern; use `tests/<area>/test_<feature>.py` as today.
|
||||
|
||||
## Model type markers (`omni` / `tts` / `diffusion`)
|
||||
|
||||
Every model-centric e2e test must declare **exactly one** type marker:
|
||||
|
||||
| Marker | Model family | Typical APIs |
|
||||
|--------|--------------|--------------|
|
||||
| `pytest.mark.omni` | Multimodal LLM (Qwen-Omni, …) | `send_omni_request`, `omni_runner`, stage YAML |
|
||||
| `pytest.mark.tts` | TTS / speech synthesis | `send_audio_speech_request`, `/v1/audio/speech` |
|
||||
| `pytest.mark.diffusion` | Diffusion generative models | `send_diffusion_request`, `send_video_diffusion_request`, `send_images_generations_http_request`, `OmniDiffusionSamplingParams` |
|
||||
|
||||
**Diffusion** uses one marker for all output modalities. Nightly CI splits diffusion by **group**, not by extra markers:
|
||||
|
||||
| Nightly group (`test-nightly.yml`) | Diffusion scope | Examples |
|
||||
|-----------------------------------|-----------------|----------|
|
||||
| **Diffusion X2I(&A&T) Model Test** | x2**i** / x2**a** / x2**t** — image, audio, text (non-video) | Qwen-Image*, BAGEL, FLUX, SD3, Z-Image, LongCat, DreamZero |
|
||||
| **Diffusion X2V Model Test** | x2**v** — video only | Wan2.2, HunyuanVideo 1.5, Wan VACE |
|
||||
|
||||
PR labels for selective nightly runs: `diffusion-x2iat-test`, `diffusion-x2v-test` (plus `nightly-test` / `NIGHTLY=1`).
|
||||
|
||||
## Level and Marker Mapping
|
||||
|
||||
| Goal | Suggested Level | Marker baseline | Typical location |
|
||||
|------|------------------|-----------------|------------------|
|
||||
| Unit logic, regression on pure code path | L1 | `core_model and cpu` | `tests/<component>/test_*.py` |
|
||||
|
||||
### L1 unit tests — mocking (`mocker`, not `unittest.mock`)
|
||||
|
||||
L1 modules run in **Simple Test** (`test-ready.yml` / `test-merge.yml`: `-m 'core_model and cpu'`). When isolation requires doubles:
|
||||
|
||||
- **Use** the **`mocker`** fixture (`pytest-mock`): `mocker.patch`, `mocker.spy`, `mocker.Mock`, `mocker.MagicMock`, `mocker.AsyncMock`.
|
||||
- **Use** **`monkeypatch`** for env vars and simple `setattr` / `delattr` without mock objects.
|
||||
- **Do not use** `unittest.mock` — no `from unittest.mock import ...`, no `@patch`, no `with patch(...)`.
|
||||
|
||||
```python
|
||||
# Good (L1)
|
||||
def test_handler(mocker):
|
||||
mocker.patch("vllm_omni.pkg.fn", return_value=0)
|
||||
|
||||
# Bad (L1)
|
||||
from unittest.mock import patch
|
||||
|
||||
@patch("vllm_omni.pkg.fn")
|
||||
def test_handler(mock_fn): ...
|
||||
```
|
||||
|
||||
E2E (L2+) should not rely on mocks unless documenting a rare exception; prefer real (or lightweight) execution paths.
|
||||
| Basic integration/e2e | L2 | `core_model` + **one of** `omni` / `tts` / `diffusion` | `tests/e2e/...` |
|
||||
| Advanced integration | L3 | `advanced_model` + type marker | `tests/e2e/...` |
|
||||
| Full function / nightly | L4 | `full_model` (nightly) + type marker | **Function:** `tests/e2e/*_expansion.py`; **Perf:** `tests/dfx/perf/tests/*.json`; **Accuracy:** `tests/e2e/accuracy/` |
|
||||
| Invalid HTTP / param validation | Weekly (dfx) | `pytest.mark.slow` + type marker + `H100` or `L4` | `tests/dfx/reliability/invalid_param_test/test_invalid_*.py` |
|
||||
|
||||
## Marker Selection Rules
|
||||
|
||||
1. **Level** (pick one):
|
||||
- `core_model` — L1/L2 (`test-ready.yml`)
|
||||
- `advanced_model` — L3 (`test-merge.yml`)
|
||||
- `full_model` — L4 nightly (`test-nightly.yml`); some expansion tests still carry both `advanced_model` and `full_model` during migration
|
||||
2. **Model type** (pick one): `omni`, `tts`, or `diffusion`
|
||||
3. **Cross-cutting** when relevant: `parallel`, `cache`, `example`, `benchmark`
|
||||
4. **Hardware**: `cpu`, `cuda`, `rocm`, `npu`, `L4`, `H100`, `distributed_cuda`, …
|
||||
5. Multi-card: `@hardware_test(...)` in `tests/helpers/mark.py`
|
||||
|
||||
## Command Templates
|
||||
|
||||
### Quick local checks
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
pytest -s -v test_xxxx.py
|
||||
```
|
||||
|
||||
### L1
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
pytest -s -v -m "core_model and cpu"
|
||||
```
|
||||
|
||||
### L2
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
pytest -s -v -m "core_model and not cpu" --run-level=core_model
|
||||
```
|
||||
|
||||
### L3 (merge)
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
pytest -s -v -m "advanced_model" --run-level=advanced_model
|
||||
```
|
||||
|
||||
### L4 (nightly)
|
||||
|
||||
**Function** (e2e expansion — default when user asks for “L4 functional cases”):
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
pytest -s -v e2e/online_serving/test_qwen_image_expansion.py -m "full_model and diffusion and H100" --run-level=full_model
|
||||
```
|
||||
|
||||
**Perf** (dfx benchmark harness — separate pillar; not `omni_server` fixtures):
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results
|
||||
export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN
|
||||
pytest -s -v dfx/perf/scripts/run_diffusion_benchmark.py \
|
||||
--test-config-file dfx/perf/tests/test_qwen_image_vllm_omni.json
|
||||
```
|
||||
|
||||
Broad marker sweep (when no explicit file shard):
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
pytest -s -v -m "full_model" --run-level=full_model
|
||||
```
|
||||
|
||||
## L4 nightly pillars (Function / Accuracy / Perf)
|
||||
|
||||
Nightly **L4** for a model is often **multiple jobs** in `test-nightly.yml`, not a single pytest module:
|
||||
|
||||
| Pillar | User says | Deliver | Nightly step |
|
||||
|--------|-----------|---------|--------------|
|
||||
| **Function** | “L4 functional cases” / “functional” / `*_expansion.py` | `tests/e2e/.../test_<model>_expansion.py` | `· Function Test with H100/L4` |
|
||||
| **Perf** | “performance” / “perf” / “benchmark” / “full L4” | `tests/dfx/perf/tests/test_<model>_vllm_omni.json` + runner | `· Perf Test · <Model>` |
|
||||
| **Accuracy** | “accuracy” / “similarity” | `tests/e2e/accuracy/test_<model>*.py` | `· Accuracy Test` |
|
||||
|
||||
**Rule:** “L4 functional cases” → **Function only** by default. State that Perf/Accuracy are separate pillars; add them only when requested.
|
||||
|
||||
**Diffusion perf JSON** (`tests/dfx/perf/tests/test_<slug>_vllm_omni.json`): array of objects with `test_name`, `server_params` (`model`, `serve_args`), `benchmark_params[]`, and per-workload **`baseline`** (`throughput_qps`, `latency_mean`, `peak_memory_mb_mean`). Copy structure from `test_qwen_image_vllm_omni.json` in-tree.
|
||||
|
||||
**Diffusion perf nightly step** (under **Diffusion X2I(&A&T)**; copy full `kubernetes` plugins from `· Perf Test · Qwen-Image`):
|
||||
|
||||
```yaml
|
||||
- label: ":full_moon: Diffusion X2I(&A&T) · Perf Test · <Model-Display-Name>"
|
||||
key: nightly-diffusion-x2iat-performance-<slug>
|
||||
timeout_in_minutes: 180
|
||||
commands:
|
||||
- export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results
|
||||
- export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN
|
||||
- export CACHE_DIT_VERSION=1.3.0
|
||||
- |
|
||||
set +e
|
||||
pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_<slug>_vllm_omni.json
|
||||
EXIT=$$?
|
||||
buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json"
|
||||
buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log"
|
||||
exit $$EXIT
|
||||
agents:
|
||||
queue: "mithril-h100-pool"
|
||||
plugins:
|
||||
# … full kubernetes block (often multi-GPU) — copy from Perf Test · Qwen-Image
|
||||
```
|
||||
|
||||
| Model type | Perf runner | Config example |
|
||||
|------------|-------------|----------------|
|
||||
| Diffusion X2I/X2V | `dfx/perf/scripts/run_diffusion_benchmark.py` | `test_qwen_image_vllm_omni.json` |
|
||||
| TTS | `dfx/perf/scripts/run_benchmark.py` | `test_tts.json` (`BENCHMARK_DIR`) |
|
||||
| Omni | `run_benchmark.py` / omni configs in-tree | `test_omni*.json` |
|
||||
|
||||
Do **not** put throughput/latency baselines inside `test_*_expansion.py` — that belongs in the dfx perf JSON + nightly Perf job.
|
||||
|
||||
## Invalid parameter validation (weekly / dfx)
|
||||
|
||||
When the user’s test plan includes **invalid parameter validation**, **invalid request bodies**, or **HTTP 4xx** validation against a live server:
|
||||
|
||||
1. **Do not** author these in `tests/e2e/online_serving/test_*.py` or `*_expansion.py`. **Move** any drafted `test_*` into `tests/dfx/reliability/invalid_param_test/`.
|
||||
2. **Pick script by route** (extend in-tree file):
|
||||
|
||||
| Route family | Script |
|
||||
|--------------|--------|
|
||||
| Omni chat / WS video·realtime | `test_invalid_omni_chat.py` |
|
||||
| `/v1/audio/speech` (+ stream / batch / voices) | `test_invalid_audio_speech.py` |
|
||||
| Audio diffusion | `test_invalid_audio_diffusion.py` |
|
||||
| `/v1/images/generations` | `test_invalid_image_generation.py` |
|
||||
| `/v1/images/edits` | `test_invalid_image_editing.py` |
|
||||
| `/v1/videos*` | `test_invalid_video_generation.py` |
|
||||
| Sleep / wakeup / server control | `test_invalid_server_control.py` |
|
||||
|
||||
3. **Style:** `pytestmark = [pytest.mark.slow, pytest.mark.<type>]`; `_PARAMS` + `hardware_marks`; `send_*_http_request` with `err_code` + `err_message`; parametrized `body_spec` rows with `id=`; `_minimal_*_json()` helpers; `_SKIP_ISSUE_3649` when tracked in [#3649](https://github.com/vllm-project/vllm-omni/issues/3649).
|
||||
4. **CI:** [`.buildkite/test-weekly.yml`](../../../../.buildkite/test-weekly.yml) group **Reliability Test - Invalid parameters Test** — **not** ready/merge/nightly.
|
||||
|
||||
```bash
|
||||
# Weekly H100 (diffusion / omni / video invalid-param)
|
||||
cd tests
|
||||
pytest -s -v dfx/reliability/invalid_param_test/ -m "slow and H100"
|
||||
|
||||
# Weekly L4 (e.g. Qwen3-TTS speech invalid-param)
|
||||
pytest -s -v dfx/reliability/invalid_param_test/ -m "slow and L4"
|
||||
|
||||
# Single script / case
|
||||
pytest -s -v dfx/reliability/invalid_param_test/test_invalid_image_generation.py::test_images_generations_invalid_requests -m "slow and H100"
|
||||
```
|
||||
|
||||
**Trigger:** `WEEKLY=1` or PR label `weekly-test`. Appending cases to existing scripts usually needs **no YAML edit** (directory sweep). No `source_file_dependencies` on weekly steps.
|
||||
|
||||
### Platform-targeted examples
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
pytest -s -v -m "core_model and distributed_cuda and L4" --run-level=core_model
|
||||
```
|
||||
|
||||
### Concrete e2e paths (common in-tree)
|
||||
|
||||
Paths are relative to `tests/` after `cd tests`.
|
||||
|
||||
| Scenario | Example command |
|
||||
|----------|------------------|
|
||||
| **Omni** offline L2 | `pytest -s -v e2e/offline_inference/test_qwen2_5_omni.py -m "core_model and omni and not cpu" --run-level=core_model` |
|
||||
| **Omni** offline L2 — one test | `pytest -s -v e2e/offline_inference/test_qwen2_5_omni.py::test_text_to_text -m "core_model and omni and not cpu" --run-level=core_model` |
|
||||
| **Omni** online L2 | `pytest -s -v e2e/online_serving/test_qwen3_omni.py -m "core_model and omni" --run-level=core_model` |
|
||||
| **Omni** offline L2 (Qwen3.5-9B VL) | `pytest -s -v e2e/offline_inference/test_qwen3_5_9b.py -m "core_model and omni and not cpu" --run-level=core_model` |
|
||||
| **TTS** online L2 | `pytest -s -v e2e/online_serving/test_qwen3_tts_base.py -m "core_model and tts" --run-level=core_model` |
|
||||
| **TTS** online L2 — one test | `pytest -s -v e2e/online_serving/test_qwen3_tts_base.py::test_text_to_audio_001 -m "core_model and tts" --run-level=core_model` |
|
||||
| **Diffusion X2I** offline L2 | `pytest -s -v e2e/offline_inference/test_t2i_model.py -m "core_model and diffusion and not cpu" --run-level=core_model` |
|
||||
| **Diffusion X2I** L2 online smoke | `pytest -s -v e2e/online_serving/test_qwen_image.py e2e/online_serving/test_bagel.py -m "core_model and diffusion" --run-level=core_model` |
|
||||
| **Diffusion X2V** L2 online smoke | `pytest -s -v e2e/online_serving/test_wan22_t2v.py -m "core_model and diffusion" --run-level=core_model` |
|
||||
| **Diffusion X2I** L4 online expansion (H100) | `pytest -s -v e2e/online_serving/test_qwen_image_expansion.py -m "full_model and diffusion and H100" --run-level=full_model` |
|
||||
| **Diffusion X2I** L4 perf (nightly) | `pytest -s -v dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file dfx/perf/tests/test_qwen_image_vllm_omni.json` |
|
||||
| **Diffusion X2I** L4 online expansion (Edit) | `pytest -s -v e2e/online_serving/test_qwen_image_edit_expansion.py -m "full_model and diffusion and H100" --run-level=full_model` |
|
||||
| **Diffusion X2V** L4 nightly | `pytest -s -v e2e/online_serving/test_wan22_expansion.py -m "full_model and cuda" --run-level=full_model` |
|
||||
| **Invalid param** weekly H100 | `pytest -s -v dfx/reliability/invalid_param_test/ -m "slow and H100"` |
|
||||
| **Invalid param** weekly L4 | `pytest -s -v dfx/reliability/invalid_param_test/ -m "slow and L4"` |
|
||||
|
||||
### Agent / author completion checklist
|
||||
|
||||
When adding or modifying tests, do not stop at “where the file lives” — also deliver:
|
||||
|
||||
1. **Local**: `cd tests` + `pytest -s -v <path>` (and `path::test_func` when a single case is enough).
|
||||
2. **CI-like**: marker string + `--run-level` matching the pipeline (`core_model` / `advanced_model` / `full_model`).
|
||||
3. **L1 mocks**: `mocker` / `monkeypatch` only; no `unittest.mock`.
|
||||
4. **API client + assert placement**: **General** validation → implement in `assertions.py`, call **inside** `send_*_request` in `runtime.py`; tests call **`send_*_request` only**. **Special** case validation → `assert_*` in `assertions.py`, called **in the test** after `send_*_request`. Low-level `send_*_http_request` is for negative/dfx tests (`err_code`), not ordinary L2+ success e2e.
|
||||
5. **Shared assertions**: logic in **`tests/helpers/assertions.py`**; general checks inside `send_*_request`; special checks only in the test. No `_assert_*` in `test_*.py`.
|
||||
6. **One case → one `test_*`**: function name reflects what is validated; no `if case_id == ...` mega-test merging multiple scenarios.
|
||||
7. **Fixture scope**: default **`omni_server` + `openai_client`** / **`omni_runner` + `omni_runner_handler`** (module). Use **`omni_server_function` + `openai_client_function`** / **`omni_runner_function` + `omni_runner_handler_function`** only when each `test_*` must spawn a fresh instance. Parametrize name must match fixture (`omni_server` vs `omni_server_function`).
|
||||
8. **Type marker**: `omni`, `tts`, or `diffusion` on every model e2e module.
|
||||
9. **Diffusion L4 Function**: wire `*_expansion.py` into **X2I(&A&T)** or **X2V** **Function Test** in **`test-nightly.yml` only** — do not add `test-merge.yml` unless the user also requested L3.
|
||||
10. **Diffusion L4 Perf** (only when requested): add `tests/dfx/perf/tests/test_<slug>_vllm_omni.json` + **Perf Test · <Model>** step (artifact upload); not part of “L4 functional cases” by default.
|
||||
11. **E2E Buildkite (L2/L3 only)**: `test-ready.yml` / `test-merge.yml` steps need **`source_file_dependencies`** + full **`agents` + `plugins`**. **L4 nightly** uses explicit file lists or perf scripts in `test-nightly.yml` (no merge job).
|
||||
12. **Invalid param**: cases in **`tests/dfx/reliability/invalid_param_test/`** (route-matching script), `send_*_http_request` + `err_code`, `pytest.mark.slow` + `H100`/`L4`; CI = **`test-weekly.yml`** only — do not put in e2e or nightly.
|
||||
13. **Prerequisites**: GPU tier, HF cache/token, and any module `skipif` / platform-only YAML.
|
||||
|
||||
## Buildkite pipeline mapping
|
||||
|
||||
| Level | Repo file | Model-type grouping |
|
||||
|-------|-----------|---------------------|
|
||||
| L1, L2 | [`.buildkite/test-ready.yml`](../../../../.buildkite/test-ready.yml) | Steps prefixed **Omni ·**, **TTS ·**, **Diffusion ·** under **E2E Test** — **`source_file_dependencies` required** |
|
||||
| L3 | [`.buildkite/test-merge.yml`](../../../../.buildkite/test-merge.yml) | Per-model E2E steps; `-m "advanced_model and …"` — **`source_file_dependencies` required** |
|
||||
| L4 | [`.buildkite/test-nightly.yml`](../../../../.buildkite/test-nightly.yml) | **Omni / TTS / Diffusion X2I(&A&T) / X2V** — each group may have **Function**, **Accuracy**, **Perf**, **Doc** steps |
|
||||
| Invalid param (weekly) | [`.buildkite/test-weekly.yml`](../../../../.buildkite/test-weekly.yml) | **Invalid parameters Test · H100** / **· L4** — sweeps `tests/dfx/reliability/invalid_param_test/` |
|
||||
|
||||
### `source_file_dependencies` (E2E Test only — ready & merge)
|
||||
|
||||
Required on each step inside the **E2E Test** group in `test-ready.yml` and `test-merge.yml`. Typical entries:
|
||||
|
||||
| Category | Path pattern |
|
||||
|----------|----------------|
|
||||
| Test module | `tests/e2e/online_serving/test_<slug>.py`, `tests/e2e/offline_inference/test_<slug>.py` |
|
||||
| API client helpers | `tests/helpers/runtime.py` (when the e2e job uses newly added `send_*` methods) |
|
||||
| Shared assert helpers | `tests/helpers/assertions.py` (when the e2e job uses newly added `assert_*` helpers) |
|
||||
| AR / omni model | `vllm_omni/model_executor/models/<family>/` |
|
||||
| Diffusion model | `vllm_omni/diffusion/models/<family>/` |
|
||||
| Stage processor | `vllm_omni/model_executor/stage_input_processors/<family>.py` |
|
||||
| Deploy config | `vllm_omni/deploy/<name>.yaml` or `vllm_omni/deploy/ci/<name>.yaml` |
|
||||
|
||||
Copy the dependency block from the closest in-tree E2E step for the same model family. Update the list whenever the pytest command or server YAML changes.
|
||||
|
||||
### E2E `agents` + `plugins` blocks (required — do not truncate)
|
||||
|
||||
When generating or documenting E2E steps, **always include the complete `plugins` section**. Set **`timeout_in_minutes`** on the step for the job deadline; run **`pytest` directly** in `commands` (no `timeout 40m bash -c` wrapper around pytest).
|
||||
|
||||
Common patterns in `test-ready.yml` / `test-merge.yml`:
|
||||
|
||||
| Queue | Plugin | Required fields |
|
||||
|-------|--------|-----------------|
|
||||
| `mithril-h100-pool` | `kubernetes` | `resources.limits.nvidia.com/gpu`, `volumeMounts` (`devshm`, `hf-cache`), `env` (`HF_HOME`, `HF_TOKEN` via `secretKeyRef`), `nodeSelector: gpu-h100-sxm`, `volumes` (`devshm` emptyDir, `hf-cache` hostPath `/mnt/hf-cache`) |
|
||||
| `gpu_1_queue` / `gpu_4_queue` | `docker#v5.2.0` | `image`, `always-pull`, `propagate-environment`, `shm-size: "8gb"`, `environment: HF_HOME`, `volumes: /fsx/hf_cache` (add `HF_TOKEN` when the step needs hub access) |
|
||||
|
||||
**H100 kubernetes template** (copy from **Diffusion · Bagel Test** / **Diffusion · Qwen Image Test** in-tree):
|
||||
|
||||
```yaml
|
||||
agents:
|
||||
queue: "mithril-h100-pool"
|
||||
plugins:
|
||||
- kubernetes:
|
||||
podSpec:
|
||||
containers:
|
||||
- image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
|
||||
resources:
|
||||
limits:
|
||||
nvidia.com/gpu: 1
|
||||
volumeMounts:
|
||||
- name: devshm
|
||||
mountPath: /dev/shm
|
||||
- name: hf-cache
|
||||
mountPath: /root/.cache/huggingface
|
||||
env:
|
||||
- name: HF_HOME
|
||||
value: /root/.cache/huggingface
|
||||
- name: HF_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: hf-token-secret
|
||||
key: token
|
||||
nodeSelector:
|
||||
node.kubernetes.io/instance-type: gpu-h100-sxm
|
||||
volumes:
|
||||
- name: devshm
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
- name: hf-cache
|
||||
hostPath:
|
||||
path: /mnt/hf-cache
|
||||
type: DirectoryOrCreate
|
||||
```
|
||||
|
||||
**L4 docker template** (copy from **TTS · Qwen3-TTS Base Test** in `test-merge.yml`):
|
||||
|
||||
```yaml
|
||||
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"
|
||||
```
|
||||
|
||||
When extending an existing step (e.g. add `tests/e2e/offline_inference/test_qwen_image.py` to **Diffusion · Qwen Image Test**), update `source_file_dependencies` and `commands` only; **keep the existing `agents` + `plugins` block unchanged** unless the hardware tier changes.
|
||||
|
||||
The root [`.buildkite/pipeline.yml`](../../../../.buildkite/pipeline.yml) decides **which** child file is uploaded. To run **L3** on a feature branch, comment out `if: build.branch == "main"` on the merge upload step. To run **L4** without `NIGHTLY=1`, comment out the nightly upload step’s `if: build.env("NIGHTLY") == "1"` and the same `if` on relevant steps inside `test-nightly.yml`. Revert such edits before merging.
|
||||
|
||||
Platform-specific pipelines (e.g. AMD) follow the same level → file pairing under `.buildkite/`.
|
||||
|
||||
## Diffusion RFC (#1832) Alignment Tips
|
||||
|
||||
For **X2I(&A&T)** coverage planning:
|
||||
|
||||
- Prioritize high-value feature combinations with minimal case count.
|
||||
- Split into lightweight L1/L2 validation plus L4 `*_expansion.py` under the **X2I** nightly shards.
|
||||
- **X2V** models (Wan, HunyuanVideo) stay in the **X2V** group — do not merge into X2I sweeps.
|
||||
|
||||
If hardware is insufficient, provide an executable reduced case plus a deferred full CI/nightly plan.
|
||||
@@ -68,6 +68,7 @@ Visit our [documentation](https://vllm-omni.readthedocs.io/en/latest/) to learn
|
||||
- [Installation](https://vllm-omni.readthedocs.io/en/latest/getting_started/installation/)
|
||||
- [Quickstart](https://vllm-omni.readthedocs.io/en/latest/getting_started/quickstart/)
|
||||
- [List of Supported Models](https://vllm-omni.readthedocs.io/en/latest/models/supported_models/)
|
||||
- [Deployment Recipes](https://recipes.vllm.ai) for vLLM-Omni model serving
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ python3 benchmarks/diffusion/diffusion_benchmark_serving.py \
|
||||
|
||||
- By default, image tasks talk to `http://<host>:<port>/v1/chat/completions`; video tasks talk to `/v1/videos`.
|
||||
- If you run the server on another host or port, pass `--base-url` accordingly.
|
||||
- To compare diffusion multi-replica serving, restart the server with different
|
||||
`runtime.num_replicas` values and keep the benchmark parameters unchanged.
|
||||
|
||||
## 2. Supported Datasets
|
||||
|
||||
@@ -93,6 +95,35 @@ You can point to your own trace using `--dataset-path`.
|
||||
|
||||
## 3. Benchmark Parameters
|
||||
|
||||
### Multi-replica sweep
|
||||
|
||||
For replica scaling, use the same model, prompt count, image/video size, and
|
||||
step count for every run. Change only the serving stage config:
|
||||
|
||||
```yaml
|
||||
runtime:
|
||||
devices: "0,1,2,3"
|
||||
num_replicas: 4
|
||||
```
|
||||
|
||||
Then run a fixed benchmark and write one JSON file per replica count:
|
||||
|
||||
```bash
|
||||
python3 benchmarks/diffusion/diffusion_benchmark_serving.py \
|
||||
--base-url http://localhost:8099 \
|
||||
--model Qwen/Qwen-Image \
|
||||
--task t2i \
|
||||
--dataset random \
|
||||
--num-prompts 32 \
|
||||
--max-concurrency 8 \
|
||||
--request-rate inf \
|
||||
--num-inference-steps 10 \
|
||||
--width 512 --height 512 \
|
||||
--output-file diffusion_replica_4.json
|
||||
```
|
||||
|
||||
Repeat for `num_replicas=1`, `2`, `3`, and `4`, using matching `runtime.devices`.
|
||||
|
||||
### Basic flags
|
||||
|
||||
- `--base-url`: Server address; `--endpoint` selects the path appended to this base URL.
|
||||
|
||||
@@ -79,6 +79,7 @@ Usage:
|
||||
import argparse
|
||||
import ast
|
||||
import asyncio
|
||||
import base64
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
@@ -106,6 +107,9 @@ from tqdm.asyncio import tqdm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_STAGE_METRICS_ENDPOINTS = {"/v1/chat/completions"}
|
||||
_RETURN_STAGE_METRICS_FIELD = "return_stage_metrics"
|
||||
|
||||
|
||||
class BaseDataset(ABC):
|
||||
def __init__(self, args, api_url: str, model: str):
|
||||
@@ -1013,6 +1017,73 @@ def calculate_metrics(
|
||||
return metrics
|
||||
|
||||
|
||||
def _save_generated_outputs(
|
||||
outputs: list[RequestFuncOutput],
|
||||
requests_list: list[RequestFuncInput],
|
||||
save_dir: str,
|
||||
) -> None:
|
||||
"""Decode and save base64 images/videos from successful responses."""
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
saved = 0
|
||||
failed = 0
|
||||
|
||||
for idx, (req, out) in enumerate(zip(requests_list, outputs)):
|
||||
if not out.success or not out.response_body:
|
||||
continue
|
||||
|
||||
media_urls: list[str] = []
|
||||
|
||||
# Chat-completions style: choices[*].message.content[*].image_url.url
|
||||
choices = out.response_body.get("choices", [])
|
||||
if isinstance(choices, list):
|
||||
for choice in choices:
|
||||
content = (choice or {}).get("message", {}).get("content")
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
for item in content:
|
||||
if not isinstance(item, dict) or item.get("type") != "image_url":
|
||||
continue
|
||||
url = (item.get("image_url") or {}).get("url", "")
|
||||
if isinstance(url, str) and url.startswith("data:"):
|
||||
media_urls.append(url)
|
||||
|
||||
# Images endpoint style: data[*].b64_json
|
||||
data_items = out.response_body.get("data", [])
|
||||
if isinstance(data_items, list):
|
||||
for data_item in data_items:
|
||||
if not isinstance(data_item, dict):
|
||||
continue
|
||||
b64_json = data_item.get("b64_json", "")
|
||||
if isinstance(b64_json, str) and b64_json:
|
||||
media_urls.append(f"data:image/png;base64,{b64_json}")
|
||||
|
||||
for img_idx, url in enumerate(media_urls):
|
||||
if "," not in url:
|
||||
continue
|
||||
|
||||
try:
|
||||
header, b64_data = url.split(",", 1)
|
||||
ext = "png"
|
||||
if "image/jpeg" in header:
|
||||
ext = "jpg"
|
||||
elif "image/webp" in header:
|
||||
ext = "webp"
|
||||
elif "video/mp4" in header:
|
||||
ext = "mp4"
|
||||
|
||||
img_bytes = base64.b64decode(b64_data)
|
||||
fname = f"req_{idx:04d}_{img_idx}.{ext}"
|
||||
fpath = os.path.join(save_dir, fname)
|
||||
with open(fpath, "wb") as f:
|
||||
f.write(img_bytes)
|
||||
saved += 1
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
logger.warning(f"Failed to save image for request {idx}: {e}", exc_info=True)
|
||||
|
||||
logger.info(f"Saved {saved} generated image(s) to {save_dir}. Failed to save {failed} image(s).")
|
||||
|
||||
|
||||
def wait_for_service(base_url: str, timeout: int = 120) -> None:
|
||||
print(f"Waiting for service at {base_url}...")
|
||||
start_time = time.time()
|
||||
@@ -1095,6 +1166,10 @@ async def benchmark(args):
|
||||
requests_list = dataset.get_requests()
|
||||
print(f"Prepared {len(requests_list)} requests from {args.dataset} dataset.")
|
||||
|
||||
if args.return_stage_metrics and args.endpoint in _STAGE_METRICS_ENDPOINTS:
|
||||
for req in requests_list:
|
||||
req.extra_body.setdefault(_RETURN_STAGE_METRICS_FIELD, True)
|
||||
|
||||
if args.endpoint == "/v1/images/edits":
|
||||
for req in requests_list:
|
||||
req.default_bot_task = args.bot_task
|
||||
@@ -1202,6 +1277,9 @@ async def benchmark(args):
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
|
||||
if args.save_dir:
|
||||
_save_generated_outputs(outputs, requests_list, args.save_dir)
|
||||
|
||||
if args.output_file:
|
||||
with open(args.output_file, "w") as f:
|
||||
json.dump(metrics, f, indent=2)
|
||||
@@ -1278,11 +1356,18 @@ if __name__ == "__main__":
|
||||
default=1,
|
||||
help="Number of warmup requests to run before measurement.",
|
||||
)
|
||||
# NOTE Changed default from 1 to 2 because some models (e.g., Bagel) run
|
||||
# `num_timesteps - 1` denoising iterations. A default of 1 results in 0 steps,
|
||||
# which causes errors.
|
||||
# TODO If this slightly longer warmup causes regression issues for other
|
||||
# diffusion pipelines in the future, consider implementing model-specific
|
||||
# overrides instead of a global default.
|
||||
parser.add_argument(
|
||||
"--warmup-num-inference-steps",
|
||||
type=int,
|
||||
default=1,
|
||||
help="num_inference_steps used for warmup requests.",
|
||||
default=2,
|
||||
help="Number of inference steps used for warmup requests. "
|
||||
"Default is 2 to ensure at least one denoising step is executed.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--warmup-concurrency",
|
||||
@@ -1323,6 +1408,13 @@ if __name__ == "__main__":
|
||||
default=3.0,
|
||||
help="SLO target multiplier: slo_ms = estimated_exec_time_ms * slo_scale (default: 3).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--save-dir",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Directory to save generated images/outputs for visual inspection. "
|
||||
"If not set, generated outputs are discarded after metric collection.",
|
||||
)
|
||||
parser.add_argument("--disable-tqdm", action="store_true", help="Disable progress bar.")
|
||||
parser.add_argument(
|
||||
"--enable-negative-prompt",
|
||||
@@ -1358,6 +1450,11 @@ if __name__ == "__main__":
|
||||
default="think",
|
||||
help=("bot_task form field for --endpoint /v1/images/edits (think, recaption, think_recaption, vanilla)."),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--return-stage-metrics",
|
||||
action="store_true",
|
||||
help="Request stage duration metrics from endpoints that support return_stage_metrics.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
asyncio.run(benchmark(args))
|
||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
Before Width: | Height: | Size: 403 KiB After Width: | Height: | Size: 262 KiB |
@@ -129,6 +129,27 @@ The precheck covers five PR types: Bug Fix, Performance, New Model, Diffusion Mo
|
||||
### 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.
|
||||
|
||||
### Automatic skip-ci (docs and pytest skip marks)
|
||||
|
||||
On pull requests and `main` pushes, the bootstrap step in [`.buildkite/pipeline.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/pipeline.yml) runs [`.buildkite/scripts/upload_pipeline.py`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/scripts/upload_pipeline.py) against the git diff. When every changed file qualifies, **L2 (`ready`) and L3 (`merge-test`) pipelines are not uploaded**, so the default GPU CI jobs are skipped.
|
||||
|
||||
| Change per file | Examples |
|
||||
| --- | --- |
|
||||
| Documentation | `docs/**`, any `*.md`, `mkdocs.yml` |
|
||||
| Pytest skip marks only (under `tests/`) | Add/remove/edit `@pytest.mark.skip`, `@pytest.mark.skipif`, or `pytest.skip(...)`; reformat `pytestmark` only to add a skip/skipif alongside existing `pytest.mark.*` entries |
|
||||
| New skipped test module | New `tests/**/*.py` whose `pytestmark` includes unconditional `pytest.mark.skip` |
|
||||
|
||||
These PR shapes all trigger skip-ci:
|
||||
|
||||
- Documentation only
|
||||
- Qualifying skip-mark edits in `tests/**/*.py` only
|
||||
- **A mix of documentation and qualifying skip-mark test edits**
|
||||
|
||||
Skip-ci does **not** apply when the diff also touches product code (for example `vllm_omni/`), or when test files change assertions, imports, fixtures, or other non-skip logic. If the diff cannot be resolved (non-PR branches outside `main`), CI runs as usual.
|
||||
|
||||
!!! note
|
||||
Skipping L2/L3 does **not** disable the Docker image build step. Nightly (L4) upload can still run when the PR has a `nightly-test` label or on scheduled `main` builds with `NIGHTLY=1`. See [CI levels](./ci/CI_5levels.md) for how bootstrap skip-ci relates to diff-gated E2E jobs.
|
||||
|
||||
### Code Quality
|
||||
|
||||
The PR needs to meet the following code quality standards:
|
||||
|
||||
@@ -11,6 +11,10 @@ Through five levels (L1-L5) and common (Common) specifications, the system clari
|
||||
<tr>
|
||||
<th>Level</th>
|
||||
<th>Scope & Focus</th>
|
||||
<th>Model Coverage Strategy</th>
|
||||
<th>Feature Coverage Strategy</th>
|
||||
<th>Interface Coverage Strategy</th>
|
||||
<th>Tags</th>
|
||||
<th>Time Cost</th>
|
||||
<th>Test Dir</th>
|
||||
<th>Doc</th>
|
||||
@@ -24,6 +28,10 @@ Through five levels (L1-L5) and common (Common) specifications, the system clari
|
||||
<td>Contribution Guideline & PR checklist</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
<td>.github/PULL_REQUEST_TEMPLATE.md <a href="../tests_style/"> Test Style (PR Checklist)</a></td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
@@ -32,6 +40,10 @@ Through five levels (L1-L5) and common (Common) specifications, the system clari
|
||||
<td>CI Failure Description</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
<td><a href="../failures/"> CI Failures</a></td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
@@ -39,6 +51,10 @@ Through five levels (L1-L5) and common (Common) specifications, the system clari
|
||||
<tr>
|
||||
<td><strong>L1</strong><br>(Unit & Logic)</td>
|
||||
<td>Unit tests for components like entrypoints, models</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
<td><code>core_model and cpu</code></td>
|
||||
<td rowspan="2"><15min</td>
|
||||
<td>/tests/{component_name}/test_xxx</td>
|
||||
<td>
|
||||
@@ -50,10 +66,18 @@ Through five levels (L1-L5) and common (Common) specifications, the system clari
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>L2</strong><br>(E2E across models & GPU-required UT)</td>
|
||||
<td>Online & Offline (basic deployment scenarios):<br>dummy, normal inference function (output format, stream), some instance startup UT</td>
|
||||
<td>Online (basic deployment scenarios):<br>dummy, normal inference function (output format, stream), some instance startup UT</td>
|
||||
<td>High-priority models + online basic scenarios + request success validation</td>
|
||||
<td>High-priority features (using random lightweight models)</td>
|
||||
<td>High-priority interfaces (using random lightweight models)</td>
|
||||
<td><code>core_model and hardware_test(H100, L4, etc.) and omni/tts/diffusion</code></td>
|
||||
<td>
|
||||
<strong>Model tests:</strong><br>
|
||||
/tests/e2e/online_serving/test_{model_name}.py<br>
|
||||
/tests/e2e/offline_inference/test_{model_name}.py
|
||||
<strong>Feature tests:</strong><br>
|
||||
/tests/{component_name}/test_xxx<br>
|
||||
<strong>Interface tests:</strong><br>
|
||||
/tests/entrypoints/test_xxx
|
||||
</td>
|
||||
<td>
|
||||
<a href="#chapter-1-l1-l2-level-testing-unit-testing-and-basic-end-to-end-verification">Chapter 1</a><br>
|
||||
@@ -65,10 +89,19 @@ Through five levels (L1-L5) and common (Common) specifications, the system clari
|
||||
<tr>
|
||||
<td><strong>L3</strong><br>(Important Perf & Integration & Accuracy)</td>
|
||||
<td>Online & Offline (multiple deployment scenarios):<br>real model, normal inference function, normal accuracy</td>
|
||||
<td>High/medium-priority models with real weights + online/offline key scenarios + basic accuracy validation</td>
|
||||
<td>Medium-priority features (using random lightweight models)</td>
|
||||
<td>Medium-priority interfaces (using random lightweight models)</td>
|
||||
<td><code>advanced_model and hardware_test(H100, L4, etc.) and omni/tts/diffusion</code></td>
|
||||
<td><30min</td>
|
||||
<td>
|
||||
<strong>Model tests:</strong><br>
|
||||
/tests/e2e/online_serving/test_{model_name}.py<br>
|
||||
/tests/e2e/offline_inference/test_{model_name}.py
|
||||
/tests/e2e/offline_inference/test_{model_name}.py<br>
|
||||
<strong>Feature tests:</strong><br>
|
||||
/tests/{component_name}/test_xxx<br>
|
||||
<strong>Interface tests:</strong><br>
|
||||
/tests/entrypoints/test_xxx
|
||||
</td>
|
||||
<td>
|
||||
<a href="#chapter-2-l3-level-testing-core-integration-performance-and-accuracy-verification">Chapter 2</a><br>
|
||||
@@ -79,18 +112,27 @@ Through five levels (L1-L5) and common (Common) specifications, the system clari
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>L4</strong><br>(Perf & Integration & Accuracy)</td>
|
||||
<td>Online & Offline: full functional scenarios + performance test + doc test</td>
|
||||
<td>Online: full functional scenarios + performance test + doc test + accuracy test</td>
|
||||
<td>High-priority models: function, performance, accuracy, and doc testing<br>Medium/low-priority models: function and doc testing</td>
|
||||
<td>Low-priority features (using random lightweight models)</td>
|
||||
<td>Low-priority interfaces (using random lightweight models)</td>
|
||||
<td><code>full_model and hardware_test(H100, L4, etc.) and omni/tts/diffusion</code></td>
|
||||
<td><3 hour</td>
|
||||
<td>
|
||||
<strong>Full Function:</strong><br>
|
||||
<strong>Model tests:</strong><br>
|
||||
/tests/e2e/online_serving/test_{model_name}_expansion.py<br>
|
||||
/tests/e2e/offline_inference/test_{model_name}_expansion.py<br>
|
||||
<strong>Feature tests:</strong><br>
|
||||
/tests/{component_name}/test_xxx<br>
|
||||
<strong>Interface tests:</strong><br>
|
||||
/tests/entrypoints/test_xxx<br>
|
||||
<strong>Performance:</strong><br>
|
||||
/tests/dfx/perf/tests/test_qwen_omni.json (Omni), test_tts.json (TTS),<br>
|
||||
and /tests/dfx/perf/tests/test_{diffusion_model}_vllm_omni.json (Diffusion)<br>
|
||||
<strong>Doc Test:</strong><br>
|
||||
tests/example/online_serving/test_{model_name}.py<br>
|
||||
tests/example/offline_inference/test_{model_name}.py
|
||||
tests/example/offline_inference/test_{model_name}.py<br>
|
||||
<strong>Accuracy Test:</strong><br>
|
||||
/tests/e2e/accuracy/test_{model_name}.py
|
||||
</td>
|
||||
<td>
|
||||
<a href="#chapter-3-l4-level-testing-full-functionality-performance-and-documentation-testing">Chapter 3</a><br>
|
||||
@@ -101,7 +143,11 @@ Through five levels (L1-L5) and common (Common) specifications, the system clari
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>L5</strong><br>(Stability & Reliability)</td>
|
||||
<td>Online & Offline: long-term stability test + reliability test</td>
|
||||
<td>Online: long-term stability test + reliability test</td>
|
||||
<td>Long-term stability and reliability testing for high-priority models</td>
|
||||
<td>/</td>
|
||||
<td>Invalid-parameter validation for high-priority interfaces</td>
|
||||
<td><code>slow and hardware_test(H100, L4, etc.) and omni/tts/diffusion</code></td>
|
||||
<td> Depends on reality </td>
|
||||
<td>
|
||||
<strong>Stability:</strong><br>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -94,6 +94,10 @@ Note: Pre-built wheels are currently available for vLLM-Omni 0.11.0rc1, 0.12.0rc
|
||||
|
||||
### Build your own docker image
|
||||
|
||||
=== "NVIDIA CUDA"
|
||||
|
||||
--8<-- "docs/getting_started/installation/gpu/cuda.inc.md:build-docker"
|
||||
|
||||
=== "AMD ROCm"
|
||||
|
||||
--8<-- "docs/getting_started/installation/gpu/rocm.inc.md:build-docker"
|
||||
|
||||
@@ -110,3 +110,53 @@ docker run --runtime nvidia --gpus 2 \
|
||||
The CUDA image does not define a default entrypoint, so include `vllm serve ... --omni` after the image name.
|
||||
|
||||
# --8<-- [end:pre-built-images]
|
||||
|
||||
# --8<-- [start:build-docker]
|
||||
|
||||
#### Build docker image
|
||||
|
||||
```bash
|
||||
DOCKER_BUILDKIT=1 docker build -f docker/Dockerfile.cuda -t vllm-omni-cuda .
|
||||
```
|
||||
|
||||
If you want to specify the base vLLM version:
|
||||
|
||||
```bash
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
-f docker/Dockerfile.cuda \
|
||||
--build-arg BASE_IMAGE=vllm/vllm-openai:v0.22.1 \
|
||||
-t vllm-omni-cuda .
|
||||
```
|
||||
|
||||
#### Launch the docker image
|
||||
|
||||
##### Launch with OpenAI API Server
|
||||
|
||||
!!! note
|
||||
The model `Qwen/Qwen3-Omni-30B-A3B-Instruct` requires significant GPU memory. The example below has been verified on 2 x H100's.
|
||||
|
||||
```bash
|
||||
docker run --runtime nvidia --gpus 2 \
|
||||
-v ${HF_HOME:-$HOME/.cache/huggingface}:/root/.cache/huggingface \
|
||||
--env "HF_TOKEN=$HF_TOKEN" \
|
||||
-p 8091:8091 \
|
||||
--ipc=host \
|
||||
vllm-omni-cuda \
|
||||
vllm serve --omni --model Qwen/Qwen3-Omni-30B-A3B-Instruct --port 8091
|
||||
```
|
||||
|
||||
By default, this mounts `$HOME/.cache/huggingface` as the model cache directory. To use a custom location, set the `HF_HOME` environment variable before running the command (e.g., `export HF_HOME=/data/models`).
|
||||
|
||||
##### Launch with interactive session for development
|
||||
|
||||
```bash
|
||||
docker run --runtime nvidia --gpus all -it --rm \
|
||||
-v ${HF_HOME:-$HOME/.cache/huggingface}:/root/.cache/huggingface \
|
||||
--env "HF_TOKEN=$HF_TOKEN" \
|
||||
-p 8091:8091 \
|
||||
--ipc=host \
|
||||
--entrypoint bash \
|
||||
vllm-omni-cuda
|
||||
```
|
||||
|
||||
# --8<-- [end:build-docker]
|
||||
@@ -6,6 +6,8 @@ vLLM-Omni supports unified multimodal comprehension and generation models across
|
||||
|
||||
If vLLM-Omni natively supports a model, its implementation can be found in <gh-file:vllm_omni/model_executor/models> and <gh-file:vllm_omni/diffusion/models>.
|
||||
|
||||
For deployment recipes (TTS and multimodal), see [recipes.vllm.ai](https://recipes.vllm.ai) and the in-repo [`recipes/`](https://github.com/vllm-project/vllm-omni/tree/main/recipes) directory.
|
||||
|
||||
## List of Supported Models
|
||||
|
||||
<style>
|
||||
@@ -22,6 +24,7 @@ th {
|
||||
| `MingFlashOmniForConditionalGeneration` + `MingImagePipeline` | Ming-flash-omni-2.0 (omni-speech + imagegen<sup>1</sup>) | `Jonathan1909/Ming-flash-omni-2.0` | ✅︎ | | | |
|
||||
| `BagelForConditionalGeneration` | BAGEL (DiT-only) | `ByteDance-Seed/BAGEL-7B-MoT` | ✅︎ | ✅︎ | | ✅︎ |
|
||||
| `InternVLAA1Pipeline` | InternVLA-A1 | `InternRobotics/InternVLA-A1-3B` | ✅︎ | ✅︎ | | |
|
||||
| `Gr00tN1d7Pipeline` | GR00T N1.7 | `nvidia/GR00T-N1.7-3B` | ✅︎ | | | |
|
||||
| `HunyuanImage3ForCausalMM` | HunyuanImage3.0 (DiT-only) | `tencent/HunyuanImage-3.0`, `tencent/HunyuanImage-3.0-Instruct` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
|
||||
| `QwenImagePipeline` | Qwen-Image | `Qwen/Qwen-Image` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
|
||||
| `QwenImagePipeline` | Qwen-Image-2512 | `Qwen/Qwen-Image-2512` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
|
||||
@@ -48,6 +51,7 @@ th {
|
||||
| `OvisImagePipeline` | Ovis-Image | `OvisAI/Ovis-Image` | ✅︎ | ✅︎ | | ✅︎ |
|
||||
| `LongcatImagePipeline` | LongCat-Image | `meituan-longcat/LongCat-Image` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
|
||||
| `LongCatImageEditPipeline` | LongCat-Image-Edit | `meituan-longcat/LongCat-Image-Edit` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
|
||||
| `StableDiffusionXLPipeline` | Stable-Diffusion-XL | `stabilityai/stable-diffusion-xl-base-1.0` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
|
||||
| `StableDiffusion3Pipeline` | Stable-Diffusion-3 | `stabilityai/stable-diffusion-3.5-medium` | ✅︎ | ✅︎ | | ✅︎ |
|
||||
| `CosyVoice3Model` | CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | ✅︎ | ✅︎ | | ✅︎ |
|
||||
| `OmniVoiceModel` | OmniVoice | `k2-fsa/OmniVoice` | ✅︎ | | | |
|
||||
@@ -62,13 +66,14 @@ th {
|
||||
| `AudioXPipeline` | AudioX | `zhangj1an/AudioX` | ✅︎ | ✅︎ | | |
|
||||
| `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` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
|
||||
| `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-Base | `Qwen/Qwen3-TTS-12Hz-1.7B-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` | ✅︎ | | | |
|
||||
| `MossTTSRealtime` | MOSS-TTS-Realtime | `OpenMOSS-Team/MOSS-TTS-Realtime` | ✅︎ | | | |
|
||||
| `HiggsAudioV2ForConditionalGeneration` | Higgs-Audio v2 | `bosonai/higgs-audio-v2-generation-3B-base` | ✅︎ | | | |
|
||||
| `HiggsMultimodalQwen3ForConditionalGeneration` | Higgs-Audio v3 (TTS) | `bosonai/higgs-audio-v3-tts-4b` | ✅︎ | | | |
|
||||
| `NextStep11Pipeline` | NextStep-1.1 | `stepfun-ai/NextStep-1.1` | ✅︎ | ✅︎ | | ✅︎ |
|
||||
| `MiMoAudioModel` | MiMo-Audio-7B-Instruct | `XiaomiMiMo/MiMo-Audio-7B-Instruct` | ✅︎ | ✅︎ | | |
|
||||
| `MiMoV2ASRForCausalLM` | MiMo-V2.5-ASR | `XiaomiMiMo/MiMo-V2.5-ASR` | ✅︎ | ✅︎ | | |
|
||||
|
||||
@@ -9,6 +9,13 @@ vLLM-Omni provides an OpenAI-compatible API for text-to-speech (TTS) generation.
|
||||
|
||||
See the [Supported Models](#supported-models) section below for the full list, including OmniVoice, VoxCPM2, and MOSS-TTS-Nano.
|
||||
|
||||
!!! tip "Deployment recipes"
|
||||
TTS deployment recipes are published at
|
||||
[recipes.vllm.ai](https://recipes.vllm.ai) (e.g.
|
||||
[Qwen3-TTS](https://recipes.vllm.ai/Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice),
|
||||
[Higgs-Audio v3](https://recipes.vllm.ai/bosonai/higgs-audio-v3-tts-4b)).
|
||||
The in-repo runbooks live under [`recipes/`](https://github.com/vllm-project/vllm-omni/tree/main/recipes).
|
||||
|
||||
Each server instance runs a single model (specified at startup via `vllm serve <model> --omni`).
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -195,13 +195,16 @@ curl -X POST http://localhost:8091/v1/videos/sync \
|
||||
|
||||
## Storage
|
||||
|
||||
Set `VLLM_OMNI_STORAGE_PATH` to control where asynchronous video outputs are
|
||||
Set `VLLM_OMNI_SERVER_STORAGE__PATH` to control where asynchronous video outputs are
|
||||
stored:
|
||||
|
||||
```bash
|
||||
export VLLM_OMNI_STORAGE_PATH=/var/tmp/vllm-omni-videos
|
||||
export VLLM_OMNI_SERVER_STORAGE__PATH=/var/tmp/vllm-omni-videos
|
||||
```
|
||||
|
||||
> `VLLM_OMNI_STORAGE_PATH` is deprecated and will be removed in a future release;
|
||||
> use `VLLM_OMNI_SERVER_STORAGE__PATH` instead.
|
||||
|
||||
## Model-Specific Examples
|
||||
|
||||
For complete text-to-video, image-to-video, and model-specific video-to-video
|
||||
|
||||
@@ -193,6 +193,7 @@ Factory function `get_offload_backend()` selects the appropriate backend based o
|
||||
| NextStep11Pipeline | `stepfun-ai/NextStep-1.1` | `NextStepModel` | - | ✓ | `"layers"` |
|
||||
| OvisImagePipeline | `AIDC-AI/Ovis-Image-7B` | `OvisImageTransformer2DModel` | - | ✓ | `"transformer"` |
|
||||
| QwenImagePipeline | `Qwen/Qwen-Image` | `QwenImageTransformer2DModel` | ✓ | ✓ | `"transformer_blocks"` |
|
||||
| StableDiffusionXLPipeline | `stabilityai/stable-diffusion-xl-base-1.0` | `SDXLUNet2DConditionModel` | ✓ | ✓ | `"down_blocks"`, `"up_blocks"` |
|
||||
| StableDiffusion3Pipeline | `stabilityai/stable-diffusion-3.5-medium` | `SD3Transformer2DModel` | - | ✓ | `"transformer_blocks"` |
|
||||
| Wan22I2VPipeline | `Wan-AI/Wan2.2-I2V-A14B-Diffusers` | `WanTransformer3DModel` | ✓ | ✓ | `"blocks"` |
|
||||
| Wan22Pipeline | `Wan-AI/Wan2.2-T2V-A14B-Diffusers` | `WanTransformer3DModel` | ✓ | ✓ | `"blocks"` |
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
This directory contains auto-tuned Triton kernel configurations for the
|
||||
MoT (Mixture-of-Tokens) GEMM and RMSNorm operators used by BAGEL and other
|
||||
MoT-architecture diffusion models.
|
||||
|
||||
File naming convention:
|
||||
device_name=<GPU_NAME>,dtype=<DTYPE>.json
|
||||
|
||||
For example:
|
||||
device_name=NVIDIA_A100-SXM4-80GB,dtype=w16a16.json
|
||||
|
||||
Each JSON file maps (K, N) matrix shapes to a dictionary of batch sizes (M)
|
||||
and their optimal Triton tile configurations:
|
||||
|
||||
{
|
||||
"3584_4608": { // K=3584, N=4608 (QKV projection)
|
||||
"1024": {
|
||||
"BLOCK_SIZE_M": 64,
|
||||
"BLOCK_SIZE_N": 128,
|
||||
"BLOCK_SIZE_K": 64,
|
||||
"GROUP_SIZE_M": 8,
|
||||
"num_warps": 4,
|
||||
"num_stages": 3
|
||||
},
|
||||
...
|
||||
},
|
||||
"3584_3584": { ... } // K=3584, N=3584 (output projection)
|
||||
}
|
||||
|
||||
Config loading order (3-tier, see ops/mot_gemm.py):
|
||||
1. $VLLM_TUNED_CONFIG_FOLDER/<filename> (env override)
|
||||
2. This directory: vllm_omni/diffusion/layers/mot/configs/<filename>
|
||||
3. Conservative default config (compiles everywhere, sub-optimal perf)
|
||||
|
||||
If no config file matches the current device, a warning is printed with
|
||||
instructions to run the auto-tuning benchmark.
|
||||
|
||||
To generate configs for your hardware:
|
||||
|
||||
python benchmarks/kernels/mot_linear_benchmarks.py \
|
||||
--model ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--tp-size 1 --dtype w16a16 --tune \
|
||||
--save-dir vllm_omni/diffusion/layers/mot/configs/
|
||||
|
||||
For multi-GPU tuning (uses Ray for parallel search):
|
||||
|
||||
python benchmarks/kernels/mot_linear_benchmarks.py \
|
||||
--model ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--tp-size 2 --tune
|
||||
|
||||
See benchmarks/kernels/mot_linear_benchmarks.py for full options.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Diffusion Multi-Replica Serving
|
||||
|
||||
Diffusion stages can be replicated in online serving by setting `runtime.num_replicas` on a `stage_type: diffusion` stage. Each replica owns an independent diffusion engine process and receives requests through round-robin routing with request affinity.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use multi-replica serving when one diffusion process does not saturate the available GPUs, or when you want higher request throughput for many independent image/video generation requests. It improves concurrency throughput; it does not make a single request faster.
|
||||
|
||||
## Configuration
|
||||
|
||||
A replica consumes the devices required by that diffusion stage. For single-device diffusion pipelines, set `runtime.devices` to one device per replica:
|
||||
|
||||
```yaml
|
||||
stages:
|
||||
- stage_id: 0
|
||||
stage_type: diffusion
|
||||
runtime:
|
||||
devices: "0,1,2,3"
|
||||
num_replicas: 4
|
||||
engine_args:
|
||||
model: Qwen/Qwen-Image
|
||||
parallel_config:
|
||||
world_size: 1
|
||||
```
|
||||
|
||||
For multi-device diffusion pipelines, `parallel_config.world_size` is treated as the number of devices per replica. For example, four 2-GPU replicas need eight devices:
|
||||
|
||||
```yaml
|
||||
stages:
|
||||
- stage_id: 0
|
||||
stage_type: diffusion
|
||||
runtime:
|
||||
devices: "0,1,2,3,4,5,6,7"
|
||||
num_replicas: 4
|
||||
engine_args:
|
||||
model: Wan-AI/Wan2.2-T2V-A14B-Diffusers
|
||||
parallel_config:
|
||||
world_size: 2
|
||||
```
|
||||
|
||||
The runtime splits the device list into contiguous chunks per replica. With the second example, replica 0 uses `0,1`, replica 1 uses `2,3`, replica 2 uses `4,5`, and replica 3 uses `6,7`.
|
||||
|
||||
## Serving
|
||||
|
||||
Start the server with the stage config:
|
||||
|
||||
```bash
|
||||
vllm serve Qwen/Qwen-Image \
|
||||
--omni \
|
||||
--port 8099 \
|
||||
--stage-configs-path /path/to/diffusion_multi_replica.yaml
|
||||
```
|
||||
|
||||
Then send normal OpenAI-compatible diffusion chat requests. The orchestrator routes new requests across replicas and keeps updates/abort calls on the same replica for each request.
|
||||
|
||||
## Benchmarking
|
||||
|
||||
Benchmark each replica count by changing `runtime.num_replicas` and `runtime.devices`, restarting the server, then running:
|
||||
|
||||
```bash
|
||||
python benchmarks/diffusion/diffusion_benchmark_serving.py \
|
||||
--base-url http://localhost:8099 \
|
||||
--model Qwen/Qwen-Image \
|
||||
--task t2i \
|
||||
--dataset random \
|
||||
--num-prompts 8 \
|
||||
--request-rate inf \
|
||||
--num-inference-steps 2 \
|
||||
--width 256 --height 256 \
|
||||
--output-file diffusion_replica_4.json
|
||||
```
|
||||
|
||||
For a quick scaling sweep, test `num_replicas` values `1`, `2`, `3`, and `4` with the same prompt count and generation parameters. Compare throughput and end-to-end latency from the JSON outputs.
|
||||
|
||||
## Notes and Limitations
|
||||
|
||||
- Multi-replica serving is currently supported for diffusion stages. Single-stage mode still rejects non-diffusion replicas.
|
||||
- `runtime.devices` must provide exactly `num_replicas * devices_per_replica` devices.
|
||||
- Request-level latency may stay similar or slightly increase because each request still runs on one replica.
|
||||
- Throughput improves only when enough concurrent requests are issued and each replica has enough GPU memory.
|
||||
@@ -130,6 +130,7 @@ The following tables show which models support each feature:
|
||||
| **Qwen-Image-Edit-2509** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ (decode) | ✅ | ❌ | ❌ |
|
||||
| **Qwen-Image-Layered** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
|
||||
| **SenseNova-U1** | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
| **Stable-Diffusion-XL** | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
|
||||
| **Stable-Diffusion3.5** | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ (decode) | ❌ | ❌ |
|
||||
| **Z-Image** | ✅ | ✅ | ✅ | ❓ | ✅ (TP=2 only) | ❌ | ✅ | ❌ | ✅ (decode) | ✅ | ❌ |
|
||||
| **ERNIE-Image** | ❌ | ✅ | ✅ | ❓ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
|
||||
@@ -19,7 +19,7 @@ both stages on a single GPU, override per stage:
|
||||
|
||||
```bash
|
||||
vllm serve zai-org/GLM-Image --omni --port 8091 \
|
||||
--stage-0-devices 0 --stage-1-devices 0
|
||||
--stage-overrides '{"0": {"devices": "0"}, "1": {"devices": "0"}}'
|
||||
```
|
||||
|
||||
## API Calls
|
||||
|
||||
@@ -84,16 +84,26 @@ curl -X POST http://localhost:8091/v1/videos/sync \
|
||||
Generated video files are stored on local disk by the async video API.
|
||||
Local file storage behavior can be controlled via the following environment variables:
|
||||
|
||||
- `VLLM_OMNI_STORAGE_PATH`: directory used for generated files (default: `/tmp/storage`)
|
||||
- `VLLM_OMNI_STORAGE_MAX_CONCURRENCY`: max concurrent save/delete operations (default: `4`)
|
||||
- `VLLM_OMNI_SERVER_STORAGE__PATH`: directory used for generated files
|
||||
(default: `/tmp/storage`)
|
||||
- `VLLM_OMNI_SERVER_STORAGE__FILE_CONCURRENCY`: max concurrent save/delete/open
|
||||
operations (default: `4`)
|
||||
- `VLLM_OMNI_SERVER_STORAGE__FILE_TTL`: optional TTL for generated files in
|
||||
seconds
|
||||
- `VLLM_OMNI_SERVER_STORAGE__TTL_SWEEP_INTERVAL`: optional sweep interval in
|
||||
seconds for enforcing file TTL; defaults to `300` when TTL is set
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
export VLLM_OMNI_STORAGE_PATH=/var/tmp/vllm-omni-videos
|
||||
export VLLM_OMNI_STORAGE_MAX_CONCURRENCY=8
|
||||
export VLLM_OMNI_SERVER_STORAGE__PATH=/var/tmp/vllm-omni-videos
|
||||
export VLLM_OMNI_SERVER_STORAGE__FILE_CONCURRENCY=8
|
||||
export VLLM_OMNI_SERVER_STORAGE__FILE_TTL=86400
|
||||
export VLLM_OMNI_SERVER_STORAGE__TTL_SWEEP_INTERVAL=300
|
||||
```
|
||||
|
||||
See also: [Configuration Options](../../../configuration/README.md)
|
||||
|
||||
## API Calls
|
||||
|
||||
### Method 1: Using curl
|
||||
|
||||
@@ -90,16 +90,26 @@ curl -X POST http://localhost:8091/v1/videos/sync \
|
||||
Generated video files are stored on local disk by the async video API.
|
||||
Local file storage behavior can be controlled via the following environment variables:
|
||||
|
||||
- `VLLM_OMNI_STORAGE_PATH`: directory used for generated files (default: `/tmp/storage`)
|
||||
- `VLLM_OMNI_STORAGE_MAX_CONCURRENCY`: max concurrent save/delete operations (default: `4`)
|
||||
- `VLLM_OMNI_SERVER_STORAGE__PATH`: directory used for generated files
|
||||
(default: `/tmp/storage`)
|
||||
- `VLLM_OMNI_SERVER_STORAGE__FILE_CONCURRENCY`: max concurrent save/delete/open
|
||||
operations (default: `4`)
|
||||
- `VLLM_OMNI_SERVER_STORAGE__FILE_TTL`: optional TTL for generated files in
|
||||
seconds
|
||||
- `VLLM_OMNI_SERVER_STORAGE__TTL_SWEEP_INTERVAL`: optional sweep interval in
|
||||
seconds for enforcing file TTL; defaults to `300` when TTL is set
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
export VLLM_OMNI_STORAGE_PATH=/var/tmp/vllm-omni-videos
|
||||
export VLLM_OMNI_STORAGE_MAX_CONCURRENCY=8
|
||||
export VLLM_OMNI_SERVER_STORAGE__PATH=/var/tmp/vllm-omni-videos
|
||||
export VLLM_OMNI_SERVER_STORAGE__FILE_CONCURRENCY=8
|
||||
export VLLM_OMNI_SERVER_STORAGE__FILE_TTL=86400
|
||||
export VLLM_OMNI_SERVER_STORAGE__TTL_SWEEP_INTERVAL=300
|
||||
```
|
||||
|
||||
See also: [Configuration Options](../../../configuration/README.md)
|
||||
|
||||
## API Calls
|
||||
|
||||
### Method 1: Using curl
|
||||
|
||||
@@ -85,6 +85,7 @@ warmup_quack_fp8([(14040, 2048, 6144), (14040, 2048, 2048)])
|
||||
| FLUX.2-klein | `black-forest-labs/FLUX.2-klein-4B` | Yes | Yes | All layers | None | |
|
||||
| HunyuanImage-3.0 | `tencent/HunyuanImage-3.0`, `tencent/HunyuanImage-3.0-Instruct` | Yes | Yes | All layers; use the Hunyuan stage config for multi-stage runs | None | |
|
||||
| HunyuanVideo-1.5 | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v`, `720p_t2v`, `480p_i2v` | Yes | Yes | All layers | None | |
|
||||
| Cosmos3 | `nvidia/Cosmos3-Nano` | Yes | Not validated | All layers | None | |
|
||||
|
||||
### Multi-Stage Omni/TTS Model (Qwen3-Omni, Qwen3-TTS)
|
||||
|
||||
|
||||
@@ -89,11 +89,54 @@ and
|
||||
| Model | Scope | Status |
|
||||
|-------|-------|--------|
|
||||
| Qwen3-Omni | Thinker language-model stage | ModelOpt FP8 checkpoint path |
|
||||
| Qwen3-Omni | Thinker language-model stage (W4A4 NVFP4) | Validated; see [Qwen3-Omni NVFP4 W4A4](#qwen3-omni-nvfp4-w4a4-thinker) below |
|
||||
| Qwen3-TTS | TTS language-model stage | Not validated |
|
||||
|
||||
Audio encoder, vision encoder, talker, and code2wav stages stay in BF16 unless
|
||||
a model-specific guide documents otherwise.
|
||||
|
||||
#### Qwen3-Omni NVFP4 W4A4 (thinker)
|
||||
|
||||
vLLM-Omni serves ModelOpt NVFP4 W4A4 quantizations of the
|
||||
Qwen3-Omni-30B-A3B-Instruct thinker language model. The thinker text body
|
||||
(attention + MoE experts) is quantized to NVFP4 with FP8 per-tensor input
|
||||
scales; the audio encoder, vision encoder, talker, and code2wav stay in BF16.
|
||||
|
||||
| Variant | HF checkpoint | Hardware |
|
||||
|---------|---------------|----------|
|
||||
| W4A4 NVFP4 (full thinker) | `YihongJin/Qwen3-Omni-30B-A3B-Instruct-NVFP4-W4A4-full-thinker-awqclip` | sm_100+ (Blackwell, FlashInfer FP4 GEMM) |
|
||||
|
||||
Calibration uses ModelOpt `mtq.NVFP4_DEFAULT_CFG` with the `awq_clip` algorithm
|
||||
on 1024 ultrachat samples chat-templated through the Qwen3-Omni tokenizer.
|
||||
Excluded modules: `*audio_tower*`, `*visual*`, `*talker*`, `*code2wav*`,
|
||||
`*lm_head*`, `*mlp.gate*`. See `scripts/nvfp4/calibrate.py` for the reference
|
||||
recipe.
|
||||
|
||||
!!! note "ModelOpt 0.44 NaN regression workaround"
|
||||
ModelOpt 0.44's float32 -> FP8 E4M3 cast of per-block weight scales
|
||||
occasionally emits literal NaN bytes (E4M3 encoding 0x7F / 0xFF) for
|
||||
blocks whose pre-cast scale rounds above the FP8 max of 448 after the
|
||||
global-scale division. A single NaN byte in any `weight_scale`
|
||||
propagates through the FP4 GEMM and collapses the served model output
|
||||
to `!!!!`. vLLM-Omni's `vllm_omni.patch` installs a defensive override
|
||||
of `ModelOptNvFp4LinearMethod.process_weights_after_loading` that
|
||||
clamps these bytes to the FP8 E4M3 max at load time. The override
|
||||
self-extinguishes once vllm-omni's vllm pin moves to a release
|
||||
containing the upstream fix. Set `VLLM_OMNI_SKIP_NVFP4_NAN_CLAMP=1`
|
||||
to disable the override for diagnostics.
|
||||
|
||||
Serving:
|
||||
|
||||
```bash
|
||||
vllm serve YihongJin/Qwen3-Omni-30B-A3B-Instruct-NVFP4-W4A4-full-thinker-awqclip \
|
||||
--omni --port 8000
|
||||
```
|
||||
|
||||
> Do **not** pass `--enforce-eager` for production / benchmarks. CUDA graphs
|
||||
> amortize launch overhead and unlock the FP4 throughput wins; with
|
||||
> `--enforce-eager` set, W4A4 TPOT degrades ~10x relative to the CUDA-graph
|
||||
> configuration.
|
||||
|
||||
### Multi-Stage Diffusion Model
|
||||
|
||||
ModelOpt checkpoints must be routed to the stage whose checkpoint contains the
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
# BAGEL-7B-MoT
|
||||
|
||||
## Setup
|
||||
|
||||
Please refer to the [stage configuration documentation](https://docs.vllm.ai/projects/vllm-omni/en/latest/configuration/stage_configs/) to configure memory allocation appropriately for your hardware setup.
|
||||
|
||||
## Architecture
|
||||
|
||||
BAGEL-7B-MoT is a Mixture-of-Transformers (MoT) model supporting both image generation and understanding. It offers two deployment topologies:
|
||||
|
||||
| Topology | Stages | Description |
|
||||
| :------- | :----- | :---------- |
|
||||
| **Two-stage** (default) | Stage 0 (Thinker, AR) + Stage 1 (DiT, Diffusion) | Thinker handles text/understanding via vLLM AR engine; DiT handles image generation. KV cache is transferred between stages. |
|
||||
| **Single-stage** | Stage 0 (DiT, Diffusion) only | The DiT stage contains a full LLM, ViT, VAE, and tokenizer internally. All modalities are handled within a single diffusion process. |
|
||||
|
||||
Both topologies support all four modalities: `text2img`, `img2img`, `img2text`, `text2text`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd examples/offline_inference/bagel
|
||||
|
||||
# Default two-stage mode (auto-detected)
|
||||
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--modality text2img \
|
||||
--prompts "A cute cat"
|
||||
|
||||
# Single-stage mode
|
||||
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--modality text2img \
|
||||
--prompts "A cute cat" \
|
||||
--deploy-config vllm_omni/deploy/bagel_single_stage.yaml
|
||||
```
|
||||
|
||||
> **Note**: These examples work with the default configuration on an **NVIDIA A100 (80GB)**. For dual-GPU setups, modify the deploy YAML to distribute stages across devices.
|
||||
|
||||
## Modality Control
|
||||
|
||||
Control the mode using the `--modality` argument:
|
||||
|
||||
| Modality | Input | Output | Description |
|
||||
| :------- | :---- | :----- | :---------- |
|
||||
| `text2img` | Text | Image | Generate images from text prompts |
|
||||
| `img2img` | Image + Text | Image | Transform images using text guidance |
|
||||
| `img2text` | Image + Text | Text | Generate text descriptions from images |
|
||||
| `text2text` | Text | Text | Pure text generation (language model mode) |
|
||||
|
||||
### Text to Image (text2img)
|
||||
|
||||
```bash
|
||||
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--modality text2img \
|
||||
--prompts "A cute cat" \
|
||||
--steps 50
|
||||
```
|
||||
|
||||
### Image to Image (img2img)
|
||||
|
||||
```bash
|
||||
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--modality img2img \
|
||||
--image-path /path/to/image.jpg \
|
||||
--prompts "Let the woman wear a blue dress"
|
||||
```
|
||||
|
||||
### Image to Text (img2text)
|
||||
|
||||
```bash
|
||||
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--modality img2text \
|
||||
--image-path /path/to/image.jpg \
|
||||
--prompts "Describe this image in detail"
|
||||
```
|
||||
|
||||
### Text to Text (text2text)
|
||||
|
||||
```bash
|
||||
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--modality text2text \
|
||||
--prompts "What is the capital of France?"
|
||||
|
||||
# Load prompts from a text file (one prompt per line):
|
||||
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--modality text2text \
|
||||
--txt-prompts /path/to/prompts.txt
|
||||
```
|
||||
|
||||
## Think Mode
|
||||
|
||||
Think mode enables the model to generate `<think>...</think>` planning/reasoning tokens before producing the final output. This improves generation quality for complex prompts.
|
||||
|
||||
- **Two-stage**: The Thinker (AR) stage decodes think tokens, then transfers the augmented KV cache to the DiT stage for image generation.
|
||||
- **Single-stage**: The DiT's internal LLM generates think tokens in-place before proceeding to denoise.
|
||||
|
||||
```bash
|
||||
# Think + text2img: plan before generating
|
||||
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--modality text2img \
|
||||
--prompts "A futuristic city with flying cars" \
|
||||
--think \
|
||||
--max-think-tokens 1000
|
||||
|
||||
# Think + img2img: reason about the edit
|
||||
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--modality img2img \
|
||||
--image-path /path/to/image.jpg \
|
||||
--prompts "Make it look like a watercolor painting" \
|
||||
--think
|
||||
|
||||
# Think + img2text: reason before describing
|
||||
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--modality img2text \
|
||||
--image-path /path/to/image.jpg \
|
||||
--prompts "What is happening in this image?" \
|
||||
--think
|
||||
|
||||
# Think + text2text: chain-of-thought reasoning
|
||||
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--modality text2text \
|
||||
--prompts "Solve: 23 * 47" \
|
||||
--think
|
||||
```
|
||||
|
||||
Think mode parameters:
|
||||
|
||||
| Argument | Default | Description |
|
||||
| :------- | :------ | :---------- |
|
||||
| `--think` | `False` | Enable thinking mode |
|
||||
| `--max-think-tokens` | `1000` | Maximum tokens for think generation |
|
||||
| `--do-sample` | `False` | Enable sampling (vs. greedy) for text generation |
|
||||
| `--text-temperature` | `0.3` | Temperature for text generation sampling |
|
||||
|
||||
## Classifier-Free Guidance (CFG)
|
||||
|
||||
CFG controls the trade-off between prompt fidelity and diversity. These parameters apply to image generation modalities (`text2img`, `img2img`).
|
||||
|
||||
```bash
|
||||
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--modality text2img \
|
||||
--prompts "A photorealistic portrait" \
|
||||
--cfg-text-scale 6.0 \
|
||||
--cfg-img-scale 2.0 \
|
||||
--negative-prompt "blurry, low quality, distorted" \
|
||||
--cfg-interval 0.4 1.0 \
|
||||
--cfg-renorm-type global \
|
||||
--cfg-renorm-min 0.0
|
||||
```
|
||||
|
||||
| Argument | Default | Description |
|
||||
| :------- | :------ | :---------- |
|
||||
| `--cfg-text-scale` | `4.0` | Text CFG scale (higher = more prompt-adherent) |
|
||||
| `--cfg-img-scale` | `1.5` | Image CFG scale (for img2img) |
|
||||
| `--negative-prompt` | `None` | Negative prompt for CFG conditioning |
|
||||
| `--cfg-interval` | pipeline default | CFG active interval `[start, end]` as fractions of total timesteps |
|
||||
| `--cfg-renorm-type` | `None` | Renormalization type: `global`, `text_channel`, `channel` |
|
||||
| `--cfg-renorm-min` | `None` | Minimum renormalization value |
|
||||
| `--cfg-parallel-size` | `1` | CFG parallel size: `1` = batched (single GPU), `2` = 2-branch parallel, `3` = full 3-GPU parallel |
|
||||
|
||||
## Deployment Topologies
|
||||
|
||||
### Two-Stage (Default)
|
||||
|
||||
The default topology auto-detected from the model. No extra flags needed.
|
||||
|
||||
```bash
|
||||
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--modality text2img \
|
||||
--prompts "A cute cat"
|
||||
```
|
||||
|
||||
The pipeline is defined in [`bagel.yaml`](../../../vllm_omni/deploy/bagel.yaml). Stage 0 (Thinker) and Stage 1 (DiT) share GPU 0 by default. For dual-GPU setups, customize the deploy YAML and set `devices: "1"` for stage 1.
|
||||
|
||||
### Single-Stage
|
||||
|
||||
Pass the single-stage deploy config via `--deploy-config`:
|
||||
|
||||
```bash
|
||||
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--modality text2img \
|
||||
--prompts "A cute cat" \
|
||||
--deploy-config vllm_omni/deploy/bagel_single_stage.yaml
|
||||
```
|
||||
|
||||
See [`bagel_single_stage.yaml`](../../../vllm_omni/deploy/bagel_single_stage.yaml) for configuration details. The `pipeline: bagel_single_stage` field selects the single-stage topology from the pipeline registry.
|
||||
|
||||
### Tensor Parallelism (TP)
|
||||
|
||||
For larger models or multi-GPU environments, customize the deploy YAML (see [`bagel.yaml`](../../../vllm_omni/deploy/bagel.yaml)) and set per-stage `tensor_parallel_size` and `devices`:
|
||||
|
||||
```yaml
|
||||
# Example: TP=2 on GPUs 0,1 for the Thinker stage
|
||||
stages:
|
||||
- stage_id: 0
|
||||
tensor_parallel_size: 2
|
||||
devices: "0,1"
|
||||
```
|
||||
|
||||
### VAE Patch Parallelism
|
||||
|
||||
[VAE Patch Parallelism](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/parallelism/vae_patch_parallel.html) splits Bagel VAE **decode/encode** tiles across multiple GPUs on the **DiT stage**, reducing **per-GPU peak memory during VAE decode**. Use it when high-resolution `text2img` or `img2img` hits VAE OOM or large decode spikes.
|
||||
|
||||
**Bagel-specific notes:**
|
||||
|
||||
- Implemented in `BagelPipeline` via `DistributedAutoEncoder` (DiT stage only).
|
||||
- **Single-stage** is the simplest path: one DiT process with TP + VAE patch parallel.
|
||||
- **Two-stage**: enable on **stage 1 (DiT)** only; stage 0 (Thinker) keeps encoder-only `VAEEncoder` and does not use VAE patch parallel.
|
||||
- You need a DiT `world_size` ≥ `vae_patch_parallel_size` (typically `tensor_parallel_size=2` on that stage). VAE PP reuses the DiT process group; it is not a standalone second-GPU VAE worker.
|
||||
|
||||
**Single-stage via deploy YAML** (recommended for `end2end.py`):
|
||||
|
||||
```yaml
|
||||
pipeline: bagel_single_stage
|
||||
async_chunk: false
|
||||
|
||||
stages:
|
||||
- stage_id: 0
|
||||
max_num_batched_tokens: 32768
|
||||
max_num_seqs: 1
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
devices: "0,1"
|
||||
vae_use_tiling: true
|
||||
parallel_config:
|
||||
tensor_parallel_size: 2
|
||||
vae_patch_parallel_size: 2
|
||||
default_sampling_params:
|
||||
seed: 52
|
||||
```
|
||||
|
||||
```bash
|
||||
cd examples/offline_inference/bagel
|
||||
|
||||
CUDA_VISIBLE_DEVICES=0,1 python end2end.py \
|
||||
--model /path/to/BAGEL-7B-MoT \
|
||||
--deploy-config /path/to/bagel_single_stage_vae_pp.yaml \
|
||||
--modality text2img \
|
||||
--prompts "A cute cat" \
|
||||
--steps 10 \
|
||||
--output ./out_vae_pp
|
||||
```
|
||||
|
||||
**Single-stage via `Omni` kwargs** (same flags as online serving):
|
||||
|
||||
```python
|
||||
from vllm_omni.entrypoints.omni import Omni
|
||||
|
||||
omni = Omni(
|
||||
model="ByteDance-Seed/BAGEL-7B-MoT",
|
||||
deploy_config="vllm_omni/deploy/bagel_single_stage.yaml",
|
||||
tensor_parallel_size=2,
|
||||
vae_patch_parallel_size=2,
|
||||
vae_use_tiling=True,
|
||||
)
|
||||
# Then call omni.generate(...) as in end2end.py
|
||||
```
|
||||
|
||||
**Two-stage (VAE PP on DiT only):**
|
||||
|
||||
```yaml
|
||||
stages:
|
||||
- stage_id: 0
|
||||
devices: "0"
|
||||
# AR Thinker — no vae_patch_parallel here
|
||||
|
||||
- stage_id: 1
|
||||
devices: "0,1"
|
||||
vae_use_tiling: true
|
||||
parallel_config:
|
||||
tensor_parallel_size: 2
|
||||
vae_patch_parallel_size: 2
|
||||
```
|
||||
|
||||
```bash
|
||||
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--deploy-config /path/to/bagel_vae_pp.yaml \
|
||||
--modality text2img \
|
||||
--prompts "A cute cat"
|
||||
```
|
||||
|
||||
**Startup log checks:**
|
||||
|
||||
```text
|
||||
INFO ... vae_patch_parallel_size=2 requires vae_use_tiling; automatically enabling it.
|
||||
```
|
||||
|
||||
| Setting | Role |
|
||||
| :------ | :--- |
|
||||
| `parallel_config.tensor_parallel_size` | DiT world size / TP (must be ≥ `vae_patch_parallel_size`) |
|
||||
| `parallel_config.vae_patch_parallel_size` | Number of ranks for distributed VAE tiles (`1` = off) |
|
||||
| `vae_use_tiling` | Enable spatial tiling (auto-enabled when `vae_patch_parallel_size > 1`) |
|
||||
|
||||
#### Hybrid Sharded Data Parallel (HSDP)
|
||||
|
||||
For larger Bagel deployments on multiple GPUs, you can enable HSDP (Hybrid Sharded Data Parallel) by modifying the stage configuration (for example, [`bagel.yaml`](../../../vllm_omni/deploy/bagel.yaml)). HSDP shards transformer weights across GPUs to reduce per-GPU memory usage.
|
||||
|
||||
1. **Enable HSDP**: Set `use_hsdp: true`.
|
||||
2. **Set shard size**: Set `hsdp_shard_size` to the number of GPUs used for sharding (for example, `4`).
|
||||
3. **Set replicate size**: Usually keep `hsdp_replicate_size: 1` unless you want replicated HSDP groups.
|
||||
4. **Set devices**: Specify the comma-separated GPU IDs used by the diffusion stage (for example, `"0,1,2,3"`).
|
||||
|
||||
Example configuration for HSDP across 4 GPUs:
|
||||
```yaml
|
||||
- stage_id: 1
|
||||
devices: "0,1,2,3"
|
||||
parallel_config:
|
||||
use_hsdp: true
|
||||
hsdp_shard_size: 4
|
||||
hsdp_replicate_size: 1
|
||||
```
|
||||
|
||||
Then pass the custom deploy YAML:
|
||||
|
||||
```bash
|
||||
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--modality text2img \
|
||||
--prompts "A cute cat" \
|
||||
--deploy-config /path/to/custom_bagel.yaml
|
||||
```
|
||||
|
||||
### FP8 Quantization
|
||||
|
||||
```bash
|
||||
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--modality text2img \
|
||||
--prompts "A cute cat" \
|
||||
--quantization fp8
|
||||
```
|
||||
|
||||
## Command Line Reference
|
||||
|
||||
### Core Arguments
|
||||
|
||||
| Argument | Type | Default | Description |
|
||||
| :------- | :--- | :------ | :---------- |
|
||||
| `--model` | string | `ByteDance-Seed/BAGEL-7B-MoT` | Model path or HuggingFace name |
|
||||
| `--modality` | choice | `text2img` | `text2img`, `img2img`, `img2text`, `text2text` |
|
||||
| `--prompts` | list | `None` | Input text prompts |
|
||||
| `--txt-prompts` | string | `None` | Path to text file with one prompt per line |
|
||||
| `--image-path` | string | `None` | Input image path (required for `img2img`/`img2text`) |
|
||||
| `--output` | string | `.` | Output directory for saved images |
|
||||
| `--steps` | int | `50` | Number of diffusion inference steps |
|
||||
| `--seed` | int | `None` | Random seed for reproducibility |
|
||||
|
||||
### Think Mode Arguments
|
||||
|
||||
| Argument | Type | Default | Description |
|
||||
| :------- | :--- | :------ | :---------- |
|
||||
| `--think` | flag | `False` | Enable `<think>...</think>` planning/reasoning |
|
||||
| `--max-think-tokens` | int | `1000` | Maximum tokens for think generation |
|
||||
| `--do-sample` | flag | `False` | Use sampling instead of greedy decoding |
|
||||
| `--text-temperature` | float | `0.3` | Sampling temperature for text generation |
|
||||
|
||||
### CFG Arguments
|
||||
|
||||
| Argument | Type | Default | Description |
|
||||
| :------- | :--- | :------ | :---------- |
|
||||
| `--cfg-text-scale` | float | `4.0` | Text CFG guidance scale |
|
||||
| `--cfg-img-scale` | float | `1.5` | Image CFG guidance scale |
|
||||
| `--negative-prompt` | string | `None` | Negative prompt for CFG |
|
||||
| `--cfg-parallel-size` | int | `1` | CFG parallel GPU count (1, 2, or 3) |
|
||||
| `--cfg-interval` | float[2] | pipeline default | CFG active window `[start, end]` |
|
||||
| `--cfg-renorm-type` | string | `None` | `global`, `text_channel`, or `channel` |
|
||||
| `--cfg-renorm-min` | float | `None` | Minimum renormalization value |
|
||||
|
||||
### Engine Arguments
|
||||
|
||||
| Argument | Type | Default | Description |
|
||||
| :------- | :--- | :------ | :---------- |
|
||||
| `--deploy-config` | string | `None` | Path to deploy YAML (auto-detected if omitted) |
|
||||
| `--worker-backend` | choice | `process` | `process` or `ray` |
|
||||
| `--ray-address` | string | `None` | Ray cluster address |
|
||||
| `--quantization` | string | `None` | Quantization method (e.g. `fp8`) |
|
||||
| `--log-stats` | flag | `False` | Enable statistics logging |
|
||||
| `--init-timeout` | int | `300` | Initialization timeout (seconds) |
|
||||
| `--batch-timeout` | int | `5` | Batch timeout (seconds) |
|
||||
| `--enable-diffusion-pipeline-profiler` | flag | `False` | Profile diffusion stage durations |
|
||||
|
||||
## FAQ
|
||||
|
||||
- If you encounter OOM errors, try decreasing `max_model_len` or `gpu_memory_utilization` in the deploy YAML.
|
||||
|
||||
**Two-stage VRAM usage:**
|
||||
|
||||
| Stage | VRAM |
|
||||
| :---- | :--- |
|
||||
| Stage 0 (Thinker) | **15.04 GiB + KV Cache** |
|
||||
| Stage 1 (DiT) | **26.50 GiB** |
|
||||
| Total | **~42 GiB + KV Cache** |
|
||||
|
||||
**Single-stage VRAM usage:** The DiT loads the full model (~42 GiB) in one process.
|
||||
@@ -1,278 +0,0 @@
|
||||
import argparse
|
||||
import os
|
||||
|
||||
from vllm_omni.inputs.data import OmniPromptType
|
||||
from vllm_omni.model_executor.stage_input_processors.bagel import (
|
||||
GEN_THINK_SYSTEM_PROMPT,
|
||||
VLM_THINK_SYSTEM_PROMPT,
|
||||
)
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
default="ByteDance-Seed/BAGEL-7B-MoT",
|
||||
help="Path to merged model directory.",
|
||||
)
|
||||
parser.add_argument("--prompts", nargs="+", default=None, help="Input text prompts.")
|
||||
parser.add_argument(
|
||||
"--txt-prompts",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to a .txt file with one prompt per line (preferred).",
|
||||
)
|
||||
parser.add_argument("--prompt-type", default="text", choices=["text"])
|
||||
|
||||
parser.add_argument(
|
||||
"--modality",
|
||||
default="text2img",
|
||||
choices=["text2img", "img2img", "img2text", "text2text"],
|
||||
help="Modality mode to control stage execution.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--image-path",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to input image for img2img.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=str,
|
||||
default=".",
|
||||
help="Output directory to save images.",
|
||||
)
|
||||
|
||||
# OmniLLM init args
|
||||
parser.add_argument("--log-stats", action="store_true", default=False)
|
||||
parser.add_argument("--init-sleep-seconds", type=int, default=20)
|
||||
parser.add_argument("--batch-timeout", type=int, default=5)
|
||||
parser.add_argument("--init-timeout", type=int, default=300)
|
||||
parser.add_argument("--shm-threshold-bytes", type=int, default=65536)
|
||||
parser.add_argument("--worker-backend", type=str, default="process", choices=["process", "ray"])
|
||||
parser.add_argument("--ray-address", type=str, default=None)
|
||||
parser.add_argument(
|
||||
"--deploy-config",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to deploy YAML. If unset, auto-loads vllm_omni/deploy/bagel.yaml based on the HF model_type.",
|
||||
)
|
||||
parser.add_argument("--steps", type=int, default=50, help="Number of inference steps.")
|
||||
|
||||
parser.add_argument("--cfg-text-scale", type=float, default=4.0, help="Text CFG scale (default: 4.0)")
|
||||
parser.add_argument("--cfg-img-scale", type=float, default=1.5, help="Image CFG scale (default: 1.5)")
|
||||
parser.add_argument(
|
||||
"--negative-prompt", type=str, default=None, help="Negative prompt for CFG (default: empty prompt)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cfg-parallel-size",
|
||||
type=int,
|
||||
default=1,
|
||||
choices=[1, 2, 3],
|
||||
help="CFG parallel size: 1=batched (single GPU), 2=parallel with 2 branches (text CFG only), 3=parallel (3 GPUs).",
|
||||
)
|
||||
parser.add_argument("--seed", type=int, default=None, help="Random seed for generation.")
|
||||
parser.add_argument(
|
||||
"--cfg-interval",
|
||||
type=float,
|
||||
nargs=2,
|
||||
default=None,
|
||||
help="CFG interval [start, end] (default: pipeline default)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cfg-renorm-type", type=str, default=None, help="CFG renorm type: global, text_channel, channel"
|
||||
)
|
||||
parser.add_argument("--cfg-renorm-min", type=float, default=None, help="CFG renorm min")
|
||||
parser.add_argument(
|
||||
"--enable-diffusion-pipeline-profiler",
|
||||
action="store_true",
|
||||
help="Enable diffusion pipeline profiler to display stage durations.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--quantization",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Quantization method (e.g. 'fp8').",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--think",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Enable thinking mode: AR stage decodes <think>...</think> planning tokens before image generation.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-think-tokens",
|
||||
type=int,
|
||||
default=1000,
|
||||
help="Maximum number of tokens for thinking text generation (default: 1000).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--do-sample",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Enable sampling for text generation (default: greedy).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--text-temperature",
|
||||
type=float,
|
||||
default=0.3,
|
||||
help="Temperature for text generation sampling (default: 0.3).",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
return args
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
os.makedirs(args.output, exist_ok=True)
|
||||
model_name = args.model
|
||||
prompts: list[OmniPromptType] = []
|
||||
try:
|
||||
if getattr(args, "txt_prompts", None) and args.prompt_type == "text":
|
||||
with open(args.txt_prompts, encoding="utf-8") as f:
|
||||
lines = [ln.strip() for ln in f.readlines()]
|
||||
prompts = [ln for ln in lines if ln != ""]
|
||||
print(f"[Info] Loaded {len(prompts)} prompts from {args.txt_prompts}")
|
||||
else:
|
||||
prompts = args.prompts
|
||||
except Exception as e:
|
||||
print(f"[Error] Failed to load prompts: {e}")
|
||||
raise
|
||||
|
||||
if not prompts:
|
||||
prompts = ["A cute cat"]
|
||||
print(f"[Info] No prompts provided, using default: {prompts}")
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from vllm_omni.entrypoints.omni import Omni
|
||||
|
||||
omni_kwargs = vars(args).copy()
|
||||
omni_kwargs.pop("model", None)
|
||||
deploy_config = args.deploy_config
|
||||
if args.think and deploy_config is None:
|
||||
deploy_config = "vllm_omni/deploy/bagel_think.yaml"
|
||||
print(f"[Info] Think mode enabled, using deploy config: {deploy_config}")
|
||||
if deploy_config:
|
||||
omni_kwargs["deploy_config"] = deploy_config
|
||||
|
||||
if args.quantization:
|
||||
omni_kwargs["quantization_config"] = args.quantization
|
||||
|
||||
omni = Omni(model=model_name, **omni_kwargs)
|
||||
|
||||
formatted_prompts = []
|
||||
for p in prompts:
|
||||
if args.modality == "img2img":
|
||||
if not args.image_path or not os.path.exists(args.image_path):
|
||||
raise ValueError(f"img2img requires --image-path pointing to an existing file, got: {args.image_path}")
|
||||
loaded_image = Image.open(args.image_path).convert("RGB")
|
||||
think_prefix = f"<|im_start|>{GEN_THINK_SYSTEM_PROMPT}<|im_end|>" if args.think else ""
|
||||
final_prompt_text = f"{think_prefix}<|fim_middle|><|im_start|>{p}<|im_end|>"
|
||||
prompt_dict = {
|
||||
"prompt": final_prompt_text,
|
||||
"multi_modal_data": {"img2img": loaded_image},
|
||||
"modalities": ["img2img"],
|
||||
}
|
||||
if args.negative_prompt is not None:
|
||||
prompt_dict["negative_prompt"] = args.negative_prompt
|
||||
formatted_prompts.append(prompt_dict)
|
||||
elif args.modality == "img2text":
|
||||
if args.image_path:
|
||||
loaded_image = Image.open(args.image_path).convert("RGB")
|
||||
think_prefix = f"<|im_start|>system\n{VLM_THINK_SYSTEM_PROMPT}<|im_end|>\n" if args.think else ""
|
||||
final_prompt_text = (
|
||||
f"{think_prefix}<|im_start|>user\n<|image_pad|>\n{p}<|im_end|>\n<|im_start|>assistant\n"
|
||||
)
|
||||
prompt_dict = {
|
||||
"prompt": final_prompt_text,
|
||||
"multi_modal_data": {"image": loaded_image},
|
||||
"modalities": ["text"],
|
||||
}
|
||||
formatted_prompts.append(prompt_dict)
|
||||
elif args.modality == "text2text":
|
||||
think_prefix = f"<|im_start|>{VLM_THINK_SYSTEM_PROMPT}<|im_end|>" if args.think else ""
|
||||
final_prompt_text = f"{think_prefix}<|im_start|>{p}<|im_end|><|im_start|>"
|
||||
prompt_dict = {"prompt": final_prompt_text, "modalities": ["text"]}
|
||||
formatted_prompts.append(prompt_dict)
|
||||
else:
|
||||
think_prefix = f"<|im_start|>{GEN_THINK_SYSTEM_PROMPT}<|im_end|>" if args.think else ""
|
||||
final_prompt_text = f"{think_prefix}<|im_start|>{p}<|im_end|>"
|
||||
prompt_dict = {"prompt": final_prompt_text, "modalities": ["image"]}
|
||||
if args.negative_prompt is not None:
|
||||
prompt_dict["negative_prompt"] = args.negative_prompt
|
||||
formatted_prompts.append(prompt_dict)
|
||||
|
||||
params_list = omni.default_sampling_params_list
|
||||
# Bagel exposes 1 sampling param set for single-stage (DiT-only) and
|
||||
# 2 for two-stage (Thinker + DiT). This heuristic may need updating
|
||||
# if future pipelines break that 1:1 mapping.
|
||||
is_single_stage = len(params_list) == 1
|
||||
|
||||
diffusion_params_idx = 0 if is_single_stage else (1 if len(params_list) > 1 else 0)
|
||||
diffusion_params = params_list[diffusion_params_idx]
|
||||
|
||||
if args.modality in ("text2img", "img2img"):
|
||||
diffusion_params.num_inference_steps = args.steps # type: ignore
|
||||
diffusion_params.cfg_parallel_size = args.cfg_parallel_size # type: ignore
|
||||
if args.seed is not None:
|
||||
diffusion_params.seed = args.seed # type: ignore
|
||||
|
||||
extra = getattr(diffusion_params, "extra_args", {}) or {}
|
||||
extra["cfg_text_scale"] = args.cfg_text_scale
|
||||
extra["cfg_img_scale"] = args.cfg_img_scale
|
||||
if args.cfg_interval is not None:
|
||||
extra["cfg_interval"] = tuple(args.cfg_interval)
|
||||
if args.cfg_renorm_type is not None:
|
||||
extra["cfg_renorm_type"] = args.cfg_renorm_type
|
||||
if args.cfg_renorm_min is not None:
|
||||
extra["cfg_renorm_min"] = args.cfg_renorm_min
|
||||
if args.negative_prompt is not None:
|
||||
extra["negative_prompt"] = args.negative_prompt
|
||||
|
||||
needs_text_gen = is_single_stage and (args.think or args.modality in ("text2text", "img2text"))
|
||||
if needs_text_gen:
|
||||
if args.think:
|
||||
extra["think"] = True
|
||||
extra["max_think_tokens"] = args.max_think_tokens
|
||||
extra["do_sample"] = args.do_sample
|
||||
extra["text_temperature"] = args.text_temperature
|
||||
diffusion_params.extra_args = extra # type: ignore
|
||||
|
||||
omni_outputs = list(omni.generate(prompts=formatted_prompts, sampling_params_list=params_list))
|
||||
|
||||
img_idx = 0
|
||||
for req_output in omni_outputs:
|
||||
# 2-stage think mode: text output from thinker stage
|
||||
ro = getattr(req_output, "request_output", None)
|
||||
if ro and getattr(ro, "outputs", None):
|
||||
txt = "".join(getattr(o, "text", "") or "" for o in ro.outputs)
|
||||
if txt:
|
||||
if args.think:
|
||||
print(f"[Think]\n{txt}")
|
||||
else:
|
||||
print(f"[Output] Text:\n{txt}")
|
||||
|
||||
# Single-stage DiT: text from custom_output
|
||||
custom = getattr(req_output, "_custom_output", {}) or {}
|
||||
if custom.get("think_text"):
|
||||
print(f"[Think]\n{custom['think_text']}")
|
||||
if custom.get("text_output"):
|
||||
print(f"[Output] Text:\n{custom['text_output']}")
|
||||
|
||||
images = getattr(req_output, "images", None)
|
||||
if not images:
|
||||
continue
|
||||
|
||||
for j, img in enumerate(images):
|
||||
save_path = os.path.join(args.output, f"output_{img_idx}_{j}.png")
|
||||
img.save(save_path)
|
||||
print(f"[Output] Saved image to {save_path}")
|
||||
img_idx += 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,129 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""DreamZero AR client frame schedule aligned with upstream ``test_client_AR.py``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
# Frame schedule constants (matching upstream debug_inference.py / test_client_AR.py).
|
||||
ACTION_HORIZON = 24
|
||||
DEFAULT_ACTION_DIM = 8
|
||||
RELATIVE_OFFSETS = [-23, -16, -8, 0]
|
||||
DEFAULT_NUM_AR_CHUNKS = 15
|
||||
|
||||
CAMERA_FILES = {
|
||||
"observation/exterior_image_0_left": "exterior_image_1_left.mp4",
|
||||
"observation/exterior_image_1_left": "exterior_image_2_left.mp4",
|
||||
"observation/wrist_image_left": "wrist_image_left.mp4",
|
||||
}
|
||||
|
||||
|
||||
def build_frame_schedule(
|
||||
total_frames: int,
|
||||
num_chunks: int,
|
||||
*,
|
||||
logger: logging.Logger | None = None,
|
||||
) -> list[list[int]]:
|
||||
"""Build the frame index schedule for multi-frame chunks.
|
||||
|
||||
Args:
|
||||
total_frames: Number of frames available in each camera video.
|
||||
num_chunks: Number of 4-frame chunks to schedule after the initial frame.
|
||||
|
||||
Returns:
|
||||
A list of frame-index lists. Each inner list has four indices. The
|
||||
returned list may be shorter than ``num_chunks`` when the videos run
|
||||
out of frames (upstream stops early instead of erroring).
|
||||
"""
|
||||
chunks: list[list[int]] = []
|
||||
current_frame = 23
|
||||
for _ in range(num_chunks):
|
||||
indices = [max(current_frame + offset, 0) for offset in RELATIVE_OFFSETS]
|
||||
if indices[-1] >= total_frames:
|
||||
if logger is not None:
|
||||
logger.info(
|
||||
"Frame %s >= %s, stopping at %s chunks",
|
||||
indices[-1],
|
||||
total_frames,
|
||||
len(chunks),
|
||||
)
|
||||
break
|
||||
chunks.append(indices)
|
||||
current_frame += ACTION_HORIZON
|
||||
return chunks
|
||||
|
||||
|
||||
def make_obs_from_video(
|
||||
camera_frames: dict[str, np.ndarray],
|
||||
frame_indices: list[int],
|
||||
*,
|
||||
prompt: str,
|
||||
session_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Build an observation dict from real video frames.
|
||||
|
||||
For one frame each image key is ``(H, W, 3)``. For four frames each key is
|
||||
``(4, H, W, 3)``.
|
||||
"""
|
||||
obs: dict[str, Any] = {}
|
||||
for camera_key, all_frames in camera_frames.items():
|
||||
selected = all_frames[frame_indices]
|
||||
obs[camera_key] = selected[0] if len(frame_indices) == 1 else selected
|
||||
|
||||
obs["observation/joint_position"] = np.zeros(7, dtype=np.float32)
|
||||
obs["observation/cartesian_position"] = np.zeros(6, dtype=np.float32)
|
||||
obs["observation/gripper_position"] = np.zeros(1, dtype=np.float32)
|
||||
obs["prompt"] = prompt
|
||||
obs["session_id"] = session_id
|
||||
return obs
|
||||
|
||||
|
||||
def build_ar_observations(
|
||||
camera_frames: dict[str, np.ndarray],
|
||||
*,
|
||||
prompt: str,
|
||||
session_id: str,
|
||||
num_chunks: int = DEFAULT_NUM_AR_CHUNKS,
|
||||
repeat_chunk_observations: bool = False,
|
||||
logger: logging.Logger | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build the AR observation sequence used by upstream ``test_client_AR.py``.
|
||||
|
||||
Step 0 sends frame ``[0]``. Each subsequent step sends one 4-frame chunk.
|
||||
|
||||
``num_chunks`` counts only the 4-frame chunks after the initial frame, not
|
||||
the total number of inferences.
|
||||
"""
|
||||
if num_chunks < 0:
|
||||
raise ValueError("num_chunks must be non-negative")
|
||||
|
||||
total_frames = min(frames.shape[0] for frames in camera_frames.values())
|
||||
observations = [
|
||||
make_obs_from_video(
|
||||
camera_frames,
|
||||
[0],
|
||||
prompt=prompt,
|
||||
session_id=session_id,
|
||||
)
|
||||
]
|
||||
|
||||
chunk_schedule = build_frame_schedule(total_frames, num_chunks, logger=logger)
|
||||
if repeat_chunk_observations and chunk_schedule and len(chunk_schedule) < num_chunks:
|
||||
while len(chunk_schedule) < num_chunks:
|
||||
chunk_schedule.append(chunk_schedule[-1])
|
||||
|
||||
for frame_indices in chunk_schedule:
|
||||
observations.append(
|
||||
make_obs_from_video(
|
||||
camera_frames,
|
||||
frame_indices,
|
||||
prompt=prompt,
|
||||
session_id=session_id,
|
||||
)
|
||||
)
|
||||
return observations
|
||||
@@ -11,6 +11,11 @@ from pathlib import Path
|
||||
import cv2
|
||||
import numpy as np
|
||||
import torch
|
||||
from client_schedule import (
|
||||
CAMERA_FILES,
|
||||
DEFAULT_NUM_AR_CHUNKS,
|
||||
build_ar_observations,
|
||||
)
|
||||
from PIL import Image
|
||||
|
||||
from vllm_omni import Omni
|
||||
@@ -20,13 +25,6 @@ from vllm_omni.outputs import OmniRequestOutput
|
||||
WORKER_EXTENSION = "vllm_omni.diffusion.models.dreamzero.video_export_worker.DreamZeroVideoExportWorkerExtension"
|
||||
ASSET_REPO_ID = "YangshenDeng/vllm-omni-dreamzero-assets"
|
||||
DEFAULT_SESSION_PREFIX = "dreamzero-export"
|
||||
RELATIVE_OFFSETS = [-23, -16, -8, 0]
|
||||
ACTION_HORIZON = 24
|
||||
CAMERA_FILES = {
|
||||
"observation/exterior_image_0_left": "exterior_image_1_left.mp4",
|
||||
"observation/exterior_image_1_left": "exterior_image_2_left.mp4",
|
||||
"observation/wrist_image_left": "wrist_image_left.mp4",
|
||||
}
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
@@ -49,6 +47,17 @@ def _parse_args() -> argparse.Namespace:
|
||||
default="Move the pan forward and use the brush in the middle of the plates to brush the inside of the pan",
|
||||
)
|
||||
parser.add_argument("--session-id", default=None)
|
||||
parser.add_argument(
|
||||
"--num-chunks",
|
||||
type=int,
|
||||
default=DEFAULT_NUM_AR_CHUNKS,
|
||||
help="Number of 4-frame chunks to send after the initial frame (default: 15).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repeat-chunk-observations",
|
||||
action="store_true",
|
||||
help="Repeat the last valid chunk observation when bundled assets run out of frames.",
|
||||
)
|
||||
parser.add_argument("--save-input-video", action="store_true")
|
||||
parser.add_argument("--save-gif", action="store_true")
|
||||
parser.add_argument("--save-actions", action="store_true")
|
||||
@@ -84,57 +93,25 @@ def _load_camera_frames(video_dir: Path) -> dict[str, np.ndarray]:
|
||||
return camera_frames
|
||||
|
||||
|
||||
def _build_frame_schedule(total_frames: int, num_chunks: int) -> list[list[int]]:
|
||||
chunks: list[list[int]] = []
|
||||
current_frame = 23
|
||||
for _ in range(num_chunks):
|
||||
indices = [max(current_frame + offset, 0) for offset in RELATIVE_OFFSETS]
|
||||
if indices[-1] >= total_frames:
|
||||
break
|
||||
chunks.append(indices)
|
||||
current_frame += ACTION_HORIZON
|
||||
return chunks
|
||||
|
||||
|
||||
def _make_obs_from_video(
|
||||
camera_frames: dict[str, np.ndarray],
|
||||
frame_indices: list[int],
|
||||
*,
|
||||
def _build_observations(
|
||||
video_dir: Path,
|
||||
prompt: str,
|
||||
session_id: str,
|
||||
) -> dict:
|
||||
obs: dict = {}
|
||||
for camera_key, all_frames in camera_frames.items():
|
||||
selected = all_frames[frame_indices]
|
||||
obs[camera_key] = selected[0] if len(frame_indices) == 1 else selected
|
||||
|
||||
obs["observation/joint_position"] = np.zeros(7, dtype=np.float32)
|
||||
obs["observation/cartesian_position"] = np.zeros(6, dtype=np.float32)
|
||||
obs["observation/gripper_position"] = np.zeros(1, dtype=np.float32)
|
||||
obs["prompt"] = prompt
|
||||
obs["session_id"] = session_id
|
||||
return obs
|
||||
|
||||
|
||||
def _build_observations(video_dir: Path, prompt: str, session_id: str) -> tuple[dict[str, np.ndarray], list[dict]]:
|
||||
*,
|
||||
num_chunks: int,
|
||||
repeat_chunk_observations: bool,
|
||||
) -> tuple[dict[str, np.ndarray], list[dict]]:
|
||||
camera_frames = _load_camera_frames(video_dir)
|
||||
total_frames = min(frames.shape[0] for frames in camera_frames.values())
|
||||
chunks = _build_frame_schedule(total_frames, 1)
|
||||
observations = [
|
||||
_make_obs_from_video(camera_frames, [0], prompt=prompt, session_id=session_id),
|
||||
]
|
||||
if chunks:
|
||||
observations.append(
|
||||
_make_obs_from_video(
|
||||
camera_frames,
|
||||
chunks[0],
|
||||
prompt=prompt,
|
||||
session_id=session_id,
|
||||
)
|
||||
)
|
||||
observations = build_ar_observations(
|
||||
camera_frames,
|
||||
prompt=prompt,
|
||||
session_id=session_id,
|
||||
num_chunks=num_chunks,
|
||||
repeat_chunk_observations=repeat_chunk_observations,
|
||||
)
|
||||
if len(observations) < 2:
|
||||
raise RuntimeError("Need at least two DreamZero example observations to export a prediction video.")
|
||||
return camera_frames, observations[:2]
|
||||
raise RuntimeError("Need at least two DreamZero observations to export a prediction video.")
|
||||
return camera_frames, observations
|
||||
|
||||
|
||||
def _extract_latents(output: OmniRequestOutput) -> torch.Tensor:
|
||||
@@ -229,6 +206,26 @@ def _run_generation(
|
||||
return omni, outputs
|
||||
|
||||
|
||||
def _decode_accumulated_with_worker(omni: Omni, session_id: str) -> np.ndarray:
|
||||
"""Decode server-side accumulated latents when the pipeline supports it."""
|
||||
stage_client = omni.engine.stage_clients[0]
|
||||
engine = getattr(stage_client, "_engine", None)
|
||||
if engine is None:
|
||||
raise RuntimeError("DreamZero export requires inline diffusion stage access.")
|
||||
|
||||
decoded = engine.executor.collective_rpc(
|
||||
"decode_accumulated_video_latents_to_uint8",
|
||||
args=(session_id,),
|
||||
unique_reply_rank=0,
|
||||
exec_all_ranks=True,
|
||||
)
|
||||
if isinstance(decoded, torch.Tensor):
|
||||
decoded = decoded.numpy()
|
||||
if not isinstance(decoded, np.ndarray):
|
||||
raise TypeError(f"Unexpected decoded output type: {type(decoded)!r}")
|
||||
return decoded
|
||||
|
||||
|
||||
def _decode_with_worker(omni: Omni, full_latents: torch.Tensor) -> np.ndarray:
|
||||
stage_client = omni.engine.stage_clients[0]
|
||||
engine = getattr(stage_client, "_engine", None)
|
||||
@@ -248,6 +245,15 @@ def _decode_with_worker(omni: Omni, full_latents: torch.Tensor) -> np.ndarray:
|
||||
return decoded
|
||||
|
||||
|
||||
def _decode_video(omni: Omni, session_id: str, outputs: list[OmniRequestOutput]) -> np.ndarray:
|
||||
"""Decode full AR rollout by concatenating per-step latents from all outputs."""
|
||||
latent_steps = [_extract_latents(output) for output in outputs]
|
||||
latent_t = [step.shape[2] for step in latent_steps]
|
||||
print(f"Decoding {len(latent_steps)} latent steps, latent_t={latent_t}, total_t={sum(latent_t)}")
|
||||
full_latents = torch.cat(latent_steps, dim=2)
|
||||
return _decode_with_worker(omni, full_latents)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = _parse_args()
|
||||
session_id = args.session_id or f"{DEFAULT_SESSION_PREFIX}-{uuid.uuid4()}"
|
||||
@@ -256,6 +262,12 @@ def main() -> None:
|
||||
video_dir=args.video_dir,
|
||||
prompt=args.prompt,
|
||||
session_id=session_id,
|
||||
num_chunks=args.num_chunks,
|
||||
repeat_chunk_observations=args.repeat_chunk_observations,
|
||||
)
|
||||
print(
|
||||
f"Running {len(observations)} inferences "
|
||||
f"(1 initial + {len(observations) - 1} chunks; requested {args.num_chunks} post-initial chunks)"
|
||||
)
|
||||
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -273,17 +285,19 @@ def main() -> None:
|
||||
deploy_config_path=args.deploy_config,
|
||||
observations=observations,
|
||||
)
|
||||
latent_steps = [_extract_latents(output) for output in outputs]
|
||||
full_latents = torch.cat(latent_steps, dim=2)
|
||||
frames = _decode_with_worker(omni, full_latents)
|
||||
frames = _decode_video(omni, session_id, outputs)
|
||||
finally:
|
||||
if omni is not None:
|
||||
omni.close()
|
||||
|
||||
expected_frames = 8 * len(observations) + 1
|
||||
mp4_path = args.output_dir / f"{args.output_stem}.mp4"
|
||||
|
||||
_write_mp4(mp4_path, frames, fps=args.fps)
|
||||
print(f"SAVED_MP4={mp4_path}")
|
||||
print(
|
||||
f"SAVED_MP4={mp4_path} frames={len(frames)} "
|
||||
f"expected≈{expected_frames} duration≈{len(frames) / max(args.fps, 1):.1f}s"
|
||||
)
|
||||
|
||||
if args.save_gif:
|
||||
gif_path = args.output_dir / f"{args.output_stem}.gif"
|
||||
@@ -294,8 +308,10 @@ def main() -> None:
|
||||
npz_path = args.output_dir / f"{args.output_stem}_actions.npz"
|
||||
np.savez(
|
||||
npz_path,
|
||||
step0=np.asarray(outputs[0].multimodal_output.get("actions")),
|
||||
step1=np.asarray(outputs[1].multimodal_output.get("actions")),
|
||||
**{
|
||||
f"step{index}": np.asarray(output.multimodal_output.get("actions"))
|
||||
for index, output in enumerate(outputs)
|
||||
},
|
||||
)
|
||||
print(f"SAVED_ACTIONS={npz_path}")
|
||||
|
||||
|
||||
@@ -97,9 +97,16 @@ import torch
|
||||
from PIL import Image
|
||||
|
||||
from vllm_omni.diffusion.data import DiffusionParallelConfig
|
||||
from vllm_omni.diffusion.utils.param_utils import apply_declared_extra_args
|
||||
from vllm_omni.entrypoints.omni import Omni
|
||||
from vllm_omni.entrypoints.openai.stage_params import clone_sampling_params
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
from vllm_omni.model_extras import (
|
||||
build_image_to_image_prompt,
|
||||
get_extra_body_params,
|
||||
get_model_class_name,
|
||||
should_init_extra_args_for_non_diffusion_stages,
|
||||
)
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
|
||||
@@ -187,6 +194,12 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument(
|
||||
"--guidance-scale-2", type=float, default=None, help="image guidance scale for image-to-image generation."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--extra-args",
|
||||
type=parse_profiler_config,
|
||||
default=None,
|
||||
help="JSON object copied to OmniDiffusionSamplingParams.extra_args, e.g. '{\"cfg_text_scale\": 4.0}'.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=str,
|
||||
@@ -468,6 +481,8 @@ def main():
|
||||
enable_diffusion_pipeline_profiler=args.enable_diffusion_pipeline_profiler,
|
||||
profiler_config=args.profiler_config,
|
||||
)
|
||||
model_class_name = get_model_class_name(omni)
|
||||
declared_extra_body_params = get_extra_body_params(model_class_name)
|
||||
print("Pipeline loaded")
|
||||
|
||||
profiler_enabled = args.profiler_config is not None
|
||||
@@ -478,6 +493,8 @@ def main():
|
||||
print(f" Model: {args.model}")
|
||||
print(f" Inference steps: {args.num_inference_steps}")
|
||||
print(f" Cache backend: {args.cache_backend if args.cache_backend else 'None (no acceleration)'}")
|
||||
if args.height is not None or args.width is not None:
|
||||
print(f" Output size: {args.width or 'auto'}x{args.height or 'auto'}")
|
||||
if isinstance(input_image, list):
|
||||
print(f" Number of input images: {len(input_image)}")
|
||||
for idx, img in enumerate(input_image):
|
||||
@@ -487,7 +504,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()
|
||||
@@ -496,10 +512,20 @@ def main():
|
||||
print("[Profiler] Starting profiling...")
|
||||
omni.start_profile()
|
||||
|
||||
# Generate edited image
|
||||
sampling_params = OmniDiffusionSamplingParams(
|
||||
width=args.width,
|
||||
prompt_dict = build_image_to_image_prompt(
|
||||
model_class_name=model_class_name,
|
||||
prompt=args.prompt,
|
||||
negative_prompt=args.negative_prompt,
|
||||
input_image=input_image,
|
||||
height=args.height,
|
||||
width=args.width,
|
||||
)
|
||||
|
||||
extra_args_from_cli = dict(args.extra_args or {})
|
||||
if args.negative_prompt is not None:
|
||||
extra_args_from_cli.setdefault("negative_prompt", args.negative_prompt)
|
||||
|
||||
diffusion_params = OmniDiffusionSamplingParams(
|
||||
generator=generator,
|
||||
true_cfg_scale=args.cfg_scale,
|
||||
guidance_scale=args.guidance_scale,
|
||||
@@ -507,17 +533,44 @@ def main():
|
||||
num_inference_steps=args.num_inference_steps,
|
||||
num_outputs_per_prompt=args.num_outputs_per_prompt,
|
||||
layers=args.layers,
|
||||
resolution=args.resolution,
|
||||
height=args.height,
|
||||
width=args.width,
|
||||
)
|
||||
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,
|
||||
if declared_extra_body_params:
|
||||
apply_declared_extra_args(
|
||||
diffusion_params,
|
||||
declared_extra_body_params,
|
||||
extra_args_from_cli,
|
||||
)
|
||||
else:
|
||||
diffusion_params.extra_args.update({k: v for k, v in extra_args_from_cli.items() if v is not None})
|
||||
|
||||
# Build per-stage sampling params for multi-stage models
|
||||
init_non_diffusion = should_init_extra_args_for_non_diffusion_stages(
|
||||
model_class_name,
|
||||
)
|
||||
defaults = list(omni.default_sampling_params_list or [])
|
||||
sampling_params_list = [clone_sampling_params(p) for p in defaults]
|
||||
if not sampling_params_list:
|
||||
sampling_params_list = [diffusion_params]
|
||||
|
||||
diffusion_replaced = False
|
||||
for idx, params in enumerate(sampling_params_list):
|
||||
if isinstance(params, OmniDiffusionSamplingParams):
|
||||
merged_extra = dict(getattr(params, "extra_args", {}) or {})
|
||||
merged_extra.update(diffusion_params.extra_args)
|
||||
diffusion_params.extra_args = merged_extra
|
||||
sampling_params_list[idx] = diffusion_params
|
||||
diffusion_replaced = True
|
||||
elif init_non_diffusion and hasattr(params, "extra_args"):
|
||||
if params.extra_args is None:
|
||||
params.extra_args = {}
|
||||
|
||||
if not diffusion_replaced and len(sampling_params_list) == 1:
|
||||
sampling_params_list = [diffusion_params]
|
||||
|
||||
outputs = omni.generate(prompt_dict, sampling_params_list=sampling_params_list)
|
||||
generation_end = time.perf_counter()
|
||||
generation_time = generation_end - generation_start
|
||||
|
||||
@@ -544,17 +597,16 @@ def main():
|
||||
if not outputs:
|
||||
raise ValueError("No output generated from omni.generate()")
|
||||
|
||||
# Extract images from OmniRequestOutput
|
||||
# omni.generate() returns list[OmniRequestOutput], extract images from request_output.images
|
||||
first_output = outputs[0]
|
||||
if not hasattr(first_output, "request_output") or not first_output.request_output:
|
||||
raise ValueError("No request_output found in OmniRequestOutput")
|
||||
images = None
|
||||
for output in outputs:
|
||||
images = getattr(output, "images", None)
|
||||
if images:
|
||||
break
|
||||
req_out = getattr(output, "request_output", None)
|
||||
images = getattr(req_out, "images", None) if req_out is not None else None
|
||||
if images:
|
||||
break
|
||||
|
||||
req_out = first_output.request_output
|
||||
if not isinstance(req_out, OmniRequestOutput) or not hasattr(req_out, "images"):
|
||||
raise ValueError("Invalid request_output structure or missing 'images' key")
|
||||
|
||||
images = req_out.images
|
||||
if not images:
|
||||
raise ValueError("No images found in request_output")
|
||||
|
||||
|
||||
@@ -257,8 +257,11 @@ async def run_single_request(
|
||||
new_chunks = audio_data[audio_list_consumed:]
|
||||
audio_list_consumed = len(audio_data)
|
||||
elif isinstance(audio_data, torch.Tensor):
|
||||
new_chunks = [audio_data]
|
||||
audio_last_tensor = audio_data
|
||||
if audio_data is not audio_last_tensor:
|
||||
new_chunks = [audio_data]
|
||||
audio_last_tensor = audio_data
|
||||
else:
|
||||
new_chunks = []
|
||||
else:
|
||||
new_chunks = []
|
||||
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Qwen3-Omni end2end functional test script
|
||||
# Uses default built-in assets, iterates over query-type x output-modalities combinations
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
export VLLM_OMNI_USE_V2_RUNNER=1
|
||||
|
||||
MODEL_PATH="/models/Qwen3-Omni-30B-A3B-Instruct"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
END2END="$SCRIPT_DIR/end2end.py"
|
||||
LOG_DIR="$SCRIPT_DIR/test_logs"
|
||||
SUMMARY_FILE="$LOG_DIR/summary.log"
|
||||
|
||||
rm -rf "$LOG_DIR"
|
||||
mkdir -p "$LOG_DIR"
|
||||
> "$SUMMARY_FILE"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
TOTAL=0
|
||||
|
||||
run_test() {
|
||||
local name="$1"
|
||||
shift
|
||||
local log_file="$LOG_DIR/${name}.log"
|
||||
local output_dir="$LOG_DIR/${name}_output"
|
||||
|
||||
TOTAL=$((TOTAL + 1))
|
||||
echo "========================================"
|
||||
echo "[${TOTAL}] Running: ${name}"
|
||||
echo " Command: python $END2END $*"
|
||||
echo "========================================"
|
||||
|
||||
local start_time
|
||||
start_time=$(date +%s)
|
||||
|
||||
if python "$END2END" "$@" --output-dir "$output_dir" > "$log_file" 2>&1; then
|
||||
local end_time
|
||||
end_time=$(date +%s)
|
||||
local elapsed=$((end_time - start_time))
|
||||
echo " => PASS (${elapsed}s)"
|
||||
echo "[PASS] ${name} (${elapsed}s)" >> "$SUMMARY_FILE"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
local exit_code=$?
|
||||
local end_time
|
||||
end_time=$(date +%s)
|
||||
local elapsed=$((end_time - start_time))
|
||||
echo " => FAIL (exit code: ${exit_code}, ${elapsed}s)"
|
||||
echo "[FAIL] ${name} (exit code: ${exit_code}, ${elapsed}s)" >> "$SUMMARY_FILE"
|
||||
# Append last 30 lines of log to summary
|
||||
echo " --- last 30 lines of log ---" >> "$SUMMARY_FILE"
|
||||
tail -n 30 "$log_file" >> "$SUMMARY_FILE"
|
||||
echo " --- end ---" >> "$SUMMARY_FILE"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Test cases
|
||||
# =============================================================================
|
||||
|
||||
# --- 1. Text-only input ---
|
||||
run_test "text_to_text" \
|
||||
--model "$MODEL_PATH" --query-type text --modalities text
|
||||
|
||||
run_test "text_to_text_audio" \
|
||||
--model "$MODEL_PATH" --query-type text --modalities text,audio
|
||||
|
||||
run_test "text_to_audio" \
|
||||
--model "$MODEL_PATH" --query-type text --modalities audio
|
||||
|
||||
# --- 2. Image input ---
|
||||
run_test "image_to_text" \
|
||||
--model "$MODEL_PATH" --query-type use_image --modalities text
|
||||
|
||||
run_test "image_to_text_audio" \
|
||||
--model "$MODEL_PATH" --query-type use_image --modalities text,audio
|
||||
|
||||
# --- 3. Audio input ---
|
||||
run_test "audio_to_text" \
|
||||
--model "$MODEL_PATH" --query-type use_audio --modalities text
|
||||
|
||||
run_test "audio_to_text_audio" \
|
||||
--model "$MODEL_PATH" --query-type use_audio --modalities text,audio
|
||||
|
||||
# --- 4. Video input ---
|
||||
run_test "video_to_text" \
|
||||
--model "$MODEL_PATH" --query-type use_video --modalities text
|
||||
|
||||
run_test "video_to_text_audio" \
|
||||
--model "$MODEL_PATH" --query-type use_video --modalities text,audio
|
||||
|
||||
# --- 5. Multi-audio input ---
|
||||
run_test "multi_audios_to_text" \
|
||||
--model "$MODEL_PATH" --query-type use_multi_audios --modalities text
|
||||
|
||||
run_test "multi_audios_to_text_audio" \
|
||||
--model "$MODEL_PATH" --query-type use_multi_audios --modalities text,audio
|
||||
|
||||
# --- 6. Mixed modality input (audio+image+video) ---
|
||||
run_test "mixed_to_text" \
|
||||
--model "$MODEL_PATH" --query-type use_mixed_modalities --modalities text
|
||||
|
||||
run_test "mixed_to_text_audio" \
|
||||
--model "$MODEL_PATH" --query-type use_mixed_modalities --modalities text,audio
|
||||
|
||||
# --- 7. Audio from video ---
|
||||
run_test "audio_in_video_to_text" \
|
||||
--model "$MODEL_PATH" --query-type use_audio_in_video --modalities text
|
||||
|
||||
run_test "audio_in_video_to_text_audio" \
|
||||
--model "$MODEL_PATH" --query-type use_audio_in_video --modalities text,audio
|
||||
|
||||
# --- 8. py-generator mode ---
|
||||
run_test "text_to_text_generator" \
|
||||
--model "$MODEL_PATH" --query-type text --modalities text --py-generator
|
||||
|
||||
run_test "audio_to_text_audio_generator" \
|
||||
--model "$MODEL_PATH" --query-type use_audio --modalities text,audio --py-generator
|
||||
|
||||
# --- 9. Multi-prompt batch inference ---
|
||||
run_test "text_to_text_batch3" \
|
||||
--model "$MODEL_PATH" --query-type text --modalities text --num-prompts 3
|
||||
|
||||
# =============================================================================
|
||||
# Summary
|
||||
# =============================================================================
|
||||
echo "========================================" | tee -a "$SUMMARY_FILE"
|
||||
echo "Tests done: total ${TOTAL}, passed ${PASS}, failed ${FAIL}" | tee -a "$SUMMARY_FILE"
|
||||
echo "========================================" | tee -a "$SUMMARY_FILE"
|
||||
echo ""
|
||||
echo "Detailed logs: $LOG_DIR"
|
||||
echo "Summary report: $SUMMARY_FILE"
|
||||
|
||||
exit $FAIL
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Qwen3-Omni end2end_async_chunk functional test script
|
||||
# Uses default built-in assets, iterates over AsyncOmni query-type x output-modalities combinations
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
export VLLM_OMNI_USE_V2_RUNNER=1
|
||||
|
||||
MODEL_PATH="/models/Qwen3-Omni-30B-A3B-Instruct"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
END2END="$SCRIPT_DIR/end2end_async_chunk.py"
|
||||
STAGE_CONFIGS="$SCRIPT_DIR/../../../vllm_omni/model_executor/stage_configs/qwen3_omni_moe_async_chunk.yaml"
|
||||
LOG_DIR="$SCRIPT_DIR/test_logs_async_chunk"
|
||||
SUMMARY_FILE="$LOG_DIR/summary.log"
|
||||
|
||||
rm -rf "$LOG_DIR"
|
||||
mkdir -p "$LOG_DIR"
|
||||
> "$SUMMARY_FILE"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
TOTAL=0
|
||||
|
||||
run_test() {
|
||||
local name="$1"
|
||||
shift
|
||||
local log_file="$LOG_DIR/${name}.log"
|
||||
local output_dir="$LOG_DIR/${name}_output"
|
||||
|
||||
TOTAL=$((TOTAL + 1))
|
||||
echo "========================================"
|
||||
echo "[${TOTAL}] Running: ${name}"
|
||||
echo " Command: python $END2END $*"
|
||||
echo "========================================"
|
||||
|
||||
local start_time
|
||||
start_time=$(date +%s)
|
||||
|
||||
if python "$END2END" "$@" --output-dir "$output_dir" > "$log_file" 2>&1; then
|
||||
local end_time
|
||||
end_time=$(date +%s)
|
||||
local elapsed=$((end_time - start_time))
|
||||
echo " => PASS (${elapsed}s)"
|
||||
echo "[PASS] ${name} (${elapsed}s)" >> "$SUMMARY_FILE"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
local exit_code=$?
|
||||
local end_time
|
||||
end_time=$(date +%s)
|
||||
local elapsed=$((end_time - start_time))
|
||||
echo " => FAIL (exit code: ${exit_code}, ${elapsed}s)"
|
||||
echo "[FAIL] ${name} (exit code: ${exit_code}, ${elapsed}s)" >> "$SUMMARY_FILE"
|
||||
# Append last 30 lines of log to summary
|
||||
echo " --- last 30 lines of log ---" >> "$SUMMARY_FILE"
|
||||
tail -n 30 "$log_file" >> "$SUMMARY_FILE"
|
||||
echo " --- end ---" >> "$SUMMARY_FILE"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Test cases
|
||||
# =============================================================================
|
||||
|
||||
# --- 1. Text-only input ---
|
||||
run_test "text_to_text" \
|
||||
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||
--query-type text --modalities text
|
||||
|
||||
run_test "text_to_text_audio" \
|
||||
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||
--query-type text --modalities text,audio
|
||||
|
||||
run_test "text_to_audio" \
|
||||
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||
--query-type text --modalities audio
|
||||
|
||||
# --- 2. Image input ---
|
||||
run_test "image_to_text" \
|
||||
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||
--query-type use_image --modalities text
|
||||
|
||||
run_test "image_to_text_audio" \
|
||||
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||
--query-type use_image --modalities text,audio
|
||||
|
||||
# --- 3. Audio input ---
|
||||
run_test "audio_to_text" \
|
||||
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||
--query-type use_audio --modalities text
|
||||
|
||||
run_test "audio_to_text_audio" \
|
||||
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||
--query-type use_audio --modalities text,audio
|
||||
|
||||
# --- 4. Video input ---
|
||||
run_test "video_to_text" \
|
||||
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||
--query-type use_video --modalities text
|
||||
|
||||
run_test "video_to_text_audio" \
|
||||
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||
--query-type use_video --modalities text,audio
|
||||
|
||||
# --- 5. Multi-prompt batch inference ---
|
||||
run_test "text_to_text_batch3" \
|
||||
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||
--query-type text --modalities text --num-prompts 3
|
||||
|
||||
# --- 6. Concurrent requests (max-in-flight) ---
|
||||
run_test "text_to_text_audio_concurrent2" \
|
||||
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||
--query-type text --modalities text,audio --num-prompts 2 --max-in-flight 2
|
||||
|
||||
# --- 7. stream-audio-to-disk mode ---
|
||||
run_test "audio_to_text_audio_stream" \
|
||||
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||
--query-type use_audio --modalities text,audio --stream-audio-to-disk
|
||||
|
||||
# --- 8. request-timeout test (long enough timeout, should PASS) ---
|
||||
run_test "text_to_text_with_timeout" \
|
||||
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||
--query-type text --modalities text --request-timeout-s 600
|
||||
|
||||
# --- 9. batch-timeout test (long enough timeout, should PASS) ---
|
||||
run_test "text_to_text_audio_batch_timeout" \
|
||||
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||
--query-type text --modalities text,audio --batch-timeout-s 600
|
||||
|
||||
# =============================================================================
|
||||
# Summary
|
||||
# =============================================================================
|
||||
echo "========================================" | tee -a "$SUMMARY_FILE"
|
||||
echo "Tests done: total ${TOTAL}, passed ${PASS}, failed ${FAIL}" | tee -a "$SUMMARY_FILE"
|
||||
echo "========================================" | tee -a "$SUMMARY_FILE"
|
||||
echo ""
|
||||
echo "Detailed logs: $LOG_DIR"
|
||||
echo "Summary report: $SUMMARY_FILE"
|
||||
|
||||
exit $FAIL
|
||||
@@ -145,7 +145,7 @@ python end2end.py \
|
||||
## Reducing GPU Memory Usage
|
||||
|
||||
For hardware with limited VRAM, enable **module-wise CPU offload** with
|
||||
`--enable-cpu-offload`. The pipeline implements `SupportsModuleOffload`, so the
|
||||
`--enable-cpu-offload`. The pipeline implements `SupportsComponentDiscovery`, so the
|
||||
vision encoder (`vision_model`) and the Qwen3 LLM (`language_model`) are
|
||||
swapped between CPU and GPU on demand:
|
||||
|
||||
|
||||
@@ -10,10 +10,18 @@ from typing import Any
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.data import logger
|
||||
from vllm_omni.diffusion.utils.param_utils import apply_declared_extra_args
|
||||
from vllm_omni.entrypoints.omni import Omni
|
||||
from vllm_omni.entrypoints.openai.stage_params import clone_sampling_params
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.lora.request import LoRARequest
|
||||
from vllm_omni.lora.utils import stable_lora_int_id
|
||||
from vllm_omni.model_extras import (
|
||||
build_text_to_image_prompt,
|
||||
get_extra_body_params,
|
||||
get_model_class_name,
|
||||
should_init_extra_args_for_non_diffusion_stages,
|
||||
)
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
|
||||
@@ -259,6 +267,19 @@ def parse_args() -> argparse.Namespace:
|
||||
action="store_true",
|
||||
help="[NextStep-1.1 only] Apply layer normalization to sampled tokens.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--extra-body",
|
||||
type=parse_profiler_config,
|
||||
default=None,
|
||||
help=(
|
||||
"Model-specific generation params as a JSON object, e.g. "
|
||||
'\'{"timestep_shift": 3.0, "cfg_text_scale": 4.0, "cfg_interval": [0.4, 1.0]}\'. '
|
||||
"Each key is filtered against the model's declared extra_body_params "
|
||||
"(see vllm_omni/model_extras), so unknown keys for the chosen model are "
|
||||
"silently dropped. Values here take precedence over the equivalent "
|
||||
"model-specific flags above."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--enable-diffusion-pipeline-profiler",
|
||||
action="store_true",
|
||||
@@ -405,6 +426,8 @@ def main():
|
||||
# NextStep-1.1 requires explicit pipeline class
|
||||
omni_kwargs["model_class_name"] = "NextStep11Pipeline"
|
||||
omni = Omni(**omni_kwargs)
|
||||
model_class_name = get_model_class_name(omni)
|
||||
declared_extra_body_params = get_extra_body_params(model_class_name)
|
||||
|
||||
if profiler_enabled:
|
||||
print("[Profiler] Starting profiling...")
|
||||
@@ -445,34 +468,79 @@ def main():
|
||||
)
|
||||
|
||||
generation_start = time.perf_counter()
|
||||
extra_args = {
|
||||
|
||||
prompt_dict = build_text_to_image_prompt(
|
||||
model_class_name=model_class_name,
|
||||
prompt=args.prompt,
|
||||
negative_prompt=args.negative_prompt,
|
||||
height=args.height,
|
||||
width=args.width,
|
||||
)
|
||||
|
||||
diffusion_params = OmniDiffusionSamplingParams(
|
||||
height=args.height,
|
||||
width=args.width,
|
||||
seed=args.seed,
|
||||
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_images_per_prompt,
|
||||
)
|
||||
|
||||
# Base layer: backward-compatible model-specific flags. New model params should
|
||||
# instead be declared in vllm_omni/model_extras and passed via --extra-body, so
|
||||
# this dict does not need to grow per-model.
|
||||
user_extra = {
|
||||
"cfg_scale": args.cfg_scale,
|
||||
"cfg_text_scale": args.cfg_scale,
|
||||
"negative_prompt": args.negative_prompt,
|
||||
"timestep_shift": args.timesteps_shift,
|
||||
"timesteps_shift": args.timesteps_shift,
|
||||
"cfg_schedule": args.cfg_schedule,
|
||||
"use_norm": args.use_norm,
|
||||
"use_system_prompt": args.use_system_prompt,
|
||||
"system_prompt": args.system_prompt,
|
||||
}
|
||||
if lora_request:
|
||||
extra_args["lora_request"] = lora_request
|
||||
extra_args["lora_scale"] = args.lora_scale
|
||||
# Override layer: generic JSON passthrough wins over the flags above. Keys are
|
||||
# still filtered against the model's declared extra_body_params downstream.
|
||||
if args.extra_body:
|
||||
user_extra.update(args.extra_body)
|
||||
if declared_extra_body_params:
|
||||
apply_declared_extra_args(diffusion_params, declared_extra_body_params, user_extra)
|
||||
else:
|
||||
diffusion_params.extra_args.update({k: v for k, v in user_extra.items() if v is not None})
|
||||
|
||||
outputs = omni.generate(
|
||||
{
|
||||
"prompt": args.prompt,
|
||||
"negative_prompt": args.negative_prompt,
|
||||
},
|
||||
OmniDiffusionSamplingParams(
|
||||
height=args.height,
|
||||
width=args.width,
|
||||
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_images_per_prompt,
|
||||
extra_args=extra_args,
|
||||
),
|
||||
if lora_request:
|
||||
diffusion_params.extra_args["lora_request"] = lora_request
|
||||
diffusion_params.extra_args["lora_scale"] = args.lora_scale
|
||||
|
||||
# Build per-stage sampling params for multi-stage models (e.g. BAGEL),
|
||||
# or wrap single diffusion params for single-stage models.
|
||||
init_non_diffusion = should_init_extra_args_for_non_diffusion_stages(
|
||||
model_class_name,
|
||||
)
|
||||
defaults = list(omni.default_sampling_params_list or [])
|
||||
sampling_params_list = [clone_sampling_params(p) for p in defaults]
|
||||
if not sampling_params_list:
|
||||
sampling_params_list = [diffusion_params]
|
||||
|
||||
diffusion_replaced = False
|
||||
for idx, params in enumerate(sampling_params_list):
|
||||
if isinstance(params, OmniDiffusionSamplingParams):
|
||||
sampling_params_list[idx] = diffusion_params
|
||||
diffusion_replaced = True
|
||||
elif init_non_diffusion and hasattr(params, "extra_args"):
|
||||
if params.extra_args is None:
|
||||
params.extra_args = {}
|
||||
if args.seed is not None and hasattr(params, "seed"):
|
||||
params.seed = args.seed
|
||||
|
||||
if not diffusion_replaced and len(sampling_params_list) == 1:
|
||||
sampling_params_list = [diffusion_params]
|
||||
|
||||
outputs = omni.generate(prompt_dict, sampling_params_list=sampling_params_list)
|
||||
|
||||
generation_end = time.perf_counter()
|
||||
generation_time = generation_end - generation_start
|
||||
@@ -502,15 +570,16 @@ def main():
|
||||
raise ValueError("No output generated from omni.generate()")
|
||||
logger.info(f"Outputs: {outputs}")
|
||||
|
||||
first_output = outputs[0]
|
||||
if not hasattr(first_output, "request_output") or not first_output.request_output:
|
||||
raise ValueError("No request_output found in OmniRequestOutput")
|
||||
images = None
|
||||
for output in outputs:
|
||||
images = getattr(output, "images", None)
|
||||
if images:
|
||||
break
|
||||
req_out = getattr(output, "request_output", None)
|
||||
images = getattr(req_out, "images", None) if req_out is not None else None
|
||||
if images:
|
||||
break
|
||||
|
||||
req_out = first_output.request_output
|
||||
if not hasattr(req_out, "images"):
|
||||
raise ValueError("Invalid request_output structure or missing 'images'.")
|
||||
|
||||
images = req_out.images
|
||||
if not images:
|
||||
raise ValueError("No images found in request_output")
|
||||
|
||||
|
||||
@@ -135,17 +135,18 @@ def _estimate_prompt_len(
|
||||
return 2048
|
||||
|
||||
|
||||
def get_custom_voice_query(use_batch_sample: bool = False) -> QueryResult:
|
||||
def get_custom_voice_query(use_batch_sample: bool = False, model_name: str | None = None) -> QueryResult:
|
||||
"""Build CustomVoice sample inputs.
|
||||
|
||||
Args:
|
||||
use_batch_sample: When True, return a batch of prompts; otherwise a single prompt.
|
||||
model_name: Override the default HF model path (e.g. with a local path).
|
||||
|
||||
Returns:
|
||||
QueryResult with Omni inputs and the CustomVoice model path.
|
||||
"""
|
||||
task_type = "CustomVoice"
|
||||
model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
|
||||
model_name = model_name or "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
|
||||
if use_batch_sample:
|
||||
texts = [
|
||||
"其实我真的有发现,我是一个特别善于观察别人情绪的人。",
|
||||
@@ -196,17 +197,18 @@ def get_custom_voice_query(use_batch_sample: bool = False) -> QueryResult:
|
||||
)
|
||||
|
||||
|
||||
def get_voice_design_query(use_batch_sample: bool = False) -> QueryResult:
|
||||
def get_voice_design_query(use_batch_sample: bool = False, model_name: str | None = None) -> QueryResult:
|
||||
"""Build VoiceDesign sample inputs.
|
||||
|
||||
Args:
|
||||
use_batch_sample: When True, return a batch of prompts; otherwise a single prompt.
|
||||
model_name: Override the default HF model path (e.g. with a local path).
|
||||
|
||||
Returns:
|
||||
QueryResult with Omni inputs and the VoiceDesign model path.
|
||||
"""
|
||||
task_type = "VoiceDesign"
|
||||
model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
|
||||
model_name = model_name or "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
|
||||
if use_batch_sample:
|
||||
texts = [
|
||||
"哥哥,你回来啦,人家等了你好久好久了,要抱抱!",
|
||||
@@ -255,18 +257,19 @@ def get_voice_design_query(use_batch_sample: bool = False) -> QueryResult:
|
||||
)
|
||||
|
||||
|
||||
def get_base_query(use_batch_sample: bool = False, mode_tag: str = "icl") -> QueryResult:
|
||||
def get_base_query(use_batch_sample: bool = False, mode_tag: str = "icl", model_name: str | None = None) -> QueryResult:
|
||||
"""Build Base (voice clone) sample inputs.
|
||||
|
||||
Args:
|
||||
use_batch_sample: When True, return a batch of prompts (Case 2).
|
||||
mode_tag: "icl" or "xvec_only" to control x_vector_only_mode behavior.
|
||||
model_name: Override the default HF model path (e.g. with a local path).
|
||||
|
||||
Returns:
|
||||
QueryResult with Omni inputs and the Base model path.
|
||||
"""
|
||||
task_type = "Base"
|
||||
model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
||||
model_name = model_name or "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
||||
ref_audio_path_1 = "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-TTS-Repo/clone_2.wav"
|
||||
ref_audio_single = ref_audio_path_1
|
||||
ref_text_single = (
|
||||
@@ -335,14 +338,17 @@ def _build_inputs(args) -> tuple[str, list]:
|
||||
)
|
||||
|
||||
query_func = query_map[args.query_type]
|
||||
model_override = args.model if args.model else None
|
||||
if args.query_type in {"CustomVoice", "VoiceDesign"}:
|
||||
query_result = query_func(use_batch_sample=args.use_batch_sample)
|
||||
query_result = query_func(use_batch_sample=args.use_batch_sample, model_name=model_override)
|
||||
elif args.query_type == "Base":
|
||||
query_result = query_func(use_batch_sample=args.use_batch_sample, mode_tag=args.mode_tag)
|
||||
query_result = query_func(
|
||||
use_batch_sample=args.use_batch_sample, mode_tag=args.mode_tag, model_name=model_override
|
||||
)
|
||||
else:
|
||||
query_result = query_func()
|
||||
|
||||
model_name = query_result.model_name
|
||||
model_name = args.model if args.model else query_result.model_name
|
||||
|
||||
if args.txt_prompts:
|
||||
with open(args.txt_prompts) as f:
|
||||
@@ -359,7 +365,10 @@ def _build_inputs(args) -> tuple[str, list]:
|
||||
for t in lines
|
||||
]
|
||||
else:
|
||||
inputs = query_result.inputs if isinstance(query_result.inputs, list) else [query_result.inputs]
|
||||
if isinstance(query_result.inputs, list):
|
||||
inputs = query_result.inputs * args.num_prompts
|
||||
else:
|
||||
inputs = [query_result.inputs] * args.num_prompts
|
||||
|
||||
return model_name, inputs
|
||||
|
||||
@@ -434,6 +443,12 @@ async def main_streaming(args):
|
||||
|
||||
def parse_args():
|
||||
parser = TrackingArgumentParser(description="Demo on using vLLM for offline inference with audio language models")
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Override the default model path for the selected query type.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--query-type",
|
||||
"-q",
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
"""Offline benchmark script for Qwen3 TTS via vLLM Omni.
|
||||
|
||||
Supports warmup + test rounds with a single Omni instance to avoid
|
||||
repeated model loading. Prints per-round wall time summary at the end.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
import soundfile as sf
|
||||
import torch
|
||||
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
|
||||
from vllm.utils.argparse_utils import FlexibleArgumentParser
|
||||
|
||||
from vllm_omni import AsyncOmni, Omni
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QueryResult(NamedTuple):
|
||||
"""Container for a prepared Omni request."""
|
||||
|
||||
inputs: dict
|
||||
model_name: str
|
||||
|
||||
|
||||
_model_cache: dict[str, Any] = {}
|
||||
|
||||
|
||||
def _estimate_prompt_len(
|
||||
additional_information: dict[str, Any],
|
||||
model_name: str,
|
||||
) -> int:
|
||||
"""Estimate prompt_token_ids placeholder length for the Talker stage."""
|
||||
try:
|
||||
from vllm_omni.model_executor.models.qwen3_tts.configuration_qwen3_tts import Qwen3TTSConfig
|
||||
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import (
|
||||
Qwen3TTSTalkerForConditionalGeneration,
|
||||
)
|
||||
|
||||
if model_name not in _model_cache:
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
tok = AutoTokenizer.from_pretrained(
|
||||
model_name, trust_remote_code=True, padding_side="left", fix_mistral_regex=True
|
||||
)
|
||||
cfg = Qwen3TTSConfig.from_pretrained(model_name, trust_remote_code=True)
|
||||
|
||||
speech_tok = None
|
||||
try:
|
||||
from transformers.utils import cached_file
|
||||
|
||||
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_tokenizer import Qwen3TTSTokenizer
|
||||
|
||||
st_cfg_path = cached_file(model_name, "speech_tokenizer/config.json")
|
||||
if st_cfg_path:
|
||||
speech_tok = Qwen3TTSTokenizer.from_pretrained(
|
||||
# os.path.dirname(st_cfg_path), torch_dtype=torch.bfloat16
|
||||
os.path.dirname(st_cfg_path),
|
||||
torch_dtype=torch.bfloat16,
|
||||
fix_mistral_regex=True,
|
||||
)
|
||||
logger.info("Loaded speech tokenizer for exact ref_code_len estimation")
|
||||
except Exception as e:
|
||||
logger.debug("Could not load speech tokenizer: %s", e)
|
||||
|
||||
_model_cache[model_name] = (tok, getattr(cfg, "talker_config", None), speech_tok)
|
||||
|
||||
tok, tcfg, speech_tok = _model_cache[model_name]
|
||||
task_type = (additional_information.get("task_type") or ["CustomVoice"])[0]
|
||||
|
||||
def _estimate_ref_code_len(ref_audio: object) -> int | None:
|
||||
if not isinstance(ref_audio, (str, list)):
|
||||
return None
|
||||
audio_path = ref_audio[0] if isinstance(ref_audio, list) else ref_audio
|
||||
if not isinstance(audio_path, str) or not audio_path.strip():
|
||||
return None
|
||||
try:
|
||||
from vllm.multimodal.media import MediaConnector
|
||||
|
||||
connector = MediaConnector(allowed_local_media_path="/")
|
||||
audio, sr = connector.fetch_audio(audio_path)
|
||||
import numpy as np
|
||||
|
||||
wav_np = np.asarray(audio, dtype=np.float32)
|
||||
|
||||
if speech_tok is not None:
|
||||
enc = speech_tok.encode(wav_np, sr=int(sr), return_dict=True)
|
||||
ref_code = getattr(enc, "audio_codes", None)
|
||||
if isinstance(ref_code, list):
|
||||
ref_code = ref_code[0] if ref_code else None
|
||||
if ref_code is not None and hasattr(ref_code, "shape"):
|
||||
shape = ref_code.shape
|
||||
return int(shape[0]) if len(shape) == 2 else int(shape[1]) if len(shape) == 3 else None
|
||||
|
||||
codec_hz = getattr(tcfg, "codec_frame_rate", None) or 12
|
||||
return int(len(audio) / sr * codec_hz)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return Qwen3TTSTalkerForConditionalGeneration.estimate_prompt_len_from_additional_information(
|
||||
additional_information=additional_information,
|
||||
task_type=task_type,
|
||||
tokenize_prompt=lambda t: tok(t, padding=False)["input_ids"],
|
||||
codec_language_id=getattr(tcfg, "codec_language_id", None),
|
||||
spk_is_dialect=getattr(tcfg, "spk_is_dialect", None),
|
||||
estimate_ref_code_len=_estimate_ref_code_len,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to estimate prompt length, using fallback 2048: %s", exc)
|
||||
return 2048
|
||||
|
||||
|
||||
def get_custom_voice_query(use_batch_sample: bool = False) -> QueryResult:
|
||||
task_type = "CustomVoice"
|
||||
model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
|
||||
if use_batch_sample:
|
||||
texts = [
|
||||
"其实我真的有发现,我是一个特别善于观察别人情绪的人。",
|
||||
"She said she would be here by noon.",
|
||||
"I like you very much.",
|
||||
"Really, you do?",
|
||||
"Yes, absolutely.",
|
||||
]
|
||||
instructs = ["", "Very happy.", "Very happy.", "Very happy.", "Very happy."]
|
||||
languages = ["Chinese", "English", "English", "English", "English"]
|
||||
speakers = ["Vivian", "Ryan", "Ryan", "Ryan", "Ryan"]
|
||||
inputs = []
|
||||
for text, instruct, language, speaker in zip(texts, instructs, languages, speakers):
|
||||
additional_information = {
|
||||
"task_type": [task_type],
|
||||
"text": [text],
|
||||
"instruct": [instruct],
|
||||
"language": [language],
|
||||
"speaker": [speaker],
|
||||
"max_new_tokens": [2048],
|
||||
}
|
||||
inputs.append(
|
||||
{
|
||||
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
|
||||
"additional_information": additional_information,
|
||||
}
|
||||
)
|
||||
else:
|
||||
text = "其实我真的有发现,我是一个特别善于观察别人情绪的人。"
|
||||
language = "Chinese"
|
||||
speaker = "Vivian"
|
||||
instruct = "用特别愤怒的语气说"
|
||||
additional_information = {
|
||||
"task_type": [task_type],
|
||||
"text": [text],
|
||||
"language": [language],
|
||||
"speaker": [speaker],
|
||||
"instruct": [instruct],
|
||||
"max_new_tokens": [2048],
|
||||
}
|
||||
inputs = {
|
||||
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
|
||||
"additional_information": additional_information,
|
||||
}
|
||||
return QueryResult(inputs=inputs, model_name=model_name)
|
||||
|
||||
|
||||
def get_voice_design_query(use_batch_sample: bool = False) -> QueryResult:
|
||||
task_type = "VoiceDesign"
|
||||
model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
|
||||
if use_batch_sample:
|
||||
texts = [
|
||||
"哥哥,你回来啦,人家等了你好久好久了,要抱抱!",
|
||||
"It's in the top drawer... wait, it's empty? No way, that's impossible! I'm sure I put it there!",
|
||||
]
|
||||
instructs = [
|
||||
"体现撒娇稚嫩的萝莉女声,音调偏高且起伏明显,营造出黏人、做作又刻意卖萌的听觉效果。",
|
||||
"Speak in an incredulous tone, but with a hint of panic beginning to creep into your voice.",
|
||||
]
|
||||
languages = ["Chinese", "English"]
|
||||
inputs = []
|
||||
for text, instruct, language in zip(texts, instructs, languages):
|
||||
additional_information = {
|
||||
"task_type": [task_type],
|
||||
"text": [text],
|
||||
"language": [language],
|
||||
"instruct": [instruct],
|
||||
"max_new_tokens": [2048],
|
||||
"non_streaming_mode": [True],
|
||||
}
|
||||
inputs.append(
|
||||
{
|
||||
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
|
||||
"additional_information": additional_information,
|
||||
}
|
||||
)
|
||||
else:
|
||||
text = "哥哥,你回来啦,人家等了你好久好久了,要抱抱!"
|
||||
instruct = "体现撒娇稚嫩的萝莉女声,音调偏高且起伏明显,营造出黏人、做作又刻意卖萌的听觉效果。"
|
||||
language = "Chinese"
|
||||
additional_information = {
|
||||
"task_type": [task_type],
|
||||
"text": [text],
|
||||
"language": [language],
|
||||
"instruct": [instruct],
|
||||
"max_new_tokens": [2048],
|
||||
"non_streaming_mode": [True],
|
||||
}
|
||||
inputs = {
|
||||
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
|
||||
"additional_information": additional_information,
|
||||
}
|
||||
return QueryResult(inputs=inputs, model_name=model_name)
|
||||
|
||||
|
||||
def get_base_query(use_batch_sample: bool = False, mode_tag: str = "icl") -> QueryResult:
|
||||
task_type = "Base"
|
||||
model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
||||
ref_audio_path_1 = "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-TTS-Repo/clone_2.wav"
|
||||
ref_audio_single = ref_audio_path_1
|
||||
ref_text_single = (
|
||||
"Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you."
|
||||
)
|
||||
syn_text_single = "Good one. Okay, fine, I'm just gonna leave this sock monkey here. Goodbye."
|
||||
syn_lang_single = "Auto"
|
||||
x_vector_only_mode = mode_tag == "xvec_only"
|
||||
if use_batch_sample:
|
||||
syn_text_batch = [
|
||||
"Good one. Okay, fine, I'm just gonna leave this sock monkey here. Goodbye.",
|
||||
"其实我真的有发现,我是一个特别善于观察别人情绪的人。",
|
||||
]
|
||||
syn_lang_batch = ["Chinese", "English"]
|
||||
inputs = []
|
||||
for text, language in zip(syn_text_batch, syn_lang_batch):
|
||||
additional_information = {
|
||||
"task_type": [task_type],
|
||||
"ref_audio": [ref_audio_single],
|
||||
"ref_text": [ref_text_single],
|
||||
"text": [text],
|
||||
"language": [language],
|
||||
"x_vector_only_mode": [x_vector_only_mode],
|
||||
"max_new_tokens": [2048],
|
||||
}
|
||||
inputs.append(
|
||||
{
|
||||
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
|
||||
"additional_information": additional_information,
|
||||
}
|
||||
)
|
||||
else:
|
||||
additional_information = {
|
||||
"task_type": [task_type],
|
||||
"ref_audio": [ref_audio_single],
|
||||
"ref_text": [ref_text_single],
|
||||
"text": [syn_text_single],
|
||||
"language": [syn_lang_single],
|
||||
"x_vector_only_mode": [x_vector_only_mode],
|
||||
"max_new_tokens": [2048],
|
||||
}
|
||||
inputs = {
|
||||
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
|
||||
"additional_information": additional_information,
|
||||
}
|
||||
return QueryResult(inputs=inputs, model_name=model_name)
|
||||
|
||||
|
||||
query_map = {
|
||||
"CustomVoice": get_custom_voice_query,
|
||||
"VoiceDesign": get_voice_design_query,
|
||||
"Base": get_base_query,
|
||||
}
|
||||
|
||||
|
||||
def _build_inputs(args) -> tuple[str, list]:
|
||||
"""Resolve model name and inputs list from CLI args."""
|
||||
if args.batch_size < 1 or (args.batch_size & (args.batch_size - 1)) != 0:
|
||||
raise ValueError(
|
||||
f"--batch-size must be a power of two (got {args.batch_size}); "
|
||||
"non-power-of-two values do not align with CUDA graph capture sizes "
|
||||
"of Code2Wav."
|
||||
)
|
||||
|
||||
query_func = query_map[args.query_type]
|
||||
if args.query_type in {"CustomVoice", "VoiceDesign"}:
|
||||
query_result = query_func(use_batch_sample=args.use_batch_sample)
|
||||
elif args.query_type == "Base":
|
||||
query_result = query_func(use_batch_sample=args.use_batch_sample, mode_tag=args.mode_tag)
|
||||
else:
|
||||
query_result = query_func()
|
||||
|
||||
model_name = query_result.model_name
|
||||
|
||||
if args.txt_prompts:
|
||||
with open(args.txt_prompts) as f:
|
||||
lines = [line.strip() for line in f if line.strip()]
|
||||
if not lines:
|
||||
raise ValueError(f"No valid prompts found in {args.txt_prompts}")
|
||||
template = query_result.inputs if not isinstance(query_result.inputs, list) else query_result.inputs[0]
|
||||
template_info = template["additional_information"]
|
||||
inputs = [
|
||||
{
|
||||
"prompt_token_ids": [0] * _estimate_prompt_len({**template_info, "text": [t]}, model_name),
|
||||
"additional_information": {**template_info, "text": [t]},
|
||||
}
|
||||
for t in lines
|
||||
]
|
||||
else:
|
||||
inputs = query_result.inputs if isinstance(query_result.inputs, list) else [query_result.inputs]
|
||||
|
||||
return model_name, inputs
|
||||
|
||||
|
||||
def _save_wav(output_dir: str, request_id: str, mm: dict) -> None:
|
||||
"""Concatenate audio chunks and write to a wav file."""
|
||||
audio_data = mm["audio"]
|
||||
sr_raw = mm["sr"]
|
||||
sr_val = sr_raw[-1] if isinstance(sr_raw, list) and sr_raw else sr_raw
|
||||
sr = sr_val.item() if hasattr(sr_val, "item") else int(sr_val)
|
||||
audio_tensor = torch.cat(audio_data, dim=-1) if isinstance(audio_data, list) else audio_data
|
||||
out_wav = os.path.join(output_dir, f"output_{request_id}.wav")
|
||||
sf.write(out_wav, audio_tensor.float().cpu().numpy().flatten(), samplerate=sr, format="WAV")
|
||||
logger.info("Saved audio to %s", out_wav)
|
||||
|
||||
|
||||
class _SummaryCollector:
|
||||
"""Monkey-patches omni._log_summary_and_cleanup to capture summary dicts.
|
||||
|
||||
NOTE: This relies on a private method ``_log_summary_and_cleanup`` which may
|
||||
break if the upstream Omni implementation is refactored. If Omni exposes a
|
||||
public hook for summary collection in the future, prefer that instead.
|
||||
"""
|
||||
|
||||
def __init__(self, omni):
|
||||
self.omni = omni
|
||||
self.summaries: list[dict[str, Any]] = []
|
||||
self.collecting = False
|
||||
self._orig = omni._log_summary_and_cleanup
|
||||
|
||||
def install(self):
|
||||
from pprint import pformat as _pformat
|
||||
|
||||
collector = self
|
||||
|
||||
def _patched(request_id: str) -> None:
|
||||
req_state = collector.omni.request_states.get(request_id)
|
||||
try:
|
||||
if req_state is None or req_state.metrics is None:
|
||||
return
|
||||
summary = req_state.metrics.build_and_log_summary()
|
||||
logger.info("[Summary] %s", _pformat(summary, sort_dicts=False))
|
||||
if collector.collecting and summary:
|
||||
collector.summaries.append(summary)
|
||||
except Exception:
|
||||
logger.exception("Failed to build/log summary for req=%s", request_id)
|
||||
finally:
|
||||
collector.omni.request_states.pop(request_id, None)
|
||||
|
||||
collector.omni._log_summary_and_cleanup = _patched
|
||||
|
||||
|
||||
def _print_summary(round_times: list[float], summaries: list[dict[str, Any]] | None = None) -> None:
|
||||
"""Print per-round wall time and optional detailed pipeline summary."""
|
||||
print("\n" + "=" * 60)
|
||||
print("BENCHMARK SUMMARY")
|
||||
print("=" * 60)
|
||||
for i, t in enumerate(round_times):
|
||||
print(f" Test round {i}: {t:.3f}s")
|
||||
print("-" * 60)
|
||||
avg = sum(round_times) / len(round_times)
|
||||
print(f" Rounds: {len(round_times)}")
|
||||
print(f" Total: {sum(round_times):.3f}s")
|
||||
print(f" Average: {avg:.3f}s")
|
||||
print(f" Min: {min(round_times):.3f}s")
|
||||
print(f" Max: {max(round_times):.3f}s")
|
||||
print("=" * 60)
|
||||
|
||||
if not summaries:
|
||||
print()
|
||||
return
|
||||
|
||||
# --- Aggregate from captured summaries ---
|
||||
overall_list = [s["overall_summary"] for s in summaries if "overall_summary" in s]
|
||||
if not overall_list:
|
||||
print()
|
||||
return
|
||||
|
||||
n = len(overall_list)
|
||||
|
||||
# Average Wall time
|
||||
avg_wall = sum(d["e2e_wall_time_ms"] for d in overall_list) / n
|
||||
print(f"\n{'=' * 60}")
|
||||
print("DETAILED PIPELINE SUMMARY (test rounds average)")
|
||||
print("=" * 60)
|
||||
print(f" Avg Wall Time: {avg_wall:.3f} ms")
|
||||
|
||||
# Per-stage timing
|
||||
stage_keys = sorted(k for k in overall_list[0] if k.startswith("e2e_stage_") and k.endswith("_wall_time_ms"))
|
||||
if stage_keys:
|
||||
print("-" * 60)
|
||||
print(" Per-stage timing (avg):")
|
||||
for sk in stage_keys:
|
||||
stage_avg = sum(d.get(sk, 0.0) for d in overall_list) / n
|
||||
# extract stage index from key like e2e_stage_0_wall_time_ms
|
||||
stage_idx = sk.replace("e2e_stage_", "").replace("_wall_time_ms", "")
|
||||
print(f" Stage {stage_idx} wall time: {stage_avg:.3f} ms")
|
||||
|
||||
# Per-stage gen time from stage_table
|
||||
all_stage_gen: dict[int, list[float]] = {}
|
||||
for s in summaries:
|
||||
for req_entry in s.get("stage_table", []):
|
||||
for stage_info in req_entry.get("stages", []):
|
||||
sid = stage_info.get("stage_id", -1)
|
||||
gen_ms = stage_info.get("stage_gen_time_ms", 0.0)
|
||||
all_stage_gen.setdefault(sid, []).append(gen_ms)
|
||||
if all_stage_gen:
|
||||
print(" Per-stage gen time (avg):")
|
||||
for sid in sorted(all_stage_gen):
|
||||
vals = all_stage_gen[sid]
|
||||
print(f" Stage {sid} gen time: {sum(vals) / len(vals):.3f} ms")
|
||||
|
||||
# Per-prompt throughput
|
||||
avg_tokens_per_s = sum(d.get("e2e_avg_tokens_per_s", 0.0) for d in overall_list) / n
|
||||
avg_time_per_req = sum(d.get("e2e_avg_time_per_request_ms", 0.0) for d in overall_list) / n
|
||||
total_tokens_list = [d.get("e2e_total_tokens", 0) for d in overall_list]
|
||||
avg_total_tokens = sum(total_tokens_list) / n
|
||||
print("-" * 60)
|
||||
print(" Per-prompt throughput (avg):")
|
||||
print(f" Tokens per request: {avg_total_tokens:.1f}")
|
||||
print(f" Time per request: {avg_time_per_req:.3f} ms")
|
||||
print(f" Tokens/s: {avg_tokens_per_s:.3f}")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
|
||||
def main(args):
|
||||
"""Run sync benchmark: warmup rounds then test rounds."""
|
||||
model_name, inputs = _build_inputs(args)
|
||||
output_dir = args.output_dir
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
omni = Omni(
|
||||
model=model_name,
|
||||
stage_configs_path=args.stage_configs_path,
|
||||
log_stats=args.log_stats,
|
||||
stage_init_timeout=args.stage_init_timeout,
|
||||
enable_diffusion_pipeline_profiler=args.enable_diffusion_pipeline_profiler,
|
||||
)
|
||||
|
||||
collector = _SummaryCollector(omni)
|
||||
collector.install()
|
||||
|
||||
batch_size = args.batch_size
|
||||
|
||||
# --- Warmup ---
|
||||
for r in range(args.warmup_rounds):
|
||||
print(f"[Warmup {r}/{args.warmup_rounds}] running...")
|
||||
for batch_start in range(0, len(inputs), batch_size):
|
||||
batch = inputs[batch_start : batch_start + batch_size]
|
||||
for _ in omni.generate(batch):
|
||||
pass
|
||||
print(f"[Warmup {r}/{args.warmup_rounds}] done")
|
||||
|
||||
# --- Test ---
|
||||
collector.collecting = True
|
||||
round_times = []
|
||||
for r in range(args.test_rounds):
|
||||
print(f"[Test {r}/{args.test_rounds}] running...")
|
||||
t_start = time.perf_counter()
|
||||
for batch_start in range(0, len(inputs), batch_size):
|
||||
batch = inputs[batch_start : batch_start + batch_size]
|
||||
for stage_outputs in omni.generate(batch):
|
||||
output = stage_outputs.request_output
|
||||
_save_wav(
|
||||
output_dir,
|
||||
f"round{r}_{output.request_id}",
|
||||
output.outputs[0].multimodal_output,
|
||||
)
|
||||
elapsed = time.perf_counter() - t_start
|
||||
round_times.append(elapsed)
|
||||
print(f"[Test {r}/{args.test_rounds}] done in {elapsed:.3f}s")
|
||||
collector.collecting = False
|
||||
|
||||
_print_summary(round_times, collector.summaries if args.log_stats else None)
|
||||
|
||||
|
||||
async def main_streaming(args):
|
||||
"""Run async streaming benchmark: warmup rounds then test rounds."""
|
||||
model_name, inputs = _build_inputs(args)
|
||||
output_dir = args.output_dir
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
omni = AsyncOmni(
|
||||
model=model_name,
|
||||
stage_configs_path=args.stage_configs_path,
|
||||
log_stats=args.log_stats,
|
||||
stage_init_timeout=args.stage_init_timeout,
|
||||
enable_diffusion_pipeline_profiler=args.enable_diffusion_pipeline_profiler,
|
||||
)
|
||||
|
||||
collector = _SummaryCollector(omni)
|
||||
collector.install()
|
||||
|
||||
# --- Warmup ---
|
||||
for r in range(args.warmup_rounds):
|
||||
print(f"[Warmup {r}/{args.warmup_rounds}] running...")
|
||||
for i, prompt in enumerate(inputs):
|
||||
async for _ in omni.generate(prompt, request_id=f"warmup_{r}_{i}"):
|
||||
pass
|
||||
print(f"[Warmup {r}/{args.warmup_rounds}] done")
|
||||
|
||||
# --- Test ---
|
||||
collector.collecting = True
|
||||
round_times = []
|
||||
for r in range(args.test_rounds):
|
||||
print(f"[Test {r}/{args.test_rounds}] running...")
|
||||
t_start = time.perf_counter()
|
||||
for i, prompt in enumerate(inputs):
|
||||
request_id = f"round{r}_{i}"
|
||||
t_req_start = time.perf_counter()
|
||||
t_prev = t_req_start
|
||||
chunk_idx = 0
|
||||
async for stage_output in omni.generate(prompt, request_id=request_id):
|
||||
mm = stage_output.request_output.outputs[0].multimodal_output
|
||||
if not stage_output.finished:
|
||||
t_now = time.perf_counter()
|
||||
audio = mm.get("audio")
|
||||
n = len(audio) if isinstance(audio, list) else (0 if audio is None else 1)
|
||||
if chunk_idx == 0:
|
||||
ttfa_ms = (t_now - t_req_start) * 1000
|
||||
logger.info("Request %s: chunk %d samples=%d TTFA=%.1fms", request_id, chunk_idx, n, ttfa_ms)
|
||||
else:
|
||||
dt_ms = (t_now - t_prev) * 1000
|
||||
logger.info(
|
||||
"Request %s: chunk %d samples=%d inter_chunk=%.1fms", request_id, chunk_idx, n, dt_ms
|
||||
)
|
||||
t_prev = t_now
|
||||
chunk_idx += 1
|
||||
else:
|
||||
t_end = time.perf_counter()
|
||||
total_ms = (t_end - t_req_start) * 1000
|
||||
logger.info("Request %s: done total=%.1fms chunks=%d", request_id, total_ms, chunk_idx)
|
||||
_save_wav(output_dir, request_id, mm)
|
||||
elapsed = time.perf_counter() - t_start
|
||||
round_times.append(elapsed)
|
||||
print(f"[Test {r}/{args.test_rounds}] done in {elapsed:.3f}s")
|
||||
collector.collecting = False
|
||||
|
||||
_print_summary(round_times, collector.summaries if args.log_stats else None)
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = FlexibleArgumentParser(description="Benchmark script for Qwen3 TTS via vLLM Omni")
|
||||
parser.add_argument(
|
||||
"--query-type",
|
||||
"-q",
|
||||
type=str,
|
||||
default="CustomVoice",
|
||||
choices=query_map.keys(),
|
||||
help="Query type.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--warmup-rounds",
|
||||
type=int,
|
||||
default=3,
|
||||
help="Number of warmup rounds (results discarded, default: 3).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test-rounds",
|
||||
type=int,
|
||||
default=7,
|
||||
help="Number of test rounds (results saved and timed, default: 7).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-stats",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Enable writing detailed statistics (default: disabled).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stage-init-timeout",
|
||||
type=int,
|
||||
default=300,
|
||||
help="Timeout for initializing a single stage in seconds (default: 300).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
default="output_audio",
|
||||
help="Output directory for generated wav files (default: output_audio).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--txt-prompts",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to a .txt file with one prompt per line.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stage-configs-path",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to a stage configs file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--use-batch-sample",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Use batch input sample for CustomVoice/VoiceDesign/Base query.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mode-tag",
|
||||
type=str,
|
||||
default="icl",
|
||||
choices=["icl", "xvec_only"],
|
||||
help="Mode tag for Base query x_vector_only_mode (default: icl).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--streaming",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Stream audio chunks via AsyncOmni.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of prompts per batch (default: 1).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--enable-diffusion-pipeline-profiler",
|
||||
action="store_true",
|
||||
help="Enable diffusion pipeline profiler to display stage durations.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
if args.streaming:
|
||||
asyncio.run(main_streaming(args))
|
||||
else:
|
||||
main(args)
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Qwen3-TTS end2end functional test script
|
||||
# Iterates over query-type and mode combinations for qwen3_tts/end2end.py
|
||||
#
|
||||
# Usage:
|
||||
# ./run_tests.sh --custom-voice-model /path/a --voice-design-model /path/b --base-model /path/c
|
||||
# ./run_tests.sh --custom-voice-model /path/a # only run CustomVoice tests
|
||||
# ./run_tests.sh --base-model /path/c # only run Base tests
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
export VLLM_OMNI_USE_V2_RUNNER=1
|
||||
# export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1
|
||||
|
||||
# ---- Parse named arguments ----
|
||||
CUSTOM_VOICE_MODEL=""
|
||||
VOICE_DESIGN_MODEL=""
|
||||
BASE_MODEL=""
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 [--custom-voice-model PATH] [--voice-design-model PATH] [--base-model PATH]"
|
||||
echo ""
|
||||
echo "Pass one or more model paths. Only the query types with a model provided will be tested."
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--custom-voice-model) CUSTOM_VOICE_MODEL="$2"; shift 2 ;;
|
||||
--voice-design-model) VOICE_DESIGN_MODEL="$2"; shift 2 ;;
|
||||
--base-model) BASE_MODEL="$2"; shift 2 ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "Unknown option: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$CUSTOM_VOICE_MODEL" && -z "$VOICE_DESIGN_MODEL" && -z "$BASE_MODEL" ]]; then
|
||||
echo "Error: at least one model path must be provided."
|
||||
usage
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
END2END="$SCRIPT_DIR/end2end.py"
|
||||
LOG_DIR="$SCRIPT_DIR/test_logs"
|
||||
SUMMARY_FILE="$LOG_DIR/summary.log"
|
||||
|
||||
rm -rf "$LOG_DIR"
|
||||
mkdir -p "$LOG_DIR"
|
||||
> "$SUMMARY_FILE"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
TOTAL=0
|
||||
|
||||
run_test() {
|
||||
local name="$1"
|
||||
shift
|
||||
local log_file="$LOG_DIR/${name}.log"
|
||||
local output_dir="$LOG_DIR/${name}_output"
|
||||
|
||||
TOTAL=$((TOTAL + 1))
|
||||
echo "========================================"
|
||||
echo "[${TOTAL}] Running: ${name}"
|
||||
echo " Command: python $END2END $*"
|
||||
echo "========================================"
|
||||
|
||||
local start_time
|
||||
start_time=$(date +%s)
|
||||
|
||||
if python "$END2END" "$@" --output-dir "$output_dir" > "$log_file" 2>&1; then
|
||||
local end_time
|
||||
end_time=$(date +%s)
|
||||
local elapsed=$((end_time - start_time))
|
||||
echo " => PASS (${elapsed}s)"
|
||||
echo "[PASS] ${name} (${elapsed}s)" >> "$SUMMARY_FILE"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
local exit_code=$?
|
||||
local end_time
|
||||
end_time=$(date +%s)
|
||||
local elapsed=$((end_time - start_time))
|
||||
echo " => FAIL (exit code: ${exit_code}, ${elapsed}s)"
|
||||
echo "[FAIL] ${name} (exit code: ${exit_code}, ${elapsed}s)" >> "$SUMMARY_FILE"
|
||||
echo " --- last 30 lines of log ---" >> "$SUMMARY_FILE"
|
||||
tail -n 30 "$log_file" >> "$SUMMARY_FILE"
|
||||
echo " --- end ---" >> "$SUMMARY_FILE"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Test cases — only run sections whose model path was provided
|
||||
# =============================================================================
|
||||
|
||||
# --- 1. CustomVoice ---
|
||||
if [[ -n "$CUSTOM_VOICE_MODEL" ]]; then
|
||||
run_test "custom_voice" \
|
||||
--model "$CUSTOM_VOICE_MODEL" --query-type CustomVoice
|
||||
|
||||
run_test "custom_voice_batch_sample" \
|
||||
--model "$CUSTOM_VOICE_MODEL" --query-type CustomVoice --use-batch-sample
|
||||
|
||||
run_test "custom_voice_py_generator" \
|
||||
--model "$CUSTOM_VOICE_MODEL" --query-type CustomVoice --py-generator
|
||||
|
||||
run_test "custom_voice_streaming" \
|
||||
--model "$CUSTOM_VOICE_MODEL" --query-type CustomVoice --streaming
|
||||
|
||||
run_test "custom_voice_batch4" \
|
||||
--model "$CUSTOM_VOICE_MODEL" --query-type CustomVoice --batch-size 4
|
||||
|
||||
run_test "custom_voice_batch4_batch_sample" \
|
||||
--model "$CUSTOM_VOICE_MODEL" --query-type CustomVoice --batch-size 4 --use-batch-sample --stage-configs-path "$SCRIPT_DIR/../../../vllm_omni/model_executor/stage_configs/qwen3_tts_batch.yaml"
|
||||
|
||||
run_test "custom_voice_num_prompts3" \
|
||||
--model "$CUSTOM_VOICE_MODEL" --query-type CustomVoice --num-prompts 3
|
||||
fi
|
||||
|
||||
# --- 2. VoiceDesign ---
|
||||
if [[ -n "$VOICE_DESIGN_MODEL" ]]; then
|
||||
run_test "voice_design" \
|
||||
--model "$VOICE_DESIGN_MODEL" --query-type VoiceDesign
|
||||
|
||||
run_test "voice_design_batch_sample" \
|
||||
--model "$VOICE_DESIGN_MODEL" --query-type VoiceDesign --use-batch-sample
|
||||
|
||||
run_test "voice_design_py_generator" \
|
||||
--model "$VOICE_DESIGN_MODEL" --query-type VoiceDesign --py-generator
|
||||
|
||||
run_test "voice_design_streaming" \
|
||||
--model "$VOICE_DESIGN_MODEL" --query-type VoiceDesign --streaming
|
||||
fi
|
||||
|
||||
# --- 3. Base (icl mode) ---
|
||||
if [[ -n "$BASE_MODEL" ]]; then
|
||||
run_test "base_icl" \
|
||||
--model "$BASE_MODEL" --query-type Base --mode-tag icl
|
||||
|
||||
run_test "base_icl_batch_sample" \
|
||||
--model "$BASE_MODEL" --query-type Base --mode-tag icl --use-batch-sample
|
||||
|
||||
run_test "base_icl_py_generator" \
|
||||
--model "$BASE_MODEL" --query-type Base --mode-tag icl --py-generator
|
||||
|
||||
run_test "base_icl_streaming" \
|
||||
--model "$BASE_MODEL" --query-type Base --mode-tag icl --streaming
|
||||
|
||||
# --- 4. Base (xvec_only mode) ---
|
||||
run_test "base_xvec_only" \
|
||||
--model "$BASE_MODEL" --query-type Base --mode-tag xvec_only
|
||||
|
||||
run_test "base_xvec_only_batch_sample" \
|
||||
--model "$BASE_MODEL" --query-type Base --mode-tag xvec_only --use-batch-sample
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Summary
|
||||
# =============================================================================
|
||||
echo "========================================" | tee -a "$SUMMARY_FILE"
|
||||
echo "Tests done: total ${TOTAL}, passed ${PASS}, failed ${FAIL}" | tee -a "$SUMMARY_FILE"
|
||||
echo "========================================" | tee -a "$SUMMARY_FILE"
|
||||
echo ""
|
||||
echo "Detailed logs: $LOG_DIR"
|
||||
echo "Summary report: $SUMMARY_FILE"
|
||||
|
||||
exit $FAIL
|
||||
@@ -1,404 +0,0 @@
|
||||
# BAGEL-7B-MoT
|
||||
|
||||
## Installation
|
||||
|
||||
Please refer to [README.md](../../../README.md)
|
||||
|
||||
## Architecture
|
||||
|
||||
BAGEL-7B-MoT is a Mixture-of-Transformers (MoT) model supporting both image generation and understanding. It offers two deployment topologies:
|
||||
|
||||
| Topology | Stages | Description |
|
||||
| :------- | :----- | :---------- |
|
||||
| **Two-stage** (default) | Stage 0 (Thinker, AR) + Stage 1 (DiT, Diffusion) | Thinker handles text/understanding via vLLM AR engine; DiT handles image generation. KV cache is transferred between stages. |
|
||||
| **Single-stage** | Stage 0 (DiT, Diffusion) only | The DiT stage contains a full LLM, ViT, VAE, and tokenizer internally. All modalities are handled within a single diffusion process. |
|
||||
|
||||
Both topologies support all four modalities: `text2img`, `img2img`, `img2text`, `text2text`.
|
||||
|
||||
> **Note**: These examples work with the default configuration on an **NVIDIA A100 (80GB)**. We also tested on dual **NVIDIA RTX 5000 Ada (32GB each)**. For dual-GPU setups, modify the deploy YAML to distribute stages across devices.
|
||||
|
||||
## Launch the Server
|
||||
|
||||
### Two-Stage (Default)
|
||||
|
||||
The default pipeline is auto-detected from the model. No extra flags needed:
|
||||
|
||||
```bash
|
||||
vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --port 8091
|
||||
```
|
||||
|
||||
Or use the convenience script:
|
||||
|
||||
```bash
|
||||
cd examples/online_serving/bagel
|
||||
bash run_server.sh
|
||||
|
||||
# Initialize each stage in a discrete isolated process terminal
|
||||
bash run_server_stage_cli.sh --stage 0
|
||||
bash run_server_stage_cli.sh --stage 1
|
||||
```
|
||||
|
||||
To use a custom deploy YAML, pass it via `--deploy-config`:
|
||||
|
||||
```bash
|
||||
vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --port 8091 \
|
||||
--deploy-config /path/to/deploy_config.yaml
|
||||
```
|
||||
|
||||
See [`bagel.yaml`](../../../vllm_omni/deploy/bagel.yaml) for the default two-stage deploy configuration.
|
||||
|
||||
### Single-Stage
|
||||
|
||||
The DiT stage contains a full LLM, ViT, VAE, and tokenizer, so it can handle all modalities (text2img, img2img, img2text, text2text, think) without a separate Thinker stage:
|
||||
|
||||
```bash
|
||||
vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --port 8091 \
|
||||
--deploy-config vllm_omni/deploy/bagel_single_stage.yaml
|
||||
```
|
||||
|
||||
See [`bagel_single_stage.yaml`](../../../vllm_omni/deploy/bagel_single_stage.yaml) for configuration. The `pipeline: bagel_single_stage` field selects the single-stage topology from the pipeline registry.
|
||||
|
||||
### Tensor Parallelism (TP)
|
||||
|
||||
For larger models or multi-GPU environments, enable TP via CLI:
|
||||
|
||||
```bash
|
||||
vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --port 8091 --tensor-parallel-size 2
|
||||
```
|
||||
|
||||
Or set `tensor_parallel_size` per stage in a custom deploy YAML.
|
||||
|
||||
### VAE Patch Parallelism
|
||||
|
||||
[VAE Patch Parallelism](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/parallelism/vae_patch_parallel.html) distributes Bagel VAE **decode/encode** across multiple GPUs by splitting latent tiles. It lowers **per-GPU peak memory during VAE decode**, which helps high-resolution `text2img` / `img2img` when VAE becomes a bottleneck.
|
||||
|
||||
**Scope for Bagel:**
|
||||
|
||||
| Topology | VAE patch parallel |
|
||||
| :------- | :----------------- |
|
||||
| **Single-stage** (DiT only) | Supported on stage 0 (`BagelPipeline` + `DistributedAutoEncoder`) |
|
||||
| **Two-stage** | Supported on **stage 1 (DiT)** only; stage 0 (Thinker) uses encoder-only VAE and is unrelated |
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- `vae_patch_parallel_size > 1` and a distributed VAE (`DistributedAutoEncoder` on the DiT pipeline).
|
||||
- The DiT process group must have at least `vae_patch_parallel_size` ranks. In practice this means the diffusion stage `world_size` must be ≥ 2 (commonly `tensor_parallel_size=2` on that stage).
|
||||
- `vae_use_tiling` must be enabled. If you set `vae_patch_parallel_size > 1` and omit tiling, the registry auto-enables `vae_use_tiling` at startup.
|
||||
|
||||
VAE patch parallel **reuses the DiT process group** (`dit_group`); it does not create a separate VAE-only worker pool. It is not a substitute for single-GPU VAE tiling (`vae_pp=1`).
|
||||
|
||||
**Online serving (single-stage, 2 GPUs):**
|
||||
|
||||
```bash
|
||||
CUDA_VISIBLE_DEVICES=0,1 vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --port 8091 \
|
||||
--deploy-config vllm_omni/deploy/bagel_single_stage.yaml \
|
||||
--tensor-parallel-size 2 \
|
||||
--vae-patch-parallel-size 2 \
|
||||
--vae-use-tiling
|
||||
```
|
||||
|
||||
**Online serving (two-stage, VAE PP on DiT stage 1):** use a custom deploy YAML, for example:
|
||||
|
||||
```yaml
|
||||
stages:
|
||||
- stage_id: 0
|
||||
devices: "0"
|
||||
# Thinker (AR) — no VAE patch parallel here
|
||||
|
||||
- stage_id: 1
|
||||
devices: "0,1"
|
||||
vae_use_tiling: true
|
||||
parallel_config:
|
||||
tensor_parallel_size: 2
|
||||
vae_patch_parallel_size: 2
|
||||
```
|
||||
|
||||
```bash
|
||||
vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --port 8091 \
|
||||
--deploy-config /path/to/bagel_vae_pp.yaml
|
||||
```
|
||||
|
||||
**Verify it is active** (check server logs at startup):
|
||||
|
||||
```text
|
||||
INFO ... vae_patch_parallel_size=2 requires vae_use_tiling; automatically enabling it.
|
||||
```
|
||||
|
||||
| CLI flag | Default | Description |
|
||||
| :------- | :------ | :---------- |
|
||||
| `--vae-patch-parallel-size` | `1` | Number of DiT ranks used for VAE tile parallelism. Set to `2` or higher to enable. Should be ≤ DiT process group size (typically match `--tensor-parallel-size` on the diffusion stage). |
|
||||
| `--vae-use-tiling` | off | Enable VAE spatial tiling. Required for VAE patch parallel (auto-enabled when `vae_patch_parallel_size > 1`). |
|
||||
|
||||
#### Hybrid Sharded Data Parallel (HSDP)
|
||||
|
||||
For larger Bagel deployments on multiple GPUs, you can enable HSDP (Hybrid Sharded Data Parallel) by modifying the stage configuration (for example, [`bagel.yaml`](../../../vllm_omni/deploy/bagel.yaml)). HSDP shards transformer weights across GPUs to reduce per-GPU memory usage.
|
||||
|
||||
1. **Enable HSDP**: Set `use_hsdp: true`.
|
||||
2. **Set shard size**: Set `hsdp_shard_size` to the number of GPUs used for sharding (for example, `4`).
|
||||
3. **Set replicate size**: Usually keep `hsdp_replicate_size: 1` unless you want replicated HSDP groups.
|
||||
4. **Set devices**: Specify the comma-separated GPU IDs used by the diffusion stage (for example, `"0,1,2,3"`).
|
||||
|
||||
Example configuration for HSDP across 4 GPUs:
|
||||
```yaml
|
||||
- stage_id: 1
|
||||
devices: "0,1,2,3"
|
||||
parallel_config:
|
||||
use_hsdp: true
|
||||
hsdp_shard_size: 4
|
||||
hsdp_replicate_size: 1
|
||||
```
|
||||
|
||||
### Multi-Node Deployment
|
||||
|
||||
Deploy each stage on a **separate node** for better resource utilization. Replace `<ORCHESTRATOR_IP>` with the actual IP address of your orchestrator node.
|
||||
|
||||
**1. Launch Stage 0 (Thinker / Orchestrator)** on the orchestrator node:
|
||||
|
||||
```bash
|
||||
# API server port for client requests: 8000
|
||||
vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni \
|
||||
--port 8000 \
|
||||
--stage-id 0 \
|
||||
--omni-master-address <ORCHESTRATOR_IP> \
|
||||
--omni-master-port 8091
|
||||
```
|
||||
|
||||
**2. Launch Stage 1 (DiT)** on the remote node in headless mode:
|
||||
|
||||
```bash
|
||||
vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni \
|
||||
--stage-id 1 \
|
||||
--headless \
|
||||
--omni-master-address <ORCHESTRATOR_IP> \
|
||||
--omni-master-port 8091
|
||||
```
|
||||
|
||||
Or use the convenience script:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Stage 0
|
||||
bash run_server_stage_cli.sh --stage 0
|
||||
|
||||
# Terminal 2: Stage 1
|
||||
bash run_server_stage_cli.sh --stage 1
|
||||
|
||||
# With extra args
|
||||
bash run_server_stage_cli.sh --stage 0 -- --tensor-parallel-size 2
|
||||
bash run_server_stage_cli.sh --stage 1 -- --gpu-memory-utilization 0.9
|
||||
```
|
||||
|
||||
**vllm serve arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
| :------- | :---------- |
|
||||
| `--stage-id` | Which stage this process runs (0 = Thinker, 1 = DiT) |
|
||||
| `--headless` | Run without the API server (worker-only mode) |
|
||||
| `-oma` / `--omni-master-address` | Orchestrator master address |
|
||||
| `-omp` / `--omni-master-port` | Orchestrator master port |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Startup Order**: Stage 0 (orchestrator) must be launched **before** Stage 1 (headless).
|
||||
> Stage 0 will appear to hang on startup until Stage 1 (worker) connects — this is expected behavior.
|
||||
|
||||
### Inter-Stage Connectors
|
||||
|
||||
When deploying stages across nodes, configure the connector type in the deploy YAML:
|
||||
|
||||
- **SharedMemoryConnector** (default): Used for single-node deployments. No explicit configuration needed.
|
||||
- **MooncakeTransferEngineConnector**: For multi-node setups with RDMA hardware. Defined in [`bagel.yaml`](../../../vllm_omni/deploy/bagel.yaml) under `connectors.rdma_connector`.
|
||||
|
||||
To use Mooncake, create a custom deploy YAML that binds `output_connectors` / `input_connectors` on each stage to the `rdma_connector` defined in the `connectors` section.
|
||||
|
||||
## Send Requests
|
||||
|
||||
```bash
|
||||
cd examples/online_serving/bagel
|
||||
```
|
||||
|
||||
### Text to Image (text2img)
|
||||
|
||||
**Python client:**
|
||||
|
||||
```bash
|
||||
python openai_chat_client.py \
|
||||
--prompt "A beautiful sunset over mountains" \
|
||||
--modality text2img \
|
||||
--output sunset.png \
|
||||
--steps 50
|
||||
```
|
||||
|
||||
**curl:**
|
||||
|
||||
```bash
|
||||
curl http://localhost:8091/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"messages": [{"role": "user", "content": [{"type": "text", "text": "<|im_start|>A beautiful sunset over mountains<|im_end|>"}]}],
|
||||
"modalities": ["image"],
|
||||
"height": 512,
|
||||
"width": 512,
|
||||
"num_inference_steps": 50,
|
||||
"seed": 42
|
||||
}'
|
||||
```
|
||||
|
||||
### Image to Image (img2img)
|
||||
|
||||
**Python client:**
|
||||
|
||||
```bash
|
||||
python openai_chat_client.py \
|
||||
--prompt "Make the cat stand up" \
|
||||
--modality img2img \
|
||||
--image-url /path/to/input.jpg \
|
||||
--output transformed.png
|
||||
```
|
||||
|
||||
**curl:**
|
||||
|
||||
```bash
|
||||
IMAGE_BASE64=$(base64 -w 0 cat.jpg)
|
||||
|
||||
cat <<EOF > payload.json
|
||||
{
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "<|im_start|>Make the cat stand up<|im_end|>"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,${IMAGE_BASE64}"}}
|
||||
]
|
||||
}],
|
||||
"modalities": ["image"],
|
||||
"height": 512,
|
||||
"width": 512,
|
||||
"num_inference_steps": 50,
|
||||
"seed": 42
|
||||
}
|
||||
EOF
|
||||
|
||||
curl http://localhost:8091/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @payload.json
|
||||
```
|
||||
|
||||
### Image to Text (img2text)
|
||||
|
||||
**Python client:**
|
||||
|
||||
```bash
|
||||
python openai_chat_client.py \
|
||||
--prompt "Describe this image in detail" \
|
||||
--modality img2text \
|
||||
--image-url /path/to/image.jpg
|
||||
```
|
||||
|
||||
**curl:**
|
||||
|
||||
```bash
|
||||
IMAGE_BASE64=$(base64 -w 0 cat.jpg)
|
||||
|
||||
cat <<EOF > payload.json
|
||||
{
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "<|im_start|>user\n<|image_pad|>\nDescribe this image in detail<|im_end|>\n<|im_start|>assistant\n"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,${IMAGE_BASE64}"}}
|
||||
]
|
||||
}],
|
||||
"modalities": ["text"]
|
||||
}
|
||||
EOF
|
||||
|
||||
curl http://localhost:8091/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @payload.json
|
||||
```
|
||||
|
||||
### Text to Text (text2text)
|
||||
|
||||
**Python client:**
|
||||
|
||||
```bash
|
||||
python openai_chat_client.py \
|
||||
--prompt "What is the capital of France?" \
|
||||
--modality text2text
|
||||
```
|
||||
|
||||
**curl:**
|
||||
|
||||
```bash
|
||||
curl http://localhost:8091/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"messages": [{"role": "user", "content": [{"type": "text", "text": "<|im_start|>user\nWhat is the capital of France?<|im_end|>\n<|im_start|>assistant\n"}]}],
|
||||
"modalities": ["text"]
|
||||
}'
|
||||
```
|
||||
|
||||
### Python Client Arguments
|
||||
|
||||
| Argument | Default | Description |
|
||||
| :------- | :------ | :---------- |
|
||||
| `--prompt` / `-p` | `A cute cat` | Text prompt |
|
||||
| `--output` / `-o` | `bagel_output.png` | Output file path |
|
||||
| `--server` / `-s` | `http://localhost:8091` | Server URL |
|
||||
| `--image-url` / `-i` | `None` | Input image URL or local path (img2img/img2text) |
|
||||
| `--modality` / `-m` | `text2img` | `text2img`, `img2img`, `img2text`, `text2text` |
|
||||
| `--height` | `512` | Image height in pixels |
|
||||
| `--width` | `512` | Image width in pixels |
|
||||
| `--steps` | `25` | Number of inference steps |
|
||||
| `--seed` | `42` | Random seed |
|
||||
| `--negative` | `None` | Negative prompt for CFG |
|
||||
|
||||
Example with custom parameters:
|
||||
|
||||
```bash
|
||||
python openai_chat_client.py \
|
||||
--prompt "A futuristic city" \
|
||||
--modality text2img \
|
||||
--height 768 \
|
||||
--width 768 \
|
||||
--steps 50 \
|
||||
--seed 42 \
|
||||
--negative "blurry, low quality"
|
||||
```
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### Deploy YAML Files
|
||||
|
||||
| File | Description |
|
||||
| :--- | :---------- |
|
||||
| [`bagel.yaml`](../../../vllm_omni/deploy/bagel.yaml) | Two-stage default (Thinker + DiT on GPU 0) |
|
||||
| [`bagel_single_stage.yaml`](../../../vllm_omni/deploy/bagel_single_stage.yaml) | Single-stage (DiT only) |
|
||||
|
||||
### Key Deploy YAML Fields
|
||||
|
||||
| Field | Scope | Description |
|
||||
| :---- | :---- | :---------- |
|
||||
| `pipeline` | top-level | Override auto-detected pipeline (e.g. `bagel_single_stage`) |
|
||||
| `stages[].stage_id` | per-stage | Stage identifier (0, 1, ...) |
|
||||
| `stages[].devices` | per-stage | GPU device IDs (e.g. `"0"`, `"0,1"`) |
|
||||
| `stages[].max_num_seqs` | per-stage | Maximum concurrent sequences |
|
||||
| `stages[].gpu_memory_utilization` | per-stage | Fraction of GPU memory to use |
|
||||
| `stages[].enforce_eager` | per-stage | Disable CUDA graphs |
|
||||
| `stages[].tensor_parallel_size` | per-stage | TP degree for this stage |
|
||||
| `stages[].parallel_config.vae_patch_parallel_size` | per-stage (DiT) | VAE tile parallelism degree (DiT stage only) |
|
||||
| `stages[].vae_use_tiling` | per-stage (DiT) | Enable VAE tiling (required for VAE patch parallel) |
|
||||
| `connectors` | top-level | Define available connector instances (SHM, Mooncake) |
|
||||
| `platforms` | top-level | Platform-specific overrides (e.g. `xpu`) |
|
||||
|
||||
## FAQ
|
||||
|
||||
- If you encounter OOM errors, try decreasing `max_model_len` or `gpu_memory_utilization` in the deploy YAML.
|
||||
|
||||
**Two-stage VRAM usage:**
|
||||
|
||||
| Stage | VRAM |
|
||||
| :---- | :--- |
|
||||
| Stage 0 (Thinker) | **15.04 GiB + KV Cache** |
|
||||
| Stage 1 (DiT) | **26.50 GiB** |
|
||||
| Total | **~42 GiB + KV Cache** |
|
||||
|
||||
**Single-stage VRAM usage:** The DiT loads the full model (~42 GiB) in one process.
|
||||
@@ -1,184 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Bagel OpenAI-compatible chat client for image generation and multimodal tasks.
|
||||
|
||||
Usage:
|
||||
python openai_chat_client.py --prompt "A cute cat" --output output.png
|
||||
python openai_chat_client.py --prompt "Describe this image" --image-url https://example.com/image.png
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def generate_image(
|
||||
prompt: str,
|
||||
server_url: str = "http://localhost:8091",
|
||||
image_url: str | None = None,
|
||||
height: int | None = None,
|
||||
width: int | None = None,
|
||||
steps: int | None = None,
|
||||
seed: int | None = None,
|
||||
negative_prompt: str | None = None,
|
||||
modality: str = "text2img", # "text2img" (default), "img2img", "img2text", "text2text"
|
||||
) -> bytes | str | None:
|
||||
"""Generate an image or text using the chat completions API.
|
||||
|
||||
Args:
|
||||
prompt: Text description or prompt
|
||||
server_url: Server URL
|
||||
image_url: URL or path to input image (for img2img/img2text)
|
||||
height: Image height in pixels
|
||||
width: Image width in pixels
|
||||
steps: Number of inference steps
|
||||
seed: Random seed
|
||||
negative_prompt: Negative prompt
|
||||
modality: Task modality hint
|
||||
|
||||
Returns:
|
||||
Image bytes (for image outputs) or Text string (for text outputs) or None if failed
|
||||
"""
|
||||
|
||||
# Construct Message Content
|
||||
content = [{"type": "text", "text": f"<|im_start|>{prompt}<|im_end|>"}]
|
||||
|
||||
if image_url:
|
||||
# Check if local file
|
||||
if Path(image_url).exists():
|
||||
with open(image_url, "rb") as f:
|
||||
b64_data = base64.b64encode(f.read()).decode("utf-8")
|
||||
final_image_url = f"data:image/jpeg;base64,{b64_data}"
|
||||
else:
|
||||
final_image_url = image_url
|
||||
|
||||
content.append({"type": "image_url", "image_url": {"url": final_image_url}})
|
||||
|
||||
messages = [{"role": "user", "content": content}]
|
||||
|
||||
# Build request payload with all parameters at top level
|
||||
# Note: vLLM ignores "extra_body", so we put parameters directly in the payload
|
||||
payload = {"messages": messages}
|
||||
|
||||
# Set output modalities at top level
|
||||
if modality == "text2img" or modality == "img2img":
|
||||
payload["modalities"] = ["image"]
|
||||
elif modality == "img2text" or modality == "text2text":
|
||||
payload["modalities"] = ["text"]
|
||||
|
||||
# Add generation parameters directly to payload
|
||||
if height is not None:
|
||||
payload["height"] = height
|
||||
if width is not None:
|
||||
payload["width"] = width
|
||||
if steps is not None:
|
||||
payload["num_inference_steps"] = steps
|
||||
if seed is not None:
|
||||
payload["seed"] = seed
|
||||
if negative_prompt:
|
||||
payload["negative_prompt"] = negative_prompt
|
||||
|
||||
# Send request
|
||||
try:
|
||||
print(f"Sending request to {server_url} with modality {modality}...")
|
||||
response = requests.post(
|
||||
f"{server_url}/v1/chat/completions",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json=payload,
|
||||
timeout=300,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Extract content - check ALL choices since server may return multiple
|
||||
# (e.g., text in choices[0], image in choices[1])
|
||||
choices = data.get("choices", [])
|
||||
|
||||
# First pass: look for image output in any choice
|
||||
for choice in choices:
|
||||
choice_content = choice.get("message", {}).get("content")
|
||||
|
||||
# Handle Image Output
|
||||
if isinstance(choice_content, list) and len(choice_content) > 0:
|
||||
first_item = choice_content[0]
|
||||
if isinstance(first_item, dict) and "image_url" in first_item:
|
||||
img_url_str = first_item["image_url"].get("url", "")
|
||||
if img_url_str.startswith("data:image"):
|
||||
_, b64_data = img_url_str.split(",", 1)
|
||||
return base64.b64decode(b64_data)
|
||||
|
||||
# Second pass: look for text output if no image found
|
||||
for choice in choices:
|
||||
choice_content = choice.get("message", {}).get("content")
|
||||
if isinstance(choice_content, str) and choice_content:
|
||||
return choice_content
|
||||
|
||||
print(f"Unexpected response format: {choices}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Bagel multimodal chat client")
|
||||
parser.add_argument("--prompt", "-p", default="A cute cat", help="Text prompt")
|
||||
parser.add_argument("--output", "-o", default="bagel_output.png", help="Output file (for image results)")
|
||||
parser.add_argument("--server", "-s", default="http://localhost:8091", help="Server URL")
|
||||
|
||||
# Modality Control
|
||||
parser.add_argument("--image-url", "-i", type=str, help="Input image URL or local path")
|
||||
parser.add_argument(
|
||||
"--modality",
|
||||
"-m",
|
||||
default="text2img",
|
||||
choices=["text2img", "img2img", "img2text", "text2text"],
|
||||
help="Task modality",
|
||||
)
|
||||
|
||||
# Generation Params
|
||||
parser.add_argument("--height", type=int, default=512, help="Image height")
|
||||
parser.add_argument("--width", type=int, default=512, help="Image width")
|
||||
parser.add_argument("--steps", type=int, default=25, help="Inference steps")
|
||||
parser.add_argument("--seed", type=int, default=42, help="Random seed")
|
||||
parser.add_argument("--negative", help="Negative prompt")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"Mode: {args.modality}")
|
||||
if args.image_url:
|
||||
print(f"Input Image: {args.image_url}")
|
||||
|
||||
result = generate_image(
|
||||
prompt=args.prompt,
|
||||
server_url=args.server,
|
||||
image_url=args.image_url,
|
||||
height=args.height,
|
||||
width=args.width,
|
||||
steps=args.steps,
|
||||
seed=args.seed,
|
||||
negative_prompt=args.negative,
|
||||
modality=args.modality,
|
||||
)
|
||||
|
||||
if result:
|
||||
if isinstance(result, bytes):
|
||||
# It's an image
|
||||
output_path = Path(args.output)
|
||||
output_path.write_bytes(result)
|
||||
print(f"Image saved to: {output_path}")
|
||||
print(f"Size: {len(result) / 1024:.1f} KB")
|
||||
elif isinstance(result, str):
|
||||
# It's text
|
||||
print("Response:")
|
||||
print(result)
|
||||
else:
|
||||
print("Failed to generate response")
|
||||
exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Bagel online serving startup script
|
||||
|
||||
MODEL="${MODEL:-ByteDance-Seed/BAGEL-7B-MoT}"
|
||||
PORT="${PORT:-8091}"
|
||||
|
||||
echo "Starting Bagel server..."
|
||||
echo "Model: $MODEL"
|
||||
echo "Port: $PORT"
|
||||
|
||||
vllm serve "$MODEL" --omni \
|
||||
--port "$PORT"
|
||||
@@ -1,164 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Bagel multi-stage online serving startup script.
|
||||
#
|
||||
# Usage:
|
||||
# ./run_server_stage_cli.sh --stage 0
|
||||
# ./run_server_stage_cli.sh --stage 1
|
||||
# ./run_server_stage_cli.sh --stage 0 -- --tensor-parallel-size 2
|
||||
# ./run_server_stage_cli.sh --stage 1 -- --gpu-memory-utilization 0.9
|
||||
#
|
||||
# By default, `--stage all` keeps the old behavior and launches both stages in
|
||||
# one session. Use `--stage 0` / `--stage 1` to launch each stage separately in
|
||||
# different terminal sessions, with stage-specific extra CLI arguments passed
|
||||
# after `--`.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MODEL="${MODEL:-ByteDance-Seed/BAGEL-7B-MoT}"
|
||||
PORT="${PORT:-8091}"
|
||||
MASTER_ADDRESS="${MASTER_ADDRESS:-127.0.0.1}"
|
||||
MASTER_PORT="${MASTER_PORT:-8092}"
|
||||
STAGE="all"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEPLOY_CONFIG="${DEPLOY_CONFIG:-$SCRIPT_DIR/../../../vllm_omni/deploy/bagel.yaml}"
|
||||
EXTRA_ARGS=()
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 [OPTIONS] [-- EXTRA_VLLM_ARGS...]
|
||||
|
||||
Options:
|
||||
--stage {0|1|all} Stage to launch (default: all)
|
||||
--model MODEL Model name/path (default: $MODEL)
|
||||
--port PORT API port for stage 0 (default: $PORT)
|
||||
--master-address ADDRESS Master/orchestrator address (default: $MASTER_ADDRESS)
|
||||
--master-port PORT Master/orchestrator port (default: $MASTER_PORT)
|
||||
--deploy-config PATH Deploy config YAML path (default: $DEPLOY_CONFIG)
|
||||
--help Show this help message
|
||||
|
||||
Examples:
|
||||
$0 --stage 0
|
||||
$0 --stage 1
|
||||
$0 --stage 0 -- --tensor-parallel-size 2
|
||||
$0 --stage 1 -- --gpu-memory-utilization 0.9
|
||||
|
||||
Notes:
|
||||
- Use different terminal sessions to launch stage 0 and stage 1 separately.
|
||||
- Extra args after '--' are forwarded only to the selected stage.
|
||||
- When using '--stage all', the extra args are forwarded to both stages.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--stage)
|
||||
STAGE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--model)
|
||||
MODEL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--port)
|
||||
PORT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--master-address)
|
||||
MASTER_ADDRESS="$2"
|
||||
shift 2
|
||||
;;
|
||||
--master-port)
|
||||
MASTER_PORT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--deploy-config)
|
||||
DEPLOY_CONFIG="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
EXTRA_ARGS=("$@")
|
||||
break
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$STAGE" != "0" && "$STAGE" != "1" && "$STAGE" != "all" ]]; then
|
||||
echo "Invalid --stage value: $STAGE" >&2
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_config() {
|
||||
echo "Model: $MODEL"
|
||||
echo "API Port: $PORT"
|
||||
echo "Master Address: $MASTER_ADDRESS"
|
||||
echo "Master Port: $MASTER_PORT"
|
||||
echo "Deploy Config: $DEPLOY_CONFIG"
|
||||
echo "Selected Stage: $STAGE"
|
||||
if [[ ${#EXTRA_ARGS[@]} -gt 0 ]]; then
|
||||
echo "Extra Args: ${EXTRA_ARGS[*]}"
|
||||
fi
|
||||
}
|
||||
|
||||
run_stage_0() {
|
||||
echo "Starting Stage 0 (Thinker) as master..."
|
||||
vllm serve "$MODEL" --omni \
|
||||
--port "$PORT" \
|
||||
--deploy-config "$DEPLOY_CONFIG" \
|
||||
--stage-id 0 \
|
||||
--omni-master-address "$MASTER_ADDRESS" \
|
||||
--omni-master-port "$MASTER_PORT" \
|
||||
"${EXTRA_ARGS[@]}"
|
||||
}
|
||||
|
||||
run_stage_1() {
|
||||
echo "Starting Stage 1 (DiT) in headless mode..."
|
||||
vllm serve "$MODEL" --omni \
|
||||
--deploy-config "$DEPLOY_CONFIG" \
|
||||
--stage-id 1 \
|
||||
--headless \
|
||||
--omni-master-address "$MASTER_ADDRESS" \
|
||||
--omni-master-port "$MASTER_PORT" \
|
||||
"${EXTRA_ARGS[@]}"
|
||||
}
|
||||
|
||||
echo "Starting Bagel multi-stage server..."
|
||||
print_config
|
||||
|
||||
case "$STAGE" in
|
||||
0)
|
||||
run_stage_0
|
||||
;;
|
||||
1)
|
||||
run_stage_1
|
||||
;;
|
||||
all)
|
||||
echo "Launching both stages in one session (legacy mode)..."
|
||||
echo "Starting Stage 0 (Thinker) in background first..."
|
||||
run_stage_0 &
|
||||
STAGE_0_PID=$!
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${STAGE_0_PID:-}" ]]; then
|
||||
kill "$STAGE_0_PID" 2>/dev/null || true
|
||||
wait "$STAGE_0_PID" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
echo "Waiting briefly for Stage 0 to initialize..."
|
||||
sleep 2
|
||||
run_stage_1
|
||||
;;
|
||||
esac
|
||||
@@ -1,6 +1,8 @@
|
||||
# Image-To-Image
|
||||
|
||||
This example demonstrates how to deploy Qwen-Image-Edit model for online image editing service using vLLM-Omni.
|
||||
This example demonstrates how to deploy image-to-image models for online image editing service using vLLM-Omni.
|
||||
|
||||
Supported models include Qwen-Image-Edit, BAGEL, and other image-to-image pipelines.
|
||||
|
||||
For **multi-image** input editing, use **Qwen-Image-Edit-2509** (QwenImageEditPlusPipeline) and send multiple images in the user message content.
|
||||
|
||||
@@ -21,6 +23,12 @@ vllm serve Qwen/Qwen-Image-Edit --omni --port 8092
|
||||
vllm serve Qwen/Qwen-Image-Edit-2509 --omni --port 8092
|
||||
```
|
||||
|
||||
### BAGEL
|
||||
|
||||
```bash
|
||||
vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --port 8091
|
||||
```
|
||||
|
||||
### Start with Parameters
|
||||
|
||||
|
||||
@@ -119,6 +127,16 @@ python openai_chat_client.py --input input.png --prompt "Convert to oil painting
|
||||
python openai_chat_client.py --input input1.png input2.png --prompt "Combine these images into a single scene" --output output.png
|
||||
```
|
||||
|
||||
Pass model-specific parameters through `--extra-body` (e.g. for BAGEL):
|
||||
|
||||
```bash
|
||||
python openai_chat_client.py \
|
||||
--input input.png \
|
||||
--prompt "Make the scene look like a watercolor painting" \
|
||||
--server http://localhost:8091 \
|
||||
--extra-body '{"cfg_text_scale": 4.0, "cfg_img_scale": 1.5}'
|
||||
```
|
||||
|
||||
### Method 4: Using Gradio Demo
|
||||
|
||||
```bash
|
||||
@@ -318,6 +336,9 @@ count, use `size` and `n` rather than `height`, `width`, or
|
||||
| `layers` | int | 4 | Number of layers (Qwen-Image-Layered) |
|
||||
| `resolution` | int | 640 | Resolution, 640 or 1024 (Qwen-Image-Layered) |
|
||||
|
||||
Models like BAGEL accept additional parameters via `extra_body` (e.g.
|
||||
`cfg_text_scale`, `cfg_img_scale`). See the [BAGEL recipe](../../../recipes/Bagel/BAGEL-7B-MoT.md) for the full list.
|
||||
|
||||
## Response Format
|
||||
|
||||
```json
|
||||
|
||||
@@ -38,6 +38,7 @@ def edit_image(
|
||||
guidance_scale: float | None = None,
|
||||
seed: int | None = None,
|
||||
negative_prompt: str | None = None,
|
||||
extra_body: dict | None = None,
|
||||
) -> bytes | None:
|
||||
"""Edit an image using the chat completions API.
|
||||
|
||||
@@ -51,6 +52,7 @@ def edit_image(
|
||||
guidance_scale: CFG guidance scale
|
||||
seed: Random seed
|
||||
negative_prompt: Negative prompt
|
||||
extra_body: Additional model-specific params (e.g. cfg_text_scale for BAGEL)
|
||||
|
||||
Returns:
|
||||
Edited image bytes or None if failed
|
||||
@@ -75,24 +77,26 @@ def edit_image(
|
||||
]
|
||||
|
||||
# Build extra_body with generation parameters
|
||||
extra_body = {}
|
||||
merged_extra_body: dict[str, object] = {}
|
||||
if height is not None:
|
||||
extra_body["height"] = height
|
||||
merged_extra_body["height"] = height
|
||||
if width is not None:
|
||||
extra_body["width"] = width
|
||||
merged_extra_body["width"] = width
|
||||
if steps is not None:
|
||||
extra_body["num_inference_steps"] = steps
|
||||
merged_extra_body["num_inference_steps"] = steps
|
||||
if guidance_scale is not None:
|
||||
extra_body["guidance_scale"] = guidance_scale
|
||||
merged_extra_body["guidance_scale"] = guidance_scale
|
||||
if seed is not None:
|
||||
extra_body["seed"] = seed
|
||||
merged_extra_body["seed"] = seed
|
||||
if negative_prompt:
|
||||
extra_body["negative_prompt"] = negative_prompt
|
||||
merged_extra_body["negative_prompt"] = negative_prompt
|
||||
if extra_body:
|
||||
merged_extra_body.update(extra_body)
|
||||
|
||||
# Build request payload
|
||||
payload = {"messages": messages}
|
||||
if extra_body:
|
||||
payload["extra_body"] = extra_body
|
||||
payload: dict[str, object] = {"messages": messages}
|
||||
if merged_extra_body:
|
||||
payload["extra_body"] = merged_extra_body
|
||||
|
||||
# Send request
|
||||
try:
|
||||
@@ -121,6 +125,19 @@ def edit_image(
|
||||
return None
|
||||
|
||||
|
||||
def parse_extra_body(value: str) -> dict:
|
||||
"""Parse a JSON string into a dict for --extra-body."""
|
||||
import json
|
||||
|
||||
try:
|
||||
obj = json.loads(value)
|
||||
except json.JSONDecodeError as e:
|
||||
raise argparse.ArgumentTypeError(f"--extra-body must be valid JSON: {e}") from e
|
||||
if not isinstance(obj, dict):
|
||||
raise argparse.ArgumentTypeError("--extra-body must be a JSON object")
|
||||
return obj
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Qwen-Image-Edit chat client")
|
||||
parser.add_argument("--input", "-i", required=True, nargs="+", help="Input image path(s)")
|
||||
@@ -133,6 +150,12 @@ def main():
|
||||
parser.add_argument("--guidance", type=float, default=7.5, help="Guidance scale")
|
||||
parser.add_argument("--seed", type=int, default=0, help="Random seed")
|
||||
parser.add_argument("--negative", help="Negative prompt")
|
||||
parser.add_argument(
|
||||
"--extra-body",
|
||||
type=parse_extra_body,
|
||||
default=None,
|
||||
help="JSON object merged into request extra_body, e.g. '{\"cfg_text_scale\": 4.0}'.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -152,6 +175,7 @@ def main():
|
||||
guidance_scale=args.guidance,
|
||||
seed=args.seed,
|
||||
negative_prompt=args.negative,
|
||||
extra_body=args.extra_body,
|
||||
)
|
||||
|
||||
if image_bytes:
|
||||
|
||||
@@ -120,14 +120,17 @@ Use `/v1/videos/sync` if you want to write the MP4 directly to a file. `POST /v1
|
||||
Generated video files are stored on local disk by the async video API.
|
||||
Local file storage behavior can be controlled via the following environment variables:
|
||||
|
||||
- `VLLM_OMNI_STORAGE_PATH`: directory used for generated files (default: `/tmp/storage`)
|
||||
- `VLLM_OMNI_STORAGE_MAX_CONCURRENCY`: max concurrent save/delete operations (default: `4`)
|
||||
- `VLLM_OMNI_SERVER_STORAGE__PATH`: directory used for generated files (default: `/tmp/storage`)
|
||||
- `VLLM_OMNI_SERVER_STORAGE__FILE_CONCURRENCY`: max concurrent save/delete operations (default: `4`)
|
||||
|
||||
`VLLM_OMNI_STORAGE_PATH` and `VLLM_OMNI_STORAGE_MAX_CONCURRENCY` are deprecated and will be
|
||||
removed in a future release; use the names above instead.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
export VLLM_OMNI_STORAGE_PATH=/var/tmp/vllm-omni-videos
|
||||
export VLLM_OMNI_STORAGE_MAX_CONCURRENCY=8
|
||||
export VLLM_OMNI_SERVER_STORAGE__PATH=/var/tmp/vllm-omni-videos
|
||||
export VLLM_OMNI_SERVER_STORAGE__FILE_CONCURRENCY=8
|
||||
```
|
||||
|
||||
## API Calls
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Default model name
|
||||
MODEL="${MODEL:-Qwen/Qwen2.5-Omni-7B}"
|
||||
|
||||
# Default query type
|
||||
QUERY_TYPE="${1:-mixed_modalities}"
|
||||
|
||||
@@ -162,7 +165,7 @@ echo ""
|
||||
|
||||
request_body=$(cat <<EOF
|
||||
{
|
||||
"model": "Qwen/Qwen2.5-Omni-7B",
|
||||
"model": "$MODEL",
|
||||
"sampling_params_list": $sampling_params_list,
|
||||
"mm_processor_kwargs": $mm_processor_kwargs,
|
||||
"modalities": $MODALITIES,
|
||||
|
||||
@@ -24,12 +24,15 @@ vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 \
|
||||
--deploy-config /path/to/your_deploy_config.yaml
|
||||
```
|
||||
|
||||
For the bundled 3x-GPU multi-replica layout (talker/code2wav scale-out),
|
||||
use:
|
||||
For a 3x-GPU multi-replica layout (talker/code2wav scale-out on cuda:1,2),
|
||||
use `--stage-overrides` on top of the default config:
|
||||
|
||||
```bash
|
||||
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 \
|
||||
--deploy-config vllm_omni/deploy/qwen3_omni_moe_multi_replicas.yaml
|
||||
--stage-overrides '{
|
||||
"1": {"num_replicas": 2, "devices": "1,2"},
|
||||
"2": {"num_replicas": 2, "devices": "1,2"}
|
||||
}'
|
||||
```
|
||||
|
||||
### Launch individual stages (stage-based CLI)
|
||||
|
||||
@@ -20,7 +20,7 @@ stage_args:
|
||||
trust_remote_code: true
|
||||
engine_output_type: text
|
||||
distributed_executor_backend: "mp"
|
||||
enable_prefix_caching: false
|
||||
enable_prefix_caching: true
|
||||
hf_config_name: thinker_config
|
||||
tensor_parallel_size: 2
|
||||
final_output: true
|
||||
|
||||
@@ -48,7 +48,7 @@ def generate(
|
||||
forwarded as top-level fields in the request payload. The serving layer
|
||||
maps standard keys (height, width, seed, num_inference_steps) to sampling
|
||||
params and forwards model-specific keys (think, cfg_norm, img_cfg_scale,
|
||||
…) to ``extra_args`` based on the pipeline's ``EXTRA_BODY_PARAMS``.
|
||||
…) to ``extra_args`` based on the model extra registry.
|
||||
|
||||
Returns:
|
||||
Image bytes (for image outputs) or text string (for text outputs).
|
||||
@@ -131,7 +131,7 @@ def main():
|
||||
parser.add_argument("--width", type=int, default=2048, help="Image width")
|
||||
parser.add_argument("--num-steps", type=int, default=50, help="Inference steps")
|
||||
parser.add_argument("--seed", type=int, default=42, help="Random seed")
|
||||
# Model-specific parameters (forwarded via EXTRA_BODY_PARAMS)
|
||||
# Model-specific parameters forwarded via the model extra registry.
|
||||
parser.add_argument("--cfg-scale", type=float, default=4.0, help="CFG scale")
|
||||
parser.add_argument("--img-cfg-scale", type=float, help="Image CFG scale (img2img)")
|
||||
parser.add_argument("--cfg-norm", type=str, help="CFG normalization mode")
|
||||
|
||||
@@ -87,14 +87,17 @@ curl -X POST http://localhost:8091/v1/videos/sync \
|
||||
Generated video files are stored on local disk by the async video API.
|
||||
Local file storage behavior can be controlled via the following environment variables:
|
||||
|
||||
- `VLLM_OMNI_STORAGE_PATH`: directory used for generated files (default: `/tmp/storage`)
|
||||
- `VLLM_OMNI_STORAGE_MAX_CONCURRENCY`: max concurrent save/delete operations (default: `4`)
|
||||
- `VLLM_OMNI_SERVER_STORAGE__PATH`: directory used for generated files (default: `/tmp/storage`)
|
||||
- `VLLM_OMNI_SERVER_STORAGE__FILE_CONCURRENCY`: max concurrent save/delete operations (default: `4`)
|
||||
|
||||
`VLLM_OMNI_STORAGE_PATH` and `VLLM_OMNI_STORAGE_MAX_CONCURRENCY` are deprecated and will be
|
||||
removed in a future release; use the names above instead.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
export VLLM_OMNI_STORAGE_PATH=/var/tmp/vllm-omni-videos
|
||||
export VLLM_OMNI_STORAGE_MAX_CONCURRENCY=8
|
||||
export VLLM_OMNI_SERVER_STORAGE__PATH=/var/tmp/vllm-omni-videos
|
||||
export VLLM_OMNI_SERVER_STORAGE__FILE_CONCURRENCY=8
|
||||
```
|
||||
|
||||
## API Calls
|
||||
|
||||
@@ -228,6 +228,7 @@ markers = [
|
||||
"tts: TTS model tests",
|
||||
"cache: Cache backend tests",
|
||||
"parallel: Parallelism/distributed tests",
|
||||
"sp: Sequence parallelism tests",
|
||||
"example: Doc example code tests",
|
||||
# platform markers
|
||||
"cpu: Tests that run on CPU",
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
# BAGEL-7B-MoT
|
||||
|
||||
> BAGEL image generation through the shared online and offline image examples
|
||||
|
||||
## Summary
|
||||
|
||||
- Vendor: ByteDance Seed
|
||||
- Model: `ByteDance-Seed/BAGEL-7B-MoT`
|
||||
- Task: Text-to-image and image-to-image generation
|
||||
- Mode: Offline inference and OpenAI-compatible online serving
|
||||
- Maintainer: Community
|
||||
|
||||
## When to use this recipe
|
||||
|
||||
Use this recipe when you want to run BAGEL through the shared image examples
|
||||
instead of model-specific example clients. The generic examples can format
|
||||
BAGEL text-to-image and image-to-image prompts, select the image output
|
||||
modality, attach reference images, and forward BAGEL-specific generation
|
||||
parameters through the pipeline-declared `extra_args` contract.
|
||||
|
||||
## References
|
||||
|
||||
- Upstream model:
|
||||
[`ByteDance-Seed/BAGEL-7B-MoT`](https://huggingface.co/ByteDance-Seed/BAGEL-7B-MoT)
|
||||
- Related offline example:
|
||||
[`examples/offline_inference/text_to_image/text_to_image.py`](../../examples/offline_inference/text_to_image/text_to_image.py)
|
||||
- Related offline image-to-image example:
|
||||
[`examples/offline_inference/image_to_image/image_edit.py`](../../examples/offline_inference/image_to_image/image_edit.py)
|
||||
- Related online example:
|
||||
[`examples/online_serving/text_to_image/openai_chat_client.py`](../../examples/online_serving/text_to_image/openai_chat_client.py)
|
||||
- Related online image-to-image example:
|
||||
[`examples/online_serving/image_to_image/openai_chat_client.py`](../../examples/online_serving/image_to_image/openai_chat_client.py)
|
||||
- Default deploy configs:
|
||||
[`vllm_omni/deploy/bagel.yaml`](../../vllm_omni/deploy/bagel.yaml),
|
||||
[`vllm_omni/deploy/bagel_single_stage.yaml`](../../vllm_omni/deploy/bagel_single_stage.yaml)
|
||||
|
||||
## Hardware Support
|
||||
|
||||
This recipe documents the CUDA layouts used by the in-repo BAGEL deploy
|
||||
configs. The default two-stage config shares one 80 GB GPU; for more headroom,
|
||||
move the diffusion stage to a second GPU in a custom deploy config.
|
||||
|
||||
## GPU
|
||||
|
||||
### 1x A100 80GB
|
||||
|
||||
#### Environment
|
||||
|
||||
- OS: Linux
|
||||
- Python: Match the repository requirements for your checkout
|
||||
- Driver / runtime: NVIDIA CUDA environment with one A100 80 GB GPU
|
||||
- vLLM version: Match the repository requirements for your checkout
|
||||
- vLLM-Omni version or commit: Use the commit you are deploying from
|
||||
|
||||
#### Offline Commands
|
||||
|
||||
Run text-to-image with the shared offline example from the repository root.
|
||||
Pick the topology with `--stage-configs-path` and forward BAGEL-specific
|
||||
generation parameters as a JSON object through `--extra-body`:
|
||||
|
||||
```bash
|
||||
# Two-stage (Thinker + DiT), shares one GPU by default
|
||||
python examples/offline_inference/text_to_image/text_to_image.py \
|
||||
--model ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--stage-configs-path vllm_omni/deploy/bagel.yaml \
|
||||
--prompt "A beautiful sunset over mountains" \
|
||||
--height 1024 \
|
||||
--width 1024 \
|
||||
--num-inference-steps 50 \
|
||||
--negative-prompt "blurry, low quality" \
|
||||
--seed 42 \
|
||||
--extra-body '{"timestep_shift": 3.0, "cfg_text_scale": 4.0, "cfg_img_scale": 1.5, "cfg_interval": [0.4, 1.0], "cfg_renorm_type": "global", "cfg_renorm_min": 0.0, "think": false}' \
|
||||
--output /tmp/bagel_text2img.png
|
||||
```
|
||||
|
||||
```bash
|
||||
# Single-stage (DiT only, with internal LLM/ViT/VAE)
|
||||
python examples/offline_inference/text_to_image/text_to_image.py \
|
||||
--model ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--stage-configs-path vllm_omni/deploy/bagel_single_stage.yaml \
|
||||
--prompt "A beautiful sunset over mountains" \
|
||||
--height 1024 \
|
||||
--width 1024 \
|
||||
--num-inference-steps 50 \
|
||||
--seed 42 \
|
||||
--extra-body '{"timestep_shift": 3.0, "cfg_text_scale": 4.0, "cfg_interval": [0.4, 1.0], "cfg_renorm_type": "global", "cfg_renorm_min": 0.0}' \
|
||||
--output /tmp/bagel_text2img_single.png
|
||||
```
|
||||
|
||||
The `--extra-body` JSON forwards BAGEL-specific parameters into
|
||||
`OmniDiffusionSamplingParams.extra_args`. Keys are filtered against the model's
|
||||
declared `extra_body_params` (see
|
||||
[`vllm_omni/model_extras/bagel.py`](../../vllm_omni/model_extras/bagel.py)), so
|
||||
unknown keys for BAGEL are silently dropped. This is the preferred way to pass
|
||||
model-specific params — `timestep_shift` (use `3.0` for 1024×1024; the
|
||||
deprecated `--timesteps-shift` flag defaults to `1.0` and is NextStep-oriented),
|
||||
`cfg_text_scale`, `cfg_img_scale`, `cfg_interval`, `cfg_renorm_type`,
|
||||
`cfg_renorm_min`, and `think`. `--seed` makes generation reproducible.
|
||||
|
||||
> **Note**: the two-stage `bagel.yaml` runs both stages on GPU 0. If you hit
|
||||
> CUDA OOM during warmup, lower stage-0 `gpu_memory_utilization` in the YAML or
|
||||
> split the stages across two GPUs (see the `2x CUDA GPUs` section).
|
||||
|
||||
Run image-to-image with the shared offline image-to-image example:
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/image_to_image/image_edit.py \
|
||||
--model ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--prompt "Make the scene look like a watercolor painting" \
|
||||
--image /tmp/bagel_text2img.png \
|
||||
--height 512 \
|
||||
--width 512 \
|
||||
--extra-args '{"cfg_text_scale": 4.0, "cfg_img_scale": 1.5}' \
|
||||
--output /tmp/bagel_img2img.png
|
||||
```
|
||||
|
||||
The `--extra-args` JSON forwards BAGEL-specific parameters (e.g. `cfg_text_scale`,
|
||||
`cfg_img_scale`, `cfg_interval`, `cfg_renorm_type`) into
|
||||
`OmniDiffusionSamplingParams.extra_args` via the model-extras registry.
|
||||
|
||||
#### Online Commands
|
||||
|
||||
Start the OpenAI-compatible server:
|
||||
|
||||
```bash
|
||||
vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --port 8091
|
||||
```
|
||||
|
||||
To use the single-stage topology online:
|
||||
|
||||
```bash
|
||||
vllm serve ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--omni \
|
||||
--port 8091 \
|
||||
--deploy-config vllm_omni/deploy/bagel_single_stage.yaml
|
||||
```
|
||||
|
||||
Send a text-to-image request with BAGEL-specific generation parameters:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8091/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "<|im_start|>A beautiful sunset over mountains<|im_end|>"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"modalities": ["image"],
|
||||
"extra_body": {
|
||||
"height": 512,
|
||||
"width": 512,
|
||||
"num_inference_steps": 50,
|
||||
"cfg_text_scale": 4.0,
|
||||
"cfg_img_scale": 1.5,
|
||||
"negative_prompt": "blurry, low quality",
|
||||
"seed": 42
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
The important part is that BAGEL-specific keys such as `cfg_text_scale`,
|
||||
`cfg_img_scale`, `cfg_interval`, `cfg_renorm_type`, and `cfg_renorm_min` belong
|
||||
in `extra_body`. The serving layer routes the declared keys into
|
||||
`OmniDiffusionSamplingParams.extra_args` for the BAGEL pipeline.
|
||||
|
||||
#### Verification
|
||||
|
||||
Decode the returned data URL into an image:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8091/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "<|im_start|>A ceramic teapot on a wooden table<|im_end|>"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"modalities": ["image"],
|
||||
"extra_body": {
|
||||
"height": 512,
|
||||
"width": 512,
|
||||
"num_inference_steps": 25,
|
||||
"cfg_text_scale": 4.0,
|
||||
"seed": 42
|
||||
}
|
||||
}' | jq -r '.choices[]?.message.content[]? | select(.image_url.url) | .image_url.url' \
|
||||
| head -n 1 \
|
||||
| cut -d',' -f2- \
|
||||
| base64 -d > /tmp/bagel_online.png
|
||||
|
||||
ls -lh /tmp/bagel_online.png
|
||||
```
|
||||
|
||||
### 2x CUDA GPUs
|
||||
|
||||
Create a custom deploy config from `vllm_omni/deploy/bagel.yaml` and move the
|
||||
diffusion stage to GPU 1:
|
||||
|
||||
```yaml
|
||||
stages:
|
||||
- stage_id: 0
|
||||
devices: "0"
|
||||
# keep the remaining stage-0 settings from bagel.yaml
|
||||
- stage_id: 1
|
||||
devices: "1"
|
||||
# keep the remaining stage-1 settings from bagel.yaml
|
||||
```
|
||||
|
||||
Then start serving with that config:
|
||||
|
||||
```bash
|
||||
vllm serve ByteDance-Seed/BAGEL-7B-MoT \
|
||||
--omni \
|
||||
--port 8091 \
|
||||
--deploy-config /path/to/custom_bagel_2gpu.yaml
|
||||
```
|
||||
|
||||
Use the online curl request from the `1x A100 80GB` section to verify that the
|
||||
server returns an image.
|
||||
@@ -0,0 +1,101 @@
|
||||
# GR00T-N1.7
|
||||
|
||||
> NVIDIA Isaac GR00T-N1.7-3B robot VLA policy served over the OpenPI WebSocket protocol
|
||||
|
||||
## Summary
|
||||
|
||||
- Vendor: NVIDIA
|
||||
- Model: `nvidia/GR00T-N1.7-3B`
|
||||
- Task: Vision-Language-Action (VLA) inference for robot manipulation
|
||||
- Mode: Online serving via OpenPI WebSocket endpoint
|
||||
- Maintainer: timzsu
|
||||
|
||||
## When to use this recipe
|
||||
|
||||
Use this recipe when you need to serve GR00T-N1.7 as a real-time robot policy
|
||||
over the OpenPI WebSocket API. It configures the DROID embodiment
|
||||
(`OXE_DROID_RELATIVE_EEF_RELATIVE_JOINT`) and exposes the standard DROID action
|
||||
keys (`eef_9d`, `gripper_position`, `joint_position`) with action horizon 40.
|
||||
|
||||
## References
|
||||
|
||||
- Upstream model: <https://huggingface.co/nvidia/GR00T-N1.7-3B>
|
||||
- Upstream codebase: <https://github.com/NVIDIA/Isaac-GR00T>
|
||||
- OpenPI client library: <https://github.com/Physical-Intelligence/openpi>
|
||||
- Pipeline: `vllm_omni.diffusion.models.gr00t.pipeline_gr00t.Gr00tN1d7Pipeline`
|
||||
- Deploy config: [`vllm_omni/deploy/Gr00tN1d7.yaml`](../../vllm_omni/deploy/Gr00tN1d7.yaml)
|
||||
- E2E test: [`tests/e2e/online_serving/test_gr00t_openpi.py`](../../tests/e2e/online_serving/test_gr00t_openpi.py)
|
||||
|
||||
## Environment
|
||||
|
||||
- OS: Linux
|
||||
- Python: 3.11+
|
||||
- Driver / runtime: NVIDIA CUDA
|
||||
- Hardware: 1 NVIDIA GPU. The upstream model card lists 16 GB+ VRAM for inference (e.g. RTX 4090, L40, H100); in practice this serving path uses ~6 GiB peak (bf16, TP=1, `max_num_seqs: 1`).
|
||||
- vLLM-Omni version or commit: use versions from your current checkout
|
||||
|
||||
## Start server
|
||||
|
||||
From repository root:
|
||||
|
||||
```bash
|
||||
vllm serve nvidia/GR00T-N1.7-3B \
|
||||
--omni \
|
||||
--host 127.0.0.1 \
|
||||
--port 8000 \
|
||||
--served-model-name gr00t-n1d7 \
|
||||
--stage-configs-path vllm_omni/deploy/Gr00tN1d7.yaml
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Only `max_num_seqs: 1` is supported (configured in the deploy YAML). The
|
||||
policy is markovian (`state_history_length=1`, `reset()` returns `{}`) — the
|
||||
reason for the cap is that `Gr00tPolicy` does its own per-sample
|
||||
(un)batching inside `get_action` and is not integrated with vLLM's
|
||||
continuous batching path.
|
||||
- This pipeline is a thin wrapper around the upstream HF policy
|
||||
(`AutoModel.from_pretrained` + `enforce_eager`, `tensor_parallel_size=1`,
|
||||
pipeline `load_weights` is a no-op). The standard diffusion accelerators
|
||||
(SP, CFG, TeaCache, VAE tiling) do not transfer to a flow-matching action
|
||||
policy at batch size 1, so a native-kernel port is intentionally out of
|
||||
scope for this recipe — the value is the OpenPI serving integration, not
|
||||
kernel-level acceleration.
|
||||
- The WebSocket endpoint is `ws://127.0.0.1:8000/v1/realtime/robot/openpi`.
|
||||
The server handshake message (first frame after connect) is a msgpack-encoded
|
||||
dict with `action_horizon`, `action_keys`, `embodiment_tag`, and
|
||||
`needs_session_id`.
|
||||
|
||||
## Verification
|
||||
|
||||
```python
|
||||
from tests.gr00t.openpi_client_helper import run_policy_session, validate_session_result
|
||||
validate_session_result(run_policy_session(host="127.0.0.1", port=8000))
|
||||
```
|
||||
|
||||
Or run the e2e test suite:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/e2e/online_serving/test_gr00t_openpi.py -v
|
||||
```
|
||||
|
||||
The test sends a synthetic two-frame DROID observation and checks:
|
||||
|
||||
- GR00T metadata contract: `image_resolution`, `action_horizon`, `action_keys`, `embodiment_tag`
|
||||
- Action shapes: `eef_9d (1,40,9)`, `gripper_position (1,40,1)`, `joint_position (1,40,7)`
|
||||
- All action values are finite float32
|
||||
- Reset response is `"reset successful"`
|
||||
|
||||
## Notes
|
||||
|
||||
- **Do not change the model-specific `policy_server_config` values.** `action_horizon`,
|
||||
`action_keys`, and `supported_embodiments` are fixed by the GR00T-N1.7 checkpoint and
|
||||
are validated against the loaded policy at startup (the server refuses to start on a
|
||||
mismatch). Only `image_resolution` and `needs_session_id` are deployment knobs.
|
||||
- To switch embodiment, edit `embodiment_tag` under both `model_config` and
|
||||
`policy_server_config` in `vllm_omni/deploy/Gr00tN1d7.yaml`. Supported values:
|
||||
`OXE_DROID_RELATIVE_EEF_RELATIVE_JOINT` (default), `XDOF`, `XDOF_SUBTASK`,
|
||||
`REAL_G1`, `REAL_R1_PRO_SHARPA`, `LIBERO_PANDA`, `SIMPLER_ENV_GOOGLE`,
|
||||
`SIMPLER_ENV_WIDOWX`.
|
||||
- GR00T weights are loaded directly by `Gr00tPolicy` via `AutoModel.from_pretrained`;
|
||||
the pipeline's `load_weights` is intentionally a no-op.
|
||||
@@ -0,0 +1,289 @@
|
||||
# Qwen-Image-2512
|
||||
|
||||
> Text-to-image serving for the 2512 (Dec-2025) refresh of Qwen-Image
|
||||
|
||||
## Summary
|
||||
|
||||
- Vendor: Qwen
|
||||
- Model: `Qwen/Qwen-Image-2512`
|
||||
- Task: Text-to-image generation
|
||||
- Mode: Online serving with optional step-wise continuous batching
|
||||
- Maintainer: Community
|
||||
|
||||
## When to use this recipe
|
||||
|
||||
Use this recipe when you want to serve the newer `Qwen/Qwen-Image-2512`
|
||||
checkpoint instead of the original `Qwen/Qwen-Image`. `Qwen-Image-2512` is a
|
||||
drop-in updated checkpoint that runs on the **same** `QwenImagePipeline`, so the
|
||||
serving path, flags, and clients are identical to
|
||||
[`Qwen-Image.md`](./Qwen-Image.md) — only the model id changes. Pick this recipe
|
||||
to validate the normal online-serving path on a single 80 GB A800, or to run the
|
||||
validated 2-GPU ModelOpt mixed FP8/NVFP4 quantized variant on B200.
|
||||
|
||||
## Difference from Qwen-Image
|
||||
|
||||
`Qwen-Image-2512` requires **no code, registry, or pipeline changes** relative to
|
||||
`Qwen-Image`:
|
||||
|
||||
- Same architecture / pipeline: `QwenImagePipeline`
|
||||
(`vllm_omni/diffusion/registry.py`, see
|
||||
[`docs/models/supported_models.md`](../../docs/models/supported_models.md)).
|
||||
- Same serving entrypoint, CLI flags, OpenAI image-generation API, example
|
||||
clients, and benchmark scripts.
|
||||
|
||||
The only change is the **model id**: swap `Qwen/Qwen-Image` for
|
||||
`Qwen/Qwen-Image-2512` everywhere (serve command, API `model` field, client
|
||||
`--model`, benchmark `--model`). Everything else in
|
||||
[`Qwen-Image.md`](./Qwen-Image.md) applies unchanged.
|
||||
|
||||
## References
|
||||
|
||||
- Sibling recipe (canonical, identical serving path):
|
||||
[`recipes/Qwen/Qwen-Image.md`](./Qwen-Image.md)
|
||||
- Upstream or canonical docs:
|
||||
[`docs/user_guide/examples/online_serving/text_to_image.md`](../../docs/user_guide/examples/online_serving/text_to_image.md)
|
||||
- Related example under `examples/`:
|
||||
[`examples/online_serving/text_to_image/README.md`](../../examples/online_serving/text_to_image/README.md)
|
||||
- Related benchmark:
|
||||
[`benchmarks/diffusion/diffusion_benchmark_serving.py`](../../benchmarks/diffusion/diffusion_benchmark_serving.py)
|
||||
- Supported models table:
|
||||
[`docs/models/supported_models.md`](../../docs/models/supported_models.md)
|
||||
|
||||
## Hardware Support
|
||||
|
||||
This recipe documents a single-GPU BF16 serving configuration on A800 and a
|
||||
validated 2-GPU ModelOpt mixed FP8/NVFP4 configuration on B200. Extend it with
|
||||
more hardware sections as community validation lands.
|
||||
|
||||
## GPU
|
||||
|
||||
### 1x A800-SXM4-80GB
|
||||
|
||||
#### Environment
|
||||
|
||||
- OS: Linux
|
||||
- Python: 3.12.13
|
||||
- PyTorch: 2.11.0+cu130
|
||||
- CUDA runtime: 13.0 (driver 580.126.09)
|
||||
- GPU: NVIDIA A800-SXM4-80GB (81920 MiB)
|
||||
- vLLM: 0.22.0
|
||||
- Transformers: 5.8.1
|
||||
- vLLM-Omni: v0.22.0rc1-70-ge91efc24
|
||||
|
||||
#### Command
|
||||
|
||||
Start the baseline server:
|
||||
|
||||
```bash
|
||||
vllm serve Qwen/Qwen-Image-2512 --omni --port 8091
|
||||
```
|
||||
|
||||
To enable the step-wise runtime without batching:
|
||||
|
||||
```bash
|
||||
vllm serve Qwen/Qwen-Image-2512 --omni \
|
||||
--port 8091 \
|
||||
--step-execution \
|
||||
--max-num-seqs 1
|
||||
```
|
||||
|
||||
To enable experimental compatible-request batching:
|
||||
|
||||
```bash
|
||||
vllm serve Qwen/Qwen-Image-2512 --omni \
|
||||
--port 8091 \
|
||||
--step-execution \
|
||||
--max-num-seqs 8
|
||||
```
|
||||
|
||||
You can also use the example launcher and pass the model id and extra flags
|
||||
through:
|
||||
|
||||
```bash
|
||||
bash examples/online_serving/text_to_image/run_server.sh \
|
||||
--model Qwen/Qwen-Image-2512 --step-execution --max-num-seqs 8
|
||||
```
|
||||
|
||||
#### Verification
|
||||
|
||||
Run the existing client example after the server is ready:
|
||||
|
||||
```bash
|
||||
python examples/online_serving/text_to_image/openai_chat_client.py \
|
||||
--server http://localhost:8091 \
|
||||
--prompt "A ceramic teapot on a wooden table" \
|
||||
--output /tmp/qwen_image_2512_recipe.png
|
||||
```
|
||||
|
||||
For a direct API smoke test:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8091/v1/images/generations \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "Qwen/Qwen-Image-2512",
|
||||
"prompt": "A ceramic teapot on a wooden table",
|
||||
"size": "1024x1024",
|
||||
"num_inference_steps": 20,
|
||||
"seed": 42
|
||||
}'
|
||||
```
|
||||
|
||||
To replay the batching benefit with matched warmup:
|
||||
|
||||
```bash
|
||||
python benchmarks/diffusion/diffusion_benchmark_serving.py \
|
||||
--endpoint /v1/chat/completions \
|
||||
--dataset vbench \
|
||||
--task t2i \
|
||||
--model Qwen/Qwen-Image-2512 \
|
||||
--num-prompts 10 \
|
||||
--height 1024 \
|
||||
--width 1024 \
|
||||
--num-inference-steps 50 \
|
||||
--seed 42 \
|
||||
--port 8089 \
|
||||
--max-concurrency 8 \
|
||||
--warmup-requests 8 \
|
||||
--warmup-concurrency 8 \
|
||||
--warmup-num-inference-steps 3 \
|
||||
--disable-tqdm
|
||||
```
|
||||
|
||||
Run that once against `--max-num-seqs 1`, then rerun it against `--max-num-seqs 8`
|
||||
and compare the output JSON or terminal metrics.
|
||||
|
||||
#### Benchmark
|
||||
|
||||
The numbers below are loaded-once request-level measurements, not an HTTP
|
||||
concurrency benchmark. The model is loaded once, warmup requests are run,
|
||||
then 100 sequential requests are measured.
|
||||
|
||||
```text
|
||||
Hardware: 1x A800-SXM4-80GB
|
||||
Requests: 100
|
||||
Concurrency: 1
|
||||
Resolution: 1024x1024
|
||||
Denoising steps: 20
|
||||
```
|
||||
|
||||
| Config | Mean | P95 | P99 | Peak VRAM |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| BF16 | 7.669s | 7.736s | 7.748s | 58,979 MiB |
|
||||
|
||||
#### Notes
|
||||
|
||||
- Memory usage: BF16 peak VRAM ~59 GiB on A800, leaving ~22 GiB headroom for
|
||||
compile overhead and image decode.
|
||||
- Key flags: `--step-execution` enables the step-wise runtime; `--max-num-seqs`
|
||||
controls how many compatible requests may stay active together.
|
||||
- Keep `--max-num-seqs 1` when you want the more conservative path, when traffic
|
||||
is mostly single-request, or when you are debugging correctness before
|
||||
measuring throughput.
|
||||
- Current batching is still shape-sensitive: different step progress can
|
||||
co-batch, but different resolutions do not yet co-batch.
|
||||
|
||||
### 2x B200 ModelOpt mixed FP8/NVFP4
|
||||
|
||||
#### Environment
|
||||
|
||||
- OS: Linux
|
||||
- Python: 3.12
|
||||
- Driver / runtime: NVIDIA CUDA environment with two B200 GPUs
|
||||
- vLLM version: Match the vLLM-Omni checkout used for deployment
|
||||
- vLLM-Omni version or commit: Use the commit that contains ModelOpt mixed
|
||||
FP8/NVFP4 checkpoint loading for diffusion models
|
||||
|
||||
#### Command
|
||||
|
||||
This configuration uses a pre-quantized ModelOpt mixed checkpoint for
|
||||
Qwen-Image-2512. The validated checkpoint keeps boundary, modulation,
|
||||
normalization, and output layers in BF16, uses FP8 for sensitive attention
|
||||
projections, and uses NVFP4 for heavier MLP / attention-output linear layers.
|
||||
|
||||
Validated checkpoint:
|
||||
|
||||
```text
|
||||
feizhai123/qwen-image-2512-modelopt-mixed-fp8-sensitive-nvfp4-heavy
|
||||
```
|
||||
|
||||
Start the server with the CUTLASS quantized linear backend:
|
||||
|
||||
```bash
|
||||
CUDA_VISIBLE_DEVICES=2,3 \
|
||||
vllm serve feizhai123/qwen-image-2512-modelopt-mixed-fp8-sensitive-nvfp4-heavy \
|
||||
--omni \
|
||||
--host 0.0.0.0 \
|
||||
--port 8091 \
|
||||
--trust-remote-code \
|
||||
--tensor-parallel-size 2 \
|
||||
--linear-backend cutlass \
|
||||
--force-cutlass-fp8
|
||||
```
|
||||
|
||||
#### Verification
|
||||
|
||||
Run a direct image generation request after the server is ready:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8091/v1/images/generations \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "feizhai123/qwen-image-2512-modelopt-mixed-fp8-sensitive-nvfp4-heavy",
|
||||
"prompt": "A cinematic Warhammer 40,000 Ultramarine warrior in blue power armor, armor stained with alien ichor, swinging a chainsword at a Tyranid creature, gothic battlefield, dramatic lighting, high detail, no text, no watermark",
|
||||
"negative_prompt": "blurry, low quality, text, watermark, logo, malformed armor, bad hands",
|
||||
"size": "1024x1024",
|
||||
"num_inference_steps": 20,
|
||||
"true_cfg_scale": 4.0,
|
||||
"guidance_scale": 4.0,
|
||||
"seed": 48
|
||||
}'
|
||||
```
|
||||
|
||||
Check that:
|
||||
|
||||
- The server responds on `http://localhost:8091/health`.
|
||||
- The response contains one generated image.
|
||||
- Logs show CUTLASS selection for ModelOpt FP8 / mixed-precision linear
|
||||
layers instead of falling back to the default automatic backend choice.
|
||||
|
||||
#### Benchmark
|
||||
|
||||
The numbers below are loaded-once request-level measurements, not an HTTP
|
||||
concurrency benchmark. Each case loads the model once, runs warmup requests,
|
||||
then measures 100 sequential requests.
|
||||
|
||||
```text
|
||||
Hardware: 2x B200
|
||||
Tensor parallel size: 2
|
||||
Requests: 100
|
||||
Concurrency: 1
|
||||
Resolution: 1024x1024
|
||||
Denoising steps: 20
|
||||
```
|
||||
|
||||
| Config | Mean | P90 | P99 | Peak VRAM |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| BF16 | 2.696s | 2.700s | 2.707s | 99,000 MiB |
|
||||
| ModelOpt FP8 CUTLASS | 2.663s | 2.671s | 2.676s | 85,760 MiB |
|
||||
| Mixed FP8/NVFP4 CUTLASS | 2.584s | 2.590s | 2.594s | 84,412 MiB |
|
||||
|
||||
The same 2-GPU setup was also used to sweep NVFP4 linear backends. Direct
|
||||
CUTLASS was the fastest validated backend for this checkpoint.
|
||||
|
||||
| Backend | Mean | P90 | P99 | Peak VRAM |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| auto | 2.927s | 2.948s | 3.535s | 85,340 MiB |
|
||||
| flashinfer-cudnn | 3.523s | 3.528s | 3.766s | 84,500 MiB |
|
||||
| flashinfer-trtllm | 2.711s | 2.727s | 3.010s | 84,996 MiB |
|
||||
| flashinfer-cutlass | 2.935s | 2.950s | 3.011s | 85,340 MiB |
|
||||
| cutlass | 2.578s | 2.584s | 2.592s | 84,412 MiB |
|
||||
|
||||
#### Notes
|
||||
|
||||
- Use `--linear-backend cutlass` to select the validated quantized linear
|
||||
backend from the CLI.
|
||||
- `--force-cutlass-fp8` keeps ModelOpt FP8 layers on the CUTLASS FP8 path.
|
||||
- The benchmark table intentionally reports Qwen-Image-2512 as
|
||||
concurrency-1 request-level data; do not compare it directly with an online
|
||||
HTTP concurrency benchmark.
|
||||
+93
-10
@@ -28,11 +28,9 @@ Smaller 0.6B variants are also available for `CustomVoice` and `Base`.
|
||||
|
||||
## References
|
||||
|
||||
- Upstream or canonical docs:
|
||||
[`docs/user_guide/examples/online_serving/qwen3_tts.md`](../../docs/user_guide/examples/online_serving/qwen3_tts.md)
|
||||
- Related examples under `examples/`:
|
||||
[`examples/online_serving/qwen3_tts/`](../../examples/online_serving/qwen3_tts/),
|
||||
[`examples/offline_inference/qwen3_tts/`](../../examples/offline_inference/qwen3_tts/)
|
||||
[`examples/online_serving/text_to_speech/qwen3_tts/`](../../examples/online_serving/text_to_speech/qwen3_tts/),
|
||||
[`examples/offline_inference/text_to_speech/qwen3_tts/`](../../examples/offline_inference/text_to_speech/qwen3_tts/)
|
||||
- Related issue or discussion:
|
||||
[RFC: add recipes folder](https://github.com/vllm-project/vllm-omni/issues/2645)
|
||||
|
||||
@@ -67,9 +65,9 @@ vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-Base \
|
||||
Alternatively, use the convenience script:
|
||||
|
||||
```bash
|
||||
./examples/online_serving/qwen3_tts/run_server.sh # Default: CustomVoice
|
||||
./examples/online_serving/qwen3_tts/run_server.sh VoiceDesign # VoiceDesign
|
||||
./examples/online_serving/qwen3_tts/run_server.sh Base # Base (voice clone)
|
||||
./examples/online_serving/text_to_speech/qwen3_tts/run_server.sh # Default: CustomVoice
|
||||
./examples/online_serving/text_to_speech/qwen3_tts/run_server.sh VoiceDesign # VoiceDesign
|
||||
./examples/online_serving/text_to_speech/qwen3_tts/run_server.sh Base # Base (voice clone)
|
||||
```
|
||||
|
||||
The bundled deploy config (`vllm_omni/deploy/qwen3_tts.yaml`) enables async
|
||||
@@ -117,7 +115,7 @@ curl http://localhost:8091/v1/audio/voices
|
||||
**Using the Python client:**
|
||||
|
||||
```bash
|
||||
cd examples/online_serving/qwen3_tts
|
||||
cd examples/online_serving/text_to_speech/qwen3_tts
|
||||
|
||||
# CustomVoice
|
||||
python openai_speech_client.py \
|
||||
@@ -157,8 +155,8 @@ curl -X POST http://localhost:8091/v1/audio/speech \
|
||||
**Offline inference (no server needed):**
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/qwen3_tts/end2end.py --query-type CustomVoice
|
||||
python examples/offline_inference/qwen3_tts/end2end.py --query-type CustomVoice --streaming
|
||||
python examples/offline_inference/text_to_speech/qwen3_tts/end2end.py --query-type CustomVoice
|
||||
python examples/offline_inference/text_to_speech/qwen3_tts/end2end.py --query-type CustomVoice --streaming
|
||||
```
|
||||
|
||||
## Notes
|
||||
@@ -168,3 +166,88 @@ python examples/offline_inference/qwen3_tts/end2end.py --query-type CustomVoice
|
||||
- Async chunking: Enabled by default in `qwen3_tts.yaml` for streaming-friendly first-audio latency. Streaming requires `stream=true` with `response_format="pcm"`.
|
||||
- Task/model matching: Each task type requires its matching model checkpoint. Using a CustomVoice model for a Base (voice clone) request will fail.
|
||||
- Known limitations: The server serves one model variant at a time. To switch task types (e.g., CustomVoice to Base), restart the server with the corresponding model.
|
||||
|
||||
## Hardware Support
|
||||
|
||||
## GPU
|
||||
|
||||
### 1x RTX 4090 24GB (0.6B CustomVoice)
|
||||
|
||||
#### Environment
|
||||
|
||||
- OS: Ubuntu 24.04 LTS
|
||||
- Python: 3.12.3
|
||||
- PyTorch: 2.11.0+cu130
|
||||
- Driver / runtime: NVIDIA 580.126.09 / CUDA 13.0
|
||||
- GPU: NVIDIA GeForce RTX 4090, 24 GB
|
||||
- vLLM version: 0.21.0
|
||||
- vLLM-Omni version or commit: 0.21.0rc3.dev91+gd4c13950
|
||||
|
||||
#### Command
|
||||
|
||||
```bash
|
||||
vllm serve Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice \
|
||||
--deploy-config vllm_omni/deploy/qwen3_tts.yaml \
|
||||
--omni --port 8091
|
||||
```
|
||||
|
||||
The default deploy config (`qwen3_tts.yaml`) works without modification on the
|
||||
RTX 4090. Both stages (talker + code2wav) share GPU 0 with
|
||||
`gpu_memory_utilization: 0.3` each.
|
||||
|
||||
#### Verification
|
||||
|
||||
**English synthesis:**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8091/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"input": "Hello, this is Qwen3-TTS running on RTX 4090.",
|
||||
"voice": "vivian",
|
||||
"language": "English"
|
||||
}' --output test_english.wav
|
||||
```
|
||||
|
||||
**Chinese synthesis:**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8091/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"input": "你好,这是在RTX 4090上运行的语音合成测试。",
|
||||
"voice": "vivian",
|
||||
"language": "Chinese"
|
||||
}' --output test_chinese.wav
|
||||
```
|
||||
|
||||
**With emotion instruction:**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8091/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"input": "I am so excited about this!",
|
||||
"voice": "vivian",
|
||||
"language": "English",
|
||||
"instructions": "Speak with great enthusiasm"
|
||||
}' --output test_emotion.wav
|
||||
```
|
||||
|
||||
#### Notes
|
||||
|
||||
- Memory usage: **~13.5 GiB / 24 GiB** at idle with the default deploy config
|
||||
(`gpu_memory_utilization: 0.3` per stage). The 0.6B model weights occupy only
|
||||
~2.4 GiB (Stage 0: 1.91 GiB, Stage 1: 0.45 GiB); the remainder is KV cache
|
||||
pre-allocated at startup. To reduce idle footprint to ~5 GiB, use a custom
|
||||
deploy config with lower utilization (inference peak ~10 GiB):
|
||||
|
||||
```yaml
|
||||
# Custom deploy config for Qwen3-TTS-12Hz-0.6B on RTX 4090
|
||||
# Copy vllm_omni/deploy/qwen3_tts.yaml and override gpu_memory_utilization:
|
||||
stages:
|
||||
- stage_id: 0
|
||||
gpu_memory_utilization: 0.15
|
||||
- stage_id: 1
|
||||
gpu_memory_utilization: 0.15
|
||||
```
|
||||
+12
-2
@@ -12,6 +12,13 @@ that is aligned with
|
||||
that external repository as a reference for structure rather than the place to
|
||||
add files for this repo. Use one Markdown file per model family by default.
|
||||
|
||||
> **Note:** The vLLM-Omni TTS recipes (12 models across 8 providers) are also
|
||||
> published on the rendered recipes site at
|
||||
> [recipes.vllm.ai](https://recipes.vllm.ai) (see
|
||||
> [vllm-project/recipes#554](https://github.com/vllm-project/recipes/pull/554)).
|
||||
> Individual pages resolve at `https://recipes.vllm.ai/<provider>/<model-id>`,
|
||||
> e.g. [`bosonai/higgs-audio-v3-tts-4b`](https://recipes.vllm.ai/bosonai/higgs-audio-v3-tts-4b).
|
||||
|
||||
Example layout:
|
||||
|
||||
```text
|
||||
@@ -29,11 +36,13 @@ recipes/
|
||||
|--------|------|----------|
|
||||
| [`audiox/AudioX.md`](./audiox/AudioX.md) | Offline + online unified text/video→audio diffusion | 1x L4 24GB |
|
||||
| [`Baidu/ERNIE-Image.md`](./Baidu/ERNIE-Image.md) | Text-to-image online serving (ERNIE-Image 8B) | 1x or 2x RTX 4090 24GB |
|
||||
| [`Bagel/BAGEL-7B-MoT.md`](./Bagel/BAGEL-7B-MoT.md) | Text-to-image with shared online/offline examples | 1x A100 80GB / 2x CUDA GPUs |
|
||||
| [`BosonAI/Higgs-Audio-V3-TTS.md`](./BosonAI/Higgs-Audio-V3-TTS.md) | Online + offline multilingual TTS with voice cloning | 1x H100 80GB |
|
||||
| [`ByteDance/Lance.md`](./ByteDance/Lance.md) | Unified AR+diffusion: text/img/video gen + understanding (Lance 3B) | 1x B300 / A100 80GB |
|
||||
| [`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) |
|
||||
| [`inclusionAI/Ming-omni-tts.md`](./inclusionAI/Ming-omni-tts.md) | Offline + online dense Ming TTS/audio generation | 1x H100 80GB / 1x AMD MI300X (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 |
|
||||
@@ -41,8 +50,9 @@ recipes/
|
||||
| [`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 |
|
||||
| [`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 |
|
||||
| [`OpenMOSS/MOSS-TTS.md`](./OpenMOSS/MOSS-TTS.md) | Online + offline multilingual TTS (MOSS-TTS family, 8B) | 1x H100 80GB |
|
||||
| [`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 |
|
||||
| [`Qwen/Qwen-Image.md`](./Qwen/Qwen-Image.md) | Text-to-image serving with step-wise continuous batching replay | 1x A100 80GB |
|
||||
| [`Qwen/Qwen-Image-2512.md`](./Qwen/Qwen-Image-2512.md) | Text-to-image serving with step-wise continuous batching replay and ModelOpt FP8 / mixed FP8/NVFP4 | 1x A800 80GB / 2x B200 |
|
||||
| [`Qwen/Qwen-Image-Edit.md`](./Qwen/Qwen-Image-Edit.md) | Text-guided single-image editing | 1x or 2x H200 141GB |
|
||||
| [`Qwen/Qwen3-Omni.md`](./Qwen/Qwen3-Omni.md) | Online serving for multimodal chat | 1x A100 80GB |
|
||||
| [`Qwen/Qwen3-TTS.md`](./Qwen/Qwen3-TTS.md) | Text-to-speech serving (CustomVoice / VoiceDesign / Base) | 1x H100/A100 80GB |
|
||||
|
||||
@@ -72,6 +72,31 @@ pytest -s -v tests/e2e/offline_inference/test_sensenova_u1_text2img.py \
|
||||
- No deploy YAML needed — the engine auto-generates a single-stage diffusion config.
|
||||
- Think mode (`--think`) is recommended for higher image quality.
|
||||
|
||||
#### Text-to-Image via the shared example
|
||||
|
||||
SenseNova-U1 is also registered with the shared text-to-image example, so you
|
||||
can run it through `text_to_image.py` instead of `end2end.py`. Forward the
|
||||
SenseNova-specific generation parameters as a JSON object through `--extra-body`:
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/text_to_image/text_to_image.py \
|
||||
--model SenseNova/SenseNova-U1-8B-MoT \
|
||||
--prompt "A beautiful sunset over mountains" \
|
||||
--width 2048 --height 2048 \
|
||||
--num-inference-steps 50 \
|
||||
--seed 42 \
|
||||
--extra-body '{"think": true, "cfg_scale": 4.0, "cfg_norm": "none", "timestep_shift": 3.0, "t_eps": 0.02}' \
|
||||
--output sensenova_text2img.png
|
||||
```
|
||||
|
||||
The `--extra-body` keys are filtered against the model's declared
|
||||
`extra_body_params` (see
|
||||
[`vllm_omni/model_extras/sensenova_u1.py`](../../vllm_omni/model_extras/sensenova_u1.py)):
|
||||
`think`, `cfg_scale`, `cfg_norm`, `timestep_shift`, `t_eps`, `img_cfg_scale`,
|
||||
and `max_tokens`. No deploy YAML is needed — the engine auto-generates a
|
||||
single-stage diffusion config. For richer task modalities (img2text, text2text)
|
||||
and CPU offload, use the dedicated `end2end.py` script described above.
|
||||
|
||||
#### Image-to-Image Editing (img2img)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# Stable Diffusion XL
|
||||
|
||||
> Text-to-image generation with Stability AI's SDXL Base 1.0
|
||||
|
||||
## Summary
|
||||
|
||||
- Vendor: Stability AI
|
||||
- Model: `stabilityai/stable-diffusion-xl-base-1.0`
|
||||
- Task: Text-to-image generation
|
||||
- Mode: Offline inference / Online serving with the OpenAI-compatible images API
|
||||
- Maintainer: Community
|
||||
|
||||
## When to use this recipe
|
||||
|
||||
Use this recipe when you want a high-quality, open-weight text-to-image model
|
||||
that generates 1024x1024 images. SDXL Base 1.0 is one of the most widely used
|
||||
diffusion models and produces photorealistic outputs with strong prompt
|
||||
adherence. It requires ~6.5 GiB for model weights and runs inference at
|
||||
~7.5 seconds per image (30 steps) on a single Intel Data Center GPU.
|
||||
|
||||
## References
|
||||
|
||||
- Model: <https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0>
|
||||
- Architecture reference: <https://github.com/Stability-AI/generative-models>
|
||||
- Related example under `examples/`:
|
||||
[`examples/offline_inference/text_to_image/text_to_image.py`](../../examples/offline_inference/text_to_image/text_to_image.py)
|
||||
|
||||
## Hardware Support
|
||||
|
||||
## GPU
|
||||
|
||||
### 1x Intel Arc BMG GPU (XPU)
|
||||
|
||||
#### Environment
|
||||
|
||||
- OS: Linux
|
||||
- Python: 3.10+
|
||||
- Driver / runtime: Intel XPU environment with Intel Arc BMG GPU
|
||||
- vLLM version: Match the repository requirements for your checkout
|
||||
- vLLM-Omni version or commit: Use the commit you are deploying from
|
||||
|
||||
#### Command
|
||||
|
||||
**Offline inference:**
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/text_to_image/text_to_image.py \
|
||||
--model stabilityai/stable-diffusion-xl-base-1.0 \
|
||||
--prompt "a beautiful sunset over the ocean, photorealistic" \
|
||||
--height 1024 --width 1024 \
|
||||
--num-inference-steps 30 \
|
||||
--guidance-scale 7.5 \
|
||||
--seed 42 \
|
||||
--output sdxl_output.png
|
||||
```
|
||||
|
||||
**Online serving:**
|
||||
|
||||
```bash
|
||||
vllm serve stabilityai/stable-diffusion-xl-base-1.0 --omni --port 8091
|
||||
```
|
||||
|
||||
#### Verification
|
||||
|
||||
**For offline inference:** Check the output image file exists and has valid content.
|
||||
|
||||
**For online serving:**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8091/v1/images/generations \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "stabilityai/stable-diffusion-xl-base-1.0",
|
||||
"prompt": "a cute cat sitting on a windowsill, highly detailed, sharp focus",
|
||||
"negative_prompt": "blurry, out of focus, low quality",
|
||||
"guidance_scale": 7.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: Model weights ~6.5 GiB, peak memory ~10.9 GiB at 1024x1024 with CFG.
|
||||
- Generation time: ~7.5 seconds for 30 inference steps at 1024x1024.
|
||||
- `negative_prompt` requires `guidance_scale > 1` to take effect. Recommended range: 5.0-9.0.
|
||||
- Default resolution is 1024x1024. Other supported sizes: 512x512, 768x768.
|
||||
- The model uses dual CLIP text encoders (ViT-L/14 + OpenCLIP ViT-bigG/14) for rich text understanding.
|
||||
|
||||
### 1x NVIDIA GPU (24+ GB VRAM)
|
||||
|
||||
#### Environment
|
||||
|
||||
- OS: Linux
|
||||
- Python: 3.10+
|
||||
- Driver / runtime: NVIDIA CUDA environment with an A100
|
||||
- vLLM version: Match the repository requirements for your checkout
|
||||
- vLLM-Omni version or commit: Use the commit you are deploying from
|
||||
|
||||
#### Command
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/text_to_image/text_to_image.py \
|
||||
--model stabilityai/stable-diffusion-xl-base-1.0 \
|
||||
--prompt "a beautiful sunset over the ocean, photorealistic" \
|
||||
--height 1024 --width 1024 \
|
||||
--num-inference-steps 30 \
|
||||
--guidance-scale 7.5 \
|
||||
--seed 42 \
|
||||
--output sdxl_output.png
|
||||
```
|
||||
|
||||
#### Notes
|
||||
|
||||
- Memory usage: ~6.5 GiB model weights, ~10 GiB peak with CFG at 1024x1024.
|
||||
- Fits on a single GPU with 24 GB VRAM (e.g., RTX 3090/4090, A5000).
|
||||
- For GPUs with less VRAM, use `--enable-cpu-offload` to offload text encoders during diffusion.
|
||||
@@ -47,6 +47,10 @@ mode is selected per request:
|
||||
**`nvidia/Cosmos3-Nano-Policy-DROID`** is served the same way
|
||||
(`domain_name=droid_lerobot`).
|
||||
|
||||
- **DROID OpenPI policy server** — serve `nvidia/Cosmos3-Nano-Policy-DROID` and
|
||||
connect an OpenPI-compatible websocket client to `/v1/realtime/robot/openpi`.
|
||||
This path returns action chunks directly instead of an mp4.
|
||||
|
||||
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.
|
||||
@@ -101,7 +105,10 @@ vllm serve nvidia/Cosmos3-Nano \
|
||||
To run **without** guardrails (you are responsible for license compliance),
|
||||
add `--no-guardrails` (no token/`cosmos-guardrail` needed). For extra GPUs use
|
||||
`--ulysses-degree N` (context parallel) or `--tensor-parallel-size N`;
|
||||
`--enable-layerwise-offload` reduces VRAM on smaller GPUs. The pipeline
|
||||
`--enable-layerwise-offload` reduces VRAM on smaller GPUs;
|
||||
`--quantization fp8` (online, no calibration) cuts peak VRAM for 720p video
|
||||
generation from ~50 GB to ~36 GB with BF16-level quality (T2V composition can
|
||||
shift at the same seed). The pipeline
|
||||
auto-resolves from `model_index.json`; pass
|
||||
`--model-class-name Cosmos3OmniDiffusersPipeline` to force it explicitly.
|
||||
|
||||
@@ -245,6 +252,39 @@ 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_inverse_dynamics.mp4
|
||||
|
||||
# DROID OpenPI policy server (websocket action serving).
|
||||
# Requires cosmos_framework on PYTHONPATH because the pipeline reuses the
|
||||
# reference RoboLab action transforms. If your checkpoint config already
|
||||
# includes policy_server_config, omit the stage_overrides file and flag.
|
||||
cat > cosmos3_droid_openpi_stage_overrides.json <<'JSON'
|
||||
{
|
||||
"0": {
|
||||
"model_config": {
|
||||
"policy_server_config": {
|
||||
"image_resolution": [540, 640],
|
||||
"n_external_cameras": 2,
|
||||
"needs_wrist_camera": true,
|
||||
"needs_stereo_camera": false,
|
||||
"needs_session_id": true,
|
||||
"action_space": "joint_position"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
vllm serve nvidia/Cosmos3-Nano-Policy-DROID \
|
||||
--omni \
|
||||
--host 0.0.0.0 --port 8000 \
|
||||
--model-class-name Cosmos3OmniDiffusersPipeline \
|
||||
--no-guardrails \
|
||||
--stage-overrides "$(cat cosmos3_droid_openpi_stage_overrides.json)"
|
||||
|
||||
# Point an OpenPI websocket client at:
|
||||
# ws://localhost:8000/v1/realtime/robot/openpi
|
||||
# The first server message is policy_server_config. Each infer request sends a
|
||||
# msgpack-numpy observation dict and receives a writable float32 action array.
|
||||
```
|
||||
|
||||
#### Notes
|
||||
@@ -272,6 +312,13 @@ curl -sS -L "http://localhost:8000/v1/videos/$VIDEO_ID/content" -o cosmos3_inver
|
||||
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).
|
||||
- **DROID OpenPI observations:** include a string `prompt`, either
|
||||
`observation/image` or the three-view DROID camera keys
|
||||
(`observation/wrist_image_left`, `observation/exterior_image_1_left`,
|
||||
`observation/exterior_image_2_left`), plus `observation/gripper_position` and
|
||||
`observation/joint_position`. Optional extra params include `history_length`,
|
||||
`conditioning_fps`, `action_chunk_size`, `raw_action_dim`, `deterministic_seed`,
|
||||
and `session_id`.
|
||||
- **Known limitations:**
|
||||
- Guardrails-on requires `cosmos-guardrail` **and** access to the gated
|
||||
`nvidia/Cosmos-1.0-Guardrail` repo (accept license + `HF_TOKEN`); otherwise
|
||||
|
||||
@@ -105,3 +105,45 @@ voice-cloning e2e test ([`Voice cloning (capability gated upstream)`](../../exam
|
||||
) or adding a `ref_audio` request parameter will fail
|
||||
because the public checkpoint does not provide the encoder weights needed to
|
||||
convert reference audio into conditioning features. The expected failure is: `RuntimeError: encode_waveforms requires encoder weights which are not available in the open-source checkpoint.`
|
||||
|
||||
### 1x NVIDIA RTX A6000 48GB
|
||||
|
||||
#### Environment
|
||||
|
||||
- OS: Ubuntu 22.04
|
||||
- Python: 3.12
|
||||
- Driver / runtime: NVIDIA driver 580.126.09, CUDA 13.0
|
||||
- GPU: NVIDIA RTX A6000, 48 GB
|
||||
- vLLM version: 0.22.0
|
||||
- vLLM-Omni version or commit: 0.22.0rc2.dev8+g41a795b5d
|
||||
- Required package: `pip install mistral-common`
|
||||
|
||||
#### Command
|
||||
|
||||
```bash
|
||||
vllm serve mistralai/Voxtral-4B-TTS-2603 --omni --port 8091
|
||||
```
|
||||
|
||||
#### Verification
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8091/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "mistralai/Voxtral-4B-TTS-2603",
|
||||
"input": "Artificial intelligence is transforming the way we interact with technology.",
|
||||
"voice": "neutral_female",
|
||||
"response_format": "wav"
|
||||
}' -o voxtral_test.wav
|
||||
```
|
||||
|
||||
Supported voice presets: `ar_male`, `casual_female`, `casual_male`, `cheerful_female`,
|
||||
`de_female`, `de_male`, `es_female`, `es_male`, `fr_female`, `fr_male`, `hi_female`,
|
||||
`hi_male`, `it_female`, `it_male`, `neutral_female`, `neutral_male`, `nl_female`,
|
||||
`nl_male`, `pt_female`, `pt_male`.
|
||||
|
||||
#### Notes
|
||||
|
||||
- Memory usage: Peak VRAM ~40.5 GiB during inference.
|
||||
- Generation time: ~8 seconds for ~200 words of English text.
|
||||
- Audio quality is high; mono 24 kHz output.
|
||||
@@ -27,3 +27,6 @@ prettytable>=3.8.0
|
||||
aenum==3.1.16
|
||||
pyzmq>=25.0.0
|
||||
janus>=1.0.0
|
||||
msgpack>=1.0.0
|
||||
pydantic>=2.1.0
|
||||
pydantic-settings>=2.0.0
|
||||
@@ -14,6 +14,37 @@ from vllm_omni.benchmarks.patch.patch import MixRequestFuncOutput
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.benchmark, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_tpot_matches_mean_itl_per_request():
|
||||
"""TPOT and pooled ITL should agree when output_len tracks ITL samples."""
|
||||
output = MixRequestFuncOutput()
|
||||
output.success = True
|
||||
output.prompt_len = 100
|
||||
# Simulate server reporting more tokens than SSE chunks (bundled tokens).
|
||||
# len(itl)=2 → 2 inter-chunk intervals, but server generated 10 tokens.
|
||||
output.output_tokens = 10
|
||||
output.generated_text = "hello world"
|
||||
output.ttft = 0.05
|
||||
output.text_latency = 0.25
|
||||
output.latency = 0.30
|
||||
output.itl = [0.10, 0.10]
|
||||
|
||||
metrics, _ = calculate_metrics(
|
||||
input_requests=[],
|
||||
outputs=[output],
|
||||
dur_s=1.0,
|
||||
tokenizer=None,
|
||||
selected_percentiles=[50.0],
|
||||
goodput_config_dict={},
|
||||
task_type=TaskType.GENERATION,
|
||||
selected_percentile_metrics=["tpot", "itl"],
|
||||
max_concurrency=None,
|
||||
request_rate=float("inf"),
|
||||
benchmark_duration=1.0,
|
||||
)
|
||||
|
||||
assert metrics.mean_tpot_ms == pytest.approx(metrics.mean_itl_ms, rel=1e-6, abs=1e-6)
|
||||
|
||||
|
||||
def _make_output(prompt_len: int, output_tokens: int = 10) -> MixRequestFuncOutput:
|
||||
"""Build a minimal successful MixRequestFuncOutput for metrics aggregation."""
|
||||
output = MixRequestFuncOutput()
|
||||
|
||||
@@ -133,7 +133,9 @@ async def test_output_tokens_not_assigned_without_metrics(mocker: MockerFixture)
|
||||
|
||||
# Assert
|
||||
assert output.success is True
|
||||
assert output.output_tokens == 0, "output_tokens should default to 0 when no metrics"
|
||||
# Without server metrics we no longer infer count from SSE chunks because
|
||||
# one chunk may carry multiple tokens. output_tokens stays 0 here.
|
||||
assert output.output_tokens == 0, "output_tokens should be 0 when no server metrics are present"
|
||||
assert output.generated_text == "Hello world"
|
||||
|
||||
|
||||
@@ -277,7 +279,7 @@ async def test_output_tokens_with_missing_num_tokens_out(mocker: MockerFixture):
|
||||
|
||||
# Assert
|
||||
assert output.success is True
|
||||
assert output.output_tokens == 0, "output_tokens should default to 0 when num_tokens_out is missing"
|
||||
assert output.output_tokens == 0, "output_tokens should be 0 when num_tokens_out is missing"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -579,6 +581,167 @@ class TestTextLatencyAttribute:
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_empty_role_chunk_for_ttft_and_itl(mocker: MockerFixture):
|
||||
"""Role/empty text chunks must not count toward TTFT or ITL."""
|
||||
request_input = RequestFuncInput(
|
||||
model="test-model",
|
||||
model_name="test-model",
|
||||
prompt="test prompt",
|
||||
api_url="http://test.com/v1/chat/completions",
|
||||
prompt_len=10,
|
||||
output_len=20,
|
||||
)
|
||||
|
||||
chunks = [
|
||||
create_sse_chunk(
|
||||
{
|
||||
"choices": [{"delta": {"role": "assistant", "content": ""}}],
|
||||
"modality": "text",
|
||||
}
|
||||
),
|
||||
create_sse_chunk(
|
||||
{
|
||||
"choices": [{"delta": {"content": "Hello"}}],
|
||||
"modality": "text",
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 1, "total_tokens": 11},
|
||||
}
|
||||
),
|
||||
create_sse_chunk(
|
||||
{
|
||||
"choices": [{"delta": {"content": " world"}}],
|
||||
"modality": "text",
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12},
|
||||
}
|
||||
),
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
|
||||
mock_response = MockResponse(200, chunks, delay_between_chunks=0.01)
|
||||
mock_session = mocker.AsyncMock()
|
||||
mock_session.post = mocker.MagicMock(return_value=mock_response)
|
||||
|
||||
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
|
||||
|
||||
assert output.success is True
|
||||
assert output.output_tokens == 2
|
||||
assert len(output.itl) == 1
|
||||
assert output.ttft > 0
|
||||
assert output.text_latency - output.ttft == pytest.approx(sum(output.itl), rel=1e-6, abs=1e-6)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bundled_tokens_split_itl_from_usage(mocker: MockerFixture):
|
||||
"""Multiple tokens in one SSE chunk should expand ITL via usage deltas."""
|
||||
request_input = RequestFuncInput(
|
||||
model="test-model",
|
||||
model_name="test-model",
|
||||
prompt="test prompt",
|
||||
api_url="http://test.com/v1/chat/completions",
|
||||
prompt_len=10,
|
||||
output_len=20,
|
||||
)
|
||||
|
||||
chunks = [
|
||||
create_sse_chunk(
|
||||
{
|
||||
"choices": [{"delta": {"content": "A"}}],
|
||||
"modality": "text",
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 1, "total_tokens": 11},
|
||||
}
|
||||
),
|
||||
create_sse_chunk(
|
||||
{
|
||||
"choices": [{"delta": {"content": "BCD"}}],
|
||||
"modality": "text",
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 4, "total_tokens": 14},
|
||||
}
|
||||
),
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
|
||||
mock_response = MockResponse(200, chunks, delay_between_chunks=0.02)
|
||||
mock_session = mocker.AsyncMock()
|
||||
mock_session.post = mocker.MagicMock(return_value=mock_response)
|
||||
|
||||
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
|
||||
|
||||
assert output.success is True
|
||||
assert output.output_tokens == 4
|
||||
assert len(output.itl) == 3
|
||||
assert output.text_latency - output.ttft == pytest.approx(sum(output.itl), rel=1e-6, abs=1e-6)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_output_tokens_prefers_usage_over_metrics(mocker: MockerFixture):
|
||||
request_input = RequestFuncInput(
|
||||
model="test-model",
|
||||
model_name="test-model",
|
||||
prompt="test prompt",
|
||||
api_url="http://test.com/v1/chat/completions",
|
||||
prompt_len=10,
|
||||
output_len=20,
|
||||
)
|
||||
|
||||
chunks = [
|
||||
create_sse_chunk(
|
||||
{
|
||||
"choices": [{"delta": {"content": "Hello"}}],
|
||||
"modality": "text",
|
||||
"metrics": {"num_tokens_out": 5},
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18},
|
||||
}
|
||||
),
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
|
||||
mock_response = MockResponse(200, chunks)
|
||||
mock_session = mocker.AsyncMock()
|
||||
mock_session.post = mocker.MagicMock(return_value=mock_response)
|
||||
|
||||
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
|
||||
|
||||
assert output.success is True
|
||||
assert output.output_tokens == 8
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metrics_only_chunk_updates_output_tokens(mocker: MockerFixture):
|
||||
request_input = RequestFuncInput(
|
||||
model="test-model",
|
||||
model_name="test-model",
|
||||
prompt="test prompt",
|
||||
api_url="http://test.com/v1/chat/completions",
|
||||
prompt_len=10,
|
||||
output_len=20,
|
||||
)
|
||||
|
||||
chunks = [
|
||||
create_sse_chunk(
|
||||
{
|
||||
"choices": [{"delta": {"content": "Text response"}}],
|
||||
"modality": "text",
|
||||
}
|
||||
),
|
||||
create_sse_chunk(
|
||||
{
|
||||
"modality": "text",
|
||||
"metrics": {"num_tokens_out": 25, "num_tokens_in": 10},
|
||||
}
|
||||
),
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
|
||||
mock_response = MockResponse(200, chunks)
|
||||
mock_session = mocker.AsyncMock()
|
||||
mock_session.post = mocker.MagicMock(return_value=mock_response)
|
||||
|
||||
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
|
||||
|
||||
assert output.success is True
|
||||
assert output.output_tokens == 25
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_len_assigned_from_usage(mocker: MockerFixture):
|
||||
# Arrange: request claims prompt_len=100, but server reports 4992 (multimodal).
|
||||
|
||||
@@ -523,6 +523,7 @@ def api_server(unused_tcp_port_factory, server_case: ServerCase, mock_async_omni
|
||||
"Qwen/Qwen-Image-Edit",
|
||||
True,
|
||||
id="image-to-image-dalle-endpoint",
|
||||
marks=pytest.mark.skip(reason="Temporarily disabled due to failure."),
|
||||
),
|
||||
pytest.param(
|
||||
ServerCase(
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Unit tests for ServerSettings env-var parameterization."""
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.config.server_settings import ServerSettings
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
ENV_VARS = [
|
||||
"VLLM_OMNI_SERVER_STORAGE__PATH",
|
||||
"VLLM_OMNI_SERVER_STORAGE__FILE_CONCURRENCY",
|
||||
"VLLM_OMNI_SERVER_STORAGE__FILE_TTL",
|
||||
"VLLM_OMNI_SERVER_STORAGE__TTL_SWEEP_INTERVAL",
|
||||
"VLLM_OMNI_STORAGE_PATH",
|
||||
"VLLM_OMNI_STORAGE_MAX_CONCURRENCY",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_env(monkeypatch):
|
||||
for name in ENV_VARS:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
|
||||
def test_defaults_when_no_env_set():
|
||||
storage = ServerSettings().storage
|
||||
|
||||
assert storage.path == "/tmp/storage"
|
||||
assert storage.file_concurrency == 4
|
||||
assert storage.file_ttl is None
|
||||
assert storage.ttl_sweep_interval is None
|
||||
|
||||
|
||||
def test_nested_env_vars_populate_storage(monkeypatch):
|
||||
monkeypatch.setenv("VLLM_OMNI_SERVER_STORAGE__PATH", "/var/storage")
|
||||
monkeypatch.setenv("VLLM_OMNI_SERVER_STORAGE__FILE_CONCURRENCY", "8")
|
||||
monkeypatch.setenv("VLLM_OMNI_SERVER_STORAGE__FILE_TTL", "120")
|
||||
monkeypatch.setenv("VLLM_OMNI_SERVER_STORAGE__TTL_SWEEP_INTERVAL", "30")
|
||||
|
||||
storage = ServerSettings().storage
|
||||
|
||||
assert storage.path == "/var/storage"
|
||||
assert storage.file_concurrency == 8
|
||||
assert storage.file_ttl == 120
|
||||
assert storage.ttl_sweep_interval == 30
|
||||
|
||||
|
||||
def test_ttl_sweep_interval_defaults_when_ttl_set(monkeypatch):
|
||||
monkeypatch.setenv("VLLM_OMNI_SERVER_STORAGE__FILE_TTL", "60")
|
||||
|
||||
storage = ServerSettings().storage
|
||||
|
||||
assert storage.file_ttl == 60
|
||||
assert storage.ttl_sweep_interval == 300
|
||||
|
||||
|
||||
def test_deprecated_path_alias_populates_and_warns(monkeypatch):
|
||||
monkeypatch.setenv("VLLM_OMNI_STORAGE_PATH", "/legacy/path")
|
||||
|
||||
with pytest.warns(DeprecationWarning, match="VLLM_OMNI_STORAGE_PATH"):
|
||||
storage = ServerSettings().storage
|
||||
|
||||
assert storage.path == "/legacy/path"
|
||||
|
||||
|
||||
def test_deprecated_concurrency_alias_populates_and_warns(monkeypatch):
|
||||
monkeypatch.setenv("VLLM_OMNI_STORAGE_MAX_CONCURRENCY", "16")
|
||||
|
||||
with pytest.warns(DeprecationWarning, match="VLLM_OMNI_STORAGE_MAX_CONCURRENCY"):
|
||||
storage = ServerSettings().storage
|
||||
|
||||
assert storage.file_concurrency == 16
|
||||
|
||||
|
||||
def test_new_env_var_wins_over_deprecated_alias(monkeypatch):
|
||||
monkeypatch.setenv("VLLM_OMNI_SERVER_STORAGE__PATH", "/new/path")
|
||||
monkeypatch.setenv("VLLM_OMNI_STORAGE_PATH", "/legacy/path")
|
||||
|
||||
with pytest.warns(DeprecationWarning, match="VLLM_OMNI_STORAGE_PATH"):
|
||||
storage = ServerSettings().storage
|
||||
|
||||
assert storage.path == "/new/path"
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Tests for OmniGenerationScheduler update_from_output finish conditions.
|
||||
|
||||
The three-way finish condition in update_from_output determines when a
|
||||
generation request (e.g. Code2Wav) is marked FINISHED_STOPPED:
|
||||
|
||||
1. No adapter (sync mode): finish when all prompt tokens are computed.
|
||||
2. Adapter present, adapter done: finish when computed AND adapter signals done.
|
||||
3. Adapter present, adapter NOT done: do NOT finish — wait for next chunk.
|
||||
|
||||
These cases are the core correctness guarantee for async_chunk batch mode.
|
||||
Without condition #3, a request finishes prematurely after processing
|
||||
only the first chunk, producing truncated audio.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
from vllm.v1.request import RequestStatus
|
||||
|
||||
|
||||
class FakeRequest:
|
||||
"""Minimal request for testing finish conditions."""
|
||||
|
||||
def __init__(self, request_id: str, prompt_len: int, num_computed: int):
|
||||
self.request_id = request_id
|
||||
self.prompt_token_ids = list(range(prompt_len))
|
||||
self.num_computed_tokens = num_computed
|
||||
self.status = RequestStatus.RUNNING
|
||||
self.stop_reason = None
|
||||
self.sampling_params = None
|
||||
self.num_cached_tokens = 0
|
||||
self.num_output_tokens = 0
|
||||
self.num_output_placeholders = 0
|
||||
self.num_external_computed_tokens = 0
|
||||
self.trace_headers = None
|
||||
self.num_nans_in_logits = 0
|
||||
self.client_index = 0
|
||||
|
||||
def is_finished(self):
|
||||
return self.status in (
|
||||
RequestStatus.FINISHED_STOPPED,
|
||||
RequestStatus.FINISHED_LENGTH_CAPPED,
|
||||
RequestStatus.FINISHED_ABORTED,
|
||||
RequestStatus.FINISHED_ERROR,
|
||||
)
|
||||
|
||||
def get_finished_reason(self):
|
||||
return "stop"
|
||||
|
||||
def take_events(self):
|
||||
return None
|
||||
|
||||
|
||||
class FakeChunkAdapter:
|
||||
"""Minimal chunk transfer adapter mock."""
|
||||
|
||||
def __init__(self, finished_request_ids: set[str] | None = None):
|
||||
self.finished_requests = finished_request_ids or set()
|
||||
|
||||
def cleanup(self, req_id, external_req_id=None):
|
||||
pass
|
||||
|
||||
|
||||
def _evaluate_finish_condition(request, chunk_transfer_adapter):
|
||||
"""Extract the three-way finish logic from update_from_output."""
|
||||
_all_computed = request.num_computed_tokens >= len(request.prompt_token_ids)
|
||||
_adapter_done = (
|
||||
chunk_transfer_adapter is not None and request.request_id in chunk_transfer_adapter.finished_requests
|
||||
)
|
||||
return (
|
||||
request.status == RequestStatus.FINISHED_STOPPED
|
||||
or (_all_computed and chunk_transfer_adapter is None)
|
||||
or (_all_computed and _adapter_done)
|
||||
)
|
||||
|
||||
|
||||
class TestFinishConditionSyncMode(unittest.TestCase):
|
||||
"""Case 1: No adapter (sync mode) — finish when all tokens computed."""
|
||||
|
||||
def test_finishes_when_all_computed(self):
|
||||
req = FakeRequest("r1", prompt_len=100, num_computed=100)
|
||||
assert _evaluate_finish_condition(req, chunk_transfer_adapter=None)
|
||||
|
||||
def test_does_not_finish_when_partially_computed(self):
|
||||
req = FakeRequest("r1", prompt_len=100, num_computed=50)
|
||||
assert not _evaluate_finish_condition(req, chunk_transfer_adapter=None)
|
||||
|
||||
|
||||
class TestFinishConditionAsyncAdapterDone(unittest.TestCase):
|
||||
"""Case 2: Adapter present AND adapter signals done — finish."""
|
||||
|
||||
def test_finishes_when_computed_and_adapter_done(self):
|
||||
req = FakeRequest("r1", prompt_len=100, num_computed=100)
|
||||
adapter = FakeChunkAdapter(finished_request_ids={"r1"})
|
||||
assert _evaluate_finish_condition(req, adapter)
|
||||
|
||||
def test_does_not_finish_when_computed_but_adapter_not_done(self):
|
||||
"""Case 3: Adapter present but NOT done — must NOT finish."""
|
||||
req = FakeRequest("r1", prompt_len=100, num_computed=100)
|
||||
adapter = FakeChunkAdapter(finished_request_ids=set())
|
||||
assert not _evaluate_finish_condition(req, adapter)
|
||||
|
||||
|
||||
class TestFinishConditionAsyncNotComputed(unittest.TestCase):
|
||||
"""Adapter present, tokens not all computed — never finishes."""
|
||||
|
||||
def test_does_not_finish_partial_compute_adapter_done(self):
|
||||
req = FakeRequest("r1", prompt_len=100, num_computed=50)
|
||||
adapter = FakeChunkAdapter(finished_request_ids={"r1"})
|
||||
assert not _evaluate_finish_condition(req, adapter)
|
||||
|
||||
def test_does_not_finish_partial_compute_adapter_not_done(self):
|
||||
req = FakeRequest("r1", prompt_len=100, num_computed=50)
|
||||
adapter = FakeChunkAdapter(finished_request_ids=set())
|
||||
assert not _evaluate_finish_condition(req, adapter)
|
||||
|
||||
|
||||
class TestFinishConditionAlreadyStopped(unittest.TestCase):
|
||||
"""Request already FINISHED_STOPPED — always returns True."""
|
||||
|
||||
def test_already_stopped_no_adapter(self):
|
||||
req = FakeRequest("r1", prompt_len=100, num_computed=0)
|
||||
req.status = RequestStatus.FINISHED_STOPPED
|
||||
assert _evaluate_finish_condition(req, chunk_transfer_adapter=None)
|
||||
|
||||
def test_already_stopped_with_adapter(self):
|
||||
req = FakeRequest("r1", prompt_len=100, num_computed=0)
|
||||
req.status = RequestStatus.FINISHED_STOPPED
|
||||
adapter = FakeChunkAdapter(finished_request_ids=set())
|
||||
assert _evaluate_finish_condition(req, adapter)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -556,6 +556,10 @@ def benchmark_params(request, diffusion_server):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_STAGE_METRICS_ENDPOINTS = {"/v1/chat/completions"}
|
||||
_DIFFUSION_PIPELINE_PROFILER_ARG = "enable-diffusion-pipeline-profiler"
|
||||
|
||||
|
||||
def run_benchmark(
|
||||
host: str,
|
||||
port: int,
|
||||
@@ -608,6 +612,11 @@ def run_benchmark(
|
||||
str(tmp_result_file),
|
||||
]
|
||||
|
||||
serve_args_dict = (server_cfg or {}).get("serve_args_dict")
|
||||
profiler_enabled = isinstance(serve_args_dict, dict) and bool(serve_args_dict.get(_DIFFUSION_PIPELINE_PROFILER_ARG))
|
||||
if endpoint in _STAGE_METRICS_ENDPOINTS and profiler_enabled:
|
||||
cmd.append("--return-stage-metrics")
|
||||
|
||||
for key, value in params.items():
|
||||
if key in exclude_keys or value is None:
|
||||
continue
|
||||
|
||||
@@ -41,7 +41,11 @@
|
||||
"tensor_parallel_size": 2,
|
||||
"hf_overrides": {
|
||||
"rope_parameters": {
|
||||
"mrope_section": [0, 32, 32],
|
||||
"mrope_section": [
|
||||
0,
|
||||
32,
|
||||
32
|
||||
],
|
||||
"rope_type": "default"
|
||||
}
|
||||
},
|
||||
@@ -58,7 +62,9 @@
|
||||
"max_tokens": 8192,
|
||||
"detokenize": true,
|
||||
"skip_special_tokens": false,
|
||||
"stop_token_ids": [128025]
|
||||
"stop_token_ids": [
|
||||
128025
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -106,7 +112,9 @@
|
||||
"content": [
|
||||
{
|
||||
"prompt": "新年宠物海报,Q版圆润的可爱标题“新年快乐汪”,副标题“HAPPY NEW YEAR”。 鱼眼镜头,背景是房间门口,近景,上传的主体歪头笑,围着红色围巾,戴着红色毛线帽,高清,绒毛细节,面部特写。 宝丽莱相纸,超现实主义,写实主义,胶片摄影,打印颗粒感肌理。肌理,超写实,复古感。",
|
||||
"image_urls": ["https://raw.githubusercontent.com/Tencent-Hunyuan/HunyuanImage-3.0/main/assets/demo_instruct_imgs/input_0_0.png"],
|
||||
"image_urls": [
|
||||
"https://raw.githubusercontent.com/Tencent-Hunyuan/HunyuanImage-3.0/main/assets/demo_instruct_imgs/input_0_0.png"
|
||||
],
|
||||
"num_inference_steps": 8,
|
||||
"seed": 42,
|
||||
"guidance_scale": 1.0
|
||||
@@ -119,10 +127,9 @@
|
||||
"num-prompts": 96,
|
||||
"skip-performance-assertion": true,
|
||||
"baseline": {
|
||||
"throughput_qps": 0.1,
|
||||
"latency_p99": 44.46,
|
||||
"latency_mean": 35.57,
|
||||
"latency_std_dev": 0.5,
|
||||
"throughput_qps": 0.1874,
|
||||
"latency_p99": 101.852,
|
||||
"latency_mean": 79.0385,
|
||||
"peak_memory_mb_max": 70000,
|
||||
"peak_memory_mb_mean": 70000
|
||||
}
|
||||
|
||||
@@ -23,10 +23,11 @@
|
||||
"max-concurrency": 1,
|
||||
"skip-performance-assertion": true,
|
||||
"baseline": {
|
||||
"throughput_qps": 0.21,
|
||||
"latency_p99": 4.9235,
|
||||
"peak_memory_mb_max": 57576,
|
||||
"peak_memory_mb_mean": 57576
|
||||
"throughput_qps": 0.213,
|
||||
"latency_mean": 4.6953,
|
||||
"latency_p99": 4.8601,
|
||||
"peak_memory_mb_max": 56912.0,
|
||||
"peak_memory_mb_mean": 56912.0
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -32,25 +32,25 @@
|
||||
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
|
||||
"baseline": {
|
||||
"mean_ttft_ms": [
|
||||
135.4379,
|
||||
187.0896,
|
||||
400,
|
||||
300,
|
||||
400,
|
||||
600,
|
||||
900
|
||||
435.675,
|
||||
724.6836
|
||||
],
|
||||
"mean_e2el_ms": [
|
||||
26000,
|
||||
43000,
|
||||
52000,
|
||||
72000,
|
||||
109000
|
||||
17619.9851,
|
||||
34811.8335,
|
||||
43656.9501,
|
||||
56657.1548,
|
||||
85097.6016
|
||||
],
|
||||
"mean_audio_rtf": [
|
||||
0.15,
|
||||
0.30,
|
||||
0.35,
|
||||
0.50,
|
||||
0.75
|
||||
0.1279,
|
||||
0.2238,
|
||||
0.2735,
|
||||
0.3712,
|
||||
0.5634
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -79,13 +79,13 @@
|
||||
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
|
||||
"baseline": {
|
||||
"mean_ttft_ms": [
|
||||
600
|
||||
340.329
|
||||
],
|
||||
"mean_e2el_ms": [
|
||||
6000
|
||||
4305.477
|
||||
],
|
||||
"mean_audio_rtf": [
|
||||
0.20
|
||||
0.1305
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -116,13 +116,13 @@
|
||||
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
|
||||
"baseline": {
|
||||
"mean_ttft_ms": [
|
||||
300
|
||||
220.0909
|
||||
],
|
||||
"mean_e2el_ms": [
|
||||
7000
|
||||
5475.9565
|
||||
],
|
||||
"mean_audio_rtf": [
|
||||
0.25
|
||||
0.1818
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -155,13 +155,13 @@
|
||||
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
|
||||
"baseline": {
|
||||
"mean_ttft_ms": [
|
||||
700
|
||||
450.0155
|
||||
],
|
||||
"mean_e2el_ms": [
|
||||
11000
|
||||
8516.8316
|
||||
],
|
||||
"mean_audio_rtf": [
|
||||
0.35
|
||||
0.2717
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -194,18 +194,18 @@
|
||||
"percentile-metrics": "ttft,tpot,itl,e2el",
|
||||
"baseline": {
|
||||
"mean_ttft_ms": [
|
||||
100,
|
||||
300,
|
||||
500,
|
||||
800,
|
||||
1500
|
||||
78.4278,
|
||||
195.8037,
|
||||
333.7318,
|
||||
561.805,
|
||||
1038.043
|
||||
],
|
||||
"mean_e2el_ms": [
|
||||
5000,
|
||||
8000,
|
||||
10000,
|
||||
14000,
|
||||
20000
|
||||
4288.3029,
|
||||
7429.9429,
|
||||
9451.6184,
|
||||
12607.5292,
|
||||
18153.2547
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -239,10 +239,10 @@
|
||||
"percentile-metrics": "ttft,tpot,itl,e2el",
|
||||
"baseline": {
|
||||
"mean_ttft_ms": [
|
||||
500
|
||||
275.3875
|
||||
],
|
||||
"mean_e2el_ms": [
|
||||
1000
|
||||
730.6995
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -278,10 +278,10 @@
|
||||
"percentile-metrics": "ttft,tpot,itl,e2el",
|
||||
"baseline": {
|
||||
"mean_ttft_ms": [
|
||||
200
|
||||
166.2201
|
||||
],
|
||||
"mean_e2el_ms": [
|
||||
800
|
||||
673.5789
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -319,10 +319,10 @@
|
||||
"percentile-metrics": "ttft,tpot,itl,e2el",
|
||||
"baseline": {
|
||||
"mean_ttft_ms": [
|
||||
600
|
||||
357.7781
|
||||
],
|
||||
"mean_e2el_ms": [
|
||||
1300
|
||||
953.3493
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,25 +32,25 @@
|
||||
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
|
||||
"baseline": {
|
||||
"mean_ttft_ms": [
|
||||
400,
|
||||
300,
|
||||
400,
|
||||
600,
|
||||
900
|
||||
109.1218,
|
||||
247.4411,
|
||||
354.9384,
|
||||
447.7885,
|
||||
694.7487
|
||||
],
|
||||
"mean_e2el_ms": [
|
||||
32000,
|
||||
34000,
|
||||
45000,
|
||||
61000,
|
||||
90000
|
||||
22413.667,
|
||||
30808.9715,
|
||||
35677.844,
|
||||
46345.8978,
|
||||
68350.4853
|
||||
],
|
||||
"mean_audio_rtf": [
|
||||
0.20,
|
||||
0.25,
|
||||
0.30,
|
||||
0.45,
|
||||
0.60
|
||||
0.1695,
|
||||
0.2033,
|
||||
0.2467,
|
||||
0.3234,
|
||||
0.4438
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -79,13 +79,13 @@
|
||||
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
|
||||
"baseline": {
|
||||
"mean_ttft_ms": [
|
||||
500
|
||||
323.3573
|
||||
],
|
||||
"mean_e2el_ms": [
|
||||
6000
|
||||
4623.4173
|
||||
],
|
||||
"mean_audio_rtf": [
|
||||
0.20
|
||||
0.1406
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -116,13 +116,13 @@
|
||||
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
|
||||
"baseline": {
|
||||
"mean_ttft_ms": [
|
||||
300
|
||||
215.4502
|
||||
],
|
||||
"mean_e2el_ms": [
|
||||
7000
|
||||
4924.149
|
||||
],
|
||||
"mean_audio_rtf": [
|
||||
0.20
|
||||
0.1637
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -155,13 +155,13 @@
|
||||
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
|
||||
"baseline": {
|
||||
"mean_ttft_ms": [
|
||||
700
|
||||
442.5815
|
||||
],
|
||||
"mean_e2el_ms": [
|
||||
9000
|
||||
6441.0986
|
||||
],
|
||||
"mean_audio_rtf": [
|
||||
0.30
|
||||
0.2091
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,18 +40,18 @@
|
||||
"percentile-metrics": "ttft,tpot,itl,e2el",
|
||||
"baseline": {
|
||||
"mean_ttft_ms": [
|
||||
300,
|
||||
300,
|
||||
1000,
|
||||
600,
|
||||
800
|
||||
69.1276,
|
||||
255.1882,
|
||||
310.639,
|
||||
422.0337,
|
||||
532.0582
|
||||
],
|
||||
"mean_e2el_ms": [
|
||||
6000,
|
||||
8000,
|
||||
11000,
|
||||
13000,
|
||||
17000
|
||||
4382.7211,
|
||||
7510.3815,
|
||||
9440.4854,
|
||||
12553.0186,
|
||||
16932.6065
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -86,10 +86,10 @@
|
||||
"percentile-metrics": "ttft,tpot,itl,e2el",
|
||||
"baseline": {
|
||||
"mean_ttft_ms": [
|
||||
30
|
||||
26.3381
|
||||
],
|
||||
"mean_e2el_ms": [
|
||||
540
|
||||
485.2436
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -126,10 +126,10 @@
|
||||
"percentile-metrics": "ttft,tpot,itl,e2el",
|
||||
"baseline": {
|
||||
"mean_ttft_ms": [
|
||||
30
|
||||
26.4517
|
||||
],
|
||||
"mean_e2el_ms": [
|
||||
600
|
||||
517.2017
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -168,10 +168,10 @@
|
||||
"percentile-metrics": "ttft,tpot,itl,e2el",
|
||||
"baseline": {
|
||||
"mean_ttft_ms": [
|
||||
40
|
||||
27.6972
|
||||
],
|
||||
"mean_e2el_ms": [
|
||||
700
|
||||
561.3684
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@
|
||||
"num-input-images": 2,
|
||||
"enable-negative-prompt": true,
|
||||
"baseline": {
|
||||
"throughput_qps": 0.06,
|
||||
"latency_mean": 20,
|
||||
"peak_memory_mb_max": 57000,
|
||||
"peak_memory_mb_mean": 57000
|
||||
"throughput_qps": 0.0721,
|
||||
"latency_mean": 13.8398,
|
||||
"peak_memory_mb_max": 54731.4,
|
||||
"peak_memory_mb_mean": 54731.4
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -40,10 +40,10 @@
|
||||
"num-input-images": 2,
|
||||
"enable-negative-prompt": true,
|
||||
"baseline": {
|
||||
"throughput_qps": 0.01,
|
||||
"latency_mean": 60,
|
||||
"peak_memory_mb_max": 67000,
|
||||
"peak_memory_mb_mean": 67000
|
||||
"throughput_qps": 0.0186,
|
||||
"latency_mean": 53.6942,
|
||||
"peak_memory_mb_max": 64402.4,
|
||||
"peak_memory_mb_mean": 64402.4
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -75,10 +75,10 @@
|
||||
"num-input-images": 2,
|
||||
"enable-negative-prompt": true,
|
||||
"baseline": {
|
||||
"throughput_qps": 0.05,
|
||||
"latency_mean": 20,
|
||||
"peak_memory_mb_max": 55000,
|
||||
"peak_memory_mb_mean": 55000
|
||||
"throughput_qps": 0.0538,
|
||||
"latency_mean": 18.5591,
|
||||
"peak_memory_mb_max": 53485.0,
|
||||
"peak_memory_mb_mean": 53485.0
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -120,10 +120,10 @@
|
||||
"num-input-images": 2,
|
||||
"enable-negative-prompt": true,
|
||||
"baseline": {
|
||||
"throughput_qps": 0.21,
|
||||
"latency_mean": 10,
|
||||
"peak_memory_mb_max": 57000,
|
||||
"peak_memory_mb_mean": 57000
|
||||
"throughput_qps": 0.2776,
|
||||
"latency_mean": 3.5938,
|
||||
"peak_memory_mb_max": 55214.0,
|
||||
"peak_memory_mb_mean": 55214.0
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -138,10 +138,10 @@
|
||||
"num-input-images": 2,
|
||||
"enable-negative-prompt": true,
|
||||
"baseline": {
|
||||
"throughput_qps": 0.08,
|
||||
"latency_mean": 20,
|
||||
"peak_memory_mb_max": 66000,
|
||||
"peak_memory_mb_mean": 66000
|
||||
"throughput_qps": 0.1051,
|
||||
"latency_mean": 9.4918,
|
||||
"peak_memory_mb_max": 64265.6,
|
||||
"peak_memory_mb_mean": 64265.6
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -21,10 +21,10 @@
|
||||
"max-concurrency": 1,
|
||||
"enable-negative-prompt": true,
|
||||
"baseline": {
|
||||
"throughput_qps": 0.1,
|
||||
"latency_mean": 10,
|
||||
"peak_memory_mb_max": 57000,
|
||||
"peak_memory_mb_mean": 57000
|
||||
"throughput_qps": 0.1129,
|
||||
"latency_mean": 8.8352,
|
||||
"peak_memory_mb_max": 55356.5,
|
||||
"peak_memory_mb_mean": 55356.5
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -38,10 +38,10 @@
|
||||
"max-concurrency": 1,
|
||||
"enable-negative-prompt": true,
|
||||
"baseline": {
|
||||
"throughput_qps": 0.02,
|
||||
"latency_mean": 50,
|
||||
"peak_memory_mb_max": 65000,
|
||||
"peak_memory_mb_mean": 65000
|
||||
"throughput_qps": 0.0240,
|
||||
"latency_mean": 41.5686,
|
||||
"peak_memory_mb_max": 62762.7,
|
||||
"peak_memory_mb_mean": 62762.7
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -72,10 +72,10 @@
|
||||
"max-concurrency": 1,
|
||||
"enable-negative-prompt": true,
|
||||
"baseline": {
|
||||
"throughput_qps": 0.06,
|
||||
"latency_mean": 20,
|
||||
"peak_memory_mb_max": 56000,
|
||||
"peak_memory_mb_mean": 56000
|
||||
"throughput_qps": 0.0672,
|
||||
"latency_mean": 14.8517,
|
||||
"peak_memory_mb_max": 53931.5,
|
||||
"peak_memory_mb_mean": 53931.5
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -116,10 +116,10 @@
|
||||
"max-concurrency": 1,
|
||||
"enable-negative-prompt": true,
|
||||
"baseline": {
|
||||
"throughput_qps": 0.26,
|
||||
"latency_mean": 10,
|
||||
"peak_memory_mb_max": 58000,
|
||||
"peak_memory_mb_mean": 58000
|
||||
"throughput_qps": 0.2879,
|
||||
"latency_mean": 3.4701,
|
||||
"peak_memory_mb_max": 55525.6,
|
||||
"peak_memory_mb_mean": 55525.6
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -133,10 +133,10 @@
|
||||
"max-concurrency": 1,
|
||||
"enable-negative-prompt": true,
|
||||
"baseline": {
|
||||
"throughput_qps": 0.1,
|
||||
"latency_mean": 10,
|
||||
"peak_memory_mb_max": 67000,
|
||||
"peak_memory_mb_mean": 67000
|
||||
"throughput_qps": 0.1113,
|
||||
"latency_mean": 8.9656,
|
||||
"peak_memory_mb_max": 63758.3,
|
||||
"peak_memory_mb_mean": 63758.3
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -21,10 +21,10 @@
|
||||
"max-concurrency": 1,
|
||||
"enable-negative-prompt": true,
|
||||
"baseline": {
|
||||
"throughput_qps": 0.05,
|
||||
"latency_mean": 20,
|
||||
"peak_memory_mb_max": 63000,
|
||||
"peak_memory_mb_mean": 63000
|
||||
"throughput_qps": 0.0703,
|
||||
"latency_mean": 14.208,
|
||||
"peak_memory_mb_max": 60543.5,
|
||||
"peak_memory_mb_mean": 60543.5
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -38,10 +38,10 @@
|
||||
"max-concurrency": 1,
|
||||
"enable-negative-prompt": true,
|
||||
"baseline": {
|
||||
"throughput_qps": 0.03,
|
||||
"latency_mean": 30,
|
||||
"peak_memory_mb_max": 63000,
|
||||
"peak_memory_mb_mean": 63000
|
||||
"throughput_qps": 0.0417,
|
||||
"latency_mean": 23.914,
|
||||
"peak_memory_mb_max": 60543.5,
|
||||
"peak_memory_mb_mean": 60543.5
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
"max-concurrency": 1,
|
||||
"enable-negative-prompt": true,
|
||||
"baseline": {
|
||||
"throughput_qps": 0.35,
|
||||
"latency_mean": 3,
|
||||
"peak_memory_mb_mean": 56000
|
||||
"throughput_qps": 0.4153,
|
||||
"latency_mean": 2.4065,
|
||||
"peak_memory_mb_mean": 52289.9
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -37,9 +37,9 @@
|
||||
"max-concurrency": 1,
|
||||
"enable-negative-prompt": true,
|
||||
"baseline": {
|
||||
"throughput_qps": 0.03,
|
||||
"latency_mean": 26,
|
||||
"peak_memory_mb_mean": 65000
|
||||
"throughput_qps": 0.0423,
|
||||
"latency_mean": 23.5991,
|
||||
"peak_memory_mb_mean": 61360.5
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -67,8 +67,8 @@
|
||||
"max-concurrency": 1,
|
||||
"enable-negative-prompt": true,
|
||||
"baseline": {
|
||||
"throughput_qps": 0.35,
|
||||
"latency_mean": 3,
|
||||
"throughput_qps": 0.4175,
|
||||
"latency_mean": 2.3953,
|
||||
"peak_memory_mb_mean": 56000
|
||||
}
|
||||
},
|
||||
@@ -83,8 +83,8 @@
|
||||
"max-concurrency": 1,
|
||||
"enable-negative-prompt": true,
|
||||
"baseline": {
|
||||
"throughput_qps": 0.03,
|
||||
"latency_mean": 26,
|
||||
"throughput_qps": 0.0423,
|
||||
"latency_mean": 23.5768,
|
||||
"peak_memory_mb_mean": 65000
|
||||
}
|
||||
}
|
||||
@@ -116,9 +116,9 @@
|
||||
"max-concurrency": 1,
|
||||
"enable-negative-prompt": true,
|
||||
"baseline": {
|
||||
"throughput_qps": 0.11,
|
||||
"latency_mean": 9,
|
||||
"peak_memory_mb_mean": 55000
|
||||
"throughput_qps": 0.1214,
|
||||
"latency_mean": 8.2211,
|
||||
"peak_memory_mb_mean": 51704.7
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -159,9 +159,9 @@
|
||||
"max-concurrency": 1,
|
||||
"enable-negative-prompt": true,
|
||||
"baseline": {
|
||||
"throughput_qps": 0.46,
|
||||
"latency_mean": 3,
|
||||
"peak_memory_mb_mean": 56000
|
||||
"throughput_qps": 0.559,
|
||||
"latency_mean": 1.794,
|
||||
"peak_memory_mb_mean": 52397.6775
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -175,9 +175,9 @@
|
||||
"max-concurrency": 1,
|
||||
"enable-negative-prompt": true,
|
||||
"baseline": {
|
||||
"throughput_qps": 0.18,
|
||||
"latency_mean": 6,
|
||||
"peak_memory_mb_mean": 67000
|
||||
"throughput_qps": 0.1918,
|
||||
"latency_mean": 5.2022,
|
||||
"peak_memory_mb_mean": 61468.8
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -209,8 +209,8 @@
|
||||
"warmup-num-inference-steps": 20,
|
||||
"enable-negative-prompt": true,
|
||||
"baseline": {
|
||||
"throughput_qps": [0.30, 0.50, 0.60, 0.70],
|
||||
"latency_mean": [3.50, 4.00, 7.00, 12.00],
|
||||
"throughput_qps": [0.4125, 0.6714, 0.6674, 0.8159],
|
||||
"latency_mean": [2.4215, 2.992, 5.9293, 9.656],
|
||||
"peak_memory_mb_mean": [67000, 67000, 67000, 67000]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,10 @@
|
||||
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
|
||||
"baseline": {
|
||||
"median_audio_ttfp_ms": [
|
||||
3000
|
||||
139.9434
|
||||
],
|
||||
"median_audio_rtf": [
|
||||
0.6
|
||||
0.1683
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -55,19 +55,19 @@
|
||||
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
|
||||
"baseline": {
|
||||
"median_audio_ttfp_ms": [
|
||||
1000,
|
||||
3000,
|
||||
14000
|
||||
227.5653,
|
||||
647.1272,
|
||||
4777.2714
|
||||
],
|
||||
"median_audio_rtf": [
|
||||
0.70,
|
||||
1.15,
|
||||
3.90
|
||||
0.3313,
|
||||
0.3642,
|
||||
1.2282
|
||||
],
|
||||
"audio_throughput": [
|
||||
12,
|
||||
14,
|
||||
14
|
||||
23.501,
|
||||
40.634,
|
||||
43.0758
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -129,10 +129,10 @@
|
||||
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
|
||||
"baseline": {
|
||||
"median_audio_ttfp_ms": [
|
||||
2000
|
||||
49.7908
|
||||
],
|
||||
"median_audio_rtf": [
|
||||
0.40
|
||||
0.1425
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -162,19 +162,19 @@
|
||||
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
|
||||
"baseline": {
|
||||
"median_audio_ttfp_ms": [
|
||||
300,
|
||||
1000,
|
||||
5000
|
||||
75.3341,
|
||||
713.7727,
|
||||
5941.7179
|
||||
],
|
||||
"median_audio_rtf": [
|
||||
0.25,
|
||||
0.35,
|
||||
1.10
|
||||
0.1859,
|
||||
0.3148,
|
||||
1.1662
|
||||
],
|
||||
"audio_throughput": [
|
||||
30,
|
||||
40,
|
||||
40
|
||||
36.465,
|
||||
49.6268,
|
||||
48.3091
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -228,13 +228,13 @@
|
||||
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
|
||||
"baseline": {
|
||||
"median_audio_ttfp_ms": [
|
||||
100
|
||||
56.6977
|
||||
],
|
||||
"median_audio_rtf": [
|
||||
0.20
|
||||
0.1555
|
||||
],
|
||||
"audio_throughput": [
|
||||
10
|
||||
11.5292
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -259,10 +259,10 @@
|
||||
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
|
||||
"baseline": {
|
||||
"median_audio_ttfp_ms": [
|
||||
100
|
||||
52.0447
|
||||
],
|
||||
"median_audio_rtf": [
|
||||
0.15
|
||||
0.1452
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -291,19 +291,19 @@
|
||||
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
|
||||
"baseline": {
|
||||
"median_audio_ttfp_ms": [
|
||||
100,
|
||||
1000,
|
||||
5000
|
||||
75.5967,
|
||||
587.5877,
|
||||
4960.3726
|
||||
],
|
||||
"median_audio_rtf": [
|
||||
0.25,
|
||||
0.40,
|
||||
1.20
|
||||
0.1871,
|
||||
0.3143,
|
||||
1.1703
|
||||
],
|
||||
"audio_throughput": [
|
||||
30,
|
||||
40,
|
||||
40
|
||||
40.2198,
|
||||
48.9046,
|
||||
48.0505
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -328,13 +328,13 @@
|
||||
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
|
||||
"baseline": {
|
||||
"median_audio_ttfp_ms": [
|
||||
100
|
||||
56.3961
|
||||
],
|
||||
"median_audio_rtf": [
|
||||
0.20
|
||||
0.1542
|
||||
],
|
||||
"audio_throughput": [
|
||||
9
|
||||
9.6672
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -353,14 +353,24 @@
|
||||
"backend": "openai-audio-speech",
|
||||
"endpoint": "/v1/audio/speech",
|
||||
"dataset_path": "benchmarks/build_dataset/seed_tts_smoke",
|
||||
"num_prompts": [20],
|
||||
"max_concurrency": [1],
|
||||
"num_prompts": [
|
||||
20
|
||||
],
|
||||
"max_concurrency": [
|
||||
1
|
||||
],
|
||||
"seed_tts_locale": "en",
|
||||
"extra_body": {"voice": "casual_male"},
|
||||
"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]
|
||||
"median_audio_ttfp_ms": [
|
||||
150
|
||||
],
|
||||
"median_audio_rtf": [
|
||||
0.15
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -370,15 +380,27 @@
|
||||
"backend": "openai-audio-speech",
|
||||
"endpoint": "/v1/audio/speech",
|
||||
"dataset_path": "benchmarks/build_dataset/seed_tts_smoke",
|
||||
"num_prompts": [80],
|
||||
"max_concurrency": [8],
|
||||
"num_prompts": [
|
||||
80
|
||||
],
|
||||
"max_concurrency": [
|
||||
8
|
||||
],
|
||||
"seed_tts_locale": "en",
|
||||
"extra_body": {"voice": "casual_male"},
|
||||
"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]
|
||||
"median_audio_ttfp_ms": [
|
||||
1500
|
||||
],
|
||||
"median_audio_rtf": [
|
||||
0.3
|
||||
],
|
||||
"audio_throughput": [
|
||||
30.0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -388,15 +410,27 @@
|
||||
"backend": "openai-audio-speech",
|
||||
"endpoint": "/v1/audio/speech",
|
||||
"dataset_path": "benchmarks/build_dataset/seed_tts_smoke",
|
||||
"num_prompts": [100],
|
||||
"request_rate": [2.0],
|
||||
"num_prompts": [
|
||||
100
|
||||
],
|
||||
"request_rate": [
|
||||
2.0
|
||||
],
|
||||
"seed_tts_locale": "en",
|
||||
"extra_body": {"voice": "casual_male"},
|
||||
"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]
|
||||
"median_audio_ttfp_ms": [
|
||||
3000
|
||||
],
|
||||
"median_audio_rtf": [
|
||||
0.25
|
||||
],
|
||||
"audio_throughput": [
|
||||
4.0
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -23,10 +23,10 @@
|
||||
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
|
||||
"baseline": {
|
||||
"median_audio_ttfp_ms": [
|
||||
600
|
||||
130.2115
|
||||
],
|
||||
"median_audio_rtf": [
|
||||
0.2
|
||||
0.0488
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -47,13 +47,13 @@
|
||||
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
|
||||
"baseline": {
|
||||
"median_audio_ttfp_ms": [
|
||||
3500
|
||||
475.3357
|
||||
],
|
||||
"median_audio_rtf": [
|
||||
0.85
|
||||
0.1671
|
||||
],
|
||||
"audio_throughput": [
|
||||
8
|
||||
22.8717
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -100,10 +100,10 @@
|
||||
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
|
||||
"baseline": {
|
||||
"median_audio_ttfp_ms": [
|
||||
350
|
||||
75.7427
|
||||
],
|
||||
"median_audio_rtf": [
|
||||
0.15
|
||||
0.0419
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -127,13 +127,13 @@
|
||||
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
|
||||
"baseline": {
|
||||
"median_audio_ttfp_ms": [
|
||||
3000
|
||||
351.5855
|
||||
],
|
||||
"median_audio_rtf": [
|
||||
0.5
|
||||
0.1207
|
||||
],
|
||||
"audio_throughput": [
|
||||
10
|
||||
73.7632
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -157,13 +157,13 @@
|
||||
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
|
||||
"baseline": {
|
||||
"median_audio_ttfp_ms": [
|
||||
500
|
||||
80.1095
|
||||
],
|
||||
"median_audio_rtf": [
|
||||
0.3
|
||||
0.0485
|
||||
],
|
||||
"audio_throughput": [
|
||||
5
|
||||
18.3715
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -30,9 +30,9 @@
|
||||
}
|
||||
],
|
||||
"baseline": {
|
||||
"throughput_qps": 0.04,
|
||||
"latency_mean": 30,
|
||||
"peak_memory_mb_mean": 75000
|
||||
"throughput_qps": 0.0465,
|
||||
"latency_mean": 21.4354,
|
||||
"peak_memory_mb_mean": 72557.58
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -72,9 +72,9 @@
|
||||
}
|
||||
],
|
||||
"baseline": {
|
||||
"throughput_qps": 0.06,
|
||||
"latency_mean": 20,
|
||||
"peak_memory_mb_mean": 48000
|
||||
"throughput_qps": 0.0642,
|
||||
"latency_mean": 15.5589,
|
||||
"peak_memory_mb_mean": 45742.88
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -97,9 +97,9 @@
|
||||
}
|
||||
],
|
||||
"baseline": {
|
||||
"throughput_qps": 0.01,
|
||||
"latency_mean": 90,
|
||||
"peak_memory_mb_mean": 56000
|
||||
"throughput_qps": 0.0128,
|
||||
"latency_mean": 77.8565,
|
||||
"peak_memory_mb_mean": 53947.84
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+79
-36
@@ -13,10 +13,12 @@ This module tests the cache backend implementations:
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import cache_dit
|
||||
import pytest
|
||||
from cache_dit import ForwardPattern
|
||||
|
||||
from vllm_omni.diffusion.cache.cache_dit_backend import (
|
||||
CUSTOM_DIT_ENABLERS,
|
||||
CacheDiTAdapterConfig,
|
||||
CacheDiTBackend,
|
||||
)
|
||||
from vllm_omni.diffusion.cache.magcache.backend import MagCacheBackend
|
||||
@@ -45,13 +47,20 @@ class TestCacheDiTBackend:
|
||||
assert backend.config.Fn_compute_blocks == 4
|
||||
assert backend.enabled is False
|
||||
|
||||
@patch("vllm_omni.diffusion.cache.cache_dit_backend.BlockAdapter")
|
||||
@patch("vllm_omni.diffusion.cache.cache_dit_backend.cache_dit")
|
||||
def test_enable_single_transformer(self, mock_cache_dit):
|
||||
def test_enable_single_transformer(self, mock_cache_dit, mock_block_adapter):
|
||||
"""Test enabling cache-dit on single-transformer pipeline."""
|
||||
# Mock pipeline
|
||||
mock_pipeline = Mock()
|
||||
mock_pipeline.__class__.__name__ = "DiTPipeline"
|
||||
mock_transformer = Mock()
|
||||
mock_transformer._cache_dit_adapter_config = CacheDiTAdapterConfig(
|
||||
block_forward_patterns={
|
||||
"layers": ForwardPattern.Pattern_0,
|
||||
},
|
||||
)
|
||||
|
||||
mock_pipeline.transformer = mock_transformer
|
||||
|
||||
# Mock cache_dit functions
|
||||
@@ -66,14 +75,20 @@ class TestCacheDiTBackend:
|
||||
assert backend._refresh_func is not None
|
||||
mock_cache_dit.enable_cache.assert_called_once()
|
||||
|
||||
@patch("vllm_omni.diffusion.cache.cache_dit_backend.BlockAdapter")
|
||||
@patch("vllm_omni.diffusion.cache.cache_dit_backend.cache_dit")
|
||||
def test_refresh(self, mock_cache_dit):
|
||||
def test_refresh(self, mock_cache_dit, mock_block_adapter):
|
||||
"""Test refreshing cache context with SCM mask policy updates when num_inference_steps changes."""
|
||||
# Mock pipeline
|
||||
mock_pipeline = Mock()
|
||||
mock_pipeline.__class__.__name__ = "DiTPipeline"
|
||||
mock_transformer = Mock()
|
||||
mock_pipeline.transformer = mock_transformer
|
||||
mock_transformer._cache_dit_adapter_config = CacheDiTAdapterConfig(
|
||||
block_forward_patterns={
|
||||
"layers": ForwardPattern.Pattern_0,
|
||||
},
|
||||
)
|
||||
|
||||
# Mock cache_dit functions
|
||||
mock_cache_dit.enable_cache = Mock()
|
||||
@@ -124,23 +139,24 @@ class TestCacheDiTBackend:
|
||||
assert "cache_config" in call_args[1]
|
||||
assert backend._last_num_inference_steps == 100
|
||||
|
||||
def test_hunyuan_custom_enabler_registered(self):
|
||||
"""Test HunyuanImage3 custom cache-dit enabler is registered."""
|
||||
assert "HunyuanImage3Pipeline" in CUSTOM_DIT_ENABLERS
|
||||
|
||||
def test_dreamid_custom_enabler_registered(self):
|
||||
"""Test DreamIDOmni custom cache-dit enabler is registered."""
|
||||
assert "DreamIDOmniPipeline" in CUSTOM_DIT_ENABLERS
|
||||
|
||||
@patch("vllm_omni.diffusion.cache.cache_dit_backend.BlockAdapter")
|
||||
@patch("vllm_omni.diffusion.cache.cache_dit_backend.cache_dit")
|
||||
def test_enable_hunyuan_pipeline_uses_model_transformer(self, mock_cache_dit, mock_block_adapter):
|
||||
"""Test HunyuanImage3 custom enabler uses pipeline.model for cache enable/refresh."""
|
||||
"""Test HunyuanImage3 uses pipeline.transformer for cache enable/refresh.
|
||||
|
||||
NOTE: HunyuanImage3 no longer has a custom enabler, so this tests against the generic path.
|
||||
"""
|
||||
mock_pipeline = Mock()
|
||||
mock_pipeline.__class__.__name__ = "HunyuanImage3Pipeline"
|
||||
mock_pipeline.model = Mock()
|
||||
mock_pipeline.model.layers = Mock()
|
||||
|
||||
mock_pipeline.model._cache_dit_adapter_config = CacheDiTAdapterConfig(
|
||||
block_forward_patterns={
|
||||
"layers": ForwardPattern.Pattern_4,
|
||||
},
|
||||
)
|
||||
# NOTE: pipe.transformer is pipe.model in HunyuanImage3Pipelines
|
||||
mock_pipeline.transformer = mock_pipeline.model
|
||||
mock_cache_dit.enable_cache = Mock()
|
||||
mock_cache_dit.refresh_context = Mock()
|
||||
|
||||
@@ -152,9 +168,9 @@ class TestCacheDiTBackend:
|
||||
mock_block_adapter.assert_called_once()
|
||||
adapter_kwargs = mock_block_adapter.call_args.kwargs
|
||||
assert adapter_kwargs["transformer"] is mock_pipeline.model
|
||||
assert adapter_kwargs["blocks"] is mock_pipeline.model.layers
|
||||
assert adapter_kwargs["forward_pattern"] == adapter_kwargs["forward_pattern"].__class__.Pattern_4
|
||||
assert len(adapter_kwargs["params_modifiers"]) == 1
|
||||
assert len(adapter_kwargs["blocks"]) == 1
|
||||
assert adapter_kwargs["blocks"][0] == mock_pipeline.model.layers
|
||||
assert adapter_kwargs["forward_pattern"][0] == ForwardPattern.Pattern_4
|
||||
mock_cache_dit.enable_cache.assert_called_once()
|
||||
|
||||
backend.refresh(mock_pipeline, num_inference_steps=12)
|
||||
@@ -163,24 +179,23 @@ class TestCacheDiTBackend:
|
||||
assert call_args[0][0] is mock_pipeline.model
|
||||
assert call_args[1]["num_inference_steps"] == 12
|
||||
|
||||
def test_enable_hunyuan_pipeline_requires_model_layers(self):
|
||||
"""Test HunyuanImage3 enabler fails with a formatted pipeline class name."""
|
||||
mock_pipeline = Mock()
|
||||
mock_pipeline.__class__.__name__ = "HunyuanImage3Pipeline"
|
||||
mock_pipeline.model = Mock(spec=[])
|
||||
|
||||
backend = CacheDiTBackend({"Fn_compute_blocks": 2})
|
||||
|
||||
with pytest.raises(ValueError, match="HunyuanImage3Pipeline"):
|
||||
backend.enable(mock_pipeline)
|
||||
|
||||
@patch("vllm_omni.diffusion.cache.cache_dit_backend.BlockAdapter")
|
||||
@patch("vllm_omni.diffusion.cache.cache_dit_backend.cache_dit")
|
||||
def test_enable_dreamid_pipeline_uses_fused_blocks(self, mock_cache_dit, mock_block_adapter):
|
||||
"""Test DreamID uses pipeline.transformer for cache enable/refresh.
|
||||
|
||||
NOTE: DreamID no longer has a custom enabler, so this tests against the generic path.
|
||||
"""
|
||||
mock_pipeline = Mock()
|
||||
mock_pipeline.__class__.__name__ = "DreamIDOmniPipeline"
|
||||
mock_pipeline.transformer = Mock()
|
||||
mock_pipeline.transformer.fused_blocks = Mock()
|
||||
mock_pipeline.transformer._cache_dit_adapter_config = CacheDiTAdapterConfig(
|
||||
block_forward_patterns={
|
||||
"fused_blocks": ForwardPattern.Pattern_0,
|
||||
},
|
||||
has_separate_cfg=True,
|
||||
)
|
||||
|
||||
mock_cache_dit.enable_cache = Mock()
|
||||
mock_cache_dit.refresh_context = Mock()
|
||||
@@ -193,10 +208,10 @@ class TestCacheDiTBackend:
|
||||
mock_block_adapter.assert_called_once()
|
||||
adapter_kwargs = mock_block_adapter.call_args.kwargs
|
||||
assert adapter_kwargs["transformer"] is mock_pipeline.transformer
|
||||
assert adapter_kwargs["blocks"] is mock_pipeline.transformer.fused_blocks
|
||||
assert adapter_kwargs["forward_pattern"] == adapter_kwargs["forward_pattern"].__class__.Pattern_0
|
||||
assert len(adapter_kwargs["blocks"]) == 1
|
||||
assert adapter_kwargs["blocks"][0] == mock_pipeline.transformer.fused_blocks
|
||||
assert adapter_kwargs["forward_pattern"][0] == ForwardPattern.Pattern_0
|
||||
assert adapter_kwargs["has_separate_cfg"] is True
|
||||
assert len(adapter_kwargs["params_modifiers"]) == 1
|
||||
mock_cache_dit.enable_cache.assert_called_once()
|
||||
|
||||
backend.refresh(mock_pipeline, num_inference_steps=12)
|
||||
@@ -205,15 +220,43 @@ class TestCacheDiTBackend:
|
||||
assert call_args[0][0] is mock_pipeline.transformer
|
||||
assert call_args[1]["num_inference_steps"] == 12
|
||||
|
||||
def test_enable_dreamid_pipeline_requires_fused_blocks(self):
|
||||
@pytest.mark.parametrize("num_inference_steps", [1, 7])
|
||||
@patch("vllm_omni.diffusion.cache.cache_dit_backend.BlockAdapter")
|
||||
@patch("vllm_omni.diffusion.cache.cache_dit_backend.cache_dit")
|
||||
def test_refresh_scm_bypassed_for_unsupported_step_counts(
|
||||
self, mock_cache_dit, mock_block_adapter, num_inference_steps
|
||||
):
|
||||
"""Ensure SCM is bypassed when num_inference_steps < 8 and not in (4, 6),
|
||||
because cache_dit.steps_mask() raises for unsupported step count.
|
||||
For these cases, we fall back to the non-SCM path to avoid crashing.
|
||||
"""
|
||||
mock_pipeline = Mock()
|
||||
mock_pipeline.__class__.__name__ = "DreamIDOmniPipeline"
|
||||
mock_pipeline.transformer = Mock(spec=[])
|
||||
mock_pipeline.__class__.__name__ = "DiTPipeline"
|
||||
mock_transformer = Mock()
|
||||
mock_pipeline.transformer = mock_transformer
|
||||
mock_transformer._cache_dit_adapter_config = CacheDiTAdapterConfig(
|
||||
block_forward_patterns={
|
||||
"layers": ForwardPattern.Pattern_0,
|
||||
},
|
||||
)
|
||||
|
||||
backend = CacheDiTBackend({"Fn_compute_blocks": 2})
|
||||
mock_cache_dit.enable_cache = Mock()
|
||||
mock_cache_dit.refresh_context = Mock()
|
||||
mock_cache_dit.steps_mask = cache_dit.steps_mask
|
||||
|
||||
with pytest.raises(ValueError, match="DreamIDOmniPipeline"):
|
||||
backend.enable(mock_pipeline)
|
||||
# Create a cache config with an scm policy & enable it
|
||||
config = DiffusionCacheConfig(scm_steps_mask_policy="fast")
|
||||
backend = CacheDiTBackend(config)
|
||||
backend.enable(mock_pipeline)
|
||||
|
||||
backend.refresh(mock_pipeline, num_inference_steps=num_inference_steps)
|
||||
|
||||
mock_cache_dit.refresh_context.assert_called_once()
|
||||
call_args = mock_cache_dit.refresh_context.call_args
|
||||
# Ensure that we properly guard, i.e., cache config is filtered
|
||||
assert call_args[0][0] == mock_transformer
|
||||
assert call_args[1]["num_inference_steps"] == num_inference_steps
|
||||
assert "cache_config" not in call_args[1]
|
||||
|
||||
|
||||
class TestTeaCacheBackend:
|
||||
|
||||
+31
-16
@@ -5,6 +5,7 @@
|
||||
Model specific tests for CacheDiT enablement.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
@@ -12,36 +13,49 @@ import torch
|
||||
from cache_dit.caching.cache_blocks.pattern_0_1_2 import CachedBlocks_Pattern_0_1_2
|
||||
|
||||
import vllm_omni.diffusion.cache.cache_dit_backend as cd_backend
|
||||
from vllm_omni.diffusion.cache.cache_dit_backend import CacheDiTBackend
|
||||
from vllm_omni.diffusion.cache.cache_dit_backend import CacheDiTAdapterConfig, CacheDiTBackend
|
||||
from vllm_omni.diffusion.data import DiffusionCacheConfig
|
||||
from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import Cosmos3VFMTransformer
|
||||
from vllm_omni.diffusion.models.helios.helios_transformer import HeliosTransformer3DModel
|
||||
from vllm_omni.diffusion.models.longcat_image.longcat_image_transformer import LongCatImageTransformer2DModel
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_transformer import LTX2VideoTransformer3DModel
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
# NOTE: We patch DreamID Omni's modules here with mocks so that we can import and inspect
|
||||
# the class even though the dependency may not be set up correctly; this is ok for these
|
||||
# tests because we just inspect it and never initialize the model.
|
||||
for mod in ("dreamid_omni", "dreamid_omni.modules", "dreamid_omni.modules.model"):
|
||||
sys.modules.setdefault(mod, Mock())
|
||||
# isort: split
|
||||
from vllm_omni.diffusion.models.dreamid_omni.fusion import FusionModel as DreamIdOmniModel # noqa: E402
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
SEPARATE_CFG_ENABLERS = [
|
||||
cd_backend.enable_cache_for_dreamid_omni,
|
||||
cd_backend.enable_cache_for_ltx2,
|
||||
cd_backend.enable_cache_for_helios,
|
||||
cd_backend.enable_cache_for_wan22,
|
||||
cd_backend.enable_cache_for_longcat_image,
|
||||
cd_backend.enable_cache_for_cosmos3,
|
||||
SEPARATE_CFG_TRANSFORMERS = [
|
||||
DreamIdOmniModel,
|
||||
LTX2VideoTransformer3DModel,
|
||||
HeliosTransformer3DModel,
|
||||
LongCatImageTransformer2DModel,
|
||||
Cosmos3VFMTransformer,
|
||||
]
|
||||
|
||||
SAMPLE_CACHE_CONFIG = DiffusionCacheConfig()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabler", SEPARATE_CFG_ENABLERS)
|
||||
@pytest.mark.parametrize("transformer_model", SEPARATE_CFG_TRANSFORMERS)
|
||||
def test_cache_dit_configs_have_separate_cfg(transformer_model):
|
||||
"""Check models with separate CFG set has_separate_cfg=True in their configs."""
|
||||
assert hasattr(transformer_model, "_cache_dit_adapter_config")
|
||||
assert isinstance(transformer_model._cache_dit_adapter_config, CacheDiTAdapterConfig)
|
||||
assert transformer_model._cache_dit_adapter_config.has_separate_cfg is True
|
||||
|
||||
|
||||
@patch("vllm_omni.diffusion.cache.cache_dit_backend.BlockAdapter")
|
||||
@patch("vllm_omni.diffusion.cache.cache_dit_backend.cache_dit")
|
||||
def test_separate_cfg(mock_cache_dit, mock_block_adapter, enabler):
|
||||
"""Ensure that custom enablers for models with separate CFG pass
|
||||
the param through to cache_dit correctly.
|
||||
|
||||
Regression test for: https://github.com/vllm-project/vllm-omni/pull/2860
|
||||
"""
|
||||
def test_separate_wan22_custom_enabler_has_separate_cfg(mock_cache_dit, mock_block_adapter):
|
||||
"""Ensure that Wan22, which has a custom enabler, setts custom CFG correctly."""
|
||||
mock_pipeline = Mock()
|
||||
enabler(mock_pipeline, SAMPLE_CACHE_CONFIG)
|
||||
cd_backend.enable_cache_for_wan22(mock_pipeline, SAMPLE_CACHE_CONFIG)
|
||||
|
||||
mock_cache_dit.enable_cache.assert_called_once()
|
||||
adapter_kwargs = mock_block_adapter.call_args.kwargs
|
||||
@@ -55,6 +69,7 @@ def test_cosmos3_cache_dit_wraps_gen_layers(mock_cache_dit, mock_block_adapter):
|
||||
mock_pipeline = Mock()
|
||||
gen_layers = object()
|
||||
mock_pipeline.transformer.gen_layers = gen_layers
|
||||
mock_pipeline.transformer._cache_dit_adapter_config = Cosmos3VFMTransformer._cache_dit_adapter_config
|
||||
|
||||
cd_backend.enable_cache_for_cosmos3(mock_pipeline, SAMPLE_CACHE_CONFIG)
|
||||
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""Unit tests for StepCacheBackend and StepCacheState."""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.cache.selector import get_cache_backend
|
||||
from vllm_omni.diffusion.cache.stepcache import (
|
||||
STEP_CACHE_CONFIG_ATTR,
|
||||
STEP_CACHE_STATE_ATTR,
|
||||
StepCacheBackend,
|
||||
StepCacheConfig,
|
||||
StepCacheState,
|
||||
is_stepcache_active,
|
||||
)
|
||||
from vllm_omni.diffusion.data import DiffusionCacheConfig
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _make_prev_predictions(sim: float, dim: int = 8) -> list[tuple[torch.Tensor]]:
|
||||
v_prev = torch.randn(1, dim)
|
||||
if sim >= 0.99:
|
||||
v_last = v_prev.clone()
|
||||
else:
|
||||
v_last = torch.randn(1, dim)
|
||||
return [(v_prev,), (v_last,)]
|
||||
|
||||
|
||||
class TestStepCacheConfig:
|
||||
def test_from_diffusion_cache_config_respects_step_cache_dit_enabled(self):
|
||||
enabled = StepCacheConfig.from_diffusion_cache_config(DiffusionCacheConfig(step_cache_dit_enabled=True))
|
||||
disabled = StepCacheConfig.from_diffusion_cache_config(DiffusionCacheConfig(step_cache_dit_enabled=False))
|
||||
assert enabled.enabled is True
|
||||
assert disabled.enabled is False
|
||||
|
||||
|
||||
class TestStepCacheState:
|
||||
def test_disabled_always_runs(self):
|
||||
state = StepCacheState(StepCacheConfig(enabled=False))
|
||||
assert state.should_run_step(_make_prev_predictions(0.99)) is True
|
||||
|
||||
def test_insufficient_history_always_runs(self):
|
||||
state = StepCacheState(StepCacheConfig(min_history_steps=2))
|
||||
assert state.should_run_step([(torch.randn(1, 4),)]) is True
|
||||
|
||||
def test_high_similarity_skips_with_countdown(self):
|
||||
state = StepCacheState(
|
||||
StepCacheConfig(
|
||||
sim_thresholds=(0.95,),
|
||||
skip_countdowns=(2,),
|
||||
)
|
||||
)
|
||||
preds = _make_prev_predictions(0.99)
|
||||
assert state.should_run_step(preds) is False
|
||||
assert state.skip_countdown == 2
|
||||
assert state.should_run_step(preds) is False
|
||||
assert state.should_run_step(preds) is True
|
||||
|
||||
def test_low_similarity_runs(self):
|
||||
state = StepCacheState(StepCacheConfig())
|
||||
preds = _make_prev_predictions(0.5)
|
||||
assert state.should_run_step(preds) is True
|
||||
|
||||
def test_trim_history(self):
|
||||
state = StepCacheState(StepCacheConfig(max_history=2))
|
||||
history: list[tuple[torch.Tensor]] = [(torch.randn(1, 2),) for _ in range(4)]
|
||||
state.trim_history(history)
|
||||
assert len(history) == 2
|
||||
|
||||
|
||||
class TestStepCacheBackend:
|
||||
def test_enable_dreamzero_pipeline(self):
|
||||
mock_pipeline = Mock()
|
||||
mock_pipeline.__class__.__name__ = "DreamZeroPipeline"
|
||||
|
||||
backend = StepCacheBackend(DiffusionCacheConfig())
|
||||
backend.enable(mock_pipeline)
|
||||
|
||||
assert backend.enabled is True
|
||||
cfg = getattr(mock_pipeline, STEP_CACHE_CONFIG_ATTR)
|
||||
assert isinstance(cfg, StepCacheConfig)
|
||||
assert is_stepcache_active(mock_pipeline)
|
||||
state = getattr(mock_pipeline, STEP_CACHE_STATE_ATTR)
|
||||
assert isinstance(state, StepCacheState)
|
||||
|
||||
def test_refresh_resets_state(self):
|
||||
mock_pipeline = Mock()
|
||||
mock_pipeline.__class__.__name__ = "DreamZeroPipeline"
|
||||
backend = StepCacheBackend(DiffusionCacheConfig())
|
||||
backend.enable(mock_pipeline)
|
||||
state = getattr(mock_pipeline, STEP_CACHE_STATE_ATTR)
|
||||
state.skip_countdown = 3
|
||||
backend.refresh(mock_pipeline, num_inference_steps=16, verbose=False)
|
||||
assert state.skip_countdown == 0
|
||||
|
||||
def test_enable_unsupported_pipeline_raises(self):
|
||||
mock_pipeline = Mock()
|
||||
mock_pipeline.__class__.__name__ = "QwenImagePipeline"
|
||||
backend = StepCacheBackend(DiffusionCacheConfig())
|
||||
|
||||
with pytest.raises(ValueError, match="step_cache backend does not support"):
|
||||
backend.enable(mock_pipeline)
|
||||
|
||||
def test_selector_returns_backend(self):
|
||||
backend = get_cache_backend("step_cache", {"velocity_sim_thresholds": [0.9]})
|
||||
assert isinstance(backend, StepCacheBackend)
|
||||
assert backend.config.velocity_sim_thresholds == [0.9]
|
||||
|
||||
def test_legacy_backend_aliases(self):
|
||||
assert isinstance(get_cache_backend("stepcache", {}), StepCacheBackend)
|
||||
assert isinstance(get_cache_backend("step_cache_dit", {}), StepCacheBackend)
|
||||
@@ -0,0 +1,689 @@
|
||||
# ruff: noqa: N803, E741
|
||||
"""Layer-level correctness & performance test for MoT parallel linear layers.
|
||||
|
||||
Compares two equivalent computation paths:
|
||||
- Reference: 2x standard vLLM parallel linear layers + PyTorch index
|
||||
scatter/gather (text_linear(x[text_idx]) + vae_linear(x[vae_idx]))
|
||||
- Target: 1x MoT fused parallel linear layer
|
||||
(mot_linear(x, text_indices, vae_indices))
|
||||
|
||||
The reference path uses cuBLAS GEMM (always auto-tuned by cuBLAS).
|
||||
The MoT path uses a fused Triton kernel whose tile config is loaded
|
||||
from a JSON file matched by ``device + dtype``. If no tuned
|
||||
config is found for the current GPU, the kernel falls back to a conservative
|
||||
default and a warning is printed — the correctness test still passes
|
||||
but the performance comparison is NOT representative.
|
||||
|
||||
Usage::
|
||||
pytest tests/diffusion/kernels/mot/test_mot_linear.py -v -s
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from vllm.config import VllmConfig, set_current_vllm_config
|
||||
from vllm.model_executor.layers.linear import (
|
||||
QKVParallelLinear,
|
||||
RowParallelLinear,
|
||||
)
|
||||
|
||||
from vllm_omni.diffusion.distributed.parallel_state import (
|
||||
destroy_distributed_env,
|
||||
init_distributed_environment,
|
||||
initialize_model_parallel,
|
||||
model_parallel_is_initialized,
|
||||
)
|
||||
from vllm_omni.diffusion.layers.mot.mot_qkv_parallel_linear import (
|
||||
MoTQKVParallelLinear,
|
||||
)
|
||||
from vllm_omni.diffusion.layers.mot.mot_row_parallel_linear import (
|
||||
MoTRowParallelLinear,
|
||||
)
|
||||
from vllm_omni.diffusion.layers.mot.ops.mot_gemm import (
|
||||
get_best_mot_config,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.gpu]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# BAGEL-7B-MoT architecture parameters
|
||||
_BAGEL_HEAD_SIZE = 128
|
||||
_BAGEL_TOTAL_NUM_HEADS = 28
|
||||
_BAGEL_TOTAL_NUM_KV_HEADS = 4
|
||||
_VAE_CHUNK_SIZE = 1024 # the token number of one image
|
||||
_IMAGE_NUM = [1, 2, 4, 8]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _init_single_rank_tp_env():
|
||||
"""Initialize single-rank distributed/TP env for vLLM linear params."""
|
||||
os.environ.setdefault("RANK", "0")
|
||||
os.environ.setdefault("LOCAL_RANK", "0")
|
||||
os.environ.setdefault("WORLD_SIZE", "1")
|
||||
os.environ.setdefault("MASTER_ADDR", "127.0.0.1")
|
||||
os.environ.setdefault("MASTER_PORT", "29501")
|
||||
|
||||
if not torch.distributed.is_initialized():
|
||||
init_distributed_environment(world_size=1, rank=0, local_rank=0)
|
||||
|
||||
if not model_parallel_is_initialized():
|
||||
initialize_model_parallel(
|
||||
data_parallel_size=1,
|
||||
cfg_parallel_size=1,
|
||||
sequence_parallel_size=1,
|
||||
ulysses_degree=1,
|
||||
ring_degree=1,
|
||||
tensor_parallel_size=1,
|
||||
pipeline_parallel_size=1,
|
||||
)
|
||||
|
||||
yield
|
||||
destroy_distributed_env()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DType configuration — extensible for future quantized types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class DTypeConfig:
|
||||
"""Parsed dtype configuration for a test case."""
|
||||
|
||||
torch_dtype: torch.dtype
|
||||
use_fp8_w8a8: bool = False
|
||||
use_int8_w8a16: bool = False
|
||||
use_int4_w4a16: bool = False
|
||||
|
||||
|
||||
def _parse_dtype(dtype_str: str) -> DTypeConfig:
|
||||
"""Parse a dtype string into quantization flags and torch dtype.
|
||||
|
||||
Supported now:
|
||||
"w16a16_bf16" — BF16 weights & activations (no quantization)
|
||||
"w16a16_fp16" — FP16 weights & activations (no quantization)
|
||||
Reserved for future:
|
||||
"fp8_w8a8" — FP8 W8A8 quantization
|
||||
"int8_w8a16" — INT8 weight-only quantization
|
||||
"int4_w4a16" — INT4 weight-only quantization
|
||||
"""
|
||||
supported: dict[str, DTypeConfig] = {
|
||||
"w16a16_bf16": DTypeConfig(torch_dtype=torch.bfloat16),
|
||||
"w16a16_fp16": DTypeConfig(torch_dtype=torch.float16),
|
||||
}
|
||||
if dtype_str in supported:
|
||||
return supported[dtype_str]
|
||||
pytest.skip(f"Quantized dtype '{dtype_str}' not yet implemented in layer test")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _report_mot_config(K: int, N: int, M: int):
|
||||
"""Print which Triton tile config the MoT kernel will use."""
|
||||
loaded_m_key, config = get_best_mot_config(M, N, K, None)
|
||||
if loaded_m_key == -1:
|
||||
print(
|
||||
" [config] WARNING: No tuned config found — "
|
||||
"using conservative default. "
|
||||
"Performance numbers are NOT representative. "
|
||||
"Run mot_linear_benchmarks.py --tune to generate configs."
|
||||
)
|
||||
else:
|
||||
print(f" [config] Tuned config loaded (actual M={M}, loaded M={loaded_m_key}) config = {config})")
|
||||
|
||||
|
||||
def _make_indices(image_num: int, vae_chunk_size: int, device: str = "cuda") -> tuple[torch.Tensor, torch.Tensor, int]:
|
||||
"""
|
||||
Simulate exact Bagel-MoT distributions for image generation:
|
||||
Pattern per image like: [1 Text] + [4096 VAE] + [1 Text]
|
||||
Returns text_indices, vae_indices, and the exact total M.
|
||||
"""
|
||||
text_idx_list = []
|
||||
vae_idx_list = []
|
||||
|
||||
current_idx = 0
|
||||
for _ in range(image_num):
|
||||
text_idx_list.append(current_idx)
|
||||
current_idx += 1
|
||||
|
||||
vae_idx_list.extend(range(current_idx, current_idx + vae_chunk_size))
|
||||
current_idx += vae_chunk_size
|
||||
|
||||
text_idx_list.append(current_idx)
|
||||
current_idx += 1
|
||||
|
||||
text_indices = torch.tensor(text_idx_list, dtype=torch.long, device=device)
|
||||
vae_indices = torch.tensor(vae_idx_list, dtype=torch.long, device=device)
|
||||
|
||||
exact_M = current_idx # exact_M = image_num * (vae_chunk_size + 2)
|
||||
|
||||
return text_indices, vae_indices, exact_M
|
||||
|
||||
|
||||
def _benchmark(fn, warmup: int = 20, iters: int = 100) -> float:
|
||||
"""Return mean latency in milliseconds."""
|
||||
cache_flusher = torch.empty(int(256 * 1024 * 1024 / 4), dtype=torch.int32, device="cuda")
|
||||
for _ in range(warmup):
|
||||
fn()
|
||||
torch.accelerator.synchronize()
|
||||
|
||||
t_flush_start = time.perf_counter()
|
||||
for _ in range(iters):
|
||||
cache_flusher.zero_()
|
||||
torch.accelerator.synchronize()
|
||||
flush_time_total = time.perf_counter() - t_flush_start
|
||||
|
||||
# 3. Measure the total time of "flush cache + operator execution"
|
||||
torch.accelerator.synchronize()
|
||||
t_total_start = time.perf_counter()
|
||||
for _ in range(iters):
|
||||
cache_flusher.zero_()
|
||||
fn()
|
||||
torch.accelerator.synchronize()
|
||||
total_time = time.perf_counter() - t_total_start
|
||||
|
||||
# 4. Asynchronous subtraction separation
|
||||
# The E2E time of the pure operator = total time - flush time
|
||||
pure_fn_time_total = total_time - flush_time_total
|
||||
avg_ms = (pure_fn_time_total / iters) * 1000.0
|
||||
|
||||
del cache_flusher
|
||||
return max(avg_ms, 0.001)
|
||||
|
||||
|
||||
def _sync_weights(ref_text, ref_vae, mot_layer):
|
||||
"""Assign the same random weights to reference layers and MoT layer."""
|
||||
with torch.no_grad():
|
||||
W_text = torch.randn_like(ref_text.weight) * 0.02
|
||||
W_vae = torch.randn_like(ref_vae.weight) * 0.02
|
||||
ref_text.weight.copy_(W_text)
|
||||
ref_vae.weight.copy_(W_vae)
|
||||
mot_layer.weight.copy_(W_text)
|
||||
mot_layer.gen_exp.weight.copy_(W_vae)
|
||||
if ref_text.bias is not None and mot_layer.bias is not None:
|
||||
b_text = torch.randn_like(ref_text.bias) * 0.02
|
||||
ref_text.bias.copy_(b_text)
|
||||
mot_layer.bias.copy_(b_text)
|
||||
if ref_vae.bias is not None and mot_layer.gen_exp.bias is not None:
|
||||
b_vae = torch.randn_like(ref_vae.bias) * 0.02
|
||||
ref_vae.bias.copy_(b_vae)
|
||||
mot_layer.gen_exp.bias.copy_(b_vae)
|
||||
|
||||
|
||||
def _reference_forward(x, text_indices, vae_indices, text_linear, vae_linear):
|
||||
"""Reference path: index-gather → 2x standard linear → index-scatter."""
|
||||
M = x.size(0)
|
||||
out_text = text_linear(x[text_indices])
|
||||
out_vae = vae_linear(x[vae_indices])
|
||||
if isinstance(out_text, tuple):
|
||||
out_text = out_text[0]
|
||||
if isinstance(out_vae, tuple):
|
||||
out_vae = out_vae[0]
|
||||
N = out_text.size(-1)
|
||||
output = torch.empty(M, N, dtype=x.dtype, device=x.device)
|
||||
output[text_indices] = out_text
|
||||
output[vae_indices] = out_vae
|
||||
return output
|
||||
|
||||
|
||||
def _check_and_report(ref: torch.Tensor, mot: torch.Tensor, tag: str):
|
||||
"""Compare outputs, print metrics, and assert correctness.
|
||||
|
||||
Both ``ref`` and ``mot`` are in the original compute dtype (e.g. bf16).
|
||||
We upcast to fp32 solely for computing error metrics with higher
|
||||
arithmetic precision — the actual layer outputs remain bf16.
|
||||
"""
|
||||
# Upcast for numerically stable error computation only
|
||||
ref_hp = ref.float()
|
||||
mot_hp = mot.float()
|
||||
|
||||
abs_err = (ref_hp - mot_hp).abs()
|
||||
max_abs = abs_err.max().item()
|
||||
|
||||
# Mixed metric: relative error where |ref| >= 1, absolute error otherwise
|
||||
denom = ref_hp.abs().clamp(min=1.0)
|
||||
max_rel = (abs_err / denom).max().item()
|
||||
|
||||
cos_sim = (
|
||||
torch.nn.functional.cosine_similarity(
|
||||
ref_hp,
|
||||
mot_hp,
|
||||
dim=-1,
|
||||
)
|
||||
.min()
|
||||
.item()
|
||||
)
|
||||
|
||||
print(f"\n [{tag}] max_abs={max_abs:.4e} max_rel={max_rel:.4e} min_cos_sim={cos_sim:.6f}")
|
||||
|
||||
# Cosine similarity is the primary correctness gate: robust to scale
|
||||
# and accumulation-order differences between cuBLAS and Triton.
|
||||
# For bf16 GEMM with K up to ~20k, cos_sim > 0.99 is easily achieved.
|
||||
assert cos_sim > 0.98, f"Cosine similarity too low: {cos_sim:.6f}"
|
||||
# Supplementary per-element check (generous to avoid flaky failures
|
||||
# on extreme K dimensions like 18944)
|
||||
assert max_rel < 0.1, f"Max relative error too large: {max_rel:.4e}"
|
||||
|
||||
|
||||
def _run_timing(
|
||||
ref_fn,
|
||||
mot_fn,
|
||||
tag: str,
|
||||
warmup: int = 20,
|
||||
iters: int = 100,
|
||||
):
|
||||
"""Benchmark both paths and print timing comparison."""
|
||||
ref_ms = _benchmark(ref_fn, warmup=warmup, iters=iters)
|
||||
mot_ms = _benchmark(mot_fn, warmup=warmup, iters=iters)
|
||||
speedup = ref_ms / mot_ms if mot_ms > 0 else float("inf")
|
||||
print(f" [{tag}] Ref(2x linear): {ref_ms:.3f} ms | MoT(fused): {mot_ms:.3f} ms | Speedup: {speedup:.2f}x")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Test: qkv proj
|
||||
# =========================================================================
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bias", [True, False])
|
||||
@pytest.mark.parametrize(
|
||||
"image_num, K, N, dtype",
|
||||
[(num, 3584, 4608, "w16a16_bf16") for num in _IMAGE_NUM] + [(num, 3584, 4608, "w16a16_fp16") for num in _IMAGE_NUM],
|
||||
ids=[f"img{num}_K3584_N4608_bf16" for num in _IMAGE_NUM] + [f"img{num}_K3584_N4608_fp16" for num in _IMAGE_NUM],
|
||||
)
|
||||
def test_mot_qkv_parallel(image_num: int, K: int, N: int, dtype: str, bias: bool):
|
||||
dcfg = _parse_dtype(dtype)
|
||||
torch.manual_seed(42)
|
||||
|
||||
with set_current_vllm_config(VllmConfig()):
|
||||
text_linear = QKVParallelLinear(
|
||||
hidden_size=K,
|
||||
head_size=_BAGEL_HEAD_SIZE,
|
||||
total_num_heads=_BAGEL_TOTAL_NUM_HEADS,
|
||||
total_num_kv_heads=_BAGEL_TOTAL_NUM_KV_HEADS,
|
||||
bias=bias,
|
||||
params_dtype=dcfg.torch_dtype,
|
||||
disable_tp=True,
|
||||
).cuda()
|
||||
vae_linear = QKVParallelLinear(
|
||||
hidden_size=K,
|
||||
head_size=_BAGEL_HEAD_SIZE,
|
||||
total_num_heads=_BAGEL_TOTAL_NUM_HEADS,
|
||||
total_num_kv_heads=_BAGEL_TOTAL_NUM_KV_HEADS,
|
||||
bias=bias,
|
||||
params_dtype=dcfg.torch_dtype,
|
||||
disable_tp=True,
|
||||
).cuda()
|
||||
mot_linear = MoTQKVParallelLinear(
|
||||
hidden_size=K,
|
||||
head_size=_BAGEL_HEAD_SIZE,
|
||||
total_num_heads=_BAGEL_TOTAL_NUM_HEADS,
|
||||
total_num_kv_heads=_BAGEL_TOTAL_NUM_KV_HEADS,
|
||||
bias=bias,
|
||||
vae_bias=bias,
|
||||
params_dtype=dcfg.torch_dtype,
|
||||
disable_tp=True,
|
||||
).cuda()
|
||||
|
||||
assert text_linear.output_size_per_partition == N, (
|
||||
f"Expected output_size_per_partition={N}, "
|
||||
f"got {text_linear.output_size_per_partition}. "
|
||||
f"Check head parameters."
|
||||
)
|
||||
|
||||
_sync_weights(text_linear, vae_linear, mot_linear)
|
||||
|
||||
text_idx, vae_idx, M = _make_indices(image_num, _VAE_CHUNK_SIZE)
|
||||
x = torch.randn(M, K, dtype=dcfg.torch_dtype, device="cuda")
|
||||
|
||||
tag = f"QKVParallel M={M} K={K} N={N}"
|
||||
_report_mot_config(K, N, M)
|
||||
|
||||
with torch.no_grad():
|
||||
ref = _reference_forward(
|
||||
x,
|
||||
text_idx,
|
||||
vae_idx,
|
||||
text_linear,
|
||||
vae_linear,
|
||||
)
|
||||
mot_out, _ = mot_linear(x, text_idx, vae_idx)
|
||||
|
||||
_check_and_report(ref, mot_out, tag)
|
||||
|
||||
with torch.no_grad():
|
||||
_run_timing(
|
||||
lambda: _reference_forward(
|
||||
x,
|
||||
text_idx,
|
||||
vae_idx,
|
||||
text_linear,
|
||||
vae_linear,
|
||||
),
|
||||
lambda: mot_linear(x, text_idx, vae_idx),
|
||||
tag,
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Test: o proj
|
||||
# =========================================================================
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bias", [True, False])
|
||||
@pytest.mark.parametrize(
|
||||
"image_num, K, N, dtype",
|
||||
[(num, 3584, 3584, "w16a16_bf16") for num in _IMAGE_NUM] + [(num, 3584, 3584, "w16a16_fp16") for num in _IMAGE_NUM],
|
||||
ids=[f"img{num}_K3584_N3584_bf16" for num in _IMAGE_NUM] + [f"img{num}_K3584_N3584_fp16" for num in _IMAGE_NUM],
|
||||
)
|
||||
def test_mot_o_proj(
|
||||
image_num: int,
|
||||
K: int,
|
||||
N: int,
|
||||
dtype: str,
|
||||
bias: bool,
|
||||
):
|
||||
dcfg = _parse_dtype(dtype)
|
||||
torch.manual_seed(42)
|
||||
|
||||
with set_current_vllm_config(VllmConfig()):
|
||||
text_linear = RowParallelLinear(
|
||||
K,
|
||||
N,
|
||||
bias=bias,
|
||||
input_is_parallel=True,
|
||||
params_dtype=dcfg.torch_dtype,
|
||||
disable_tp=True,
|
||||
).cuda()
|
||||
vae_linear = RowParallelLinear(
|
||||
K,
|
||||
N,
|
||||
bias=bias,
|
||||
input_is_parallel=True,
|
||||
params_dtype=dcfg.torch_dtype,
|
||||
disable_tp=True,
|
||||
).cuda()
|
||||
mot_linear = MoTRowParallelLinear(
|
||||
K,
|
||||
N,
|
||||
bias=bias,
|
||||
vae_bias=bias,
|
||||
input_is_parallel=True,
|
||||
params_dtype=dcfg.torch_dtype,
|
||||
disable_tp=True,
|
||||
).cuda()
|
||||
|
||||
_sync_weights(text_linear, vae_linear, mot_linear)
|
||||
|
||||
text_idx, vae_idx, M = _make_indices(image_num, _VAE_CHUNK_SIZE)
|
||||
x = torch.randn(M, K, dtype=dcfg.torch_dtype, device="cuda")
|
||||
|
||||
tag = f"O Proj M={M} K={K} N={N}"
|
||||
_report_mot_config(K, N, M)
|
||||
|
||||
# Correctness (also warms up Triton JIT compilation)
|
||||
with torch.no_grad():
|
||||
ref = _reference_forward(
|
||||
x,
|
||||
text_idx,
|
||||
vae_idx,
|
||||
text_linear,
|
||||
vae_linear,
|
||||
)
|
||||
mot_out, _ = mot_linear(x, text_idx, vae_idx)
|
||||
|
||||
_check_and_report(ref, mot_out, tag)
|
||||
|
||||
# Performance
|
||||
with torch.no_grad():
|
||||
_run_timing(
|
||||
lambda: _reference_forward(
|
||||
x,
|
||||
text_idx,
|
||||
vae_idx,
|
||||
text_linear,
|
||||
vae_linear,
|
||||
),
|
||||
lambda: mot_linear(x, text_idx, vae_idx),
|
||||
tag,
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Test: und-mode (text_indices=None) — falls back to standard forward
|
||||
# =========================================================================
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bias", [True, False])
|
||||
@pytest.mark.parametrize(
|
||||
"K, N, dtype",
|
||||
[
|
||||
(3584, 4608, "w16a16_bf16"),
|
||||
(3584, 4608, "w16a16_fp16"),
|
||||
],
|
||||
ids=["QKV_K3584_N4608_bf16", "QKV_K3584_N4608_fp16"],
|
||||
)
|
||||
def test_mot_qkv_und_mode(K: int, N: int, dtype: str, bias: bool):
|
||||
"""und-mode: text_indices=None should produce same output as standard QKVParallelLinear."""
|
||||
dcfg = _parse_dtype(dtype)
|
||||
torch.manual_seed(42)
|
||||
M = 1026
|
||||
|
||||
with set_current_vllm_config(VllmConfig()):
|
||||
ref_linear = QKVParallelLinear(
|
||||
hidden_size=K,
|
||||
head_size=_BAGEL_HEAD_SIZE,
|
||||
total_num_heads=_BAGEL_TOTAL_NUM_HEADS,
|
||||
total_num_kv_heads=_BAGEL_TOTAL_NUM_KV_HEADS,
|
||||
bias=bias,
|
||||
params_dtype=dcfg.torch_dtype,
|
||||
disable_tp=True,
|
||||
).cuda()
|
||||
mot_linear = MoTQKVParallelLinear(
|
||||
hidden_size=K,
|
||||
head_size=_BAGEL_HEAD_SIZE,
|
||||
total_num_heads=_BAGEL_TOTAL_NUM_HEADS,
|
||||
total_num_kv_heads=_BAGEL_TOTAL_NUM_KV_HEADS,
|
||||
bias=bias,
|
||||
vae_bias=bias,
|
||||
params_dtype=dcfg.torch_dtype,
|
||||
disable_tp=True,
|
||||
).cuda()
|
||||
|
||||
with torch.no_grad():
|
||||
mot_linear.weight.copy_(ref_linear.weight)
|
||||
if bias and ref_linear.bias is not None:
|
||||
mot_linear.bias.copy_(ref_linear.bias)
|
||||
|
||||
x = torch.randn(M, K, dtype=dcfg.torch_dtype, device="cuda")
|
||||
|
||||
with torch.no_grad():
|
||||
ref_out, _ = ref_linear(x)
|
||||
mot_out, _ = mot_linear(x, text_indices=None, vae_indices=None)
|
||||
|
||||
_check_and_report(ref_out, mot_out, f"QKV und-mode M={M} K={K}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bias", [True, False])
|
||||
@pytest.mark.parametrize(
|
||||
"K, N, dtype",
|
||||
[
|
||||
(3584, 3584, "w16a16_bf16"),
|
||||
(3584, 3584, "w16a16_fp16"),
|
||||
],
|
||||
ids=["Row_K3584_N3584_bf16", "Row_K3584_N3584_fp16"],
|
||||
)
|
||||
def test_mot_row_und_mode(K: int, N: int, dtype: str, bias: bool):
|
||||
"""und-mode: text_indices=None should produce same output as standard RowParallelLinear."""
|
||||
dcfg = _parse_dtype(dtype)
|
||||
torch.manual_seed(42)
|
||||
M = 1026
|
||||
|
||||
with set_current_vllm_config(VllmConfig()):
|
||||
ref_linear = RowParallelLinear(
|
||||
K,
|
||||
N,
|
||||
bias=bias,
|
||||
input_is_parallel=True,
|
||||
params_dtype=dcfg.torch_dtype,
|
||||
disable_tp=True,
|
||||
).cuda()
|
||||
mot_linear = MoTRowParallelLinear(
|
||||
K,
|
||||
N,
|
||||
bias=bias,
|
||||
vae_bias=bias,
|
||||
input_is_parallel=True,
|
||||
params_dtype=dcfg.torch_dtype,
|
||||
disable_tp=True,
|
||||
).cuda()
|
||||
|
||||
with torch.no_grad():
|
||||
mot_linear.weight.copy_(ref_linear.weight)
|
||||
if bias and ref_linear.bias is not None:
|
||||
mot_linear.bias.copy_(ref_linear.bias)
|
||||
|
||||
x = torch.randn(M, K, dtype=dcfg.torch_dtype, device="cuda")
|
||||
|
||||
with torch.no_grad():
|
||||
ref_out, _ = ref_linear(x)
|
||||
mot_out, _ = mot_linear(x, text_indices=None, vae_indices=None)
|
||||
|
||||
_check_and_report(ref_out, mot_out, f"Row und-mode M={M} K={K}")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Test: boundary cases (all-text, all-VAE)
|
||||
# =========================================================================
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"boundary_mode",
|
||||
["all_text", "all_vae"],
|
||||
ids=["all_text", "all_vae"],
|
||||
)
|
||||
@pytest.mark.parametrize("dtype", ["w16a16_bf16", "w16a16_fp16"])
|
||||
def test_mot_qkv_boundary(boundary_mode: str, dtype: str):
|
||||
"""Boundary: all tokens routed to one expert."""
|
||||
dcfg = _parse_dtype(dtype)
|
||||
torch.manual_seed(42)
|
||||
M = 512
|
||||
K = 3584
|
||||
|
||||
with set_current_vllm_config(VllmConfig()):
|
||||
text_linear = QKVParallelLinear(
|
||||
hidden_size=K,
|
||||
head_size=_BAGEL_HEAD_SIZE,
|
||||
total_num_heads=_BAGEL_TOTAL_NUM_HEADS,
|
||||
total_num_kv_heads=_BAGEL_TOTAL_NUM_KV_HEADS,
|
||||
bias=False,
|
||||
params_dtype=dcfg.torch_dtype,
|
||||
disable_tp=True,
|
||||
).cuda()
|
||||
vae_linear = QKVParallelLinear(
|
||||
hidden_size=K,
|
||||
head_size=_BAGEL_HEAD_SIZE,
|
||||
total_num_heads=_BAGEL_TOTAL_NUM_HEADS,
|
||||
total_num_kv_heads=_BAGEL_TOTAL_NUM_KV_HEADS,
|
||||
bias=False,
|
||||
params_dtype=dcfg.torch_dtype,
|
||||
disable_tp=True,
|
||||
).cuda()
|
||||
mot_linear = MoTQKVParallelLinear(
|
||||
hidden_size=K,
|
||||
head_size=_BAGEL_HEAD_SIZE,
|
||||
total_num_heads=_BAGEL_TOTAL_NUM_HEADS,
|
||||
total_num_kv_heads=_BAGEL_TOTAL_NUM_KV_HEADS,
|
||||
bias=False,
|
||||
vae_bias=False,
|
||||
params_dtype=dcfg.torch_dtype,
|
||||
disable_tp=True,
|
||||
).cuda()
|
||||
|
||||
_sync_weights(text_linear, vae_linear, mot_linear)
|
||||
|
||||
all_indices = torch.arange(M, dtype=torch.long, device="cuda")
|
||||
empty_indices = torch.empty(0, dtype=torch.long, device="cuda")
|
||||
|
||||
if boundary_mode == "all_text":
|
||||
text_idx, vae_idx = all_indices, empty_indices
|
||||
else:
|
||||
text_idx, vae_idx = empty_indices, all_indices
|
||||
|
||||
x = torch.randn(M, K, dtype=dcfg.torch_dtype, device="cuda")
|
||||
|
||||
with torch.no_grad():
|
||||
ref = _reference_forward(x, text_idx, vae_idx, text_linear, vae_linear)
|
||||
mot_out, _ = mot_linear(x, text_idx, vae_idx)
|
||||
|
||||
_check_and_report(ref, mot_out, f"QKV boundary={boundary_mode} M={M}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"boundary_mode",
|
||||
["all_text", "all_vae"],
|
||||
ids=["all_text", "all_vae"],
|
||||
)
|
||||
@pytest.mark.parametrize("dtype", ["w16a16_bf16", "w16a16_fp16"])
|
||||
def test_mot_row_boundary(boundary_mode: str, dtype: str):
|
||||
"""Boundary: all tokens routed to one expert for RowParallel."""
|
||||
dcfg = _parse_dtype(dtype)
|
||||
torch.manual_seed(42)
|
||||
M = 512
|
||||
K = 3584
|
||||
N = 3584
|
||||
|
||||
with set_current_vllm_config(VllmConfig()):
|
||||
text_linear = RowParallelLinear(
|
||||
K,
|
||||
N,
|
||||
bias=False,
|
||||
input_is_parallel=True,
|
||||
params_dtype=dcfg.torch_dtype,
|
||||
disable_tp=True,
|
||||
).cuda()
|
||||
vae_linear = RowParallelLinear(
|
||||
K,
|
||||
N,
|
||||
bias=False,
|
||||
input_is_parallel=True,
|
||||
params_dtype=dcfg.torch_dtype,
|
||||
disable_tp=True,
|
||||
).cuda()
|
||||
mot_linear = MoTRowParallelLinear(
|
||||
K,
|
||||
N,
|
||||
bias=False,
|
||||
vae_bias=False,
|
||||
input_is_parallel=True,
|
||||
params_dtype=dcfg.torch_dtype,
|
||||
disable_tp=True,
|
||||
).cuda()
|
||||
|
||||
_sync_weights(text_linear, vae_linear, mot_linear)
|
||||
|
||||
all_indices = torch.arange(M, dtype=torch.long, device="cuda")
|
||||
empty_indices = torch.empty(0, dtype=torch.long, device="cuda")
|
||||
|
||||
if boundary_mode == "all_text":
|
||||
text_idx, vae_idx = all_indices, empty_indices
|
||||
else:
|
||||
text_idx, vae_idx = empty_indices, all_indices
|
||||
|
||||
x = torch.randn(M, K, dtype=dcfg.torch_dtype, device="cuda")
|
||||
|
||||
with torch.no_grad():
|
||||
ref = _reference_forward(x, text_idx, vae_idx, text_linear, vae_linear)
|
||||
mot_out, _ = mot_linear(x, text_idx, vae_idx)
|
||||
|
||||
_check_and_report(ref, mot_out, f"Row boundary={boundary_mode} M={M}")
|
||||
@@ -0,0 +1,357 @@
|
||||
# ruff: noqa: N803, E741
|
||||
"""Layer-level correctness & performance test for MoTRMSNorm.
|
||||
|
||||
Compares two equivalent computation paths:
|
||||
- Reference: 2x vLLM IR RMSNorm op + PyTorch index scatter/gather
|
||||
(ir.ops.rms_norm(x[text_idx], text_w) + ir.ops.rms_norm(x[vae_idx], vae_w))
|
||||
- Target: 1x MoTRMSNorm fused Triton kernel
|
||||
(mot_norm(x, text_indices, vae_indices))
|
||||
|
||||
Usage::
|
||||
pytest tests/diffusion/kernels/mot/test_mot_norm.py -v -s
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from vllm import ir
|
||||
from vllm.config import VllmConfig, set_current_vllm_config
|
||||
|
||||
from vllm_omni.diffusion.layers.mot.mot_layernorm import MoTRMSNorm
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.gpu]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_EPS = 1e-6
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_indices(
|
||||
M: int,
|
||||
text_ratio: float,
|
||||
device: str = "cuda",
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
M_text = max(1, int(M * text_ratio))
|
||||
perm = torch.randperm(M, device=device)
|
||||
text_indices = perm[:M_text].sort().values
|
||||
vae_indices = perm[M_text:].sort().values
|
||||
return text_indices, vae_indices
|
||||
|
||||
|
||||
def _benchmark(fn, warmup: int = 20, iters: int = 200) -> float:
|
||||
"""Return mean latency in milliseconds."""
|
||||
for _ in range(warmup):
|
||||
fn()
|
||||
torch.accelerator.synchronize()
|
||||
t0 = time.perf_counter()
|
||||
for _ in range(iters):
|
||||
fn()
|
||||
torch.accelerator.synchronize()
|
||||
return (time.perf_counter() - t0) / iters * 1000.0
|
||||
|
||||
|
||||
def _reference_forward(
|
||||
x: torch.Tensor,
|
||||
text_indices: torch.Tensor,
|
||||
vae_indices: torch.Tensor,
|
||||
text_weight: torch.Tensor,
|
||||
vae_weight: torch.Tensor,
|
||||
eps: float,
|
||||
) -> torch.Tensor:
|
||||
"""Reference: index-gather -> 2x vLLM IR RMSNorm op -> scatter."""
|
||||
output = torch.empty_like(x)
|
||||
output[text_indices] = ir.ops.rms_norm(x[text_indices], text_weight, eps)
|
||||
output[vae_indices] = ir.ops.rms_norm(x[vae_indices], vae_weight, eps)
|
||||
return output
|
||||
|
||||
|
||||
def _reference_forward_head_norm(
|
||||
x: torch.Tensor,
|
||||
text_indices: torch.Tensor,
|
||||
vae_indices: torch.Tensor,
|
||||
text_weight: torch.Tensor,
|
||||
vae_weight: torch.Tensor,
|
||||
eps: float,
|
||||
) -> torch.Tensor:
|
||||
"""Reference for head_norm=True path.
|
||||
|
||||
The MoT routing happens on token dimension while RMSNorm is applied
|
||||
independently on each head's last dimension.
|
||||
"""
|
||||
output = torch.empty_like(x)
|
||||
hidden_size = x.shape[-1]
|
||||
output[text_indices] = ir.ops.rms_norm(
|
||||
x[text_indices].reshape(-1, hidden_size),
|
||||
text_weight,
|
||||
eps,
|
||||
).reshape_as(x[text_indices])
|
||||
output[vae_indices] = ir.ops.rms_norm(
|
||||
x[vae_indices].reshape(-1, hidden_size),
|
||||
vae_weight,
|
||||
eps,
|
||||
).reshape_as(x[vae_indices])
|
||||
return output
|
||||
|
||||
|
||||
def _check_and_report(ref: torch.Tensor, mot: torch.Tensor, tag: str):
|
||||
"""Compare outputs, print metrics, and assert correctness.
|
||||
|
||||
Both ``ref`` and ``mot`` are in the original compute dtype (e.g. bf16).
|
||||
We upcast to fp32 solely for computing error metrics with higher
|
||||
arithmetic precision — the actual layer outputs remain bf16.
|
||||
"""
|
||||
ref_hp = ref.float()
|
||||
mot_hp = mot.float()
|
||||
|
||||
abs_err = (ref_hp - mot_hp).abs()
|
||||
max_abs = abs_err.max().item()
|
||||
|
||||
denom = ref_hp.abs().clamp(min=1.0)
|
||||
max_rel = (abs_err / denom).max().item()
|
||||
|
||||
cos_sim = (
|
||||
torch.nn.functional.cosine_similarity(
|
||||
ref_hp,
|
||||
mot_hp,
|
||||
dim=-1,
|
||||
)
|
||||
.min()
|
||||
.item()
|
||||
)
|
||||
|
||||
print(f"\n [{tag}] max_abs={max_abs:.4e} max_rel={max_rel:.4e} min_cos_sim={cos_sim:.6f}")
|
||||
|
||||
# RMSNorm is element-wise (no cross-element accumulation like GEMM),
|
||||
# so the error between two fp32-accumulating implementations should
|
||||
# be very small — well within bf16 rounding.
|
||||
assert cos_sim > 0.99, f"Cosine similarity too low: {cos_sim:.6f}"
|
||||
assert max_rel < 0.05, f"Max relative error too large: {max_rel:.4e}"
|
||||
|
||||
|
||||
def _run_timing(
|
||||
ref_fn,
|
||||
mot_fn,
|
||||
tag: str,
|
||||
warmup: int = 20,
|
||||
iters: int = 200,
|
||||
):
|
||||
"""Benchmark both paths and print timing comparison."""
|
||||
ref_ms = _benchmark(ref_fn, warmup=warmup, iters=iters)
|
||||
mot_ms = _benchmark(mot_fn, warmup=warmup, iters=iters)
|
||||
speedup = ref_ms / mot_ms if mot_ms > 0 else float("inf")
|
||||
print(f" [{tag}] Ref(2x rms_norm): {ref_ms:.3f} ms | MoT(fused): {mot_ms:.3f} ms | Speedup: {speedup:.2f}x")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Test: MoTRMSNorm vs 2x vLLM rms_norm
|
||||
# =========================================================================
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"M, hidden_size, text_ratio, dtype",
|
||||
[
|
||||
(2048, 3584, 0.01, torch.bfloat16),
|
||||
(8192, 3584, 0.01, torch.bfloat16),
|
||||
(2048, 128, 0.01, torch.bfloat16),
|
||||
(8192, 128, 0.01, torch.bfloat16),
|
||||
(2048, 3584, 0.01, torch.float16),
|
||||
(8192, 3584, 0.01, torch.float16),
|
||||
],
|
||||
ids=[
|
||||
"M2048_H3584_layernorm_bf16",
|
||||
"M8192_H3584_layernorm_bf16",
|
||||
"M2048_H128_qknorm_bf16",
|
||||
"M8192_H128_qknorm_bf16",
|
||||
"M2048_H3584_layernorm_fp16",
|
||||
"M8192_H3584_layernorm_fp16",
|
||||
],
|
||||
)
|
||||
def test_mot_rms_norm(M: int, hidden_size: int, text_ratio: float, dtype: torch.dtype):
|
||||
torch.manual_seed(42)
|
||||
|
||||
# --- Build MoT layer ---
|
||||
mot_norm = MoTRMSNorm(hidden_size, eps=_EPS).cuda()
|
||||
with torch.no_grad():
|
||||
W_text = torch.randn(hidden_size, dtype=dtype, device="cuda")
|
||||
W_vae = torch.randn(hidden_size, dtype=dtype, device="cuda")
|
||||
mot_norm.weight.data.copy_(W_text)
|
||||
mot_norm.gen_weight.data.copy_(W_vae)
|
||||
|
||||
# --- Build reference weights (same data) ---
|
||||
ref_text_weight = W_text.clone()
|
||||
ref_vae_weight = W_vae.clone()
|
||||
|
||||
# --- Inputs ---
|
||||
x = torch.randn(M, hidden_size, dtype=dtype, device="cuda")
|
||||
text_idx, vae_idx = _make_indices(M, text_ratio)
|
||||
|
||||
tag = f"RMSNorm M={M} H={hidden_size}"
|
||||
|
||||
# vLLM's IR RMSNorm op may inspect global config; wrap in VllmConfig
|
||||
# context for safety.
|
||||
with set_current_vllm_config(VllmConfig()):
|
||||
# --- Correctness (also warms up Triton JIT) ---
|
||||
with torch.no_grad():
|
||||
ref = _reference_forward(
|
||||
x,
|
||||
text_idx,
|
||||
vae_idx,
|
||||
ref_text_weight,
|
||||
ref_vae_weight,
|
||||
_EPS,
|
||||
)
|
||||
mot_out = mot_norm(x, text_idx, vae_idx)
|
||||
|
||||
_check_and_report(ref, mot_out, tag)
|
||||
|
||||
# --- Performance ---
|
||||
with torch.no_grad():
|
||||
_run_timing(
|
||||
lambda: _reference_forward(
|
||||
x,
|
||||
text_idx,
|
||||
vae_idx,
|
||||
ref_text_weight,
|
||||
ref_vae_weight,
|
||||
_EPS,
|
||||
),
|
||||
lambda: mot_norm(x, text_idx, vae_idx),
|
||||
tag,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"M, num_heads, head_dim, text_ratio",
|
||||
[
|
||||
(2048, 28, 128, 0.01),
|
||||
],
|
||||
ids=[
|
||||
"M2048_NH28_HD128_qknorm_head_norm",
|
||||
],
|
||||
)
|
||||
def test_mot_rms_norm_head_norm(
|
||||
M: int,
|
||||
num_heads: int,
|
||||
head_dim: int,
|
||||
text_ratio: float,
|
||||
):
|
||||
torch.manual_seed(42)
|
||||
dtype = torch.bfloat16
|
||||
|
||||
# --- Build MoT layer (head_norm path) ---
|
||||
mot_norm = MoTRMSNorm(head_dim, head_norm=True, eps=_EPS).cuda()
|
||||
with torch.no_grad():
|
||||
W_text = torch.randn(head_dim, dtype=dtype, device="cuda")
|
||||
W_vae = torch.randn(head_dim, dtype=dtype, device="cuda")
|
||||
mot_norm.weight.data.copy_(W_text)
|
||||
mot_norm.gen_weight.data.copy_(W_vae)
|
||||
|
||||
# --- Build reference weights (same data) ---
|
||||
ref_text_weight = W_text.clone()
|
||||
ref_vae_weight = W_vae.clone()
|
||||
|
||||
# --- Inputs ---
|
||||
x = torch.randn(M, num_heads, head_dim, dtype=dtype, device="cuda")
|
||||
text_idx, vae_idx = _make_indices(M, text_ratio)
|
||||
|
||||
tag = f"RMSNorm(head_norm=True) M={M} NH={num_heads} HD={head_dim}"
|
||||
|
||||
with set_current_vllm_config(VllmConfig()):
|
||||
# --- Correctness (also warms up Triton JIT) ---
|
||||
with torch.no_grad():
|
||||
ref = _reference_forward_head_norm(
|
||||
x,
|
||||
text_idx,
|
||||
vae_idx,
|
||||
ref_text_weight,
|
||||
ref_vae_weight,
|
||||
_EPS,
|
||||
)
|
||||
mot_out = mot_norm(x, text_idx, vae_idx)
|
||||
|
||||
_check_and_report(ref, mot_out, tag)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Test: und-mode (text_indices=None) — degrades to standard RMSNorm
|
||||
# =========================================================================
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"M, hidden_size, dtype",
|
||||
[
|
||||
(2048, 3584, torch.bfloat16),
|
||||
(2048, 3584, torch.float16),
|
||||
(2048, 128, torch.bfloat16),
|
||||
],
|
||||
ids=["M2048_H3584_bf16", "M2048_H3584_fp16", "M2048_H128_bf16"],
|
||||
)
|
||||
def test_mot_rms_norm_und_mode(M: int, hidden_size: int, dtype: torch.dtype):
|
||||
"""und-mode: text_indices=None should apply self.weight to all tokens (standard RMSNorm)."""
|
||||
torch.manual_seed(42)
|
||||
|
||||
mot_norm = MoTRMSNorm(hidden_size, eps=_EPS).cuda()
|
||||
with torch.no_grad():
|
||||
W_text = torch.randn(hidden_size, dtype=dtype, device="cuda")
|
||||
mot_norm.weight.data.copy_(W_text)
|
||||
|
||||
x = torch.randn(M, hidden_size, dtype=dtype, device="cuda")
|
||||
|
||||
with set_current_vllm_config(VllmConfig()):
|
||||
with torch.no_grad():
|
||||
ref = ir.ops.rms_norm(x, W_text, _EPS)
|
||||
mot_out = mot_norm(x, text_indices=None, vae_indices=None)
|
||||
|
||||
_check_and_report(ref, mot_out, f"RMSNorm und-mode M={M} H={hidden_size}")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Test: boundary cases (all-text, all-VAE)
|
||||
# =========================================================================
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"boundary_mode",
|
||||
["all_text", "all_vae"],
|
||||
ids=["all_text", "all_vae"],
|
||||
)
|
||||
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16], ids=["bf16", "fp16"])
|
||||
def test_mot_rms_norm_boundary(boundary_mode: str, dtype: torch.dtype):
|
||||
"""Boundary: all tokens routed to one expert."""
|
||||
torch.manual_seed(42)
|
||||
M = 1024
|
||||
hidden_size = 3584
|
||||
|
||||
mot_norm = MoTRMSNorm(hidden_size, eps=_EPS).cuda()
|
||||
with torch.no_grad():
|
||||
W_text = torch.randn(hidden_size, dtype=dtype, device="cuda")
|
||||
W_vae = torch.randn(hidden_size, dtype=dtype, device="cuda")
|
||||
mot_norm.weight.data.copy_(W_text)
|
||||
mot_norm.gen_weight.data.copy_(W_vae)
|
||||
|
||||
x = torch.randn(M, hidden_size, dtype=dtype, device="cuda")
|
||||
all_indices = torch.arange(M, dtype=torch.long, device="cuda")
|
||||
empty_indices = torch.empty(0, dtype=torch.long, device="cuda")
|
||||
|
||||
if boundary_mode == "all_text":
|
||||
text_idx, vae_idx = all_indices, empty_indices
|
||||
expected_weight = W_text
|
||||
else:
|
||||
text_idx, vae_idx = empty_indices, all_indices
|
||||
expected_weight = W_vae
|
||||
|
||||
with set_current_vllm_config(VllmConfig()):
|
||||
with torch.no_grad():
|
||||
ref = ir.ops.rms_norm(x, expected_weight, _EPS)
|
||||
mot_out = mot_norm(x, text_idx, vae_idx)
|
||||
|
||||
_check_and_report(ref, mot_out, f"RMSNorm boundary={boundary_mode} M={M}")
|
||||
@@ -19,15 +19,18 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
NUM_TOKENS = 8
|
||||
HIDDEN_DIM = 16
|
||||
NUM_TIMESTEPS = 5
|
||||
# generate_image samples (num_timesteps + 1) points then drops the last, matching
|
||||
# upstream lance: see Bagel.generate_image in bagel_transformer.py.
|
||||
EXPECTED_STEPS = NUM_TIMESTEPS
|
||||
# Official BAGEL samples num_timesteps points then drops the terminal t=0,
|
||||
# yielding num_timesteps - 1 denoise steps.
|
||||
EXPECTED_STEPS = NUM_TIMESTEPS - 1
|
||||
|
||||
|
||||
def _make_mock_bagel(mocker: MockerFixture):
|
||||
"""Create a mock Bagel with forward returning constant velocity."""
|
||||
mock = mocker.MagicMock(spec=Bagel)
|
||||
mock._sp_size = 1
|
||||
# MagicMock would otherwise auto-return a truthy stub for this flag; pin it
|
||||
# to the official BAGEL schedule convention (num_timesteps points).
|
||||
mock._denoise_schedule_extra_step = False
|
||||
|
||||
# forward returns a small constant velocity so x_t changes each step
|
||||
def fake_forward(self, x_t, **kwargs):
|
||||
@@ -179,7 +182,7 @@ class TestTrajectoryRecording:
|
||||
bagel, args = bagel_and_args
|
||||
|
||||
# Recompute the expected timestep schedule (mirrors generate_image logic)
|
||||
ts = torch.linspace(1, 0, args["num_timesteps"] + 1)
|
||||
ts = torch.linspace(1, 0, args["num_timesteps"])
|
||||
shift = args.get("timestep_shift", 1.0)
|
||||
ts = shift * ts / (1 + (shift - 1) * ts)
|
||||
expected_timesteps = ts[:-1] # last element is dropped
|
||||
@@ -197,7 +200,7 @@ class TestTrajectoryRecording:
|
||||
bagel, args = bagel_and_args
|
||||
|
||||
# Compute the number of denoising steps the same way generate_image does
|
||||
ts = torch.linspace(1, 0, args["num_timesteps"] + 1)
|
||||
ts = torch.linspace(1, 0, args["num_timesteps"])
|
||||
shift = args.get("timestep_shift", 1.0)
|
||||
ts = shift * ts / (1 + (shift - 1) * ts)
|
||||
num_steps = len(ts) - 1 # timesteps = ts[:-1]
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
@@ -230,6 +231,8 @@ def test_pipeline_registered_and_exported() -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import Cosmos3OmniDiffusersPipeline
|
||||
from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin
|
||||
from vllm_omni.diffusion.registry import (
|
||||
_DIFFUSION_ACTION_POST_PROCESS_FUNCS,
|
||||
_DIFFUSION_IR_OP_PRIORITY_FUNCS,
|
||||
_DIFFUSION_MODELS,
|
||||
_DIFFUSION_POST_PROCESS_FUNCS,
|
||||
_DIFFUSION_PRE_PROCESS_FUNCS,
|
||||
@@ -245,6 +248,10 @@ def test_pipeline_registered_and_exported() -> None:
|
||||
)
|
||||
assert _DIFFUSION_PRE_PROCESS_FUNCS["Cosmos3OmniDiffusersPipeline"] == "get_cosmos3_pre_process_func"
|
||||
assert _DIFFUSION_POST_PROCESS_FUNCS["Cosmos3OmniDiffusersPipeline"] == "get_cosmos3_post_process_func"
|
||||
assert (
|
||||
_DIFFUSION_ACTION_POST_PROCESS_FUNCS["Cosmos3OmniDiffusersPipeline"] == "get_cosmos3_action_post_process_func"
|
||||
)
|
||||
assert _DIFFUSION_IR_OP_PRIORITY_FUNCS["Cosmos3OmniDiffusersPipeline"] == "get_cosmos3_ir_op_priority_func"
|
||||
assert "Cosmos3OmniDiffusersPipeline" in CUSTOM_DIT_ENABLERS
|
||||
assert "Cosmos3OmniDiffusersPipeline" in cosmos3.__all__
|
||||
|
||||
@@ -413,6 +420,73 @@ def test_postprocess_handles_image_video_audio_and_validation() -> None:
|
||||
func({"image": video, "video": video})
|
||||
|
||||
|
||||
def test_action_postprocess_handles_robolab_policy_outputs() -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import (
|
||||
RoboLabPolicyInputs,
|
||||
get_cosmos3_action_post_process_func,
|
||||
make_robolab_action_postprocess_inputs,
|
||||
)
|
||||
|
||||
func = get_cosmos3_action_post_process_func(SimpleNamespace())
|
||||
inputs = RoboLabPolicyInputs(
|
||||
prompt="Pick the cube.",
|
||||
video_tensor=torch.zeros(1, 3, 3, 16, 16),
|
||||
action_tensor=torch.zeros(2, 2),
|
||||
action_condition_indexes=[0],
|
||||
action_start_frame_offset=1,
|
||||
raw_action_dim=2,
|
||||
domain_id=7,
|
||||
fps=15.0,
|
||||
height=16,
|
||||
width=16,
|
||||
image_size=None,
|
||||
num_frames=3,
|
||||
num_inference_steps=4,
|
||||
guidance_scale=3.0,
|
||||
flow_shift=5.0,
|
||||
seed=11,
|
||||
history_length=1,
|
||||
action_space="joint_pos",
|
||||
observation={},
|
||||
)
|
||||
|
||||
action = torch.tensor([[[0.0, 0.25], [1.0, 0.75]]])
|
||||
custom_output = {"robolab_action_postprocess": make_robolab_action_postprocess_inputs(inputs)}
|
||||
processed = func(action, custom_output=custom_output)
|
||||
|
||||
assert processed.shape == (1, 2)
|
||||
assert processed.dtype == torch.zeros((), dtype=torch.float32).numpy().dtype
|
||||
torch.testing.assert_close(torch.from_numpy(processed), torch.tensor([[1.0, 0.25]]))
|
||||
assert "robolab_action_postprocess" not in custom_output
|
||||
|
||||
|
||||
def test_ir_op_priority_hook_preserves_platform_fields(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import get_cosmos3_ir_op_priority_func
|
||||
|
||||
@dataclass
|
||||
class FakeIrOpPriorityConfig:
|
||||
rms_norm: list[str]
|
||||
fused_add_rms_norm: list[str]
|
||||
custom_op: list[str]
|
||||
|
||||
fake_kernel = types.ModuleType("vllm.config.kernel")
|
||||
fake_kernel.IrOpPriorityConfig = FakeIrOpPriorityConfig
|
||||
monkeypatch.setitem(sys.modules, fake_kernel.__name__, fake_kernel)
|
||||
|
||||
func = get_cosmos3_ir_op_priority_func(SimpleNamespace())
|
||||
default_priority = FakeIrOpPriorityConfig(
|
||||
rms_norm=["vllm_c", "native"],
|
||||
fused_add_rms_norm=["vllm_c", "native"],
|
||||
custom_op=["platform_kernel", "native"],
|
||||
)
|
||||
|
||||
merged = func(default_priority, vllm_config=SimpleNamespace())
|
||||
|
||||
assert merged.rms_norm == ["native"]
|
||||
assert merged.fused_add_rms_norm == ["native"]
|
||||
assert merged.custom_op == ["platform_kernel", "native"]
|
||||
|
||||
|
||||
def test_prompt_formatting_and_checkpoint_key_remap(make_cosmos3_pipeline) -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import Cosmos3OmniDiffusersPipeline
|
||||
|
||||
@@ -762,6 +836,95 @@ class TestForwardRouting:
|
||||
)
|
||||
assert captured["diffuse_calls"][-1]["shared_kwargs"]["action_domain_ids"].tolist() == [7]
|
||||
assert output.custom_output["action"].shape == (1, 2, 2)
|
||||
assert "action_only_output" not in output.custom_output
|
||||
|
||||
def test_forward_dispatches_robolab_policy_flow(
|
||||
self,
|
||||
make_cosmos3_pipeline,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3 import pipeline_cosmos3
|
||||
|
||||
pipeline = make_cosmos3_pipeline()
|
||||
pipeline.transformer = pipeline.transformer.__class__(latent_channel_size=2, action_gen=True, action_dim=4)
|
||||
captured = self._install_forward_stubs(pipeline)
|
||||
video_latents = torch.zeros(1, 2, 1, 1, 1)
|
||||
velocity_mask = torch.ones(1, 1, 1, 1, 1)
|
||||
condition_latents = torch.zeros_like(video_latents)
|
||||
|
||||
inputs = pipeline_cosmos3.RoboLabPolicyInputs(
|
||||
prompt="Pick the cube.",
|
||||
video_tensor=torch.zeros(1, 3, 3, 16, 16),
|
||||
action_tensor=torch.zeros(2, 2),
|
||||
action_condition_indexes=[0],
|
||||
action_start_frame_offset=1,
|
||||
raw_action_dim=2,
|
||||
domain_id=7,
|
||||
fps=15.0,
|
||||
height=16,
|
||||
width=16,
|
||||
image_size=None,
|
||||
num_frames=3,
|
||||
num_inference_steps=4,
|
||||
guidance_scale=3.0,
|
||||
flow_shift=5.0,
|
||||
seed=11,
|
||||
history_length=1,
|
||||
action_space="joint_pos",
|
||||
observation={},
|
||||
)
|
||||
|
||||
def fake_prepare_action_latents(**kwargs):
|
||||
captured["prepare_action"] = kwargs
|
||||
action_chunk_size = kwargs["action_chunk_size"]
|
||||
raw_action_dim = int(kwargs["raw_action_dim"])
|
||||
return (
|
||||
torch.zeros(1, action_chunk_size, 4),
|
||||
torch.ones(1, action_chunk_size, 1),
|
||||
torch.zeros(1, action_chunk_size, 4),
|
||||
raw_action_dim,
|
||||
)
|
||||
|
||||
def fake_prepare_action_video(*args, **kwargs):
|
||||
captured["prepare_action_video"] = {"args": args, "kwargs": kwargs}
|
||||
return video_latents, velocity_mask, condition_latents
|
||||
|
||||
monkeypatch.setattr(
|
||||
pipeline_cosmos3,
|
||||
"build_robolab_unipc_scheduler",
|
||||
lambda num_steps, shift, device: StubScheduler(list(range(num_steps, 0, -1)), flow_shift=shift),
|
||||
)
|
||||
pipeline._build_robolab_policy_inputs = lambda sp, prompt_data, request_id=None: inputs
|
||||
pipeline._prepare_action_latents = fake_prepare_action_latents
|
||||
pipeline._prepare_latents_action_video = fake_prepare_action_video
|
||||
pipeline._decode_latents = lambda latents: (_ for _ in ()).throw(
|
||||
AssertionError("RoboLab should not decode video")
|
||||
)
|
||||
|
||||
output = pipeline.forward(SimpleNamespace(prompts=["ignored"], sampling_params=make_sampling_params()))
|
||||
|
||||
assert captured["format"] == {
|
||||
"prompt": "Pick the cube.",
|
||||
"negative_prompt": "",
|
||||
"num_frames": 3,
|
||||
"frame_rate": 15.0,
|
||||
"height": 16,
|
||||
"width": 16,
|
||||
"is_t2i": False,
|
||||
}
|
||||
assert "flow_shifts" not in captured
|
||||
assert pipeline.scheduler.set_timesteps_calls == []
|
||||
assert captured["prepare_action"]["clean_action"] is inputs.action_tensor
|
||||
assert captured["prepare_action"]["condition_indexes"] == [0]
|
||||
assert captured["prepare_action_video"]["kwargs"] == {"image_size": None}
|
||||
assert captured["diffuse_calls"][-1]["shared_kwargs"]["action_domain_ids"].tolist() == [7]
|
||||
assert captured["diffuse_calls"][-1]["timesteps"].tolist() == [4, 3, 2, 1]
|
||||
assert output.output == {}
|
||||
assert output.custom_output["action_only_output"] is True
|
||||
assert output.custom_output["action"].shape == (1, 2, 2)
|
||||
assert "actions" not in output.custom_output
|
||||
assert "robolab_action_postprocess" in output.custom_output
|
||||
assert "robolab_policy_inputs" not in output.custom_output
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("prompt", "sampling_params", "message"),
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,141 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from vllm_omni.diffusion.models.gr00t import pipeline_gr00t
|
||||
from vllm_omni.diffusion.models.gr00t.pipeline_gr00t import Gr00tN1d7Pipeline
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class FakeGr00tPolicy:
|
||||
instances = []
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self.reset_calls = 0
|
||||
self.seen_obs = None
|
||||
self.embodiment_tag = SimpleNamespace(value="fake_embodiment")
|
||||
self.language_key = "annotation.language.language_instruction"
|
||||
self.modality_configs = {
|
||||
"action": SimpleNamespace(
|
||||
delta_indices=[0, 1],
|
||||
modality_keys=["arm", "gripper"],
|
||||
)
|
||||
}
|
||||
self.processor = SimpleNamespace(
|
||||
state_action_processor=SimpleNamespace(
|
||||
norm_params={
|
||||
"fake_embodiment": {
|
||||
"action": {
|
||||
"arm": {"dim": np.array(2)},
|
||||
"gripper": {"dim": np.array(1)},
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
FakeGr00tPolicy.instances.append(self)
|
||||
|
||||
def get_action(self, obs):
|
||||
self.seen_obs = obs
|
||||
return {
|
||||
"arm": np.array([[[1.0, 2.0]]], dtype=np.float64),
|
||||
"gripper": [[[3.0]]],
|
||||
}, {"latency_ms": 1.0}
|
||||
|
||||
def reset(self):
|
||||
self.reset_calls += 1
|
||||
return {"reset": True}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fake_gr00t_policy(monkeypatch):
|
||||
FakeGr00tPolicy.instances.clear()
|
||||
monkeypatch.setattr(pipeline_gr00t, "Gr00tPolicy", FakeGr00tPolicy)
|
||||
|
||||
|
||||
def _pipeline():
|
||||
od_config = SimpleNamespace(
|
||||
model="nvidia/GR00T-N1.7-3B",
|
||||
model_config={
|
||||
"embodiment_tag": "LIBERO_PANDA",
|
||||
"strict": False,
|
||||
},
|
||||
custom_pipeline_args={},
|
||||
)
|
||||
return Gr00tN1d7Pipeline(od_config=od_config)
|
||||
|
||||
|
||||
def test_pipeline_initializes_local_policy():
|
||||
pipeline = _pipeline()
|
||||
|
||||
policy = FakeGr00tPolicy.instances[0]
|
||||
assert policy.kwargs["model_path"] == "nvidia/GR00T-N1.7-3B"
|
||||
assert policy.kwargs["embodiment_tag"] == "LIBERO_PANDA"
|
||||
assert policy.kwargs["strict"] is False
|
||||
assert pipeline.weights_sources == ()
|
||||
assert pipeline.load_weights(iter(())) == set()
|
||||
|
||||
|
||||
def test_forward_returns_dict_actions_in_output():
|
||||
pipeline = _pipeline()
|
||||
req = OmniDiffusionRequest(
|
||||
prompts=["pick"],
|
||||
request_id="req",
|
||||
sampling_params=OmniDiffusionSamplingParams(
|
||||
extra_args={
|
||||
"robot_obs": {
|
||||
"images": {"cam": np.zeros((1, 1, 8, 8, 3), dtype=np.uint8)},
|
||||
"state": {"joint": np.zeros((1, 1, 2), dtype=np.float32)},
|
||||
"prompt": "pick the cube",
|
||||
"session_id": "session-a",
|
||||
},
|
||||
"reset": True,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
output = pipeline.forward(req)
|
||||
|
||||
assert output.error is None
|
||||
actions = output.output["actions"]
|
||||
assert set(actions) == {"arm", "gripper"}
|
||||
assert actions["arm"].dtype == np.float32
|
||||
np.testing.assert_allclose(actions["arm"], np.array([[[1.0, 2.0]]], dtype=np.float32))
|
||||
policy = FakeGr00tPolicy.instances[0]
|
||||
assert "video" in policy.seen_obs
|
||||
assert policy.seen_obs["language"] == {"annotation.language.language_instruction": [["pick the cube"]]}
|
||||
assert "images" not in policy.seen_obs
|
||||
assert "prompt" not in policy.seen_obs
|
||||
assert "session_id" not in policy.seen_obs
|
||||
assert policy.reset_calls == 1
|
||||
|
||||
|
||||
def test_dummy_warmup_returns_shape_correct_zero_actions():
|
||||
pipeline = _pipeline()
|
||||
req = OmniDiffusionRequest(
|
||||
prompts=["dummy run"],
|
||||
request_id="dummy_req_id",
|
||||
sampling_params=OmniDiffusionSamplingParams(num_inference_steps=1),
|
||||
)
|
||||
|
||||
output = pipeline.forward(req)
|
||||
|
||||
assert output.error is None
|
||||
actions = output.output["actions"]
|
||||
assert set(actions) == {"arm", "gripper"}
|
||||
assert actions["arm"].shape == (1, 2, 2)
|
||||
assert actions["gripper"].shape == (1, 2, 1)
|
||||
assert not actions["arm"].any()
|
||||
assert FakeGr00tPolicy.instances[0].seen_obs is None
|
||||
|
||||
|
||||
def test_reset_delegates_to_policy():
|
||||
pipeline = _pipeline()
|
||||
|
||||
assert pipeline.reset() == {"reset": True}
|
||||
assert FakeGr00tPolicy.instances[0].reset_calls == 1
|
||||
@@ -0,0 +1,366 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
import vllm_omni.diffusion.models.hunyuan_image3.pipeline_hunyuan_image3 as hy3_module
|
||||
from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec
|
||||
from vllm_omni.diffusion.models.hunyuan_image3.pipeline_hunyuan_image3 import (
|
||||
_STEP_AR_KV,
|
||||
_STEP_CFG_FACTOR,
|
||||
_STEP_GENERATOR,
|
||||
_STEP_GUIDANCE_SCALE,
|
||||
_STEP_INPUT_IDS,
|
||||
_STEP_MODEL_KWARGS,
|
||||
_STEP_PROMPT_KV,
|
||||
HunyuanImage3Pipeline,
|
||||
)
|
||||
from vllm_omni.diffusion.worker.input_batch import InputBatch
|
||||
from vllm_omni.diffusion.worker.utils import DiffusionRequestState
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _pipeline():
|
||||
pipeline = object.__new__(HunyuanImage3Pipeline)
|
||||
pipeline._tkwrapper = SimpleNamespace(pad_token_id=0)
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
diffusion_attention_config=AttentionConfig(default=AttentionSpec(backend="TORCH_SDPA")),
|
||||
parallel_config=SimpleNamespace(sequence_parallel_size=1, cfg_parallel_size=1),
|
||||
cache_backend=None,
|
||||
diffusion_kv_cache_skip_step_indices=None,
|
||||
)
|
||||
pipeline._pipeline = SimpleNamespace()
|
||||
return pipeline
|
||||
|
||||
|
||||
def _state(request_id: str, step_index: int) -> DiffusionRequestState:
|
||||
state = DiffusionRequestState(
|
||||
request_id=request_id,
|
||||
sampling=SimpleNamespace(),
|
||||
prompts=["prompt"],
|
||||
)
|
||||
state.step_index = step_index
|
||||
state.timesteps = torch.tensor([1.0, 0.5, 0.25, 0.0])
|
||||
state.latents = torch.zeros(1, 4, 8, 8)
|
||||
state.extra = {
|
||||
_STEP_CFG_FACTOR: 1,
|
||||
_STEP_AR_KV: None,
|
||||
_STEP_INPUT_IDS: None,
|
||||
_STEP_GUIDANCE_SCALE: 1.0,
|
||||
_STEP_MODEL_KWARGS: {
|
||||
"num_image_tokens": 17,
|
||||
"ar_kv_reuse_len": 0,
|
||||
},
|
||||
}
|
||||
return state
|
||||
|
||||
|
||||
def _sampling_params(**extra_args):
|
||||
return SimpleNamespace(
|
||||
timesteps=None,
|
||||
sigmas=None,
|
||||
num_outputs_per_prompt=None,
|
||||
extra_args=extra_args,
|
||||
height=512,
|
||||
width=512,
|
||||
num_inference_steps=4,
|
||||
guidance_scale=1.0,
|
||||
guidance_scale_provided=True,
|
||||
guidance_rescale=0.0,
|
||||
generator=None,
|
||||
)
|
||||
|
||||
|
||||
def test_hunyuan_step_group_key_ignores_step_index_for_later_steps():
|
||||
pipeline = _pipeline()
|
||||
states = [_state("req-0", 1), _state("req-1", 3)]
|
||||
|
||||
groups = pipeline._split_step_groups(states)
|
||||
|
||||
assert len(groups) == 1
|
||||
assert [state.request_id for state in groups[0]] == ["req-0", "req-1"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sampling", "prompt_item", "expected_model_bot_task", "expected_system_bot_task"),
|
||||
[
|
||||
pytest.param(
|
||||
_sampling_params(bot_task="think_recaption", use_system_prompt="dynamic"),
|
||||
{"prompt": "prompt", "bot_task": "vanilla"},
|
||||
"think",
|
||||
"think",
|
||||
id="extra-args-precedence",
|
||||
),
|
||||
pytest.param(
|
||||
_sampling_params(use_system_prompt="dynamic"),
|
||||
{"prompt": "prompt", "bot_task": "vanilla"},
|
||||
"image",
|
||||
"image",
|
||||
id="prompt-dict-fallback",
|
||||
),
|
||||
pytest.param(
|
||||
_sampling_params(use_system_prompt="dynamic"),
|
||||
{"prompt": "prompt"},
|
||||
"auto",
|
||||
"image",
|
||||
id="default-auto-system-prompt",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_prepare_encode_preserves_normal_hunyuan_bot_task_semantics(
|
||||
monkeypatch,
|
||||
sampling,
|
||||
prompt_item,
|
||||
expected_model_bot_task,
|
||||
expected_system_bot_task,
|
||||
):
|
||||
pipeline = _pipeline()
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_get_system_prompt(sys_type, bot_task, system_prompt=None):
|
||||
del sys_type, system_prompt
|
||||
captured["system_prompt_bot_task"] = bot_task
|
||||
return "system prompt"
|
||||
|
||||
def fake_prepare_model_inputs(**kwargs):
|
||||
captured.update(kwargs)
|
||||
raise RuntimeError("stop after prepare_model_inputs")
|
||||
|
||||
monkeypatch.setattr(hy3_module, "get_system_prompt", fake_get_system_prompt)
|
||||
pipeline.prepare_model_inputs = fake_prepare_model_inputs
|
||||
state = DiffusionRequestState(
|
||||
request_id="req-bot-task",
|
||||
sampling=sampling,
|
||||
prompts=[prompt_item],
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="stop after prepare_model_inputs"):
|
||||
pipeline.prepare_encode(state)
|
||||
|
||||
assert captured["bot_task"] == expected_model_bot_task
|
||||
assert captured["system_prompt_bot_task"] == expected_system_bot_task
|
||||
|
||||
|
||||
def test_forward_uses_same_hunyuan_bot_task_semantics(monkeypatch):
|
||||
pipeline = _pipeline()
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_get_system_prompt(sys_type, bot_task, system_prompt=None):
|
||||
del sys_type, system_prompt
|
||||
captured["system_prompt_bot_task"] = bot_task
|
||||
return "system prompt"
|
||||
|
||||
def fake_prepare_model_inputs(**kwargs):
|
||||
captured.update(kwargs)
|
||||
raise RuntimeError("stop after prepare_model_inputs")
|
||||
|
||||
monkeypatch.setattr(hy3_module, "get_system_prompt", fake_get_system_prompt)
|
||||
pipeline.prepare_model_inputs = fake_prepare_model_inputs
|
||||
req = SimpleNamespace(
|
||||
request_id="req-forward-bot-task",
|
||||
sampling_params=_sampling_params(bot_task="think_recaption", use_system_prompt="dynamic"),
|
||||
prompts=[{"prompt": "prompt", "bot_task": "vanilla"}],
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="stop after prepare_model_inputs"):
|
||||
pipeline.forward(req)
|
||||
|
||||
assert captured["bot_task"] == "think"
|
||||
assert captured["system_prompt_bot_task"] == "think"
|
||||
|
||||
|
||||
def test_grouped_denoise_rejects_non_sdpa_attention_backend():
|
||||
pipeline = _pipeline()
|
||||
pipeline.od_config.diffusion_attention_config = AttentionConfig(default=AttentionSpec(backend="FLASH_ATTN"))
|
||||
|
||||
with pytest.raises(ValueError, match="only supports TORCH_SDPA"):
|
||||
pipeline._ensure_grouped_attention_backend_supported(2)
|
||||
|
||||
|
||||
def test_single_denoise_allows_non_sdpa_attention_backend():
|
||||
pipeline = _pipeline()
|
||||
pipeline.od_config.diffusion_attention_config = AttentionConfig(default=AttentionSpec(backend="FLASH_ATTN"))
|
||||
|
||||
pipeline._ensure_grouped_attention_backend_supported(1)
|
||||
|
||||
|
||||
def test_grouped_denoise_allows_sdpa_attention_backend():
|
||||
pipeline = _pipeline()
|
||||
|
||||
pipeline._ensure_grouped_attention_backend_supported(2)
|
||||
|
||||
|
||||
def test_step_scheduler_preserves_latent_dtype_for_mixed_progress_batches():
|
||||
pipeline = _pipeline()
|
||||
pipeline._pipeline = SimpleNamespace(prepare_extra_func_kwargs=lambda step, kwargs: {})
|
||||
|
||||
class FakeScheduler:
|
||||
def step(self, noise_pred, timestep, latents, **kwargs):
|
||||
del timestep, kwargs
|
||||
return (latents.float() + noise_pred.float(),)
|
||||
|
||||
state = _state("req", 0)
|
||||
state.timesteps = torch.tensor([1.0])
|
||||
state.scheduler = FakeScheduler()
|
||||
state.latents = torch.zeros(1, 4, 8, 8, dtype=torch.bfloat16)
|
||||
state.extra[_STEP_GENERATOR] = None
|
||||
|
||||
pipeline.step_scheduler(state, torch.ones_like(state.latents, dtype=torch.float32))
|
||||
|
||||
assert state.latents.dtype == torch.bfloat16
|
||||
assert state.step_index == 1
|
||||
|
||||
|
||||
def test_later_step_merge_shifts_spans_without_polluting_request_state():
|
||||
pipeline = _pipeline()
|
||||
states = [_state("short", 2), _state("long", 4)]
|
||||
states[0].extra[_STEP_MODEL_KWARGS].update(
|
||||
{
|
||||
"attention_mask": torch.ones(1, 1, 3, 5, dtype=torch.bool),
|
||||
"full_attn_spans": [[(2, 5)]],
|
||||
}
|
||||
)
|
||||
states[1].extra[_STEP_MODEL_KWARGS].update(
|
||||
{
|
||||
"attention_mask": torch.ones(1, 1, 3, 7, dtype=torch.bool),
|
||||
"full_attn_spans": [[(4, 7)]],
|
||||
}
|
||||
)
|
||||
states[0].extra[_STEP_PROMPT_KV] = [{"lens": torch.tensor([2])}]
|
||||
states[1].extra[_STEP_PROMPT_KV] = [{"lens": torch.tensor([4])}]
|
||||
|
||||
row_state_indexes = [0, 1]
|
||||
row_branches = [0, 0]
|
||||
_, merged = pipeline._merge_step_model_inputs(
|
||||
states,
|
||||
row_state_indexes,
|
||||
row_branches,
|
||||
first_step=False,
|
||||
)
|
||||
|
||||
assert merged["attention_mask"].shape == (2, 1, 3, 7)
|
||||
assert merged["full_attn_spans"] == [[(4, 7)], [(4, 7)]]
|
||||
|
||||
pipeline._split_merged_kwargs_to_states(states, merged, row_state_indexes, row_branches)
|
||||
|
||||
assert states[0].extra[_STEP_MODEL_KWARGS]["attention_mask"].shape == (1, 1, 3, 5)
|
||||
assert states[1].extra[_STEP_MODEL_KWARGS]["attention_mask"].shape == (1, 1, 3, 7)
|
||||
assert states[0].extra[_STEP_MODEL_KWARGS]["full_attn_spans"] == [[(2, 5)]]
|
||||
assert states[1].extra[_STEP_MODEL_KWARGS]["full_attn_spans"] == [[(4, 7)]]
|
||||
|
||||
|
||||
def test_later_step_merge_allows_request_local_step_counts_and_guidance_values():
|
||||
pipeline = _pipeline()
|
||||
states = [_state("req-0", 1), _state("req-1", 3)]
|
||||
for idx, state in enumerate(states):
|
||||
state.extra[_STEP_MODEL_KWARGS].update(
|
||||
{
|
||||
"attention_mask": torch.ones(1, 1, 2, 4, dtype=torch.bool),
|
||||
"full_attn_spans": [[(2, 4)]],
|
||||
"guidance_scale": 3.0 + idx,
|
||||
"num_inference_steps": 20 + idx,
|
||||
}
|
||||
)
|
||||
state.extra[_STEP_PROMPT_KV] = [{"lens": torch.tensor([2])}]
|
||||
|
||||
_, merged = pipeline._merge_step_model_inputs(
|
||||
states,
|
||||
row_state_indexes=[0, 1],
|
||||
row_branches=[0, 0],
|
||||
first_step=False,
|
||||
)
|
||||
|
||||
assert "guidance_scale" not in merged
|
||||
assert "num_inference_steps" not in merged
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("request_id", "mutate_state", "error_match"),
|
||||
[
|
||||
pytest.param(
|
||||
"broken-req",
|
||||
lambda state: state.extra.pop(_STEP_MODEL_KWARGS),
|
||||
"broken-req",
|
||||
id="missing-model-kwargs",
|
||||
),
|
||||
pytest.param(
|
||||
"bad-cfg",
|
||||
lambda state: state.extra.__setitem__(_STEP_CFG_FACTOR, 3),
|
||||
"bad-cfg",
|
||||
id="unsupported-cfg-factor",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_denoise_step_reports_invalid_group_state_with_request_id(request_id, mutate_state, error_match):
|
||||
pipeline = _pipeline()
|
||||
state = _state(request_id, 0)
|
||||
mutate_state(state)
|
||||
|
||||
with pytest.raises(ValueError, match=error_match):
|
||||
pipeline.denoise_step(InputBatch.make_batch([state]))
|
||||
|
||||
|
||||
def test_denoise_step_uses_input_batch_group_order_and_splits_back(monkeypatch):
|
||||
pipeline = _pipeline()
|
||||
monkeypatch.setattr(HunyuanImage3Pipeline, "device", property(lambda self: torch.device("cpu")))
|
||||
states = [_state("req-0", 1), _state("req-1", 3)]
|
||||
for idx, state in enumerate(states):
|
||||
prefix_len = 2 + idx * 2
|
||||
state.latents = torch.full((1, 1), float(idx))
|
||||
state.extra[_STEP_CFG_FACTOR] = 2
|
||||
state.extra[_STEP_GUIDANCE_SCALE] = 1.0
|
||||
state.extra[_STEP_INPUT_IDS] = None
|
||||
state.extra[_STEP_MODEL_KWARGS].update(
|
||||
{
|
||||
"attention_mask": torch.ones(2, 1, 2, prefix_len + 2, dtype=torch.bool),
|
||||
"full_attn_spans": [[(prefix_len, prefix_len + 2)], [(prefix_len, prefix_len + 2)]],
|
||||
}
|
||||
)
|
||||
state.extra[_STEP_PROMPT_KV] = [
|
||||
{
|
||||
"key": torch.zeros(2, prefix_len, 1, 1),
|
||||
"value": torch.zeros(2, prefix_len, 1, 1),
|
||||
"lens": torch.tensor([prefix_len, prefix_len]),
|
||||
}
|
||||
]
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_restore_prompt_kv_cache(states_arg, row_state_indexes, row_branches):
|
||||
del states_arg
|
||||
captured["row_state_indexes"] = list(row_state_indexes)
|
||||
captured["row_branches"] = list(row_branches)
|
||||
|
||||
def fake_prepare_inputs_for_generation(input_ids, images, timestep, **model_kwargs):
|
||||
captured["input_ids"] = input_ids
|
||||
captured["images"] = images.clone()
|
||||
captured["timestep"] = timestep.clone()
|
||||
captured["merged_attention_mask_shape"] = tuple(model_kwargs["attention_mask"].shape)
|
||||
captured["merged_full_attn_spans"] = model_kwargs["full_attn_spans"]
|
||||
return {"model_kwargs": model_kwargs}
|
||||
|
||||
pipeline._restore_prompt_kv_cache = fake_restore_prompt_kv_cache
|
||||
pipeline.prepare_inputs_for_generation = fake_prepare_inputs_for_generation
|
||||
pipeline.forward_call = lambda **kwargs: {"diffusion_prediction": torch.tensor([[10.0], [20.0], [1.0], [2.0]])}
|
||||
pipeline._update_model_kwargs_for_generation = lambda model_output, model_kwargs: model_kwargs
|
||||
pipeline._pipeline = SimpleNamespace(cfg_operator=lambda cond, uncond, scale, step: cond + uncond)
|
||||
|
||||
batch = InputBatch.make_batch(states)
|
||||
out = pipeline.denoise_step(batch)
|
||||
|
||||
assert captured["row_state_indexes"] == [0, 1, 0, 1]
|
||||
assert captured["row_branches"] == [0, 0, 1, 1]
|
||||
assert captured["input_ids"] is None
|
||||
assert tuple(captured["images"].shape) == (4, 1)
|
||||
assert captured["timestep"].tolist() == [0.5, 0.0, 0.5, 0.0]
|
||||
assert captured["merged_attention_mask_shape"] == (4, 1, 2, 6)
|
||||
assert captured["merged_full_attn_spans"] == [[(4, 6)], [(4, 6)], [(4, 6)], [(4, 6)]]
|
||||
torch.testing.assert_close(out, torch.tensor([[11.0], [22.0]]))
|
||||
assert states[0].extra[_STEP_MODEL_KWARGS]["attention_mask"].shape == (2, 1, 2, 4)
|
||||
assert states[1].extra[_STEP_MODEL_KWARGS]["attention_mask"].shape == (2, 1, 2, 6)
|
||||
assert states[0].extra[_STEP_MODEL_KWARGS]["full_attn_spans"] == [[(2, 4)], [(2, 4)]]
|
||||
assert states[1].extra[_STEP_MODEL_KWARGS]["full_attn_spans"] == [[(4, 6)], [(4, 6)]]
|
||||
@@ -692,16 +692,27 @@ class TestRegistryIntegration:
|
||||
assert name in _DIFFUSION_POST_PROCESS_FUNCS, f"{name} not in _DIFFUSION_POST_PROCESS_FUNCS"
|
||||
assert _DIFFUSION_POST_PROCESS_FUNCS[name] == "get_ltx2_post_process_func"
|
||||
|
||||
def test_cache_dit_enablers_registered(self):
|
||||
"""Pipeline variants must be registered in CUSTOM_DIT_ENABLERS."""
|
||||
def test_cache_dit_for_ltx2_does_not_have_custom_enablers_registered(self):
|
||||
"""Pipeline variants are *not* registered in CUSTOM_DIT_ENABLERS."""
|
||||
from vllm_omni.diffusion.cache.cache_dit_backend import CUSTOM_DIT_ENABLERS
|
||||
|
||||
# NOTE: We used to have custom enablers for this model, but refactored to handle
|
||||
# it more generically. Now we only need to ensure it has git cache adapter config.
|
||||
expected = [
|
||||
"LTX23Pipeline",
|
||||
"LTX23ImageToVideoPipeline",
|
||||
]
|
||||
for name in expected:
|
||||
assert name in CUSTOM_DIT_ENABLERS, f"{name} not in CUSTOM_DIT_ENABLERS"
|
||||
assert name not in CUSTOM_DIT_ENABLERS, f"{name} not in CUSTOM_DIT_ENABLERS"
|
||||
|
||||
def test_ltx2_transformer_has_dit_cache_config(self):
|
||||
"""Ensure LTX2 has a Cache DiT adapter config and that it uses separate CFG."""
|
||||
from vllm_omni.diffusion.cache.cache_dit_backend import CacheDiTAdapterConfig
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_transformer import LTX2VideoTransformer3DModel
|
||||
|
||||
adapter_config = getattr(LTX2VideoTransformer3DModel, "_cache_dit_adapter_config")
|
||||
assert isinstance(adapter_config, CacheDiTAdapterConfig)
|
||||
assert adapter_config.has_separate_cfg
|
||||
|
||||
|
||||
class TestVocoderSampleRateDetection:
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Regression tests for #4443: padded prompt width vs RoPE table length.
|
||||
|
||||
When ``max(txt_seq_lens) < prompt_embeds.shape[1]``, ``QwenEmbedRope`` builds a
|
||||
text frequency table that is too short for the padded encoder width.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.qwen_image.qwen_image_transformer import QwenEmbedRope
|
||||
from vllm_omni.diffusion.models.qwen_image.rope_utils import txt_seq_lens_from_embeds
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
# Representative request-batch padding: valid tokens 10/20, shared width 32.
|
||||
PADDED_WIDTH = 32
|
||||
VALID_LENS = [10, 20]
|
||||
|
||||
|
||||
def test_pre_pr_txt_seq_lens_is_shorter_than_padded_width():
|
||||
mask = torch.zeros(len(VALID_LENS), PADDED_WIDTH, dtype=torch.bool)
|
||||
mask[0, : VALID_LENS[0]] = True
|
||||
mask[1, : VALID_LENS[1]] = True
|
||||
|
||||
pre_pr_txt_seq_lens = mask.sum(dim=1).tolist()
|
||||
assert pre_pr_txt_seq_lens == VALID_LENS
|
||||
assert max(pre_pr_txt_seq_lens) < PADDED_WIDTH
|
||||
|
||||
|
||||
def test_post_pr_txt_seq_lens_matches_padded_width():
|
||||
prompt_embeds = torch.zeros(len(VALID_LENS), PADDED_WIDTH, 8)
|
||||
assert txt_seq_lens_from_embeds(prompt_embeds) == [PADDED_WIDTH, PADDED_WIDTH]
|
||||
|
||||
|
||||
def test_rope_table_length_matches_padded_width_after_fix():
|
||||
rope = QwenEmbedRope(theta=10000, axes_dim=[16, 56, 56], scale_rope=True)
|
||||
img_shapes = [[(1, 16, 16)]]
|
||||
|
||||
_, freqs_pre_pr = rope(img_shapes, VALID_LENS, device="cpu")
|
||||
_, freqs_post_pr = rope(img_shapes, [PADDED_WIDTH, PADDED_WIDTH], device="cpu")
|
||||
|
||||
assert freqs_pre_pr.shape[0] == max(VALID_LENS)
|
||||
assert freqs_pre_pr.shape[0] < PADDED_WIDTH
|
||||
assert freqs_post_pr.shape[0] == PADDED_WIDTH
|
||||
Loaded 100 of 439 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user