Compare commits

...
Author SHA1 Message Date
Hongsheng Liu 7b2f3682f7 [CI/Build] Clarify model-specific example filename
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-11 16:36:52 +08:00
Hongsheng Liu 8ef0d3f391 [CI/Build] Reuse examples policy in review-pr skill
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-11 16:31:36 +08:00
Hongsheng Liu 49985ec849 [CI/Build] Add examples policy to precheck-pr skill
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-11 16:23:31 +08:00
4ab1b4ff9c [Perf][Flux2][HunyuanVideo1.5] Skip attention-mask to avoid varlen path (#4645)
Signed-off-by: kTorp <Kristoffer.Torp@amd.com>
Signed-off-by: Didan Deng <33117903+wtomin@users.noreply.github.com>
Co-authored-by: Didan Deng <33117903+wtomin@users.noreply.github.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-11 15:31:02 +08:00
06e733d3cd [Model] Add LongCat-Video-Avatar-1.5 ai2v, at2v support (#4099)
Signed-off-by: weiyan <weiyanlin117@gmail.com>
Signed-off-by: Didan Deng <33117903+wtomin@users.noreply.github.com>
Co-authored-by: Didan Deng <33117903+wtomin@users.noreply.github.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-11 15:17:17 +08:00
Wang fuyinandHongsheng Liu af3faa6c1e [Diffusion] Add Worker contracts and RPC plumbing for Scheduler-managed paged KV cache (#5550)
Signed-off-by: Acerak01-fy <wfy2003324@163.com>
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-11 14:55:42 +08:00
322600bfb2 [CI][MiniCPM-o] Add MiniCPM-o 4.5 accuracy and performance coverage (#5524)
Signed-off-by: R2-Y <ruiruyang2@gmail.com>
Signed-off-by: psv666 <2693925048@qq.com>
Co-authored-by: psv666 <2693925048@qq.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-08-11 14:53:49 +08:00
Hongsheng Liu c4a14091c9 [Doc] Explain repository skills for agentic contributions (#6029)
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-11 14:18:41 +08:00
yiminghub2024 6d4e4ffc7c Add MiniMax-H3 recipe for RTX 4090 setup (#5850)
Signed-off-by: yiminghub2024 <482890@qq.com>
2026-08-11 14:08:51 +08:00
Zhou248 f97af75d1d [Model] Batch MiniCPM-o Talker codec sampling (#5792)
Signed-off-by: Zhou248 <62542847+Zhou248@users.noreply.github.com>
2026-08-11 14:05:02 +08:00
Hongsheng Liu 73a312d5b6 [Bugfix] Normalize NumPy image outputs before saving (#6031)
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-11 13:48:03 +08:00
Zijun HuangandWeiQing Chen 1a56953d7a [NPU][Model] Add distilled 4-step sigma schedule support for MiniMax H3 t2va (#5991)
Signed-off-by: Zijun Huang <zijunhua@usc.edu>
Co-authored-by: WeiQing Chen <40507679+david6666666@users.noreply.github.com>
2026-08-11 13:46:10 +08:00
WeiQing Chen 1f65814fab [CI] Align MiniMax H3 Ref2VA inputs and add I2VA/Ref2VA accuracy coverage (#5978)
Signed-off-by: david6666666 <530634352@qq.com>
2026-08-11 13:36:37 +08:00
boatmanandYueqian Lin 4f9b65074d [Perf][Qwen3-TTS]Cached Incremental Decode (#5202)
Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
2026-08-11 05:24:04 +00:00
Juan Pablo ZuluagaandYueqian Lin 12a5f6fbf3 [Perf][TTS] step_audio2: keep streaming tokens on-device and batch audio-feature length syncs (#5067)
Signed-off-by: JuanPZuluaga <juanz9312@gmail.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
2026-08-10 21:24:25 -07:00
Juan Pablo ZuluagaandYueqian Lin 53afbf3a0c [Perf][TTS] voxtral_tts hot path: cudagraph opt-out, host-sync removal, kernel caches (#5175)
Signed-off-by: JuanPZuluaga <juanz9312@gmail.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
2026-08-10 21:03:32 -07:00
ee118ab492 [Bugfix] Per-replica fault isolation: keep API server alive on single-stage death (#4285) (#4583)
Signed-off-by: Shenglei Fu <sfu@confluent.io>
Signed-off-by: Shenglei Fu <117230642+ShengleiFu@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
2026-08-10 23:00:18 -04:00
fan2956andFan c27623c29c [Bugfix] rainfusion support all shape video (#6000)
Signed-off-by: Fan <fan@FandeMacBook-Air.local>
Co-authored-by: Fan <fan@FandeMacBook-Air.local>
2026-08-11 05:10:50 +08:00
Yueqian Lin f8fa86fb1f [BugFix] Don't fail the TTS ratchet when the branch count goes down (#6008)
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
2026-08-11 05:08:31 +08:00
ooooooyeandHongsheng Liu 422d8fd8f9 [Bugfix][Diffusion] Restore supports_packed_mask_free on teacache FakeBackend (#5997)
Signed-off-by: brandneway <gyuan4892@gmail.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-10 22:20:37 +08:00
Hongsheng Liu 62e239bf1e [Docs] Fix broken attention backend link (#5998)
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-10 21:55:01 +08:00
140 changed files with 13842 additions and 1678 deletions

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@
# NPU naming: {queue}_{resource_class}
# a2b3_npu_1 — ascend-a2b3, resource_class npu-1 (A2/B3 CI image)
# a2b3_npu_4 — ascend-a2b3, resource_class npu-4
# a3_npu_2 — ascend-a3, resource_class npu-2 (A3 CI image tag …:$BUILDKITE_COMMIT-a3)
# a3_npu_2 — ascend-a3, resource_class npu-2
x-npu-k8s-plugins: &npu_k8s_plugins
- kubernetes:
+4 -4
View File
@@ -139,7 +139,7 @@ steps:
timeout_in_minutes: 50
commands:
- pytest -s -v tests/e2e/offline_inference/test_minicpmo_4_5.py tests/e2e/online_serving/test_minicpmo_4_5.py -m 'advanced_model and cuda' --run-level 'advanced_model'
mirror_hardwares: h100_2
mirror_hardwares: h100_1
- label: "Omni · MiniCPM-o 4.5 Duplex Test"
source_file_dependencies:
@@ -148,10 +148,10 @@ steps:
- tests/e2e/online_serving/test_minicpmo_4_5_duplex.py
- tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py
- tests/e2e/online_serving/test_minicpmo_realtime_duplex_drivers.py
- tests/e2e/online_serving/minicpmo_realtime_duplex_scenarios.py
- tests/e2e/online_serving/helpers/minicpmo_realtime_duplex_scenarios.py
- tests/e2e/online_serving/run_minicpmo_realtime_duplex_multi_session.py
- tests/e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py
- tests/helpers/minicpmo_4_5_duplex.py
- tests/e2e/online_serving/helpers/minicpmo_4_5_duplex.py
- vllm_omni/deploy/minicpmo_4_5_duplex.yaml
- vllm_omni/experimental/fullduplex/
- vllm_omni/model_executor/models/minicpmo_4_5/
@@ -164,7 +164,7 @@ steps:
timeout_in_minutes: 50
commands:
- pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5_duplex.py -m 'advanced_model and cuda' --run-level 'advanced_model'
mirror_hardwares: h100_2
mirror_hardwares: h100_1
- label: "TTS · VoxCPM2 Test"
source_file_dependencies:
+42 -3
View File
@@ -22,7 +22,7 @@ steps:
timeout_in_minutes: 50
commands:
- pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py -m "full_model and cuda and H100 and omni" --run-level "full_model"
mirror_hardwares: h100_2
mirror_hardwares: h100_1
- label: ":full_moon: Omni · Doc Test with L4"
timeout_in_minutes: 90
@@ -50,6 +50,19 @@ steps:
exit $$EXIT
mirror_hardwares: h100_2
- label: ":full_moon: Omni · MiniCPM-o 4.5 · Accuracy Test"
timeout_in_minutes: 180
commands:
- export SEED_TTS_WER_EVAL=1
- export SEED_TTS_EVAL_DEVICE=cuda:1
- |
set +e
pytest -s -v tests/e2e/accuracy/minicpmo_4_5/test_minicpmo_4_5.py -m "full_model and cuda" --run-level full_model
EXIT=$$?
buildkite-agent artifact upload "tests/e2e/accuracy/minicpmo_4_5/results/*.json"
exit $$EXIT
mirror_hardwares: h100_2
- label: ":full_moon: Omni · Perf Test · No Async Chunk"
key: nightly-omni-performance-no-async-chunk
timeout_in_minutes: 300
@@ -102,6 +115,32 @@ steps:
exit $$EXIT
mirror_hardwares: h100_3
- label: ":full_moon: Omni · MiniCPM-o 4.5 · Perf Test"
key: nightly-omni-performance-minicpmo-4-5
timeout_in_minutes: 180
commands:
- export BENCHMARK_DIR=tests/dfx/perf/results
- |
set +e
pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_minicpmo_4_5.json
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
exit $$EXIT
mirror_hardwares: h100_1
- label: ":full_moon: Omni · MiniCPM-o 4.5 · Duplex Seed-TTS Perf Test"
key: nightly-omni-performance-minicpmo-4-5-duplex-seed-tts
timeout_in_minutes: 180
commands:
- export BENCHMARK_DIR=tests/dfx/perf/results
- |
set +e
pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_minicpmo_4_5_duplex_seed_tts.json
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
exit $$EXIT
mirror_hardwares: h100_1
- label: ":full_moon: Omni · Multi-Replica Startup Test with 4x H100"
timeout_in_minutes: 45
commands:
@@ -427,10 +466,10 @@ steps:
- pytest -s -v tests/e2e/accuracy/test_diffusers_backend_similarity.py -k '2v_matches_diffusers' -m full_model --run-level full_model
mirror_hardwares: h100_2
- label: ":full_moon: Diffusion X2V · MiniMax H3 T2VA Accuracy Test"
- label: ":full_moon: Diffusion X2V · MiniMax H3 I2VA/Ref2VA Accuracy Test"
timeout_in_minutes: 180
commands:
- pytest -s -v tests/e2e/accuracy/minimax_h3/test_minimax_h3_t2va_similarity.py -m full_model --run-level full_model
- pytest -s -v tests/e2e/accuracy/minimax_h3/test_minimax_h3_i2va_ref2va_similarity.py -m full_model --run-level full_model
mirror_hardwares: h100_4
- label: ":full_moon: Diffusion X2V · Other Accuracy Test"
+5 -5
View File
@@ -172,7 +172,7 @@ steps:
timeout 20m bash -c "
pytest -s -v tests/e2e/offline_inference/test_minicpmo_4_5.py -m 'core_model' --run-level 'core_model'
"
mirror_hardwares: h100_2
mirror_hardwares: h100_1
- label: "Omni · MiniCPM-o 4.5 Online Test"
source_file_dependencies:
@@ -183,7 +183,7 @@ steps:
timeout 20m bash -c "
pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5.py -m 'core_model' --run-level 'core_model'
"
mirror_hardwares: h100_2
mirror_hardwares: h100_1
- label: "Omni · MiniCPM-o 4.5 Duplex Test"
source_file_dependencies:
@@ -192,10 +192,10 @@ steps:
- tests/e2e/online_serving/test_minicpmo_4_5_duplex.py
- tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py
- tests/e2e/online_serving/test_minicpmo_realtime_duplex_drivers.py
- tests/e2e/online_serving/minicpmo_realtime_duplex_scenarios.py
- tests/e2e/online_serving/helpers/minicpmo_realtime_duplex_scenarios.py
- tests/e2e/online_serving/run_minicpmo_realtime_duplex_multi_session.py
- tests/e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py
- tests/helpers/minicpmo_4_5_duplex.py
- tests/e2e/online_serving/helpers/minicpmo_4_5_duplex.py
- vllm_omni/deploy/minicpmo_4_5_duplex.yaml
- vllm_omni/experimental/fullduplex/
- vllm_omni/model_executor/models/minicpmo_4_5/
@@ -210,7 +210,7 @@ steps:
timeout 20m bash -c "
pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5_duplex.py -m 'core_model and cuda' --run-level 'core_model'
"
mirror_hardwares: h100_2
mirror_hardwares: h100_1
- label: "TTS · VoxCPM2 Test"
source_file_dependencies:
+29 -1
View File
@@ -39,7 +39,7 @@ steps:
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
exit $$EXIT
- label: ":robot: Omni · MiniCPM-o 4.5 Test"
- label: ":full_moon: Omni · MiniCPM-o 4.5 Test"
mirror_hardwares: a3_npu_2
env:
VLLM_WORKER_MULTIPROC_METHOD: spawn
@@ -47,6 +47,34 @@ steps:
HF_TOKEN: "${HF_TOKEN}"
commands:
- pytest -s -v tests/e2e/offline_inference/test_minicpmo_4_5.py tests/e2e/online_serving/test_minicpmo_4_5.py tests/e2e/online_serving/test_minicpmo_4_5_expansion.py -m 'full_model' --run-level 'full_model'
- label: ":full_moon: Omni · MiniCPM-o 4.5 · Duplex Test"
timeout_in_minutes: 180
mirror_hardwares: a3_npu_2
env:
VLLM_WORKER_MULTIPROC_METHOD: spawn
VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1"
HF_TOKEN: "${HF_TOKEN}"
commands:
- >-
pytest -s -v
tests/e2e/online_serving/test_minicpmo_4_5_duplex.py
tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py
-m "npu and A3 and omni"
--run-level full_model
- label: ":full_moon: Omni · MiniCPM-o 4.5 · Accuracy Test"
timeout_in_minutes: 180
mirror_hardwares: a3_npu_2
env:
VLLM_WORKER_MULTIPROC_METHOD: spawn
VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1"
HF_TOKEN: "${HF_TOKEN}"
commands:
- |
set +e
pytest -s -v tests/e2e/accuracy/minicpmo_4_5/test_minicpmo_4_5.py -m "full_model and npu and A3" --run-level full_model
EXIT=$$?
buildkite-agent artifact upload "tests/e2e/accuracy/minicpmo_4_5/results/*.json"
exit $$EXIT
- group: ":card_index_dividers: TTS Model Test"
key: nightly-tts-test-group
depends_on: upload-nightly-pipeline
+10 -12
View File
@@ -3,22 +3,20 @@ steps:
key: ready-omni-group
depends_on: upload-ready-pipeline
steps:
- label: ":robot: Omni · MiniCPM-o 4.5 Test"
agents:
queue: "ascend-a3"
resource_class: "npu-2"
image: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/vllm-omni-ci-npu:${BUILDKITE_COMMIT}-a3"
plugins:
- kubernetes:
podSpecPatch:
imagePullSecrets:
- name: swr-secret
- label: "Omni · MiniCPM-o 4.5 Test"
mirror_hardwares: a3_npu_2
env:
VLLM_WORKER_MULTIPROC_METHOD: spawn
VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1"
HF_TOKEN: "${HF_TOKEN}"
commands:
- pytest -s -v tests/e2e/offline_inference/test_minicpmo_4_5.py tests/e2e/online_serving/test_minicpmo_4_5.py -m 'core_model' --run-level 'core_model'
- pytest -s -v tests/e2e/offline_inference/test_minicpmo_4_5.py tests/e2e/online_serving/test_minicpmo_4_5.py -m 'core_model and npu and A3 and omni' --run-level 'core_model'
- label: "Omni · MiniCPM-o 4.5 Duplex Test"
mirror_hardwares: a3_npu_2
env:
VLLM_WORKER_MULTIPROC_METHOD: spawn
VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1"
commands:
- pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5_duplex.py -m 'core_model and npu and A3 and omni' --run-level 'core_model'
- group: ":card_index_dividers: TTS Model Test"
key: ready-tts-group
+4 -1
View File
@@ -1,6 +1,6 @@
---
name: precheck-pr
description: Self-check your branch before creating a PR — catch dead code, verify accuracy/perf claims, validate PR title format, and confirm merge readiness. Use when the user says "precheck", "self review", "pre-submit check", or "check my PR before I open it." Never posts to GitHub.
description: Self-check your branch before creating a PR — catch dead code, prevent new model-specific Python examples, verify accuracy/perf claims, validate PR title format, and confirm merge readiness. Use when the user says "precheck", "self review", "pre-submit check", or "check my PR before I open it." Never posts to GitHub.
---
# PR Pre-Check
@@ -65,6 +65,8 @@ Ask: "Quick mode or full mode?" Then walk the checklist for the detected PR type
**Also run the Code-Quality sweep on every PR**, regardless of type or mode: the five diff-scoped checks in [references/code-quality.md](references/code-quality.md) — kwargs fragility, broad-except swallow, `Any`/wrong type hints, hot-path `.clone()`/`deepcopy`, and event-loop blocking — plus the advisory conventions (log level, structured logging, synchronization, cleanup, dependencies, naming) in [checklists.md](references/checklists.md). These count only lines the PR *adds* — the pre-existing backlog across the repo is out of scope.
**Also run the Examples-Policy check on every PR** using [references/examples-policy.md](references/examples-policy.md). Inspect only Python paths introduced by the diff. Treat a new model-, checkpoint-, vendor-, or family-specific Python example as blocking; do not report pre-existing example debt.
### Step 5: Print Report
```
@@ -77,6 +79,7 @@ Pre-check report for <branch>
───────────────── ──────
PR title format ✓
Code quality ⚠ 1 broad except, 2 Any hints
Examples policy ✗ new model-specific Python example
PR desc integrity ✓
Registry/config ✓
Dead code ⚠ 2 warnings
@@ -47,6 +47,19 @@ Diff-scoped sweep for five fragility patterns that are pervasive in the existing
Roll the ⚠/✗ counts into the report as a single **Code quality** dimension row.
---
## All PRs: Examples Policy (run on every PR, quick & full)
Inspect Python files introduced under `examples/` using
[examples-policy.md](examples-policy.md). This is a diff-scoped ratchet: existing
model-specific examples may be edited or removed without triggering the check.
- [ ] **No new model-specific Python examples:** the diff adds, copies, or renames no Python file whose path or behavior is specific to one model, checkpoint, vendor, or model family. ✗ for any violation. Route inference through a shared task/protocol entrypoint, model request behavior through `vllm_omni/model_extras`, and commands through task docs or `recipes/`.
- [ ] **New generic example code is justified:** any new task-neutral Python file under `examples/` is an actual user-facing entrypoint rather than reusable production logic, a benchmark, an app, a setup tool, or a reproducer. ⚠ when the placement is questionable.
Roll the result into the report as a separate **Examples policy** dimension.
### Conventions (eyeball — no grep)
Judgment calls that don't grep cleanly — apply them while reading the diff, not as a mechanical check:
@@ -0,0 +1,68 @@
# Examples Python Policy
Prevent new model-specific Python examples without forcing cleanup of the
existing backlog. Run this check on every PR in quick and full modes.
## Scope
Set the merge base as in the main workflow, then list Python paths introduced
by the PR, including additions, copies, and renames:
```bash
BASE="$(git merge-base HEAD origin/main)"
git diff --name-status --diff-filter=ACR --find-renames --find-copies \
"${BASE}"...HEAD -- 'examples/**/*.py'
```
Inspect destination paths for copies and renames. Existing model-specific files
that are only modified or deleted are grandfathered and must not be reported.
## Blocking classification
Mark ✗ when a newly introduced Python file does any of the following:
- Names or scopes a path to one model, checkpoint, vendor, or model family.
- Hard-codes a model identifier, model-specific prompt, request shape, output
conversion, or launch configuration behind an otherwise generic filename.
- Duplicates an existing task/protocol runner to demonstrate one model.
- Adds a per-model helper below a generic task hub such as `text_to_speech/`.
Do not infer safety from a generic filename alone. For example, a file named
`text_to_audio.py` is still model-specific if it only implements one model's
contract.
## Acceptable additions
A new Python example may pass when it is a genuinely model-neutral,
user-facing task or protocol entrypoint. It must accept model selection through
configuration and keep model-specific behavior out of the script.
Use ⚠ when the file is model-neutral but reusable logic appears misplaced under
`examples/`. Route code by purpose:
| Code purpose | Preferred location |
|---|---|
| Model prompt, defaults, request/output adaptation | `vllm_omni/model_extras/` or production model modules |
| Runnable model command and validation evidence | Task documentation or `recipes/` |
| UI or interactive application | `apps/` |
| Benchmark or evaluator | `benchmarks/` |
| Regression reproducer | `tests/` |
| Download, conversion, or setup utility | `tools/` |
If a model cannot use an existing shared runner, report the missing generic
capability. Do not solve the gap by adding a model-specific Python example.
## Report format
Add one row to the pre-check report:
```text
Examples policy ✓ no new Python example paths
Examples policy ⚠ generic helper belongs in tools/
Examples policy ✗ examples/offline_inference/new_model/end2end.py is model-specific
Examples policy ✗ examples/offline_inference/x_to_y_model_name.py is model-specific
```
For a blocker, name the path, the model-specific behavior, and the appropriate
shared runner or destination. Do not propose deleting unrelated existing
examples as part of the PR.
+38 -28
View File
@@ -1,9 +1,9 @@
# Claude Skills for vLLM-Omni
# Repository Skills for vLLM-Omni
This directory contains Claude Code skills maintained for the `vllm-omni`
repository. These skills capture repeatable workflows for common contributor
tasks such as model integration, pull request review, and release note
generation.
This directory contains repository-scale skills maintained for `vllm-omni`.
They capture repeatable workflows for common contributor and maintainer tasks
such as model integration, CI-aligned testing, performance optimization, and
pull request review.
## Directory Structure
@@ -14,31 +14,41 @@ include:
- `references/`: focused reference material used by the skill
- `scripts/`: small helper scripts used by the skill
## Using the Skills
Coding agents that discover repository skills can invoke the relevant skill by
name. For other agents, point them to the skill's `SKILL.md` and ask them to
read it before changing code. Combine a domain skill with `vllm-omni-test` when
tests or CI coverage change, then use `precheck-pr` for the contributor's final
self-check.
## Available Skills
- `add-diffusion-model`: guides integration of a new diffusion model into
`vllm-omni`
- `diffusion-perf-opt`: guides diffusion model performance optimization,
including profiling traces, parallel strategies, stage timing analysis, and
benchmark-driven tuning
- `quantization`: guides quantization method selection, model integration,
checkpoint loading, and quality/performance validation for vLLM-Omni
- `add-omni-model`: covers addition of new omni-modality model support
- `add-tts-model`: covers integration of new TTS models and related serving
workflows
- `generate-release-note`: helps prepare release notes for repository changes
- `precheck-pr`: self-check a branch before creating a PR — validates PR title
format, catches dead code, verifies accuracy/perf claims, and confirms merge
readiness
- `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
- [`add-diffusion-model`](add-diffusion-model/SKILL.md): guides integration of
a new diffusion model into `vllm-omni`
- [`add-tts-model`](add-tts-model/SKILL.md): covers integration of new TTS
models and related serving workflows
- [`diffusion-perf-opt`](diffusion-perf-opt/SKILL.md): guides diffusion model
performance optimization, including profiling traces, parallel strategies,
stage timing analysis, and benchmark-driven tuning
- [`precheck-pr`](precheck-pr/SKILL.md): self-checks a branch before creating a
PR by validating title format, dead code, accuracy and performance claims,
and merge readiness
- [`quantization`](quantization/SKILL.md): guides quantization method selection,
model integration, checkpoint loading, and quality/performance validation
for vLLM-Omni
- [`review-pr`](review-pr/SKILL.md): provides a frozen-snapshot, contract-aware
workflow for maintainers and reviewers; contributors should use
`precheck-pr` for self-review
- [`vllm-omni-npu-model-runner-upgrade`](vllm-omni-npu-upgrade/SKILL.md):
upgrades NPU model runners to align with the latest vllm-ascend
`NPUModelRunner`
- [`vllm-omni-test`](vllm-omni-test/SKILL.md): 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
## Maintenance Guidelines
+8
View File
@@ -97,6 +97,7 @@ If docs and live code disagree, verify the code/tests and report the drift.
| [test-quality-evaluation.md](references/checks/test-quality-evaluation.md) | Tests change, are absent for risky code, or may not exercise production behavior. |
| [tests-docs-checklist.md](references/checks/tests-docs-checklist.md) | Coverage, CI markers, examples, user docs, or PR evidence need review. |
| [verification.md](references/checks/verification.md) | Hardware, a server, or a runnable affected path is available for active verification. |
| [examples-policy.md](../precheck-pr/references/examples-policy.md) | The PR adds, copies, or renames Python under `examples/`; apply the canonical policy shared with `precheck-pr`. |
### Delivery and reviewer coordination
@@ -152,6 +153,13 @@ contract they protect or use only the applicable evidence checks.
Apply every category in [general-checks.md](references/process/general-checks.md) before
lower-priority comments.
If the diff census contains an added, copied, or renamed Python path under
`examples/`, read and apply the canonical
[examples policy](../precheck-pr/references/examples-policy.md). Treat a new
model-specific Python example as blocking. Do not flag model-specific example
debt that the PR only modifies or removes, and do not run the rest of the
author-oriented `precheck-pr` workflow.
For each changed value or behavior, trace:
```text
+4 -1
View File
@@ -66,7 +66,10 @@ repos:
entry: python tools/pre_commit/check_tts_adapter.py
language: python
pass_filenames: false
files: ^vllm_omni/entrypoints/openai/(serving_speech\.py|tts_adapters/__init__\.py)$
files: ^(vllm_omni/entrypoints/openai/(serving_speech\.py|tts_adapters/__init__\.py)|tools/pre_commit/check_tts_adapter\.py)$
# The budget-is-now-slack reminder is printed on a PASSING run, and
# pre-commit hides output from passing hooks unless the hook is verbose.
verbose: true
# Keep `suggestion` last
- id: suggestion
+1
View File
@@ -45,6 +45,7 @@ RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \
"hyperpyyaml>=1.2.0" \
"onnx>=1.16.0" \
"librosa>=0.10.0" \
"datasets>=3.0.0" \
--index-url http://cache-service.nginx-pypi-cache.svc.cluster.local/pypi/simple \
--trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \
--default-timeout=60 && \
+1
View File
@@ -46,6 +46,7 @@ RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \
"onnx>=1.16.0" \
"librosa>=0.10.0" \
"torchmetrics>=1.9.0" \
"datasets>=3.0.0" \
--index-url http://cache-service.nginx-pypi-cache.svc.cluster.local/pypi/simple \
--trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \
--default-timeout=60 && \
+41
View File
@@ -76,6 +76,47 @@ Please refer to the [test instructions](./ci/test_execution_guide.md) for detail
!!! warning
Currently, not all unit tests pass when run on CPU platforms. If you don't have access to a GPU platform to run unit tests locally, rely on the continuous integration system to run the tests for now.
### Using repository skills with coding agents
vLLM-Omni maintains [repository-scale skills](https://github.com/vllm-project/vllm-omni/tree/main/.claude/skills) for common coding, testing, performance, and review workflows. These skills capture repository-specific structure, validation requirements, and evidence standards that a general coding agent may not know.
If your coding agent discovers repository skills automatically, ask it to use the relevant skill by name. Otherwise, point it to the linked `SKILL.md` and ask it to read the file before changing code. Skills can be combined: use a domain skill for the implementation, `vllm-omni-test` for test and CI decisions, and `precheck-pr` before opening the pull request.
| Task | Repository skill |
| --- | --- |
| Add or extend a diffusion model | [`add-diffusion-model`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/add-diffusion-model/SKILL.md) |
| Add or extend a text-to-speech model | [`add-tts-model`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/add-tts-model/SKILL.md) |
| Add, select, or debug quantization | [`quantization`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/quantization/SKILL.md) |
| Diagnose or optimize diffusion performance | [`diffusion-perf-opt`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/diffusion-perf-opt/SKILL.md) |
| Upgrade an NPU model runner | [`vllm-omni-npu-model-runner-upgrade`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/vllm-omni-npu-upgrade/SKILL.md) |
| Add a regression test, choose L1-L4 coverage, or wire Buildkite | [`vllm-omni-test`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/vllm-omni-test/SKILL.md) |
| Self-check a branch before opening a pull request | [`precheck-pr`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/precheck-pr/SKILL.md) |
| Perform a maintainer or reviewer review of an existing pull request or local branch | [`review-pr`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/review-pr/SKILL.md) |
Use the following workflow when collaborating with a coding agent:
1. **Ground the task.** Provide the issue, RFC, or design document that defines the expected behavior. Ask the agent to state its assumptions and identify the affected module or feature contracts before editing code.
2. **Select the narrowest applicable skills.** Start with the domain skill that owns the change. Add `vllm-omni-test` whenever production behavior or tests change. For a bug fix, ask for the smallest reproducer and a regression test tied to the original symptom.
3. **Require executable evidence.** Ask the agent to run the narrowest relevant checks and report the exact commands and results. It must identify tests it could not run because of unavailable hardware, model weights, credentials, or dependencies; an unrun check is not a pass.
4. **Inspect the resulting diff.** Confirm that the implementation stays within the issue or RFC, does not overwrite unrelated work, and includes the required tests, documentation, and benchmark or accuracy evidence.
5. **Run the pre-submit workflow.** Use `precheck-pr`, then run the applicable local tests and pre-commit hooks before opening the pull request.
For example:
```text
Use add-diffusion-model and vllm-omni-test to implement the linked issue.
Read the relevant design documents first, keep the change within the issue,
and report every test command, result, and hardware validation gap.
```
```text
Use vllm-omni-test to turn this bug reproducer into the smallest deterministic
regression test. Prefer L1 CPU coverage; if the failure requires real weights
or serving, explain the required L2/L3 environment and provide the exact command.
```
Repository skills guide the agent, but they do not replace contributor judgment, the accepted issue or RFC, required test evidence, or maintainer review. Review all generated changes before submitting them.
## Issues
If you encounter a bug or have a feature request, please search existing issues first to see if it has already been reported. If not, please file a new issue, providing as much relevant information as possible.
+1
View File
@@ -51,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` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `LongCatVideoAvatarPipeline` | LongCat-Video-Avatar-1.5 A2V/AI2V (native single-speaker and multi-speaker AI2V/AVC) | `meituan-longcat/LongCat-Video-Avatar-1.5` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/meituan-longcat/LongCat-Video-Avatar-1.5.md) |
| `BooguImagePipeline` | Boogu-Image | `Boogu/Boogu-Image-0.1-Base`, `Boogu/Boogu-Image-0.1-Edit` | ✅︎ | | | | — |
| `StableDiffusionXLPipeline` | Stable-Diffusion-XL | `stabilityai/stable-diffusion-xl-base-1.0` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `StableDiffusion3Pipeline` | Stable-Diffusion-3 | `stabilityai/stable-diffusion-3.5-medium` | ✅︎ | ✅︎ | | ✅︎ | — |
+12 -15
View File
@@ -283,23 +283,20 @@ blocks — use Ulysses SP (`ring_degree=1`).
### Which geometries run sparse
Sparsity is only applied when the video segment is a **multiple of 128 rows**, where the row count
is `latent_t × (height / 32) × (width / 32)`. Otherwise `rf_v2`'s block mask and the kernel's own
tiling disagree on the block count, and the block straddling the seam mixes video and prefix rows
that selection may then drop. Any resolution still runs — an unaligned geometry falls back to dense
attention and logs `RAINFUSION_ATTN staying dense` with the row count it computed — but it gets no
speedup, so pick an aligned geometry when you want one.
MindIE-SD rf_v2 handles arbitrary video grids by spatially rearranging video first and, when
necessary, promoting a real-video suffix to its always-kept prefix segment. It never pads: rf_v2
does not consume an attention mask for padded keys. The remaining video rows stay sparse, while the
promoted rows remain visible to every query and key.
Alignment is necessary, not sufficient, for good quality. `rf_v2` groups video positions into 8x8
spatial tiles (two tiles fill one 128-row block), which is what makes a selected block a compact
patch of the frame. When the latent `h` or `w` is not a multiple of 8, the leftover rows or columns
are peeled off and appended as a flat run instead of being tiled, so they get pooled with spatially
distant positions and selection can no longer rank them meaningfully. This is silent, and invisible
to a `sparsity=0` check, because a fully populated mask does not care how blocks are grouped.
For example, MiniMax-H3 at 1344x768 has grid `(62, 24, 42)`: its 62496 video rows leave a 32-row
128-block residue, and its two non-8-wide latent columns form a 2928-row rearranged suffix.
MindIE-SD promotes 2976 rows, leaving 59520 sparse rows (`465 × 128`). This keeps the block mask
and kernel tiling aligned, at the cost of retaining more blocks.
"Multiple of 8" on the latent grid means **width and height that are multiples of 256**. Aligned
resolutions off that grid still run sparse; they just lose more fidelity at the same `sparsity`,
which you can buy back with `start_step`.
8x8-aligned spatial grids remain preferable for performance. A latent `h` or `w` not divisible by 8
creates an always-kept residual suffix and lowers realized sparsity. "Multiple of 8" on the latent
grid means **width and height that are multiples of 256**. This path requires a MindIE-SD release
that implements protected video tails for rf_v2.
## End-to-End Benchmark (BF16, sm_120 RTX Pro 6000 Blackwell)
@@ -164,10 +164,12 @@ In `DiffusionParallelConfig`:
| `ulysses_degree` | int | 1 | Number of GPUs for Ulysses-SP. Uses all-to-all communication. |
| `ring_degree` | int | 1 | Number of GPUs for Ring-Attention. Uses P2P ring communication. |
| `ulysses_mode` | str | `"default"` | Ulysses attention mode. Set to `"advanced_uaa"` to handle arbitrary sequence lengths and head counts without padding. |
| `mask_sp_padding` | bool | `False` | When the sequence length is not divisible by the SP size, tokens are auto-padded with zeros. Set to `True` to mask those padding tokens (strict, but uses the slower varlen attention path); the default `False` leaves them unmasked, keeping the fast path with negligible numerical impact. |
**Notes:**
- Total sequence parallel size equals to `ulysses_degree × ring_degree`
- Degrees must evenly divide the sequence length for optimal performance (or use `ulysses_mode="advanced_uaa"` for Ulysses-SP)
- `mask_sp_padding` is an experimental feature, currently only supported by `Wan2.2`, `Wan2.2 Vace`, `Qwen-Image`, `Flux 2`, and `HunyuanVideo 1.5`
## Best Practices
+1
View File
@@ -159,6 +159,7 @@ The following tables show which models support each feature:
| **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (encode/decode) | ✅ | ❌ |
| **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| **Cosmos3** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (encode/decode) | ✅ | ❌ |
| **LongCat-Video-Avatar-1.5** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **MiniMax-H3** | ✅ (FL2VA) | ✅ | ✅ | ❌ | ✅ (DiT/TE) | ❌ | ✅ | ✅ | ✅ (tile) | ✅ (DiT) | ❌ |
**Frame Interpolation Support**
@@ -99,7 +99,7 @@ a native vLLM-Omni pipeline for those cases.
### Attention Backends
The diffusers backend converts
[vLLM-Omni standard of attention backend setting](../../../docs/user_guide/diffusion/attention_backends.md)
[vLLM-Omni standard of attention backend setting](../../diffusion/attention_backends.md)
to [diffusers standard](https://huggingface.co/docs/diffusers/optimization/attention_backends#available-backends).
Specifically for `FLASH_ATTN`, it will first attempt to use FlashAttention-3 and then FlashAttention-2.
@@ -0,0 +1,319 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import argparse
import json
import re
import subprocess
import tempfile
import wave
from functools import lru_cache
from pathlib import Path
from typing import Any
import imageio
import numpy as np
import torch
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.diffusion.models.longcat_video.pipeline_longcat_video_avatar import (
prepare_longcat_video_avatar_model_for_omni,
)
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.platforms import current_omni_platform
def parse_args():
parser = argparse.ArgumentParser(description="Generate LongCat-Video-Avatar A2V/AI2V video.")
parser.add_argument("--model", default="meituan-longcat/LongCat-Video-Avatar-1.5")
parser.add_argument("--base-model-dir", default=None, help="Optional local LongCat-Video base model directory.")
parser.add_argument("--stage", choices=["at2v", "ai2v"], default="at2v")
parser.add_argument("--prompt", default="A person speaks calmly while facing the camera.")
parser.add_argument("--negative-prompt", default="low quality, blurry, watermark, text")
parser.add_argument("--audio", default=None, help="Input speech audio path.")
parser.add_argument("--image", default=None, help="Input reference image path for AI2V.")
parser.add_argument("--input-json", default=None, help="Official LongCat Avatar JSON case for AI2V.")
parser.add_argument("--output", default="longcat_avatar_output.mp4")
parser.add_argument("--resolution", choices=["480p", "720p"], default="480p")
parser.add_argument("--num-frames", type=int, default=93)
parser.add_argument("--num-segments", default="1", help="Number of AVC segments, or 'auto' to cover full audio.")
parser.add_argument("--num-cond-frames", type=int, default=13)
parser.add_argument("--ref-img-index", type=int, default=10)
parser.add_argument("--mask-frame-range", type=int, default=3)
parser.add_argument("--fps", type=int, default=25)
parser.add_argument("--num-inference-steps", type=int, default=8)
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--use-distill", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--use-int8", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument(
"--build-components-on-gpu",
action="store_true",
default=False,
help="Build large Avatar components directly on GPU for faster startup. Requires more peak VRAM.",
)
parser.add_argument("--use-kv-cache", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--offload-kv-cache", action=argparse.BooleanOptionalAction, default=False)
parser.add_argument("--enforce-eager", action="store_true")
return parser.parse_args()
def _extract_frames(output: Any):
if isinstance(output, list):
output = output[0] if output else None
if isinstance(output, OmniRequestOutput):
if not output.images:
raise ValueError("No video frames found in OmniRequestOutput.")
frames = output.images[0] if len(output.images) == 1 else output.images
if isinstance(frames, tuple) and len(frames) == 2:
frames = frames[0]
if isinstance(frames, dict):
frames = frames.get("frames") or frames.get("video")
else:
frames = output
if frames is None:
raise ValueError("No video frames found in output.")
return frames
def _frames_to_uint8(frames):
frames_np = np.asarray([np.asarray(frame) for frame in frames])
if frames_np.ndim == 5:
frames_np = frames_np[0]
if np.issubdtype(frames_np.dtype, np.floating):
frames_np = (np.clip(frames_np, 0.0, 1.0) * 255).round()
return np.clip(frames_np, 0, 255).astype(np.uint8)
@lru_cache(maxsize=1)
def _ffmpeg_exe() -> str:
try:
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
except Exception as exc:
raise RuntimeError("LongCat Avatar example requires the Python imageio[ffmpeg] dependency.") from exc
def _wav_duration(path: Path) -> float:
with wave.open(str(path), "rb") as audio:
return audio.getnframes() / float(audio.getframerate())
def _save_video_with_audio(frames, output_path: Path, audio_path: str, fps: int) -> None:
frames_np = _frames_to_uint8(frames)
output_path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="longcat_avatar_export_") as tmp:
tmp_dir = Path(tmp)
temp_video = tmp_dir / "video.mp4"
crop_audio = tmp_dir / "audio.wav"
crop_video = tmp_dir / "video_cropped.mp4"
writer = imageio.get_writer(str(temp_video), fps=fps, quality=5)
try:
for frame in frames_np:
writer.append_data(frame)
finally:
writer.close()
duration = len(frames_np) / fps
ffmpeg = _ffmpeg_exe()
subprocess.run([ffmpeg, "-y", "-i", audio_path, "-t", f"{duration}", str(crop_audio)], check=True)
audio_duration = _wav_duration(crop_audio)
subprocess.run(
[
ffmpeg,
"-y",
"-i",
str(temp_video),
"-t",
f"{audio_duration}",
"-c:v",
"copy",
str(crop_video),
],
check=True,
)
subprocess.run(
[
ffmpeg,
"-y",
"-i",
str(crop_video),
"-i",
str(crop_audio),
"-c:v",
"libx264",
"-c:a",
"aac",
"-shortest",
str(output_path),
],
check=True,
)
def _infer_asset_root_from_input_json(input_json: Path) -> Path:
resolved = input_json.resolve()
parts = resolved.parts
if "assets" not in parts:
return resolved.parent
asset_idx = parts.index("assets")
return Path(*parts[:asset_idx])
def _resolve_case_path(path: str, asset_root: Path) -> str:
raw_path = Path(path)
if raw_path.is_absolute():
return str(raw_path)
candidate = asset_root / raw_path
return str(candidate if candidate.exists() else raw_path)
def _speaker_sort_key(name: str) -> tuple[int, str]:
match = re.search(r"(\d+)$", str(name))
return (int(match.group(1)) if match else 10_000, str(name))
def _load_input_case(input_json: str) -> tuple[dict[str, Any], Path]:
input_json_path = Path(input_json)
with input_json_path.open("r", encoding="utf-8") as f:
case = json.load(f)
return case, _infer_asset_root_from_input_json(input_json_path)
def _create_merged_audio_for_case(
audio_paths: dict[str, str],
audio_type: str,
output_path: Path,
) -> str:
sorted_paths = [path for _, path in sorted(audio_paths.items(), key=lambda item: _speaker_sort_key(str(item[0])))]
if len(sorted_paths) == 1:
return sorted_paths[0]
inputs = [arg for path in sorted_paths for arg in ("-i", path)]
audio_type = audio_type.lower()
if audio_type == "para":
filter_complex = f"amix=inputs={len(sorted_paths)}:duration=longest:normalize=0"
elif audio_type == "add":
filter_complex = "".join(f"[{idx}:a]" for idx in range(len(sorted_paths)))
filter_complex += f"concat=n={len(sorted_paths)}:v=0:a=1"
else:
raise NotImplementedError(f"Unsupported LongCat Avatar audio_type {audio_type!r}.")
subprocess.run([_ffmpeg_exe(), "-y", *inputs, "-filter_complex", filter_complex, str(output_path)], check=True)
return str(output_path)
def main():
args = parse_args()
input_case = None
input_asset_root = None
audio_for_mux = args.audio
if args.input_json:
input_case, input_asset_root = _load_input_case(args.input_json)
args.stage = "ai2v"
args.prompt = input_case.get("prompt") or args.prompt
args.image = _resolve_case_path(input_case["cond_image"], input_asset_root)
args.audio = None
if not args.input_json and not args.audio:
raise ValueError("--audio is required unless --input-json is provided.")
if args.stage == "ai2v" and not args.image:
raise ValueError("--image is required when --stage=ai2v.")
model_for_omni = prepare_longcat_video_avatar_model_for_omni(args.model, args.use_int8)
additional_config = {
"model_type": "avatar-v1.5",
"resolution": args.resolution,
"use_distill": args.use_distill,
"use_int8": args.use_int8,
"build_components_on_gpu": args.build_components_on_gpu,
}
if args.base_model_dir:
additional_config["base_model_dir"] = args.base_model_dir
omni = Omni(
model=model_for_omni,
model_class_name="LongCatVideoAvatarPipeline",
dtype="bfloat16",
enforce_eager=args.enforce_eager,
additional_config=additional_config,
parallel_config=DiffusionParallelConfig(
ulysses_degree=1,
ring_degree=1,
cfg_parallel_size=1,
tensor_parallel_size=1,
vae_patch_parallel_size=1,
pipeline_parallel_size=1,
),
)
audio_input: str | dict[str, str] | None = args.audio
additional_information = {"stage": args.stage, "resolution": args.resolution}
if input_case is not None and input_asset_root is not None:
audio_input = {
speaker: _resolve_case_path(path, input_asset_root)
for speaker, path in sorted(
input_case["cond_audio"].items(),
key=lambda item: _speaker_sort_key(str(item[0])),
)
}
additional_information.update(
{
"input_json": args.input_json,
"audio_type": input_case.get("audio_type", "para"),
"bbox": input_case.get("bbox", {}),
}
)
prompt = {
"prompt": args.prompt,
"negative_prompt": args.negative_prompt,
"multi_modal_data": {"audio": audio_input},
"additional_information": additional_information,
}
if args.stage == "ai2v":
prompt["multi_modal_data"]["image"] = args.image
try:
output = omni.generate(
prompt,
OmniDiffusionSamplingParams(
generator=torch.Generator(device=current_omni_platform.device_type).manual_seed(args.seed),
seed=args.seed,
guidance_scale=1.0 if args.use_distill else 4.0,
guidance_scale_2=1.0 if args.use_distill else 4.0,
num_inference_steps=args.num_inference_steps,
num_frames=args.num_frames,
fps=args.fps,
extra_args={
"stage": args.stage,
"resolution": args.resolution,
"save_fps": args.fps,
"use_distill": args.use_distill,
"use_int8": args.use_int8,
"input_json": args.input_json,
"num_segments": args.num_segments,
"num_cond_frames": args.num_cond_frames,
"ref_img_index": args.ref_img_index,
"mask_frame_range": args.mask_frame_range,
"use_kv_cache": args.use_kv_cache,
"offload_kv_cache": args.offload_kv_cache,
},
),
use_tqdm=True,
)
finally:
omni.close()
with tempfile.TemporaryDirectory(prefix="longcat_avatar_audio_mux_") as tmp:
if input_case is not None and isinstance(audio_input, dict):
audio_for_mux = _create_merged_audio_for_case(
audio_input,
input_case.get("audio_type", "para"),
Path(tmp) / "merged_audio.wav",
)
_save_video_with_audio(_extract_frames(output), Path(args.output), audio_for_mux, args.fps)
print(f"Saved video to {args.output}")
if __name__ == "__main__":
main()
@@ -8,7 +8,9 @@ import time
from pathlib import Path
from typing import Any
import numpy as np
import torch
from diffusers.utils import numpy_to_pil
from vllm_omni.diffusion.data import logger
from vllm_omni.diffusion.utils.image_output import extract_images_from_outputs
@@ -54,6 +56,17 @@ def parse_json_object(value: str, flag_name: str = "argument") -> dict[str, Any]
parse_profiler_config = functools.partial(parse_json_object, flag_name="--profiler-config")
def _normalize_images_for_save(images: list[Any]) -> list[Any]:
"""Convert NumPy diffusion outputs to PIL images before saving."""
normalized = []
for image in images:
if isinstance(image, np.ndarray):
normalized.extend(numpy_to_pil(image))
else:
normalized.append(image)
return normalized
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate an image with supported diffusion models.")
parser.add_argument(
@@ -619,6 +632,7 @@ def main():
if not images:
raise ValueError("No images found in request_output")
images = _normalize_images_for_save(images)
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
+3 -3
View File
@@ -23,10 +23,10 @@ including `librosa`.
The deploy config auto-loads via `--omni`.
The default `vllm_omni/deploy/minicpmo_4_5.yaml` keeps all three stages on
logical device 0 with memory budgets of 55%, 15%, and 15%, leaving headroom
logical device 0 with memory budgets of 55%, 15%, and 18%, leaving headroom
for runtime kernels such as the HiFi-GAN vocoder's cuDNN workspace. The
profile admits at most four sequences per stage and bounds startup video
profiling to 32 frames per video. For throughput,
profile admits at most four sequences per stage and bounds Talker context
to 4096 tokens. For throughput,
`minicpmo_4_5_2gpu.yaml` gives the Thinker
GPU 0 (90%) and colocates the Talker (55%) and Code2Wav (35%) on GPU 1. That
profile admits at most four concurrent sequences per stage.
@@ -196,6 +196,7 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
args.model,
ref_audio=_ref_audio_data_url(args.ref_audio),
session_id=args.session_id,
temperature=args.temperature,
timeout_s=args.timeout_s,
)
stream_event_cursor = len(client.events.events)
@@ -383,6 +384,12 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--output-dir", default="/tmp/minicpmo_realtime_duplex_demo")
parser.add_argument("--chunk-ms", type=int, default=200)
parser.add_argument("--timeout-s", type=float, default=60.0)
parser.add_argument(
"--temperature",
type=float,
default=None,
help="Stage0 sampling temperature; response-required soft-interrupt uses 0.0.",
)
parser.add_argument("--no-realtime-pacing", action="store_true")
parser.add_argument("--require-audio", action="store_true")
return parser.parse_args()
+8
View File
@@ -89,6 +89,14 @@ local = [
"FlagEmbedding",
]
# LongCat-Video-Avatar A2V/AI2V runtime dependencies for vocal
# separation and loudness normalization.
longcat-video-avatar = [
"audio-separator==0.44.2",
"pyloudnorm==0.1.1",
"soundfile>=0.13.1",
]
# Optional Blackwell-only fused-bias FP8 GEMM; package is `quack-kernels` (imported
# as `quack`), not the unrelated `quack`. See docs/user_guide/quantization/fp8.md.
quack = [
+169
View File
@@ -0,0 +1,169 @@
# MiniMax-H3 on RTX 4090
This recipe uses BF16 weights, tiled VAE decode, tensor parallelism, optional
Ulysses sequence parallelism, and distributed layerwise offload (DLO). It is the
24 GiB sibling of [MiniMax-H3-5090.md](MiniMax-H3-5090.md): the two-GPU topology
matches that recipe, but the resident DiT-block count is lower so the per-rank
peak fits in 24 GiB. Lower resident counts reduce HBM use and increase
CPU-to-GPU transfer time.
## Capacity requirements
| Resource | Two RTX 4090s | Four RTX 4090s |
| --- | ---: | ---: |
| GPU HBM | 24 GiB per GPU | 24 GiB per GPU |
| Checkpoint storage | 135 GiB per partition | 135 GiB per partition |
| Available system RAM | 200 GiB minimum | 200 GiB minimum |
| Recommended system RAM | 384 GiB | 384 GiB |
`FL2VA` and `Ref2VA` are separate 135 GiB checkpoint partitions. Start one
server at a time. DLO keeps rank-local weights in pinned host memory; increasing
`--dlo-resident-layers` improves latency but does **not** reduce host RAM in the
current implementation because resident layers retain pinned CPU master copies.
A single RTX 4090 is not covered here. The one-GPU DLO profile in the RTX 5090
recipe peaked at 26.50 GiB with 12 resident layers, which exceeds 24 GiB. On one
4090, use the model-level CPU offload command in
[MiniMax-H3.md](MiniMax-H3.md#single-gpu-accuracy-and-memory-first) instead, or
lower the resident count and re-measure before trusting it.
> **Modular H3:** after #5720 lands, preserve this recipe's one-partition
> behavior with `--task-type fl2va` or `--task-type ref2va`.
## Two RTX 4090s: 1024x576, 5 seconds
Use TP2 and 12 resident DiT layers. This is the `rtx4090` profile from the main
recipe. On two RTX 4090s it peaked at 15,620 MiB per rank for T2VA and
14,918 MiB per rank for Ref2VA, leaving roughly 9 GiB of headroom per card. See
[Target-hardware validation](#target-hardware-validation) for the full numbers.
```bash
export VLLM_WORKER_MULTIPROC_METHOD=spawn
export VLLM_OMNI_VIDEO_SYNC_TIMEOUT=14400
CUDA_VISIBLE_DEVICES=0,1 vllm serve /path/to/MiniMax-H3/FL2VA \
--omni --trust-remote-code --host 0.0.0.0 --port 8000 \
--num-gpus 2 --tensor-parallel-size 2 --text-encoder-tp-size 2 \
--usp 1 --ring 1 --vae-patch-parallel-size 2 \
--vae-parallel-mode tile --vae-use-tiling \
--enable-distributed-layerwise-offload --dlo-no-use-allgather \
--dlo-resident-layers 12 --enforce-eager \
--diffusion-attention-backend CUDNN_ATTN
```
For Ref2VA, stop the FL2VA server and restart the same command with
`/path/to/MiniMax-H3/Ref2VA`. Ref2VA reference image count and prompt length can
increase activation memory; begin with one request at a time.
`VLLM_OMNI_VIDEO_SYNC_TIMEOUT` matters more here than on larger cards. DLO
streams non-resident DiT blocks over PCIe on every denoising step, so a 60-step
request takes several minutes and would otherwise hit the 600-second default.
## Four RTX 4090s: 1024x576, 5 seconds
Keep TP2 and raise Ulysses sequence parallel to 2 so the world size is
`TP × USP = 4`. Text-encoder TP and VAE patch parallel follow the GPU count.
Resident layers stay at 12: adding USP does not shard DiT weights further, so
per-GPU peak HBM stays close to the two-GPU profile while wall-clock improves.
```bash
export VLLM_WORKER_MULTIPROC_METHOD=spawn
export VLLM_OMNI_VIDEO_SYNC_TIMEOUT=14400
CUDA_VISIBLE_DEVICES=0,1,2,3 vllm serve /path/to/MiniMax-H3/FL2VA \
--omni --trust-remote-code --host 0.0.0.0 --port 8000 \
--num-gpus 4 --tensor-parallel-size 2 --text-encoder-tp-size 4 \
--usp 2 --ring 1 --vae-patch-parallel-size 4 \
--vae-parallel-mode tile --vae-use-tiling \
--enable-distributed-layerwise-offload --dlo-no-use-allgather \
--dlo-resident-layers 12 --enforce-eager \
--diffusion-attention-backend CUDNN_ATTN
```
For Ref2VA, stop the FL2VA server and restart the same command with
`/path/to/MiniMax-H3/Ref2VA`. Pin four dedicated GPUs; if the launcher exposes
fewer than four devices, startup fails with
`Stage 0 requires 4 device(s) based on parallel_config`.
## Target-hardware validation
Measured on RTX 4090 (24,564 MiB each, driver 580.126.09) with the serve
commands above, at vLLM-Omni `0.26.1.dev55+g81b48e83e`, vLLM `0.26.0`, and
PyTorch `2.11.0+cu130`. GPUs used by each server were dedicated. `Client E2E`
and `Peak per GPU` come from the `/v1/videos/sync` response headers
`x-inference-time-s` and `x-peak-memory-mb`; the latter is the rank-0 CUDA
reserved high-water mark, so it is a per-GPU figure.
| GPUs | Topology | Task | Shape | Frames | Steps | Client E2E | Peak per GPU | Output validation |
| ---: | --- | --- | ---: | ---: | ---: | ---: | ---: | --- |
| 2 | TP2 × USP1 | T2VA (`FL2VA`) | 1024x576 | 124 at 24 FPS | 60 | 7 min 9 s | 15.3 GiB | H.264 video + 32 kHz stereo AAC; full `ffmpeg` decode passed |
| 2 | TP2 × USP1 | Ref2VA | 1024x576 | 124 at 24 FPS | 60 | 14 min 52 s | 14.6 GiB | H.264 video + 32 kHz stereo AAC; full `ffmpeg` decode passed |
| 4 | TP2 × USP2 | T2VA (`FL2VA`) | 1024x576 | 124 at 24 FPS | 60 | 4 min 29 s | 15.2 GiB | H.264 video + 32 kHz stereo AAC; full `ffmpeg` decode passed |
| 4 | TP2 × USP2 | Ref2VA | 1024x576 | 124 at 24 FPS | 60 | 9 min 5 s | 16.1 GiB | H.264 video + 32 kHz stereo AAC; successful `/v1/videos/sync` 200 |
All rows use `seed=1101`, `flow_shift=12`, and `audio_flow_shift=3.0`.
Two-GPU repeats: T2VA measured 434.9 s and 429.3 s; Ref2VA measured 891.9 s and
892.1 s. Peak memory was identical across repeats for both tasks, and the two
outputs of each pair agreed to within 115 bytes.
Four-GPU T2VA repeats: 273.8 s and 269.5 s, peaking at 15,560 MiB and
15,612 MiB. Four-GPU Ref2VA is a single successful end-to-end run (545.2 s,
16,448 MiB). Follow-up Ref2VA requests on this build completed diffusion but
then hit the engine's hardcoded 30 s async output wait
(`_ASYNC_OUTPUT_TIMEOUT` in `diffusion_engine.py`) during post-compute D2H,
so they are not reported as repeats.
Relative to the two-GPU baseline on the same shape, four-GPU TP2×USP2 was about
1.6× faster for both tasks, while per-GPU peak HBM stayed in the mid-15 GiB
range because USP does not further shard resident DiT weights. These are
single-request validation runs, not concurrent throughput benchmarks.
## Request examples
`t2va` requires an explicit `aspect_ratio`. Without it the request fails with
`t2va requires an explicit aspect_ratio`, even when `width` and `height` are set.
Text to video and audio, against the `FL2VA` server:
```bash
curl -sS -D headers_t2va.txt -o out_t2va.mp4 --max-time 14400 \
-X POST "http://127.0.0.1:8000/v1/videos/sync" \
-F 'prompt=傍晚的小厨房,夕阳余晖从窗边洒进来,旧木桌上放着洗到一半的马克杯和起雾的玻璃瓶,悬挂的抹布轻轻晃动。画面带有手持拍摄的轻微晃动和逆光曝光波动,生活感十足。环境音是安静的厨房底噪。' \
-F 'aspect_ratio=16:9' \
-F 'width=1024' -F 'height=576' -F 'fps=24' \
-F 'num_inference_steps=60' -F 'flow_shift=12' -F 'seed=1101' \
-F 'extra_params={"task":"t2va","duration":5,"audio_flow_shift":3.0}'
```
Reference to video and audio, against the `Ref2VA` server:
```bash
curl -sS -D headers_ref2va.txt -o out_ref2va.mp4 --max-time 14400 \
-X POST "http://127.0.0.1:8000/v1/videos/sync" \
-F 'prompt=傍晚的小厨房,夕阳余晖从窗边洒进来,旧木桌上放着洗到一半的马克杯和起雾的玻璃瓶,悬挂的抹布轻轻晃动。画面带有手持拍摄的轻微晃动和逆光曝光波动,生活感十足。环境音是安静的厨房底噪。' \
-F 'input_reference=@/path/to/reference.jpg;type=image/jpeg' \
-F 'audio_reference=</path/to/audio_reference.json' \
-F 'width=1024' -F 'height=576' -F 'fps=24' \
-F 'num_inference_steps=60' -F 'flow_shift=12' -F 'seed=1101' \
-F 'extra_params={"task":"ref2va","duration":5,"audio_flow_shift":3.0}'
```
`audio_reference` is a JSON object, which is why the example loads it from a file
with curl's `<` syntax. Its `audio_url` must be an `http(s)` URL or a data URL;
a bare filesystem path is rejected with `Invalid audio_reference.audio_url`.
```json
{"audio_url": "https://example.com/reference.wav"}
```
Reference images are validated before inference: the short edge must be at least
256 pixels, the long edge at most 5760, and the aspect ratio must fall between
0.4 and 2.5.
The response headers carry the measurements used in the table above:
```bash
cat headers_t2va.txt
ffmpeg -v error -i out_t2va.mp4 -f null - && echo DECODE_OK
```
+2 -1
View File
@@ -30,8 +30,9 @@ also provided.
`hf_config.version="4.5"`):
- Default single-GPU compatibility layout (auto-loaded):
[`vllm_omni/deploy/minicpmo_4_5.yaml`](../../vllm_omni/deploy/minicpmo_4_5.yaml)
- 2-GPU and 3-GPU layouts:
- Recommended 2-GPU continuous-batching layout:
[`vllm_omni/deploy/minicpmo_4_5_2gpu.yaml`](../../vllm_omni/deploy/minicpmo_4_5_2gpu.yaml),
- 3-GPU layout:
[`vllm_omni/deploy/minicpmo_4_5_3gpu.yaml`](../../vllm_omni/deploy/minicpmo_4_5_3gpu.yaml)
- 8x RTX 4090 layout:
[`vllm_omni/deploy/minicpmo_4_5_8x4090.yaml`](../../vllm_omni/deploy/minicpmo_4_5_8x4090.yaml)
+1
View File
@@ -55,6 +55,7 @@ recipes/
| [`krea/Krea-2.md`](./krea/Krea-2.md) | Text-to-image (Turbo + Raw), offline + online, with LoRA | 1x H100 80GB |
| [`LTX/LTX-2.md`](./LTX/LTX-2.md) | LTX-2/LTX-2.3 text-to-video and image-to-video with synchronized audio | H200 141GB / 96GB-class GPU |
| [`MammothModa2/MammothModa2.md`](./MammothModa2/MammothModa2.md) | Preview and Dev text-to-image (AR → DiT); Dev text/image understanding | Preview: 1x L40S 48GB / 1x ≥40GB GPU; Dev: 1x NVIDIA GPU with sufficient cache headroom |
| [`meituan-longcat/LongCat-Video-Avatar-1.5.md`](./meituan-longcat/LongCat-Video-Avatar-1.5.md) | Audio-driven avatar video generation (AT2V / AI2V, single- and multi-speaker, AVC continuation) | 1x H100 80GB |
| [`mistralai/Voxtral-TTS.md`](./mistralai/Voxtral-TTS.md) | Online serving for TTS | 1x RTX 4090 24GB |
| [`Robbyant/LingBot-Video.md`](./Robbyant/LingBot-Video.md) | Native dense and MoE text-to-video serving | Dense: 1x L20X; MoE: 1x L20X (~67.7 GiB peak) |
| [`cosmos3/Cosmos3-Nano.md`](./cosmos3/Cosmos3-Nano.md) | Text-to-image, text-to-video, image-to-video, video-to-video generation, text to video with sound, action policy | 1x H200 141GB / B300 |
@@ -0,0 +1,282 @@
# LongCat-Video-Avatar-1.5
> Audio-driven avatar video generation (AT2V / AI2V)
## Summary
- Vendor: meituan-longcat
- Model: `meituan-longcat/LongCat-Video-Avatar-1.5`
- Task: Audio-to-video (AT2V) and audio-and-image-to-video (AI2V) avatar generation
- Mode: Offline inference
- Maintainer: Community
## When to use this recipe
Use this recipe to drive a talking or singing avatar from a speech clip, either
from text alone (AT2V) or from a reference image (AI2V), with the native
LongCat-Video-Avatar-1.5 pipeline in vLLM-Omni. It covers:
1. **Single-speaker AT2V / AI2V** — one 93-frame segment from one audio track.
2. **AVC continuation** — chained segments that cover a longer audio track.
3. **Multi-speaker AI2V** — several audio tracks placed on a reference image
through speaker bounding boxes.
The example saves an mp4 with the generated frames muxed with the input audio.
## References
- Upstream model card: <https://huggingface.co/meituan-longcat/LongCat-Video-Avatar-1.5>
- Upstream repository and example assets: <https://github.com/meituan-longcat/LongCat-Video>
- Related example under `examples/`: [`longcat_video/end2end.py`](../../examples/offline_inference/longcat_video/end2end.py)
## Hardware Support
## CUDA
### 1× NVIDIA H100 (80 GB)
#### Environment
- OS: Linux
- Python: 3.10+
- Driver: NVIDIA driver with CUDA 12.x
- vLLM version: Match the repository requirements for your checkout
- vLLM-Omni version or commit: Use the commit you are deploying from
#### Prerequisites
Install the LongCat Avatar optional runtime dependencies:
```bash
pip install "vllm-omni[longcat-video-avatar]"
```
The example uses the `imageio_ffmpeg` executable installed by vLLM-Omni's
`imageio[ffmpeg]` dependency.
#### Command
Audio-to-video (AT2V):
```bash
python examples/offline_inference/longcat_video/end2end.py \
--model meituan-longcat/LongCat-Video-Avatar-1.5 \
--stage at2v \
--audio /path/to/speech.wav \
--prompt "A person speaks calmly while facing the camera." \
--num-frames 93 \
--num-inference-steps 8 \
--output longcat_avatar_at2v.mp4
```
Audio-and-image-to-video (AI2V):
```bash
python examples/offline_inference/longcat_video/end2end.py \
--model meituan-longcat/LongCat-Video-Avatar-1.5 \
--stage ai2v \
--audio /path/to/speech.wav \
--image /path/to/reference.jpg \
--prompt "A person speaks calmly while facing the camera." \
--num-frames 93 \
--num-inference-steps 8 \
--output longcat_avatar_ai2v.mp4
```
#### Official Example Assets
Download the official Avatar example assets:
```bash
mkdir -p longcat_avatar_assets/assets/avatar/single longcat_avatar_assets/assets/avatar/multi
curl -L -o longcat_avatar_assets/assets/avatar/single/man.mp3 \
https://raw.githubusercontent.com/meituan-longcat/LongCat-Video/main/assets/avatar/single/man.mp3
curl -L -o longcat_avatar_assets/assets/avatar/single/man.png \
https://raw.githubusercontent.com/meituan-longcat/LongCat-Video/main/assets/avatar/single/man.png
curl -L -o longcat_avatar_assets/assets/avatar/single_example_1.json \
https://raw.githubusercontent.com/meituan-longcat/LongCat-Video/main/assets/avatar/single_example_1.json
curl -L -o longcat_avatar_assets/assets/avatar/multi/sing.png \
https://raw.githubusercontent.com/meituan-longcat/LongCat-Video/main/assets/avatar/multi/sing.png
curl -L -o longcat_avatar_assets/assets/avatar/multi/sing_man.WAV \
https://raw.githubusercontent.com/meituan-longcat/LongCat-Video/main/assets/avatar/multi/sing_man.WAV
curl -L -o longcat_avatar_assets/assets/avatar/multi/sing_woman.WAV \
https://raw.githubusercontent.com/meituan-longcat/LongCat-Video/main/assets/avatar/multi/sing_woman.WAV
curl -L -o longcat_avatar_assets/assets/avatar/multi/introduce.png \
https://raw.githubusercontent.com/meituan-longcat/LongCat-Video/main/assets/avatar/multi/introduce.png
curl -L -o longcat_avatar_assets/assets/avatar/multi/introduce_man.mp3 \
https://raw.githubusercontent.com/meituan-longcat/LongCat-Video/main/assets/avatar/multi/introduce_man.mp3
curl -L -o longcat_avatar_assets/assets/avatar/multi/introduce_woman.mp3 \
https://raw.githubusercontent.com/meituan-longcat/LongCat-Video/main/assets/avatar/multi/introduce_woman.mp3
curl -L -o longcat_avatar_assets/assets/avatar/multi_example_1.json \
https://raw.githubusercontent.com/meituan-longcat/LongCat-Video/main/assets/avatar/multi_example_1.json
curl -L -o longcat_avatar_assets/assets/avatar/multi_example_2.json \
https://raw.githubusercontent.com/meituan-longcat/LongCat-Video/main/assets/avatar/multi_example_2.json
export LONGCAT_VIDEO_ASSET_DIR="$PWD/longcat_avatar_assets/assets/avatar"
export AVATAR_MODEL=meituan-longcat/LongCat-Video-Avatar-1.5
export PROMPT="$(python -c 'import json, os; print(json.load(open(os.environ["LONGCAT_VIDEO_ASSET_DIR"] + "/single_example_1.json"))["prompt"])')"
```
`--base-model-dir` points at a local LongCat-Video base checkpoint for the
tokenizer, text encoder and VAE; omit it to download those components from
Hugging Face.
```bash
export BASE_MODEL_DIR=/cache/longcat_models/LongCat-Video
```
#### Official Asset Smoke Test
Audio-to-video with the official audio:
```bash
python examples/offline_inference/longcat_video/end2end.py \
--model "$AVATAR_MODEL" \
--base-model-dir "$BASE_MODEL_DIR" \
--stage at2v \
--audio "$LONGCAT_VIDEO_ASSET_DIR/single/man.mp3" \
--prompt "$PROMPT" \
--num-frames 93 \
--num-inference-steps 8 \
--output official_asset_longcat_avatar_at2v.mp4
```
Audio-and-image-to-video with the official audio and image:
```bash
python examples/offline_inference/longcat_video/end2end.py \
--model "$AVATAR_MODEL" \
--base-model-dir "$BASE_MODEL_DIR" \
--stage ai2v \
--audio "$LONGCAT_VIDEO_ASSET_DIR/single/man.mp3" \
--image "$LONGCAT_VIDEO_ASSET_DIR/single/man.png" \
--prompt "$PROMPT" \
--num-frames 93 \
--num-inference-steps 8 \
--output official_asset_longcat_avatar_ai2v.mp4
```
#### Official Single-Speaker AVC Continuation
`--num-segments` chains AVC segments; `auto` covers the full input audio.
```bash
python examples/offline_inference/longcat_video/end2end.py \
--model "$AVATAR_MODEL" \
--base-model-dir "$BASE_MODEL_DIR" \
--input-json "$LONGCAT_VIDEO_ASSET_DIR/single_example_1.json" \
--num-frames 93 \
--num-cond-frames 13 \
--num-segments 5 \
--num-inference-steps 8 \
--output official_asset_longcat_avatar_single_example_1_ai2v_5seg.mp4
```
Replace `--num-segments 5` with `--num-segments auto` to run until the full
official audio is covered. That is a slow archival validation path rather than a
regular smoke test: about 24m28s on a Modal H100 for the full
`single_example_1.json` audio with 8 inference steps.
#### Official Multi-Speaker AI2V
The multi-speaker cases use several audio tracks with speaker masks and optional
bounding boxes. The command below generates the first 93-frame segment, matching
the first call to the official `generate_ai2v()`.
```bash
python examples/offline_inference/longcat_video/end2end.py \
--model "$AVATAR_MODEL" \
--base-model-dir "$BASE_MODEL_DIR" \
--input-json "$LONGCAT_VIDEO_ASSET_DIR/multi_example_1.json" \
--num-frames 93 \
--num-inference-steps 8 \
--output official_asset_longcat_avatar_multi_example_1_ai2v.mp4
```
Use `multi_example_2.json` for the sequential-speaking case, and add
`--num-cond-frames 13 --num-segments auto` to cover the full audio with AVC
continuation.
#### Verification
```bash
ffprobe -hide_banner longcat_avatar_ai2v.mp4
```
The output must report one video stream and one audio stream: the example muxes
the generated frames with the input audio.
#### Notes
**Support matrix**
| Mode | Speakers | Image required | AVC continuation |
| --- | --- | --- | --- |
| AT2V | Single | No | Yes |
| AI2V | Single | Yes | Yes |
| AT2V | Multi | Not supported | Not supported |
| AI2V | Multi | Yes | Yes |
Multi-speaker Avatar requires AI2V because speaker bounding boxes and masks are
defined on a reference image; AT2V with several tracks fails with an explicit
error. AVC continuation uses full-audio embeddings only when `--num-segments` is
greater than `1` or set to `auto`; single-segment AT2V/AI2V keeps the shorter
first-segment audio path.
**Sampling parameters**
| Parameter | LongCat pipeline value | Description |
| --- | --- | --- |
| `--num-frames` | `num_frames` | Frames generated per segment. The default is 93 frames, or about 3.72 seconds at 25 FPS. LongCat normalizes other values to the temporal-VAE-compatible `4k+1` form. |
| `--num-inference-steps` | `steps` | Denoising steps. The distilled Avatar path uses 8 steps. |
| `--fps` | `save_fps` | Output frame rate, and the rate used to align audio embeddings with video frames. The default is 25 FPS. |
| `sampling_params.guidance_scale` | `text_guidance_scale` | Classifier-free guidance applied to the text prompt. |
| `sampling_params.guidance_scale_2` | `audio_guidance_scale` | Classifier-free guidance applied to the speech audio. |
With the distilled LoRA enabled (the default), the pipeline uses 8 inference
steps and sets both guidance scales to `1.0`. With `--no-use-distill`, the
example sets both to `4.0`. For AVC continuation every segment holds
`num_frames` frames, but each segment after the first reuses `num_cond_frames`
conditioning frames, so the unique frame count is
`num_frames + (num_segments - 1) * (num_frames - num_cond_frames)`.
**Key flags**
- `--stage`: `at2v` for audio-to-video, `ai2v` for audio-and-image-to-video.
- `--resolution`: `480p` or `720p`.
- `--input-json`: official LongCat Avatar JSON case, used for the single-speaker
and multi-speaker AI2V examples.
- `--num-segments`: number of AVC segments, or `auto` to cover the full audio.
- `--num-cond-frames`: previous-frame conditioning window for AVC continuation.
- `--use-int8` / `--no-use-int8`: load the official INT8 Avatar DiT weights by
default, or full precision weights.
- `--use-distill` / `--no-use-distill`: enable the official distilled LoRA path
by default.
- `--build-components-on-gpu`: build large Avatar components directly on GPU for
faster startup, at a higher peak VRAM cost. Disabled by default.
- `--base-model-dir`: local LongCat-Video base model directory for the
tokenizer, text encoder and VAE components.
**Memory usage**
Measured on a Modal H100 running the official 93-frame AI2V example with the
INT8 DiT, distilled LoRA and 8 inference steps:
| Component build | CLI option | Peak GPU memory | Model loading time |
| --- | --- | ---: | ---: |
| CPU (default) | None | About 41.0 GiB | About 130s |
| GPU | `--build-components-on-gpu` | About 56.8 GiB | About 33s |
The reported peak covers the end-to-end run. Building components on CPU only
reduces initialization memory; denoising still runs on GPU. These numbers are
memory guidance rather than a performance benchmark.
**Known limitations**
- Single GPU only. Cache acceleration, sequence/CFG/tensor parallelism, HSDP,
CPU offload, VAE patch parallelism and quantization are not wired up for this
pipeline yet; see the diffusion feature matrix in
[`docs/user_guide/diffusion_features.md`](../../docs/user_guide/diffusion_features.md).
- Offline inference only. Online serving is not supported yet.
+159 -1
View File
@@ -6,13 +6,21 @@ Unit tests for patch.py
"""
import asyncio
import base64
import json
import time
from types import SimpleNamespace
import pytest
from pytest_mock import MockerFixture
from vllm.benchmarks.lib.endpoint_request_func import RequestFuncInput
from vllm_omni.benchmarks.patch.patch import MixRequestFuncOutput, async_request_openai_chat_omni_completions
from vllm_omni.benchmarks.patch.patch import (
MixRequestFuncOutput,
async_request_openai_chat_omni_completions,
async_request_openai_realtime_duplex,
)
from vllm_omni.experimental.fullduplex.client import RealtimeEventCollector
pytestmark = [pytest.mark.core_model, pytest.mark.benchmark, pytest.mark.cpu]
@@ -40,6 +48,156 @@ class MockResponse:
pass
@pytest.mark.asyncio
async def test_seed_tts_realtime_duplex_exports_per_request_metrics(monkeypatch):
class FakeRealtimeClient:
last_instance = None
def __init__(self, url):
assert url == "ws://localhost:8000/v1/realtime?duplex=1"
self.events = RealtimeEventCollector()
self.configure_kwargs = None
self.sent = []
self.response_count = 0
self.ack_count = 0
type(self).last_instance = self
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def configure(self, model, **kwargs):
assert model == "openbmb/MiniCPM-o-4_5"
self.configure_kwargs = kwargs
async def send(self, event):
self.sent.append(event)
if event["type"] != "response.create":
return
self.response_count += 1
response_id = f"resp-{self.response_count}"
now = time.monotonic()
self.events.add(
{"type": "response.created", "response": {"id": response_id}},
received_at_s=now + 0.01,
)
self.events.add(
{
"type": "response.audio_transcript.delta",
"response_id": response_id,
"delta": f"turn {self.response_count}",
},
received_at_s=now + 0.02,
)
self.events.add(
{
"type": "response.audio.delta",
"response_id": response_id,
"delta": base64.b64encode(b"\x00\x00" * 2400).decode(),
"sample_rate_hz": 24_000,
"metadata": {"audio_duration_ms": 100},
},
received_at_s=now + 0.03,
)
self.events.add(
{"type": "response.done", "response": {"id": response_id}},
received_at_s=now + 0.04,
)
async def acknowledge_playback(self):
self.ack_count += 1
async def close_session(self, **_kwargs):
return None
monkeypatch.setattr(
"vllm_omni.benchmarks.patch.patch.RealtimeDuplexClient",
FakeRealtimeClient,
)
request_input = RequestFuncInput(
model="openbmb/MiniCPM-o-4_5",
model_name="openbmb/MiniCPM-o-4_5",
prompt="hello",
api_url="http://localhost:8000/v1/realtime",
prompt_len=1,
output_len=20,
logprobs=None,
multi_modal_content=None,
ignore_eos=False,
extra_body={"save_duplex_request_metrics": True},
)
request_input.seed_tts_speech_extra = {"ref_audio": "data:audio/wav;base64,AAAA"}
request_input.seed_tts_system_prompt = "Speak exactly."
request_input.seed_tts_turns = tuple(
SimpleNamespace(utterance_id=f"utt-{index}", target_text=f"text {index}") for index in range(4)
)
output = await async_request_openai_realtime_duplex(
request_input,
session=None,
)
client = FakeRealtimeClient.last_instance
assert client.configure_kwargs["native_duplex"] is False
assert client.configure_kwargs["extra_body"] == {
"ref_audio": "data:audio/wav;base64,AAAA",
}
assert client.sent == [
event
for index in range(4)
for event in (
{
"type": "conversation.item.create",
"item": {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": f"text {index}"}],
},
},
{"type": "response.create"},
)
]
assert client.ack_count == 4
assert output.success is True
assert output.generated_text == "turn 1 turn 2 turn 3 turn 4"
assert output.audio_duration == pytest.approx(0.4)
assert output.ttft > 0
assert output.audio_ttfp > output.ttft
assert output.audio_rtf > 0
assert output.latency > 0
assert output.tts_turn_pcm_bytes == [b"\x00\x00" * 2400] * 4
assert output.tts_output_pcm_bytes == b"\x00\x00" * 9600
session_id = output.duplex_request_metrics[0]["session_id"]
assert len(output.duplex_request_metrics) == 4
for index, metrics in enumerate(output.duplex_request_metrics):
assert metrics == {
"session_id": session_id,
"request_index": index,
"utterance_id": f"utt-{index}",
"response_id": f"resp-{index + 1}",
"source": "client_monotonic_receive",
"measurement_origin": {
"ttft": "conversation.item.create client send to first non-empty text delta",
"ttfp": "conversation.item.create client send to first audio packet",
"rtf": "request-start-to-last-audio receive time divided by emitted audio duration",
},
"ttft_ms": pytest.approx(20.0, abs=2.0),
"ttfp_ms": pytest.approx(30.0, abs=2.0),
"rtf": pytest.approx(0.3, abs=0.03),
"audio_generation_ms": pytest.approx(30.0, abs=2.0),
"audio_duration_ms": 100.0,
}
assert output.duplex_session_metrics == {
"session_id": session_id,
"audio_turn_count": 4,
"mean_ttft_ms": pytest.approx(20.0, abs=2.0),
"mean_ttfp_ms": pytest.approx(30.0, abs=2.0),
"mean_rtf": pytest.approx(0.3, abs=0.03),
}
def create_sse_chunk(data_dict):
"""Helper to create SSE formatted chunk"""
return f"data: {json.dumps(data_dict)}\n\n".encode()
+23 -1
View File
@@ -42,7 +42,10 @@ from benchmarks.accuracy.text_to_image.gbench import (
summarize_generated_records as summarize_gebench_generated_records,
summarize_gebench_results,
)
from tests.e2e.accuracy.qwen3_omni.qwen3_omni_acc_bench_core import seed_tts_bench_argv
from tests.e2e.accuracy.qwen3_omni.qwen3_omni_acc_bench_core import (
build_serve_common_argv,
seed_tts_bench_argv,
)
from tests.e2e.accuracy.qwen3_omni.run_qwen_omni_acc_benchmark import sync_dataset_env_from_ns
from vllm_omni.benchmarks.data_modules.seed_tts_dataset import resolve_seed_tts_root
@@ -57,6 +60,25 @@ def test_seed_tts_bench_argv_preserves_hf_repo_id_from_env(monkeypatch):
assert argv[dataset_idx + 1] == "zhaochenyang20/seed-tts-eval"
def test_build_serve_common_argv_accepts_realtime_seed_tts_route(tmp_path):
argv = build_serve_common_argv(
host="127.0.0.1",
port=8000,
model="openbmb/MiniCPM-o-4_5",
num_prompts=4,
max_concurrency=1,
num_warmups=0,
percentile_metrics="ttft,audio_ttfp,audio_rtf",
result_dir=tmp_path,
result_filename="result.json",
backend="openai-realtime-tts",
endpoint="/v1/realtime",
)
assert argv[argv.index("--backend") + 1] == "openai-realtime-tts"
assert argv[argv.index("--endpoint") + 1] == "/v1/realtime"
def test_sync_dataset_env_preserves_seed_tts_hf_repo_id(monkeypatch):
ns = argparse.Namespace(
daily_omni_repo=None,
@@ -28,10 +28,13 @@ if _MODULE_NAME not in sys.modules:
_spec.loader.exec_module(_mod)
from vllm_omni.benchmarks.data_modules.seed_tts_dataset import ( # noqa: E402
SeedTTSDataset,
SeedTTSDesignDataset,
SeedTTSDesignSampleRequest,
SeedTTSSampleRequest,
SeedTTSTextDataset,
SeedTTSTextSampleRequest,
SeedTTSTurn,
)
# ---------------------------------------------------------------------------
@@ -70,6 +73,94 @@ def mock_tokenizer(mocker):
# ---------------------------------------------------------------------------
def test_seed_tts_dataset_groups_four_turns_with_one_reference(seed_tts_root, mock_tokenizer):
ds = SeedTTSDataset(
dataset_path=str(seed_tts_root),
random_seed=0,
locale="en",
disable_shuffle=True,
)
requests = ds.sample(
mock_tokenizer,
num_requests=1,
turns_per_session=4,
no_oversample=True,
)
assert len(requests) == 1
request = requests[0]
assert [turn.utterance_id for turn in request.seed_tts_turns] == [
"utt000",
"utt001",
"utt002",
"utt003",
]
assert [turn.target_text for turn in request.seed_tts_turns] == [
"target text 0",
"target text 1",
"target text 2",
"target text 3",
]
assert request.seed_tts_speech_extra["ref_text"] == "ref text 0"
assert request.seed_tts_ref_wav_path.endswith("utt000.wav")
def test_seed_tts_eval_expands_grouped_pcm_by_turn():
from vllm_omni.benchmarks.data_modules.seed_tts_eval import (
_expand_seed_tts_turn_outputs,
)
request = SeedTTSSampleRequest(
prompt="target 0",
prompt_len=4,
expected_output_len=100,
multi_modal_data=None,
request_id="session-0",
seed_tts_turns=tuple(
SeedTTSTurn(utterance_id=f"utt-{index}", target_text=f"target {index}") for index in range(4)
),
)
output = types.SimpleNamespace(
success=True,
tts_turn_pcm_bytes=[bytes([index]) for index in range(4)],
)
requests, outputs = _expand_seed_tts_turn_outputs([request], [output])
assert [item.prompt for item in requests] == [f"target {index}" for index in range(4)]
assert [item.seed_tts_utterance_id for item in requests] == [f"utt-{index}" for index in range(4)]
assert [item.tts_output_pcm_bytes for item in outputs] == [bytes([index]) for index in range(4)]
def test_seed_tts_eval_keeps_session_pcm_for_single_turn_chat():
"""Chat-omni Seed-TTS sets session PCM only; expand must not wipe it."""
from vllm_omni.benchmarks.data_modules.seed_tts_eval import (
_expand_seed_tts_turn_outputs,
)
request = SeedTTSSampleRequest(
prompt="target 0",
prompt_len=4,
expected_output_len=100,
multi_modal_data=None,
request_id="session-0",
seed_tts_turns=(SeedTTSTurn(utterance_id="utt-0", target_text="target 0"),),
)
pcm = b"\x01\x02\x03\x04"
output = types.SimpleNamespace(
success=True,
tts_output_pcm_bytes=pcm,
tts_turn_pcm_bytes=None,
)
requests, outputs = _expand_seed_tts_turn_outputs([request], [output])
assert len(requests) == 1
assert requests[0].prompt == "target 0"
assert outputs[0].tts_output_pcm_bytes == pcm
def test_seed_tts_text_dataset_omits_ref_audio(seed_tts_root, mock_tokenizer):
ds = SeedTTSTextDataset(
dataset_path=str(seed_tts_root),
+2 -1
View File
@@ -2241,7 +2241,8 @@ class TestPlatformOverrides:
load_deploy_config(Path(get_deploy_config_path(filename))), platform="cuda"
)
replica_stages = merge_pipeline_deploy(pipeline, replica)
assert replica_stages[1].yaml_engine_args["kv_cache_memory_bytes"] is None
# Explicit null clears the inherited single-GPU 2 GiB CUDA cap.
assert replica_stages[1].yaml_engine_args.get("kv_cache_memory_bytes") is None
def test_npu_overrides(self):
deploy_path = Path(get_deploy_config_path("qwen3_omni_moe.yaml"))
+2
View File
@@ -474,6 +474,8 @@ def test_sub_config_fields_match_structured_scopes():
"moe_backend",
"hf_overrides",
"limit_mm_per_prompt",
"interleave_mm_strings",
"media_io_kwargs",
"active_stream_window",
"duplex_max_sessions",
"enable_sleep_mode",
+9
View File
@@ -752,3 +752,12 @@ def pytest_addoption(parser: pytest.Parser) -> None:
default=None,
help=("Path to benchmark config JSON. Example: --test-config-file tests/dfx/perf/tests/test_tts.json"),
)
parser.addoption(
"--assert-baseline",
action="store_true",
default=False,
help=(
"When set, omni/diffusion perf runners compare metrics against the baseline block in the JSON config "
"(default: off)."
),
)
+15 -3
View File
@@ -157,8 +157,19 @@ def benchmark_params(request):
}
def assert_result(result, num_prompt) -> None:
def assert_result(result, params, num_prompt) -> None:
assert result["completed"] == num_prompt, "Request failures exist"
expected_audio_turns = params.get("expected_duplex_audio_turns_per_session")
if expected_audio_turns is not None:
session_metrics = result.get("duplex_session_metrics")
assert isinstance(session_metrics, list), "Duplex session metrics are missing"
assert len(session_metrics) == num_prompt, (
f"Expected {num_prompt} duplex session metric rows, got {len(session_metrics)}"
)
assert all(
isinstance(metric, dict) and metric.get("audio_turn_count") == expected_audio_turns
for metric in session_metrics
), f"Not every duplex session emitted {expected_audio_turns} audio turns"
@pytest.mark.benchmark
@@ -212,6 +223,7 @@ def test_performance_benchmark(omni_server, benchmark_params):
"enabled",
"eval_phase",
"trust_remote_code",
"expected_duplex_audio_turns_per_session",
}
for key, value in params.items():
@@ -251,7 +263,7 @@ def test_performance_benchmark(omni_server, benchmark_params):
random_output_len=params.get("random_output_len"),
resource_label=resource_label,
)
assert_result(result, num_prompt)
assert_result(result, params, num_prompt)
# concurrency test
for sweep_index, (concurrency, num_prompt) in enumerate(zip(max_concurrency_list, num_prompt_list)):
@@ -268,4 +280,4 @@ def test_performance_benchmark(omni_server, benchmark_params):
random_output_len=params.get("random_output_len"),
resource_label=resource_label,
)
assert_result(result, num_prompt)
assert_result(result, params, num_prompt)
@@ -0,0 +1,87 @@
[
{
"test_name": "test_minicpmo_4_5",
"mark": [
{
"hardware_marks": {
"res": {
"cuda": "H100",
"npu": "A3"
},
"num_cards": 1
}
},
"full_model",
"omni"
],
"server_params": {
"model": "openbmb/MiniCPM-o-4_5",
"extra_cli_args": [
"--deploy-config",
"vllm_omni/deploy/minicpmo_4_5.yaml",
"--trust-remote-code"
]
},
"benchmark_params": [
{
"dataset_name": "seed-tts",
"dataset_path": "zhaochenyang20/seed-tts-eval",
"backend": "openai-chat-omni",
"endpoint": "/v1/chat/completions",
"num_prompts": [
32,
64,
128
],
"max_concurrency": [
1,
4,
8
],
"no_oversample": true,
"trust_remote_code": true,
"seed_tts_locale": "en",
"extra_body": {
"modalities": [
"text",
"audio"
],
"chat_template_kwargs": {
"enable_thinking": false,
"use_tts_template": true
}
},
"percentile-metrics": "ttft,e2el,audio_ttfp,audio_rtf,audio_duration",
"baseline": {
"H100": {
"request_throughput": [
0.7803,
1.1008,
1.2027
],
"mean_ttft_ms": [
166.9764,
307.7133,
323.584
],
"mean_e2el_ms": [
1361.1682,
3800.6332,
6961.8636
],
"mean_audio_ttfp_ms": [
635.8099,
1615.6591,
2888.9831
],
"mean_audio_rtf": [
0.3151,
0.8568,
1.5425
]
}
}
}
]
}
]
@@ -0,0 +1,68 @@
[
{
"test_name": "test_minicpmo_4_5_duplex_seed_tts",
"mark": [
{
"hardware_marks": {
"res": {
"cuda": "H100",
"npu": "A3"
},
"num_cards": 1
}
},
"full_model",
"omni"
],
"server_params": {
"model": "openbmb/MiniCPM-o-4_5",
"extra_cli_args": [
"--deploy-config",
"vllm_omni/deploy/minicpmo_4_5_duplex.yaml",
"--trust-remote-code"
]
},
"benchmark_params": [
{
"dataset_name": "seed-tts",
"dataset_path": "zhaochenyang20/seed-tts-eval",
"backend": "openai-realtime-tts",
"endpoint": "/v1/realtime",
"num_prompts": [
1
],
"max_concurrency": [
1
],
"no_oversample": true,
"trust_remote_code": true,
"seed_tts_locale": "en",
"seed_tts_turns_per_session": 4,
"expected_duplex_audio_turns_per_session": 4,
"extra_body": {
"save_duplex_request_metrics": true
},
"percentile-metrics": "ttft,e2el,audio_ttfp,audio_rtf,audio_duration",
"baseline": {
"H100": {
"request_throughput": [
0.1594
],
"mean_ttft_ms": [
274.164
],
"mean_e2el_ms": [
6250.7696
],
"mean_audio_ttfp_ms": [
994.422
],
"mean_audio_rtf": [
0.46163
]
}
}
}
]
}
]
@@ -485,3 +485,30 @@ def test_diffusion_build_run_params_resolves_baseline_per_sweep(tmp_path, monkey
assert sweeps[2]["params"]["baseline"]["H100"]["throughput_qps"] == 0.3
assert "A3" in sweeps[2]["params"]["baseline"]
assert not isinstance(sweeps[2]["params"]["baseline"]["H100"]["throughput_qps"], list)
def test_omni_duplex_expected_audio_turns_accepts_complete_session():
from tests.dfx.perf.scripts.run_benchmark import assert_result
assert_result(
{
"completed": 1,
"duplex_session_metrics": [{"audio_turn_count": 4}],
},
{"expected_duplex_audio_turns_per_session": 4},
1,
)
def test_omni_duplex_expected_audio_turns_rejects_incomplete_session():
from tests.dfx.perf.scripts.run_benchmark import assert_result
with pytest.raises(AssertionError, match="emitted 4 audio turns"):
assert_result(
{
"completed": 1,
"duplex_session_metrics": [{"audio_turn_count": 3}],
},
{"expected_duplex_audio_turns_per_session": 4},
1,
)
@@ -50,12 +50,12 @@ def _prompt() -> str:
return "What is the capital of China? Answer in 20 words."
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", _MINICPMO_SERVER, indirect=True)
def test_invalid_audio_format_rejected(omni_server: OmniServerParams, openai_client: OpenAIClientHandler) -> None:
"""
Test that invalid audio format is properly rejected.
Deploy Setting: default 2GPU
Deploy Setting: default single GPU
Input Modal: text + invalid audio format
Output Modal: error
"""
@@ -315,11 +315,32 @@ def _assert_post_fault_health_terminal(host: str, port: int, *, scenario: str) -
pytest.fail(f"[{scenario} health] no terminal post-fault health observed: {last_observation}")
def _assert_server_alive_after_fault(host: str, port: int, *, scenario: str) -> None:
"""#4285: a single-stage engine death must NOT take down the API server.
The process must stay up and keep answering /health (503 is acceptable —
errored but listening). A connection-refused means the server process
exited, which is the bug this fix closes.
"""
deadline = time.monotonic() + 15.0
responded = False
while time.monotonic() < deadline:
try:
status, _ = get_health_raw(host, port, timeout_sec=5)
responded = True
assert status in (200, 503), f"[{scenario} alive] unexpected /health status={status}"
except Exception as exc: # noqa: BLE001
if _looks_like_server_unreachable(exc):
pytest.fail(f"[{scenario} alive] server exited after single-stage OOM (#4285): {exc!r}")
raise
time.sleep(0.5)
assert responded, f"[{scenario} alive] /health never responded after fault"
QWEN_PARAMS = create_reliability_omni_server_params(RELIABILITY_SCENARIOS, DEPLOY_CONFIGS_DIR)
@pytest.mark.slow
@pytest.mark.skip(reason="issue#4285")
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
def test_reliability_fault_gpu_oom_error_contract_consistent_chat_speech(
@@ -386,9 +407,11 @@ def test_reliability_fault_gpu_oom_error_contract_consistent_chat_speech(
assert "code" in chat_error, f"chat error lacks code field: {chat_error!r}"
assert "code" in speech_error, f"speech error lacks code field: {speech_error!r}"
# #4285: a single dead stage must not take the whole API server down.
_assert_server_alive_after_fault(host, port, scenario="oom_contract")
@pytest.mark.slow
@pytest.mark.skip(reason="issue#4285")
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
def test_reliability_fault_gpu_oom_chat_large_payload_failure(omni_server_function, openai_client_function) -> None:
@@ -431,7 +454,6 @@ def test_reliability_fault_gpu_oom_chat_large_payload_failure(omni_server_functi
@pytest.mark.slow
@pytest.mark.skip(reason="issue#4285")
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
def test_reliability_fault_gpu_oom_concurrent_pressure_failure(omni_server_function, openai_client_function) -> None:
@@ -774,7 +796,11 @@ def test_reliability_fault_process_kill_tree_with_load_fast_fail_and_cleanup(
@pytest.mark.slow
@pytest.mark.skip(reason="issue#4285")
@pytest.mark.skip(
reason="needs request-scoped OOM recovery: under transient pressure the stage "
"EngineCore still dies and is evicted (not respawned), so the engine does not "
"recover after the hog stops. Out of scope for the #4285 fault-isolation fix."
)
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
def test_reliability_fault_gpu_oom_state_converges_after_fault_removed(
@@ -5,10 +5,9 @@
The grids here are the ones MiniMax-H3 FL2VA actually produces for an 8.7s clip:
1280x768 gives a 62x24x40 latent grid whose 59520 video rows land on the 128-row
kernel block, and 1344x768 gives 62x24x42 whose 62496 rows straddle it. Only the
first can run sparse: rf_v2 tiles the sequence as a whole but pools its block
mask from the video and prefix segments separately, so the two only agree on the
block count when the video segment ends on a boundary.
kernel block, and 1344x768 gives 62x24x42 whose 62496 rows do not. Both are
handed to MindIE-SD: its rf_v2 preprocessing promotes an irregular video tail
to the always-kept prefix segment before generating the block mask.
"""
import dataclasses
@@ -57,10 +56,13 @@ def test_block_aligned_video_segment_runs_sparse():
assert plan.latent_shape == list(ALIGNED_GRID)
def test_misaligned_video_segment_stays_dense():
# Realigning the sequence would mean padding it, and rf_v2 ignores attn_mask:
# pad keys would take a share of every real query's softmax denominator.
assert make_impl()._resolve_plan(make_metadata(MISALIGNED_GRID)) is None
def test_misaligned_video_segment_runs_sparse_with_updated_mindiesd():
plan = make_impl()._resolve_plan(make_metadata(MISALIGNED_GRID))
assert plan is not None
assert plan.prefix_len == PREFIX_ROWS
assert plan.used_len == PREFIX_ROWS + 62496
assert plan.latent_shape == list(MISALIGNED_GRID)
@pytest.mark.parametrize("prefix_len", [1, 127, _BLOCK_SIZE, 710, 900])
@@ -155,3 +157,28 @@ def test_fully_populated_mask_reproduces_dense_attention():
).transpose(1, 2)
error = (out.float() - reference.float()).abs().mean() / reference.float().abs().mean()
assert error < 2e-3, f"mean relative error {error:.4%} against dense attention"
@pytest.mark.skipif(not current_omni_platform.is_npu(), reason="rf_v2 runs on Ascend NPU only.")
def test_irregular_video_tail_reproduces_dense_attention_with_updated_mindiesd():
"""vLLM forwards the full irregular grid; MindIE-SD owns its tail handling."""
torch.manual_seed(1)
prefix_len, grid = 384, (4, 16, 10)
video_len = grid[0] * grid[1] * grid[2]
used = prefix_len + video_len
heads, head_dim = 4, 128
q, k, v = (torch.randn(1, used, heads, head_dim, dtype=torch.bfloat16, device="npu") for _ in range(3))
impl = make_impl()
impl.rainfusion = dataclasses.replace(impl.rainfusion, sparsity=0.0)
plan = RainFusionPlan(prefix_len=prefix_len, used_len=used, latent_shape=list(grid))
out = impl._forward_sparse_npu(q, k, v, plan)
reference = torch.nn.functional.scaled_dot_product_attention(
q.transpose(1, 2),
k.transpose(1, 2),
v.transpose(1, 2),
scale=impl.softmax_scale,
).transpose(1, 2)
error = (out.float() - reference.float()).abs().mean() / reference.float().abs().mean()
assert error < 2e-3, f"mean relative error {error:.4%} against dense attention"
+8
View File
@@ -488,6 +488,14 @@ class _MiniMaxH3FakeAttention(nn.Module):
class FakeBackend:
supports_prefix_kv_slicing = False
# Mirror AttentionBackend.supports_packed_mask_free: H3's packed
# attention path calls attn_backend.supports_packed_mask_free() to decide
# whether to build the padding mask. Return False so the fake exercises
# the masked path (the abstract backend default).
@classmethod
def supports_packed_mask_free(cls) -> bool:
return False
def __init__(self, **kwargs):
del kwargs
super().__init__()
@@ -0,0 +1,110 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
from vllm_omni.diffusion.diffusion_kv.metadata import (
DiffusionKVContextMetadata,
DiffusionKVMetadata,
DiffusionKVSequenceMetadata,
)
pytestmark = [pytest.mark.diffusion, pytest.mark.core_model, pytest.mark.cpu]
def test_diffusion_kv_metadata_uses_native_cache_group_block_ids() -> None:
context = DiffusionKVContextMetadata(
context_id="text",
cache_role="cross_attention",
num_tokens=3,
block_ids=([7], [11]),
)
sequence = DiffusionKVSequenceMetadata(
sequence_id=1,
prefix_len=4,
target_len=2,
seq_len=8,
block_ids=([1, 2], [5, 6]),
context_ids=(context.context_id,),
)
metadata = DiffusionKVMetadata(
request_id="req-0",
allocation_generation=3,
sequences=(sequence,),
contexts=(context,),
)
assert metadata.sequences[0].block_ids == ([1, 2], [5, 6])
assert metadata.contexts[0].block_ids == ([7], [11])
def test_cfg_sequences_can_share_one_request_context() -> None:
shared_context = DiffusionKVContextMetadata(
context_id="shared-text",
cache_role="cross_attention",
num_tokens=3,
block_ids=([7], [11]),
)
sequences = tuple(
DiffusionKVSequenceMetadata(
sequence_id=sequence_id,
prefix_len=4,
target_len=2,
seq_len=8,
block_ids=([sequence_id + 1],),
context_ids=(shared_context.context_id,),
)
for sequence_id in range(2)
)
metadata = DiffusionKVMetadata(
request_id="req-shared-context",
allocation_generation=1,
sequences=sequences,
contexts=(shared_context,),
)
assert metadata.contexts == (shared_context,)
assert metadata.sequences[0].context_ids == (shared_context.context_id,)
assert metadata.sequences[1].context_ids == (shared_context.context_id,)
def test_cfg_sequences_can_reference_branch_specific_contexts() -> None:
conditional_context = DiffusionKVContextMetadata(
context_id="conditional-text",
cache_role="cross_attention",
num_tokens=3,
block_ids=([7], [11]),
)
unconditional_context = DiffusionKVContextMetadata(
context_id="unconditional-text",
cache_role="cross_attention",
num_tokens=3,
block_ids=([8], [12]),
)
metadata = DiffusionKVMetadata(
request_id="req-branch-contexts",
allocation_generation=1,
sequences=(
DiffusionKVSequenceMetadata(
sequence_id=0,
prefix_len=4,
target_len=2,
seq_len=8,
block_ids=([1],),
context_ids=(conditional_context.context_id,),
),
DiffusionKVSequenceMetadata(
sequence_id=1,
prefix_len=4,
target_len=2,
seq_len=8,
block_ids=([2],),
context_ids=(unconditional_context.context_id,),
),
),
contexts=(conditional_context, unconditional_context),
)
assert metadata.sequences[0].context_ids == (conditional_context.context_id,)
assert metadata.sequences[1].context_ids == (unconditional_context.context_id,)
@@ -0,0 +1,290 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from vllm_omni.diffusion.data import DiffusionOutput
from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode
from vllm_omni.diffusion.diffusion_kv.metadata import (
DiffusionKVMetadata,
DiffusionKVSequenceMetadata,
)
from vllm_omni.diffusion.executor.multiproc_executor import MultiprocDiffusionExecutor
from vllm_omni.diffusion.sched.interface import (
CachedRequestData,
DiffusionSchedulerOutput,
NewRequestData,
)
from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner
from vllm_omni.diffusion.worker.diffusion_worker import DiffusionWorker, WorkerWrapperBase
pytestmark = [pytest.mark.diffusion, pytest.mark.core_model, pytest.mark.cpu]
def make_metadata(request_id: str = "req-0") -> DiffusionKVMetadata:
return DiffusionKVMetadata(
request_id=request_id,
allocation_generation=1,
sequences=(
DiffusionKVSequenceMetadata(
sequence_id=0,
prefix_len=4,
target_len=2,
seq_len=8,
block_ids=([1, 2],),
),
),
)
def make_runner(mode: DiffusionKVCacheMode) -> DiffusionModelRunner:
runner = object.__new__(DiffusionModelRunner)
runner.od_config = SimpleNamespace(diffusion_kv_mode=mode)
return runner
def make_scheduler_output(*new_reqs: NewRequestData) -> DiffusionSchedulerOutput:
return DiffusionSchedulerOutput(
step_id=0,
scheduled_new_reqs=list(new_reqs),
scheduled_cached_reqs=CachedRequestData.make_empty(),
finished_req_ids=set(),
num_running_reqs=len(new_reqs),
num_waiting_reqs=0,
)
def make_executor() -> tuple[MultiprocDiffusionExecutor, list[tuple]]:
executor = object.__new__(MultiprocDiffusionExecutor)
executor.od_config = SimpleNamespace(
enable_distributed_layerwise_offload=False,
dlo_use_allgather=True,
)
executor._ensure_open = lambda: None
calls: list[tuple] = []
def collective_rpc(method, *, args, unique_reply_rank, exec_all_ranks):
calls.append((method, args, unique_reply_rank, exec_all_ranks))
return DiffusionOutput(output=None)
executor.collective_rpc = collective_rpc
return executor, calls
def test_new_request_data_carries_scheduler_allocation_atomically() -> None:
req = SimpleNamespace(request_id="req-0")
state = SimpleNamespace(request_id="req-0", req=req)
metadata = make_metadata()
new_req = NewRequestData.from_state(state, diffusion_kv_metadata=metadata)
assert new_req.req is req
assert new_req.diffusion_kv_metadata is metadata
def test_paged_request_without_metadata_reaches_request_forward() -> None:
runner = make_runner(DiffusionKVCacheMode.PAGED_SCHEDULER)
expected_output = DiffusionOutput(output=None)
runner._execute_request_list = Mock(
return_value=SimpleNamespace(
runner_outputs=[SimpleNamespace(result=expected_output)],
)
)
req = SimpleNamespace(request_id="req-0")
assert runner.execute_model(req) is expected_output
runner._execute_request_list.assert_called_once()
def test_dense_request_rejects_metadata_before_request_forward() -> None:
runner = make_runner(DiffusionKVCacheMode.DENSE_LEGACY)
runner._execute_request_list = Mock()
with pytest.raises(ValueError, match="dense_legacy.*must not carry"):
runner.execute_model(
SimpleNamespace(request_id="req-0"),
diffusion_kv_metadata=make_metadata(),
)
runner._execute_request_list.assert_not_called()
def test_paged_request_rejects_mismatched_metadata_identity() -> None:
runner = make_runner(DiffusionKVCacheMode.PAGED_SCHEDULER)
runner._execute_request_list = Mock()
with pytest.raises(ValueError, match="request mismatch"):
runner.execute_model(
SimpleNamespace(request_id="req-0"),
diffusion_kv_metadata=make_metadata("stale"),
)
runner._execute_request_list.assert_not_called()
def test_request_rpc_rejects_envelope_request_identity_mismatch() -> None:
executor, calls = make_executor()
req = SimpleNamespace(request_id="actual-request-id")
new_req = NewRequestData(
request_id="envelope-id",
req=req,
diffusion_kv_metadata=make_metadata("envelope-id"),
)
with pytest.raises(ValueError, match="request identity mismatch"):
executor.execute_request(make_scheduler_output(new_req))
assert calls == []
def test_batch_path_allows_missing_metadata_before_forward() -> None:
runner = make_runner(DiffusionKVCacheMode.PAGED_SCHEDULER)
expected_output = object()
runner._execute_request_list = Mock(return_value=expected_output)
new_req = NewRequestData(
request_id="req-0",
req=SimpleNamespace(request_id="req-0"),
)
assert runner.execute_model_batch(make_scheduler_output(new_req), runner.od_config) is expected_output
runner._execute_request_list.assert_called_once()
def test_batch_path_rejects_envelope_request_identity_mismatch() -> None:
runner = make_runner(DiffusionKVCacheMode.PAGED_SCHEDULER)
runner._execute_request_list = Mock()
new_req = NewRequestData(
request_id="envelope-id",
req=SimpleNamespace(request_id="actual-request-id"),
diffusion_kv_metadata=make_metadata("envelope-id"),
)
with pytest.raises(ValueError, match="request identity mismatch"):
runner.execute_model_batch(make_scheduler_output(new_req), runner.od_config)
runner._execute_request_list.assert_not_called()
def test_step_path_allows_missing_metadata_before_step_support_check() -> None:
runner = make_runner(DiffusionKVCacheMode.PAGED_SCHEDULER)
runner.pipeline = object()
runner._supports_step_mode = Mock(return_value=False)
new_req = NewRequestData(
request_id="req-0",
req=SimpleNamespace(request_id="req-0"),
)
with pytest.raises(ValueError, match="does not support step execution"):
runner.execute_stepwise(make_scheduler_output(new_req))
runner._supports_step_mode.assert_called_once()
def test_step_path_rejects_envelope_request_identity_mismatch() -> None:
runner = make_runner(DiffusionKVCacheMode.PAGED_SCHEDULER)
runner.pipeline = object()
runner._supports_step_mode = Mock()
new_req = NewRequestData(
request_id="envelope-id",
req=SimpleNamespace(request_id="actual-request-id"),
diffusion_kv_metadata=make_metadata("envelope-id"),
)
with pytest.raises(ValueError, match="request identity mismatch"):
runner.execute_stepwise(make_scheduler_output(new_req))
runner._supports_step_mode.assert_not_called()
def test_dense_request_rpc_keeps_legacy_positional_shape() -> None:
executor, calls = make_executor()
prepared_layout = object()
req = SimpleNamespace(request_id="req-0", prepared_layout=prepared_layout)
new_req = NewRequestData(request_id="req-0", req=req)
result = executor.execute_request(make_scheduler_output(new_req))
assert result.request_ids == ["req-0"]
assert calls == [
(
"execute_model",
(req, executor.od_config, None),
0,
True,
)
]
assert calls[0][1][0].prepared_layout is prepared_layout
def test_request_rpc_sends_attached_metadata_for_only_that_request() -> None:
executor, calls = make_executor()
req_0 = SimpleNamespace(request_id="req-0")
req_1 = SimpleNamespace(request_id="req-1")
metadata_0 = make_metadata("req-0")
metadata_1 = make_metadata("req-1")
new_reqs = (
NewRequestData(request_id="req-0", req=req_0, diffusion_kv_metadata=metadata_0),
NewRequestData(request_id="req-1", req=req_1, diffusion_kv_metadata=metadata_1),
)
result = executor.execute_request(make_scheduler_output(*new_reqs))
assert result.request_ids == ["req-0", "req-1"]
assert [call[1] for call in calls] == [
(req_0, executor.od_config, None, metadata_0),
(req_1, executor.od_config, None, metadata_1),
]
def test_worker_wrapper_only_extends_paged_request_rpc() -> None:
calls: list[tuple] = []
class Worker:
def execute_model(self, req, od_config, **kwargs):
calls.append((req, od_config, kwargs))
return DiffusionOutput(output=None)
wrapper = object.__new__(WorkerWrapperBase)
wrapper.worker = Worker()
req = SimpleNamespace(request_id="req-0")
od_config = SimpleNamespace()
metadata = make_metadata()
wrapper.execute_model(req, od_config)
wrapper.execute_model(req, od_config, diffusion_kv_metadata=metadata)
assert calls == [
(req, od_config, {"kv_prefetch_job": None}),
(
req,
od_config,
{
"kv_prefetch_job": None,
"diffusion_kv_metadata": metadata,
},
),
]
def test_dense_worker_call_does_not_extend_model_runner_signature() -> None:
calls: list[tuple] = []
class DenseModelRunner:
def execute_model(self, req, kv_prefetch_job=None):
calls.append((req, kv_prefetch_job))
return DiffusionOutput(output=None)
worker = object.__new__(DiffusionWorker)
worker.model_runner = DenseModelRunner()
worker.lora_manager = None
worker._get_profiler = lambda: None
req = SimpleNamespace(request_id="req-0")
worker.execute_model(req, SimpleNamespace())
assert calls == [(req, None)]
@@ -0,0 +1,266 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import json
import numpy as np
import pytest
import torch
from PIL import Image
from vllm_omni.diffusion.models.longcat_video.longcat_video_avatar_transformer import (
LongCatVideoAvatarTransformer3DModel,
_read_config,
replace_linear_with_quantized,
)
from vllm_omni.diffusion.models.longcat_video.pipeline_longcat_video_avatar import (
_avatar_model_allow_patterns,
_build_multi_speaker_ref_target_masks,
_default_at2v_shape,
_infer_asset_root_from_path,
_prepare_multi_speaker_audio_arrays,
_resolve_num_segments,
prepare_longcat_video_avatar_model_for_omni,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
@pytest.mark.parametrize(
("resolution", "expected_shape"),
[
("480p", (480, 832)),
("720p", (736, 1248)),
],
)
def test_longcat_video_avatar_at2v_default_shape_uses_resolution_bucket(
resolution: str,
expected_shape: tuple[int, int],
):
assert _default_at2v_shape(resolution) == expected_shape
@pytest.mark.parametrize(
("use_int8", "expected_weight_dir", "unexpected_weight_dir"),
[
(True, "base_model_int8/*", "base_model/*"),
(False, "base_model/*", "base_model_int8/*"),
],
)
def test_longcat_video_avatar_allow_patterns_download_one_weight_set(
use_int8: bool,
expected_weight_dir: str,
unexpected_weight_dir: str,
):
allow_patterns = _avatar_model_allow_patterns(use_int8)
assert expected_weight_dir in allow_patterns
assert unexpected_weight_dir not in allow_patterns
assert "whisper-large-v3/*" not in allow_patterns
assert "whisper-large-v3/model.safetensors" in allow_patterns
assert "whisper-large-v3/pytorch_model.bin" not in allow_patterns
assert "whisper-large-v3/flax_model.msgpack" not in allow_patterns
assert "vocal_separator/*" in allow_patterns
def test_longcat_video_avatar_prepare_model_adds_omni_metadata(tmp_path):
model_dir = tmp_path / "LongCat-Video-Avatar-1.5"
base_model_dir = model_dir / "base_model_int8"
base_model_dir.mkdir(parents=True)
(base_model_dir / "config.json").write_text('{"model_type": "longcat_avatar"}', encoding="utf-8")
(model_dir / "model_index.json").write_text("{}", encoding="utf-8")
prepared = prepare_longcat_video_avatar_model_for_omni(str(model_dir), use_int8=True)
assert prepared == str(model_dir)
model_index = json.loads((model_dir / "model_index.json").read_text(encoding="utf-8"))
assert model_index["_class_name"] == "LongCatVideoAvatarPipeline"
assert model_index["_diffusers_version"] == "0.38.0"
transformer_config = json.loads((model_dir / "transformer" / "config.json").read_text(encoding="utf-8"))
assert transformer_config == {"model_type": "longcat_avatar"}
def test_longcat_video_avatar_infers_repo_root_from_official_asset_path(tmp_path):
input_json = tmp_path / "assets" / "avatar" / "single_example_1.json"
input_json.parent.mkdir(parents=True)
input_json.write_text("{}", encoding="utf-8")
assert _infer_asset_root_from_path(input_json) == tmp_path
def test_longcat_video_avatar_falls_back_to_json_parent_without_official_layout(tmp_path):
input_json = tmp_path / "single_example_1.json"
input_json.write_text("{}", encoding="utf-8")
assert _infer_asset_root_from_path(input_json) == tmp_path
@pytest.mark.parametrize(
("value", "audio_duration", "expected"),
[
(None, 26.6, 1),
("1", 26.6, 1),
(3, 26.6, 3),
("auto", 3.0, 1),
("auto", 23.8, 8),
("auto", 26.6, 9),
],
)
def test_longcat_video_avatar_resolve_num_segments(
value,
audio_duration: float,
expected: int,
):
assert (
_resolve_num_segments(
value,
audio_duration=audio_duration,
num_frames=93,
num_cond_frames=13,
save_fps=25,
)
== expected
)
def test_longcat_video_avatar_read_config_filters_non_constructor_metadata(tmp_path):
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"_class_name": "LongCatVideoAvatarTransformer3DModel",
"architectures": ["LongCatVideoAvatarTransformer3DModel"],
"_diffusers_version": "0.35.1",
"model_max_length": 512,
"hidden_size": 4096,
"depth": 48,
"num_heads": 32,
}
),
encoding="utf-8",
)
config = _read_config(config_path)
assert config == {
"hidden_size": 4096,
"depth": 48,
"num_heads": 32,
}
def test_longcat_video_avatar_load_weights_supports_int8_buffers():
model = LongCatVideoAvatarTransformer3DModel(
hidden_size=4,
depth=0,
num_heads=1,
caption_channels=4,
intermediate_dim=4,
output_dim=4,
audio_channel=4,
context_tokens=1,
)
replace_linear_with_quantized(model)
buffer_name = "t_embedder.mlp.0.weight_int8"
buffers = dict(model.named_buffers())
loaded_weight = torch.ones_like(buffers[buffer_name])
loaded_params = model.load_weights([(buffer_name, loaded_weight)])
assert buffer_name in loaded_params
assert torch.equal(dict(model.named_buffers())[buffer_name], loaded_weight)
def test_longcat_video_avatar_multi_speaker_para_audio_arrays_are_aligned():
left = np.ones(3, dtype=np.float32)
right = np.ones(5, dtype=np.float32) * 2
left_out, right_out = _prepare_multi_speaker_audio_arrays(
[left, right],
audio_type="para",
generate_duration=0.5,
sample_rate=10,
)
assert left_out.tolist() == [1, 1, 1, 0, 0]
assert right_out.tolist() == [2, 2, 2, 2, 2]
def test_longcat_video_avatar_multi_speaker_add_audio_arrays_are_sequential():
left = np.ones(3, dtype=np.float32)
right = np.ones(2, dtype=np.float32) * 2
left_out, right_out = _prepare_multi_speaker_audio_arrays(
[left, right],
audio_type="add",
generate_duration=0.1,
sample_rate=10,
)
assert left_out.tolist() == [1, 1, 1, 0, 0]
assert right_out.tolist() == [0, 0, 0, 2, 2]
def test_longcat_video_avatar_multi_speaker_masks_use_explicit_bboxes():
image = Image.new("RGB", (8, 6))
masks, use_background = _build_multi_speaker_ref_target_masks(
image,
{
"person1": [1, 1, 4, 3],
"person2": [2, 4, 5, 7],
},
)
assert not use_background
assert masks.shape == (3, 6, 8)
assert masks[0, 1:4, 1:3].sum() == 6
assert masks[1, 2:5, 4:7].sum() == 9
assert masks[2, 0, 0] == 1
assert masks[2, 2, 2] == 0
def test_longcat_video_avatar_multi_speaker_masks_default_to_left_right_split():
image = Image.new("RGB", (10, 8))
masks, use_background = _build_multi_speaker_ref_target_masks(image, None)
assert not use_background
assert masks.shape == (3, 8, 10)
assert masks[0, :, :5].sum() > 0
assert masks[0, :, 5:].sum() == 0
assert masks[1, :, :5].sum() == 0
assert masks[1, :, 5:].sum() > 0
def test_longcat_video_avatar_transformer_accepts_multi_speaker_masks():
model = LongCatVideoAvatarTransformer3DModel(
hidden_size=4,
depth=0,
num_heads=1,
caption_channels=4,
intermediate_dim=4,
output_dim=4,
audio_channel=4,
context_tokens=1,
)
hidden_states = torch.randn(1, 16, 5, 4, 4)
timestep = torch.zeros(1, 5)
encoder_hidden_states = torch.randn(1, 1, 4, 4)
audio_embs = torch.randn(2, 5, 5, 12, 4)
ref_target_masks = torch.zeros(3, 4, 4)
ref_target_masks[0, :, :2] = 1
ref_target_masks[1, :, 2:] = 1
ref_target_masks[2] = torch.where(ref_target_masks[0] + ref_target_masks[1] > 0, 0, 1)
output = model(
hidden_states=hidden_states,
timestep=timestep,
encoder_hidden_states=encoder_hidden_states,
num_cond_latents=1,
audio_embs=audio_embs,
ref_target_masks=ref_target_masks,
)
assert output.shape == hidden_states.shape
@@ -484,6 +484,182 @@ def test_shifted_sigma_schedule_matches_reference_values():
)
def test_base_schedule_overrides_the_uniform_sigma_positions():
from vllm_omni.diffusion.models.minimax_h3.time_request import (
minimax_h3_time_shift_sigmas,
)
base_schedule = [1.0, 0.7, 0.4, 0.15, 0.0]
video = minimax_h3_time_shift_sigmas(
num_steps=len(base_schedule),
shift_scale=12.0,
base_schedule=base_schedule,
)
audio = minimax_h3_time_shift_sigmas(
num_steps=len(base_schedule),
shift_scale=3.0,
base_schedule=base_schedule,
)
assert video == pytest.approx([1.0, 0.9655172, 0.8888889, 0.6792453, 0.0], abs=1e-6)
assert audio == pytest.approx([1.0, 0.875, 0.6666667, 0.3461539, 0.0], abs=1e-6)
assert video != pytest.approx(
minimax_h3_time_shift_sigmas(num_steps=len(base_schedule), shift_scale=12.0),
abs=1e-6,
)
@pytest.mark.parametrize(
"base_schedule",
[
[0.9, 0.5, 0.0],
[1.0, 0.5, 0.1],
[1.0, 0.5, 0.5, 0.0],
[1.0],
[],
[1.0, float("nan"), 0.4, 0.0],
[1.0, float("inf"), 0.4, 0.0],
],
)
def test_base_schedule_rejects_malformed_positions(base_schedule):
from vllm_omni.diffusion.models.minimax_h3.time_request import (
minimax_h3_time_shift_sigmas,
)
with pytest.raises(ValueError):
minimax_h3_time_shift_sigmas(
num_steps=max(len(base_schedule), 1),
shift_scale=12.0,
base_schedule=base_schedule,
)
def _distilled_pipeline(diffuse_calls, base_schedule_by_partition):
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
from vllm_omni.diffusion.sched import DMD2SigmaSchedule
schedules = {
partition: None if positions is None else DMD2SigmaSchedule.from_positions(positions)
for partition, positions in base_schedule_by_partition.items()
}
pipeline = object.__new__(MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
pipeline.partition = "combined"
pipeline.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"})
pipeline.default_video_shift = 12.0
pipeline.default_audio_shift = 3.0
pipeline.device = torch.device("cpu")
pipeline.od_config = SimpleNamespace()
pipeline._base_schedule_by_partition = schedules
pipeline._quality_policy = Mock()
pipeline._quality_policy.resolve.return_value = SimpleNamespace(cache_dit=None)
pipeline._cache_dit_runtime = SimpleNamespace(prepare=lambda spec: None)
pipeline.encode_prompt = Mock(
return_value=(
torch.ones(1, 2),
torch.ones(1, dtype=torch.long),
)
)
def diffuse(**kwargs):
diffuse_calls.append(kwargs)
return torch.zeros(1), torch.zeros(1)
pipeline.diffuse = diffuse
pipeline.decode = Mock(return_value=(torch.zeros(1), torch.zeros(1)))
return pipeline
def _t2va_batch(num_inference_steps=None):
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
sampling = OmniDiffusionSamplingParams(
quality="lossless",
width=1344,
height=768,
fps=24,
num_frames=124,
num_inference_steps=num_inference_steps,
extra_args={"task": "t2va", "aspect_ratio": "16:9"},
)
return DiffusionRequestBatch(
[
OmniDiffusionRequest(
prompt="distilled schedule",
sampling_params=sampling,
request_id="distilled",
)
]
)
def test_base_schedule_is_scoped_to_the_serving_partition():
"""A distilled FL2VA must not drag a regular Ref2VA onto its step count."""
distilled = [1.0, 0.7, 0.4, 0.15, 0.0]
pipeline = _distilled_pipeline([], {"fl2va": distilled, "ref2va": None})
assert pipeline._base_schedule_for_task("t2va").base_schedule == tuple(distilled)
assert pipeline._base_schedule_for_task("fl2va").base_schedule == tuple(distilled)
assert pipeline._base_schedule_for_task("ref2va") is None
def test_partially_constructed_pipeline_falls_back_to_the_uniform_schedule():
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
assert pipeline._base_schedule_for_task("t2va") is None
def test_distilled_forward_reports_denoising_steps_not_sigma_boundaries():
diffuse_calls = []
base_schedule = [1.0, 0.7, 0.4, 0.15, 0.0]
pipeline = _distilled_pipeline(diffuse_calls, {"fl2va": base_schedule, "ref2va": None})
pipeline.forward(_t2va_batch())
assert diffuse_calls[0]["base_schedule"] == tuple(base_schedule)
# Five boundaries describe four denoising steps.
assert diffuse_calls[0]["num_steps"] == 4
pipeline._quality_policy.resolve.assert_called_once_with(
quality="lossless",
num_inference_steps=4,
extra_args={"task": "t2va", "aspect_ratio": "16:9"},
)
def test_distilled_forward_accepts_the_matching_explicit_step_count():
diffuse_calls = []
pipeline = _distilled_pipeline(diffuse_calls, {"fl2va": [1.0, 0.7, 0.4, 0.15, 0.0], "ref2va": None})
pipeline.forward(_t2va_batch(num_inference_steps=4))
assert diffuse_calls[0]["num_steps"] == 4
def test_distilled_forward_rejects_a_mismatched_explicit_step_count():
from vllm_omni.errors import OmniClientError
pipeline = _distilled_pipeline([], {"fl2va": [1.0, 0.7, 0.4, 0.15, 0.0], "ref2va": None})
with pytest.raises(OmniClientError, match="must be 4 or omitted"):
pipeline.forward(_t2va_batch(num_inference_steps=50))
def test_absent_base_schedule_key_differs_from_an_empty_list():
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
_read_base_schedule,
)
assert _read_base_schedule({}) is None
assert _read_base_schedule({"base_schedule": [1.0, 0.5, 0.0]}).base_schedule == (1.0, 0.5, 0.0)
with pytest.raises(ValueError):
_read_base_schedule({"base_schedule": []})
def test_cudnn_packed_attention_uses_python_length_without_padding_mask():
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
MiniMaxH3Attention,
@@ -1246,8 +1422,38 @@ def test_r7_r8_ref2va_video_segment_matrix(monkeypatch, tmp_path, case, start_ti
)
assert prepared[0]["duration_seconds"] == pytest.approx(expected_duration)
assert prepared[0]["audio_duration_seconds"] == pytest.approx(min(expected_duration, 209 / 24.0))
assert prepared[0]["start_time_seconds"] == pytest.approx(start_time or 0.0)
assert transcode_calls[0][1]["duration_seconds"] == pytest.approx(expected_duration)
assert transcode_calls[0][1]["target_frame_count"] == 209
def test_ref2va_qwen_sampling_decodes_prepared_video_once(monkeypatch):
from vllm_omni.diffusion.models.minimax_h3 import reference_video as reference_video_module
decoded = np.arange(25 * 2 * 2 * 3, dtype=np.uint8).reshape(25, 2, 2, 3)
load_calls = []
monkeypatch.setattr(
reference_video_module,
"_probe_video",
lambda _path: {"frame_count": len(decoded)},
)
monkeypatch.setattr(
reference_video_module,
"load_video_frames",
lambda path: load_calls.append(path) or decoded,
)
sampled = reference_video_module.sample_reference_video_frames("prepared.mp4")
assert load_calls == ["prepared.mp4"]
for frame, expected in zip(
sampled["frames"],
(decoded[0], decoded[12], decoded[24]),
strict=True,
):
np.testing.assert_array_equal(frame, expected)
assert sampled["block_timestamps"] == [0.25, 1.0]
def test_ref2va_two_video_recipe_tolerates_container_rounding(monkeypatch, tmp_path):
@@ -1487,6 +1693,47 @@ def test_g4_standalone_audio_duration_and_total_duration_contract():
)
def test_ref2va_audio_duration_validation_precedes_rank_branch(monkeypatch):
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as pipeline_module
pipeline = object.__new__(pipeline_module.MiniMaxH3Pipeline)
pipeline.device = torch.device("cpu")
monkeypatch.setattr(pipeline_module, "_dit_rank_world", lambda: (None, 1, 2))
waveform = torch.zeros(1, 10)
with pytest.raises(ValueError, match="max_duration_seconds must be positive"):
pipeline._encode_audio_conditions(
[(waveform, 10)],
max_duration_seconds=0,
)
def test_ref2va_standalone_audio_condition_is_bounded_to_output_duration():
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
pipeline.device = torch.device("cpu")
observed = []
class FakeAudioVAE:
def encode_waveform(self, waveform, sample_rate):
observed.append((waveform.clone(), sample_rate))
return waveform, waveform.shape[-1]
pipeline.audio_vae = FakeAudioVAE()
waveform = torch.arange(40, dtype=torch.float32).reshape(1, 40)
encoded, lengths = pipeline._encode_audio_conditions(
[(waveform, 10)],
max_duration_seconds=2.5,
)
assert observed[0][0].shape == (1, 25)
assert observed[0][1] == 10
assert encoded.shape == (1, 25)
assert lengths == [25]
@pytest.mark.parametrize(
("field", "value", "message"),
[
@@ -0,0 +1,52 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
from vllm_omni.diffusion.sched import DMD2SigmaSchedule
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_step_count_is_intervals_not_boundaries():
schedule = DMD2SigmaSchedule.from_positions([1.0, 0.7, 0.4, 0.15, 0.0])
assert schedule.num_inference_steps == 4
@pytest.mark.parametrize(
"positions",
[
[],
[1.0],
[0.9, 0.5, 0.0],
[1.0, 0.5, 0.1],
[1.0, 0.4, 0.6, 0.0],
[1.0, 0.5, 0.5, 0.0],
[1.0, float("nan"), 0.4, 0.0],
[1.0, float("inf"), 0.4, 0.0],
],
)
def test_malformed_positions_are_rejected(positions):
with pytest.raises(ValueError):
DMD2SigmaSchedule.from_positions(positions)
def test_one_base_schedule_drives_several_shift_scales():
schedule = DMD2SigmaSchedule.from_positions([1.0, 0.7, 0.4, 0.15, 0.0])
assert schedule.shifted_sigmas(12.0) == pytest.approx([1.0, 0.9655172, 0.8888889, 0.6792453, 0.0], abs=1e-6)
assert schedule.shifted_sigmas(3.0) == pytest.approx([1.0, 0.875, 0.6666667, 0.3461539, 0.0], abs=1e-6)
def test_non_positive_shift_scale_is_rejected():
schedule = DMD2SigmaSchedule.from_positions([1.0, 0.5, 0.0])
with pytest.raises(ValueError):
schedule.shifted_sigmas(0.0)
def test_absent_metadata_key_means_undistilled_but_empty_is_malformed():
assert DMD2SigmaSchedule.from_metadata({}) is None
assert DMD2SigmaSchedule.from_metadata({"base_schedule": [1.0, 0.5, 0.0]}).base_schedule == (1.0, 0.5, 0.0)
with pytest.raises(ValueError):
DMD2SigmaSchedule.from_metadata({"base_schedule": []})
@@ -123,6 +123,47 @@ def test_init_accepts_custom_scheduler(monkeypatch: pytest.MonkeyPatch) -> None:
assert engine.scheduler is custom_scheduler
def test_scheduler_initialization_failure_closes_scheduler_and_executor(monkeypatch: pytest.MonkeyPatch) -> None:
od_config = SimpleNamespace(
custom_pipeline_args=None,
model_class_name="SchedulerInitializationFailurePipeline",
streaming_output=False,
)
initialization_error = RuntimeError("Scheduler initialization failed")
custom_scheduler = SimpleNamespace(
initialize=Mock(side_effect=initialization_error),
close=Mock(),
)
fake_executor = SimpleNamespace(
shutdown=Mock(),
)
monkeypatch.setattr(
"vllm_omni.diffusion.diffusion_engine.get_diffusion_post_process_func",
lambda *args, **kwargs: None,
)
monkeypatch.setattr(
"vllm_omni.diffusion.diffusion_engine.get_diffusion_pre_process_func",
lambda *args, **kwargs: None,
)
monkeypatch.setattr(
"vllm_omni.diffusion.diffusion_engine.DiffusionExecutor.get_class",
lambda *args, **kwargs: Mock(return_value=fake_executor),
)
monkeypatch.setattr(
"vllm_omni.diffusion.diffusion_engine.supports_request_batch",
lambda *args, **kwargs: False,
)
with pytest.raises(RuntimeError) as exc_info:
DiffusionEngine(od_config, scheduler=custom_scheduler)
assert exc_info.value is initialization_error
custom_scheduler.initialize.assert_called_once_with(od_config)
custom_scheduler.close.assert_called_once_with()
fake_executor.shutdown.assert_called_once_with()
@pytest.mark.asyncio
async def test_step_compatibility_wrapper_returns_final_batch() -> None:
engine = _make_engine()
@@ -341,7 +341,7 @@ def test_execute_stepwise_streaming_returns_chunks_at_boundaries(monkeypatch):
monkeypatch.setattr(model_runner_module.current_omni_platform, "max_memory_allocated", lambda: 0)
scheduler_output = SimpleNamespace(
finished_req_ids=set(),
scheduled_new_reqs=[SimpleNamespace(request_id="req", req=req)],
scheduled_new_reqs=[SimpleNamespace(request_id="req", req=req, diffusion_kv_metadata=None)],
scheduled_cached_reqs=SimpleNamespace(request_ids=[]),
)
@@ -381,7 +381,7 @@ def test_execute_stepwise_streaming_decodes_final_only_pipeline(monkeypatch):
monkeypatch.setattr(model_runner_module.current_omni_platform, "max_memory_allocated", lambda: 0)
scheduler_output = SimpleNamespace(
finished_req_ids=set(),
scheduled_new_reqs=[SimpleNamespace(request_id="req", req=req)],
scheduled_new_reqs=[SimpleNamespace(request_id="req", req=req, diffusion_kv_metadata=None)],
scheduled_cached_reqs=SimpleNamespace(request_ids=[]),
)
@@ -561,7 +561,11 @@ def _make_scheduler_output(num_reqs: int):
reqs = [_make_request() for _ in range(num_reqs)]
for i, req in enumerate(reqs):
req.request_id = f"req-{i}"
return SimpleNamespace(scheduled_new_reqs=[SimpleNamespace(req=req) for req in reqs])
return SimpleNamespace(
scheduled_new_reqs=[
SimpleNamespace(request_id=req.request_id, req=req, diffusion_kv_metadata=None) for req in reqs
],
)
@pytest.mark.core_model
@@ -0,0 +1 @@
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,211 @@
# SPDX-License-Identifier: Apache-2.0
"""MiniCPM-o 4.5 Daily-Omni + Seed-TTS accuracy regression coverage.
Daily-Omni settings follow the MiniCPM interleaved AV recipe that reaches
~78% overall accuracy on Daily-Omni (``minicpm-interleave``, ``temperature=0``,
text modalities with ``use_tts_template``, and server ``--interleave-mm-strings``
+ 1fps / 128-frame media-io kwargs, also pinned in ``minicpmo_4_5.yaml``).
"""
from __future__ import annotations
import importlib.util
import json
import os
from pathlib import Path
import pytest
from tests.e2e.accuracy.qwen3_omni import run_qwen_omni_acc_benchmark as _acc_bench
from tests.e2e.accuracy.qwen3_omni.qwen3_omni_acc_bench_core import (
build_acc_benchmark_cli_argv,
find_vllm_cli,
)
from tests.e2e.online_serving.helpers.minicpmo_4_5_duplex import SERVER_PARAMS as DUPLEX_TEST_PARAMS
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniServerParams
from tests.helpers.stage_config import get_deploy_config_path
_MODEL = os.environ.get("VLLM_TEST_MINICPMO_4_5_MODEL", "openbmb/MiniCPM-o-4_5")
_DEPLOY_CONFIG = get_deploy_config_path("minicpmo_4_5.yaml")
_RESULT_DIR = Path(
os.environ.get(
"ACC_BENCH_RESULT_DIR",
str(Path(__file__).resolve().parent / "results"),
)
)
_MIN_DAILY_OMNI_ACCURACY = 0.78
_MAX_SEED_TTS_MEAN_WER = 0.05
# Match the validated Daily-Omni client body from daily_omni_bench.sh.
_DAILY_EXTRA_BODY = {
"modalities": ["text"],
"chat_template_kwargs": {
"enable_thinking": False,
"use_tts_template": True,
},
}
_SEED_EXTRA_BODY = {
"modalities": ["text", "audio"],
"chat_template_kwargs": {
"enable_thinking": False,
"use_tts_template": True,
},
}
# Server flags required for MiniCPM interleaved image/audio packs (Daily-Omni only).
# Do not reuse these for Seed-TTS: ``--interleave-mm-strings`` + TTS ref_audio can trip
# msgspec ValidationError and kill the orchestrator mid-suite.
# Also pinned in ``minicpmo_4_5.yaml`` stage 0 so they survive config-path routing.
_DAILY_OMNI_SERVER_ARGS = [
"--trust-remote-code",
"--interleave-mm-strings",
"--media-io-kwargs",
'{"video":{"fps":1,"num_frames":128}}',
]
_SEED_TTS_SERVER_ARGS = [
"--trust-remote-code",
]
pytestmark = [pytest.mark.full_model, pytest.mark.omni]
# Evaluated at setup time, before the ``omni_server`` fixture boots a server. An
# ``importorskip`` inside the test body would skip only after paying several minutes
# of startup, and the resulting skip is easy to miss in a nightly log.
_missing_daily_omni_deps = [name for name in ("datasets", "huggingface_hub") if importlib.util.find_spec(name) is None]
requires_daily_omni_deps = pytest.mark.skipif(
bool(_missing_daily_omni_deps),
reason=f"Daily-Omni accuracy bench needs {', '.join(_missing_daily_omni_deps)}",
)
daily_test_params = [
OmniServerParams(
model=_MODEL,
stage_config_path=_DEPLOY_CONFIG,
server_args=list(_DAILY_OMNI_SERVER_ARGS),
)
]
seed_test_params = [
OmniServerParams(
model=_MODEL,
stage_config_path=_DEPLOY_CONFIG,
server_args=list(_SEED_TTS_SERVER_ARGS),
)
]
def _require_vllm_cli() -> None:
try:
find_vllm_cli()
except FileNotFoundError as exc:
pytest.skip(str(exc))
@pytest.fixture(autouse=True)
def _inline_daily_omni_media(monkeypatch: pytest.MonkeyPatch) -> None:
"""Inline cached Hub/local videos because the test server has no local-media allowlist."""
original = _acc_bench.daily_omni_bench_argv
def _wrapped() -> list[str]:
argv = list(original())
if "--daily-omni-inline-local-video" not in argv:
argv.append("--daily-omni-inline-local-video")
return argv
monkeypatch.setattr(_acc_bench, "daily_omni_bench_argv", _wrapped)
monkeypatch.setenv("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
@requires_daily_omni_deps
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", daily_test_params, indirect=True)
def test_minicpmo_4_5_daily_omni_accuracy_bench(omni_server) -> None:
_require_vllm_cli()
argv = build_acc_benchmark_cli_argv(
omni_server,
skip_seed=True,
skip_daily=False,
)
argv.extend(
[
"--result-dir",
str(_RESULT_DIR),
"--min-daily-omni-accuracy",
str(_MIN_DAILY_OMNI_ACCURACY),
"--temperature",
"0",
"--output-len",
"512",
"--daily-omni-input-mode",
"all",
"--daily-omni-pack-mode",
"minicpm-interleave",
"--daily-extra-body-json",
json.dumps(_DAILY_EXTRA_BODY, separators=(",", ":")),
"--trust-remote-code",
]
)
assert _acc_bench.run_acc_benchmark(_acc_bench.parse_acc_benchmark_args(argv)) == 0
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", seed_test_params, indirect=True)
def test_minicpmo_4_5_seed_tts_wer_bench(omni_server) -> None:
_require_vllm_cli()
pytest.importorskip("huggingface_hub")
argv = build_acc_benchmark_cli_argv(
omni_server,
skip_seed=False,
skip_daily=True,
)
argv.extend(
[
"--result-dir",
str(_RESULT_DIR),
"--max-seed-tts-mean-wer",
str(_MAX_SEED_TTS_MEAN_WER),
"--seed-extra-body-json",
json.dumps(_SEED_EXTRA_BODY, separators=(",", ":")),
"--trust-remote-code",
]
)
assert _acc_bench.run_acc_benchmark(_acc_bench.parse_acc_benchmark_args(argv)) == 0
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.parametrize("omni_server", DUPLEX_TEST_PARAMS, indirect=True)
def test_minicpmo_4_5_duplex_seed_tts_wer_bench(omni_server) -> None:
"""Gate Seed-TTS WER through the explicit Realtime TTS contract."""
_require_vllm_cli()
pytest.importorskip("huggingface_hub")
argv = build_acc_benchmark_cli_argv(
omni_server,
skip_seed=False,
skip_daily=True,
num_prompts=50,
max_concurrency=1,
)
argv.extend(
[
"--result-dir",
str(_RESULT_DIR),
"--max-seed-tts-mean-wer",
str(_MAX_SEED_TTS_MEAN_WER),
"--seed-extra-body-json",
json.dumps({"save_duplex_request_metrics": True}, separators=(",", ":")),
"--seed-backend",
"openai-realtime-tts",
"--seed-endpoint",
"/v1/realtime",
"--seed-tts-turns-per-session",
"4",
"--trust-remote-code",
]
)
assert _acc_bench.run_acc_benchmark(_acc_bench.parse_acc_benchmark_args(argv)) == 0
@@ -0,0 +1,386 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import hashlib
import json
import os
import subprocess
from pathlib import Path
import pytest
import requests
import torch
from huggingface_hub import snapshot_download
from tests.e2e.accuracy.helpers import (
assert_video_metadata,
assert_video_similarity_metrics,
probe_binary,
probe_video,
reset_artifact_dir,
)
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniServer
pytestmark = [pytest.mark.benchmark, pytest.mark.diffusion, pytest.mark.full_model]
MODEL_REPO_ID = "MiniMaxAI/MiniMax-H3"
# The previously pinned revision was removed from the Hugging Face repository.
MODEL_REVISION = "main"
MODEL_ENV_VAR = "VLLM_TEST_MINIMAX_H3_FL2VA_MODEL"
REF2VA_MODEL_ENV_VAR = "VLLM_TEST_MINIMAX_H3_REF2VA_MODEL"
I2VA_REFERENCE_VIDEO_URL = "https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/main/assets/i2va.mp4"
I2VA_REFERENCE_VIDEO_SHA256 = "5a5d6e0fcdda7e6a5d98cb92d9e5cb4b6b0b44d6e1dfd5a370d16dfaa79b9ae6"
I2VA_IMAGE_URL = (
"https://cdn.hailuoai.com/prod/hailuo_demo/testsets/H3_AA_I2VA/gallery/"
"sr_v17_variants_seed42_43_20260724/inputs/4a3a90bf9100_KDmcbkhzYo5sjjxr9FqcVmWVnzb.png"
)
I2VA_IMAGE_SHA256 = "352e959e08b886406d3766cee25ebe7bbf679e6d53eeffbd1c607b8dc93e2068"
REF2VA_REFERENCE_VIDEO_URL = "https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/main/assets/r2va.mp4"
REF2VA_REFERENCE_VIDEO_SHA256 = "5c9d33dfc517438c59e93da7b3d0b2a62e3cf69bc2adb7d1b2d33d7d8df7161f"
REF2VA_INPUT_VIDEO_URL = (
"https://cdn.hailuoai.com/prod/hailuo_demo/testsets/h3_promo_eval_ref2va/"
"gallery/sr_v2p26_trio_seed42_20260724/inputs/"
"297573323635_00_%E8%A7%86%E9%A2%911_YnyRbxEwio_video_20260525_163755_1927e9d3.mp4"
)
REF2VA_INPUT_VIDEO_SHA256 = "d6edc20ac82aa980c9cbd0b05b371f60d12dc8d018492e571b42de0086819c41"
REF2VA_INPUT_AUDIO_URL = (
"https://cdn.hailuoai.com/prod/hailuo_demo/testsets/h3_promo_eval_ref2va/"
"gallery/sr_v2p26_trio_seed42_20260724/inputs/"
"f463d523c5ce_01_%E9%9F%B3%E9%A2%911_RSLcbpzJPo_6%E6%9C%885%E6%97%A5(1).mp3"
)
REF2VA_INPUT_AUDIO_SHA256 = "2a48d447b343b38a2c504d5281a0c97c688db0ba87931ed9282c598bd6752454"
I2VA_PROMPT = """For the target video, at 0.00 seconds into the target video, <Picture 1> (from [Shot 1]) is fully referenced.
integrated_multimodal_description: [Shot 1] This is a live-action, cinematic shot with a shallow depth of field. The camera holds a perfectly static shot throughout the entire eight-second duration, capturing a cozy family gathering in a traditional Japanese dining room. The scene opens with a large, intricately patterned blue and white ceramic bowl of ramen in the immediate foreground, rendered in crisp, sharp focus. The bowl sits on a smooth, polished long wooden table. Inside the bowl, a rich, oily golden-brown broth surrounds yellow wavy noodles, topped with two thick, round slices of chashu pork featuring visible fat marbling and a distinct spiral meat pattern. A generous mound of freshly chopped, bright green scallions rests in the center, and a crisp, dark green rectangular sheet of nori seaweed is tucked into the right edge. To the left of the bowl, a pair of light brown wooden chopsticks rests horizontally on a small, dark rectangular chopstick rest, near a small cylindrical ceramic teacup with blue painted patterns. On the right side of the table, a spherical paper lantern with a ribbed bamboo frame sits on a black wooden base. In the background, a large family of seven is gathered around the table, initially appearing as a soft, blurred presence. Behind them, traditional Japanese sliding shoji screens with wooden lattice frames are open, revealing a bright outdoor scene with lush green trees. Early in the clip, the thick, white steam rising from the hot ramen broth immediately intensifies, billowing upwards in thick, swirling clouds that dance continuously above the bowl. As the clip progresses into the middle seconds, the camera maintains its static position while the focus begins a deliberate, smooth shift deeper into the room. The foreground ramen bowl, its vibrant ingredients, and the rising steam gradually soften into a hazy, out-of-focus blur. Simultaneously, the family members in the background come into sharp, detailed clarity. The heavy steam continues to rise from the foreground, creating a dynamic, translucent veil between the camera and the family. With the focus now firmly locked on the background, the vibrant family dinner comes alive. The man in the dark navy blue long-sleeved shirt on the left leans forward, his mouth moving animatedly in a silent exchange. The young girl in the crisp white short-sleeved t-shirt beside him smiles brightly, looking toward the center of the table. The woman on the far left, wearing a soft light blue long-sleeved blouse, turns her head slightly, smiling gently. Across the table, the woman in the light grey button-down shirt smiles broadly, her eyes crinkling, as she rests her hands near her plate. The woman in the dark grey top further back uses her wooden chopsticks to pick up a small piece of food from a central ceramic dish filled with bright red pickled vegetables. The woman in the center back in the light grey sweater smiles gently, her hands clasped softly in front of her, observing the interaction. Throughout the remainder of the clip, the family continues their lively physical interaction, their mouths moving in continuous, silent cadences of conversation, while the thick, white steam from the blurred ramen bowl in the foreground never stops rising, adding a comforting atmosphere to the warm gathering.
overall_soundscape: The soundscape begins with a quiet room tone mixed with the faint, airy rustle of the thick steam billowing from the hot ramen bowl in the foreground, accompanied by the subtle, continuous hissing and bubbling of the rich broth. As the visual focus shifts deeper into the room, the physical sounds of the bustling family dinner become dominant in the foreground. The clear, sharp clinking of ceramic bowls and wooden chopsticks touching plates is clearly heard as the family members reach for food. This is followed by the faint, muffled thud of a cup being set down on the smooth wooden table, and the subtle, rhythmic rustle of cotton and wool clothing as the family members lean forward and gesture, perfectly capturing the lively, physical atmosphere of the shared meal.
non_diegetic_music: A gentle, heartwarming acoustic guitar melody plays softly in the background, accompanied by the subtle, resonant notes of a traditional Japanese koto. The music maintains a slow, comforting tempo that enhances the cozy, nostalgic, and joyful atmosphere of the family gathering."""
REF2VA_PROMPT = """subject_definitions:
<Subject 1> is the young man with short wavy blonde hair, wearing a bright pink suit jacket, matching pink trousers, an unbuttoned white shirt, and silver rings, holding a small black lamb in his arms in <Video 1>.
<Video 1> is the source video for the editing task.
<Audio 1> is the synchronized audio track of <Video 1>, providing the background music.
<Audio 2> is the voice timbre reference for <Subject 1>'s voice, containing a spoken male voiceover.
summary:
[video editing + audio reference + audio reuse] The target video is an edited version of <Video 1>. <Subject 1>, wearing a bright pink suit and holding a black lamb, stands in a grassy field with other white lambs in the background. The edit animates <Subject 1>'s face to speak the user-provided dialogue. <Audio 1> is partially reused as the continuous background music, while the target references the calm male voice timbre of <Audio 2> for <Subject 1>'s spoken lines.
retention_analysis:
<Subject 1> (appears in [Shot 1]): fully_preserved - the man retains his identity, wavy blonde hair, pink suit, white shirt, accessories, and the black lamb he holds, with his mouth newly animated to speak.
<Video 1> (source video editing): fully_preserved - the original camera framing, warm golden hour lighting, grassy hill setting, and background white lambs are maintained while the central character is edited.
<Audio 1>: partially_copy - the atmospheric background music from <Audio 1> is reused in the target video, mixed beneath the newly added spoken dialogue.
<Audio 2>: reference - the target audio references the male voice timbre from <Audio 2> to generate <Subject 1>'s spoken dialogue.
detailed_description:
The target video is in realistic photographic style.
[Shot 1] The shot begins from the source <Video 1>, showing <Subject 1>, a young man with short wavy blonde hair, wearing a bright pink suit jacket, matching pink trousers, and a casually unbuttoned white shirt. He stands confidently in a sunlit green pasture, gently holding a small black lamb securely in his arms. The warm, golden hour lighting casts soft shadows across his face and the bright pink fabric of his suit. Behind him, several white lambs stand and graze on the rolling grassy hill against a clear, pale blue sky. The atmospheric background music from <Audio 1> plays continuously throughout the scene. <Subject 1> physically speaks, his mouth movements naturally syncing to the new dialogue, with his voice timbre referencing the calm male delivery from <Audio 2>. Looking thoughtfully forward, <Subject 1> (S1) speaks softly, <d>[English] Follow the wind, live free.</d> As he delivers the line, he subtly shifts his weight, cradling the resting black lamb while the camera slowly pushes in. <Subject 1> (S1) continues his thought, <d>[English] Leave worries behind, enjoy the moment.</d> Exactly as his voice stops, his lips meet in a relaxed, peaceful smile, and his jaw ceases speaking motion. He then turns his gaze slightly away toward the horizon, gently stroking the black lamb's fleece with his fingers as the camera holds on this tranquil, sunlit state through the end of the video.
overall_soundscape:
The soundscape consists of the continuous, atmospheric background music from <Audio 1>, overlaid with the clear, calm male dialogue spoken by the main character, referencing the voice timbre of <Audio 2>.
non_diegetic_music:
The atmospheric, sustained background music from <Audio 1> is reused as the continuous score, playing quietly beneath the spoken dialogue.
"""
WIDTH = 1344
HEIGHT = 768
FPS = 24
I2VA_NUM_FRAMES = 192
REF2VA_NUM_FRAMES = 124
NUM_INFERENCE_STEPS = 50
FLOW_SHIFT = 12.0
AUDIO_FLOW_SHIFT = 3.0
I2VA_DURATION_SECONDS = 8.0
REF2VA_DURATION_SECONDS = 5.0
SEED = 0
SSIM_THRESHOLD = 0.97
PSNR_THRESHOLD = 34.0
REQUEST_TIMEOUT_SECONDS = 60 * 60
def _model_name(model_env_var: str = MODEL_ENV_VAR, component: str = "FL2VA") -> str:
configured = os.environ.get(model_env_var)
if configured:
return configured
snapshot_root = snapshot_download(
repo_id=MODEL_REPO_ID,
revision=MODEL_REVISION,
allow_patterns=[f"{component}/**"],
)
return str(Path(snapshot_root) / component)
def _server_args() -> list[str]:
return [
"--trust-remote-code",
"--num-gpus",
"4",
"--usp",
"4",
"--ring",
"1",
"--use-hsdp",
"--hsdp-shard-size",
"4",
"--text-encoder-tp-size",
"4",
"--vae-patch-parallel-size",
"4",
"--vae-parallel-mode",
"tile",
"--vae-use-tiling",
"--diffusion-attention-backend",
"FLASH_ATTN",
"--stage-init-timeout",
"1800",
"--init-timeout",
"1800",
]
def _download_reference_asset(
output_dir: Path,
*,
filename: str,
url: str,
sha256: str,
label: str,
) -> Path:
reference_path = output_dir / filename
download_url = url.replace("/blob/", "/resolve/", 1)
response = requests.get(download_url, timeout=120)
response.raise_for_status()
content = response.content
digest = hashlib.sha256(content).hexdigest()
assert digest == sha256, f"Official MiniMax H3 {label} reference changed: got {digest}, expected {sha256}"
reference_path.write_bytes(content)
return reference_path
def _probe_audio(path: Path) -> dict[str, str | int]:
probe_binary("ffprobe")
result = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-select_streams",
"a:0",
"-show_entries",
"stream=codec_name,sample_rate,channels",
"-of",
"json",
str(path),
],
capture_output=True,
text=True,
check=True,
)
streams = json.loads(result.stdout).get("streams", [])
assert len(streams) == 1, f"Expected one audio stream in {path}, got {streams}"
stream = streams[0]
return {
"codec_name": str(stream["codec_name"]),
"sample_rate": int(stream["sample_rate"]),
"channels": int(stream["channels"]),
}
def _assert_official_video(
generated_path: Path,
reference_path: Path,
*,
frame_count: int,
label: str,
ssim_threshold: float = SSIM_THRESHOLD,
psnr_threshold: float = PSNR_THRESHOLD,
) -> None:
reference_metadata = probe_video(reference_path)
generated_metadata = probe_video(generated_path)
assert reference_metadata == generated_metadata
assert_video_metadata(
generated_metadata,
width=WIDTH,
height=HEIGHT,
fps=FPS,
frame_count=frame_count,
)
expected_audio = {"codec_name": "aac", "sample_rate": 32000, "channels": 2}
assert _probe_audio(reference_path) == expected_audio
assert _probe_audio(generated_path) == expected_audio
assert_video_similarity_metrics(
label=label,
online_path=generated_path,
offline_path=reference_path,
ssim_threshold=ssim_threshold,
psnr_threshold=psnr_threshold,
)
@hardware_test(res={"cuda": "H100"}, num_cards=4)
def test_minimax_h3_i2va_matches_official_reference(
accuracy_artifact_root: Path,
) -> None:
if not torch.cuda.is_available():
pytest.skip("MiniMax H3 I2VA accuracy test requires CUDA.")
probe_binary("ffmpeg")
probe_binary("ffprobe")
output_dir = reset_artifact_dir(accuracy_artifact_root / "minimax_h3_i2va")
reference_path = _download_reference_asset(
output_dir,
filename="reference.mp4",
url=I2VA_REFERENCE_VIDEO_URL,
sha256=I2VA_REFERENCE_VIDEO_SHA256,
label="I2VA",
)
image_path = _download_reference_asset(
output_dir,
filename="input.png",
url=I2VA_IMAGE_URL,
sha256=I2VA_IMAGE_SHA256,
label="I2VA input image",
)
generated_path = output_dir / "vllm_omni.mp4"
server_env = {
"FLASHINFER_DISABLE_VERSION_CHECK": "1",
"VLLM_WORKER_MULTIPROC_METHOD": "spawn",
"VLLM_OMNI_VIDEO_SYNC_TIMEOUT": str(REQUEST_TIMEOUT_SECONDS),
"VLLM_OMNI_STORAGE_PATH": str(output_dir / "storage"),
}
request_data = {
"prompt": I2VA_PROMPT,
"width": str(WIDTH),
"height": str(HEIGHT),
"fps": str(FPS),
"num_inference_steps": str(NUM_INFERENCE_STEPS),
"flow_shift": str(FLOW_SHIFT),
"seed": str(SEED),
"extra_params": json.dumps(
{
"task": "fl2va",
"duration": I2VA_DURATION_SECONDS,
"aspect_ratio": "auto",
"frame_indices": [0],
"audio_flow_shift": AUDIO_FLOW_SHIFT,
}
),
}
with OmniServer(
_model_name(),
_server_args(),
env_dict=server_env,
use_omni=True,
) as server:
with image_path.open("rb") as image_file:
response = requests.post(
f"http://{server.host}:{server.port}/v1/videos/sync",
data=request_data,
files={"input_reference": ("input.png", image_file, "image/png")},
timeout=REQUEST_TIMEOUT_SECONDS,
)
response.raise_for_status()
assert response.headers["content-type"].startswith("video/mp4")
generated_path.write_bytes(response.content)
_assert_official_video(
generated_path,
reference_path,
frame_count=I2VA_NUM_FRAMES,
label="minimax_h3_i2va_official_reference",
)
@hardware_test(res={"cuda": "H100"}, num_cards=4)
def test_minimax_h3_ref2va_matches_official_reference(
accuracy_artifact_root: Path,
) -> None:
if not torch.cuda.is_available():
pytest.skip("MiniMax H3 Ref2VA accuracy test requires CUDA.")
probe_binary("ffmpeg")
probe_binary("ffprobe")
output_dir = reset_artifact_dir(accuracy_artifact_root / "minimax_h3_ref2va")
reference_path = _download_reference_asset(
output_dir,
filename="reference.mp4",
url=REF2VA_REFERENCE_VIDEO_URL,
sha256=REF2VA_REFERENCE_VIDEO_SHA256,
label="Ref2VA",
)
input_video_path = _download_reference_asset(
output_dir,
filename="input_video.mp4",
url=REF2VA_INPUT_VIDEO_URL,
sha256=REF2VA_INPUT_VIDEO_SHA256,
label="Ref2VA input video",
)
input_audio_path = _download_reference_asset(
output_dir,
filename="input_audio.mp3",
url=REF2VA_INPUT_AUDIO_URL,
sha256=REF2VA_INPUT_AUDIO_SHA256,
label="Ref2VA input audio",
)
generated_path = output_dir / "vllm_omni.mp4"
server_env = {
"FLASHINFER_DISABLE_VERSION_CHECK": "1",
"VLLM_WORKER_MULTIPROC_METHOD": "spawn",
"VLLM_OMNI_VIDEO_SYNC_TIMEOUT": str(REQUEST_TIMEOUT_SECONDS),
"VLLM_OMNI_STORAGE_PATH": str(output_dir / "storage"),
}
request_data = {
"prompt": REF2VA_PROMPT,
"width": str(WIDTH),
"height": str(HEIGHT),
"fps": str(FPS),
"num_inference_steps": str(NUM_INFERENCE_STEPS),
"flow_shift": str(FLOW_SHIFT),
"seed": str(SEED),
"extra_params": json.dumps(
{
"task": "ref2va",
"duration": REF2VA_DURATION_SECONDS,
"aspect_ratio": "auto",
"audio_flow_shift": AUDIO_FLOW_SHIFT,
}
),
}
with OmniServer(
_model_name(REF2VA_MODEL_ENV_VAR, "Ref2VA"),
_server_args(),
env_dict=server_env,
use_omni=True,
) as server:
with input_video_path.open("rb") as video_file, input_audio_path.open("rb") as audio_file:
response = requests.post(
f"http://{server.host}:{server.port}/v1/videos/sync",
data=request_data,
files=[
("input_references", ("input_video.mp4", video_file, "video/mp4")),
("input_references", ("input_audio.mp3", audio_file, "audio/mpeg")),
],
timeout=REQUEST_TIMEOUT_SECONDS,
)
response.raise_for_status()
assert response.headers["content-type"].startswith("video/mp4")
generated_path.write_bytes(response.content)
_assert_official_video(
generated_path,
reference_path,
frame_count=REF2VA_NUM_FRAMES,
label="minimax_h3_ref2va_official_reference",
)
@@ -1,216 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import hashlib
import json
import os
import subprocess
from pathlib import Path
import pytest
import requests
import torch
from huggingface_hub import snapshot_download
from tests.e2e.accuracy.helpers import (
assert_video_metadata,
assert_video_similarity_metrics,
probe_binary,
probe_video,
reset_artifact_dir,
)
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniServer
pytestmark = [pytest.mark.benchmark, pytest.mark.diffusion, pytest.mark.full_model]
MODEL_REPO_ID = "MiniMaxAI/MiniMax-H3"
# The previously pinned revision was removed from the Hugging Face repository.
MODEL_REVISION = "main"
MODEL_ENV_VAR = "VLLM_TEST_MINIMAX_H3_FL2VA_MODEL"
# Keep the official asset link visible in the test report. Hugging Face's
# ``blob`` URL is an HTML page, so resolve it to the raw asset for download.
REFERENCE_VIDEO_URL = "https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/main/assets/t2va.mp4"
REFERENCE_VIDEO_SHA256 = "d66903241362e224085cc93f7a5e70fba6ab378d0ac6ff6af83acf2559849a42"
# This is the official reproducible 768p T2VA prompt associated with
# ``assets/t2va.mp4`` in the MiniMax H3 repository.
PROMPT = """integrated_multimodal_description: [Shot 1] Cinematic, medium wide shot, pushing in slowly. In the cavernous, dimly lit bridge of a starship, sleek metallic consoles with glowing amber displays flank a massive, curved observation window. A female captain, in her late 40s with an athletic build and short silver-streaked black hair, stands in the center midground. She wears a structured, high-collared dark navy military tunic with silver chest insignias. Her back is to the camera, silhouetted against the cool, ambient starlight pouring through the thick glass. She stands perfectly still with her hands clasped tightly behind her back. Outside the window, a massive armada of jagged, dark grey dreadnoughts hovers in tight formation against a deep purple space nebula. The fleet's massive rear thrusters begin to glow with an intense, escalating bright blue light. [Shot 2] At 00:04.500, the camera cuts to a close-up of the captain's face and shakes strongly. The brilliant blue-white light from the fleet's gathering energy reflects vividly in her dark eyes. Suddenly, a blinding white flash floods through the window, completely washing out the background as the fleet jumps to hyperspace. The sheer spatial force violently jolts the bridge, causing the captain from Shot 1 to stagger slightly forward, her shoulders tensing as she visibly braces herself against the physical tremors. As the intense white light fades abruptly, leaving only the dim, empty expanse of the purple nebula reflected on her starkly lit skin, her jaw clenches, and she slowly closes her eyes in the newly emptied space.
overall_soundscape: A low, resonant hum of the ship's ambient life support systems serves as the baseline, soon drowned out by an audible, escalating, high-pitched electronic whine as the fleet outside charges its hyperdrives. A massive, deafening, bass-heavy boom and sharp crackle erupts during the blinding flash, accompanied by the loud metallic creaking, rattling, and deep thuds of the bridge's bulkheads vibrating under immense physical stress. The intense roaring impact then cuts abruptly back to a hollow, echoing room tone, leaving only the faint, steady hum of the isolated bridge.
non_diegetic_music: Cinematic space-opera orchestral score, slow tempo, featuring a solitary, mournful French horn melody over deep, sustained string dissonances that build rapidly in volume and intensity, swelling to a massive orchestral peak before snapping immediately into silence right after the jump."""
WIDTH = 1344
HEIGHT = 768
FPS = 24
NUM_FRAMES = 243
NUM_INFERENCE_STEPS = 50
FLOW_SHIFT = 12.0
AUDIO_FLOW_SHIFT = 3.0
DURATION_SECONDS = 10.0
SEED = 0
SSIM_THRESHOLD = 0.82
PSNR_THRESHOLD = 20.0
REQUEST_TIMEOUT_SECONDS = 60 * 60
def _model_name() -> str:
configured = os.environ.get(MODEL_ENV_VAR)
if configured:
return configured
snapshot_root = snapshot_download(
repo_id=MODEL_REPO_ID,
revision=MODEL_REVISION,
allow_patterns=["FL2VA/**"],
)
return str(Path(snapshot_root) / "FL2VA")
def _server_args() -> list[str]:
return [
"--trust-remote-code",
"--num-gpus",
"4",
"--usp",
"4",
"--ring",
"1",
"--use-hsdp",
"--hsdp-shard-size",
"4",
"--text-encoder-tp-size",
"4",
"--vae-patch-parallel-size",
"4",
"--vae-parallel-mode",
"tile",
"--vae-use-tiling",
"--diffusion-attention-backend",
"FLASH_ATTN",
"--stage-init-timeout",
"1800",
"--init-timeout",
"1800",
]
def _download_reference_video(output_dir: Path) -> Path:
reference_path = output_dir / "reference.mp4"
download_url = REFERENCE_VIDEO_URL.replace("/blob/", "/resolve/", 1)
response = requests.get(download_url, timeout=120)
response.raise_for_status()
content = response.content
digest = hashlib.sha256(content).hexdigest()
assert digest == REFERENCE_VIDEO_SHA256, (
f"Official MiniMax H3 T2VA reference changed: got {digest}, expected {REFERENCE_VIDEO_SHA256}"
)
reference_path.write_bytes(content)
return reference_path
def _probe_audio(path: Path) -> dict[str, str | int]:
probe_binary("ffprobe")
result = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-select_streams",
"a:0",
"-show_entries",
"stream=codec_name,sample_rate,channels",
"-of",
"json",
str(path),
],
capture_output=True,
text=True,
check=True,
)
streams = json.loads(result.stdout).get("streams", [])
assert len(streams) == 1, f"Expected one audio stream in {path}, got {streams}"
stream = streams[0]
return {
"codec_name": str(stream["codec_name"]),
"sample_rate": int(stream["sample_rate"]),
"channels": int(stream["channels"]),
}
@hardware_test(res={"cuda": "H100"}, num_cards=4)
def test_minimax_h3_t2va_matches_official_reference(
accuracy_artifact_root: Path,
) -> None:
if not torch.cuda.is_available():
pytest.skip("MiniMax H3 T2VA accuracy test requires CUDA.")
probe_binary("ffmpeg")
probe_binary("ffprobe")
output_dir = reset_artifact_dir(accuracy_artifact_root / "minimax_h3_t2va")
reference_path = _download_reference_video(output_dir)
generated_path = output_dir / "vllm_omni.mp4"
server_env = {
"FLASHINFER_DISABLE_VERSION_CHECK": "1",
"VLLM_WORKER_MULTIPROC_METHOD": "spawn",
"VLLM_OMNI_VIDEO_SYNC_TIMEOUT": str(REQUEST_TIMEOUT_SECONDS),
"VLLM_OMNI_STORAGE_PATH": str(output_dir / "storage"),
}
request_data = {
"prompt": PROMPT,
"width": str(WIDTH),
"height": str(HEIGHT),
"fps": str(FPS),
"num_inference_steps": str(NUM_INFERENCE_STEPS),
"flow_shift": str(FLOW_SHIFT),
"seed": str(SEED),
"extra_params": json.dumps(
{
"task": "t2va",
"duration": DURATION_SECONDS,
"aspect_ratio": "16:9",
"audio_flow_shift": AUDIO_FLOW_SHIFT,
}
),
}
with OmniServer(
_model_name(),
_server_args(),
env_dict=server_env,
use_omni=True,
) as server:
response = requests.post(
f"http://{server.host}:{server.port}/v1/videos/sync",
data=request_data,
timeout=REQUEST_TIMEOUT_SECONDS,
)
response.raise_for_status()
assert response.headers["content-type"].startswith("video/mp4")
generated_path.write_bytes(response.content)
reference_metadata = probe_video(reference_path)
generated_metadata = probe_video(generated_path)
assert reference_metadata == generated_metadata
assert_video_metadata(
generated_metadata,
width=WIDTH,
height=HEIGHT,
fps=FPS,
frame_count=NUM_FRAMES,
)
expected_audio = {"codec_name": "aac", "sample_rate": 32000, "channels": 2}
assert _probe_audio(reference_path) == expected_audio
assert _probe_audio(generated_path) == expected_audio
assert_video_similarity_metrics(
label="minimax_h3_t2va_official_reference",
online_path=generated_path,
offline_path=reference_path,
ssim_threshold=SSIM_THRESHOLD,
psnr_threshold=PSNR_THRESHOLD,
)
@@ -19,6 +19,7 @@ import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any, Protocol
@@ -121,9 +122,13 @@ def seed_tts_bench_argv(*, locale: str = "en") -> list[str]:
def find_vllm_cli() -> str:
exe = shutil.which("vllm")
if not exe:
raise FileNotFoundError("Could not find `vllm` on PATH (install vLLM-Omni with CLI entrypoints).")
return exe
if exe:
return exe
# Fallback when pytest is invoked via absolute venv path without activating PATH.
sibling = Path(sys.executable).resolve().parent / "vllm"
if sibling.is_file() and os.access(sibling, os.X_OK):
return str(sibling)
raise FileNotFoundError("Could not find `vllm` on PATH (install vLLM-Omni with CLI entrypoints).")
def run_vllm_bench_subprocess(vllm: str, argv: list[str], *, extra_env: dict[str, str] | None = None) -> None:
@@ -150,6 +155,11 @@ def build_serve_common_argv(
result_dir: Path,
result_filename: str,
ready_check_timeout_sec: int | None = None,
trust_remote_code: bool = False,
backend: str = "openai-chat-omni",
endpoint: str = "/v1/chat/completions",
temperature: float | None = None,
output_len: int | None = None,
) -> list[str]:
out = [
"bench",
@@ -162,9 +172,9 @@ def build_serve_common_argv(
"--model",
model,
"--endpoint",
"/v1/chat/completions",
endpoint,
"--backend",
"openai-chat-omni",
backend,
"--request-rate",
"inf",
"--num-prompts",
@@ -184,6 +194,12 @@ def build_serve_common_argv(
]
if ready_check_timeout_sec is not None:
out.extend(["--ready-check-timeout-sec", str(int(ready_check_timeout_sec))])
if trust_remote_code:
out.append("--trust-remote-code")
if temperature is not None:
out.extend(["--temperature", str(temperature)])
if output_len is not None:
out.extend(["--output-len", str(int(output_len))])
return out
@@ -171,7 +171,13 @@ def _preserve_benchmark_dataset_env() -> Any:
os.environ[key] = value
def _build_common_args(ns: argparse.Namespace, *, result_filename: str) -> list[str]:
def _build_common_args(
ns: argparse.Namespace,
*,
result_filename: str,
backend: str = "openai-chat-omni",
endpoint: str = "/v1/chat/completions",
) -> list[str]:
return build_serve_common_argv(
host=ns.host,
port=ns.port,
@@ -183,6 +189,11 @@ def _build_common_args(ns: argparse.Namespace, *, result_filename: str) -> list[
result_dir=ns.result_dir,
result_filename=result_filename,
ready_check_timeout_sec=ns.ready_check_timeout_sec,
trust_remote_code=ns.trust_remote_code,
backend=backend,
endpoint=endpoint,
temperature=getattr(ns, "temperature", None),
output_len=getattr(ns, "output_len", None),
)
@@ -201,6 +212,9 @@ def run_daily_omni(ns: argparse.Namespace, vllm: str) -> Path:
json.dumps(extra, ensure_ascii=False, separators=(",", ":")),
]
)
pack_mode = getattr(ns, "daily_omni_pack_mode", None)
if pack_mode:
argv.extend(["--daily-omni-pack-mode", pack_mode])
if ns.daily_omni_save_eval_items:
argv.append("--daily-omni-save-eval-items")
print("\n$", vllm, *argv, "\n", flush=True)
@@ -217,7 +231,12 @@ def run_seed_tts(ns: argparse.Namespace, vllm: str) -> Path:
result_filename = f"qwen_omni_acc_seed_tts_{tag}.json"
extra = json.loads(ns.seed_extra_body_json)
argv = (
_build_common_args(ns, result_filename=result_filename)
_build_common_args(
ns,
result_filename=result_filename,
backend=ns.seed_backend,
endpoint=ns.seed_endpoint,
)
+ seed_tts_bench_argv(locale=ns.seed_tts_locale)
+ [
"--seed-tts-wer-eval",
@@ -225,6 +244,8 @@ def run_seed_tts(ns: argparse.Namespace, vllm: str) -> Path:
json.dumps(extra, ensure_ascii=False, separators=(",", ":")),
]
)
if ns.seed_tts_turns_per_session > 1:
argv.extend(["--seed-tts-turns-per-session", str(ns.seed_tts_turns_per_session)])
if ns.seed_tts_wer_save_items:
argv.append("--seed-tts-wer-save-items")
if ns.seed_tts_file_ref_audio:
@@ -266,6 +287,23 @@ def build_arg_parser() -> argparse.ArgumentParser:
help="If set, forwarded to ``vllm bench serve`` (probe first request until success). "
"Omit to use upstream default (typically skip).",
)
p.add_argument(
"--trust-remote-code",
action="store_true",
help="Forward trust_remote_code to ``vllm bench serve`` for models with custom code.",
)
p.add_argument(
"--temperature",
type=float,
default=None,
help="Optional sampling temperature forwarded to ``vllm bench serve`` (e.g. 0 for MiniCPM-o Daily-Omni MCQ).",
)
p.add_argument(
"--output-len",
type=int,
default=None,
help="Optional max output tokens forwarded to ``vllm bench serve`` (e.g. 512 for MiniCPM-o Daily-Omni).",
)
p.add_argument(
"--result-dir",
type=Path,
@@ -295,6 +333,13 @@ def build_arg_parser() -> argparse.ArgumentParser:
help="Optional local Videos root; if omitted, media is fetched lazily from Hub Videos.tar.",
)
p.add_argument("--daily-omni-input-mode", choices=("all", "visual", "audio"), default="all")
p.add_argument(
"--daily-omni-pack-mode",
choices=("qwen", "minicpm-interleave"),
default=None,
help="Daily-Omni multimodal pack recipe. Use ``minicpm-interleave`` for MiniCPM-o "
"(1fps interleaved image/audio; see PR #5606).",
)
p.add_argument(
"--daily-extra-body-json",
default='{"modalities":["text"]}',
@@ -326,6 +371,22 @@ def build_arg_parser() -> argparse.ArgumentParser:
default='{"modalities":["text","audio"]}',
help="JSON for Seed-TTS chat requests (must include audio for synthesis + PCM capture).",
)
p.add_argument(
"--seed-backend",
default="openai-chat-omni",
help="Seed-TTS benchmark backend (for example openai-realtime-tts).",
)
p.add_argument(
"--seed-endpoint",
default="/v1/chat/completions",
help="Seed-TTS serving endpoint corresponding to --seed-backend.",
)
p.add_argument(
"--seed-tts-turns-per-session",
type=int,
default=1,
help="Group this many Seed-TTS target texts into each Realtime session.",
)
p.add_argument("--seed-tts-wer-save-items", action="store_true")
p.add_argument(
"--seed-tts-file-ref-audio",
@@ -400,6 +461,11 @@ def run_acc_benchmark(ns: argparse.Namespace) -> int:
min_mean_sim=ns.min_seed_tts_mean_sim,
min_mean_utmos=ns.min_seed_tts_mean_utmos,
)
if ns.seed_tts_turns_per_session > 1:
expected_turns = ns.num_prompts * ns.seed_tts_turns_per_session
evaluated_turns = int(data.get("seed_tts_content_evaluated") or 0)
if evaluated_turns != expected_turns:
errs.append(f"seed_tts_content_evaluated={evaluated_turns} != expected {expected_turns}")
if errs:
failed.extend([f"[Seed-TTS] {e}" for e in errs])
else:
@@ -11,6 +11,9 @@ from vllm_omni.experimental.fullduplex.client import (
read_pcm16_wav,
write_pcm16_wav,
)
from vllm_omni.experimental.fullduplex.minicpmo45.policy import (
MiniCPMO45DuplexPolicy,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -32,6 +35,19 @@ def test_realtime_client_builds_explicit_native_duplex_url():
}
def test_seed_tts_initial_text_is_part_of_native_duplex_context():
prefix, suffix = MiniCPMO45DuplexPolicy.session_context_texts(
"Speak exactly.",
True,
"The quick brown fox.",
)
assert prefix == "<|im_start|>system\nSpeak exactly.\n<|audio_start|>"
assert suffix == (
"<|audio_end|><|im_end|>\n<|im_start|>user\nThe quick brown fox.<|im_end|>\n<|im_start|>assistant\n"
)
def test_realtime_client_builds_resume_only_url_when_autostart_disabled():
url = build_realtime_url(
"ws://localhost:8099/v1/realtime?duplex=1",
@@ -86,6 +102,59 @@ async def test_realtime_client_configure_sends_explicit_ref_audio():
assert session["ref_audio"] == "data:audio/wav;base64,AAAA"
@pytest.mark.asyncio
async def test_realtime_client_configure_sends_seed_tts_text_condition():
class Client(RealtimeDuplexClient):
def __init__(self):
super().__init__("ws://unused")
self.sent = []
async def send(self, event):
self.sent.append(event)
self.events.add({"type": "session.created"})
client = Client()
await client.configure(
"openbmb/MiniCPM-o-4_5",
instructions="Speak the requested text exactly.",
initial_user_text="The quick brown fox.",
timeout_s=1,
)
session = client.sent[0]["session"]
assert session["instructions"] == "Speak the requested text exactly."
assert session["extra_body"]["duplex_initial_user_text"] == "The quick brown fox."
@pytest.mark.asyncio
async def test_realtime_client_configure_explicit_tts_opts_out_of_native_duplex():
class Client(RealtimeDuplexClient):
def __init__(self):
super().__init__("ws://unused")
self.sent = []
async def send(self, event):
self.sent.append(event)
self.events.add({"type": "session.created"})
client = Client()
await client.configure(
"openbmb/MiniCPM-o-4_5",
native_duplex=False,
auto_response=False,
extra_body={"ref_audio": "data:audio/wav;base64,AAAA"},
timeout_s=1,
)
session_extra_body = client.sent[0]["session"]["extra_body"]
assert session_extra_body == {
"ref_audio": "data:audio/wav;base64,AAAA",
"minicpmo45_native_duplex": False,
}
def test_realtime_event_collector_partitions_audio_by_response():
collector = RealtimeEventCollector()
collector.add({"type": "response.created", "response": {"id": "resp-a"}})
@@ -134,6 +203,26 @@ def test_realtime_event_collector_reports_engine_token_and_audio_intervals():
},
received_at_s=received_at_s,
)
collector.add(
{
"type": "response.audio_transcript.delta",
"response_id": "resp-a",
"delta": "",
},
received_at_s=10.1,
)
collector.add(
{
"type": "response.audio_transcript.delta",
"response_id": "resp-a",
"delta": "hello",
},
received_at_s=10.15,
)
collector.add(
{"type": "response.done", "response": {"id": "resp-a"}},
received_at_s=10.4,
)
timing = collector.timing_summary(
after_s=10.0,
@@ -175,6 +264,19 @@ def test_realtime_event_collector_reports_engine_token_and_audio_intervals():
},
"max_chunk_gap_ms": 110.0,
}
assert timing["request_metrics"] == {
"source": "client_monotonic_receive",
"measurement_origin": {
"ttft": "input_audio_buffer.commit client send to first non-empty text delta",
"ttfp": "input_audio_buffer.commit client send to first audio packet",
"rtf": "commit-to-last-audio receive time divided by emitted audio duration",
},
"ttft_ms": 250.0,
"ttfp_ms": 300.0,
"rtf": pytest.approx(1.916667),
"audio_generation_ms": 460.0,
"audio_duration_ms": 240.0,
}
def test_response_timing_ignores_unowned_session_level_metrics():
@@ -0,0 +1,246 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from collections.abc import Generator
import numpy as np
import pytest
import soundfile as sf
from PIL import Image
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from vllm_omni.diffusion.models.longcat_video.pipeline_longcat_video_avatar import (
prepare_longcat_video_avatar_model_for_omni,
)
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
MODEL = "meituan-longcat/LongCat-Video-Avatar-1.5"
PROMPT = "A person speaks calmly while facing the camera."
NEGATIVE_PROMPT = "low quality, blurry, watermark, text"
@pytest.fixture()
def synthetic_audio_path(tmp_path):
sample_rate = 16000
duration_s = 2.0
t = np.arange(int(sample_rate * duration_s), dtype=np.float32) / sample_rate
audio = (0.08 * np.sin(2 * np.pi * 220 * t)).astype(np.float32)
path = tmp_path / "avatar_reference.wav"
sf.write(path, audio, sample_rate)
return str(path)
@pytest.fixture()
def synthetic_multi_audio_paths(tmp_path):
sample_rate = 16000
duration_s = 2.0
t = np.arange(int(sample_rate * duration_s), dtype=np.float32) / sample_rate
left = (0.08 * np.sin(2 * np.pi * 220 * t)).astype(np.float32)
right = (0.08 * np.sin(2 * np.pi * 330 * t)).astype(np.float32)
left_path = tmp_path / "avatar_left.wav"
right_path = tmp_path / "avatar_right.wav"
sf.write(left_path, left, sample_rate)
sf.write(right_path, right, sample_rate)
return {"person1": str(left_path), "person2": str(right_path)}
@pytest.fixture()
def synthetic_image_path(tmp_path):
path = tmp_path / "avatar_reference.png"
Image.new("RGB", (768, 512), (130, 104, 88)).save(path)
return str(path)
def _sampling(
stage: str,
*,
num_frames: int = 5,
extra_args: dict | None = None,
) -> OmniDiffusionSamplingParams:
sampling_extra_args = {
"stage": stage,
"resolution": "480p",
"save_fps": 25,
"use_distill": True,
"use_int8": True,
}
if extra_args:
sampling_extra_args.update(extra_args)
return OmniDiffusionSamplingParams(
num_frames=num_frames,
fps=25,
num_inference_steps=8,
guidance_scale=1.0,
guidance_scale_2=1.0,
seed=42,
extra_args=sampling_extra_args,
)
@pytest.fixture(scope="module")
def longcat_omni_runner() -> Generator[OmniRunner, None, None]:
model = prepare_longcat_video_avatar_model_for_omni(MODEL, use_int8=True)
with OmniRunner(
model,
seed=42,
stage_configs_path=None,
model_class_name="LongCatVideoAvatarPipeline",
additional_config={
"model_type": "avatar-v1.5",
"resolution": "480p",
"use_distill": True,
"use_int8": True,
},
) as runner:
yield runner
def _generate_avatar_frames(
omni_runner: OmniRunner,
*,
stage: str,
audio_path: str | dict[str, str],
image_path: str | None = None,
additional_information: dict | None = None,
num_frames: int = 5,
sampling_extra_args: dict | None = None,
):
prompt = {
"prompt": PROMPT,
"negative_prompt": NEGATIVE_PROMPT,
"multi_modal_data": {"audio": audio_path},
"additional_information": additional_information or {},
}
prompt["additional_information"].update({"stage": stage, "resolution": "480p"})
if image_path is not None:
prompt["multi_modal_data"]["image"] = image_path
outputs = omni_runner.generate(
[prompt],
[_sampling(stage, num_frames=num_frames, extra_args=sampling_extra_args)],
)
assert outputs
output = outputs[0]
assert output.images
frames = output.images[0]
if isinstance(frames, tuple) and len(frames) == 2:
frames = frames[0]
if isinstance(frames, dict):
frames = frames.get("frames") or frames.get("video")
assert isinstance(frames, list)
return frames
def _assert_frames(frames, *, expected_size: tuple[int, int], expected_count: int = 5):
assert len(frames) == expected_count
for frame in frames:
assert isinstance(frame, Image.Image)
assert frame.mode == "RGB"
assert frame.size == expected_size
@pytest.mark.advanced_model
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100"}, num_cards={"cuda": 1})
def test_longcat_video_avatar_at2v(longcat_omni_runner: OmniRunner, synthetic_audio_path):
frames = _generate_avatar_frames(
longcat_omni_runner,
stage="at2v",
audio_path=synthetic_audio_path,
)
_assert_frames(frames, expected_size=(832, 480))
@pytest.mark.advanced_model
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100"}, num_cards={"cuda": 1})
def test_longcat_video_avatar_ai2v(
longcat_omni_runner: OmniRunner,
synthetic_audio_path,
synthetic_image_path,
):
frames = _generate_avatar_frames(
longcat_omni_runner,
stage="ai2v",
audio_path=synthetic_audio_path,
image_path=synthetic_image_path,
)
_assert_frames(frames, expected_size=(768, 512))
@pytest.mark.advanced_model
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100"}, num_cards={"cuda": 1})
def test_longcat_video_avatar_multi_speaker_ai2v(
longcat_omni_runner: OmniRunner,
synthetic_multi_audio_paths,
synthetic_image_path,
):
frames = _generate_avatar_frames(
longcat_omni_runner,
stage="ai2v",
audio_path=synthetic_multi_audio_paths,
image_path=synthetic_image_path,
additional_information={
"audio_type": "para",
"bbox": {
"person1": [32, 48, 384, 336],
"person2": [32, 432, 384, 720],
},
},
)
_assert_frames(frames, expected_size=(768, 512))
@pytest.mark.advanced_model
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100"}, num_cards={"cuda": 1})
def test_longcat_video_avatar_single_speaker_ai2v_avc_continuation(
longcat_omni_runner: OmniRunner,
synthetic_audio_path,
synthetic_image_path,
):
frames = _generate_avatar_frames(
longcat_omni_runner,
stage="ai2v",
audio_path=synthetic_audio_path,
image_path=synthetic_image_path,
num_frames=17,
sampling_extra_args={
"num_segments": 2,
"num_cond_frames": 5,
"use_kv_cache": True,
},
)
_assert_frames(frames, expected_size=(768, 512), expected_count=29)
@pytest.mark.advanced_model
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100"}, num_cards={"cuda": 1})
def test_longcat_video_avatar_multi_speaker_ai2v_avc_continuation(
longcat_omni_runner: OmniRunner,
synthetic_multi_audio_paths,
synthetic_image_path,
):
frames = _generate_avatar_frames(
longcat_omni_runner,
stage="ai2v",
audio_path=synthetic_multi_audio_paths,
image_path=synthetic_image_path,
additional_information={
"audio_type": "para",
"bbox": {
"person1": [32, 48, 384, 336],
"person2": [32, 432, 384, 720],
},
},
num_frames=17,
sampling_extra_args={
"num_segments": 2,
"num_cond_frames": 5,
"use_kv_cache": True,
},
)
_assert_frames(frames, expected_size=(768, 512), expected_count=29)
@@ -34,7 +34,7 @@ def get_question(prompt_type: str = "text") -> str:
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_text_to_text(omni_runner, omni_runner_handler) -> None:
"""Test processing text, generating text output."""
@@ -44,7 +44,7 @@ def test_text_to_text(omni_runner, omni_runner_handler) -> None:
@pytest.mark.full_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_audio_to_text(omni_runner, omni_runner_handler) -> None:
"""Test processing audio, generating text output."""
@@ -57,7 +57,7 @@ def test_audio_to_text(omni_runner, omni_runner_handler) -> None:
@pytest.mark.full_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_image_to_text(omni_runner, omni_runner_handler) -> None:
"""Test processing image, generating text output."""
@@ -68,7 +68,7 @@ def test_image_to_text(omni_runner, omni_runner_handler) -> None:
@pytest.mark.full_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_video_to_text(omni_runner, omni_runner_handler) -> None:
"""Test processing video, generating text output."""
@@ -79,7 +79,7 @@ def test_video_to_text(omni_runner, omni_runner_handler) -> None:
@pytest.mark.full_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_text_to_audio(omni_runner, omni_runner_handler) -> None:
"""Test processing text and generating audio through Talker and Code2Wav."""
@@ -90,7 +90,7 @@ def test_text_to_audio(omni_runner, omni_runner_handler) -> None:
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_mix_to_audio(omni_runner, omni_runner_handler) -> None:
"""Test processing mixed modalities (image + audio), generating audio output."""
@@ -109,7 +109,7 @@ def test_mix_to_audio(omni_runner, omni_runner_handler) -> None:
@pytest.mark.full_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_video_to_audio(omni_runner, omni_runner_handler) -> None:
"""Test processing video, generating audio output."""
@@ -0,0 +1 @@
"""Shared helpers for online-serving MiniCPM-o duplex E2E tests."""
@@ -19,14 +19,19 @@ DEPLOY_CONFIG = modify_stage_config(
get_deploy_config_path("minicpmo_4_5_duplex.yaml"),
updates={
"base_config": get_deploy_config_path("minicpmo_4_5.yaml"),
# Cap per-stage KV so Thinker/Talker/Code2Wav share one GPU with
# max_sessions=2. Talker must stay within its 4096 context so the
# 0.5 GiB budget passes vLLM's min-KV check at init.
"stages": {
0: {"kv_cache_memory_bytes": 6 * 1024 * 1024 * 1024},
1: {"kv_cache_memory_bytes": 512 * 1024 * 1024},
1: {
"max_model_len": 4096,
"kv_cache_memory_bytes": 512 * 1024 * 1024,
},
2: {"kv_cache_memory_bytes": 256 * 1024 * 1024},
},
# Platform overrides are applied after ordinary stage settings. Keep
# the constrained duplex test's Talker budget ahead of the base CUDA
# profile's single-GPU default.
# Platform overrides apply after ordinary stage settings and would
# otherwise reinstate the base CUDA 2 GiB Talker default.
"platforms": {
"cuda": {
"stages": [
@@ -48,7 +53,7 @@ CORE_DEPLOY_CONFIG = modify_stage_config(
}
},
)
ASSET_DIR = Path(__file__).resolve().parents[1] / "assets" / "minicpmo_4_5"
ASSET_DIR = Path(__file__).resolve().parents[3] / "assets" / "minicpmo_4_5"
RESPONSE_REQUIRED_WAV = ASSET_DIR / "response_required_16k.wav"
RESPONSE_REQUIRED_SHA256 = "2e5fd4eb3ee434ce107ee3a0591fa624a33f7683c7462f45fe651c443c9af941"
SOFT_INTERRUPT_WAV = ASSET_DIR / "soft_interrupt_16k.wav"
@@ -60,7 +65,7 @@ SERVER_PARAMS = [
OmniServerParams(
model=MODEL,
stage_config_path=DEPLOY_CONFIG,
use_stage_cli=True,
use_stage_cli=False,
server_args=["--trust-remote-code"],
),
id="three-stage-single-gpu",
@@ -71,7 +76,7 @@ CORE_SERVER_PARAMS = [
OmniServerParams(
model=MODEL,
stage_config_path=CORE_DEPLOY_CONFIG,
use_stage_cli=True,
use_stage_cli=False,
server_args=["--trust-remote-code"],
),
id="three-stage-single-gpu",
@@ -14,7 +14,7 @@ not a separate model-level barge-in contract.
Run only after a MiniCPM-o 4.5 vLLM-Omni server is up:
python tests/e2e/online_serving/minicpmo_realtime_duplex_scenarios.py \
python tests/e2e/online_serving/helpers/minicpmo_realtime_duplex_scenarios.py \
--url ws://localhost:8099/v1/realtime?duplex=1 \
--model openbmb/MiniCPM-o-4_5 \
--input-wav input_16k_mono_pcm16.wav \
@@ -37,7 +37,7 @@ import wave
from dataclasses import dataclass, field
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[3]
_REPO_ROOT = Path(__file__).resolve().parents[4]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
@@ -53,6 +53,7 @@ from vllm_omni.experimental.fullduplex.client import ( # noqa: E402
RealtimeEventCollector,
build_realtime_url,
read_pcm16_wav,
summarize_session_request_metrics,
)
_url_with_model = build_realtime_url
@@ -194,6 +195,38 @@ class DemoState:
summaries[response_id] = timing
return summaries
def session_request_metrics(
self,
*,
session_id: str | None = None,
) -> list[dict[str, object]]:
"""Return benchmark-style metrics for every response in this session."""
requests: list[dict[str, object]] = []
for request_index, (response_id, timing) in enumerate(self.response_timing_summaries().items()):
metrics = timing.get("request_metrics")
if not isinstance(metrics, dict):
continue
requests.append(
{
"session_id": session_id,
"request_index": request_index,
"response_id": response_id,
**metrics,
}
)
return requests
def session_metric_summary(
self,
*,
session_id: str | None = None,
) -> dict[str, object]:
"""Average TTFT, TTFP, and RTF across responses that emitted audio."""
return summarize_session_request_metrics(
self.session_request_metrics(session_id=session_id),
session_id=session_id,
)
def first_index(self, event_type: str, predicate=None) -> int | None:
for index, event in enumerate(self.events):
if event.get("type") != event_type:
@@ -1538,6 +1571,8 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
"input_transcription_ok": input_transcription_ok,
"completed_response_ids": completed_response_ids,
"response_timings": state.response_timing_summaries(),
"request_metrics": state.session_request_metrics(session_id=args.session_id),
"session_metrics": state.session_metric_summary(session_id=args.session_id),
"lifecycle_counts_ok": lifecycle_counts_ok,
"validation_mode": validation_mode,
"scenario": scenario,
@@ -8,6 +8,7 @@ import hashlib
import json
import sys
import uuid
from functools import lru_cache
from pathlib import Path
from types import SimpleNamespace
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
@@ -16,14 +17,29 @@ import websockets
from websockets.exceptions import ConnectionClosed
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
REPO_ROOT = SCRIPT_DIR.parents[3]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from minicpmo_realtime_duplex_scenarios import ( # noqa: E402
_ref_audio_data_url,
_url_with_model,
run_demo,
)
@lru_cache(maxsize=1)
def _scenario_module():
"""Delay heavyweight vLLM imports so ``--help`` remains directly executable."""
from tests.e2e.online_serving.helpers import minicpmo_realtime_duplex_scenarios
return minicpmo_realtime_duplex_scenarios
def _ref_audio_data_url(path: str) -> str:
return _scenario_module()._ref_audio_data_url(path)
def _url_with_model(*args, **kwargs) -> str:
return _scenario_module()._url_with_model(*args, **kwargs)
async def run_demo(args):
return await _scenario_module().run_demo(args)
def _with_resume_mode(url: str) -> str:
@@ -314,6 +314,14 @@ def summarize_artifacts(
and event["response"].get("status") == "cancelled"
)
result_ok = result.get("ok") is True
# response-required keeps the full listen sandwich around commit. model-policy
# only requires a completed audio response that started before final commit:
# single-GPU co-location often still drains speak after the WAV ends.
commit_listen_contract_ok = (
listen_after_response_before_commit and final_listen_after_commit
if validation_mode == "response-required"
else response_before_final_commit
)
common_contract_ok = bool(
result_ok
and not error_events
@@ -323,8 +331,7 @@ def summarize_artifacts(
and multi_delta_ok
and response_audio_contract_ok
and response_before_final_commit
and listen_after_response_before_commit
and final_listen_after_commit
and commit_listen_contract_ok
)
mode_contract_ok = validation_mode == "model-policy" or (
second_response_before_final_commit
@@ -393,6 +400,11 @@ async def run_soft_interrupt(args: argparse.Namespace) -> dict[str, object]:
f"duplex-soft-interrupt-{uuid.uuid4().hex}",
]
command.extend(["--ref-audio", str(_canonical_path(args.ref_audio))])
temperature = getattr(args, "temperature", None)
if temperature is None and args.validation_mode == "response-required":
temperature = 0.0
if temperature is not None:
command.extend(["--temperature", str(temperature)])
if args.require_audio:
command.append("--require-audio")
if args.no_realtime_pacing:
@@ -453,6 +465,12 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--summary-output")
parser.add_argument("--chunk-ms", type=int, default=200)
parser.add_argument("--timeout-s", type=float, default=120.0)
parser.add_argument(
"--temperature",
type=float,
default=None,
help="Stage0 sampling temperature; response-required defaults to 0.0.",
)
parser.add_argument("--require-audio", action="store_true")
parser.add_argument("--no-realtime-pacing", action="store_true")
parser.add_argument(
+12 -12
View File
@@ -68,12 +68,12 @@ def get_max_batch_size(size_type="few"):
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_text_to_text_001(omni_server, openai_client) -> None:
"""
Test text-only input generating text output via OpenAI API.
Deploy Setting: default 2GPU
Deploy Setting: default single GPU
Input Modal: text
Output Modal: text
Input Setting: stream=False
@@ -93,13 +93,13 @@ def test_text_to_text_001(omni_server, openai_client) -> None:
@pytest.mark.full_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_text_to_audio_001(omni_server, openai_client) -> None:
"""
Test text-only input generating text + audio output via OpenAI API.
This exercises the talker TTS region detection and in-process token2wav vocoder.
Deploy Setting: default 2GPU
Deploy Setting: default single GPU
Input Modal: text
Output Modal: text + audio
Input Setting: stream=True
@@ -118,12 +118,12 @@ def test_text_to_audio_001(omni_server, openai_client) -> None:
@pytest.mark.full_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_audio_to_text_audio_001(omni_server, openai_client) -> None:
"""
Test audio input generating text + audio output via OpenAI API.
Deploy Setting: default 2GPU
Deploy Setting: default single GPU
Input Modal: text + audio
Output Modal: text + audio
Input Setting: stream=True
@@ -146,12 +146,12 @@ def test_audio_to_text_audio_001(omni_server, openai_client) -> None:
@pytest.mark.full_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_image_to_text_audio_001(omni_server, openai_client) -> None:
"""
Test image input generating text + audio output via OpenAI API.
Deploy Setting: default 2GPU
Deploy Setting: default single GPU
Input Modal: text + image
Output Modal: text + audio
Input Setting: stream=True
@@ -174,12 +174,12 @@ def test_image_to_text_audio_001(omni_server, openai_client) -> None:
@pytest.mark.full_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_video_to_text_audio_001(omni_server, openai_client) -> None:
"""
Test video input generating text + audio output via OpenAI API.
Deploy Setting: default 2GPU
Deploy Setting: default single GPU
Input Modal: text + video
Output Modal: text + audio
Input Setting: stream=True
@@ -203,12 +203,12 @@ def test_video_to_text_audio_001(omni_server, openai_client) -> None:
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_mix_to_text_audio_001(omni_server, openai_client) -> None:
"""
Test multi-modal input (text + audio + video + image) generating text + audio output.
Deploy Setting: default 2GPU
Deploy Setting: default single GPU
Input Modal: text + audio + video + image
Output Modal: text + audio
Input Setting: stream=True
@@ -10,15 +10,7 @@ from pathlib import Path
import pytest
import websockets
from tests.e2e.online_serving.minicpmo_realtime_duplex_scenarios import (
_ref_audio_data_url,
run_demo,
)
from tests.e2e.online_serving.run_minicpmo_realtime_duplex_multi_session import (
run_multi_session,
)
from tests.helpers.mark import hardware_test
from tests.helpers.minicpmo_4_5_duplex import (
from tests.e2e.online_serving.helpers.minicpmo_4_5_duplex import (
CORE_SERVER_PARAMS,
SERVER_PARAMS,
demo_args,
@@ -27,11 +19,42 @@ from tests.helpers.minicpmo_4_5_duplex import (
resolve_ref_audio,
validated_input_wav,
)
from tests.e2e.online_serving.helpers.minicpmo_realtime_duplex_scenarios import (
_ref_audio_data_url,
run_demo,
)
from tests.e2e.online_serving.run_minicpmo_realtime_duplex_multi_session import (
run_multi_session,
)
from tests.helpers.mark import hardware_test
from vllm_omni.experimental.fullduplex.client import build_realtime_url
pytestmark = pytest.mark.omni
def _assert_request_metrics(metrics: object, *, expected_count: int) -> None:
assert isinstance(metrics, list)
assert len(metrics) == expected_count
for request_index, request in enumerate(metrics):
assert isinstance(request["session_id"], str)
assert request["request_index"] == request_index
assert isinstance(request["response_id"], str)
assert request["ttft_ms"] is not None and request["ttft_ms"] >= 0
assert request["ttfp_ms"] >= 0
assert request["rtf"] is not None and request["rtf"] >= 0
assert request["audio_generation_ms"] >= 0
assert request["audio_duration_ms"] > 0
def _assert_session_metrics(metrics: object, *, expected_count: int) -> None:
assert isinstance(metrics, dict)
assert isinstance(metrics["session_id"], str)
assert metrics["audio_turn_count"] == expected_count
assert metrics["mean_ttft_ms"] is not None and metrics["mean_ttft_ms"] >= 0
assert metrics["mean_ttfp_ms"] is not None and metrics["mean_ttfp_ms"] >= 0
assert metrics["mean_rtf"] is not None and metrics["mean_rtf"] >= 0
async def _receive_protocol_events(ws, required_types: set[str], *, timeout_s: float) -> list[dict[str, object]]:
async def receive() -> list[dict[str, object]]:
events: list[dict[str, object]] = []
@@ -83,7 +106,7 @@ async def _run_protocol_smoke(*, url: str, model: str, ref_audio: Path) -> list[
@pytest.mark.core_model
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", CORE_SERVER_PARAMS, indirect=True)
def test_duplex_websocket_protocol_smoke(omni_server, model_prefix: str) -> None:
ref_audio = resolve_ref_audio(model_prefix)
@@ -101,29 +124,29 @@ def test_duplex_websocket_protocol_smoke(omni_server, model_prefix: str) -> None
@pytest.mark.advanced_model
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
def test_duplex_single_session_response_required(omni_server, model_prefix: str, tmp_path: Path) -> None:
result = asyncio.run(
run_demo(
demo_args(
omni_server=omni_server,
input_wav=validated_input_wav(),
ref_audio=resolve_ref_audio(model_prefix),
output_dir=tmp_path / "single_session",
)
)
args = demo_args(
omni_server=omni_server,
input_wav=validated_input_wav(),
ref_audio=resolve_ref_audio(model_prefix),
output_dir=tmp_path / "single_session",
)
args.turns = 2
result = asyncio.run(run_demo(args))
assert result["ok"] is True, json.dumps(result, ensure_ascii=False, indent=2)
assert result["audio_delta_count"] > 0
assert result["done_count"] == 1
assert result["done_count"] == 2
assert result["error_count"] == 0
assert result["all_audio_responses_have_transcript"] is True
assert result["transcript_delta_done_ok"] is True
_assert_request_metrics(result["request_metrics"], expected_count=2)
_assert_session_metrics(result["session_metrics"], expected_count=2)
@pytest.mark.advanced_model
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
def test_duplex_two_sessions_resume_and_takeover(omni_server, model_prefix: str, tmp_path: Path) -> None:
result = asyncio.run(
@@ -145,3 +168,6 @@ def test_duplex_two_sessions_resume_and_takeover(omni_server, model_prefix: str,
assert all(session["audio_delta_count"] > 0 for session in result["sessions"])
assert all(session["done_count"] == 1 for session in result["sessions"])
assert all(session["error_count"] == 0 for session in result["sessions"])
for session in result["sessions"]:
_assert_request_metrics(session["request_metrics"], expected_count=1)
_assert_session_metrics(session["session_metrics"], expected_count=1)
@@ -9,14 +9,7 @@ from types import SimpleNamespace
import pytest
from tests.e2e.online_serving.run_minicpmo_realtime_duplex_multi_session import (
run_lifecycle_probes,
)
from tests.e2e.online_serving.run_minicpmo_realtime_duplex_soft_interrupt import (
run_soft_interrupt,
)
from tests.helpers.mark import hardware_test
from tests.helpers.minicpmo_4_5_duplex import (
from tests.e2e.online_serving.helpers.minicpmo_4_5_duplex import (
SERVER_PARAMS,
SOFT_INTERRUPT_SHA256,
multi_session_args,
@@ -25,11 +18,18 @@ from tests.helpers.minicpmo_4_5_duplex import (
validated_input_wav,
validated_soft_interrupt_wav,
)
from tests.e2e.online_serving.run_minicpmo_realtime_duplex_multi_session import (
run_lifecycle_probes,
)
from tests.e2e.online_serving.run_minicpmo_realtime_duplex_soft_interrupt import (
run_soft_interrupt,
)
from tests.helpers.mark import hardware_test
pytestmark = [pytest.mark.full_model, pytest.mark.omni]
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
def test_duplex_admission_and_expiry_reaper(omni_server, model_prefix: str, tmp_path: Path) -> None:
args = multi_session_args(
@@ -53,7 +53,7 @@ def test_duplex_admission_and_expiry_reaper(omni_server, model_prefix: str, tmp_
assert result["admission"]["overflow_error_code"] == "resource_exhausted"
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
def test_duplex_soft_interrupt(omni_server, model_prefix: str, tmp_path: Path) -> None:
input_wav = validated_soft_interrupt_wav()
@@ -70,8 +70,13 @@ def test_duplex_soft_interrupt(omni_server, model_prefix: str, tmp_path: Path) -
timeout_s=180.0,
require_audio=True,
no_realtime_pacing=False,
validation_mode="response-required",
min_responses=2,
# Single-GPU co-location cannot reliably finish the fixture's
# two-response soft-interrupt handoff before the WAV ends.
# model-policy + deterministic sampling checks streaming audio
# lifecycle instead of the diagnostic two-response contract.
validation_mode="model-policy",
temperature=0.0,
min_responses=1,
min_audio_deltas_per_response=2,
input_sha256=SOFT_INTERRUPT_SHA256,
expect_followup_response_substring=None,
@@ -83,4 +88,5 @@ def test_duplex_soft_interrupt(omni_server, model_prefix: str, tmp_path: Path) -
assert result["error_count"] == 0
assert result["response_lifecycle_ok"] is True
assert result["response_audio_contract_ok"] is True
assert result["followup_response_transcript_ok"] is True
assert result["response_before_final_commit"] is True
assert result["enough_responses"] is True
@@ -63,12 +63,12 @@ def get_max_batch_size(size_type="few"):
return batch_sizes.get(size_type, 5)
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_text_video_to_text_001(omni_server, openai_client) -> None:
"""
Test text + video input generating text output.
Deploy Setting: default 2GPU
Deploy Setting: default single GPU
Input Modal: text + video
Output Modal: text
Input Setting: stream=False
@@ -90,13 +90,13 @@ def test_text_video_to_text_001(omni_server, openai_client) -> None:
openai_client.send_omni_request(request_config, request_num=get_max_batch_size())
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_sequential_requests_independent(omni_server, openai_client) -> None:
"""
Verify that sequential requests produce independent results and that the
second request does not receive the first request's audio or token state.
Deploy Setting: default 2GPU
Deploy Setting: default single GPU
Input Modal: text (two different prompts)
Output Modal: text + audio (both)
"""
@@ -131,13 +131,13 @@ def test_sequential_requests_independent(omni_server, openai_client) -> None:
)
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_text_to_audio_long_output_001(omni_server, openai_client) -> None:
"""
Test text input generating a longer audio output to exercise the
Code2Wav stage across multiple frames.
Deploy Setting: default 2GPU
Deploy Setting: default single GPU
Input Modal: text (longer prompt)
Output Modal: text + audio
Input Setting: stream=True
@@ -166,7 +166,7 @@ def test_text_to_audio_long_output_001(omni_server, openai_client) -> None:
openai_client.send_omni_request(request_config, request_num=get_max_batch_size())
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_chinese_text_to_audio(omni_server, openai_client) -> None:
"""
@@ -13,7 +13,8 @@ import pytest
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
DRIVER_DIR = Path(__file__).resolve().parent
DEMO_PATH = DRIVER_DIR / "minicpmo_realtime_duplex_scenarios.py"
HELPERS_DIR = DRIVER_DIR / "helpers"
DEMO_PATH = HELPERS_DIR / "minicpmo_realtime_duplex_scenarios.py"
MULTI_DEMO_PATH = DRIVER_DIR / "run_minicpmo_realtime_duplex_multi_session.py"
PAIR_DEMO_PATH = DRIVER_DIR / "run_minicpmo_realtime_duplex_demo_pair.py"
SOFT_INTERRUPT_DEMO_PATH = DRIVER_DIR / "run_minicpmo_realtime_duplex_soft_interrupt.py"
@@ -333,6 +334,71 @@ def test_realtime_duplex_soft_interrupt_accepts_explicit_ref_audio(monkeypatch):
assert args.ref_audio == "ref.wav"
assert args.validation_mode == "model-policy"
assert args.temperature is None
def test_realtime_duplex_soft_interrupt_response_required_defaults_temperature(tmp_path, monkeypatch):
demo = _load_soft_interrupt_demo_module()
input_wav = tmp_path / "input.wav"
with wave.open(str(input_wav), "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(16_000)
wav_file.writeframes(b"\x00\x00" * 320)
ref_audio = tmp_path / "ref.wav"
ref_audio.write_bytes(b"ref")
output_dir = tmp_path / "out"
output_dir.mkdir()
captured: dict[str, object] = {}
class _FakeProcess:
returncode = 0
async def communicate(self):
return b'{"ok": true}', b""
def kill(self):
return None
async def _fake_exec(*command, **kwargs):
captured["command"] = list(command)
return _FakeProcess()
monkeypatch.setattr(demo.asyncio, "create_subprocess_exec", _fake_exec)
monkeypatch.setattr(
demo,
"summarize_artifacts",
lambda **kwargs: {"ok": True, "returncode": 0},
)
result = asyncio.run(
demo.run_soft_interrupt(
SimpleNamespace(
url="ws://127.0.0.1:8099/v1/realtime?duplex=1",
model="openbmb/MiniCPM-o-4_5",
input_wav=str(input_wav),
ref_audio=str(ref_audio),
output_dir=str(output_dir),
summary_output=None,
chunk_ms=200,
timeout_s=5.0,
require_audio=True,
no_realtime_pacing=False,
validation_mode="response-required",
temperature=None,
min_responses=2,
min_audio_deltas_per_response=2,
input_sha256=None,
expect_followup_response_substring=None,
)
)
)
command = captured["command"]
assert isinstance(command, list)
assert "--temperature" in command
assert command[command.index("--temperature") + 1] == "0.0"
assert result["ok"] is True
def test_realtime_duplex_soft_interrupt_response_required_needs_bound_fixture(monkeypatch):
@@ -497,6 +563,43 @@ def test_realtime_duplex_soft_interrupt_model_policy_accepts_single_response(tmp
assert summary["listen_after_response_before_commit"] is True
def test_realtime_duplex_soft_interrupt_model_policy_accepts_commit_during_speak(tmp_path):
demo = _load_soft_interrupt_demo_module()
output = tmp_path / "model_policy_commit_during_speak"
output.mkdir()
response_id = "resp-long"
events = [
{"type": "response.listen", "_client_received_at_s": 1.0},
{"type": "response.created", "response": {"id": response_id}, "_client_received_at_s": 2.0},
{"type": "response.audio.delta", "response_id": response_id, "delta": "AAAA", "_client_received_at_s": 2.1},
{"type": "response.audio.delta", "response_id": response_id, "delta": "AAAA", "_client_received_at_s": 2.2},
{"type": "input_audio_buffer.committed", "_client_received_at_s": 3.0},
{"type": "response.audio.delta", "response_id": response_id, "delta": "AAAA", "_client_received_at_s": 4.0},
{"type": "response.done", "response_id": response_id, "_client_received_at_s": 5.0},
]
(output / "events.jsonl").write_text(
"".join(demo.json.dumps(event) + "\n" for event in events),
encoding="utf-8",
)
(output / "result.json").write_text(
demo.json.dumps({"ok": True, "response_ids": [response_id]}),
encoding="utf-8",
)
summary = demo.summarize_artifacts(
output_dir=output,
validation_mode="model-policy",
min_responses=1,
min_audio_deltas_per_response=2,
expect_followup_response_substring=None,
)
assert summary["ok"] is True
assert summary["response_before_final_commit"] is True
assert summary["listen_after_response_before_commit"] is False
assert summary["final_listen_after_commit"] is False
def test_realtime_duplex_soft_interrupt_response_required_rejects_single_response(tmp_path):
demo = _load_soft_interrupt_demo_module()
output = tmp_path / "response_required"
@@ -799,6 +902,7 @@ def test_realtime_duplex_demo_reads_response_playback_cursor():
def test_realtime_duplex_demo_partitions_timing_by_response_identity():
demo = _load_demo_module()
state = demo.DemoState()
state.input_commit_sent_at_s.extend((9.9, 19.9))
for response_id, created_at_s, audio_at_s, token_count in (
("resp-1", 10.0, 10.1, 3),
("resp-2", 20.0, 20.2, 5),
@@ -826,15 +930,66 @@ def test_realtime_duplex_demo_partitions_timing_by_response_identity():
},
received_at_s=audio_at_s,
)
state.add(
{
"type": "response.audio_transcript.delta",
"response_id": response_id,
"delta": "audio",
},
received_at_s=created_at_s + 0.05,
)
timings = state.response_timing_summaries()
requests = state.session_request_metrics(session_id="seed-tts-session")
session_metrics = state.session_metric_summary(session_id="seed-tts-session")
assert timings["resp-1"]["stage0_tokens"]["output_token_count"] == 3
assert timings["resp-1"]["audio_output"]["response_created_to_first_audio_ms"] == 100.0
assert timings["resp-1"]["audio_output"]["commit_to_first_audio_ms"] is None
assert timings["resp-1"]["audio_output"]["commit_to_first_audio_ms"] == 200.0
assert timings["resp-2"]["stage0_tokens"]["output_token_count"] == 5
assert timings["resp-2"]["audio_output"]["response_created_to_first_audio_ms"] == 200.0
assert timings["resp-2"]["audio_output"]["commit_to_first_audio_ms"] is None
assert timings["resp-2"]["audio_output"]["commit_to_first_audio_ms"] == 300.0
assert requests == [
{
"session_id": "seed-tts-session",
"request_index": 0,
"response_id": "resp-1",
"ttft_ms": 150.0,
"ttfp_ms": 200.0,
"rtf": 2.5,
"audio_generation_ms": 200.0,
"audio_duration_ms": 80.0,
"source": "client_monotonic_receive",
"measurement_origin": {
"ttft": "input_audio_buffer.commit client send to first non-empty text delta",
"ttfp": "input_audio_buffer.commit client send to first audio packet",
"rtf": "commit-to-last-audio receive time divided by emitted audio duration",
},
},
{
"session_id": "seed-tts-session",
"request_index": 1,
"response_id": "resp-2",
"ttft_ms": 150.0,
"ttfp_ms": 300.0,
"rtf": 3.75,
"audio_generation_ms": 300.0,
"audio_duration_ms": 80.0,
"source": "client_monotonic_receive",
"measurement_origin": {
"ttft": "input_audio_buffer.commit client send to first non-empty text delta",
"ttfp": "input_audio_buffer.commit client send to first audio packet",
"rtf": "commit-to-last-audio receive time divided by emitted audio duration",
},
},
]
assert session_metrics == {
"session_id": "seed-tts-session",
"audio_turn_count": 2,
"mean_ttft_ms": 150.0,
"mean_ttfp_ms": 250.0,
"mean_rtf": 3.125,
}
def test_realtime_duplex_demo_model_policy_accepts_one_listen_per_streamed_turn():
+50
View File
@@ -340,6 +340,56 @@ def test_strip_single_engine_args_model_does_not_trigger_warning(mocker):
assert "model" not in warned_args
def test_new_format_deploy_yaml_does_not_strip_interleave_cli_overrides(tmp_path):
"""New-format deploy YAMLs must not strip interleave_mm_strings / media_io_kwargs.
Pytest historically passed ``--stage-configs-path`` for deploy YAMLs; stripping
those CLI flags dropped MiniCPM interleaved AV packing and cut Daily-Omni
accuracy from ~78% to ~70%.
"""
from vllm_omni.entrypoints.utils import is_new_format_deploy_config
yaml_path = tmp_path / "minicpm_deploy.yaml"
yaml_path.write_text(
"""
pipeline: minicpmo_4_5
async_chunk: true
stages:
- stage_id: 0
devices: "0"
""".strip(),
encoding="utf-8",
)
path = str(yaml_path)
assert is_new_format_deploy_config(path)
kwargs = {
"stage_configs_path": path,
"trust_remote_code": True,
"interleave_mm_strings": True,
"media_io_kwargs": {"video": {"fps": 1, "num_frames": 128}},
"gpu_memory_utilization": 0.55,
}
# Mirror AsyncOmniEngine._resolve_stage_configs strip decision.
if kwargs.get("stage_configs_path") is not None and not is_new_format_deploy_config(kwargs["stage_configs_path"]):
base = AsyncOmniEngine._strip_single_engine_args(dict(kwargs))
else:
base = dict(kwargs)
assert base.get("interleave_mm_strings") is True
assert base.get("media_io_kwargs") == {"video": {"fps": 1, "num_frames": 128}}
# Parent EngineArgs fields also remain available for deploy merge.
assert base.get("gpu_memory_utilization") == 0.55
legacy = tmp_path / "legacy.yaml"
legacy.write_text("stage_args:\n - stage_id: 0\n", encoding="utf-8")
assert not is_new_format_deploy_config(str(legacy))
legacy_kwargs = dict(kwargs, stage_configs_path=str(legacy))
stripped = AsyncOmniEngine._strip_single_engine_args(legacy_kwargs)
assert "interleave_mm_strings" not in stripped
assert "media_io_kwargs" not in stripped
assert "gpu_memory_utilization" not in stripped
# For https://github.com/vllm-project/vllm-omni/issues/3293
def test_tensor_parallel_size_none_is_handled():
"""Ensure the tensor parallel size of None isn't forwarded."""
@@ -83,6 +83,11 @@ class _FakePool:
self.stage_client = SimpleNamespace(requires_multimodal_data=False, custom_process_input_func=None)
self.aborted: list[list[str]] = []
def live_replica_ids(self) -> list[int]:
# Single always-live replica: these tests cover CFG bundling, not
# replica death, and the forward path fast-fails on an empty pool.
return [0]
async def abort_requests(self, request_ids):
self.aborted.append(list(request_ids))
+1 -4
View File
@@ -74,8 +74,6 @@ async def test_engine_dead_broadcasts_fatal_to_rpc_waiters(monkeypatch: pytest.M
rpc_async_queue=rpc_queue,
stage_pools=[],
)
orchestrator._fatal_error = "stage engine died"
orchestrator._fatal_error_stage_id = 2
async def wait_for_requests() -> None:
await asyncio.Event().wait()
@@ -91,8 +89,7 @@ async def test_engine_dead_broadcasts_fatal_to_rpc_waiters(monkeypatch: pytest.M
fatal = rpc_queue.get_nowait()
assert isinstance(fatal, ErrorMessage)
assert fatal.fatal is True
assert fatal.error == "stage engine died"
assert fatal.stage_id == 2
assert "stage engine died" in fatal.error
@dataclass
+641 -30
View File
@@ -3,7 +3,7 @@
"""Tests for error propagation paths within the Orchestrator.
Covers:
- EngineDeadError from an LLM stage poll → fatal error broadcast + shutdown
- EngineDeadError from an LLM stage poll → fatal error broadcast + replica eviction
- Diffusion stage error output (OmniRequestOutput.from_error) → routed correctly
"""
@@ -14,17 +14,30 @@ import queue
import time
from types import SimpleNamespace
import janus
import pytest
from vllm.v1.engine.exceptions import EngineDeadError
from vllm_omni.engine.messages import EngineQueueMessage, ErrorMessage, ShutdownRequestMessage
from vllm_omni.engine.messages import (
AddCompanionRequestMessage,
EngineQueueMessage,
ErrorMessage,
ShutdownRequestMessage,
StageSubmissionMessage,
)
from vllm_omni.engine.orchestrator import Orchestrator, OrchestratorRequestState
from vllm_omni.engine.stage_pool import StageUnavailableError
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
from .test_orchestrator import (
FakeOutputProcessor,
FakeStageClient,
OrchestratorFixture,
_build_harness,
_build_request_output,
_build_stage_pools,
_engine_core_outputs,
_enqueue_add_request,
_wait_for,
)
@@ -50,6 +63,22 @@ async def _get_any_output_message(fixture: OrchestratorFixture, *, timeout: floa
await asyncio.sleep(0.01)
async def _wait_for_error_message(
fixture: OrchestratorFixture,
*,
request_id: str | None = None,
max_messages: int = 50,
) -> ErrorMessage:
"""Drain output messages until the ErrorMessage arrives, skipping the
non-error outputs (e.g. stage metrics, forwarded stage results) that a
driven pipeline can interleave ahead of the failure."""
for _ in range(max_messages):
msg = await _get_any_output_message(fixture)
if isinstance(msg, ErrorMessage) and (request_id is None or msg.request_id == request_id):
return msg
raise AssertionError(f"No ErrorMessage for req={request_id} within {max_messages} messages")
@pytest.fixture
def orchestrator_factory():
fixtures: list[OrchestratorFixture] = []
@@ -72,32 +101,14 @@ def orchestrator_factory():
# ───────── EngineDeadError from LLM stage poll ─────────
class FakeDeadLLMStageClient(FakeStageClient):
"""LLM stage client that raises EngineDeadError once a request is in-flight.
Raising on the first poll races with ``add_request``: under suite load the
orchestrator can die and drain before the test enqueues anything, so no
fatal ErrorMessage is ever published. Keep returning empty outputs until
``add_request_async`` has been called, then fail on the next poll.
"""
async def get_output_async(self):
if not self.add_request_calls:
return SimpleNamespace(
outputs=[],
scheduler_stats=None,
finished_requests=None,
)
raise EngineDeadError("Stage-0 engine core is dead")
@pytest.mark.asyncio
async def test_engine_dead_error_broadcasts_fatal_and_shuts_down(orchestrator_factory) -> None:
"""When a stage raises EngineDeadError during poll, the orchestrator must:
async def test_engine_dead_error_evicts_replica_and_keeps_running(orchestrator_factory) -> None:
"""When a stage replica raises EngineDeadError during poll, the orchestrator must:
1. Enqueue a fatal error message for each affected request
2. Shut itself down (thread exits)
2. Evict the dead replica and keep running so the API server stays up
(per-replica fault isolation, #4285)
"""
stage0 = FakeDeadLLMStageClient(stage_type="llm", final_output=True)
stage0 = _DieOnDemandLLMStageClient(stage_type="llm", final_output=True)
orchestrator_fixture = orchestrator_factory([stage0])
request = SimpleNamespace(request_id="req-dead", prompt_token_ids=[1, 2])
@@ -110,6 +121,12 @@ async def test_engine_dead_error_broadcasts_fatal_and_shuts_down(orchestrator_fa
sampling_params_list=[_sampling_params()],
final_stage_id=0,
)
# Let the request dispatch before the replica dies; a replica dead on
# its very first poll can be evicted before dispatch, which routes the
# failure through the add-request guard instead (that ordering is
# covered by test_add_request_to_dead_stage_fails_request_not_server).
await _wait_for(lambda: len(stage0.add_request_calls) == 1)
stage0.die = True
# Collect the fatal error message.
msg = await _get_any_output_message(orchestrator_fixture)
@@ -118,13 +135,17 @@ async def test_engine_dead_error_broadcasts_fatal_and_shuts_down(orchestrator_fa
assert msg.type == "error"
assert msg.fatal is True
assert msg.request_id == "req-dead"
assert "Stage-0 engine core is dead" in msg.error
assert "Stage-0 replica-0 is dead" in msg.error
# The orchestrator thread should exit after the fatal error.
orchestrator_fixture.thread.join(timeout=5)
assert not orchestrator_fixture.thread.is_alive()
# The dead replica is evicted; the orchestrator keeps running.
pool = orchestrator_fixture.orchestrator.stage_pools[0]
deadline = time.monotonic() + 5.0
while pool.live_replica_ids() and time.monotonic() < deadline:
await asyncio.sleep(0.02)
assert pool.live_replica_ids() == []
assert orchestrator_fixture.thread.is_alive()
# Request state should be cleaned up.
# Affected request state should be cleaned up.
assert "req-dead" not in orchestrator_fixture.orchestrator.request_states
finally:
if orchestrator_fixture.thread.is_alive():
@@ -132,6 +153,596 @@ async def test_engine_dead_error_broadcasts_fatal_and_shuts_down(orchestrator_fa
orchestrator_fixture.thread.join(timeout=5)
class _DieOnDemandLLMStageClient(FakeStageClient):
"""LLM stage replica that raises EngineDeadError on poll once ``die`` is set."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.die = False
async def get_output_async(self):
if self.die:
raise EngineDeadError(f"Stage-{self.stage_id} replica-{self.replica_id} is dead")
return await super().get_output_async()
class _DieOnDemandDiffusionStageClient(FakeStageClient):
"""Diffusion stage replica that raises EngineDeadError on poll once ``die`` is set."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.die = False
def get_diffusion_output_nowait(self):
if self.die:
raise EngineDeadError(f"Stage-{self.stage_id} diffusion replica-{self.replica_id} is dead")
return super().get_diffusion_output_nowait()
class _UnavailableDiffusionStageClient(FakeStageClient):
"""Diffusion replica that stays live (so the pool keeps stage_type) but whose
dispatch fails with StageUnavailableError, as a downstream prewarm would when
its stage has no capacity."""
async def add_request_async(self, *args, **kwargs) -> None:
raise StageUnavailableError(f"stage {self.stage_id} has no live replica")
@pytest.mark.asyncio
async def test_engine_dead_error_fails_only_dead_replica_requests(orchestrator_factory) -> None:
"""Multi-replica stage: one replica dying must fail only the requests bound
to it. Requests bound to a surviving replica keep running, and the stage
(still having a live replica) is not torn down (per-replica fault isolation,
#4285).
"""
r0 = _DieOnDemandLLMStageClient(stage_type="llm", final_output=True)
r1 = FakeStageClient(stage_type="llm", final_output=True)
stage_pools = _build_stage_pools([[r0, r1]])
orchestrator_fixture = orchestrator_factory([], stage_pools=stage_pools)
pool = orchestrator_fixture.orchestrator.stage_pools[0]
try:
# req-0 → replica 0 (round-robin starts at 0)
await _enqueue_add_request(
orchestrator_fixture,
request_id="req-0",
prompt=SimpleNamespace(request_id="req-0", prompt_token_ids=[1, 2]),
original_prompt={"prompt": "a"},
sampling_params_list=[_sampling_params()],
final_stage_id=0,
)
await _wait_for(lambda: len(r0.add_request_calls) == 1)
# req-1 → replica 1
await _enqueue_add_request(
orchestrator_fixture,
request_id="req-1",
prompt=SimpleNamespace(request_id="req-1", prompt_token_ids=[3, 4]),
original_prompt={"prompt": "b"},
sampling_params_list=[_sampling_params()],
final_stage_id=0,
)
await _wait_for(lambda: len(r1.add_request_calls) == 1)
assert pool.get_bound_replica_id("req-0") == 0
assert pool.get_bound_replica_id("req-1") == 1
# Kill replica 0 on its next poll.
r0.die = True
msg = await _get_any_output_message(orchestrator_fixture)
assert isinstance(msg, ErrorMessage)
assert msg.fatal is True
assert msg.request_id == "req-0"
# Replica 0 evicted, replica 1 still serving; req-1 untouched.
await _wait_for(lambda: pool.live_replica_ids() == [1])
assert orchestrator_fixture.thread.is_alive()
assert "req-0" not in orchestrator_fixture.orchestrator.request_states
assert "req-1" in orchestrator_fixture.orchestrator.request_states
finally:
if orchestrator_fixture.thread.is_alive():
orchestrator_fixture.request_sync_q.put_nowait(ShutdownRequestMessage())
orchestrator_fixture.thread.join(timeout=5)
@pytest.mark.asyncio
async def test_forward_to_dead_downstream_stage_fails_request_not_server(orchestrator_factory) -> None:
"""A request mid-pipeline whose downstream stage has lost all replicas must
fail that request, not tear down the orchestrator. Forwarding to a stage with
no live replica is handled gracefully (per-replica fault isolation, #4285).
"""
stage0 = FakeStageClient(stage_type="llm", final_output=False, next_inputs=[{"prompt_token_ids": [7, 8]}])
stage1 = _DieOnDemandLLMStageClient(stage_type="llm", final_output=True)
proc0 = FakeOutputProcessor(request_outputs=[_build_request_output("req-x", token_ids=[3], finished=True)])
proc1 = FakeOutputProcessor()
stage_pools = _build_stage_pools([[stage0], [stage1]], output_processors=[proc0, proc1])
orchestrator_fixture = orchestrator_factory([], stage_pools=stage_pools)
pool1 = orchestrator_fixture.orchestrator.stage_pools[1]
try:
await _enqueue_add_request(
orchestrator_fixture,
request_id="req-x",
prompt=SimpleNamespace(request_id="req-x", prompt_token_ids=[1, 2]),
original_prompt={"prompt": "hello"},
sampling_params_list=[_sampling_params(), _sampling_params()],
final_stage_id=1,
)
await _wait_for(lambda: len(stage0.add_request_calls) == 1)
# Kill stage-1's only replica before stage-0 output is forwarded.
stage1.die = True
await _wait_for(lambda: pool1.live_replica_ids() == [])
# Completing stage 0 triggers a forward to the now-empty stage 1.
stage0.push_engine_core_outputs(_engine_core_outputs("s0-raw", 1.0))
# Completing stage 0 also emits its normal outputs; skip past them to
# the downstream-forward failure.
error_msg = await _wait_for_error_message(orchestrator_fixture, request_id="req-x")
assert error_msg.fatal is True
assert error_msg.stage_id == 1
assert orchestrator_fixture.thread.is_alive()
assert "req-x" not in orchestrator_fixture.orchestrator.request_states
finally:
if orchestrator_fixture.thread.is_alive():
orchestrator_fixture.request_sync_q.put_nowait(ShutdownRequestMessage())
orchestrator_fixture.thread.join(timeout=5)
@pytest.mark.asyncio
async def test_add_request_to_dead_stage_fails_request_not_server(orchestrator_factory) -> None:
"""A new request entering a stage that already lost all replicas fails that
request instead of raising out of the request handler and tearing the server
down. The guard runs before request state is registered, so nothing leaks.
"""
r0 = _DieOnDemandLLMStageClient(stage_type="llm", final_output=True)
stage_pools = _build_stage_pools([[r0]])
orchestrator_fixture = orchestrator_factory([], stage_pools=stage_pools)
pool = orchestrator_fixture.orchestrator.stage_pools[0]
try:
# Send a first request so the replica is polled and dies → evicted.
await _enqueue_add_request(
orchestrator_fixture,
request_id="req-0",
prompt=SimpleNamespace(request_id="req-0", prompt_token_ids=[1, 2]),
original_prompt={"prompt": "a"},
sampling_params_list=[_sampling_params()],
final_stage_id=0,
)
r0.die = True
await _wait_for(lambda: pool.live_replica_ids() == [])
# A new request now enters a stage with no live replica.
await _enqueue_add_request(
orchestrator_fixture,
request_id="req-after",
prompt=SimpleNamespace(request_id="req-after", prompt_token_ids=[3, 4]),
original_prompt={"prompt": "b"},
sampling_params_list=[_sampling_params()],
final_stage_id=0,
)
error_msg = await _wait_for_error_message(orchestrator_fixture, request_id="req-after")
assert error_msg.fatal is True
assert error_msg.stage_id == 0
assert orchestrator_fixture.thread.is_alive()
# Guard runs before registration, so the failed request never enters state.
assert "req-after" not in orchestrator_fixture.orchestrator.request_states
finally:
if orchestrator_fixture.thread.is_alive():
orchestrator_fixture.request_sync_q.put_nowait(ShutdownRequestMessage())
orchestrator_fixture.thread.join(timeout=5)
@pytest.mark.asyncio
async def test_diffusion_replica_death_on_poll_keeps_server(orchestrator_factory) -> None:
"""A diffusion stage replica raising EngineDeadError during poll must be
evicted while the orchestrator keeps serving. The per-replica catch has to
cover the diffusion polling path, not just the LLM one (#4285).
"""
stage0 = _DieOnDemandDiffusionStageClient(stage_type="diffusion", final_output=True, final_output_type="image")
stage_pools = _build_stage_pools([[stage0]])
orchestrator_fixture = orchestrator_factory([], stage_pools=stage_pools)
pool = orchestrator_fixture.orchestrator.stage_pools[0]
try:
await _enqueue_add_request(
orchestrator_fixture,
request_id="req-dit",
prompt={"prompt": "a cat"},
original_prompt={"prompt": "a cat"},
sampling_params_list=[OmniDiffusionSamplingParams()],
final_stage_id=0,
)
await _wait_for(lambda: len(stage0.add_request_calls) == 1)
stage0.die = True
error_msg = await _wait_for_error_message(orchestrator_fixture, request_id="req-dit")
assert error_msg.fatal is True
assert error_msg.stage_id == 0
await _wait_for(lambda: pool.live_replica_ids() == [])
assert orchestrator_fixture.thread.is_alive()
assert "req-dit" not in orchestrator_fixture.orchestrator.request_states
finally:
if orchestrator_fixture.thread.is_alive():
orchestrator_fixture.request_sync_q.put_nowait(ShutdownRequestMessage())
orchestrator_fixture.thread.join(timeout=5)
@pytest.mark.asyncio
async def test_async_chunk_prewarm_failure_reports_failing_downstream_stage(orchestrator_factory) -> None:
"""When an async-chunk prewarm dispatch to a downstream stage fails, the
failure must be attributed to that stage, not stage 0 (the prewarm's own
dispatch stage). Regression for the guard using stage_id=0.
"""
stage0 = FakeStageClient(stage_type="llm", final_output=False)
stage1 = FakeStageClient(stage_type="diffusion", final_output=False, final_output_type="image")
stage2 = _UnavailableDiffusionStageClient(stage_type="diffusion", final_output=True, final_output_type="image")
stage_pools = _build_stage_pools([[stage0], [stage1], [stage2]])
orchestrator_fixture = orchestrator_factory([], stage_pools=stage_pools, async_chunk=True)
try:
await _enqueue_add_request(
orchestrator_fixture,
request_id="req-x",
prompt=SimpleNamespace(request_id="req-x", prompt_token_ids=[1, 2, 3]),
original_prompt={"prompt": "a cat"},
sampling_params_list=[_sampling_params(), OmniDiffusionSamplingParams(), OmniDiffusionSamplingParams()],
final_stage_id=2,
)
error_msg = await _wait_for_error_message(orchestrator_fixture, request_id="req-x")
assert error_msg.fatal is True
assert error_msg.stage_id == 2
assert "Stage-2" in error_msg.error
# Stage 1 prewarm ran before the stage-2 failure; server stays up.
assert len(stage1.add_request_calls) == 1
assert orchestrator_fixture.thread.is_alive()
finally:
if orchestrator_fixture.thread.is_alive():
orchestrator_fixture.request_sync_q.put_nowait(ShutdownRequestMessage())
orchestrator_fixture.thread.join(timeout=5)
# ───────── Direct unit tests for the fault-isolation helpers ─────────
def _build_bare_orchestrator(stage_pools) -> tuple[Orchestrator, tuple[janus.Queue, ...]]:
"""Construct an Orchestrator on the current event loop without running it.
Lets tests await the fault-isolation helpers directly instead of driving
them through the orchestration loop.
"""
queues = (janus.Queue(), janus.Queue(), janus.Queue())
orchestrator = Orchestrator(
request_async_queue=queues[0].async_q,
output_async_queue=queues[1].async_q,
rpc_async_queue=queues[2].async_q,
stage_pools=stage_pools,
)
return orchestrator, queues
def _register_request(orchestrator: Orchestrator, request_id: str, *, submitted_stage: int | None = 0) -> None:
state = OrchestratorRequestState(request_id=request_id)
if submitted_stage is not None:
state.stage_submit_ts[submitted_stage] = time.time()
orchestrator.request_states[request_id] = state
@pytest.mark.asyncio
async def test_handle_dead_replica_fails_only_bound_requests() -> None:
"""Direct helper test: with a surviving replica, only requests bound to the
dead replica fail; the survivor's requests and bindings are untouched.
"""
r0 = FakeStageClient(stage_type="llm", final_output=True)
r1 = FakeStageClient(stage_type="llm", final_output=True)
orchestrator, queues = _build_bare_orchestrator(_build_stage_pools([[r0, r1]]))
try:
pool = orchestrator.stage_pools[0]
_register_request(orchestrator, "req-0")
_register_request(orchestrator, "req-1")
assert pool.select_replica_id("req-0") == 0
assert pool.select_replica_id("req-1") == 1
await orchestrator._handle_dead_replica(0, 0, EngineDeadError("replica-0 dead"))
assert pool.live_replica_ids() == [1]
assert r0.shutdown_calls == 1
msg = queues[1].async_q.get_nowait()
assert isinstance(msg, ErrorMessage)
assert msg.fatal is True
assert msg.request_id == "req-0"
assert msg.stage_id == 0
assert "replica-0 dead" in msg.error
assert queues[1].async_q.empty()
assert "req-0" not in orchestrator.request_states
assert "req-1" in orchestrator.request_states
assert pool.get_bound_replica_id("req-0") is None
assert pool.get_bound_replica_id("req-1") == 1
finally:
for q in queues:
q.close()
@pytest.mark.asyncio
async def test_handle_dead_replica_last_replica_fails_all_stage_requests() -> None:
"""Direct helper test: when the dead replica was the stage's last one, every
request routed through the stage fails — bound or not — while requests that
never entered the stage survive.
"""
orchestrator, queues = _build_bare_orchestrator(_build_stage_pools([[FakeStageClient(stage_type="llm")]]))
try:
pool = orchestrator.stage_pools[0]
_register_request(orchestrator, "req-bound")
_register_request(orchestrator, "req-unbound")
_register_request(orchestrator, "req-elsewhere", submitted_stage=None)
assert pool.select_replica_id("req-bound") == 0
await orchestrator._handle_dead_replica(0, 0, EngineDeadError("last replica dead"))
assert pool.live_replica_ids() == []
failed = {queues[1].async_q.get_nowait().request_id for _ in range(2)}
assert failed == {"req-bound", "req-unbound"}
assert queues[1].async_q.empty()
assert set(orchestrator.request_states) == {"req-elsewhere"}
finally:
for q in queues:
q.close()
@pytest.mark.asyncio
async def test_fail_request_dead_stage_cleans_registered_request() -> None:
"""Direct helper test: a registered request failed against a dead stage gets
a fatal ErrorMessage, its state and bindings are released, and its
in-flight work on live replicas is aborted.
"""
r0 = FakeStageClient(stage_type="llm")
orchestrator, queues = _build_bare_orchestrator(_build_stage_pools([[r0]]))
try:
pool = orchestrator.stage_pools[0]
_register_request(orchestrator, "req-a")
assert pool.select_replica_id("req-a") == 0
await orchestrator._fail_request_dead_stage("req-a", 0)
msg = queues[1].async_q.get_nowait()
assert isinstance(msg, ErrorMessage)
assert msg.error == "Stage-0 has no live replica"
assert msg.fatal is True
assert msg.request_id == "req-a"
assert msg.stage_id == 0
assert "req-a" not in orchestrator.request_states
assert pool.get_bound_replica_id("req-a") is None
# abort=True: the failed request's work on the still-live replica is
# stopped, not left to run to completion.
assert r0.abort_calls == [["req-a"]]
finally:
for q in queues:
q.close()
@pytest.mark.asyncio
async def test_streaming_update_racing_eviction_fails_request_not_server() -> None:
"""A streaming_update can find its request state still present while the
stage has already lost all replicas (_handle_dead_replica awaits between
eviction and cleanup). Submitting then raises from replica selection; the
handler must fail that request instead of letting the raise escape
_request_handler and shut the server down (#4285).
"""
orchestrator, queues = _build_bare_orchestrator(_build_stage_pools([[FakeStageClient(stage_type="llm")]]))
try:
pool = orchestrator.stage_pools[0]
_register_request(orchestrator, "req-s")
orchestrator.request_states["req-s"].sampling_params_list = [_sampling_params()]
assert pool.select_replica_id("req-s") == 0
pool.evict_replica(0)
msg = StageSubmissionMessage(
type="streaming_update",
request_id="req-s",
prompt=SimpleNamespace(request_id="req-s", prompt_token_ids=[1]),
original_prompt={"prompt": "hi"},
output_prompt_text=None,
sampling_params_list=[],
final_stage_id=0,
preprocess_ms=0.0,
request_timestamp=time.time(),
enqueue_ts=time.time(),
)
await orchestrator._handle_streaming_update(msg)
err = queues[1].async_q.get_nowait()
assert isinstance(err, ErrorMessage)
assert err.fatal is True
assert err.request_id == "req-s"
assert "req-s" not in orchestrator.request_states
finally:
for q in queues:
q.close()
@pytest.mark.asyncio
async def test_streaming_update_unrelated_error_is_not_masked() -> None:
"""The dispatch guard converts only StageUnavailableError/EngineDeadError;
a genuine bug surfacing as a plain RuntimeError must propagate (fail
fast), not be misreported as a dead-stage request failure.
"""
class _BuggyClient(FakeStageClient):
async def add_request_async(self, *args, **kwargs):
raise RuntimeError("boom: unrelated bug")
orchestrator, queues = _build_bare_orchestrator(
_build_stage_pools([[_BuggyClient(stage_type="llm", final_output=True)]])
)
try:
_register_request(orchestrator, "req-b")
orchestrator.request_states["req-b"].sampling_params_list = [_sampling_params()]
msg = StageSubmissionMessage(
type="streaming_update",
request_id="req-b",
prompt=SimpleNamespace(request_id="req-b", prompt_token_ids=[1]),
original_prompt={"prompt": "hi"},
output_prompt_text=None,
sampling_params_list=[],
final_stage_id=0,
preprocess_ms=0.0,
request_timestamp=time.time(),
enqueue_ts=time.time(),
)
with pytest.raises(RuntimeError, match="unrelated bug"):
await orchestrator._handle_streaming_update(msg)
finally:
for q in queues:
q.close()
@pytest.mark.asyncio
async def test_add_companion_racing_eviction_fails_parent_not_server() -> None:
"""A CFG companion submission hitting a fully dead stage must fail the
parent request (whose cleanup also releases the companion), not raise out
of _request_handler (#4285).
"""
orchestrator, queues = _build_bare_orchestrator(_build_stage_pools([[FakeStageClient(stage_type="llm")]]))
try:
pool = orchestrator.stage_pools[0]
_register_request(orchestrator, "req-p")
orchestrator.request_states["req-p"].sampling_params_list = [_sampling_params()]
pool.evict_replica(0)
msg = AddCompanionRequestMessage(
companion_id="req-p-cfg",
parent_id="req-p",
role="negative",
prompt=SimpleNamespace(request_id="req-p-cfg", prompt_token_ids=[2]),
companion_prompt_text=None,
sampling_params_list=[_sampling_params()],
)
await orchestrator._handle_add_companion(msg)
err = queues[1].async_q.get_nowait()
assert isinstance(err, ErrorMessage)
assert err.fatal is True
assert err.request_id == "req-p"
assert "req-p" not in orchestrator.request_states
assert "req-p-cfg" not in orchestrator.request_states
finally:
for q in queues:
q.close()
@pytest.mark.asyncio
async def test_fail_request_dead_stage_is_noop_safe_for_unregistered_request() -> None:
"""Direct helper test: the stage-0 dispatch guard calls the helper before the
request is registered; the cleanup must be a safe no-op and still emit the
fatal ErrorMessage.
"""
orchestrator, queues = _build_bare_orchestrator(_build_stage_pools([[FakeStageClient(stage_type="llm")]]))
try:
await orchestrator._fail_request_dead_stage("req-unknown", 0)
msg = queues[1].async_q.get_nowait()
assert isinstance(msg, ErrorMessage)
assert msg.error == "Stage-0 has no live replica"
assert msg.fatal is True
assert msg.request_id == "req-unknown"
assert orchestrator.request_states == {}
finally:
for q in queues:
q.close()
@pytest.mark.asyncio
async def test_dispatch_death_evicts_so_subsequent_requests_reroute_to_survivor() -> None:
"""A replica dying mid-submit (EngineDeadError from dispatch, before the poll
loop evicts it) is evicted by the guard so subsequent requests reroute to the
survivor instead of round-robining back onto the dead slot until the poll
catches up. The survivor and its request are untouched (#4285).
"""
r0 = FakeStageClient(stage_type="llm")
r1 = FakeStageClient(stage_type="llm")
orchestrator, queues = _build_bare_orchestrator(_build_stage_pools([[r0, r1]]))
try:
pool = orchestrator.stage_pools[0]
_register_request(orchestrator, "req-dead")
_register_request(orchestrator, "req-live")
assert pool.select_replica_id("req-dead") == 0
assert pool.select_replica_id("req-live") == 1
async def _die() -> None:
raise EngineDeadError("stage-0 replica-0 died on submit")
failed = await orchestrator._dispatch_or_fail_request(
_die, req_id="req-dead", stage_id=0, operation="add_request"
)
assert failed is False
# The dead replica is evicted; only the request on it fails, the survivor
# and its request are untouched.
assert pool.live_replica_ids() == [1]
assert r0.shutdown_calls == 1
assert r1.shutdown_calls == 0
msg = queues[1].async_q.get_nowait()
assert isinstance(msg, ErrorMessage) and msg.fatal is True and msg.request_id == "req-dead"
assert queues[1].async_q.empty()
assert "req-dead" not in orchestrator.request_states
assert "req-live" in orchestrator.request_states and pool.get_bound_replica_id("req-live") == 1
# The point of eviction: a burst of subsequent requests all route to the
# survivor and never land back on the evicted replica.
for rid in ("next-1", "next-2", "next-3"):
assert pool.select_replica_id(rid) == 1
finally:
for q in queues:
q.close()
@pytest.mark.asyncio
async def test_dispatch_death_evicts_so_subsequent_requests_fast_fail() -> None:
"""When the replica dying on submit was the stage's only one, the guard evicts
it (leaving the stage empty). A subsequent request then fast-fails at routing —
the ``_handle_add_request`` entry guard turns ``StageUnavailableError`` into a
fatal 5xx immediately, rather than dispatching onto the dead replica (#4285).
"""
r0 = FakeStageClient(stage_type="llm")
orchestrator, queues = _build_bare_orchestrator(_build_stage_pools([[r0]]))
try:
pool = orchestrator.stage_pools[0]
_register_request(orchestrator, "req-a")
assert pool.select_replica_id("req-a") == 0
async def _die() -> None:
raise EngineDeadError("stage-0 replica-0 died on submit")
failed = await orchestrator._dispatch_or_fail_request(_die, req_id="req-a", stage_id=0, operation="add_request")
assert failed is False
assert pool.live_replica_ids() == []
assert r0.shutdown_calls == 1
msg = queues[1].async_q.get_nowait()
assert isinstance(msg, ErrorMessage) and msg.request_id == "req-a"
assert queues[1].async_q.empty()
assert "req-a" not in orchestrator.request_states
# A subsequent request cannot be routed at all: the stage has no live
# replica, so selection raises fast instead of dispatching onto the dead
# slot. _handle_add_request converts this to a fatal 5xx at its entry guard.
with pytest.raises(StageUnavailableError):
pool.select_replica_id("req-next")
finally:
for q in queues:
q.close()
# ───────── Diffusion stage error output routing ─────────
@@ -51,6 +51,19 @@ from vllm_omni.outputs import OmniRequestOutput
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_native_input_append_supports_explicit_session_opt_out():
session = DuplexSession(
"sid-explicit-tts",
DuplexSessionConfig(model="test-model"),
capabilities=DuplexCapabilities.minicpmo45_native(),
)
assert OmniDuplexSessionHandler._uses_native_input_append(session) is True
session.config.extra_body["minicpmo45_native_duplex"] = False
assert OmniDuplexSessionHandler._uses_native_input_append(session) is False
class _ModelConfig:
model = "test-model"
@@ -15,6 +15,7 @@ from contextlib import asynccontextmanager
from pathlib import Path
from types import SimpleNamespace
import av
import numpy as np
import pytest
from fastapi import FastAPI, HTTPException
@@ -249,6 +250,25 @@ def _make_test_video_bytes(size=(32, 24), num_frames=3) -> bytes:
return mux_video_audio_bytes(frames, fps=8, video_codec_options={"preset": "ultrafast", "threads": "0"})
def test_mux_video_audio_marks_aac_priming_timestamp():
frames = np.zeros((2, 16, 16, 3), dtype=np.uint8)
audio = np.zeros((2, 2048), dtype=np.float32)
payload = mux_video_audio_bytes(
frames,
fps=24,
audio_waveform=audio,
audio_sample_rate=32000,
video_codec_options={"preset": "ultrafast", "threads": "0"},
)
with av.open(io.BytesIO(payload)) as container:
audio_stream = container.streams.audio[0]
first_packet = next(packet for packet in container.demux(audio_stream) if packet.pts is not None)
assert first_packet.pts < 0
def _make_test_video_data_url(size=(32, 24), num_frames=3) -> str:
encoded = base64.b64encode(_make_test_video_bytes(size, num_frames)).decode("utf-8")
return f"data:video/mp4;base64,{encoded}"
+71 -28
View File
@@ -104,6 +104,9 @@ class FakeAsyncOmniEngine:
]
self.supported_tasks = ("generate",)
self.stage_clients = [SimpleNamespace(is_comprehension=False) for _ in range(self.num_stages)]
# One replica per stage, sharing the stage_clients objects so a test that
# marks stage_clients[i] dead is reflected in the pool's liveness.
self.stage_pools = [_FakeStagePool([client], stage_id=i) for i, client in enumerate(self.stage_clients)]
self.stage_vllm_configs = [None for _ in range(self.num_stages)]
self.output_processors = [SimpleNamespace(tokenizer=None) for _ in range(self.num_stages)]
self.input_processor = None
@@ -969,11 +972,17 @@ async def test_async_omni_propagates_non_400_client_error_status(
assert str(exc_info.value) == error_text
class _FakeStagePool:
def __init__(self, clients: list, stage_id: int = 0):
self.clients = clients
self.stage_id = stage_id
def test_async_omni_errored_property_alive():
omni = object.__new__(AsyncOmni)
omni.engine = SimpleNamespace(
is_alive=lambda: True,
stage_clients=[SimpleNamespace(is_comprehension=False)],
stage_pools=[_FakeStagePool(clients=[SimpleNamespace()])],
)
assert omni.errored is False
@@ -983,21 +992,47 @@ def test_async_omni_errored_property_dead_engine():
omni = object.__new__(AsyncOmni)
omni.engine = SimpleNamespace(
is_alive=lambda: False,
stage_clients=[SimpleNamespace(is_comprehension=False)],
stage_pools=[_FakeStagePool(clients=[SimpleNamespace()])],
)
assert omni.errored is True
def test_async_omni_errored_property_dead_stage():
def test_async_omni_errored_false_when_stage_dead():
# errored is process-fatal only: a dead stage must not trip the serving
# precheck, or requests that never touch that stage would be rejected too.
# Stage liveness surfaces via check_health / per-request dispatch failures.
omni = object.__new__(AsyncOmni)
dead_stage = SimpleNamespace(is_comprehension=False, _engine_dead=True)
omni.engine = SimpleNamespace(
is_alive=lambda: True,
stage_clients=[dead_stage],
stage_pools=[_FakeStagePool(clients=[SimpleNamespace(_engine_dead=True)])],
)
assert omni.errored is True
assert omni.errored is False
def test_async_omni_errored_false_when_a_replica_survives():
# Per-replica fault isolation (#4285): one replica dies, another lives →
# the stage is still serving, so the engine is not errored.
omni = object.__new__(AsyncOmni)
omni.engine = SimpleNamespace(
is_alive=lambda: True,
stage_pools=[_FakeStagePool(clients=[SimpleNamespace(_engine_dead=True), SimpleNamespace()])],
)
assert omni.errored is False
def test_async_omni_errored_false_when_evicted_replica_leaves_stage_empty():
# Even a fully evicted stage (all slots None/dead) is not process-fatal;
# only orchestrator death makes errored True.
omni = object.__new__(AsyncOmni)
omni.engine = SimpleNamespace(
is_alive=lambda: True,
stage_pools=[_FakeStagePool(clients=[None, SimpleNamespace(_engine_dead=True)])],
)
assert omni.errored is False
def _enqueue_stage_error(
@@ -1067,20 +1102,22 @@ async def test_async_omni_propagates_engine_generate_error(monkeypatch: pytest.M
def test_check_health_passes_when_all_healthy():
base = _make_base()
healthy_stage = MagicMock()
healthy_stage.check_health = MagicMock()
base.engine.is_alive.return_value = True
base.engine.stage_clients = [healthy_stage]
healthy_stage = SimpleNamespace(check_health=lambda: None)
base.engine.stage_pools = [_FakeStagePool([healthy_stage])]
base.check_health() # should not raise
def test_check_health_raises_when_stage_dead():
base = _make_base()
dead_stage = MagicMock()
dead_stage.check_health = MagicMock(side_effect=EngineDeadError("Stage-1 dead"))
base.engine.is_alive.return_value = True
base.engine.stage_clients = [dead_stage]
with pytest.raises(EngineDeadError, match="Stage-1 dead"):
def _raise_dead() -> None:
raise EngineDeadError("Stage-1 engine core is dead")
dead_stage = SimpleNamespace(check_health=_raise_dead)
base.engine.stage_pools = [_FakeStagePool([dead_stage], stage_id=1)]
with pytest.raises(EngineDeadError, match="Stage-1"):
base.check_health()
@@ -1098,22 +1135,25 @@ def test_check_health_raises_when_orchestrator_dead():
def test_omni_base_errored_false_when_alive():
base = _make_base()
base.engine.is_alive.return_value = True
base.engine.stage_clients = [SimpleNamespace()]
base.engine.stage_pools = [_FakeStagePool([SimpleNamespace()])]
assert base.errored is False
def test_omni_base_is_running_false_when_stage_engine_dead():
def test_omni_base_is_running_true_when_stage_engine_dead():
# is_running tracks the orchestrator only; a dead stage must not make
# `errored and not is_running` true, which would let terminate_if_errored
# kill the server on a partial failure.
base = _make_base()
base.engine.is_alive.return_value = True
base.engine.stage_clients = [SimpleNamespace(_engine_dead=True)]
assert base.is_running is False
base.engine.stage_pools = [_FakeStagePool([SimpleNamespace(_engine_dead=True)])]
assert base.is_running is True
def test_omni_base_is_running_false_when_stage_resources_engine_dead():
def test_omni_base_is_running_true_when_stage_resources_engine_dead():
base = _make_base()
base.engine.is_alive.return_value = True
base.engine.stage_clients = [SimpleNamespace(resources=SimpleNamespace(engine_dead=True))]
assert base.is_running is False
base.engine.stage_pools = [_FakeStagePool([SimpleNamespace(resources=SimpleNamespace(engine_dead=True))])]
assert base.is_running is True
def test_omni_base_errored_true_when_orchestrator_dead():
@@ -1123,20 +1163,22 @@ def test_omni_base_errored_true_when_orchestrator_dead():
assert base.errored is True
def test_omni_base_errored_true_when_stage_engine_dead():
def test_omni_base_errored_false_when_stage_engine_dead():
# Process-fatal only: the serving precheck reads errored before routing,
# so a dead stage here would reject requests that never touch it.
base = _make_base()
base.engine.is_alive.return_value = True
dead_stage = SimpleNamespace(_engine_dead=True)
base.engine.stage_clients = [dead_stage]
assert base.errored is True
base.engine.stage_pools = [_FakeStagePool([dead_stage])]
assert base.errored is False
def test_omni_base_errored_true_when_stage_resources_engine_dead():
def test_omni_base_errored_false_when_stage_resources_engine_dead():
base = _make_base()
base.engine.is_alive.return_value = True
dead_stage = SimpleNamespace(resources=SimpleNamespace(engine_dead=True))
base.engine.stage_clients = [dead_stage]
assert base.errored is True
base.engine.stage_pools = [_FakeStagePool([dead_stage])]
assert base.errored is False
# ───────── Omni (sync) EngineDeadError / EngineGenerateError ─────────
@@ -1202,13 +1244,14 @@ def test_omni_errored_property_dead_engine(monkeypatch: pytest.MonkeyPatch):
def test_omni_errored_property_dead_stage(monkeypatch: pytest.MonkeyPatch):
"""Omni.errored returns True when a stage client is marked dead."""
"""Omni.errored stays False when only a stage client dies (process-fatal
semantics): the request-level failure and check_health carry the signal."""
engine = FakeAsyncOmniEngine(stage_metadata=THREE_STAGE_META)
_patch_engine(monkeypatch, engine)
app = Omni("dummy-model")
try:
engine.stage_clients[0]._engine_dead = True
assert app.errored is True
assert app.errored is False
finally:
app.shutdown()
+6 -1
View File
@@ -3179,7 +3179,12 @@ def iter_omni_server(
print("OmniServer stopping...")
else:
if stage_config_path is not None:
server_args += ["--deploy-config", stage_config_path]
from vllm_omni.entrypoints.utils import is_new_format_deploy_config
if is_new_format_deploy_config(stage_config_path):
server_args += ["--deploy-config", stage_config_path]
else:
server_args += ["--stage-configs-path", stage_config_path]
with (
OmniServer(
@@ -168,7 +168,7 @@ class TestDeployTopology:
assert memory_utilizations == [
0.55,
0.15,
0.15,
0.18,
]
assert sum(memory_utilizations) <= 0.9 + 1e-6
# Daily-Omni minicpm-interleave: up to 64 image/audio items (+ optional video).
@@ -12,7 +12,9 @@ from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni import (
MiniCPMO45OmniForConditionalGeneration,
)
from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni_tts import (
_REPETITION_PENALTY_CHUNK_SIZE,
MiniCPMO45OmniTTSForConditionalGeneration,
_apply_batched_repetition_penalty,
_max_audio_tokens,
_restore_weight_norm_weight,
)
@@ -73,6 +75,50 @@ def _routed(output, index: int):
)
def _reference_repetition_penalty(
logits: torch.Tensor,
history: torch.Tensor,
*,
penalty: float,
window_size: int,
) -> torch.Tensor:
if penalty == 1.0 or history.numel() == 0:
return logits
recent = history.reshape(-1)[-window_size:].to(device=logits.device, dtype=torch.long)
frequencies = torch.bincount(recent, minlength=logits.shape[-1]).to(dtype=logits.dtype)
alpha = torch.pow(torch.as_tensor(penalty, device=logits.device, dtype=logits.dtype), frequencies)
return torch.where(logits < 0, logits * alpha, logits / alpha)
def _make_sampling_talker() -> MiniCPMO45OmniTTSForConditionalGeneration:
talker = _make_talker()
talker._codec_temperature = 0.8
talker._codec_top_k = 5
talker._codec_top_p = 0.85
talker._codec_repetition_penalty = 1.05
talker._request_audio_states = {
"req-a": {"min_tokens": 2},
"req-b": {"min_tokens": 0},
}
talker.head_code = nn.ModuleList([nn.Linear(2, 8, bias=False)])
with torch.no_grad():
talker.head_code[0].weight.copy_(
torch.tensor(
[
[0.1, -0.3],
[0.2, 0.4],
[-0.5, 0.2],
[0.7, -0.1],
[-0.2, 0.8],
[0.6, 0.3],
[-0.4, -0.7],
[0.3, 0.5],
]
)
)
return talker
@pytest.mark.parametrize(
("condition_tokens", "expected"),
[(3, 64), (100, 1000), (1000, 2048)],
@@ -101,16 +147,153 @@ def test_weight_norm_restore_matches_checkpoint_parametrization_in_bfloat16() ->
assert torch.equal(restored, linear.weight)
def test_batched_repetition_penalty_matches_request_local_rows() -> None:
logits = torch.tensor(
[
[-2.0, -1.0, 1.0, 2.0, 3.0, 4.0],
[4.0, 3.0, 2.0, 1.0, -1.0, -2.0],
[1.0, -1.0, 2.0, -2.0, 3.0, -3.0],
]
)
histories = [
torch.tensor([1, 1, 2, 5]),
torch.tensor([0, 4, 4]),
torch.empty(0, dtype=torch.long),
]
actual = _apply_batched_repetition_penalty(
logits,
histories,
penalty=1.2,
window_size=3,
)
expected = torch.cat(
[
_reference_repetition_penalty(
logits[row : row + 1],
history,
penalty=1.2,
window_size=3,
)
for row, history in enumerate(histories)
],
dim=0,
)
assert torch.equal(actual, expected)
def test_batched_repetition_penalty_matches_rows_across_chunks(mocker) -> None:
batch_size = 2 * _REPETITION_PENALTY_CHUNK_SIZE + 1
vocab_size = 11
logits = torch.arange(batch_size * vocab_size, dtype=torch.float32).reshape(batch_size, vocab_size) - 100
histories = [
torch.tensor([(row + offset) % vocab_size for offset in range(row % 7)], dtype=torch.long)
for row in range(batch_size)
]
expected = torch.cat(
[
_reference_repetition_penalty(
logits[row : row + 1],
history,
penalty=1.2,
window_size=5,
)
for row, history in enumerate(histories)
],
dim=0,
)
bincount = mocker.spy(torch, "bincount")
actual = _apply_batched_repetition_penalty(
logits,
histories,
penalty=1.2,
window_size=5,
)
assert torch.equal(actual, expected)
assert [call.kwargs["minlength"] for call in bincount.call_args_list] == [
_REPETITION_PENALTY_CHUNK_SIZE * vocab_size,
_REPETITION_PENALTY_CHUNK_SIZE * vocab_size,
vocab_size,
]
def test_batched_codec_sampling_is_request_order_independent() -> None:
talker = _make_sampling_talker()
histories = [torch.tensor([1, 1, 2]), torch.tensor([3, 4, 4])]
hidden = torch.tensor([[0.25, -0.75], [-0.5, 0.5]])
forward_order = talker._sample_audio_codes(
hidden,
histories,
["req-a", "req-b"],
[0, 0],
)
talker._request_generators = {}
reverse_order = talker._sample_audio_codes(
hidden.flip(0),
list(reversed(histories)),
["req-b", "req-a"],
[0, 0],
)
assert torch.equal(forward_order[0], reverse_order[1])
assert torch.equal(forward_order[1], reverse_order[0])
def test_batched_codec_sampling_preserves_request_rng_after_compaction() -> None:
talker = _make_sampling_talker()
history_a = torch.tensor([1, 1, 2])
history_b = torch.tensor([3, 4, 4])
hidden_a = torch.tensor([[0.25, -0.75]])
hidden_b = torch.tensor([[-0.5, 0.5]])
first_batch = talker._sample_audio_codes(
torch.cat([hidden_a, hidden_b]),
[history_a, history_b],
["req-a", "req-b"],
[0, 0],
)
compacted_b = talker._sample_audio_codes(
hidden_b,
[history_b],
["req-b"],
[1],
)
talker._request_generators = {}
standalone_b_first = talker._sample_audio_codes(
hidden_b,
[history_b],
["req-b"],
[0],
)[0]
standalone_b_second = talker._sample_audio_codes(
hidden_b,
[history_b],
["req-b"],
[1],
)[0]
assert torch.equal(first_batch[1], standalone_b_first)
assert torch.equal(compacted_b[0], standalone_b_second)
def test_talker_emits_request_aligned_codec_deltas_after_compaction(mocker) -> None:
talker = _make_talker()
seen: list[tuple[str, list[float], list[int]]] = []
def sample(hidden, history, request_id, step):
assert step == 0
seen.append((request_id, hidden.reshape(-1).tolist(), history.tolist()))
return torch.tensor(2 if request_id == "req-a" else 3)
def sample(hidden, histories, request_ids, steps):
assert steps == [0, 0]
seen.extend(
(request_id, hidden[row].reshape(-1).tolist(), histories[row].tolist())
for row, request_id in enumerate(request_ids)
)
return torch.tensor([2, 3])
mocker.patch.object(talker, "_sample_audio_code", side_effect=sample)
batched_sample = mocker.patch.object(talker, "_sample_audio_codes", side_effect=sample)
infos = [
{"request_id": "req-a", "audio_codes": {"accumulated": torch.tensor([1])}},
{"request_id": "req-b", "audio_codes": {"accumulated": torch.empty(0, dtype=torch.long)}},
@@ -126,6 +309,7 @@ def test_talker_emits_request_aligned_codec_deltas_after_compaction(mocker) -> N
("req-a", [2.0, 0.0], [1]),
("req-b", [3.0, 0.0], []),
]
batched_sample.assert_called_once()
assert infos[0]["audio_codes"]["accumulated"].tolist() == [1, 2]
assert infos[1]["audio_codes"]["accumulated"].tolist() == [3]
assert set(output.multimodal_outputs) == {"codes", "meta"}
@@ -140,7 +324,7 @@ def test_talker_emits_request_aligned_codec_deltas_after_compaction(mocker) -> N
def test_talker_projects_request_aligned_duplex_metadata(mocker) -> None:
talker = _make_talker()
mocker.patch.object(talker, "_sample_audio_code", return_value=torch.tensor(2))
mocker.patch.object(talker, "_sample_audio_codes", return_value=torch.tensor([2, 2]))
infos = [
{
"request_id": "req-a",
@@ -186,7 +370,7 @@ def test_talker_projects_request_aligned_duplex_metadata(mocker) -> None:
def test_talker_rejects_native_duplex_without_fence_identity(mocker) -> None:
talker = _make_talker()
mocker.patch.object(talker, "_sample_audio_code", return_value=torch.tensor(2))
mocker.patch.object(talker, "_sample_audio_codes", return_value=torch.tensor([2]))
info = {
"request_id": "req-missing-fence",
"native_duplex": True,
@@ -203,7 +387,7 @@ def test_talker_rejects_native_duplex_without_fence_identity(mocker) -> None:
def test_incomplete_prefill_emits_no_code_and_does_not_advance_state(mocker) -> None:
talker = _make_talker()
sample = mocker.patch.object(talker, "_sample_audio_code", return_value=torch.tensor(2))
sample = mocker.patch.object(talker, "_sample_audio_codes", return_value=torch.tensor([2]))
infos = [
{
"request_id": "req-prefill",
@@ -225,7 +409,7 @@ def test_incomplete_prefill_emits_no_code_and_does_not_advance_state(mocker) ->
)
sample.assert_called_once()
assert sample.call_args.args[2] == "req-decode"
assert sample.call_args.args[2] == ["req-decode"]
assert infos[0]["audio_state"]["step"] == 0
assert infos[0]["audio_codes"]["accumulated"].numel() == 0
assert infos[1]["audio_state"]["step"] == 5
@@ -235,7 +419,7 @@ def test_incomplete_prefill_emits_no_code_and_does_not_advance_state(mocker) ->
def test_eos_is_terminal_once_and_never_enters_codec_history(mocker) -> None:
talker = _make_talker()
sample = mocker.patch.object(talker, "_sample_audio_code", return_value=torch.tensor(7))
sample = mocker.patch.object(talker, "_sample_audio_codes", return_value=torch.tensor([7]))
info = {
"request_id": "req-stop",
"audio_state": {"step": 3},
@@ -265,7 +449,7 @@ def test_eos_is_terminal_once_and_never_enters_codec_history(mocker) -> None:
def test_max_token_terminal_drops_unconsumed_codec_delta(mocker) -> None:
talker = _make_talker()
mocker.patch.object(talker, "_sample_audio_code", return_value=torch.tensor(3))
mocker.patch.object(talker, "_sample_audio_codes", return_value=torch.tensor([3]))
info = {
"request_id": "req-limit",
"audio_state": {"step": 1, "max_tokens": 2},
@@ -288,7 +472,7 @@ def test_max_token_terminal_drops_unconsumed_codec_delta(mocker) -> None:
def test_request_local_state_survives_missing_runner_buffer_update(mocker) -> None:
talker = _make_talker()
mocker.patch.object(talker, "_sample_audio_code", return_value=torch.tensor(3))
mocker.patch.object(talker, "_sample_audio_codes", return_value=torch.tensor([3]))
first_info = {
"request_id": "req-local-state",
"audio_state": {"step": 1, "max_tokens": 3},
@@ -24,7 +24,7 @@ class _FakeDecoder(nn.Module):
super().__init__()
self.total_upsample = total_upsample
self.decode_calls: list[dict[str, int]] = []
self.batched_decode_calls: list[dict[str, int]] = []
self.batched_decode_calls: list[dict[str, object]] = []
self.decode_codes: list[torch.Tensor] = []
self.cudagraph_calls: list[dict[str, int | torch.device]] = []
@@ -35,6 +35,7 @@ class _FakeDecoder(nn.Module):
self,
codes: torch.Tensor,
*,
caches=None,
chunk_size: int = 300,
left_context_size: int = 25,
) -> torch.Tensor:
@@ -58,6 +59,7 @@ class _FakeDecoder(nn.Module):
codes: torch.Tensor,
lengths: list[int],
*,
caches=None,
chunk_size: int = 300,
left_context_size: int = 25,
max_batch_size: int = 0,
@@ -69,14 +71,28 @@ class _FakeDecoder(nn.Module):
"max_batch_size": max_batch_size,
"codes_shape": tuple(codes.shape),
"lengths": tuple(lengths),
"caches": caches,
}
)
if caches is not None:
outputs = []
for row, (length, cache) in enumerate(zip(lengths, caches, strict=True)):
output = self.chunked_decode(
codes[row : row + 1, :, :length],
caches=cache,
chunk_size=chunk_size,
left_context_size=left_context_size,
)
outputs.append(output[0])
return outputs
batch = codes.shape[0]
frames = codes.shape[-1]
wav_len = frames * self.total_upsample + 6
wav = torch.arange(wav_len, dtype=torch.float32).view(1, 1, -1)
offsets = torch.arange(batch, dtype=torch.float32).view(batch, 1, 1) * 1000
return wav.expand(batch, 1, wav_len) + offsets
outputs = wav.expand(batch, 1, wav_len) + offsets
return [outputs[row, :, : lengths[row] * self.total_upsample] for row in range(batch)]
def enable_cudagraph(self, **kwargs):
self.cudagraph_calls.append(kwargs)
@@ -157,7 +173,7 @@ def _load_weights_noop(model: Qwen3TTSCode2Wav) -> set[str]:
return model.load_weights(iter(()))
def test_forward_trims_context_on_exact_frame_boundaries():
def test_forward_uses_decoder_audio_contract_on_exact_frame_boundaries():
model = _make_model()
out = model.forward(
@@ -170,7 +186,7 @@ def test_forward_trims_context_on_exact_frame_boundaries():
torch.testing.assert_close(audio, expected)
def test_forward_trims_trailing_padding_without_context():
def test_forward_uses_decoder_audio_contract_without_context():
model = _make_model()
out = model.forward(
@@ -183,24 +199,40 @@ def test_forward_trims_trailing_padding_without_context():
torch.testing.assert_close(audio, expected)
def test_forward_reuses_cached_ref_context_for_followup_chunk():
@pytest.mark.parametrize("skipped_length", [0, 1], ids=["empty", "malformed"])
def test_stateless_batch_uses_original_request_index_for_left_context(skipped_length):
model = _make_model()
first_codes = torch.tensor(
[
9,
8,
1,
2,
9,
8,
3,
4,
skipped_request = torch.zeros(skipped_length, dtype=torch.long)
valid_request = torch.tensor([9, 8, 1, 2, 9, 8, 3, 4], dtype=torch.long)
out = model.forward(
input_ids=torch.cat([skipped_request, valid_request]),
seq_token_counts=[skipped_length, valid_request.numel()],
runtime_additional_information=[
{
"codes": {"audio": torch.zeros(skipped_length, dtype=torch.float32)},
"meta": {"left_context_size": 0},
},
{
"codes": {"audio": torch.zeros(8, dtype=torch.float32)},
"meta": {"left_context_size": 2},
},
],
dtype=torch.long,
)
model.forward(
input_ids=first_codes,
wav_output = out.multimodal_outputs["model_outputs"]
torch.testing.assert_close(wav_output[0], torch.empty(0))
torch.testing.assert_close(wav_output[1], torch.arange(8, 16, dtype=torch.float32))
def test_stateless_forward_trims_reference_prefix_from_decoder_output():
model = _make_model()
def _incremental_decode(codes, lengths, *, caches, **_kwargs):
return torch.arange(16, dtype=torch.float32).view(1, 1, -1)
model.decoder.batched_chunked_decode = _incremental_decode
out = model.forward(
input_ids=torch.arange(8, dtype=torch.long),
runtime_additional_information=[
{
"meta": {
@@ -213,104 +245,48 @@ def test_forward_reuses_cached_ref_context_for_followup_chunk():
],
)
followup_codes = torch.tensor([5, 6, 7, 15, 16, 17], dtype=torch.long)
audio = out.multimodal_outputs["model_outputs"][0]
torch.testing.assert_close(audio, torch.arange(8, 16, dtype=torch.float32))
def test_request_aware_forward_passes_only_delta_frames_to_decoder():
model = _make_model(async_chunk=True)
model.forward(
input_ids=followup_codes,
runtime_additional_information=[
{
"meta": {
"left_context_size": 3,
"ref_context_size": 2,
"ref_context_request_id": "rid",
"ref_context_included": False,
"finished": torch.tensor(True),
}
input_ids=torch.arange(6, dtype=torch.long),
runtime_additional_information=[{"meta": {"request_id": "rid", "left_context_size": 0}}],
)
model.forward(
input_ids=torch.arange(4, dtype=torch.long),
runtime_additional_information=[{"meta": {"request_id": "rid", "left_context_size": 0}}],
)
assert [tuple(codes.shape) for codes in model.decoder.decode_codes[-2:]] == [
(1, _NUM_QUANTIZERS, 3),
(1, _NUM_QUANTIZERS, 2),
]
def test_four_frame_first_chunk_uses_configured_initial_size_without_ramp():
model = _make_model(
async_chunk=True,
stage_connector_config={
"extra": {
"codec_chunk_frames": 25,
"initial_codec_chunk_frames": 4,
}
],
},
)
_load_weights_noop(model)
model.forward(
input_ids=torch.arange(8, dtype=torch.long),
runtime_additional_information=[{"meta": {"request_id": "four-frame-request", "left_context_size": 0}}],
)
torch.testing.assert_close(
model.decoder.decode_codes[-1],
torch.tensor(
[
[
[9, 8, 5, 6, 7],
[9, 8, 15, 16, 17],
]
],
dtype=torch.long,
),
)
assert "rid" not in model._ref_context_cache
def test_forward_fails_fast_when_ref_context_cache_is_missing():
model = _make_model()
followup_codes = torch.tensor([5, 6, 7, 15, 16, 17], dtype=torch.long)
with pytest.raises(ValueError, match="Missing Qwen3-TTS ref context cache"):
model.forward(
input_ids=followup_codes,
runtime_additional_information=[
{
"meta": {
"left_context_size": 3,
"ref_context_size": 2,
"ref_context_request_id": "rid",
"ref_context_included": False,
}
}
],
)
def test_ref_context_cache_evicts_lru_entries_when_requests_abort():
model = _make_model()
model._ref_context_cache_max_entries = 2
first_codes = torch.tensor([9, 8, 1, 2, 9, 8, 3, 4], dtype=torch.long)
for rid in ("a", "b", "c"):
model.forward(
input_ids=first_codes,
runtime_additional_information=[
{
"meta": {
"left_context_size": 2,
"ref_context_size": 2,
"ref_context_request_id": rid,
"ref_context_included": True,
}
}
],
)
assert list(model._ref_context_cache) == ["b", "c"]
assert model._ref_context_cache_bytes == sum(model._tensor_nbytes(t) for t in model._ref_context_cache.values())
def test_ref_context_cache_evicts_to_byte_cap():
model = _make_model()
model._ref_context_cache_max_entries = 100
model._ref_context_cache_max_bytes = 33
first_codes = torch.tensor([9, 8, 1, 2, 9, 8, 3, 4], dtype=torch.long)
for rid in ("a", "b"):
model.forward(
input_ids=first_codes,
runtime_additional_information=[
{
"meta": {
"left_context_size": 2,
"ref_context_size": 2,
"ref_context_request_id": rid,
"ref_context_included": True,
}
}
],
)
assert list(model._ref_context_cache) == ["b"]
assert model._ref_context_cache_bytes == 32
assert model.decoder._initial_codec_chunk_frames == 4
assert model.decoder._incremental_chunk_ramp == []
assert tuple(model.decoder.decode_codes[-1].shape) == (1, _NUM_QUANTIZERS, 4)
def test_connector_codec_chunking_does_not_override_decode_chunking():
@@ -332,7 +308,7 @@ def test_connector_codec_chunking_does_not_override_decode_chunking():
model.forward(
input_ids=torch.arange(12, dtype=torch.long),
runtime_additional_information=[{"meta": {"left_context_size": 0}}],
runtime_additional_information=[{"meta": {"request_id": "rid", "left_context_size": 0}}],
)
assert model.decoder.decode_calls[-1] == {
@@ -391,6 +367,66 @@ def test_forward_emits_zero_samples_for_empty_codec_payload():
assert out.multimodal_outputs["model_outputs"][0].numel() == 0
def test_dummy_runtime_information_provides_finished_request_state():
model = _make_model(async_chunk=True)
runtime_info = model.get_dummy_runtime_additional_information(2)
assert len(runtime_info) == 2
assert [info["meta"]["request_id"] for info in runtime_info] == [
"__qwen3_tts_dummy_run__0",
"__qwen3_tts_dummy_run__1",
]
assert all(info["meta"]["finished"] for info in runtime_info)
def test_dummy_request_state_is_marked_for_graph_stats_suppression():
model = _make_model(async_chunk=True)
runtime_info = model.get_dummy_runtime_additional_information(1)
model.forward(
input_ids=torch.arange(4, dtype=torch.long),
runtime_additional_information=runtime_info,
)
cache = model.decoder.batched_decode_calls[0]["caches"][0]
assert cache["_is_dummy_run"] is True
assert model._decoder_state_cache == {}
def test_decoder_state_cache_capacity_warns_without_evicting_or_failing():
model = _make_model(async_chunk=True)
assert model._decoder_state_cache_warn_entries == 512
model._decoder_state_cache.update({f"active-{index}": {"prefix_frames": 0} for index in range(512)})
with patch("vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_code2wav.logger.warning_once") as warning:
model.forward(
input_ids=torch.arange(4, dtype=torch.long),
runtime_additional_information=[{"meta": {"request_id": "new-request"}}],
)
warning.assert_called_once()
assert len(model._decoder_state_cache) == 513
assert "new-request" in model._decoder_state_cache
def test_decoder_state_cache_uses_scheduler_request_id_for_finished_cleanup():
model = _make_model(async_chunk=True)
model.forward(
input_ids=torch.arange(4, dtype=torch.long),
request_ids=["internal-request-id"],
runtime_additional_information=[{"meta": {"request_id": "external-request-id"}}],
)
assert "internal-request-id" in model._decoder_state_cache
assert "external-request-id" not in model._decoder_state_cache
model.on_requests_finished(["internal-request-id"])
assert model._decoder_state_cache == {}
def test_forward_batches_equal_length_requests_in_one_decoder_call():
model = _make_model()
@@ -403,11 +439,15 @@ def test_forward_batches_equal_length_requests_in_one_decoder_call():
],
)
assert model.decoder.decode_calls == [
assert model.decoder.decode_calls == []
assert model.decoder.batched_decode_calls == [
{
"chunk_size": 300,
"left_context_size": 25,
"max_batch_size": 0,
"codes_shape": (2, _NUM_QUANTIZERS, 3),
"lengths": (3, 3),
"caches": None,
}
]
audios = out.multimodal_outputs["model_outputs"]
@@ -415,10 +455,81 @@ def test_forward_batches_equal_length_requests_in_one_decoder_call():
torch.testing.assert_close(audios[1], torch.arange(1004, 1012, dtype=torch.float32))
def test_forward_uses_variable_length_chunk_batching_for_long_bucket_group():
def test_forward_batches_request_aware_decoder_states():
model = _make_model(async_chunk=True)
calls = []
def _batched_chunked_decode(codes, lengths, caches, **kwargs):
calls.append((codes, lengths, caches))
return [torch.arange(16, dtype=torch.float32).view(1, -1) + row * 100 for row in range(2)]
model.decoder.batched_chunked_decode = _batched_chunked_decode
per_request = torch.tensor([9, 8, 1, 2, 9, 8, 3, 4], dtype=torch.long)
out = model.forward(
input_ids=torch.cat([per_request, per_request]),
seq_token_counts=[8, 8],
runtime_additional_information=[
{
"meta": {
"left_context_size": 2,
"ref_context_size": 2,
"ref_context_request_id": request_id,
"request_id": request_id,
"ref_context_included": True,
}
}
for request_id in ("a", "b")
],
)
assert len(calls) == 1
assert tuple(calls[0][0].shape) == (2, 2, 4)
assert calls[0][1] == [4, 4]
assert [cache["prefix_frames"] for cache in calls[0][2]] == [2, 2]
audios = out.multimodal_outputs["model_outputs"]
torch.testing.assert_close(audios[0], torch.arange(16, dtype=torch.float32))
torch.testing.assert_close(audios[1], torch.arange(100, 116, dtype=torch.float32))
def test_forward_allows_followup_delta_without_reference_prefix():
model = _make_model(async_chunk=True)
first_codes = torch.tensor([9, 8, 1, 2, 9, 8, 3, 4], dtype=torch.long)
model.forward(
input_ids=first_codes,
runtime_additional_information=[
{
"meta": {
"request_id": "rid",
"ref_context_request_id": "rid",
"ref_context_size": 2,
"ref_context_included": True,
}
}
],
)
model.forward(
input_ids=torch.tensor([5, 6, 7, 15, 16, 17], dtype=torch.long),
runtime_additional_information=[
{
"meta": {
"request_id": "rid",
"ref_context_request_id": "rid",
"ref_context_size": 2,
"ref_context_included": False,
}
}
],
)
assert [tuple(codes.shape) for codes in model.decoder.decode_codes[-2:]] == [
(1, _NUM_QUANTIZERS, 4),
(1, _NUM_QUANTIZERS, 3),
]
def test_forward_passes_variable_length_padded_batch_to_decoder():
model = _make_model()
model._decode_batch_bucket_frames = [4]
model._decode_variable_chunk_batch_min_frames = 1
out = model.forward(
input_ids=torch.arange(12, dtype=torch.long),
@@ -437,6 +548,7 @@ def test_forward_uses_variable_length_chunk_batching_for_long_bucket_group():
"max_batch_size": 0,
"codes_shape": (2, _NUM_QUANTIZERS, 4),
"lengths": (2, 4),
"caches": None,
}
]
audios = out.multimodal_outputs["model_outputs"]
@@ -444,108 +556,6 @@ def test_forward_uses_variable_length_chunk_batching_for_long_bucket_group():
torch.testing.assert_close(audios[1], torch.arange(1008, 1016, dtype=torch.float32))
def test_forward_bucket_batches_different_length_requests_and_trims_rows():
model = _make_model()
model._decode_batch_bucket_frames = [4]
out = model.forward(
input_ids=torch.arange(18, dtype=torch.long),
seq_token_counts=[4, 6, 8],
runtime_additional_information=[
{"meta": {"left_context_size": 0}},
{"meta": {"left_context_size": 1}},
{"meta": {"left_context_size": 2}},
],
)
assert model.decoder.decode_calls == [
{
"chunk_size": 300,
"left_context_size": 25,
"codes_shape": (3, _NUM_QUANTIZERS, 4),
}
]
assert model.decoder.batched_decode_calls == []
audios = out.multimodal_outputs["model_outputs"]
torch.testing.assert_close(audios[0], torch.arange(8, dtype=torch.float32))
torch.testing.assert_close(audios[1], torch.arange(1004, 1012, dtype=torch.float32))
torch.testing.assert_close(audios[2], torch.arange(2008, 2016, dtype=torch.float32))
def test_forward_bucket_pads_only_to_group_max_frame_length():
model = _make_model()
model._decode_batch_bucket_frames = [8]
out = model.forward(
input_ids=torch.arange(10, dtype=torch.long),
seq_token_counts=[4, 6],
runtime_additional_information=[
{"meta": {"left_context_size": 0}},
{"meta": {"left_context_size": 0}},
],
)
assert model.decoder.decode_calls == [
{
"chunk_size": 300,
"left_context_size": 25,
"codes_shape": (2, _NUM_QUANTIZERS, 3),
}
]
assert model.decoder.batched_decode_calls == []
audios = out.multimodal_outputs["model_outputs"]
torch.testing.assert_close(audios[0], torch.arange(8, dtype=torch.float32))
torch.testing.assert_close(audios[1], torch.arange(1000, 1012, dtype=torch.float32))
def test_forward_splits_bucket_groups_by_configured_max_batch_size():
model = _make_model()
model._decode_batch_bucket_frames = [4]
model._decode_batch_max_size = 2
model.forward(
input_ids=torch.arange(18, dtype=torch.long),
seq_token_counts=[4, 6, 8],
runtime_additional_information=[
{"meta": {"left_context_size": 0}},
{"meta": {"left_context_size": 0}},
{"meta": {"left_context_size": 0}},
],
)
assert model.decoder.decode_calls == [
{
"chunk_size": 300,
"left_context_size": 25,
"codes_shape": (2, _NUM_QUANTIZERS, 3),
},
{
"chunk_size": 300,
"left_context_size": 25,
"codes_shape": (1, _NUM_QUANTIZERS, 4),
},
]
assert model.decoder.batched_decode_calls == []
def test_forward_does_not_pad_singleton_bucket_group():
model = _make_model()
model._decode_batch_bucket_frames = [4]
model.forward(
input_ids=torch.arange(4, dtype=torch.long),
runtime_additional_information=[{"meta": {"left_context_size": 0}}],
)
assert model.decoder.decode_calls == [
{
"chunk_size": 300,
"left_context_size": 25,
"codes_shape": (1, _NUM_QUANTIZERS, 2),
}
]
def test_decode_chunking_override_is_passed_to_cudagraph():
model = _make_model(
async_chunk=True,
@@ -563,27 +573,26 @@ def test_decode_chunking_override_is_passed_to_cudagraph():
_load_weights_noop(model)
assert model.decoder.cudagraph_calls[-1] == {
"capture_sizes": None,
"capture_batch_sizes": None,
"extra_capture_shapes": None,
"compile_shapes": None,
"stateless_capture_sizes": None,
"device": torch.device("cuda"),
"codec_chunk_frames": 25,
"codec_left_context_frames": 72,
"initial_codec_chunk_frames": 1,
"codec_chunk_ramp": None,
"async_chunk": True,
"decode_chunk_size": 400,
"decode_left_context": 17,
}
def test_cudagraph_capture_shapes_can_be_configured():
def test_cudagraph_batch_config_is_preserved():
model = _make_model(
async_chunk=True,
device=torch.device("cuda"),
stage_connector_config={
"extra": {
"decode_cudagraph_capture_sizes": "97,325",
"decode_cudagraph_batch_sizes": [1, 2, 4, 8],
"decode_cudagraph_extra_capture_shapes": ["3:325", [5, 325]],
}
},
)
@@ -591,26 +600,39 @@ def test_cudagraph_capture_shapes_can_be_configured():
_load_weights_noop(model)
call = model.decoder.cudagraph_calls[-1]
assert call["capture_sizes"] == [97, 325]
assert call["capture_batch_sizes"] == [1, 2, 4, 8]
assert call["extra_capture_shapes"] == [(3, 325), (5, 325)]
def test_decode_compile_shapes_can_be_configured():
def test_stateless_cudagraph_capture_sizes_are_passed_from_config():
model = _make_model(
async_chunk=False,
device=torch.device("cuda"),
stage_connector_config={
"extra": {
"decode_cudagraph_capture_sizes": [97, 400],
}
},
)
_load_weights_noop(model)
assert model.decoder.cudagraph_calls[-1]["stateless_capture_sizes"] == [97, 400]
def test_async_cudagraph_ignores_stateless_capture_sizes():
model = _make_model(
async_chunk=True,
device=torch.device("cuda"),
stage_connector_config={
"extra": {
"decode_compile_shapes": ["1:325", [1, 73]],
"decode_cudagraph_capture_sizes": [97, 400],
}
},
)
_load_weights_noop(model)
call = model.decoder.cudagraph_calls[-1]
assert call["compile_shapes"] == [(1, 73), (1, 325)]
assert model.decoder.cudagraph_calls[-1]["stateless_capture_sizes"] is None
def test_decode_tf32_can_be_configured():
@@ -642,23 +664,19 @@ def test_decode_tf32_can_be_configured():
torch.set_float32_matmul_precision(old_matmul_precision)
def test_decode_batch_bucket_frames_can_be_configured():
def test_decode_batch_max_size_can_be_configured():
model = _make_model(
async_chunk=True,
stage_connector_config={
"extra": {
"decode_batch_bucket_frames": "73,169",
"decode_batch_max_size": 10,
"decode_variable_chunk_batch_min_frames": 512,
}
},
)
_load_weights_noop(model)
assert model._decode_batch_bucket_frames == [73, 169]
assert model._decode_batch_max_size == 10
assert model._decode_variable_chunk_batch_min_frames == 512
def test_invalid_decode_batch_max_size_is_rejected():
File diff suppressed because it is too large. Load diff
@@ -1,125 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import inspect
import pytest
import torch
from transformers.masking_utils import create_sliding_window_causal_mask
from vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz.configuration_qwen3_tts_tokenizer_v2 import (
Qwen3TTSTokenizerV2DecoderConfig,
)
from vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz.modeling_qwen3_tts_tokenizer_v2 import (
Qwen3TTSTokenizerV2DecoderTransformerModel,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _make_config(attention_implementation: str) -> Qwen3TTSTokenizerV2DecoderConfig:
config = Qwen3TTSTokenizerV2DecoderConfig(
hidden_size=32,
latent_dim=16,
max_position_embeddings=512,
num_attention_heads=4,
num_key_value_heads=4,
intermediate_size=64,
num_hidden_layers=1,
num_quantizers=2,
decoder_dim=32,
upsample_rates=(2,),
upsampling_ratios=(2,),
)
config._attn_implementation = attention_implementation
return config
def _make_model(
attention_implementation: str,
dtype: torch.dtype = torch.float32,
) -> Qwen3TTSTokenizerV2DecoderTransformerModel:
model = Qwen3TTSTokenizerV2DecoderTransformerModel._from_config(_make_config(attention_implementation))
return model.to(dtype=dtype).eval()
def _create_reference_mask(
model: Qwen3TTSTokenizerV2DecoderTransformerModel,
inputs_embeds: torch.Tensor,
) -> torch.Tensor | None:
sequence_length = inputs_embeds.shape[1]
mask_kwargs = {
"config": model.config,
"attention_mask": None,
"past_key_values": None,
"position_ids": None,
}
sig = inspect.signature(create_sliding_window_causal_mask)
if "input_embeds" in sig.parameters:
mask_kwargs["input_embeds"] = inputs_embeds[:1]
mask_kwargs["cache_position"] = torch.arange(sequence_length)
else:
mask_kwargs["inputs_embeds"] = inputs_embeds[:1]
return create_sliding_window_causal_mask(**mask_kwargs)
@pytest.mark.parametrize("sequence_length", [17, 72, 73, 169, 325])
@pytest.mark.parametrize(
("attention_implementation", "dtype"),
[("sdpa", torch.float32), ("eager", torch.float32), ("eager", torch.float16)],
)
def test_cached_sliding_mask_matches_transformers(
sequence_length: int,
attention_implementation: str,
dtype: torch.dtype,
):
model = _make_model(attention_implementation, dtype)
inputs_embeds = torch.empty(2, sequence_length, model.config.hidden_size, dtype=dtype)
actual = model._get_sliding_attention_mask(inputs_embeds)
expected = _create_reference_mask(model, inputs_embeds)
if expected is None:
assert actual is None
assert model._get_sliding_attention_mask(inputs_embeds) is None
return
assert actual is not None
torch.testing.assert_close(actual, expected, atol=0, rtol=0)
assert actual.shape == (1, 1, sequence_length, sequence_length)
assert actual.is_contiguous()
assert model._get_sliding_attention_mask(inputs_embeds) is actual
def test_cached_eager_mask_tracks_compute_dtype():
model = _make_model("eager")
fp32_inputs = torch.empty(1, 73, model.config.hidden_size)
fp32_mask = model._get_sliding_attention_mask(fp32_inputs)
assert fp32_mask is not None
assert fp32_mask.dtype == torch.float32
model.half()
fp16_inputs = fp32_inputs.half()
fp16_mask = model._get_sliding_attention_mask(fp16_inputs)
assert fp16_mask is not None
assert fp16_mask.dtype == torch.float16
assert torch.isfinite(fp16_mask).all()
assert fp16_mask.min() == torch.finfo(torch.float16).min
@pytest.mark.parametrize(
("kwargs", "message"),
[
({"use_cache": True}, "use_cache=True"),
({"past_key_values": object()}, "past_key_values"),
({"cache_position": torch.arange(1, 18)}, "zero-based cache_position"),
({"position_ids": torch.arange(1, 18).unsqueeze(0)}, "zero-based position_ids"),
],
)
def test_cached_mask_rejects_unsupported_cache_and_positions(kwargs: dict, message: str):
model = _make_model("sdpa")
inputs_embeds = torch.randn(1, 17, model.config.latent_dim)
with pytest.raises(ValueError, match=message):
model(inputs_embeds=inputs_embeds, **kwargs)
@@ -83,11 +83,16 @@ class SyntheticAcousticTransformer(nn.Module):
# time_embedding: (B, 1) -> (B, dim)
self.time_embedding = nn.Linear(1, HIDDEN_DIM, bias=False)
# _predict_velocity: takes x_t (B, C), llm_output (B, D), t_emb (B, D) -> (B, C)
# Identities: callers pass pre-projected inputs; the synthetic transformer folds
# everything into _velocity_proj.
self.llm_projection = nn.Identity()
self.time_projection = nn.Identity()
# _predict_velocity: takes x_t (B, C), llm_proj (B, D), t_proj (B, D) -> (B, C)
self._velocity_proj = nn.Linear(N_ACOUSTIC_CODEBOOK + HIDDEN_DIM + HIDDEN_DIM, N_ACOUSTIC_CODEBOOK, bias=False)
def _predict_velocity(self, x_t: torch.Tensor, llm_output: torch.Tensor, t_emb: torch.Tensor) -> torch.Tensor:
combined = torch.cat([x_t, llm_output, t_emb], dim=-1)
def _predict_velocity(self, x_t: torch.Tensor, llm_proj: torch.Tensor, t_proj: torch.Tensor) -> torch.Tensor:
combined = torch.cat([x_t, llm_proj, t_proj], dim=-1)
return self._velocity_proj(combined)
@@ -129,14 +134,18 @@ class SyntheticModel(nn.Module):
hidden_zero = torch.zeros_like(hidden_states)
timesteps = torch.linspace(0, 1, 16, device=hidden_states.device, dtype=hidden_states.dtype)
# llm_projection is constant across steps so hoist it (mirrors decode_one_frame).
llm_batched = torch.cat([hidden_states, hidden_zero], dim=0)
llm_proj_batched = at.llm_projection(llm_batched)
for i in range(len(timesteps) - 1):
t = timesteps[i]
dt = timesteps[i + 1] - timesteps[i]
t_emb = at.time_embedding(t.view(-1, 1).repeat(B, 1)).to(hidden_states.dtype)
t_proj = at.time_projection(t_emb)
x_batched = torch.cat([x, x], dim=0)
llm_batched = torch.cat([hidden_states, hidden_zero], dim=0)
t_emb_batched = t_emb.repeat(2, 1)
v_all = at._predict_velocity(x_t=x_batched, llm_output=llm_batched, t_emb=t_emb_batched)
t_proj_batched = t_proj.repeat(2, 1)
v_all = at._predict_velocity(x_t=x_batched, llm_proj=llm_proj_batched, t_proj=t_proj_batched)
v_t, uncond_v_t = v_all[:B], v_all[B:]
cfg_alpha = 1.2
v_t = cfg_alpha * v_t + (1 - cfg_alpha) * uncond_v_t
@@ -788,10 +788,16 @@ def test_qwen3_tts_code2wav_forward_decodes_connector_payload() -> None:
self.last_codes = codes.detach().clone()
return codes.sum(dim=1).to(torch.float32)
def batched_chunked_decode(self, codes, lengths, caches, **kwargs):
assert lengths == [codes.shape[-1]]
assert caches is None
return self.chunked_decode(codes, **kwargs)
decoder = _Decoder()
model = Qwen3TTSCode2Wav.__new__(Qwen3TTSCode2Wav)
torch.nn.Module.__init__(model)
model.decoder = decoder
model._async_chunk = False
model._num_quantizers = 2
model._total_upsample = 1
model._output_sample_rate = 24000
@@ -156,8 +156,9 @@ def test_streaming_phases(config, n_frames, finished, expected):
else:
exp_ctx, exp_window = expected
assert payload is not None
assert payload.meta.left_context_size == exp_ctx
assert len(payload.codes.audio) == _Q * exp_window
assert payload.meta.left_context_size == 0
expected_delta = exp_window if finished else exp_window - exp_ctx
assert len(payload.codes.audio) == _Q * expected_delta
def test_dynamic_ic_adapts_to_load():
@@ -202,7 +203,8 @@ def test_ic_load_change_mid_request():
assert _call(tm, "r", n_frames=25) is None
p3 = _call(tm, "r", n_frames=27)
assert p3 is not None
assert p3.meta.left_context_size == 2
assert p3.meta.left_context_size == 0
assert len(p3.codes.audio) == _Q * 25
# A *new* request under high load gets IC=16 (not IC=2).
# Frame 2 would emit under IC=2 but must hold under IC=16.
@@ -227,7 +229,8 @@ def test_connector_initial_chunk_config_overrides_dynamic_ic():
assert _call(tm, "r", n_frames=25) is None
p2 = _call(tm, "r", n_frames=29)
assert p2 is not None
assert p2.meta.left_context_size == 4
assert p2.meta.left_context_size == 0
assert len(p2.codes.audio) == _Q * 25
@pytest.mark.parametrize(
@@ -282,8 +285,8 @@ def test_first_streaming_chunk_prepends_ref_code_context():
assert len(payload.codes.audio) == _Q * 12
def test_followup_ref_code_context_is_sent_as_metadata_handle():
"""Follow-up chunks keep full ref context semantically without resending it."""
def test_followup_sends_only_codec_delta_without_ref_metadata():
"""Follow-up chunks rely on decoder state and resend neither reference codes nor metadata."""
tm = _tm()
rid = "r-ref2"
tm.code_prompt_token_ids[rid] = [_FRAME[:] for _ in range(35)]
@@ -299,13 +302,11 @@ def test_followup_ref_code_context_is_sent_as_metadata_handle():
)
assert payload is not None
# ref_code (2 frames) is represented in metadata so Code2Wav can restore it
# from its request-local cache. It must not be resent in codes.audio.
assert payload.meta.left_context_size == 10 + 2
assert payload.meta.ref_context_size == 2
assert payload.meta.ref_context_request_id == rid
assert payload.meta.ref_context_included is False
assert len(payload.codes.audio) == _Q * 35
assert payload.meta.left_context_size == 0
assert payload.meta.ref_context_size is None
assert payload.meta.ref_context_request_id is None
assert payload.meta.ref_context_included is None
assert len(payload.codes.audio) == _Q * 25
def test_streaming_ref_code_context_is_bounded_for_batchable_shapes():
@@ -332,8 +333,8 @@ def test_streaming_ref_code_context_is_bounded_for_batchable_shapes():
)
assert payload is not None
assert payload.meta.left_context_size == 3 + 3
assert len(payload.codes.audio) == _Q * (3 + 3 + 4)
assert payload.meta.left_context_size == 3
assert len(payload.codes.audio) == _Q * (3 + 4)
frames = payload.codes.audio.reshape(_Q, -1).transpose(0, 1)
torch.testing.assert_close(frames[:3], ref_code[-3:])
@@ -700,36 +701,40 @@ class TestChunkRampEmission:
assert self._emit(tm, rid, 4) is None
p1 = self._emit(tm, rid, 5)
assert p1 is not None
assert len(p1.codes.audio) == _Q * 5
assert p1.meta.left_context_size == 1
assert len(p1.codes.audio) == _Q * 4
assert p1.meta.left_context_size == 0
tm.ramp_chunk_count[rid] = 2
for n in range(6, 13):
assert self._emit(tm, rid, n) is None
p2 = self._emit(tm, rid, 13)
assert p2 is not None
assert p2.meta.left_context_size == 5
assert p2.meta.left_context_size == 0
assert len(p2.codes.audio) == _Q * 8
tm.ramp_chunk_count[rid] = 3
for n in range(14, 29):
assert self._emit(tm, rid, n) is None
p3 = self._emit(tm, rid, 29)
assert p3 is not None
assert p3.meta.left_context_size == 13
assert p3.meta.left_context_size == 0
assert len(p3.codes.audio) == _Q * 16
tm.ramp_chunk_count[rid] = 4
for n in range(30, 54):
assert self._emit(tm, rid, n) is None
p4 = self._emit(tm, rid, 54)
assert p4 is not None
assert p4.meta.left_context_size == 25
assert p4.meta.left_context_size == 0
assert len(p4.codes.audio) == _Q * 25
tm.ramp_chunk_count[rid] = 5
for n in range(55, 79):
assert self._emit(tm, rid, n) is None
p5 = self._emit(tm, rid, 79)
assert p5 is not None
assert p5.meta.left_context_size == 25
assert p5.meta.left_context_size == 0
assert len(p5.codes.audio) == _Q * 25
def test_ramp_finished_flush_mid_chunk(self):
tm = _tm(chunk_ramp=self.RAMP)
@@ -742,8 +747,8 @@ class TestChunkRampEmission:
p_fin = self._emit(tm, rid, 3, finished=True)
assert p_fin is not None
assert p_fin.meta.finished.item() is True
assert len(p_fin.codes.audio) == _Q * (1 + 2)
assert p_fin.meta.left_context_size == 1
assert len(p_fin.codes.audio) == _Q * 2
assert p_fin.meta.left_context_size == 0
def test_ramp_finished_no_new_frames(self):
tm = _tm(chunk_ramp=self.RAMP)
@@ -783,7 +788,8 @@ class TestChunkRampEmission:
assert self._emit(tm, rid, n) is None
p1 = self._emit(tm, rid, 26)
assert p1 is not None
assert p1.meta.left_context_size == 1
assert p1.meta.left_context_size == 0
assert len(p1.codes.audio) == _Q * 25
def test_ramp_with_ref_code_first_chunk(self):
tm = _tm(chunk_ramp=self.RAMP, left_context=25)
@@ -820,14 +826,16 @@ class TestChunkRampEmission:
assert self._emit(tm, rid, n) is None
p2 = self._emit(tm, rid, 30)
assert p2 is not None
assert p2.meta.left_context_size == 5
assert p2.meta.left_context_size == 0
assert len(p2.codes.audio) == _Q * 25
tm.ramp_chunk_count[rid] = 3
for n in range(31, 55):
assert self._emit(tm, rid, n) is None
p3 = self._emit(tm, rid, 55)
assert p3 is not None
assert p3.meta.left_context_size == 25
assert p3.meta.left_context_size == 0
assert len(p3.codes.audio) == _Q * 25
def test_ramp_profile_4_4_8_16_25(self):
"""Ramp [4,4,8,16,25]: chunk 0=4 frames (320ms audio covers chunk 1
@@ -846,35 +854,40 @@ class TestChunkRampEmission:
assert self._emit(tm, rid, n) is None
p1 = self._emit(tm, rid, 8)
assert p1 is not None
assert p1.meta.left_context_size == 4
assert p1.meta.left_context_size == 0
assert len(p1.codes.audio) == _Q * 4
tm.ramp_chunk_count[rid] = 2
for n in range(9, 16):
assert self._emit(tm, rid, n) is None
p2 = self._emit(tm, rid, 16)
assert p2 is not None
assert p2.meta.left_context_size == 8
assert p2.meta.left_context_size == 0
assert len(p2.codes.audio) == _Q * 8
tm.ramp_chunk_count[rid] = 3
for n in range(17, 32):
assert self._emit(tm, rid, n) is None
p3 = self._emit(tm, rid, 32)
assert p3 is not None
assert p3.meta.left_context_size == 16
assert p3.meta.left_context_size == 0
assert len(p3.codes.audio) == _Q * 16
tm.ramp_chunk_count[rid] = 4
for n in range(33, 57):
assert self._emit(tm, rid, n) is None
p4 = self._emit(tm, rid, 57)
assert p4 is not None
assert p4.meta.left_context_size == 25
assert p4.meta.left_context_size == 0
assert len(p4.codes.audio) == _Q * 25
tm.ramp_chunk_count[rid] = 5
for n in range(58, 82):
assert self._emit(tm, rid, n) is None
p5 = self._emit(tm, rid, 82)
assert p5 is not None
assert p5.meta.left_context_size == 25
assert p5.meta.left_context_size == 0
assert len(p5.codes.audio) == _Q * 25
def test_ramp_resets_on_segment_boundary(self):
"""After segment 1 emits chunks, a segment boundary clears
@@ -40,6 +40,7 @@ EXCLUDED_MODELS = [
"WanT2VDMD2Pipeline",
"WanI2VDMD2Pipeline",
"LongCatImagePipeline",
"LongCatVideoAvatarPipeline",
"BagelPipeline",
"BooguImagePipeline",
"LancePipeline",
+22 -6
View File
@@ -123,18 +123,29 @@ def _legacy_detectors(tree: ast.AST) -> list[tuple[int, str]]:
return found
def _check(label: str, path: Path, actual: int, budget: int, sample: list[str], budget_name: str) -> list[str]:
def _check(
label: str,
path: Path,
actual: int,
budget: int,
sample: list[str],
budget_name: str,
notices: list[str],
) -> list[str]:
if actual > budget:
listing = "\n".join(f" {line}" for line in sample)
return [
f"{path}: {actual} {label} exceeds the budget of {budget}.\n{listing}\n\n{_GUIDANCE}\n",
]
if actual < budget:
return [
f"{path}: {actual} {label}, down from a budget of {budget}. "
f"Lower {budget_name} to {actual} in {Path(__file__).relative_to(REPO_ROOT)} "
f"so the ratchet holds the new ground.",
]
# Progress is never a build failure. Removing branches is the goal, so a
# PR that does it must not hand-edit this file to stay green -- nor break
# `main` for everyone else when it merges from a stale base.
notices.append(
f"{path}: {actual} {label}, below the budget of {budget}. "
f"Lower {budget_name} to {actual} in "
f"{Path(__file__).relative_to(REPO_ROOT)} to hold the new ground."
)
return []
@@ -152,6 +163,7 @@ def main(argv: list[str] | None = None) -> int:
print(f"check_tts_adapter: expected file not found: {path}", file=sys.stderr)
return 1
notices: list[str] = []
branches = _model_type_branches(ast.parse(serving_path.read_text()))
detectors = _legacy_detectors(ast.parse(adapters_path.read_text()))
@@ -162,6 +174,7 @@ def main(argv: list[str] | None = None) -> int:
MAX_MODEL_TYPE_BRANCHES,
[f"line {lineno}: {src}" for lineno, src in branches],
"MAX_MODEL_TYPE_BRANCHES",
notices,
)
errors += _check(
"unmigrated model types in LEGACY_TTS_DETECTORS",
@@ -170,8 +183,11 @@ def main(argv: list[str] | None = None) -> int:
MAX_LEGACY_DETECTORS,
[f"line {lineno}: {name}" for lineno, name in detectors],
"MAX_LEGACY_DETECTORS",
notices,
)
for notice in notices:
print(f"check_tts_adapter: {notice}", file=sys.stderr)
for error in errors:
print(f"check_tts_adapter: {error}", file=sys.stderr)
return 1 if errors else 0
@@ -55,6 +55,16 @@ class SeedTTSSampleRequest(SampleRequest):
seed_tts_system_prompt: str = ""
#: Local path to reference prompt WAV (for SIM vs. synthesized PCM in ``seed_tts_eval``).
seed_tts_ref_wav_path: str = ""
#: Ordered text targets evaluated as turns in one Realtime session.
seed_tts_turns: tuple[SeedTTSTurn, ...] = ()
@dataclass(frozen=True)
class SeedTTSTurn:
"""One target utterance within a grouped Realtime Seed-TTS session."""
utterance_id: str
target_text: str
@dataclass
@@ -216,24 +226,39 @@ class SeedTTSDataset(BenchmarkDataset):
) -> list[SampleRequest]:
if output_len is None:
output_len = self.DEFAULT_OUTPUT_LEN
turns_per_session = int(kwargs.pop("turns_per_session", 1))
if turns_per_session < 1:
raise ValueError("turns_per_session must be at least 1")
tok = get_cached_tokenizer(tokenizer)
out: list[SampleRequest] = []
for i, row in enumerate(self._rows):
if len(out) >= num_requests:
break
valid_rows: list[tuple[_SeedTTSRow, Path]] = []
for row in self._rows:
wav_path = (self._root / self.locale / row.prompt_wav_rel).resolve()
if not wav_path.is_file():
logger.warning("Missing prompt wav for %s: %s", row.utterance_id, wav_path)
continue
valid_rows.append((row, wav_path))
target = row.target_text
prompt_len = len(tok.encode(f"{self._system_prompt}\n{target}"))
for session_index in range(num_requests):
offset = session_index * turns_per_session
group = valid_rows[offset : offset + turns_per_session]
if len(group) < turns_per_session:
break
reference_row, reference_wav_path = group[0]
turns = tuple(
SeedTTSTurn(
utterance_id=row.utterance_id,
target_text=row.target_text,
)
for row, _ in group
)
prompt_len = sum(len(tok.encode(f"{self._system_prompt}\n{turn.target_text}")) for turn in turns)
lang = "English" if self.locale == "en" else "Chinese"
ref_uri = _ref_audio_payload(wav_path, inline=self.inline_ref_audio)
ref_uri = _ref_audio_payload(reference_wav_path, inline=self.inline_ref_audio)
speech_extra: dict[str, Any] = {
"ref_audio": ref_uri,
"ref_text": row.ref_text,
"ref_text": reference_row.ref_text,
"task_type": "Base",
"language": lang,
"max_new_tokens": output_len,
@@ -241,20 +266,26 @@ class SeedTTSDataset(BenchmarkDataset):
out.append(
SeedTTSSampleRequest(
prompt=target,
prompt=turns[0].target_text,
prompt_len=prompt_len,
expected_output_len=output_len,
multi_modal_data=None,
request_id=f"{request_id_prefix}{i}",
request_id=f"{request_id_prefix}{session_index}",
seed_tts_speech_extra=speech_extra,
seed_tts_utterance_id=row.utterance_id,
seed_tts_utterance_id=turns[0].utterance_id,
seed_tts_locale=self.locale,
seed_tts_system_prompt=self._system_prompt,
seed_tts_ref_wav_path=str(wav_path),
seed_tts_ref_wav_path=str(reference_wav_path),
seed_tts_turns=turns,
)
)
logger.info("Seed-TTS: built %d requests (asked %d)", len(out), num_requests)
logger.info(
"Seed-TTS: built %d sessions (asked %d, turns_per_session=%d)",
len(out),
num_requests,
turns_per_session,
)
self.maybe_oversample_requests(out, num_requests, request_id_prefix, no_oversample)
return out
@@ -54,6 +54,8 @@ import string
import tempfile
import threading
import wave
from copy import copy
from dataclasses import replace
from pathlib import Path
from typing import Any
@@ -123,11 +125,25 @@ def _get_eval_device() -> str:
return explicit
try:
import torch
return "cuda:0" if torch.cuda.is_available() else "cpu"
except ImportError:
return "cpu"
if torch.cuda.is_available():
return "cuda:0"
# Ascend: ``torch.npu`` only exists once ``torch_npu`` has been imported, which does
# not necessarily happen in the benchmark client process. Without this branch the
# eval silently lands on CPU, where Whisper-large-v3 needs hours for a full Seed-TTS
# run and the CI job is killed by its timeout instead of reporting a WER.
try:
import torch_npu # noqa: F401
except ImportError:
pass
if hasattr(torch, "npu") and torch.npu.is_available():
return "npu:0"
return "cpu"
def _punctuation_all() -> str:
from zhon.hanzi import punctuation
@@ -512,6 +528,42 @@ def _missing_deps_message(lang: str) -> str | None:
return None
def _expand_seed_tts_turn_outputs(
input_requests: list[SeedTTSSampleRequest],
outputs: list[Any],
) -> tuple[list[SeedTTSSampleRequest], list[Any]]:
"""Expand grouped Realtime sessions into one request/output pair per turn."""
expanded_requests: list[SeedTTSSampleRequest] = []
expanded_outputs: list[Any] = []
for request, output in zip(input_requests, outputs, strict=True):
turns = request.seed_tts_turns
if not turns:
expanded_requests.append(request)
expanded_outputs.append(output)
continue
turn_pcm = getattr(output, "tts_turn_pcm_bytes", None)
session_pcm = getattr(output, "tts_output_pcm_bytes", None)
for turn_index, turn in enumerate(turns):
expanded_requests.append(
replace(
request,
prompt=turn.target_text,
seed_tts_utterance_id=turn.utterance_id,
seed_tts_turns=(),
)
)
turn_output = copy(output)
if isinstance(turn_pcm, list) and turn_index < len(turn_pcm):
turn_output.tts_output_pcm_bytes = turn_pcm[turn_index]
elif len(turns) == 1:
# openai-chat-omni Seed-TTS only fills the session-level PCM field.
turn_output.tts_output_pcm_bytes = session_pcm
else:
turn_output.tts_output_pcm_bytes = None
expanded_outputs.append(turn_output)
return expanded_requests, expanded_outputs
def compute_seed_tts_wer_metrics(
input_requests: list[SampleRequest],
outputs: list[Any],
@@ -524,6 +576,8 @@ def compute_seed_tts_wer_metrics(
return None
if not all(isinstance(r, SeedTTSSampleRequest) for r in input_requests):
return None
session_count = len(input_requests)
input_requests, outputs = _expand_seed_tts_turn_outputs(input_requests, outputs)
first = input_requests[0]
assert isinstance(first, SeedTTSSampleRequest)
@@ -759,6 +813,8 @@ def compute_seed_tts_wer_metrics(
result: dict[str, Any] = {
"seed_tts_eval_protocol": "seed-tts-eval",
"seed_tts_session_count": session_count,
"seed_tts_turn_count": len(input_requests),
"seed_tts_content_evaluated": len(errs),
"seed_tts_content_error_mean": statistics.fmean(errs) if errs else None,
"seed_tts_content_error_median": statistics.median(errs) if errs else None,
+227 -3
View File
@@ -9,11 +9,13 @@ import ssl
import sys
import time
import traceback
import uuid
import wave
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Literal
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
import aiohttp
import numpy as np
@@ -47,6 +49,11 @@ from vllm_omni.benchmarks.data_modules.seed_tts_dataset import (
)
from vllm_omni.benchmarks.data_modules.sound_effect_dataset import SoundEffectDataset
from vllm_omni.benchmarks.data_modules.ttsd_dataset import TTSDDataset
from vllm_omni.experimental.fullduplex.client import (
RealtimeDuplexClient,
summarize_session_request_metrics,
wait_for,
)
from vllm_omni.metrics import definitions as defs
from vllm_omni.metrics.utils import coerce_positive_int_scalar
@@ -188,6 +195,9 @@ def _attach_seed_tts_to_request_func_input(sample: SampleRequest, rfi: RequestFu
# Mark for PCM capture (WER / UTMOS eval) regardless of extra body presence.
setattr(rfi, "seed_tts_row", True)
sys_prompt = (sample.seed_tts_system_prompt or "").strip() or SEED_TTS_DEFAULT_OMNI_SYSTEM_PROMPT
setattr(rfi, "seed_tts_system_prompt", sys_prompt)
setattr(rfi, "seed_tts_speech_extra", sample.seed_tts_speech_extra)
setattr(rfi, "seed_tts_turns", sample.seed_tts_turns)
setattr(
rfi,
"omni_chat_messages",
@@ -235,7 +245,12 @@ def get_samples(args, tokenizer):
)
# Check if we need to handle omni-related backends/datasets
is_omni_backend = args.backend in ["openai-chat-omni", "openai-audio-speech", "daily-omni"]
is_omni_backend = args.backend in [
"openai-chat-omni",
"openai-audio-speech",
"openai-realtime-duplex",
"daily-omni",
]
is_omni_dataset = is_daily_omni or is_seed_tts or args.dataset_name == "random-mm"
if not is_omni_backend and not is_omni_dataset:
@@ -334,10 +349,17 @@ def get_samples(args, tokenizer):
return input_requests
if is_seed_tts:
if args.backend not in ("openai-audio-speech", "openai-chat-omni"):
if args.backend not in (
"openai-audio-speech",
"openai-chat-omni",
"openai-realtime-duplex",
"openai-realtime-tts",
):
raise ValueError(
"Seed-TTS requires --backend openai-audio-speech (POST /v1/audio/speech) or "
"--backend openai-chat-omni (POST /v1/chat/completions with ref_audio/ref_text). "
"--backend openai-chat-omni (POST /v1/chat/completions with ref_audio/ref_text), or "
"--backend openai-realtime-duplex or openai-realtime-tts "
"(WebSocket /v1/realtime). "
f"Got backend={args.backend!r}."
)
repo_id = getattr(args, "dataset_path", None) or getattr(args, "hf_name", None)
@@ -346,6 +368,14 @@ def get_samples(args, tokenizer):
"Seed-TTS requires --dataset-path (HF dataset repo id or local directory) or "
"--hf-name for the Hub dataset id."
)
turns_per_session = int(getattr(args, "seed_tts_turns_per_session", 1))
if turns_per_session > 1 and args.backend not in {
"openai-realtime-duplex",
"openai-realtime-tts",
}:
raise ValueError(
f"--seed-tts-turns-per-session > 1 requires a Realtime Seed-TTS backend. Got backend={args.backend!r}."
)
_cls_map = {
"seed-tts": SeedTTSDataset,
@@ -375,6 +405,7 @@ def get_samples(args, tokenizer):
output_len=out_len,
request_id_prefix=args.request_id_prefix,
no_oversample=args.no_oversample,
turns_per_session=turns_per_session,
)
# Handle random-mm dataset (Omni's synthetic multimodal dataset)
@@ -430,10 +461,14 @@ class MixRequestFuncOutput(RequestFuncOutput):
#: Raw PCM s16le mono at 24 kHz for Seed-TTS WER: from ``/v1/audio/speech`` stream or
#: resampled export after ``openai-chat-omni`` audio deltas.
tts_output_pcm_bytes: bytes | None = None
#: Per-turn 24 kHz mono PCM for grouped Realtime Seed-TTS WER.
tts_turn_pcm_bytes: list[bytes] | None = None
#: Per-stage snapshot from orchestrator ``metrics["stage_metrics"]`` (merged across SSE chunks).
stage_metrics: dict[str, dict] | None = None
stage_id: int | None = None
final_output_type: str | None = None
duplex_request_metrics: list[dict[str, object]] | None = None
duplex_session_metrics: dict[str, object] | None = None
_IMAGE_EDITS_EXTRA_BODY_FORM_FIELDS = (
@@ -1249,6 +1284,180 @@ async def async_request_openai_audio_speech(
return output
def _realtime_websocket_url(api_url: str) -> str:
parts = urlsplit(api_url)
scheme = "wss" if parts.scheme == "https" else "ws"
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query.setdefault("duplex", "1")
return urlunsplit((scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
async def async_request_openai_realtime_duplex(
request_func_input: RequestFuncInput,
session: aiohttp.ClientSession,
pbar: tqdm | None = None,
) -> MixRequestFuncOutput:
"""Run one or more Seed-TTS turns through one explicit Realtime TTS session."""
del session
output = MixRequestFuncOutput()
output.prompt_len = request_func_input.prompt_len
output.start_time = time.perf_counter()
speech_extra = getattr(request_func_input, "seed_tts_speech_extra", None)
if not isinstance(speech_extra, dict):
speech_extra = {}
configured_turns = getattr(request_func_input, "seed_tts_turns", ())
turn_prompts = [
(
str(getattr(turn, "utterance_id", "") or ""),
str(getattr(turn, "target_text", "") or ""),
)
for turn in configured_turns
if str(getattr(turn, "target_text", "") or "").strip()
]
if not turn_prompts:
turn_prompts = [("", request_func_input.prompt)]
session_id = f"seed-tts-{request_func_input.request_id or uuid.uuid4().hex}"
try:
async with RealtimeDuplexClient(_realtime_websocket_url(request_func_input.api_url)) as client:
await client.configure(
request_func_input.model_name or request_func_input.model,
output_audio_format="pcm16",
instructions=getattr(
request_func_input,
"seed_tts_system_prompt",
SEED_TTS_DEFAULT_OMNI_SYSTEM_PROMPT,
),
native_duplex=False,
auto_response=False,
extra_body=speech_extra,
session_id=session_id,
timeout_s=120.0,
)
turn_metrics: list[dict[str, object]] = []
turn_timings: list[dict[str, object]] = []
turn_pcm_bytes: list[bytes] = []
turn_transcripts: list[str] = []
measurement_origin = {
"ttft": "conversation.item.create client send to first non-empty text delta",
"ttfp": "conversation.item.create client send to first audio packet",
"rtf": "request-start-to-last-audio receive time divided by emitted audio duration",
}
for request_index, (utterance_id, target_text) in enumerate(turn_prompts):
response_offset = len(client.events.response_ids)
done_before = client.events.count("response.done")
errors_before = len(client.events.errors())
turn_started_at_s = time.monotonic()
await client.send(
{
"type": "conversation.item.create",
"item": {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": target_text}],
},
}
)
await client.send({"type": "response.create"})
await wait_for(
lambda: client.events.count("response.done") > done_before
or len(client.events.errors()) > errors_before,
timeout_s=180.0,
label=f"Seed-TTS Realtime TTS turn {request_index} response.done",
)
errors = client.events.errors()
if len(errors) > errors_before:
raise RuntimeError(f"Seed-TTS Realtime TTS server error: {errors[-1]}")
new_audio_response_ids = [
response_id
for response_id in client.events.response_ids[response_offset:]
if client.events.audio_bytes(response_id)
]
if len(new_audio_response_ids) != 1:
raise RuntimeError(
f"Seed-TTS Realtime TTS turn {request_index} expected one audio response, "
f"got {len(new_audio_response_ids)}"
)
response_id = new_audio_response_ids[0]
timing = client.events.timing_summary(
after_s=turn_started_at_s,
input_committed_at_s=turn_started_at_s,
response_id=response_id,
measurement_origin=measurement_origin,
)
request_metrics = timing.get("request_metrics")
if not isinstance(request_metrics, dict):
raise RuntimeError(f"Seed-TTS duplex audio turn {response_id} omitted per-request metrics")
turn_timings.append(timing)
turn_metrics.append(
{
"session_id": session_id,
"request_index": request_index,
"utterance_id": utterance_id or None,
"response_id": response_id,
**request_metrics,
}
)
response_audio = client.events.audio_bytes(response_id)
turn_pcm_bytes.append(
_pcm_s16le_to_seed_tts_wer_bytes(
response_audio,
sample_rate=client.events.output_sample_rate_hz,
channels=1,
)
)
turn_transcripts.append(
"".join(
str(event.get("delta") or "")
for event in client.events.events
if client.events.response_id(event) == response_id
and event.get("type")
in {
"response.audio_transcript.delta",
"response.output_text.delta",
"response.text.delta",
}
)
)
await client.acknowledge_playback()
request_finished_at = time.perf_counter()
session_metrics = summarize_session_request_metrics(
turn_metrics,
session_id=session_id,
)
await client.close_session(timeout_s=30.0)
output.generated_text = " ".join(filter(None, turn_transcripts))
output.ttft = float(session_metrics.get("mean_ttft_ms") or 0.0) / 1000.0
output.audio_ttfp = float(session_metrics.get("mean_ttfp_ms") or 0.0) / 1000.0
output.audio_rtf = float(session_metrics.get("mean_rtf") or 0.0)
output.audio_duration = (
sum(float(metric.get("audio_duration_ms") or 0.0) for metric in turn_metrics) / 1000.0
)
output.audio_frames = int(output.audio_duration * client.events.output_sample_rate_hz)
output.latency = request_finished_at - output.start_time
output.tts_turn_pcm_bytes = turn_pcm_bytes
output.tts_output_pcm_bytes = b"".join(turn_pcm_bytes)
if bool((request_func_input.extra_body or {}).get("save_duplex_request_metrics")):
output.duplex_request_metrics = turn_metrics
output.duplex_session_metrics = session_metrics
output.output_tokens = sum(
int(stage0.get("output_token_count") or 0)
for timing in turn_timings
if isinstance((stage0 := timing.get("stage0_tokens")), dict)
)
output.success = True
except Exception:
output.success = False
output.error = traceback.format_exc()
logger.error(
"Seed-TTS Realtime TTS request failed: %s",
output.error,
)
if pbar:
pbar.update(1)
return output
ASYNC_REQUEST_FUNCS["openai-chat-omni"] = async_request_openai_chat_omni_completions
if "openai-chat-omni" not in OPENAI_COMPATIBLE_BACKENDS:
OPENAI_COMPATIBLE_BACKENDS.append("openai-chat-omni")
@@ -1257,6 +1466,13 @@ ASYNC_REQUEST_FUNCS["openai-audio-speech"] = async_request_openai_audio_speech
if "openai-audio-speech" not in OPENAI_COMPATIBLE_BACKENDS:
OPENAI_COMPATIBLE_BACKENDS.append("openai-audio-speech")
ASYNC_REQUEST_FUNCS["openai-realtime-duplex"] = async_request_openai_realtime_duplex
if "openai-realtime-duplex" not in OPENAI_COMPATIBLE_BACKENDS:
OPENAI_COMPATIBLE_BACKENDS.append("openai-realtime-duplex")
ASYNC_REQUEST_FUNCS["openai-realtime-tts"] = async_request_openai_realtime_duplex
if "openai-realtime-tts" not in OPENAI_COMPATIBLE_BACKENDS:
OPENAI_COMPATIBLE_BACKENDS.append("openai-realtime-tts")
ASYNC_REQUEST_FUNCS["openai-image-edits-omni"] = async_request_openai_image_edits_omni
if "openai-image-edits-omni" not in OPENAI_COMPATIBLE_BACKENDS:
OPENAI_COMPATIBLE_BACKENDS.append("openai-image-edits-omni")
@@ -1603,6 +1819,14 @@ async def benchmark(
"input_lens": [output.prompt_len for output in outputs],
"errors": [output.error for output in outputs],
}
duplex_request_metrics = [metric for output in outputs for metric in (output.duplex_request_metrics or [])]
if duplex_request_metrics:
result["duplex_request_metrics"] = duplex_request_metrics
duplex_session_metrics = [
output.duplex_session_metrics for output in outputs if output.duplex_session_metrics is not None
]
if duplex_session_metrics:
result["duplex_session_metrics"] = duplex_session_metrics
from vllm_omni.benchmarks.data_modules.daily_omni_eval import (
compute_daily_omni_accuracy_metrics,
+5
View File
@@ -94,6 +94,8 @@ class _ModelEngineOverrides(TypedDict, total=False):
moe_backend: str
hf_overrides: Any
limit_mm_per_prompt: dict[str, Any]
interleave_mm_strings: bool
media_io_kwargs: dict[str, Any]
active_stream_window: int
enable_sleep_mode: bool
subtalker_sampling_params: dict[str, Any]
@@ -322,6 +324,9 @@ class OmniStageModelConfig:
moe_backend: str = "auto"
hf_overrides: Any = None
limit_mm_per_prompt: dict[str, Any] | None = None
# MiniCPM interleaved AV packing and media decode knobs (Daily-Omni).
interleave_mm_strings: bool | None = None
media_io_kwargs: dict[str, Any] | None = None
active_stream_window: int = Field(default=0, ge=0)
duplex_max_sessions: int = Field(default=1, ge=1)
enable_sleep_mode: bool = False
+11 -1
View File
@@ -23,11 +23,19 @@ connectors:
stages:
- stage_id: 0
max_num_seqs: 4
# Leave headroom on the shared GPU for Talker KV + concurrent Code2Wav.
gpu_memory_utilization: 0.55
enforce_eager: false
tensor_parallel_size: 1
max_num_batched_tokens: 16384
devices: "0"
# Required for MiniCPM interleaved image/audio Daily-Omni packs (~78% recipe).
# Keep these in YAML so they survive both --deploy-config and --stage-configs-path.
interleave_mm_strings: true
media_io_kwargs:
video:
fps: 1
num_frames: 128
limit_mm_per_prompt:
# Daily-Omni MiniCPM pack: up to 64 interleaved image/audio pairs (1fps).
image: 64
@@ -49,6 +57,8 @@ stages:
gpu_memory_utilization: 0.15
enforce_eager: false
max_num_batched_tokens: 8192
# Talker context is 4096 (tts_config.max_position_embeddings).
max_model_len: 4096
devices: "0"
output_connectors:
to_stage_2: connector_of_shared_memory
@@ -66,7 +76,7 @@ stages:
- stage_id: 2
max_num_seqs: 4
gpu_memory_utilization: 0.15
gpu_memory_utilization: 0.18
enforce_eager: true
enable_chunked_prefill: false
max_num_batched_tokens: 65536
+5
View File
@@ -31,6 +31,11 @@ stages:
tensor_parallel_size: 1
max_num_batched_tokens: 16384
devices: "0"
interleave_mm_strings: true
media_io_kwargs:
video:
fps: 1
num_frames: 128
limit_mm_per_prompt:
video: 1
default_sampling_params:
@@ -43,6 +43,11 @@ stages:
max_model_len: 4096
max_num_batched_tokens: 4096
devices: "0,1,2,3"
interleave_mm_strings: true
media_io_kwargs:
video:
fps: 1
num_frames: 128
limit_mm_per_prompt:
video: 1
default_sampling_params:
@@ -27,6 +27,8 @@ stages:
- stage_id: 1
max_num_seqs: 2
async_scheduling: false
# Talker context is 4096 (tts_config.max_position_embeddings).
max_model_len: 4096
# The split Talker exposes a binary continue/stop vocabulary; native
# segment and turn boundaries travel as explicit output metadata.
default_sampling_params:
+13 -5
View File
@@ -33,11 +33,19 @@ connectors:
# initial_codec_chunk_frames (including the per-request API field).
# Example:
# codec_chunk_ramp: [4, 4, 8, 16, 25]
# Common Stage1 decode buckets:
# no-ref first/steady chunks: 25 / 97 frames
# Base ref-context first/steady chunks: 73 / 169 frames
# decoder internal non-streaming chunks: 325 frames
decode_cudagraph_capture_sizes: [25, 73, 97, 169, 325]
# Decoder CUDA Graph sizing:
# * async_chunk=true derives all stateful ICL/xvec shapes from
# codec_chunk_ramp when enabled, otherwise from
# initial_codec_chunk_frames, codec_chunk_frames, and sliding_window;
# decode_cudagraph_capture_sizes is ignored in this mode.
# * async_chunk=false captures stateless shapes 150 and
# decode_chunk_frames + decode_left_context_frames (325 by default).
# Extra stateless shapes may be added (union, not intersection) with:
# decode_cudagraph_capture_sizes: [97, 400]
# Graph batch size defaults to 1. Add larger buckets only when needed:
# decode_cudagraph_batch_sizes: [1, 2, 4]
# Limit eager batches to reduce temporary stateful cache allocations; CUDA Graph batching uses the buckets above.
decode_batch_max_size: 4
stages:
- stage_id: 0
@@ -38,16 +38,12 @@ connectors:
# initial_codec_chunk_frames (including the per-request API field).
# Example:
# codec_chunk_ramp: [4, 4, 8, 16, 25]
# Common Stage1 decode buckets:
# no-ref first/steady chunks: 25 / 97 frames
# Base ref-context first/steady chunks: 73 / 169 frames
# decoder internal non-streaming chunks: 325 frames
decode_cudagraph_capture_sizes: [25, 73, 97, 169, 325]
# Keep B>1 captures opt-in; c64 e2e validation did not show a stable win.
# async_chunk derives stateful ICL/xvec graph shapes from the ramp when
# enabled, otherwise from the fixed chunk settings above, and ignores
# decode_cudagraph_capture_sizes. Keep B>1
# captures opt-in; c64 e2e validation did not show a stable win.
decode_cudagraph_batch_sizes: [1]
decode_compile_shapes: []
decode_batch_max_size: 1
decode_batch_bucket_frames: []
decode_enable_tf32: false
stages:
@@ -128,9 +128,9 @@ class RainFusionAttentionImpl(AttentionImpl):
whose extent the model publishes as ``AttentionMetadata.video_layout``. Every
other case — warmup denoise steps, exempt layers, a layer that does not declare
``qkv_layout="BSND"``, sequences without a published video segment, video
segments too short to pay for block selection or not aligned to the kernel's
block size — delegates to FlashAttention, so a model can select this backend
unconditionally.
segments too short to pay for block selection — delegates to FlashAttention,
so a model can select this backend unconditionally. MindIE-SD handles an
irregular video tail internally, retaining it outside the sparse blocks.
"""
def __init__(
@@ -292,25 +292,6 @@ class RainFusionAttentionImpl(AttentionImpl):
_MIN_VIDEO_BLOCKS,
)
return None
if video_len % _BLOCK_SIZE != 0:
# rf_v2 tiles the sequence into ceil((video + prefix) / 128) blocks but
# builds its block mask from the two segments pooled apart. The counts
# only agree when the video segment ends on a block boundary; otherwise
# a prefix block is forced on out of range, and the block straddling the
# seam holds real prefix rows that selection may drop. Padding the
# sequence to realign it is not an option because rf_v2 ignores
# attn_mask, so pad keys would take a share of every softmax denominator.
logger.warning_once(
"RAINFUSION_ATTN staying dense: latent grid %s gives %d video rows, which is not a "
"multiple of the %d-row kernel block. Choose a geometry whose latent_t * h * w is a "
"multiple of %d to enable sparsity.",
tuple(latent_shape),
video_len,
_BLOCK_SIZE,
_BLOCK_SIZE,
)
return None
logger.info_once(
"RAINFUSION_ATTN active: sparsity=%.2f, start_step=%d, exempt_layers=%d, "
"latent_grid=%s, prefix_rows=%d, video_rows=%d. Realized sparsity is lower than nominal "
+18 -4
View File
@@ -188,10 +188,24 @@ class DiffusionEngine:
self._init_process_hooks(od_config)
self.execution_mode = self._resolve_execution_mode(od_config)
self._init_executor(od_config)
self._init_scheduler(od_config, scheduler)
self._init_runtime_state()
self._init_execute_fn()
self._log_execution_mode(od_config)
try:
self._init_scheduler(od_config, scheduler)
self._init_runtime_state()
self._init_execute_fn()
self._log_execution_mode(od_config)
except Exception:
# close() cannot be used because runtime synchronization state may not exist yet.
scheduler_to_close = getattr(self, "scheduler", None)
if scheduler_to_close is not None:
try:
scheduler_to_close.close()
except Exception:
logger.exception("Failed to close Scheduler after DiffusionEngine initialization failed")
try:
self.executor.shutdown()
except Exception:
logger.exception("Failed to shut down Executor after DiffusionEngine initialization failed")
raise
def _init_process_hooks(self, od_config: OmniDiffusionConfig) -> None:
self.post_process_func = get_diffusion_post_process_func(od_config)
@@ -0,0 +1,57 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Scheduler-to-Worker allocation payloads for diffusion KV.
Like native vLLM's SchedulerOutput DTOs, these classes carry trusted internal
Scheduler results. Cache geometry and block-table validation stay with the
native cache builders and Worker installation path.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class DiffusionKVContextMetadata:
"""Request-scoped Worker allocation for an independent K/V context.
``context_id`` identifies this allocation within the enclosing
:class:`DiffusionKVMetadata`.
"""
context_id: str
cache_role: str
num_tokens: int
block_ids: tuple[list[int], ...]
@dataclass
class DiffusionKVSequenceMetadata:
"""Worker allocation for one Scheduler-owned diffusion sequence.
``context_ids`` reference request-scoped context allocations from the
enclosing :class:`DiffusionKVMetadata`.
"""
sequence_id: int
prefix_len: int
target_len: int
seq_len: int
block_ids: tuple[list[int], ...]
context_ids: tuple[str, ...] = ()
@dataclass
class DiffusionKVMetadata:
"""Scheduler allocation sent with a newly scheduled public request.
Independent contexts live once at request scope so multiple sequences can
reference the same allocation.
"""
request_id: str
allocation_generation: int
sequences: tuple[DiffusionKVSequenceMetadata, ...]
contexts: tuple[DiffusionKVContextMetadata, ...] = ()
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more