Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b112c7fdee | ||
|
|
e63ae881e7 | ||
|
|
2787f7f5f6 | ||
|
|
3cc05bf1c1 | ||
|
|
5a33440362 | ||
|
|
4ab1b4ff9c | ||
|
|
06e733d3cd | ||
|
|
af3faa6c1e | ||
|
|
322600bfb2 | ||
|
|
c4a14091c9 | ||
|
|
6d4e4ffc7c | ||
|
|
f97af75d1d | ||
|
|
73a312d5b6 | ||
|
|
1a56953d7a | ||
|
|
1f65814fab | ||
|
|
4f9b65074d | ||
|
|
12a5f6fbf3 | ||
|
|
53afbf3a0c | ||
|
|
ee118ab492 | ||
|
|
c27623c29c | ||
|
|
f8fa86fb1f | ||
|
|
422d8fd8f9 | ||
|
|
62e239bf1e | ||
|
|
740f45abcd | ||
|
|
5e4b9f8c6e | ||
|
|
ae811043e9 |
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+38
-28
@@ -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
|
||||
|
||||
|
||||
+110
-28
@@ -1,6 +1,11 @@
|
||||
# CODEOWNERS is used to automatically assign PR reviewers
|
||||
# Format: path pattern @username or @team
|
||||
# More info: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||
#
|
||||
# The vllm_omni/** source-tree rules below are derived from the `owners` and
|
||||
# `primary_code_paths` front matter in docs/design/module/*.md. When updating
|
||||
# ownership, update the design doc first, then regenerate the matching rule here.
|
||||
# Last-match-wins: more specific rules must come AFTER broader ones.
|
||||
|
||||
# Default owners (fallback reviewers for all files)
|
||||
* @Gaohan123 @hsliuustc0106 @ywang96
|
||||
@@ -21,25 +26,10 @@
|
||||
# CI
|
||||
/.buildkite/ @yenuo26 @congw729 @NickCao
|
||||
|
||||
# Engine and model_executor
|
||||
/vllm_omni/core/ @tzhouam @yinpeiqi @fake0fan @Sy0307
|
||||
/vllm_omni/engine/ @tzhouam @yinpeiqi @fake0fan @Sy0307
|
||||
/vllm_omni/entrypoints/ @tzhouam @yinpeiqi @fake0fan @alex-jw-brooks @NickCao
|
||||
/vllm_omni/model_executor/ @tzhouam @gcanlin
|
||||
/vllm_omni/worker/ @tzhouam @gcanlin
|
||||
# ComfyUI / external apps
|
||||
/apps/ @fhfuih @alex-jw-brooks
|
||||
|
||||
# Diffusion
|
||||
/vllm_omni/diffusion/ @Isotr0py @princepride @SamitHuang @wtomin @ZJY0516 @RuixiangMa @david6666666 @xuechendi
|
||||
/vllm_omni/lora/ @princepride @SamitHuang @wtomin @ZJY0516 @RuixiangMa @yuanheng-zhao
|
||||
|
||||
# Diffusion CPU offloading
|
||||
/vllm_omni/diffusion/offloader/ @Isotr0py @princepride @SamitHuang @wtomin @ZJY0516 @RuixiangMa @yuanheng-zhao @xuechendi
|
||||
|
||||
# Omni and TTS support
|
||||
/vllm_omni/transformers_utils/ @tzhouam @yuanheng-zhao
|
||||
/vllm_omni/model_executor/models/ @linyueqian @ZeldaHuang @princepride @yuanheng-zhao @amy-why-3459 @Bounty-hunter @Sy0307 @gcanlin @NickCao
|
||||
|
||||
# Benchmarks
|
||||
# Benchmarks (out-of-tree)
|
||||
/benchmarks/accuracy/ @david6666666
|
||||
/benchmarks/build_dataset/ @linyueqian @yuanheng-zhao
|
||||
/benchmarks/diffusion/ @Isotr0py @princepride @SamitHuang @wtomin @ZJY0516 @RuixiangMa @xuechendi
|
||||
@@ -47,20 +37,112 @@
|
||||
/benchmarks/glm_image/ @princepride @JaredforReal
|
||||
/benchmarks/tts/ @linyueqian @ZeldaHuang @princepride @Sy0307 @gcanlin
|
||||
|
||||
# Usability and Observability
|
||||
/vllm_omni/deploy/ @tzhouam @lishunyang12
|
||||
/vllm_omni/metrics/ @lishunyang12
|
||||
/vllm_omni/profiler/ @gcanlin
|
||||
# =====================================================================
|
||||
# Module design ownership (docs/design/module/*.md)
|
||||
# Each section header cites the source design doc.
|
||||
# =====================================================================
|
||||
|
||||
# ComfyUI
|
||||
/apps/ @fhfuih @alex-jw-brooks
|
||||
# Autoregressive runtime — docs/design/module/ar_runtime.md
|
||||
/vllm_omni/core/ @tzhouam @yinpeiqi @fake0fan @Sy0307 @Gaohan123
|
||||
/vllm_omni/worker/ @tzhouam @yinpeiqi @fake0fan @Sy0307 @Gaohan123
|
||||
|
||||
# Large Scale Serving
|
||||
# Engine orchestration & stage runtime — docs/design/module/engine_orchestration.md, stage_runtime.md
|
||||
/vllm_omni/engine/ @tzhouam @fake0fan
|
||||
# File-level overrides: input/output modality contracts own these engine files
|
||||
# — docs/design/module/input_output_modality_contracts.md
|
||||
/vllm_omni/engine/messages.py @Sy0307 @amy-why-3459 @Gaohan123 @alex-jw-brooks
|
||||
/vllm_omni/engine/serialization.py @Sy0307 @amy-why-3459 @Gaohan123 @alex-jw-brooks
|
||||
/vllm_omni/engine/mm_outputs.py @Sy0307 @amy-why-3459 @Gaohan123 @alex-jw-brooks
|
||||
/vllm_omni/engine/output_modality.py @Sy0307 @amy-why-3459 @Gaohan123 @alex-jw-brooks
|
||||
/vllm_omni/engine/output_processor.py @Sy0307 @amy-why-3459 @Gaohan123 @alex-jw-brooks
|
||||
|
||||
# Input/output modality contracts — docs/design/module/input_output_modality_contracts.md
|
||||
/vllm_omni/inputs/ @Sy0307 @amy-why-3459 @Gaohan123 @alex-jw-brooks
|
||||
/vllm_omni/outputs/ @Sy0307 @amy-why-3459 @Gaohan123 @alex-jw-brooks
|
||||
/vllm_omni/request.py @Sy0307 @amy-why-3459 @Gaohan123 @alex-jw-brooks
|
||||
/vllm_omni/data_entry_keys.py @Sy0307 @amy-why-3459 @Gaohan123 @alex-jw-brooks
|
||||
|
||||
# Entrypoints and serving boundaries — docs/design/module/entrypoints.md
|
||||
/vllm_omni/entrypoints/ @alex-jw-brooks @linyueqian @NickCao
|
||||
# File-level overrides within entrypoints/
|
||||
/vllm_omni/entrypoints/openai/errors.py @alex-jw-brooks @NickCao
|
||||
/vllm_omni/entrypoints/cli/benchmark/ @alex-jw-brooks @Bounty-hunter
|
||||
|
||||
# Error contracts — docs/design/module/error_contracts.md
|
||||
/vllm_omni/errors.py @alex-jw-brooks @NickCao
|
||||
|
||||
# OmniConnector (inter-stage transport) — docs/design/module/omni_connector.md
|
||||
/vllm_omni/distributed/ @princepride @yuanheng-zhao @xuechendi @natureofnature @fake0fan
|
||||
|
||||
# Quantization and configuration
|
||||
# Model integration — docs/design/module/model_integration.md
|
||||
/vllm_omni/model_executor/ @tzhouam @gcanlin
|
||||
/vllm_omni/model_executor/models/ @tzhouam @gcanlin @Sy0307 @amy-why-3459 @linyueqian
|
||||
/vllm_omni/model_extras/ @tzhouam @gcanlin @princepride
|
||||
/vllm_omni/plugins/ @gcanlin @tjtanaa @xuechendi
|
||||
# Config override within model_executor — docs/design/module/vllm_omni_config.md
|
||||
/vllm_omni/model_executor/stage_configs/ @lishunyang12 @alex-jw-brooks
|
||||
|
||||
# Execution platforms (hardware backends) — docs/design/module/execution_platforms.md
|
||||
/vllm_omni/platforms/ @gcanlin @tjtanaa @xuechendi
|
||||
/vllm_omni/attention/ @gcanlin @tjtanaa @xuechendi @david6666666 @lishunyang12
|
||||
|
||||
# Configuration & deploy — docs/design/module/vllm_omni_config.md
|
||||
/vllm_omni/config/ @lishunyang12 @alex-jw-brooks
|
||||
/vllm_omni/deploy/ @lishunyang12 @alex-jw-brooks
|
||||
|
||||
# Quantization — docs/design/module/quantization.md
|
||||
/vllm_omni/quantization/ @david6666666 @Isotr0py @lishunyang12
|
||||
|
||||
# Hardware plugins and accelerators
|
||||
/vllm_omni/platforms/ @gcanlin @tjtanaa @xuechendi
|
||||
# Observability — docs/design/module/observability.md
|
||||
/vllm_omni/metrics/ @lishunyang12 @vraiti
|
||||
|
||||
# Profiling — docs/design/module/profiling.md
|
||||
/vllm_omni/profiler/ @gcanlin
|
||||
|
||||
# Benchmarking (in-tree) — docs/design/module/benchmarking.md
|
||||
/vllm_omni/benchmarks/ @alex-jw-brooks @Bounty-hunter
|
||||
|
||||
# LoRA (no dedicated design doc; ownership intentionally narrowed)
|
||||
/vllm_omni/lora/ @SamitHuang @wtomin
|
||||
|
||||
# Transformers utils (related path only in design docs; preserves prior ownership)
|
||||
/vllm_omni/transformers_utils/ @tzhouam @yuanheng-zhao
|
||||
|
||||
# =====================================================================
|
||||
# Diffusion module family — docs/design/module/diffusion/
|
||||
# Umbrella rule first; sub-module rules after it win via last-match.
|
||||
# =====================================================================
|
||||
|
||||
# Diffusion umbrella (top-level files: data.py, forward_context.py, etc.)
|
||||
# — docs/design/module/diffusion/index.md
|
||||
/vllm_omni/diffusion/ @Isotr0py @princepride @SamitHuang @wtomin @ZJY0516 @RuixiangMa @david6666666 @xuechendi
|
||||
|
||||
# Cache management — docs/design/module/cache_management.md
|
||||
/vllm_omni/diffusion/cache/ @Isotr0py @princepride @SamitHuang
|
||||
|
||||
# Diffusion quantization — docs/design/module/quantization.md
|
||||
/vllm_omni/diffusion/quantization/ @david6666666 @Isotr0py @lishunyang12
|
||||
|
||||
# Diffusion profiling — docs/design/module/profiling.md
|
||||
/vllm_omni/diffusion/profiler/ @gcanlin
|
||||
|
||||
# Diffusion runtime (scheduler, executor, worker)
|
||||
# — docs/design/module/diffusion/diffusion_runtime.md, continuous_batching.md
|
||||
/vllm_omni/diffusion/sched/ @Isotr0py @princepride @SamitHuang @fhfuih
|
||||
/vllm_omni/diffusion/executor/ @Isotr0py @princepride @SamitHuang @fhfuih
|
||||
/vllm_omni/diffusion/worker/ @Isotr0py @princepride @SamitHuang @fhfuih
|
||||
|
||||
# Diffusion model integration (pipelines, registration, checkpoint loading)
|
||||
# — docs/design/module/diffusion/diffusion_model_integration.md
|
||||
/vllm_omni/diffusion/models/ @fhfuih @Bounty-hunter @wtomin
|
||||
/vllm_omni/diffusion/model_loader/ @fhfuih @Bounty-hunter @wtomin @RuixiangMa
|
||||
/vllm_omni/diffusion/lora/ @fhfuih @Bounty-hunter @wtomin @RuixiangMa
|
||||
|
||||
# Diffusion parallelism (rank topology, TP/SP/PP/VAE patch)
|
||||
# — docs/design/module/diffusion/parallelism.md
|
||||
/vllm_omni/diffusion/distributed/ @princepride @yuanheng-zhao @xuechendi @Bounty-hunter
|
||||
/vllm_omni/diffusion/attention/parallel/ @princepride @yuanheng-zhao @xuechendi @Bounty-hunter
|
||||
|
||||
# Diffusion offloader (CPU offloading, DLO)
|
||||
# — docs/design/module/diffusion/offloader.md
|
||||
/vllm_omni/diffusion/offloader/ @yuanheng-zhao @david6666666 @lishunyang12
|
||||
@@ -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
|
||||
|
||||
@@ -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 && \
|
||||
|
||||
@@ -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 && \
|
||||
|
||||
+36
-40
@@ -16,31 +16,21 @@ nav:
|
||||
- Examples:
|
||||
- examples/README.md
|
||||
- Offline Inference:
|
||||
- GLM-Image Multistage End-to-End Inference: user_guide/examples/offline_inference/glm_image.md
|
||||
- HunyuanImage-3.0 Image-to-Text Inference: user_guide/examples/offline_inference/hunyuan_image3.md
|
||||
- Image-To-Image: user_guide/examples/offline_inference/image_to_image.md
|
||||
- Image-To-Video: user_guide/examples/offline_inference/image_to_video.md
|
||||
- InternVLA-A1: user_guide/examples/offline_inference/internvla_a1.md
|
||||
- MiMo-Audio: user_guide/examples/offline_inference/mimo_audio.md
|
||||
- Qwen2.5-Omni: user_guide/examples/offline_inference/qwen2_5_omni.md
|
||||
- Qwen3-Omni: user_guide/examples/offline_inference/qwen3_omni.md
|
||||
- Speech-To-Video: user_guide/examples/offline_inference/speech_to_video.md
|
||||
- Text-To-Audio: user_guide/examples/offline_inference/text_to_audio.md
|
||||
- Text-To-Image: user_guide/examples/offline_inference/text_to_image.md
|
||||
- Text-To-Speech: user_guide/examples/offline_inference/text_to_speech.md
|
||||
- Text-To-Video: user_guide/examples/offline_inference/text_to_video.md
|
||||
- X-To-Text: user_guide/examples/offline_inference/x_to_text.md
|
||||
- X-To-Video-Audio: user_guide/examples/offline_inference/x_to_video_audio.md
|
||||
- Online Serving:
|
||||
- BAGEL-7B-MoT: user_guide/examples/online_serving/bagel.md
|
||||
- vLLM-Omni Helm Chart: user_guide/examples/online_serving/chart-helm.md
|
||||
- Diffusers Backend Adapter: user_guide/examples/online_serving/diffusers_pipeline_adapter.md
|
||||
- GLM-Image Online Serving: user_guide/examples/online_serving/glm_image.md
|
||||
- Image-To-Image: user_guide/examples/online_serving/image_to_image.md
|
||||
- Image-To-Video: user_guide/examples/online_serving/image_to_video.md
|
||||
- MiMo-Audio: user_guide/examples/online_serving/mimo_audio.md
|
||||
- Qwen2.5-Omni: user_guide/examples/online_serving/qwen2_5_omni.md
|
||||
- Qwen3-Omni: user_guide/examples/online_serving/qwen3_omni.md
|
||||
- Text-To-Speech: user_guide/examples/online_serving/text_to_speech.md
|
||||
- Speech-To-Video: user_guide/examples/online_serving/speech_to_video.md
|
||||
- Text-To-Image: user_guide/examples/online_serving/text_to_image.md
|
||||
- Text-To-Speech: user_guide/examples/online_serving/text_to_speech.md
|
||||
- Text-To-Video: user_guide/examples/online_serving/text_to_video.md
|
||||
- General:
|
||||
- usage/*
|
||||
@@ -50,30 +40,12 @@ nav:
|
||||
- Models:
|
||||
- models/supported_models.md
|
||||
- Features:
|
||||
- Sleep Mode: features/sleep_mode.md
|
||||
- Diffusion Features:
|
||||
- Overview: user_guide/diffusion_features.md
|
||||
- Feature Compatibility: user_guide/feature_compatibility.md
|
||||
- Regional Compilation: user_guide/diffusion/regional_compilation.md
|
||||
- Cache Acceleration:
|
||||
- TeaCache: user_guide/diffusion/cache_acceleration/teacache.md
|
||||
- Cache-DiT: user_guide/diffusion/cache_acceleration/cache_dit.md
|
||||
- Attention Backends: user_guide/diffusion/attention_backends.md
|
||||
- Frame Interpolation: user_guide/diffusion/frame_interpolation.md
|
||||
- Parallelism:
|
||||
- Overview: user_guide/diffusion/parallelism/overview.md
|
||||
- CFG Parallel: user_guide/diffusion/parallelism/cfg_parallel.md
|
||||
- Expert Parallel: user_guide/diffusion/parallelism/expert_parallel.md
|
||||
- Hybrid Sharded Data Parallel: user_guide/diffusion/parallelism/hsdp.md
|
||||
- Sequence Parallel: user_guide/diffusion/parallelism/sequence_parallel.md
|
||||
- Tensor Parallel: user_guide/diffusion/parallelism/tensor_parallel.md
|
||||
- VAE Parallelism: user_guide/diffusion/parallelism/vae_parallelism.md
|
||||
- CPU Offloading: user_guide/diffusion/cpu_offload.md
|
||||
- LoRA: user_guide/diffusion/lora.md
|
||||
- Custom Pipeline: features/custom_pipeline.md
|
||||
- Execution Modes: user_guide/diffusion/execution_modes.md
|
||||
- Overview: features/README.md
|
||||
- Runtime and Stage Execution:
|
||||
- Execution Modes and Streaming: user_guide/diffusion/execution_modes.md
|
||||
- Sleep Mode: features/sleep_mode.md
|
||||
- Quantization:
|
||||
- Quantization: user_guide/quantization/overview.md
|
||||
- Overview: user_guide/quantization/overview.md
|
||||
- Online Quantization: user_guide/quantization/online.md
|
||||
- Quantized KV Cache: user_guide/quantization/quantized_kvcache.md
|
||||
- FP8 W8A8: user_guide/quantization/fp8.md
|
||||
@@ -82,9 +54,33 @@ nav:
|
||||
- MXFP8 W8A8: user_guide/quantization/mxfp8.md
|
||||
- MXFP4 W4A4: user_guide/quantization/mxfp4.md
|
||||
- ModelOpt: user_guide/quantization/modelopt.md
|
||||
- GGUF: user_guide/quantization/gguf.md
|
||||
- AutoRound: user_guide/quantization/autoround.md
|
||||
- msModelSlim: user_guide/quantization/msmodelslim.md
|
||||
- Diffusion Acceleration:
|
||||
- Overview and Compatibility:
|
||||
- Overview: user_guide/diffusion_features.md
|
||||
- Feature Compatibility: user_guide/feature_compatibility.md
|
||||
- CPU Offloading: user_guide/diffusion/cpu_offload.md
|
||||
- Cache Acceleration:
|
||||
- TeaCache: user_guide/diffusion/cache_acceleration/teacache.md
|
||||
- Cache-DiT: user_guide/diffusion/cache_acceleration/cache_dit.md
|
||||
- Parallelism:
|
||||
- Overview: user_guide/diffusion/parallelism/overview.md
|
||||
- CFG Parallel: user_guide/diffusion/parallelism/cfg_parallel.md
|
||||
- Expert Parallel: user_guide/diffusion/parallelism/expert_parallel.md
|
||||
- Hybrid Sharded Data Parallel: user_guide/diffusion/parallelism/hsdp.md
|
||||
- Sequence Parallel: user_guide/diffusion/parallelism/sequence_parallel.md
|
||||
- Tensor Parallel: user_guide/diffusion/parallelism/tensor_parallel.md
|
||||
- VAE Parallelism: user_guide/diffusion/parallelism/vae_parallelism.md
|
||||
- Attention Backends: user_guide/diffusion/attention_backends.md
|
||||
- Regional Compilation: user_guide/diffusion/regional_compilation.md
|
||||
- Frame Interpolation: user_guide/diffusion/frame_interpolation.md
|
||||
- Startup and Loading: user_guide/diffusion/startup_and_loading.md
|
||||
- LoRA: user_guide/diffusion/lora.md
|
||||
- Experimental:
|
||||
- Session State Manager: features/session_state_manager.md
|
||||
- Integrations:
|
||||
- Custom Diffusion Pipeline: features/custom_pipeline.md
|
||||
- ComfyUI: features/comfyui.md
|
||||
- Fault Tolerance:
|
||||
- Failure Modes: user_guide/fault_injection_reliability_matrix.md
|
||||
@@ -118,6 +114,7 @@ nav:
|
||||
- Shared Memory Connector: design/feature/omni_connectors/shared_memory_connector.md
|
||||
- Yuanrong Store Connector: design/feature/omni_connectors/yuanrong_connector.md
|
||||
- Yuanrong Transfer Engine Connector: design/feature/omni_connectors/yuanrong_transfer_engine_connector.md
|
||||
- Quantization: design/feature/quantization.md
|
||||
- Diffusion Acceleration:
|
||||
- Parallelism:
|
||||
- CFG-Parallel: design/feature/cfg_parallel.md
|
||||
@@ -127,9 +124,8 @@ nav:
|
||||
- Sequence Parallel: design/feature/sequence_parallel.md
|
||||
- Tensor Parallel: design/feature/tensor_parallel.md
|
||||
- VAE Patch Parallelism: design/feature/vae_parallel.md
|
||||
- Attention Backends:
|
||||
- Attention Optimization:
|
||||
- Skip-Softmax: design/feature/skip_softmax.md
|
||||
- Quantization: design/feature/quantization.md
|
||||
- Cache-DiT: design/feature/cache_dit.md
|
||||
- TeaCache: design/feature/teacache.md
|
||||
- Diffusion Continuous Batching: design/feature/diffusion_continuous_batching.md
|
||||
|
||||
@@ -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.
|
||||
|
||||
+13
-5
@@ -11,6 +11,10 @@ in the active navigation.
|
||||
|
||||
## Feature Design Documents
|
||||
|
||||
For user-facing configuration and current compatibility, see the
|
||||
[Features overview](../features/README.md). A design document defines an
|
||||
implementation contract; it is not, by itself, a general support claim.
|
||||
|
||||
### Runtime and stage execution
|
||||
|
||||
- [Disaggregated Inference](feature/disaggregated_inference.md)
|
||||
@@ -30,6 +34,10 @@ in the active navigation.
|
||||
- [Yuanrong Store Connector](feature/omni_connectors/yuanrong_connector.md)
|
||||
- [Yuanrong Transfer Engine Connector](feature/omni_connectors/yuanrong_transfer_engine_connector.md)
|
||||
|
||||
### Quantization
|
||||
|
||||
- [Quantization](feature/quantization.md)
|
||||
|
||||
### Diffusion acceleration
|
||||
|
||||
#### Parallelism
|
||||
@@ -42,14 +50,14 @@ in the active navigation.
|
||||
- [Tensor Parallel](feature/tensor_parallel.md)
|
||||
- [VAE Patch Parallelism](feature/vae_parallel.md)
|
||||
|
||||
#### Attention Backends
|
||||
#### Attention optimization
|
||||
|
||||
The [Diffusion Attention Backends](../user_guide/diffusion/attention_backends.md)
|
||||
guide lists all selectable backends and their platform defaults. Skip-Softmax
|
||||
is the backend-related optimization with a standalone feature-design contract:
|
||||
|
||||
- [Skip-Softmax](feature/skip_softmax.md)
|
||||
|
||||
#### Quantization
|
||||
|
||||
- [Quantization](feature/quantization.md)
|
||||
|
||||
- [Cache-DiT](feature/cache_dit.md)
|
||||
- [TeaCache](feature/teacache.md)
|
||||
- [Diffusion Continuous Batching](feature/diffusion_continuous_batching.md)
|
||||
|
||||
@@ -7,6 +7,7 @@ owners:
|
||||
- "@yinpeiqi"
|
||||
- "@fake0fan"
|
||||
- "@Sy0307"
|
||||
- "@Gaohan123"
|
||||
primary_code_paths:
|
||||
- vllm_omni/core/**
|
||||
- vllm_omni/worker/**
|
||||
|
||||
@@ -4,6 +4,7 @@ kind: module
|
||||
status: draft
|
||||
owners:
|
||||
- "@alex-jw-brooks"
|
||||
- "@Bounty-hunter"
|
||||
primary_code_paths:
|
||||
- vllm_omni/benchmarks/**
|
||||
- vllm_omni/entrypoints/cli/benchmark/**
|
||||
|
||||
@@ -5,6 +5,7 @@ status: draft
|
||||
owners:
|
||||
- "@Isotr0py"
|
||||
- "@princepride"
|
||||
- "@SamitHuang"
|
||||
primary_code_paths:
|
||||
- vllm_omni/diffusion/cache/**
|
||||
related_code_paths:
|
||||
|
||||
@@ -3,9 +3,10 @@ title: Diffusion Model Integration
|
||||
kind: module
|
||||
status: draft
|
||||
owners:
|
||||
- "@Isotr0py"
|
||||
- "@fhfuih"
|
||||
- "@Bounty-hunter"
|
||||
- "@wtomin"
|
||||
- "@ZJY0516"
|
||||
- "@RuixiangMa"
|
||||
primary_code_paths:
|
||||
- vllm_omni/diffusion/models/**
|
||||
- vllm_omni/diffusion/model_loader/**
|
||||
|
||||
@@ -6,6 +6,7 @@ owners:
|
||||
- "@Isotr0py"
|
||||
- "@princepride"
|
||||
- "@SamitHuang"
|
||||
- "@fhfuih"
|
||||
primary_code_paths:
|
||||
- vllm_omni/diffusion/sched/**
|
||||
- vllm_omni/diffusion/executor/**
|
||||
|
||||
@@ -4,7 +4,8 @@ kind: module
|
||||
status: draft
|
||||
owners:
|
||||
- "@yuanheng-zhao"
|
||||
- "@Isotr0py"
|
||||
- "@david6666666"
|
||||
- "@lishunyang12"
|
||||
primary_code_paths:
|
||||
- vllm_omni/diffusion/offloader/**
|
||||
related_code_paths:
|
||||
|
||||
@@ -6,6 +6,7 @@ owners:
|
||||
- "@princepride"
|
||||
- "@yuanheng-zhao"
|
||||
- "@xuechendi"
|
||||
- "@Bounty-hunter"
|
||||
primary_code_paths:
|
||||
- vllm_omni/diffusion/distributed/**
|
||||
- vllm_omni/diffusion/attention/parallel/**
|
||||
|
||||
@@ -6,6 +6,7 @@ architecture_state: current-plus-in-flight
|
||||
owners:
|
||||
- "@alex-jw-brooks"
|
||||
- "@linyueqian"
|
||||
- "@NickCao"
|
||||
document_stewards:
|
||||
- "@hsliuustc0106"
|
||||
- "@Gaohan123"
|
||||
|
||||
@@ -8,8 +8,15 @@ owners:
|
||||
- "@xuechendi"
|
||||
primary_code_paths:
|
||||
- vllm_omni/platforms/**
|
||||
related_code_paths:
|
||||
- vllm_omni/attention/**
|
||||
primary_code_path_owners:
|
||||
# Additional owners scoped to attention kernels (quantized/custom attention)
|
||||
- paths:
|
||||
- vllm_omni/attention/**
|
||||
owners:
|
||||
- "@david6666666"
|
||||
- "@lishunyang12"
|
||||
related_code_paths: []
|
||||
depends_on:
|
||||
- ar_runtime.md
|
||||
- diffusion/index.md
|
||||
|
||||
@@ -6,6 +6,8 @@ architecture_state: current
|
||||
owners:
|
||||
- "@Sy0307"
|
||||
- "@amy-why-3459"
|
||||
- "@Gaohan123"
|
||||
- "@alex-jw-brooks"
|
||||
document_stewards:
|
||||
- "@hsliuustc0106"
|
||||
- "@Gaohan123"
|
||||
|
||||
@@ -9,6 +9,14 @@ primary_code_paths:
|
||||
- vllm_omni/model_executor/**
|
||||
- vllm_omni/model_extras/**
|
||||
- vllm_omni/plugins/**
|
||||
primary_code_path_owners:
|
||||
# Additional owners scoped to a sub-path of model_executor/**
|
||||
- paths:
|
||||
- vllm_omni/model_executor/models/**
|
||||
owners:
|
||||
- "@Sy0307"
|
||||
- "@amy-why-3459"
|
||||
- "@linyueqian"
|
||||
related_code_paths:
|
||||
- vllm_omni/transformers_utils/**
|
||||
- vllm_omni/tokenizers/**
|
||||
|
||||
@@ -4,6 +4,7 @@ kind: module
|
||||
status: draft
|
||||
owners:
|
||||
- "@lishunyang12"
|
||||
- "@vraiti"
|
||||
primary_code_paths:
|
||||
- vllm_omni/metrics/**
|
||||
related_code_paths:
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Features
|
||||
|
||||
Use this section to choose and configure vLLM-Omni runtime and optimization
|
||||
features. The navigation follows the active
|
||||
[Feature Design](../design/index.md#feature-design-documents) taxonomy whenever
|
||||
an implementation contract has a user-facing workflow.
|
||||
|
||||
!!! note "User guides, design documents, and recipes"
|
||||
|
||||
User guides explain how to enable a feature and where it is supported.
|
||||
Design documents define internal contracts and do not, by themselves,
|
||||
imply general or production support. For model-specific launch flags and
|
||||
hardware requirements, use the linked examples and validated recipes from
|
||||
[Supported Models](../models/supported_models.md).
|
||||
|
||||
## Runtime and Stage Execution
|
||||
|
||||
| Goal | User guide | Related design contract |
|
||||
| --- | --- | --- |
|
||||
| Choose serial, batched, step-wise, or streaming diffusion execution | [Execution Modes and Streaming](../user_guide/diffusion/execution_modes.md) | [Diffusion Continuous Batching](../design/feature/diffusion_continuous_batching.md), [Async Diffusion Output](../design/feature/async_diffusion_output.md) |
|
||||
| Reclaim stage memory without restarting the server | [Sleep Mode](sleep_mode.md) | Runtime lifecycle behavior is documented in the user guide |
|
||||
|
||||
Some runtime designs are deliberately not promoted as standalone User Guide
|
||||
features yet:
|
||||
|
||||
- [Disaggregated Inference](../design/feature/disaggregated_inference.md) and
|
||||
[OmniConnector implementations](../design/index.md#communication) describe
|
||||
topology and transport contracts. Practical deployment configuration remains
|
||||
under [Pipeline and deploy configurations](../configuration/stage_configs.md).
|
||||
- [Async Chunk](../design/feature/async_chunk.md) and
|
||||
[Async Omni Output Materialization](../design/feature/omni_async_output_materialization.md)
|
||||
are model- and pipeline-dependent. Use the selected model's deploy
|
||||
configuration and recipe for the supported settings.
|
||||
- [Automatic Prefix Caching](../design/feature/prefix_caching.md) remains a
|
||||
design-level contract until its user-facing configuration and compatibility
|
||||
surface is consolidated.
|
||||
|
||||
## Quantization
|
||||
|
||||
Quantization is a cross-model feature rather than a diffusion-only
|
||||
optimization. The unified [`quantization_config` guide](../user_guide/quantization/overview.md)
|
||||
covers diffusion-only models, multi-stage omni/TTS models, and multi-stage
|
||||
diffusion models. Its [design contract](../design/feature/quantization.md)
|
||||
defines the shared configuration and backend extension points.
|
||||
|
||||
## Diffusion Acceleration
|
||||
|
||||
| Goal | User guide | Related design contract |
|
||||
| --- | --- | --- |
|
||||
| Compare acceleration methods and supported combinations | [Overview](../user_guide/diffusion_features.md), [Feature Compatibility](../user_guide/feature_compatibility.md) | [Diffusion acceleration designs](../design/index.md#diffusion-acceleration) |
|
||||
| Move weights between host and device memory | [CPU Offloading](../user_guide/diffusion/cpu_offload.md) | [Distributed Layerwise Offload](../design/feature/distributed_layerwise_offload.md) |
|
||||
| Reuse denoising computation | [Cache Acceleration](../user_guide/diffusion/cache_acceleration/cache_dit.md) | [Cache-DiT](../design/feature/cache_dit.md), [TeaCache](../design/feature/teacache.md) |
|
||||
| Distribute diffusion work across devices | [Parallelism](../user_guide/diffusion/parallelism/overview.md) | [Parallelism designs](../design/index.md#parallelism) |
|
||||
| Select dense, sparse, or quantized attention paths | [Attention Backends](../user_guide/diffusion/attention_backends.md) | [Skip-Softmax](../design/feature/skip_softmax.md) |
|
||||
| Compile repeated diffusion regions | [Regional Compilation](../user_guide/diffusion/regional_compilation.md) | User-facing optimization guide |
|
||||
| Add generated video frames | [Frame Interpolation](../user_guide/diffusion/frame_interpolation.md) | User-facing extension guide |
|
||||
| Reduce diffusion model startup time | [Startup and Loading](../user_guide/diffusion/startup_and_loading.md) | User-facing loading guide |
|
||||
| Apply diffusion adapters | [LoRA](../user_guide/diffusion/lora.md) | User-facing extension guide |
|
||||
|
||||
The [Pipeline Parallelism guide](../user_guide/diffusion/parallelism/pipeline_parallel.md)
|
||||
remains available by direct link, but it is not promoted in the primary User
|
||||
Guide navigation until its user-facing support and placement are settled.
|
||||
|
||||
## Experimental
|
||||
|
||||
[Session State Manager](session_state_manager.md) is opt-in and experimental.
|
||||
Its APIs and compatibility may change without notice.
|
||||
@@ -24,15 +24,22 @@ SERVING_MODE_TITLES = {
|
||||
"online_serving": "Online serving",
|
||||
}
|
||||
|
||||
# Generated example pages keep their source-derived URLs even when their
|
||||
# navigation category changes. Quantization helpers are feature documentation,
|
||||
# not user-serving examples, so keep them under the existing Features /
|
||||
# Quantization section instead of creating a second top-level Quantization
|
||||
# category under Examples.
|
||||
EXAMPLE_NAV_TARGETS = {
|
||||
"quantization": "quantization",
|
||||
}
|
||||
GENERATED_QUANTIZATION_EXAMPLES_TITLE = "Generated Examples"
|
||||
# Keep the Examples navigation focused on reusable modality/task entry points.
|
||||
# Model-specific scripts remain available under ``examples/`` and are surfaced
|
||||
# from these shared pages instead of getting one page per model.
|
||||
GENERAL_EXAMPLE_SLUGS = frozenset(
|
||||
{
|
||||
"image_to_image",
|
||||
"image_to_video",
|
||||
"speech_to_video",
|
||||
"text_to_audio",
|
||||
"text_to_image",
|
||||
"text_to_speech",
|
||||
"text_to_video",
|
||||
"x_to_text",
|
||||
"x_to_video_audio",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def load_model_display_names() -> dict[str, str]:
|
||||
@@ -56,6 +63,13 @@ def load_model_display_names() -> dict[str, str]:
|
||||
MODEL_DISPLAY_NAMES = load_model_display_names()
|
||||
|
||||
|
||||
def is_general_example(example: "Example") -> bool:
|
||||
"""Return whether an example should get a generated serving page."""
|
||||
if example.category not in SERVING_MODE_TITLES:
|
||||
return True
|
||||
return example.path.stem in GENERAL_EXAMPLE_SLUGS
|
||||
|
||||
|
||||
def fix_case(text: str) -> str:
|
||||
subs = {
|
||||
"api": "API",
|
||||
@@ -358,12 +372,9 @@ def update_nav_file(examples: list[Example]):
|
||||
items.append({example.nav_title: rel_path.as_posix()})
|
||||
return items
|
||||
|
||||
# Build the new Examples section - start with preserved items. Categories
|
||||
# not explicitly routed elsewhere retain the historical Examples behavior.
|
||||
# Build the new Examples section - start with preserved items.
|
||||
examples_section = preserved_items.copy()
|
||||
for category in sorted(examples_by_category.keys()):
|
||||
if EXAMPLE_NAV_TARGETS.get(category, "examples") != "examples":
|
||||
continue
|
||||
items = category_items(category)
|
||||
if items:
|
||||
category_title = fix_case(category.replace("_", " ").title())
|
||||
@@ -372,36 +383,6 @@ def update_nav_file(examples: list[Example]):
|
||||
# Update the nav structure
|
||||
nav_list[user_guide_idx]["User Guide"][examples_idx]["Examples"] = examples_section
|
||||
|
||||
features_content = next(
|
||||
(item["Features"] for item in user_guide_content if isinstance(item, dict) and "Features" in item),
|
||||
None,
|
||||
)
|
||||
quantization_content = None
|
||||
if isinstance(features_content, list):
|
||||
quantization_content = next(
|
||||
(item["Quantization"] for item in features_content if isinstance(item, dict) and "Quantization" in item),
|
||||
None,
|
||||
)
|
||||
|
||||
if isinstance(quantization_content, list):
|
||||
# Remove only the group owned by this hook so repeated builds are
|
||||
# idempotent and hand-authored Quantization navigation is retained.
|
||||
quantization_content[:] = [
|
||||
item
|
||||
for item in quantization_content
|
||||
if not (isinstance(item, dict) and GENERATED_QUANTIZATION_EXAMPLES_TITLE in item)
|
||||
]
|
||||
|
||||
quantization_examples = examples_by_category.get("quantization")
|
||||
if quantization_examples:
|
||||
if not isinstance(quantization_content, list):
|
||||
logger.warning(
|
||||
"Could not find 'User Guide' -> 'Features' -> 'Quantization' section; "
|
||||
"omitting generated quantization examples from navigation"
|
||||
)
|
||||
else:
|
||||
quantization_content.append({GENERATED_QUANTIZATION_EXAMPLES_TITLE: category_items("quantization")})
|
||||
|
||||
# Write back to file
|
||||
nav_data["nav"] = nav_list
|
||||
with open(NAV_FILE, "w", encoding="utf-8") as f:
|
||||
@@ -432,6 +413,8 @@ def on_startup(command: Literal["build", "gh-deploy", "serve"], dirty: bool):
|
||||
for path in category.glob("*/*.md"):
|
||||
examples.append(Example(path.parent, category.stem))
|
||||
|
||||
examples = [example for example in examples if is_general_example(example)]
|
||||
|
||||
# Generate the example documentation
|
||||
for example in sorted(examples, key=lambda e: e.path.stem):
|
||||
example_name = f"{example.path.stem}.md"
|
||||
|
||||
@@ -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` | ✅︎ | ✅︎ | | ✅︎ | — |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Diffusion Startup and Loading
|
||||
|
||||
Large diffusion models can take several minutes to load at startup. vLLM-Omni
|
||||
loads safetensors shards in parallel to reduce this initialization time.
|
||||
|
||||
Multi-thread weight loading is enabled by default with four threads. No
|
||||
configuration is needed for the default behavior.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Parameter | CLI flag | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `enable_multithread_weight_load` | `--disable-multithread-weight-load` | `True` | Pass the flag to disable multi-thread loading |
|
||||
| `num_weight_load_threads` | `--num-weight-load-threads` | `4` | Number of parallel weight-loading threads |
|
||||
|
||||
!!! tip
|
||||
|
||||
The default balances startup speed and disk I/O contention. Fast NVMe
|
||||
storage may benefit from more threads, while network storage or hard disks
|
||||
may not.
|
||||
|
||||
## Online Serving
|
||||
|
||||
```bash
|
||||
# Default: multi-thread loading with four threads
|
||||
vllm serve Qwen/Qwen-Image --omni --port 8091
|
||||
|
||||
# Increase the thread count
|
||||
vllm serve Wan-AI/Wan2.2-I2V-A14B-Diffusers --omni \
|
||||
--num-weight-load-threads 8
|
||||
|
||||
# Disable multi-thread loading
|
||||
vllm serve Qwen/Qwen-Image --omni --disable-multithread-weight-load
|
||||
```
|
||||
|
||||
## Offline Inference
|
||||
|
||||
```python
|
||||
from vllm_omni import Omni
|
||||
|
||||
# Default: multi-thread loading with four threads
|
||||
omni = Omni(model="Qwen/Qwen-Image")
|
||||
|
||||
# Increase the thread count
|
||||
omni = Omni(
|
||||
model="Wan-AI/Wan2.2-I2V-A14B-Diffusers",
|
||||
num_weight_load_threads=8,
|
||||
)
|
||||
```
|
||||
|
||||
## Reference Benchmarks
|
||||
|
||||
The following measurements were collected on NVIDIA H800 hardware. Treat them
|
||||
as reference results rather than a guarantee for other storage or hardware
|
||||
configurations.
|
||||
|
||||
| Model | Sequential loading | Multi-thread loading | Speedup |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| **Qwen/Qwen-Image** (53.7 GiB) | 168 s | 27 s | **6.2x** |
|
||||
| **Wan-AI/Wan2.2-I2V-A14B-Diffusers** (64.5 GiB) | 283 s | 56 s | **5.1x** |
|
||||
@@ -49,7 +49,7 @@ Parallelism methods distribute computation across GPUs without quality loss (mat
|
||||
|
||||
| Method | Description | Best For |
|
||||
|--------|-------------|----------|
|
||||
| **[Multi-Thread Weight Loading](#multi-thread-weight-loading)** | Loads safetensors shards in parallel using a thread pool | All diffusion models; reduces startup from minutes to seconds |
|
||||
| **[Multi-Thread Weight Loading](diffusion/startup_and_loading.md)** | Loads safetensors shards in parallel using a thread pool | All diffusion models; reduces startup from minutes to seconds |
|
||||
|
||||
**Note:** Some acceleration methods can be combined together for optimized performance. See [Feature Compatibility Table](#feature-compatibility) and [Feature Compatibility Tutorial](feature_compatibility.md) for detailed configuration examples.
|
||||
|
||||
@@ -100,7 +100,9 @@ The following tables show which models support each feature:
|
||||
|
||||
- **🔀SP (Ulysses & Ring)**: Includes both Ulysses-SP and Ring-Attention methods
|
||||
- ✅ = Fully supported
|
||||
- ✅* = Supported with the constraint listed below the table
|
||||
- ❌ = Not supported
|
||||
- ❓ = Not verified; not recommended
|
||||
|
||||
> Notes:
|
||||
|
||||
@@ -119,7 +121,7 @@ The following tables show which models support each feature:
|
||||
| **FLUX.2-dev** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
| **GLM-Image** | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **Hidream-I1-Full** | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **HunyuanImage3** | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ |
|
||||
| **HunyuanImage3** | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅* |
|
||||
| **Krea 2** | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
|
||||
| **LongCat-Image** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
| **LongCat-Image-Edit** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
@@ -145,6 +147,7 @@ The following tables show which models support each feature:
|
||||
> 2. `Tongyi-MAI/Z-Image-Turbo` and `SII-GAIR/daVinci-MagiHuman-Base-1080p` are distilled models with minimal NFEs; CFG-Parallel is not necessary.
|
||||
> 3. Cosmos3 T2I uses `Cosmos3OmniDiffusersPipeline` with `modalities=["image"]`. Model-level CPU offload swaps the nested UND reasoner and GEN generator pathways; layerwise offload remains available for blockwise GEN/UND offload.
|
||||
> 4. Krea 2 currently supports single-GPU inference plus LoRA, Cache-DiT, HSDP, CPU/layerwise offload, and VAE-patch-parallel (decode). TP/SP/CFG-Parallel are not yet wired. The few-step distilled (Turbo) checkpoint uses `is_distilled=true` (fixed timestep shift `mu=1.15`); generate at 2048x2048 by default with `num_inference_steps≈8` and `guidance_scale=0`. The Raw checkpoint uses 1024x1024, `num_inference_steps=28`, and `guidance_scale=4.5`.
|
||||
> 5. HunyuanImage3 supports step execution. Multi-request step execution requires `TORCH_SDPA`; see [Diffusion Execution Modes](diffusion/execution_modes.md#step-execution).
|
||||
|
||||
### VideoGen
|
||||
|
||||
@@ -155,12 +158,16 @@ The following tables show which models support each feature:
|
||||
| **Wan2.1-VACE** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
|
||||
| **LTX-2** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
|
||||
| **LTX-2.3** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
|
||||
| **Helios** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| **Helios** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅* |
|
||||
| **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (encode/decode) | ✅ | ❌ |
|
||||
| **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| **Cosmos3** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (encode/decode) | ✅ | ❌ |
|
||||
| **LongCat-Video-Avatar-1.5** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **MiniMax-H3** | ✅ (FL2VA) | ✅ | ✅ | ❌ | ✅ (DiT/TE) | ❌ | ✅ | ✅ | ✅ (tile) | ✅ (DiT) | ❌ |
|
||||
|
||||
> **Step execution note:** Helios supports single-request step execution only;
|
||||
> use `max_num_seqs=1`.
|
||||
|
||||
**Frame Interpolation Support**
|
||||
|
||||
- **Supported**: Wan2.2 text-to-video, image-to-video, and TI2V pipelines
|
||||
@@ -189,7 +196,7 @@ The following tables show which models support each feature:
|
||||
| **🔀Ring-Attn** | ✅ | ✅ | ✅ | | | | | | | | | | | |
|
||||
| **🔀CFG-Parallel** | ✅ | ✅ | ✅ | ✅ | | | | | | | | | | |
|
||||
| **🔀Tensor Parallel** | ✅ | ✅ | ✅ | ✅ | ✅ | | | | | | | | | |
|
||||
| **🔀HSDP** | ❓ | ❓ | ❓ | ❓ | ❓ | ❌ | | | | | | | | |
|
||||
| **🔀HSDP** | ❓ | ❓ | ✅ | ❓ | ✅ | ❌ | | | | | | | | |
|
||||
| **🔀Expert Parallel** | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | | | | | | |
|
||||
| **💾CPU Offloading (Layerwise)** | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | | | | | | |
|
||||
| **💾CPU Offloading (Module-wise)** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❓ | ❓ | ❌ | | | | | |
|
||||
@@ -200,97 +207,43 @@ The following tables show which models support each feature:
|
||||
|
||||
!!! info
|
||||
|
||||
1. Tensor Parallel and HSDP are not compatible.
|
||||
1. HSDP can be combined with Ulysses-SP or CFG-Parallel. Tensor Parallel
|
||||
and HSDP are not compatible; other HSDP combinations in the table
|
||||
remain unverified.
|
||||
2. TeaCache and Cache-DiT are not compatible.
|
||||
3. CPU Offloading (Layerwise) and CPU Offloading (Module-wise) are not compatible.
|
||||
4. CPU Offloading (Layerwise) supports single-card for now.
|
||||
5. Using FP8-Quant as an example of qunatization methods.
|
||||
4. The CPU Offloading (Layerwise) row describes local layerwise offload.
|
||||
Multi-device Distributed Layerwise Offload has a separate topology and
|
||||
compatibility matrix in the [CPU Offloading guide](diffusion/cpu_offload.md#distributed-layerwise-offloading).
|
||||
5. The compatibility matrix uses FP8 as the representative quantization method.
|
||||
6. Step Execution is not compatible with any diffusion cache backend. LoRA is supported, but each scheduled batch must use a single adapter (requests with different `lora_request` or `lora_scale` are kept in separate batches).
|
||||
|
||||
|
||||
## Multi-Thread Weight Loading
|
||||
|
||||
Large diffusion models can take several minutes to load weights at startup (e.g., ~3 min for Qwen-Image, ~5 min for Wan2.2 I2V 14B). Multi-thread weight loading speeds up this process by loading safetensors shards in parallel using a thread pool instead of sequentially.
|
||||
|
||||
This optimization is **enabled by default** with 4 threads. No configuration is needed for the default behavior.
|
||||
|
||||
### Configuration
|
||||
|
||||
| Parameter | CLI Flag | Default | Description |
|
||||
|-----------|----------|---------|-------------|
|
||||
| `enable_multithread_weight_load` | `--disable-multithread-weight-load` | `True` (enabled) | Pass the flag to disable multi-thread loading |
|
||||
| `num_weight_load_threads` | `--num-weight-load-threads` | `4` | Number of threads for parallel weight loading |
|
||||
|
||||
!!! tip
|
||||
The default of 4 threads balances speed and disk I/O contention. On fast NVMe storage you may benefit from more threads (e.g., 8). On HDD or network storage, the default of 4 avoids saturating I/O bandwidth.
|
||||
|
||||
### Online Serving
|
||||
|
||||
```bash
|
||||
# Default (multi-thread enabled, 4 threads)
|
||||
vllm serve Qwen/Qwen-Image --omni --port 8091
|
||||
|
||||
# Custom thread count
|
||||
vllm serve Wan-AI/Wan2.2-I2V-A14B-Diffusers --omni --num-weight-load-threads 8
|
||||
|
||||
# Disable multi-thread loading
|
||||
vllm serve Qwen/Qwen-Image --omni --disable-multithread-weight-load
|
||||
```
|
||||
|
||||
### Offline Inference
|
||||
|
||||
```python
|
||||
from vllm_omni import Omni
|
||||
|
||||
# Default (multi-thread enabled, 4 threads)
|
||||
omni = Omni(model="Qwen/Qwen-Image")
|
||||
|
||||
# Custom thread count
|
||||
omni = Omni(
|
||||
model="Wan-AI/Wan2.2-I2V-A14B-Diffusers",
|
||||
num_weight_load_threads=8,
|
||||
)
|
||||
```
|
||||
|
||||
### Benchmarks
|
||||
|
||||
Measured on NVIDIA H800:
|
||||
|
||||
| Model | Before | After | Speedup |
|
||||
|-------|--------|-------|---------|
|
||||
| **Qwen/Qwen-Image** (53.7 GiB) | 168s | 27s | **6.2x** |
|
||||
| **Wan-AI/Wan2.2-I2V-A14B-Diffusers** (64.5 GiB) | 283s | 56s | **5.1x** |
|
||||
The loading guide now lives at [Diffusion Startup and
|
||||
Loading](diffusion/startup_and_loading.md). This heading remains so existing
|
||||
links to this section continue to work.
|
||||
|
||||
## Learn More
|
||||
|
||||
**Cache Acceleration:**
|
||||
The Diffusion Acceleration navigation groups the remaining guides as follows:
|
||||
|
||||
- **[TeaCache Configuration Guide](diffusion/cache_acceleration/teacache.md)** - Parameter tuning, performance tips, troubleshooting
|
||||
- **[Cache-DiT Advanced Guide](diffusion/cache_acceleration/cache_dit.md)** - DBCache, TaylorSeer, SCM techniques and optimization
|
||||
| Area | Guide |
|
||||
| --- | --- |
|
||||
| Compatibility | [Feature Compatibility](feature_compatibility.md) |
|
||||
| CPU offloading | [CPU Offloading](diffusion/cpu_offload.md) |
|
||||
| Cache acceleration | [TeaCache](diffusion/cache_acceleration/teacache.md), [Cache-DiT](diffusion/cache_acceleration/cache_dit.md) |
|
||||
| Parallelism | [Parallelism Overview](diffusion/parallelism/overview.md) |
|
||||
| Attention | [Attention Backends](diffusion/attention_backends.md) |
|
||||
| Compilation | [Regional Compilation](diffusion/regional_compilation.md) |
|
||||
| Video extension | [Frame Interpolation](diffusion/frame_interpolation.md) |
|
||||
| Startup | [Startup and Loading](diffusion/startup_and_loading.md) |
|
||||
| Adapters | [LoRA](diffusion/lora.md) |
|
||||
|
||||
**Parallelism Methods:**
|
||||
Related cross-model and runtime features are documented separately:
|
||||
|
||||
- **[Parallelism Overview](diffusion/parallelism/overview.md)** - Tensor Parallelism, Sequence Parallelism, CFG Parallelism, Pipeline Parallelism, HSDP, and Expert Parallelism
|
||||
|
||||
**Memory Optimization:**
|
||||
|
||||
- **[CPU Offload Guide](diffusion/cpu_offload.md)** - Offload model components to CPU, reduce GPU memory usage
|
||||
- **[VAE Parallelism Guide](diffusion/parallelism/vae_parallelism.md)** - Distribute VAE decode work across GPUs for high-resolution images and videos
|
||||
- **[Quantization](quantization/overview.md)** - Quantization methods for diffusion, multi-stage omni/TTS, and multi-stage diffusion models
|
||||
|
||||
**Extensions:**
|
||||
|
||||
- **[LoRA Inference Guide](diffusion/lora.md)** - Low-Rank Adaptation for style customization and fine-tuning
|
||||
- **[Frame Interpolation Guide](diffusion/frame_interpolation.md)** - Worker-side post-generation video frame interpolation for smoother motion
|
||||
|
||||
**Execution Modes:**
|
||||
|
||||
- **[Diffusion Execution Modes](diffusion/execution_modes.md)** - Configure request batching, step execution, continuous batching, and streaming output
|
||||
|
||||
**Startup Optimization:**
|
||||
|
||||
- **[Multi-Thread Weight Loading](#multi-thread-weight-loading)** - Speed up model startup by loading safetensors shards in parallel
|
||||
|
||||
**Advanced Topics:**
|
||||
|
||||
- **[Feature Compatibility](feature_compatibility.md)** - How to combine multiple features for maximum performance
|
||||
- [Quantization](quantization/overview.md) covers diffusion-only models,
|
||||
multi-stage omni/TTS models, and multi-stage diffusion models.
|
||||
- [Execution Modes and Streaming](diffusion/execution_modes.md) covers the
|
||||
diffusion runtime, including batching, step execution, and streaming output.
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -79,7 +79,8 @@ sharing the tokenizer, processor, text encoder, and VAEs from `FL2VA`.
|
||||
### Multi-NPU: 768P combined-service configuration
|
||||
|
||||
Use Ulysses sequence parallelism degree 8, text-encoder tensor parallelism
|
||||
degree 8, native tiled VAE patch parallelism degree 8, and layerwise offload:
|
||||
degree 8, native tiled VAE patch parallelism degree 8, and distributed
|
||||
layerwise offload:
|
||||
|
||||
```bash
|
||||
export ASCEND_RT_VISIBLE_DEVICES=0,1,2,3,4,5,6,7
|
||||
@@ -98,7 +99,7 @@ vllm serve "${MODEL}" \
|
||||
--usp 8 \
|
||||
--ring 1 \
|
||||
--text-encoder-tp-size 8 \
|
||||
--enable-layerwise-offload \
|
||||
--enable-distributed-layerwise-offload \
|
||||
--vae-parallel-mode tile \
|
||||
--vae-use-tiling \
|
||||
--vae-patch-parallel-size 8 \
|
||||
@@ -112,6 +113,11 @@ H3 is CFG-distilled, so `--cfg-parallel-size` must remain 1.
|
||||
The same endpoint accepts `task=t2va`, `task=fl2va`, and `task=ref2va`; no
|
||||
partition restart is required. Layerwise offload applies to both DiTs.
|
||||
|
||||
On Atlas 800I A3 (64 GB HBM per device) the combined service does not fit at
|
||||
768P without offloading or sharding: use distributed layerwise offload (as
|
||||
above) or HSDP — see
|
||||
[§ Memory and attention optimizations](#memory-and-attention-optimizations-a3).
|
||||
|
||||
### Optional optimizations
|
||||
|
||||
Two independent optimizations may be enabled on top of the configuration
|
||||
@@ -134,6 +140,63 @@ Keep `--ring 1` when using RainFusion: the `rf_v2` kernel ranks key blocks
|
||||
over the whole sequence, so ring parallelism would split away the keys it
|
||||
needs. Scale with `--usp` instead.
|
||||
|
||||
## Memory and attention optimizations (A3)
|
||||
|
||||
### Fitting 768P into 64 GB HBM: distributed layerwise offload or HSDP
|
||||
|
||||
Each Atlas 800I A3 NPU has 64 GB of HBM, which is not enough for the
|
||||
combined MiniMax-H3 service at 768P. Enable **one** of the following at
|
||||
server startup:
|
||||
|
||||
**Distributed layerwise offload** — DiT layers are offloaded with parameters
|
||||
gathered across the parallel group instead of replicated per rank:
|
||||
|
||||
```bash
|
||||
--enable-distributed-layerwise-offload
|
||||
```
|
||||
|
||||
Measured peak NPU memory is about 45 GB per device for Ref2VA with a 13.88 s
|
||||
input video generating a 15 s 768P (1344x768) output video. Host (CPU) memory
|
||||
usage is high with this option.
|
||||
|
||||
**HSDP** — hybrid sharded data parallelism for the DiT parameters. The
|
||||
multi-stream memory-reuse knob is mandatory with this flag:
|
||||
|
||||
```bash
|
||||
export MULTI_STREAM_MEMORY_REUSE=2
|
||||
```
|
||||
|
||||
```bash
|
||||
--use-hsdp
|
||||
```
|
||||
|
||||
Host memory usage is small, but large shapes may still OOM; HBM usage
|
||||
optimizations for this path are ongoing.
|
||||
|
||||
### FLASH_ATTN backend with MindIE-SD
|
||||
|
||||
Keep `--diffusion-attention-backend FLASH_ATTN` and install MindIE-SD (see
|
||||
[§ Environment](#environment)). On NPU this backend carries most of the
|
||||
memory-reduction and performance work: a mask-free packed varlen path that
|
||||
never materializes the quadratic `full_qk` padding mask, and K/V prefix
|
||||
slicing, both driven by the packed `cu_seqlens` metadata emitted by the H3
|
||||
transformer.
|
||||
|
||||
### Optional: LaserAttention fused kernel
|
||||
|
||||
For an additional attention speedup, select the Ascend LaserAttention fused
|
||||
kernel before starting the server:
|
||||
|
||||
```bash
|
||||
export MINDIE_SD_FA_TYPE="ascend_laser_attention"
|
||||
```
|
||||
|
||||
This requires the FLASH_ATTN backend and MindIE-SD. H3 automatically applies
|
||||
exact power-of-two input pre-scaling (`laser_input_scale=256`) so the
|
||||
kernel's fp16 workspace cannot overflow on outlier activations. Measured
|
||||
speedup numbers will be added here.
|
||||
|
||||
|
||||
## HTTP API examples
|
||||
|
||||
The request format is identical to the GPU recipe; see
|
||||
@@ -154,8 +217,8 @@ configuration above:
|
||||
|
||||
| Workload | Configuration |
|
||||
|----------|---------------|
|
||||
| T2VA, 209 frames, 1344x768 | TE TP8, layerwise offload, Ulysses 8, VPP8 tile, regional compile |
|
||||
| Ref2VA (prompt + video), 124 frames, 1344x768 | TE TP8, layerwise offload, Ulysses 8, VPP8 tile, regional compile |
|
||||
| T2VA, 209 frames, 1344x768 | TE TP8, distributed layerwise offload, Ulysses 8, VPP8 tile, regional compile |
|
||||
| Ref2VA (prompt + video), 124 frames, 1344x768 | TE TP8, distributed layerwise offload, Ulysses 8, VPP8 tile, regional compile |
|
||||
|
||||
These measurements describe the validated shapes rather than a general
|
||||
throughput guarantee.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)."
|
||||
),
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -9,6 +9,10 @@ This script tests two main scenarios:
|
||||
2. Case 2: Comparing FlashAttention and SDPA backends for batch_size=2 with padding
|
||||
"""
|
||||
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
@@ -371,6 +375,264 @@ def test_packed_varlen_metadata_must_be_complete(monkeypatch):
|
||||
impl.forward_cuda(query, query, query, metadata)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NPU mask-free packed attention (PR #5891).
|
||||
#
|
||||
# These tests cover the backend-side NPU branches that ``forward_fa_npu`` adds:
|
||||
# - boundary resolution in ``_resolve_packed_seq_npu`` (pure Python, no device
|
||||
# sync, no MindIE-SD import);
|
||||
# - env dispatch in ``forward_fa_npu`` (which op a packed forward takes);
|
||||
# - the laser input pre-scaling math in ``_forward_prefix_kv_slice_npu``.
|
||||
#
|
||||
# They run on CPU without a real NPU by injecting a fake ``mindiesd`` module
|
||||
# into ``sys.modules`` (same pattern as ``test_paged_attention`` /
|
||||
# ``benchmarks/conftest``). No production code is exercised for real kernels.
|
||||
|
||||
|
||||
def _npu_impl(causal: bool = False) -> FlashAttentionImpl:
|
||||
return FlashAttentionImpl(num_heads=2, head_size=4, softmax_scale=0.5, causal=causal)
|
||||
|
||||
|
||||
def _fake_mindiesd(monkeypatch, *, attention_forward=None, attention_forward_varlen=None):
|
||||
"""Install a stub ``mindiesd`` so local ``from mindiesd import ...`` works.
|
||||
|
||||
Restored automatically by monkeypatch. Defaults are no-op callables so the
|
||||
top-level import in ``forward_fa_npu`` succeeds even when the test mocks the
|
||||
instance methods that would otherwise call them.
|
||||
"""
|
||||
fake = SimpleNamespace(
|
||||
attention_forward=attention_forward or Mock(return_value=torch.zeros(1)),
|
||||
attention_forward_varlen=attention_forward_varlen or Mock(return_value=torch.zeros(1)),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "mindiesd", fake)
|
||||
return fake
|
||||
|
||||
|
||||
# --- Test group A: boundary resolution (_resolve_packed_seq_npu) -------------
|
||||
|
||||
|
||||
def test_resolve_packed_seq_accepts_real_plus_pad_contract():
|
||||
impl = _npu_impl()
|
||||
q = torch.randn(1, 8, 2, 4)
|
||||
cu = torch.tensor([0, 5, 8], dtype=torch.int32)
|
||||
extra = {"cu_seqlens_q": cu, "cu_seqlens_k": cu, "max_seqlen_q": 5, "max_seqlen_k": 5}
|
||||
|
||||
assert impl._resolve_packed_seq_npu(q, q, extra) == ([5, 8], [5, 8])
|
||||
|
||||
|
||||
def test_resolve_packed_seq_single_document_no_padding():
|
||||
impl = _npu_impl()
|
||||
q = torch.randn(1, 8, 2, 4)
|
||||
cu = torch.tensor([0, 8], dtype=torch.int32)
|
||||
extra = {"cu_seqlens_q": cu, "cu_seqlens_k": cu, "max_seqlen_q": 8, "max_seqlen_k": 8}
|
||||
|
||||
assert impl._resolve_packed_seq_npu(q, q, extra) == ([8], [8])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
[
|
||||
"causal",
|
||||
"missing_key",
|
||||
"batch_ne_1",
|
||||
"cu_too_long",
|
||||
"cu_qk_mismatch",
|
||||
"max_seqlen_not_int",
|
||||
"used_gt_total",
|
||||
"used_zero",
|
||||
"pad_longer_than_real",
|
||||
],
|
||||
)
|
||||
def test_resolve_packed_seq_rejects_malformed_metadata(case):
|
||||
"""Any metadata outside the exact [real, pad] contract returns None so the
|
||||
caller falls back to the masked path."""
|
||||
cu3 = torch.tensor([0, 5, 8], dtype=torch.int32)
|
||||
cu2 = torch.tensor([0, 8], dtype=torch.int32)
|
||||
cu4 = torch.tensor([0, 3, 5, 8], dtype=torch.int32)
|
||||
cu_short = torch.tensor([0, 3, 8], dtype=torch.int32)
|
||||
base = {"cu_seqlens_q": cu3, "cu_seqlens_k": cu3, "max_seqlen_q": 5, "max_seqlen_k": 5}
|
||||
q = torch.randn(1, 8, 2, 4)
|
||||
|
||||
impl = _npu_impl(causal=(case == "causal"))
|
||||
if case == "missing_key":
|
||||
extra = {"cu_seqlens_q": cu3, "max_seqlen_q": 5} # 3 of 4 keys absent
|
||||
elif case == "batch_ne_1":
|
||||
q = torch.randn(2, 8, 2, 4)
|
||||
extra = base
|
||||
elif case == "cu_too_long":
|
||||
extra = {**base, "cu_seqlens_q": cu4, "cu_seqlens_k": cu4}
|
||||
elif case == "cu_qk_mismatch":
|
||||
extra = {**base, "cu_seqlens_q": cu3, "cu_seqlens_k": cu2}
|
||||
elif case == "max_seqlen_not_int":
|
||||
extra = {**base, "max_seqlen_q": 5.0}
|
||||
elif case == "used_gt_total":
|
||||
extra = {**base, "max_seqlen_q": 9} # 9 > total length 8
|
||||
elif case == "used_zero":
|
||||
extra = {**base, "max_seqlen_q": 0, "max_seqlen_k": 0}
|
||||
elif case == "pad_longer_than_real":
|
||||
# real=3, pad=5: the real document must be the longer one.
|
||||
extra = {
|
||||
"cu_seqlens_q": cu_short,
|
||||
"cu_seqlens_k": cu_short,
|
||||
"max_seqlen_q": 3,
|
||||
"max_seqlen_k": 3,
|
||||
}
|
||||
else:
|
||||
extra = base
|
||||
|
||||
assert impl._resolve_packed_seq_npu(q, q, extra) is None
|
||||
|
||||
|
||||
# --- Test group B: env dispatch in forward_fa_npu (current behavior) --------
|
||||
|
||||
|
||||
def _npu_impl_with_mocked_paths() -> tuple[FlashAttentionImpl, torch.Tensor]:
|
||||
"""FlashAttentionImpl whose two packed paths are Mocks returning a sentinel.
|
||||
|
||||
Lets a test assert which branch ``forward_fa_npu`` dispatched to without
|
||||
running any real kernel.
|
||||
"""
|
||||
impl = _npu_impl()
|
||||
sentinel = torch.tensor([1.0])
|
||||
impl._forward_varlen_packed_npu = Mock(return_value=sentinel)
|
||||
impl._forward_prefix_kv_slice_npu = Mock(return_value=sentinel)
|
||||
return impl, sentinel
|
||||
|
||||
|
||||
def test_npu_env_unset_uses_varlen(monkeypatch):
|
||||
monkeypatch.delenv("MINDIE_SD_FA_TYPE", raising=False)
|
||||
_fake_mindiesd(monkeypatch)
|
||||
impl, _ = _npu_impl_with_mocked_paths()
|
||||
metadata = AttentionMetadata(extra={"npu_attn_varlen": True})
|
||||
|
||||
impl.forward_fa_npu(torch.randn(1, 8, 2, 4), *[torch.randn(1, 8, 2, 4)] * 2, metadata)
|
||||
|
||||
impl._forward_varlen_packed_npu.assert_called_once()
|
||||
impl._forward_prefix_kv_slice_npu.assert_not_called()
|
||||
|
||||
|
||||
def test_npu_env_laser_uses_slice(monkeypatch):
|
||||
monkeypatch.setenv("MINDIE_SD_FA_TYPE", "ascend_laser_attention")
|
||||
_fake_mindiesd(monkeypatch)
|
||||
impl, _ = _npu_impl_with_mocked_paths()
|
||||
metadata = AttentionMetadata(extra={"npu_attn_varlen": True})
|
||||
|
||||
impl.forward_fa_npu(torch.randn(1, 8, 2, 4), *[torch.randn(1, 8, 2, 4)] * 2, metadata)
|
||||
|
||||
impl._forward_prefix_kv_slice_npu.assert_called_once()
|
||||
impl._forward_varlen_packed_npu.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fa_type", ["prompt_flash_attn", "fused_attn_score", "ascend_laser_attenton", "garbage"])
|
||||
def test_npu_env_non_laser_value_falls_back_to_varlen(monkeypatch, fa_type):
|
||||
"""Locks in the CURRENT dispatch behavior (PR #5891).
|
||||
|
||||
Any value other than ``ascend_laser_attention`` — including the valid
|
||||
``prompt_flash_attn``/``fused_attn_score`` and typos — is silently routed to
|
||||
the TND varlen op, which cannot honor ``MINDIE_SD_FA_TYPE``. This is review
|
||||
comment #1 on the PR and is intentionally captured here as a regression
|
||||
net: update this test when the dispatch starts honoring/rejecting these.
|
||||
"""
|
||||
monkeypatch.setenv("MINDIE_SD_FA_TYPE", fa_type)
|
||||
_fake_mindiesd(monkeypatch)
|
||||
impl, _ = _npu_impl_with_mocked_paths()
|
||||
metadata = AttentionMetadata(extra={"npu_attn_varlen": True})
|
||||
|
||||
impl.forward_fa_npu(torch.randn(1, 8, 2, 4), *[torch.randn(1, 8, 2, 4)] * 2, metadata)
|
||||
|
||||
impl._forward_varlen_packed_npu.assert_called_once()
|
||||
impl._forward_prefix_kv_slice_npu.assert_not_called()
|
||||
|
||||
|
||||
def test_npu_varlen_opt_in_unset_takes_mask_path(monkeypatch):
|
||||
"""Models that do not set ``npu_attn_varlen`` (e.g. Wan/Cosmos) keep using
|
||||
the existing masked attention_forward path; the new branches are skipped."""
|
||||
fake_forward = Mock(return_value=torch.zeros(1, 8, 2, 4))
|
||||
_fake_mindiesd(monkeypatch, attention_forward=fake_forward)
|
||||
impl, _ = _npu_impl_with_mocked_paths()
|
||||
metadata = AttentionMetadata(
|
||||
attn_mask=torch.tensor([[True, True, True, True, True, False, False, False]]),
|
||||
extra={}, # no npu_attn_varlen opt-in
|
||||
)
|
||||
q = torch.randn(1, 8, 2, 4)
|
||||
|
||||
out = impl.forward_fa_npu(q, q, q, metadata)
|
||||
|
||||
impl._forward_varlen_packed_npu.assert_not_called()
|
||||
impl._forward_prefix_kv_slice_npu.assert_not_called()
|
||||
fake_forward.assert_called_once()
|
||||
assert out is fake_forward.return_value
|
||||
|
||||
|
||||
# --- Test group C: laser input pre-scaling in _forward_prefix_kv_slice_npu --
|
||||
|
||||
|
||||
def test_prefix_kv_slice_applies_laser_input_scaling(monkeypatch):
|
||||
monkeypatch.setenv("MINDIE_SD_FA_TYPE", "ascend_laser_attention")
|
||||
captured: dict = {}
|
||||
|
||||
def fake_attention_forward(query, key, value, **kwargs):
|
||||
captured["query"] = query
|
||||
captured["key"] = key
|
||||
captured["value"] = value
|
||||
captured.update(kwargs)
|
||||
return torch.ones_like(query) # deterministic stand-in kernel output
|
||||
|
||||
_fake_mindiesd(monkeypatch, attention_forward=fake_attention_forward)
|
||||
impl = _npu_impl() # softmax_scale == 0.5
|
||||
q = torch.randn(1, 8, 2, 4)
|
||||
cu = torch.tensor([0, 5, 8], dtype=torch.int32)
|
||||
extra = {
|
||||
"cu_seqlens_q": cu,
|
||||
"cu_seqlens_k": cu,
|
||||
"max_seqlen_q": 5,
|
||||
"max_seqlen_k": 5,
|
||||
"laser_input_scale": 256.0,
|
||||
}
|
||||
|
||||
out = impl._forward_prefix_kv_slice_npu(q, q, q, extra)
|
||||
|
||||
# K/V sliced to the valid prefix (used_k == 5); q keeps full length.
|
||||
assert captured["key"].shape == (1, 5, 2, 4)
|
||||
assert captured["value"].shape == (1, 5, 2, 4)
|
||||
assert captured["query"].shape == (1, 8, 2, 4)
|
||||
assert captured["attn_mask"] is None
|
||||
# Pre-divide q/k/v by the factor and compensate the kernel scale by its
|
||||
# square (exact for power-of-two); output scaled back by the factor.
|
||||
torch.testing.assert_close(captured["query"], q / 256.0)
|
||||
torch.testing.assert_close(captured["key"], q[:, :5] / 256.0)
|
||||
assert captured["scale"] == pytest.approx(0.5 * 256.0**2)
|
||||
torch.testing.assert_close(out, torch.ones(1, 8, 2, 4) * 256.0)
|
||||
|
||||
|
||||
def test_prefix_kv_slice_no_scaling_without_factor(monkeypatch):
|
||||
monkeypatch.setenv("MINDIE_SD_FA_TYPE", "ascend_laser_attention")
|
||||
captured: dict = {}
|
||||
|
||||
def fake_attention_forward(query, key, value, **kwargs):
|
||||
captured["query"] = query
|
||||
captured.update(kwargs)
|
||||
return torch.ones_like(query)
|
||||
|
||||
_fake_mindiesd(monkeypatch, attention_forward=fake_attention_forward)
|
||||
impl = _npu_impl()
|
||||
q = torch.randn(1, 8, 2, 4)
|
||||
cu = torch.tensor([0, 5, 8], dtype=torch.int32)
|
||||
extra = {
|
||||
"cu_seqlens_q": cu,
|
||||
"cu_seqlens_k": cu,
|
||||
"max_seqlen_q": 5,
|
||||
"max_seqlen_k": 5,
|
||||
# no laser_input_scale → no pre-scaling
|
||||
}
|
||||
|
||||
out = impl._forward_prefix_kv_slice_npu(q, q, q, extra)
|
||||
|
||||
torch.testing.assert_close(captured["query"], q)
|
||||
assert captured["scale"] == pytest.approx(0.5)
|
||||
torch.testing.assert_close(out, torch.ones(1, 8, 2, 4))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Running FlashAttention Padding Tests...")
|
||||
print("=" * 60)
|
||||
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
@@ -492,6 +668,10 @@ def test_cudnn_packed_attention_uses_python_length_without_padding_mask():
|
||||
class FakeBackend:
|
||||
supports_prefix_kv_slicing = True
|
||||
|
||||
@classmethod
|
||||
def supports_packed_mask_free(cls) -> bool:
|
||||
return False
|
||||
|
||||
class FakeAttention(torch.nn.Module):
|
||||
attn_backend = FakeBackend
|
||||
|
||||
@@ -526,6 +706,48 @@ def test_cudnn_packed_attention_uses_python_length_without_padding_mask():
|
||||
assert attention.attention.metadata.extra["valid_kv_length"] == 5
|
||||
|
||||
|
||||
def test_packed_attention_skips_mask_for_packed_mask_free_backend():
|
||||
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
|
||||
MiniMaxH3Attention,
|
||||
)
|
||||
|
||||
class FakeBackend:
|
||||
supports_prefix_kv_slicing = False
|
||||
|
||||
@classmethod
|
||||
def supports_packed_mask_free(cls) -> bool:
|
||||
return True
|
||||
|
||||
class FakeAttention(torch.nn.Module):
|
||||
attn_backend = FakeBackend
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.metadata = None
|
||||
|
||||
def forward(self, query, key, value, metadata):
|
||||
self.metadata = metadata
|
||||
return query
|
||||
|
||||
attention = object.__new__(MiniMaxH3Attention)
|
||||
torch.nn.Module.__init__(attention)
|
||||
attention.attention = FakeAttention()
|
||||
q = torch.randn(8, 2, 4)
|
||||
|
||||
attention._run_packed_attention(
|
||||
q,
|
||||
q,
|
||||
q,
|
||||
cu_seqlens=torch.tensor([0, 5, 8], dtype=torch.int32),
|
||||
max_seqlen=5,
|
||||
packed_total=8,
|
||||
)
|
||||
|
||||
assert attention.attention.metadata.attn_mask is None
|
||||
assert attention.attention.metadata.extra["valid_kv_length"] == 5
|
||||
assert attention.attention.metadata.extra["npu_attn_varlen"] is True
|
||||
|
||||
|
||||
def test_packed_attention_keeps_padding_mask_for_other_backends():
|
||||
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
|
||||
MiniMaxH3Attention,
|
||||
@@ -534,6 +756,10 @@ def test_packed_attention_keeps_padding_mask_for_other_backends():
|
||||
class FakeBackend:
|
||||
supports_prefix_kv_slicing = False
|
||||
|
||||
@classmethod
|
||||
def supports_packed_mask_free(cls) -> bool:
|
||||
return False
|
||||
|
||||
class FakeAttention(torch.nn.Module):
|
||||
attn_backend = FakeBackend
|
||||
|
||||
@@ -1196,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):
|
||||
@@ -1437,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
|
||||
|
||||
@@ -6,7 +6,6 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
@@ -47,111 +46,9 @@ def test_model_display_names_require_string_mapping(tmp_path, monkeypatch):
|
||||
generate_examples.load_model_display_names()
|
||||
|
||||
|
||||
def test_quantization_examples_keep_source_urls_under_feature_navigation(tmp_path, monkeypatch):
|
||||
nav_file = tmp_path / "docs" / ".nav.yml"
|
||||
nav_file.parent.mkdir(parents=True)
|
||||
nav_file.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"nav": [
|
||||
{
|
||||
"User Guide": [
|
||||
{
|
||||
"Examples": [
|
||||
"examples/README.md",
|
||||
{"Offline Inference": [{"Old": "user_guide/examples/offline_inference/old.md"}]},
|
||||
]
|
||||
},
|
||||
{"Features": [{"Quantization": [{"Overview": "user_guide/quantization/overview.md"}]}]},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
sort_keys=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
def test_only_general_serving_examples_get_generated_pages():
|
||||
general_example = SimpleNamespace(category="offline_inference", path=Path("text_to_image"))
|
||||
model_example = SimpleNamespace(category="offline_inference", path=Path("qwen3_omni"))
|
||||
|
||||
monkeypatch.setattr(generate_examples, "ROOT_DIR", tmp_path)
|
||||
monkeypatch.setattr(generate_examples, "NAV_FILE", nav_file)
|
||||
monkeypatch.setattr(generate_examples, "EXAMPLE_DOC_DIR", tmp_path / "docs/user_guide/examples")
|
||||
|
||||
quantization_example = SimpleNamespace(
|
||||
category="quantization",
|
||||
path=tmp_path / "examples/quantization/check_modelopt_fp8_export.py",
|
||||
nav_title="Check Modelopt FP8 Export",
|
||||
)
|
||||
offline_example = SimpleNamespace(
|
||||
category="offline_inference",
|
||||
path=tmp_path / "examples/offline_inference/demo.py",
|
||||
nav_title="Demo",
|
||||
)
|
||||
|
||||
generate_examples.update_nav_file([quantization_example, offline_example])
|
||||
generate_examples.update_nav_file([quantization_example, offline_example])
|
||||
|
||||
nav_data = yaml.safe_load(nav_file.read_text(encoding="utf-8"))
|
||||
user_guide = nav_data["nav"][0]["User Guide"]
|
||||
examples = next(item["Examples"] for item in user_guide if "Examples" in item)
|
||||
features = next(item["Features"] for item in user_guide if "Features" in item)
|
||||
quantization = next(item["Quantization"] for item in features if "Quantization" in item)
|
||||
|
||||
assert not any(isinstance(item, dict) and "Quantization" in item for item in examples)
|
||||
generated_examples = [
|
||||
item[generate_examples.GENERATED_QUANTIZATION_EXAMPLES_TITLE]
|
||||
for item in quantization
|
||||
if generate_examples.GENERATED_QUANTIZATION_EXAMPLES_TITLE in item
|
||||
]
|
||||
assert len(generated_examples) == 1
|
||||
assert generated_examples[0] == [
|
||||
{"Check Modelopt FP8 Export": "user_guide/examples/quantization/check_modelopt_fp8_export.md"}
|
||||
]
|
||||
|
||||
# A later build without quantization examples must not leave a stale
|
||||
# generated group in the hand-authored Quantization section.
|
||||
generate_examples.update_nav_file([offline_example])
|
||||
nav_data = yaml.safe_load(nav_file.read_text(encoding="utf-8"))
|
||||
user_guide = nav_data["nav"][0]["User Guide"]
|
||||
features = next(item["Features"] for item in user_guide if "Features" in item)
|
||||
quantization = next(item["Quantization"] for item in features if "Quantization" in item)
|
||||
assert not any(
|
||||
isinstance(item, dict) and generate_examples.GENERATED_QUANTIZATION_EXAMPLES_TITLE in item
|
||||
for item in quantization
|
||||
)
|
||||
|
||||
|
||||
def test_quantization_examples_are_not_added_to_examples_without_feature_target(tmp_path, monkeypatch, caplog):
|
||||
nav_file = tmp_path / "docs" / ".nav.yml"
|
||||
nav_file.parent.mkdir(parents=True)
|
||||
nav_file.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"nav": [
|
||||
{
|
||||
"User Guide": [
|
||||
{"Examples": ["examples/README.md"]},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
sort_keys=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(generate_examples, "ROOT_DIR", tmp_path)
|
||||
monkeypatch.setattr(generate_examples, "NAV_FILE", nav_file)
|
||||
monkeypatch.setattr(generate_examples, "EXAMPLE_DOC_DIR", tmp_path / "docs/user_guide/examples")
|
||||
|
||||
quantization_example = SimpleNamespace(
|
||||
category="quantization",
|
||||
path=tmp_path / "examples/quantization/check_modelopt_fp8_export.py",
|
||||
nav_title="Check Modelopt FP8 Export",
|
||||
)
|
||||
|
||||
generate_examples.update_nav_file([quantization_example])
|
||||
|
||||
nav_data = yaml.safe_load(nav_file.read_text(encoding="utf-8"))
|
||||
examples = nav_data["nav"][0]["User Guide"][0]["Examples"]
|
||||
assert not any(isinstance(item, dict) and "Quantization" in item for item in examples)
|
||||
assert "omitting generated quantization examples from navigation" in caplog.text
|
||||
assert generate_examples.is_general_example(general_example)
|
||||
assert not generate_examples.is_general_example(model_example)
|
||||
@@ -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."""
|
||||
+12
-7
@@ -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",
|
||||
+37
-2
@@ -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(
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user