Compare commits

...
Author SHA1 Message Date
hsliuustc0106 765caa2a12 [Doc] Address README review feedback
Signed-off-by: hsliuustc0106 <liuhongsheng4@huawei.com>
2026-08-03 23:46:43 +08:00
hsliuustc0106 75fdab6b35 [Doc] Align v0.26 release documentation
Signed-off-by: hsliuustc0106 <liuhongsheng4@huawei.com>
2026-08-03 23:46:43 +08:00
hsliuustc0106 e261962661 [Doc] Update README for v0.26.0
Signed-off-by: hsliuustc0106 <liuhongsheng4@huawei.com>
2026-08-03 23:46:43 +08:00
Yukim1 7a2007cc17 [Refactor] Remove legacy --stage-configs-path from the serve CLI (#5647)
Signed-off-by: zwhzzz0821 <2831474076@qq.com>
2026-08-03 23:41:52 +08:00
WeiQing Chen 76de642c54 Update WeChat community QR code (#5701)
Signed-off-by: David Chen <530634352@qq.com>
2026-08-03 17:56:16 +08:00
ooooooye a4ea67a21b [Model] Add soundfile fallback for MiniMax H3 audio loading (#5699)
Signed-off-by: brandneway <gyuan4892@gmail.com>
2026-08-03 16:24:50 +08:00
zzh e0c2b9d73f Adapt HunyuanImage3.0 test for npu (#5436)
Signed-off-by: zzh <943967662@qq.com>
2026-08-03 06:31:50 +00:00
Isotr0py 24741961eb [Model] Add MiniMax H3 diffusion support (#5691) 2026-08-03 14:10:40 +08:00
wangyu 727f90958e [CI/Build] Split diffusion distributed nightly tests by L4 and H100 (#5695)
Signed-off-by: wangyu <410167048@qq.com>
2026-08-03 12:35:54 +08:00
ChoHee 67c54777bb [Bugfix] Reject /v1/completions for thinker+talker models when --trust-remote-code is unset (#5525)
Signed-off-by: ChoHee15 <cc5281@126.com>
2026-08-01 15:49:12 +00:00
Zhou TaichangandClaude Fable 5 2c4e9a945b [BugFix] Make pure-TP KV receive fallback all-or-nothing across ranks (#5636)
Signed-off-by: tzhouam <tzhouam@connect.ust.hk>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 15:43:48 +00:00
SYLARandlishunyang12 4b1547b4d8 [Bugfix] Fix Wan2.2 omitted guidance scale resolution (#5615)
Signed-off-by: lishunyang12 <lishunyang03@gmail.com>
Signed-off-by: SYLAR <125541396+lishunyang12@users.noreply.github.com>
Co-authored-by: lishunyang12 <lishunyang03@gmail.com>
2026-08-01 15:17:33 +00:00
SYLARandlishunyang12 6e8c279a68 [Bugfix] Match image file metadata to output format (#5619)
Signed-off-by: lishunyang12 <lishunyang03@gmail.com>
Co-authored-by: lishunyang12 <lishunyang03@gmail.com>
2026-08-01 15:09:27 +00:00
SYLARandlishunyang12 6f98c807d2 [Bugfix] Restore BAGEL CFG KV transfers (#5620)
Signed-off-by: lishunyang12 <lishunyang03@gmail.com>
Co-authored-by: lishunyang12 <lishunyang03@gmail.com>
2026-08-01 15:06:05 +00:00
Librium 185e3d6df6 [Bugfix] Preserve channel-last video tensor layout (#5418)
Signed-off-by: pxljs <1621352782@qq.com>
2026-08-01 07:05:57 +00:00
amy-why-3459andCursor 67aa6c55a4 [Bugfix][MiniCPM-o] Align Daily-Omni bench with MiniCPM interleaved A… (#5625)
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 22:41:00 -07:00
Shenglei Fu 9d67f8e02f [CI/Build] Let audio assertions pin the transcript language (#5646)
Signed-off-by: Shenglei Fu <sfu@confluent.io>
2026-07-31 21:44:28 -07:00
akshatvishu cd5daaea6a [BugFix][Metrics] Fix per-stage time and token stats in metrics API (#4974)
Signed-off-by: akshatvishu <akshatnayak197@gmail.com>
2026-08-01 03:54:17 +00:00
Harenome 安仁 Ranaivoarivony-Razanajato 8cb79308c6 diffusion: Fix silent override of explicit guidance_scale=0 (#4999)
Signed-off-by: Harenome Ranaivoarivony-Razanajato <harenome.razanajato1@huawei.com>
2026-08-01 03:19:21 +00:00
NATURE 0a03359224 [Bugfix][MiniCPM-o] Stabilize single-GPU audio generation (#5637)
Signed-off-by: natureofnature <wzliu@connect.hku.hk>
2026-08-01 03:01:06 +00:00
Weiming Liao fe02e8b81e [CI][NPU] Add MiniCPM-o 4.5 dependencies (#5660)
Signed-off-by: Weiming Liao <liaowm5@gmail.com>
2026-08-01 02:57:12 +00:00
SYLAR 896f05e7e6 [Kernel] Add typed SAGE for TRTLLM_ATTN (#5509)
Signed-off-by: lishunyang12 <lishunyang12@163.com>
2026-07-31 23:11:39 +00:00
林鑫 d33c905d80 [Perf] Cache RoPE cos/sin tables in code predictor (#5503)
Signed-off-by: I-wave <2083236231@qq.com>
2026-07-31 22:03:18 +08:00
Nathan PriceandClaude Opus 5 4715570acc [Bugfix][Full Duplex] Make runtime_control payloads JSON serializable (#5613)
Signed-off-by: thecodewrangler <nathan@abridge.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:17:25 -07:00
wuli666 4956a41f12 [Bugfix] Default missing legacy stage input sources (#5498)
Signed-off-by: wuli666 <421774554@qq.com>
2026-07-31 03:00:14 -07:00
ruirui(rein) yang 2739f0776b [Bugfix][MiniCPM-o]Fix implicit deploy connector resolution for registered pipelines (#5533)
Signed-off-by: R2-Y <ruiruyang2@gmail.com>
2026-07-31 02:38:26 -07:00
maithilijoshi20andmaithilijoshi20 2d8ef4754c [Bugfix] Restore Gradio demo dependency (#5632)
Signed-off-by: maithilijoshi20 <maithilijoshi2003@gmail.com>
Co-authored-by: maithilijoshi20 <maithilijoshi2003@gmail.com>
2026-07-31 02:24:45 -07:00
SYLARandlishunyang12 f8a8fbf72e [Bugfix][CI] Reuse servers across paired perf cases (#5624)
Signed-off-by: lishunyang12 <lishunyang03@gmail.com>
Co-authored-by: lishunyang12 <lishunyang03@gmail.com>
2026-07-31 08:23:17 +00:00
boatman 226cb4a38c [Bugfix][VoxCPM2] Fix Chunked Prefill Length Mismatch (#5416)
Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
2026-07-31 05:44:53 +00:00
0a51a18570 [Bugfix] Leave MiniCPM-o vocoder memory headroom (#5621)
Signed-off-by: lishunyang12 <lishunyang03@gmail.com>
Signed-off-by: SYLAR <125541396+lishunyang12@users.noreply.github.com>
Co-authored-by: lishunyang12 <lishunyang03@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-31 03:35:13 +00:00
SYLARandlishunyang12 499d930fe5 [Bugfix] Use MiniCPM-o speech template in long audio test (#5623)
Signed-off-by: lishunyang12 <lishunyang03@gmail.com>
Co-authored-by: lishunyang12 <lishunyang03@gmail.com>
2026-07-31 03:16:17 +00:00
273fc8eb1d [Tests] Further extend the tiny model testing framework and enable multiple models (#5353)
Signed-off-by: Nick Cao <ncao@redhat.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-07-31 07:47:26 +08:00
a951dd0cb4 [BugFix] Never dispatch diffusion with an incomplete CFG companion bundle (#5482)
Signed-off-by: tzhouam <tzhouam@gmail.com>
Co-authored-by: tzhouam <tzhouam@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 08:17:24 -07:00
akshatvishu 4563999ec0 [BugFix][Diffusion] Add CPU LAPACK fallback for FlowUniPC (#5329)
Signed-off-by: akshatvishu <akshatnayak197@gmail.com>
2026-07-30 15:09:48 +00:00
Weiming Liaoand汪志鹏 86d7864588 [Bugfix][Diffusion] Honor HF_HUB_OFFLINE in OmniDiffusionConfig (align with AR stage) (#5403)
Signed-off-by: Weiming Liao <liaowm5@gmail.com>
Signed-off-by: Weiming Liao <fayespica@outlook.com>
Co-authored-by: 汪志鹏 <wangzhipeng628@gmail.com>
2026-07-30 15:07:46 +00:00
Shenglei FuandClaude 3b0718f22e [Bugfix] Fix active_stream_window silently stalling audio generation (#5349) (#5373)
Signed-off-by: Shenglei Fu <sfu@confluent.io>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-30 14:56:18 +00:00
Yuanheng Zhao ce3d8e4bac [Bufix] Ming-omni-tts-16.8B Skip rejected and unused weights loading (#5607)
Signed-off-by: Yuanheng Zhao <jonathan.zhaoyh@gmail.com>
2026-07-30 22:38:40 +08:00
Henry JandClaude Fable 5 81cd7964d4 [Bugfix] Qwen3-TTS full-payload: emit one placeholder frame on a degenerate take instead of an empty payload (#5472)
Signed-off-by: henryj <349043+henryj@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:27:39 +00:00
Sy03 09c6ad4204 [CI/Build][MiniCPM-o] Allow auto-response before input commit (#5567)
Signed-off-by: Sy03 <1370724210@qq.com>
2026-07-30 14:14:19 +00:00
Yash JainandClaude Fable 5 f7e87e7cfd [Bugfix][Diffusion] Apply MagCache residual once per skipped step (#5561)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 07:09:25 -07:00
196 changed files with 12640 additions and 803 deletions

No files matched your search

+9 -3
View File
@@ -457,14 +457,20 @@ steps:
- group: ":card_index_dividers: Diffusion Test"
key: nightly-diffusion-test-group
depends_on: upload-nightly-pipeline
if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test" || build.pull_request.labels includes "diffusion-x2iat-test"
if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test"
steps:
- label: ":full_moon: Diffusion · Distributed Test"
- label: ":full_moon: Diffusion · Distributed Test with L4"
timeout_in_minutes: 60
commands:
- pytest -sv tests/diffusion/distributed/ -m 'full_model and cuda' --run-level "full_model"
- pytest -sv tests/diffusion/distributed/ -m 'full_model and cuda and L4' --run-level "full_model"
mirror_hardwares: l4_4
- label: ":full_moon: Diffusion · Distributed Test with H100"
timeout_in_minutes: 60
commands:
- pytest -sv tests/diffusion/distributed/ -m 'full_model and cuda and H100' --run-level "full_model"
mirror_hardwares: h100_4
- label: ":full_moon: Diffusion · Offloader Test"
timeout_in_minutes: 60
commands:
+82
View File
@@ -95,6 +95,80 @@ steps:
buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json"
buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log"
exit $$EXIT
- group: ":card_index_dividers: Diffusion I2I Model Test"
key: nightly-HunyuanImage3-x2i-test-group
depends_on: upload-nightly-pipeline
if: >-
build.env("NIGHTLY") == "1" ||
build.pull_request.labels includes "nightly-test" ||
build.pull_request.labels includes "diffusion-x2iat-test"
steps:
- label: ":full_moon: HunyuanImage3-DIT · Accuracy Test"
timeout_in_minutes: 180
mirror_hardwares: a3_npu_4
env:
VLLM_WORKER_MULTIPROC_METHOD: spawn
HUNYUAN_IMAGE3_MODEL: "tencent/HunyuanImage-3.0-Instruct"
HUNYUAN_IMAGE3_DEVICES: "0,1,2,3"
DIFFUSION_ATTENTION_BACKEND: TORCH_SDPA
commands:
- pytest -s -v tests/e2e/accuracy/test_hunyuan_image3_pixel_accuracy.py -m "full_model and A3" --run-level full_model
- group: ":card_index_dividers: Diffusion Benchmark Perf Test"
key: nightly-diffusion-benchmark-group
depends_on: upload-nightly-pipeline
if: >-
build.env("NIGHTLY") == "1" ||
build.pull_request.labels includes "nightly-test" ||
build.pull_request.labels includes "diffusion-x2iat-test"
steps:
- label: ":full_moon: Diffusion Perf · HunyuanImage3 TP=2 CFGP=2"
timeout_in_minutes: 180
mirror_hardwares: a3_npu_4
env:
VLLM_WORKER_MULTIPROC_METHOD: spawn
HF_TOKEN: "${HF_TOKEN}"
DIFFUSION_ATTENTION_BACKEND: TORCH_SDPA
commands:
- export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results
- |
set +e
pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py -m A3 --test-config-file tests/dfx/perf/tests/test_hunyuan_image_tp2_cfgp2.json
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json"
buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log"
exit $$EXIT
- label: ":full_moon: Diffusion Perf · HunyuanImage3 TP=2 SP=2"
timeout_in_minutes: 180
mirror_hardwares: a3_npu_4
env:
VLLM_WORKER_MULTIPROC_METHOD: spawn
HF_TOKEN: "${HF_TOKEN}"
DIFFUSION_ATTENTION_BACKEND: TORCH_SDPA
commands:
- export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results
- |
set +e
pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py -m A3 --test-config-file tests/dfx/perf/tests/test_hunyuan_image_tp2_sp2.json
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json"
buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log"
exit $$EXIT
- label: ":full_moon: Diffusion Perf · HunyuanImage3 TP=4"
timeout_in_minutes: 180
mirror_hardwares: a3_npu_4
env:
VLLM_WORKER_MULTIPROC_METHOD: spawn
HF_TOKEN: "${HF_TOKEN}"
DIFFUSION_ATTENTION_BACKEND: TORCH_SDPA
commands:
- export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results
- |
set +e
pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py -m A3 --test-config-file tests/dfx/perf/tests/test_hunyuan_image_tp4.json
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json"
buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log"
exit $$EXIT
- group: ":card_index_dividers: Diffusion X2V Model Test"
key: nightly-diffusion-x2v-group
depends_on: upload-nightly-pipeline
@@ -103,6 +177,14 @@ steps:
build.pull_request.labels includes "nightly-test" ||
build.pull_request.labels includes "diffusion-x2v-test"
steps:
- label: ":full_moon: Diffusion X2V · Function Test"
mirror_hardwares: a2b3_npu_4
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_wan22_expansion.py -k npu_i2v --run-level "full_model"
- label: ":full_moon: Diffusion X2V · Perf Test"
key: nightly-diffusion-x2v-performance
timeout_in_minutes: 180
+5 -8
View File
@@ -16,14 +16,10 @@ Easy, fast, and cheap omni-modality model serving for everyone
---
*Latest News* 🔥
- [2026/08] We released [0.26.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.26.0) - aligned with the vLLM 0.26 release line, featuring [MiniMax H3](recipes/MiniMaxAI/MiniMax-H3.md) joint video/audio generation, an experimental full-duplex realtime runtime for [MiniCPM-o 4.5](recipes/OpenBMB/MiniCPM-o-4_5.md), distributed layerwise diffusion offload, and broader model, hardware, streaming, TTS, and quantization support.
- [2026/07] We released [0.24.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.24.0) - aligned with the vLLM 0.24 release line, expanding production-ready coverage across TTS, speech, diffusion, image/video generation, and robot-policy serving, with major Omni stage runtime refactoring, diffusion request-level batching, async output materialization, quantization/cache/memory improvements, and broad CUDA/ROCm/XPU/NPU support.
- [2026/06] We released [0.22.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.22.0) - an **omnimodal world-model** release aligned with vLLM 0.22, featuring [Nvidia Cosmos3](recipes/cosmos3/Cosmos3-Nano.md)/DreamZero world model support, expanded quantization coverage across Blackwell/NPU/XPU, TTS production improvements, new models including MiniCPM-o 4.5, MOSS-TTS, and Lance, plus RL integration with [VeRL-Omni](https://github.com/verl-project/verl-omni).
- [2026/05] We released [0.20.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.20.0) - refreshes the serving/runtime stack for large-scale omni workloads, and improves diffusion model performance, quantization, and hardware readiness across CUDA, ROCm, MUSA, NPU, and XPU backends.
- [2026/03] We released [0.18.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.18.0) - strengthens the core runtime through a large entrypoint refactor and scheduler/runtime cleanups, expands unified quantization and diffusion execution, broadens multimodal model coverage, and improves production readiness across audio, omni, image, video, RL, and multi-platform deployments.
- [2026/06] Starting with [0.14.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.14.0), vLLM-Omni publishes a stable release aligned with every even-numbered upstream vLLM minor version. [0.16.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.16.0), [0.18.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.18.0), [0.20.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.20.0), and [0.22.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.22.0) continued this cadence, expanding omni and world-model support with [NVIDIA Cosmos3](recipes/cosmos3/Cosmos3-Nano.md) and DreamZero, adding models such as MiniCPM-o 4.5, MOSS-TTS, and Lance, and advancing TTS, diffusion, distributed execution, quantization, RL integration through [VeRL-Omni](https://github.com/verl-project/verl-omni), and CUDA/ROCm/MUSA/NPU/XPU coverage.
- [2026/03] Check out our first public [project deepdive](https://youtu.be/sgwNfsNnR9I) at the vLLM Hong Kong Meetup!
- [2026/03] **[vllm-omni-skills](https://github.com/hsliuustc0106/vllm-omni-skills)** is a community-driven collection of AI assistant skills that help developers work with vLLM-Omni more effectively. These skills can be used with popular agentic AI coding assistants like **Cursor IDE**, **Claude**, **Codex**, and more.
- [2026/02] We released [0.16.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.16.0) - A major alignment + capability release that rebases onto **upstream vLLM v0.16.0** and significantly expands performance, distributed execution, and production readiness across **Qwen3-Omni / Qwen3-TTS**, **Bagel**, **MiMo-Audio**, **GLM-Image** and the **Diffusion (DiT) image/video stack**—while also improving platform coverage (CUDA / ROCm / NPU / XPU), CI quality, and documentation.
- [2026/02] We released [0.14.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.14.0) - This is the first **stable release** of vLLM-Omni that expands Omni’s diffusion / image-video generation and audio / TTS stack, improves distributed execution and memory efficiency, and broadens platform/backend coverage (GPU/ROCm/NPU/XPU). It also brings meaningful upgrades to serving APIs, profiling & benchmarking, and overall stability. Please check our latest [paper](https://arxiv.org/abs/2602.02204) for architecture design and performance results.
- [2025/11] vLLM community officially released [vllm-project/vllm-omni](https://github.com/vllm-project/vllm-omni) in order to support omni-modality models serving.
---
@@ -55,12 +51,13 @@ vLLM-Omni is flexible and easy to use with:
- Tensor, pipeline, data and expert parallelism support for distributed inference
- Streaming outputs
- OpenAI-compatible API server
- Full-duplex realtime serving with streaming audio input and output (experimental)
vLLM-Omni seamlessly supports most popular open-source models on HuggingFace, including:
- **Omni-modality models** (e.g. Qwen3-Omni, Cosmos3, HunyuanImage, BAGEL)
- **Omni-modality models** (e.g. Qwen3-Omni, MiniCPM-o 4.5, Cosmos3, HunyuanImage, BAGEL)
- **TTS models** (e.g. Qwen3-TTS, VoxCPM2, Ming-Omni-TTS, CosyVoice3)
- **Diffusion models** — image, video, and audio generation (e.g. Qwen-Image, Wan2.2, FLUX)
- **Diffusion models** — image, video, and audio generation (e.g. MiniMax H3, Qwen-Image, Wan2.2, FLUX)
- **Robot-policy and action models** (e.g. GR00T-N1.7, DreamZero-DROID, InternVLA-A1, Cosmos3 action policy)
## Getting Started
+4
View File
@@ -26,6 +26,10 @@ RUN export PIP_EXTRA_INDEX_URL=https://mirrors.huaweicloud.com/ascend/repos/pypi
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/Ascend/ascend-toolkit/latest/`uname -i`-linux/devlib && \
python3 -m pip install -v -e /vllm-workspace/vllm-omni/ --no-build-isolation
RUN python3 -m pip install -v --no-deps \
"stepaudio2-minicpmo>=0.1.1" \
"step-audio2>=1.0.0"
ENV VLLM_WORKER_MULTIPROC_METHOD=spawn
ENTRYPOINT []
+4
View File
@@ -26,6 +26,10 @@ RUN export PIP_EXTRA_INDEX_URL=https://mirrors.huaweicloud.com/ascend/repos/pypi
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/Ascend/ascend-toolkit/latest/`uname -i`-linux/devlib && \
python3 -m pip install -v -e /vllm-workspace/vllm-omni/ --no-build-isolation
RUN python3 -m pip install -v --no-deps \
"stepaudio2-minicpmo>=0.1.1" \
"step-audio2>=1.0.0"
ENV VLLM_WORKER_MULTIPROC_METHOD=spawn
ENTRYPOINT []
+1
View File
@@ -35,6 +35,7 @@ RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \
"psutil>=7.2.0" \
"soundfile>=0.13.1" \
"pyttsx3>=2.99" \
"opencc==1.4.1" \
"opencv-python>=4.12.0.88" \
"imageio[ffmpeg]>=0.6.0" \
"jiwer>=3.0.0" \
+2
View File
@@ -35,6 +35,7 @@ RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \
"psutil>=7.2.0" \
"soundfile>=0.13.1" \
"pyttsx3>=2.99" \
"opencc==1.4.1" \
"opencv-python>=4.12.0.88" \
"imageio[ffmpeg]>=0.6.0" \
"jiwer>=3.0.0" \
@@ -44,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" \
"torchmetrics>=1.9.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 && \
+3 -2
View File
@@ -52,12 +52,13 @@ vLLM-Omni is flexible and easy to use with:
- Tensor, pipeline, data and expert parallelism support for distributed inference
- Streaming outputs
- OpenAI-compatible API server
- Full-duplex realtime serving with streaming audio input and output (experimental)
vLLM-Omni seamlessly supports most popular open-source models on HuggingFace, including:
- Omni-modality models (e.g. Qwen3-Omni, Cosmos3, HunyuanImage, BAGEL)
- Omni-modality models (e.g. Qwen3-Omni, MiniCPM-o 4.5, Cosmos3, HunyuanImage, BAGEL)
- TTS models (e.g. Qwen3-TTS, VoxCPM2, Ming-Omni-TTS, CosyVoice3)
- Diffusion models — image, video, and audio generation (e.g. Qwen-Image, Wan2.2, FLUX)
- Diffusion models — image, video, and audio generation (e.g. MiniMax H3, Qwen-Image, Wan2.2, FLUX)
- Robot-policy and action models (e.g. GR00T-N1.7, DreamZero-DROID, InternVLA-A1, Cosmos3 action policy)
For more information, checkout the following:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

After

Width:  |  Height:  |  Size: 274 KiB

+1 -7
View File
@@ -18,18 +18,12 @@ Specify the port:
vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091
```
For a migrated model, load a custom deploy configuration with `--deploy-config`:
Load a custom deploy configuration with `--deploy-config`:
```bash
vllm serve Qwen/Qwen2.5-Omni-7B --omni --deploy-config /path/to/deploy_config.yaml
```
The deprecated `--stage-configs-path` flag is retained for models that still use the legacy `stage_args` schema:
```bash
vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --stage-configs-path /path/to/legacy_stage_config.yaml
```
## bench
Run benchmark tests for online serving throughput.
+1 -2
View File
@@ -8,7 +8,6 @@ The stage-based CLI is designed for deployments that require launching each pipe
- For **migrated models** that utilize the bundled deployment YAML configurations located in
`vllm_omni/deploy/`, the `--deploy-config` flag is only required to override the default configuration. By default, executing `vllm serve MODEL --omni ...`
automatically loads the bundled deployment configuration.
- For custom legacy `stage_args` YAMLs, pass the file with `--stage-configs-path`.
Example: Initializing Stage 0 (Orchestrator and API Server):
The commands below show a common device mapping where Stage 0 uses GPU 0 and
@@ -32,7 +31,7 @@ CUDA_VISIBLE_DEVICES=1 vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni \
--omni-master-port 26000
```
When utilizing a custom deployment YAML based on the new schema, append `--deploy-config /path/to/override.yaml` to each command execution. Conversely, for legacy models, substitute this parameter with `--stage-configs-path /path/to/stage_configs.yaml`.
When utilizing a custom deployment YAML, append `--deploy-config /path/to/override.yaml` to each command execution.
In the standard execution paradigm, the `--stage-overrides` argument is utilized to apply stage-specific configurations from a single CLI command.
However, under the **stage-based CLI** paradigm, where each process strictly encapsulates a single stage, it is recommended to specify tuning parameters directly via discrete command-line flags for the respective stage, rather than constructing a composite `--stage-overrides` JSON string.
+1 -1
View File
@@ -2,7 +2,7 @@
This section lists the most common options for running vLLM-Omni.
For options within a vLLM Engine. Please refer to [vLLM Configuration](https://docs.vllm.ai/en/v0.16.0/configuration/index.html)
For options within a vLLM Engine, please refer to the [vLLM 0.26 configuration guide](https://docs.vllm.ai/en/v0.26.0/configuration/index.html).
Currently, the main options are maintained by stage configs for each model.
-15
View File
@@ -34,12 +34,8 @@ Two additional `StrategySpec` slots — `layer_hook_specs` and `kernel_specs`
| Flag | Description |
|------|-------------|
| `--strategy-config PATH` | Path to a `strategy.yaml`. Only takes effect on registry-based deploy paths (`--deploy-config` or the bundled default deploy YAML). Its derived sizing is overlaid onto the merged stages *before* CLI overrides. |
| `--stage-configs-path PATH` | **Legacy.** Loads the old `stage_args` YAML schema for un-migrated models. Passing `--strategy-config` together with this path is silently inapplicable — vLLM-Omni emits a warning and the strategy is ignored (see `vllm_omni/entrypoints/utils.py`, lines 372–380). |
| `--omni-lb-policy POLICY` | Orchestrator-wide load-balancer policy. Strategy-derived from any `stage_replica` axis; an explicit CLI value wins and must match the derived one or `AsyncOmniEngine` raises `Conflicting load-balancer policy` at construction. |
!!! warning
`--strategy-config` cannot be used with `--stage-configs-path` (the legacy deploy path). If your model has not been migrated to the registry-based pipeline yet, the strategy is silently dropped with a warning. Migrate to a registry-based model or use `--deploy-config` to apply a strategy.
## Sub-schemas
### `axis`
@@ -216,17 +212,6 @@ Strategy keys MUST be `model_stage` *names* (strings), the same labels stage con
!!! warning "Mixing CLI overrides with a strategy on the same axis"
A strategy is meant to be the single writer for the axes it declares. If a CLI override (`--tensor-parallel-size 2`, `--stage-overrides '{"0": {"tensor_parallel_size": 2}}'`) sets the same axis, the **CLI value wins** and a warning is emitted from `_reconcile_strategy_with_cli`. The device-layout guard then re-runs against the effective layout, so a CLI override that breaks `tp * dp * pp * num_replicas` will still fail fast at config time.
!!! warning "Passing `--strategy-config` against the legacy `--stage-configs-path`"
Composable parallel only applies on the registry-based deploy path. If a model still resolves through the legacy `stage_args` YAML (loaded via `--stage-configs-path`), `--strategy-config` is silently inapplicable and emits:
```text
--strategy-config (<path>) was provided but model '<model>' resolves via the
legacy stage_configs YAML path, which does not support composable-parallel
strategies; the strategy is ignored. Use a registry-based model to apply it.
```
Migrate the model to the new deploy schema (see `configuration/stage_configs.md`) before applying a strategy.
!!! warning "Conflicting `omni_lb_policy` across stages"
`omni_lb_policy` is a single pipeline-wide knob. If two `stage_replica` axes in different stages derive different policies, `apply_strategy_specs` raises:
+1 -1
View File
@@ -158,7 +158,7 @@ runtime:
```bash
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 \
--stage-configs-path /path/to/qwen3_omni_pd.yaml
--deploy-config /path/to/qwen3_omni_pd.yaml
```
## Operational Notes
+6 -14
View File
@@ -1,9 +1,9 @@
# Deploy and legacy stage configurations
# Pipeline and deploy configurations
In vLLM-Omni, a model's `PipelineConfig` defines its fixed stage topology, while a deploy configuration controls how those stages run. Models that have not migrated to this split still use legacy stage configurations with a `stage_args` schema.
In vLLM-Omni, a model's `PipelineConfig` defines its fixed stage topology, while a deploy configuration controls how those stages run.
!!! note
Default deploy config YAMLs (for example, `vllm_omni/deploy/qwen2_5_omni.yaml`, `vllm_omni/deploy/qwen3_omni_moe.yaml`, and `vllm_omni/deploy/qwen3_tts.yaml`) are bundled and loaded automatically when neither `--stage-configs-path` nor `--deploy-config` is provided — the model registry resolves the right pipeline + deploy YAML by `model_type`. Custom legacy `stage_args` YAMLs are still accepted via `--stage-configs-path`.
Default deploy config YAMLs (for example, `vllm_omni/deploy/qwen2_5_omni.yaml`, `vllm_omni/deploy/qwen3_omni_moe.yaml`, and `vllm_omni/deploy/qwen3_tts.yaml`) are bundled and loaded automatically when `--deploy-config` is omitted. The model registry resolves the right pipeline and deploy YAML by `model_type`.
## Deploy configuration schema
@@ -86,10 +86,9 @@ stages:
| Flag | Description |
|------|-------------|
| `--deploy-config PATH` | Load a new-schema deploy YAML. It is mutually exclusive with `--stage-configs-path`. **Optional** — when omitted, the bundled `vllm_omni/deploy/<model_type>.yaml` is auto-loaded by the model registry. |
| `--deploy-config PATH` | Load a deploy YAML. **Optional** — when omitted, the bundled `vllm_omni/deploy/<model_type>.yaml` is auto-loaded by the model registry. |
| `--stage-overrides JSON` | Per-stage JSON overrides, e.g. `'{"0":{"gpu_memory_utilization":0.5}}'`. Per-stage values always win over global flags. |
| `--async-chunk` / `--no-async-chunk` | Flip the deploy YAML's `async_chunk:` bool. Unset (default) leaves the YAML value in force. |
| `--stage-configs-path` | **Deprecated.** Load a legacy `stage_args` YAML. New-schema YAMLs passed to this flag are auto-detected for compatibility, but should use `--deploy-config`. It is mutually exclusive with `--deploy-config` and will be removed in a future release. |
### Stage-Based CLI Paradigm
@@ -119,8 +118,7 @@ CUDA_VISIBLE_DEVICES=1 vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni \
--omni-master-port 26000
```
When instantiating a custom deployment YAML conforming to the updated schema, append the `--deploy-config /path/to/override.yaml` directive
to all node invocations. For legacy architectures (e.g., BAGEL) configured via deprecated `stage_args:` schemas, continue to specify the relevant configuration via `--stage-configs-path /path/to/config.yaml`.
When instantiating a custom deployment YAML, append the `--deploy-config /path/to/override.yaml` directive to all node invocations.
In the context of standard initialization architectures, utilizing the `--stage-overrides` parameter operates as the optimal methodology
for delineating stage-specific tuning from the CLI interface:
@@ -220,8 +218,7 @@ Therefore, as a core part of vLLM-Omni, a model's pipeline and deployment config
To override specific parameters, explicitly inject the customized configuration schema
in both online and offline instantiation flows. Use the `--deploy-config` flag
when loading a deploy configuration, reserving the `--stage-configs-path` parameter
exclusively to maintain compatibility with legacy `stage_args` YAML constructs.
when loading a deploy configuration.
Examples:
@@ -236,11 +233,6 @@ For online serving:
vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 --deploy-config /path/to/deploy_config.yaml
```
Legacy online serving:
```bash
vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --port 8091 --stage-configs-path /path/to/stage_configs_file
```
!!! important
We are actively iterating on the definition of deployment configurations, and we welcome feedback from users and developers.
@@ -653,7 +653,7 @@ python end2end.py --model your-org/your-model-name --modality text2img --prompts
Mirror BAGEL’s online serving setup:
- Server launcher: `examples/online_serving/your_model_name/run_server.sh`
- Wrap `vllm serve ... --omni --port ...` (and `--stage-configs-path ...` if needed)
- Wrap `vllm serve ... --omni --port ...` (and `--deploy-config ...` if needed)
- Client: `examples/online_serving/your_model_name/openai_chat_client.py`
- Send requests to `POST /v1/chat/completions`
- Support multimodal inputs (e.g., base64 image) if your model needs it
+2 -2
View File
@@ -48,8 +48,8 @@ vllm_omni/deploy/
```
New in-tree models should register their `PipelineConfig` in
`vllm_omni/config/pipeline_registry.py`; use `--stage-configs-path` only for
custom legacy `stage_args` YAMLs.
`vllm_omni/config/pipeline_registry.py`; use `--deploy-config` for custom
deployment YAMLs.
## Step-by-Step Implementation
+2 -3
View File
@@ -449,9 +449,8 @@ Key configuration fields:
!!! note
New in-tree models should define frozen topology in `models/<model>/pipeline.py`,
register it in `vllm_omni/config/pipeline_registry.py`, and put deployment
knobs in `vllm_omni/deploy/<model>.yaml`. Legacy `stage_args` YAMLs are still
accepted for custom configs via `--stage-configs-path`, but should not be used
for new bundled defaults.
knobs in `vllm_omni/deploy/<model>.yaml`. Use `--deploy-config` to load a
custom deployment YAML.
### Batch mode
+7 -1
View File
@@ -148,7 +148,13 @@ Upstream vLLM's `Scheduler.make_stats()` runs on every AR generation step, retur
`OmniSchedulerMixin.make_stats()` (in `vllm_omni/core/sched/omni_scheduler_mixin.py`) throttles stats emission to at most once per second **per scheduler** — i.e. per `(stage, replica)` since each replica owns its own scheduler instance. Between intervals it returns `None`, which the engine core skips serializing. This keeps gauges fresh enough for Prometheus scrapes (typically 15-30s intervals) while eliminating the per-step overhead.
The orchestrator side does not add its own throttle on top: the per-replica recording loop gates only on `raw_outputs.scheduler_stats is not None` (i.e. this replica's scheduler passed its own 1 Hz gate). A previous global `_last_stats_ts` on the orchestrator was removed because it starved every replica other than the first to emit within each second.
The orchestrator side does not add its own throttle on top. Scheduler gauges are
recorded when `raw_outputs.scheduler_stats is not None` (i.e. this replica's
scheduler passed its own 1 Hz gate), while output-batch request/token metrics
are recorded from `IterationStats` whenever a non-empty output batch is
processed. A previous global `_last_stats_ts` on the orchestrator was removed
because it starved every replica other than the first to emit within each
second.
## Metric Definitions
@@ -419,8 +419,7 @@ Notes:
```bash
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct \
--omni \
--port 8091 \
--stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_omni_moe_async_chunk.yaml
--port 8091
```
#### 3) Key config knobs
@@ -494,7 +493,7 @@ The default config (`qwen3_tts.yaml`) enables the full optimization stack:
vllm-omni serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
--omni \
--port 8000 \
--stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_tts_no_async_chunk.yaml
--no-async-chunk
```
#### 3) Key config knobs
@@ -1,5 +1,8 @@
# Installation
!!! important
vLLM-Omni is released against the matching upstream vLLM major/minor version. vLLM-Omni 0.26.x requires vLLM 0.26.x; the stable v0.26.0 instructions pin vLLM 0.26.0.
vLLM-Omni supports the following hardware platforms:
- [GPU](gpu.md)
@@ -5,7 +5,7 @@
# --8<-- [end:requirements]
# --8<-- [start:set-up-using-python]
vLLM-Omni depends vLLM. So please follow instructions below mainly for vLLM.
vLLM-Omni depends on the matching major/minor release of vLLM. The vLLM-Omni 0.26.x release line uses vLLM 0.26.x.
!!! note
PyTorch installed via `conda` will statically link `NCCL` library, which can cause issues when vLLM tries to use `NCCL`. See <gh-issue:8420> for more details.
@@ -124,7 +124,7 @@ If you want to specify the base vLLM version:
```bash
DOCKER_BUILDKIT=1 docker build \
-f docker/Dockerfile.cuda \
--build-arg BASE_IMAGE=vllm/vllm-openai:v0.22.1 \
--build-arg BASE_IMAGE=vllm/vllm-openai:v0.26.0 \
-t vllm-omni-cuda .
```
@@ -53,16 +53,20 @@ We are keeping [issue #886](https://github.com/vllm-project/vllm-omni/issues/886
You can also build vLLM-Omni from the latest main branch if you want to use the latest features or bug fixes. (But sometimes it will break for a while. You can check [issue #886](https://github.com/vllm-project/vllm-omni/issues/886) for the status of the latest commit of vLLM-Omni main branch on NPU.)
```bash
# Pin vLLM version to 0.18.0
git clone -b v0.18.0 https://github.com/vllm-project/vllm.git
# Pin vLLM and vLLM-Ascend to the v0.26 release line
git clone -b v0.26.0 https://github.com/vllm-project/vllm.git
cd vllm
VLLM_TARGET_DEVICE=empty pip install -v -e .
cd ..
git clone -b v0.18.0rc1 https://github.com/vllm-project/vllm-ascend.git
git clone -b releases/v0.26.0rc https://github.com/vllm-project/vllm-ascend.git
cd vllm-ascend
pip install -v -e .
cd ..
# Install vLLM-Omni from the latest main branch
git clone https://github.com/vllm-project/vllm-omni.git
cd /vllm-workspace/vllm-omni
cd vllm-omni
pip install -v -e . --no-build-isolation
# or VLLM_OMNI_TARGET_DEVICE=npu pip install -v -e .
export VLLM_WORKER_MULTIPROC_METHOD=spawn
+1
View File
@@ -44,6 +44,7 @@ th {
| `LTX2Pipeline` | LTX-2 / LTX-2.3 one-stage T2V and I2V | `Lightricks/LTX-2`, `diffusers/LTX-2.3-Diffusers` | ✅︎ | ✅︎ | | |
| `LTX2DistilledPipeline` | LTX-2 distilled two-stage T2V and I2V | `rootonchair/LTX-2-19b-distilled` | ✅︎ | ✅︎ | | |
| `LingBotVideoPipeline` | LingBot-Video dense and MoE T2V | `robbyant/lingbot-video-dense-1.3b`, `robbyant/lingbot-video-moe-30b-a3b` | ✅︎ | | | |
| `MiniMaxH3Pipeline` | MiniMax H3 T2VA, FL2VA, and Ref2VA | `MiniMaxAI/MiniMax-H3` | ✅︎ | | | |
| `DreamZeroPipeline` | DreamZero-DROID | `GEAR-Dreams/DreamZero-DROID` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `HeliosPipeline`, `HeliosPyramidPipeline` | Helios | `BestWishYsh/Helios-Base`, `BestWishYsh/Helios-Mid`, `BestWishYsh/Helios-Distilled` | ✅︎ | ✅︎ | ✅︎ | |
| `MagiHumanPipeline` | MagiHuman | `SII-GAIR/daVinci-MagiHuman-Base-1080p` | ✅︎ | ✅︎ | | |
+53 -64
View File
@@ -15,7 +15,7 @@ The full set of backends and their platform defaults is in the **Backend Options
| Value | Notes |
|---|---|
| `TRTLLM_ATTN` | FlashInfer's trtllm-gen FMHA (TensorRT-LLM's generated kernels, vendored by FlashInfer). BF16, GQA native, `head_dim=128`. Datacenter Blackwell only (sm_100 / sm_103). Supports **Skip-Softmax** sparse attention — see below. Requires `flashinfer`. |
| `FLASH_ATTN` | Wraps FlashAttention 2. Default on Hopper / Ada / Ampere when `flash-attn` is installed. |
| `FLASH_ATTN` | Wraps FlashAttention 4 on Blackwell when `flash-attn-4` is installed, then falls back to FlashAttention 3/2. Default on Hopper / Ada / Ampere when a compatible FlashAttention package is installed. |
| `CUDNN_ATTN` | Pins `sdpa_kernel([CUDNN_ATTENTION])`. Default on Blackwell (sm_10x / sm_12x) with cuDNN ≥ 9.5. Wins on mask-heavy DiTs (HunyuanVideo-1.5: 2× e2e vs SDPA). |
| `FLASHINFER_ATTN` | Calls FlashInfer's dense `single_prefill_with_kv_cache` directly with `custom_mask` for non-causal masked attention. Used as Blackwell fallback when cuDNN is unavailable. Requires `flashinfer`. |
| `TORCH_SDPA` | PyTorch `scaled_dot_product_attention` with the default backend dispatcher. Most conservative; always available. |
@@ -167,6 +167,48 @@ sequence length, since Skip-Softmax only accelerates attention. Dense BF16 is `T
Requires datacenter Blackwell with `head_dim == 128`; elsewhere, or without FlashInfer, selecting
`TRTLLM_ATTN` raises rather than silently degrading.
## TRTLLM_ATTN SAGE Quantization
On top of dense/Skip-Softmax, `TRTLLM_ATTN` can run **SAGE** (SageAttention) — the trtllm-gen
FMHA kernel with per-block quantized Q/K and per-channel fp8 V, so the two attention matmuls run
in low precision. Enable it through the typed `quant` block on the attention spec:
| Key | Valid values | Meaning |
|---|---|---|
| `dtype_qk` | `int8`, `fp8_e4m3` | Quantization dtype for Q and K. Absent ⇒ dense (SAGE off). |
| `q_block_size` | `1`, `4`, `16` | Per-token block size for Q scales (default `1`). |
| `k_block_size` | `1`, `4`, `16` | Per-token block size for K scales (default `16`). |
V is always quantized per-channel to fp8_e4m3 and K-smoothing is applied inside the routine, so
neither is a user knob. Only block sizes `1`, `4`, `16` have compiled kernels.
The `quant` block is a shared `AttnQuantSpec` consumed by more than one backend; each reads the
fields that apply and rejects the rest. `TRTLLM_ATTN` (SAGE) reads `dtype_qk` (`int8` / `fp8_e4m3`),
`q_block_size`, and `k_block_size`; `FLASHINFER_ATTN` reads `dtype_qk` (`float16` / `bfloat16`),
`dtype_vo`, and `flashinfer_backend` (see its section). A `dtype_qk` from the wrong set raises.
```bash
vllm-omni serve Wan-AI/Wan2.2-T2V-A14B-Diffusers \
--diffusion-attention-config '{"default": {"backend": "TRTLLM_ATTN",
"quant": {"dtype_qk": "fp8_e4m3", "q_block_size": 1, "k_block_size": 16}}}'
```
```python
from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec, AttnQuantSpec
AttentionConfig(
default=AttentionSpec(
backend="TRTLLM_ATTN",
quant=AttnQuantSpec(dtype_qk="fp8_e4m3", q_block_size=1, k_block_size=16),
),
)
```
**Requirements.** Needs FlashInfer ≥ 0.6.16rc1. Kernel
availability is arch-dependent: `fp8_e4m3` QK has kernels on both **SM100** (B200) and **SM103**
(B300); `int8` QK kernels are compiled for **SM100 only**.
## End-to-End Benchmark (BF16, sm_120 RTX Pro 6000 Blackwell)
Same prompt and seed across runs. `Total generation time` from `text_to_video.py` / `text_to_image.py`.
@@ -193,9 +235,17 @@ DIFFUSION_ATTENTION_BACKEND=FLASHINFER_ATTN python examples/offline_inference/te
--model Lightricks/LTX-2 ...
```
### FA4 not yet integrated
### FlashAttention-4 on Blackwell
FlashAttention-4 (released March 2026) targets Blackwell natively and reportedly beats cuDNN by ~20% on B200. As of this writing the `flash-attn-4 4.0.0b10` wheel crashes with `AttributeError: 'NoneType' object has no attribute '_trait'` during JIT on sm_120. Not yet wired into vLLM-Omni; revisit when stable lands.
Install the optional CUDA 13 extra to use the CuTe-based FA4 path:
```bash
pip install 'vllm-omni[fa4]'
```
`FLASH_ATTN` prefers `flash_attn.cute` on Blackwell and falls back to FA3/FA2
when it is unavailable. Version `4.0.0b18` is required; earlier beta
wheels had known JIT failures on Blackwell.
## Choosing a Backend Manually
@@ -353,64 +403,3 @@ DIFFUSION_ATTENTION_BACKEND=SAGE_ATTN_3 python examples/offline_inference/text_t
--tensor-parallel-size 2 \
--output outputs/hv15_sage3.mp4
```
### Mixed backends across roles
Use `FLASH_ATTN` for self-attention and `TORCH_SDPA` for cross-attention:
```bash
python examples/offline_inference/text_to_video/text_to_video.py \
--model Wan-AI/Wan2.2-TI2V-5B-Diffusers \
--prompt "A dog running across a field of golden wheat." \
--diffusion-attention-config.per_role.self.backend FLASH_ATTN \
--diffusion-attention-config.per_role.cross.backend TORCH_SDPA \
--tensor-parallel-size 2 \
--output outputs/wan22_mixed.mp4
```
### Compare against FlashAttention
Unset the backend override, or explicitly use `FLASH_ATTN`:
```bash
python examples/offline_inference/text_to_video/text_to_video.py \
--model Wan-AI/Wan2.2-TI2V-5B-Diffusers \
--prompt "A dog running across a field of golden wheat." \
--height 704 --width 1280 --num-frames 49 \
--num-inference-steps 30 --seed 42 --guidance-scale 5.0 \
--tensor-parallel-size 2 \
--output outputs/wan22_fa3.mp4
```
## Validation Guidance
Don't assume a faster attention backend is numerically interchangeable with `TORCH_SDPA`.
Always compare:
- End-to-end runtime
- Diffusion-stage runtime (`add_req_and_wait` line in DiffusionEngine.step breakdown)
- Output quality against a known-good baseline (CLIP similarity, frame-level diff, or visual review)
At minimum, keep the same:
- model
- prompt
- seed
- resolution
- frame count / step count
- parallel config (TP / CFG-parallel / Ulysses degrees)
## Reproducing the Benchmark Table
The end-to-end numbers above were collected by running `text_to_video.py` /
`text_to_image.py` with the same prompt and seed while varying
`DIFFUSION_ATTENTION_BACKEND`. For a quick kernel-level comparison of the
backends without loading a model:
```bash
python benchmarks/diffusion/bench_attention_backends.py --preset hv15
```
It runs all three BF16 backends on representative DiT attention shapes and
prints a ranking table at the end.
@@ -40,7 +40,7 @@ bash run_server_stage_cli.sh --stage 0
bash run_server_stage_cli.sh --stage 1
```
To use a custom deploy YAML (note: `--stage-configs-path` is deprecated in favor of `--deploy-config`):
To use a custom deploy YAML:
```bash
vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --port 8091 \
@@ -11,8 +11,8 @@ memory, sampling params) live in `vllm_omni/deploy/glm_image.yaml`.
vllm serve zai-org/GLM-Image --omni --port 8091
```
The config system auto-detects the pipeline from the model's `model_index.json` — no
manual `--stage-configs-path` or `--deploy-config` needed.
The config system auto-detects the pipeline from the model's `model_index.json`, so
no manual `--deploy-config` is needed.
By default, stage 0 (AR) runs on GPU 0 and stage 1 (Diffusion) on GPU 1. To colocate
both stages on a single GPU, override per stage:
@@ -15,9 +15,9 @@ Please refer to [README.md](https://github.com/vllm-project/vllm-omni/tree/main/
vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091
```
If you have custom stage configs file, launch the server with command below
To use a custom deploy config, launch the server with the command below:
```bash
vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 --stage-configs-path /path/to/stage_configs_file
vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 --deploy-config /path/to/deploy_config.yaml
```
### Send Multi-modal Request
@@ -182,7 +182,7 @@ The script supports the following arguments:
- `--model`: Model name/path (default: Qwen/Qwen2.5-Omni-7B)
- `--server-port`: Port for vLLM server (default: 8091)
- `--gradio-port`: Port for Gradio demo (default: 7861)
- `--stage-configs-path`: Path to custom stage configs YAML file (optional)
- `--deploy-config`: Path to a custom deploy config YAML file (optional)
- `--server-host`: Host for vLLM server (default: 0.0.0.0)
- `--gradio-ip`: IP for Gradio demo (default: 127.0.0.1)
- `--share`: Share Gradio demo publicly (creates a public link)
@@ -195,9 +195,9 @@ The script supports the following arguments:
vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091
```
If you have custom stage configs file:
To use a custom deploy config:
```bash
vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 --stage-configs-path /path/to/stage_configs_file
vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 --deploy-config /path/to/deploy_config.yaml
```
**Step 2: Run the Gradio demo**
@@ -69,19 +69,19 @@ The behaviors and conclusions above are summarized from current fault injection
### Example: `SIGTERM` fault injection log (Qwen3-Omni)
The following full log is from a real run where a `SIGTERM` signal was sent to the serve root process.
The following log is based on a real run where a `SIGTERM` signal was sent to the serve root process. Its command line has been normalized to the current `--deploy-config` option.
```text
Launching OmniServer with: /workspace/.venv/bin/python3 -m vllm_omni.entrypoints.cli.main serve Qwen/Qwen3-Omni-30B-A3B-Instruct --host 127.0.0.1 --port 60675 --omni --async-chunk --stage-init-timeout 600 --init-timeout 900 --log-stats --stage-configs-path vllm-omni/vllm_omni/deploy/qwen3_omni_moe.yaml
Launching OmniServer with: /workspace/.venv/bin/python3 -m vllm_omni.entrypoints.cli.main serve Qwen/Qwen3-Omni-30B-A3B-Instruct --host 127.0.0.1 --port 60675 --omni --async-chunk --stage-init-timeout 600 --init-timeout 900 --log-stats --deploy-config vllm-omni/vllm_omni/deploy/qwen3_omni_moe.yaml
Server ready on 127.0.0.1:60675 (OmniServer startup took 206.023s)
OmniServer started successfully
[reliability][process-kill] current_server_proc pid=556855 name=python3 cmdline=/workspace/.venv/bin/python3 -m vllm_omni.entrypoints.cli.main serve Qwen/Qwen3-Omni-30B-A3B-Instruct --host 127.0.0.1 --port 60675 --omni --async-chunk --stage-init-timeout 600 --init-timeout 900 --log-stats --stage-configs-path vllm-omni/vllm_omni/deploy/qwen3_omni_moe.yaml
[reliability][process-kill] current_server_proc pid=556855 name=python3 cmdline=/workspace/.venv/bin/python3 -m vllm_omni.entrypoints.cli.main serve Qwen/Qwen3-Omni-30B-A3B-Instruct --host 127.0.0.1 --port 60675 --omni --async-chunk --stage-init-timeout 600 --init-timeout 900 --log-stats --deploy-config vllm-omni/vllm_omni/deploy/qwen3_omni_moe.yaml
[reliability][process-kill] current_server_proc pid=557118 name=python3 cmdline=/workspace/.venv/bin/python3 -c from multiprocessing.resource_tracker import main;main(57)
[reliability][process-kill] current_server_proc pid=557119 name=VLLM::StageEngineCoreProc_noid_replica0_DP0 cmdline=VLLM::StageEngineCoreProc_noid_replica0_DP0
[reliability][process-kill] current_server_proc pid=557122 name=VLLM::StageEngineCoreProc_noid_replica0_DP0 cmdline=VLLM::StageEngineCoreProc_noid_replica0_DP0
[reliability][process-kill] current_server_proc pid=558201 name=VLLM::StageEngineCoreProc_noid_replica0_DP0 cmdline=VLLM::StageEngineCoreProc_noid_replica0_DP0
[reliability][process-kill] root-kill pid=556855 name=python3 signal=SIGTERM cmdline=/workspace/.venv/bin/python3 -m vllm_omni.entrypoints.cli.main serve Qwen/Qwen3-Omni-30B-A3B-Instruct --host 127.0.0.1 --port 60675 --omni --async-chunk --stage-init-timeout 600 --init-timeout 900 --log-stats --stage-configs-path vllm-omni/vllm_omni/deploy/qwen3_omni_moe.yaml
[reliability][process-kill] root-kill pid=556855 name=python3 signal=SIGTERM cmdline=/workspace/.venv/bin/python3 -m vllm_omni.entrypoints.cli.main serve Qwen/Qwen3-Omni-30B-A3B-Instruct --host 127.0.0.1 --port 60675 --omni --async-chunk --stage-init-timeout 600 --init-timeout 900 --log-stats --deploy-config vllm-omni/vllm_omni/deploy/qwen3_omni_moe.yaml
[0;36m(APIServer pid=556855)[0;0m INFO 05-19 12:24:51 [omni_base.py:463] [AsyncOmni] Shutting down
[0;36m(APIServer pid=556855)[0;0m INFO 05-19 12:24:51 [async_omni_engine.py:2397] [AsyncOmniEngine] Shutting down Orchestrator
[0;36m(APIServer pid=556855)[0;0m INFO 05-19 12:24:51 [orchestrator.py:351] [Orchestrator] Received shutdown signal
+1 -1
View File
@@ -53,5 +53,5 @@ vllm-omni serve "$MODEL" \
--host 0.0.0.0 \
--port "$PORT" \
--trust-remote-code \
--stage-configs-path "$DEPLOY_YAML" \
--deploy-config "$DEPLOY_YAML" \
--omni
@@ -28,8 +28,11 @@ If image.command is set, uses that as a full override.
- {{ .Values.omniArgs.numGpus | quote }}
{{- end }}
{{- if .Values.omniArgs.stageConfigsPath }}
- "--stage-configs-path"
- {{ .Values.omniArgs.stageConfigsPath | quote }}
{{- fail "omniArgs.stageConfigsPath has been removed; use omniArgs.deployConfigPath" }}
{{- end }}
{{- if .Values.omniArgs.deployConfigPath }}
- "--deploy-config"
- {{ .Values.omniArgs.deployConfigPath | quote }}
{{- end }}
{{- if and .Values.omniArgs.cacheBackend (ne .Values.omniArgs.cacheBackend "none") }}
- "--cache-backend"
@@ -78,6 +78,26 @@ tests:
path: spec.template.spec.containers[0].command
content: "--enable-layerwise-offload"
- it: should include deploy config in command
set:
omniArgs:
deployConfigPath: "/configs/deploy.yaml"
asserts:
- equal:
path: spec.template.spec.containers[0].command[8]
value: "--deploy-config"
- equal:
path: spec.template.spec.containers[0].command[9]
value: "/configs/deploy.yaml"
- it: should reject removed stage config value
set:
omniArgs:
stageConfigsPath: "/configs/legacy.yaml"
asserts:
- failedTemplate:
errorMessage: "omniArgs.stageConfigsPath has been removed; use omniArgs.deployConfigPath"
- it: should use full command override when image.command is set
set:
image:
@@ -67,8 +67,8 @@ omniArgs:
enableCpuOffload: false
# -- Number of GPUs for diffusion inference (empty = auto)
numGpus:
# -- Path to stage configs file (empty = auto-detected from model)
stageConfigsPath:
# -- Path to deploy config file (empty = auto-detected from model)
deployConfigPath:
# -- Cache backend: none, tea_cache, or cache_dit
cacheBackend: "none"
# -- Default sampling params as a JSON string
+5 -3
View File
@@ -23,9 +23,11 @@ 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%, 22%, and 22%. The profile admits
at most four sequences per stage and bounds startup video profiling to 32
frames per video. For throughput,`minicpmo_4_5_2gpu.yaml` gives the Thinker
logical device 0 with memory budgets of 55%, 15%, and 15%, 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,
`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.
@@ -3,7 +3,7 @@
#
# Prereq:
# Start a vllm-omni OpenAI server for MiniCPM-o 4.5 on :8099 (see the
# 8x4090 stage config under vllm_omni/model_executor/stage_configs).
# 8x4090 deploy config at vllm_omni/deploy/minicpmo_4_5_8x4090.yaml).
set -e
HERE="$(cd "$(dirname "$0")" && pwd)"
@@ -12,9 +12,9 @@ Please refer to [README.md](../../../README.md)
vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091
```
If you have custom stage configs file, launch the server with command below
To use a custom deploy config, launch the server with the command below:
```bash
vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 --stage-configs-path /path/to/stage_configs_file
vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 --deploy-config /path/to/deploy_config.yaml
```
### Send Multi-modal Request
@@ -173,7 +173,7 @@ The script supports the following arguments:
- `--model`: Model name/path (default: Qwen/Qwen2.5-Omni-7B)
- `--server-port`: Port for vLLM server (default: 8091)
- `--gradio-port`: Port for Gradio demo (default: 7861)
- `--stage-configs-path`: Path to custom stage configs YAML file (optional)
- `--deploy-config`: Path to a custom deploy config YAML file (optional)
- `--server-host`: Host for vLLM server (default: 0.0.0.0)
- `--gradio-ip`: IP for Gradio demo (default: 127.0.0.1)
- `--share`: Share Gradio demo publicly (creates a public link)
@@ -186,9 +186,9 @@ The script supports the following arguments:
vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091
```
If you have custom stage configs file:
To use a custom deploy config:
```bash
vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 --stage-configs-path /path/to/stage_configs_file
vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 --deploy-config /path/to/deploy_config.yaml
```
**Step 2: Run the Gradio demo**
@@ -13,7 +13,7 @@ set -e
MODEL="Qwen/Qwen2.5-Omni-7B"
SERVER_PORT=8091
GRADIO_PORT=7861
STAGE_CONFIGS_PATH=""
DEPLOY_CONFIG_PATH=""
SERVER_HOST="0.0.0.0"
GRADIO_IP="127.0.0.1"
GRADIO_SHARE=false
@@ -33,8 +33,8 @@ while [[ $# -gt 0 ]]; do
GRADIO_PORT="$2"
shift 2
;;
--stage-configs-path)
STAGE_CONFIGS_PATH="$2"
--deploy-config)
DEPLOY_CONFIG_PATH="$2"
shift 2
;;
--server-host)
@@ -56,7 +56,7 @@ while [[ $# -gt 0 ]]; do
echo " --model MODEL Model name/path (default: Qwen/Qwen2.5-Omni-7B)"
echo " --server-port PORT Port for vLLM server (default: 8091)"
echo " --gradio-port PORT Port for Gradio demo (default: 7861)"
echo " --stage-configs-path PATH Path to custom stage configs YAML file (optional)"
echo " --deploy-config PATH Path to custom deploy config YAML file (optional)"
echo " --server-host HOST Host for vLLM server (default: 0.0.0.0)"
echo " --gradio-ip IP IP for Gradio demo (default: 127.0.0.1)"
echo " --share Share Gradio demo publicly"
@@ -87,8 +87,8 @@ echo "=========================================="
# Build vLLM server command
SERVER_CMD=("vllm" "serve" "$MODEL" "--omni" "--port" "$SERVER_PORT" "--host" "$SERVER_HOST")
if [ -n "$STAGE_CONFIGS_PATH" ]; then
SERVER_CMD+=("--stage-configs-path" "$STAGE_CONFIGS_PATH")
if [ -n "$DEPLOY_CONFIG_PATH" ]; then
SERVER_CMD+=("--deploy-config" "$DEPLOY_CONFIG_PATH")
fi
# Function to cleanup on exit
@@ -336,7 +336,7 @@ vllm serve OpenMOSS-Team/MOSS-TTS-Nano --omni --port 8091
# or:
./moss_tts_nano/run_server.sh
```
The deploy config at `vllm_omni/deploy/moss_tts_nano.yaml` auto-loads; no `--stage-configs-path`, `--trust-remote-code`, or `--enforce-eager` flags are needed.
The deploy config at `vllm_omni/deploy/moss_tts_nano.yaml` auto-loads; no `--deploy-config`, `--trust-remote-code`, or `--enforce-eager` flags are needed.
### Sending requests
```bash
+7
View File
@@ -75,6 +75,7 @@ dev = [
]
demo = [
"gradio>=6.7.0",
"opencv-python>=4.12.0.88", # cli/stream_client frame reading
"requests>=2.28.0", # cli/stream_client HTTP
"cn2an>=0.5.22", # Chinese number normalization for TTS (e.g. "1次" -> "一次")
@@ -95,6 +96,12 @@ quack = [
"quack-kernels>=0.3.11",
]
# Optional Blackwell-native FlashAttention-4 backend. Keep this opt-in because
# the CuTe/CUTLASS dependency is CUDA 13 and Blackwell specific.
fa4 = [
"flash-attn-4[cu13]==4.0.0b18",
]
docs = [
"mkdocs>=1.5.0",
"mkdocs-api-autonav",
+162
View File
@@ -0,0 +1,162 @@
# MiniMax H3 on Ascend NPU
> Joint video and audio generation with text, first-frame, image/audio, or
> multi-video conditions — Ascend NPU deployment guide
## Summary
- Vendor: MiniMaxAI
- Model: [`MiniMaxAI/MiniMax-H3`](https://huggingface.co/MiniMaxAI/MiniMax-H3)
- Tasks: T2VA, FL2VA, and Ref2VA
- Mode: OpenAI-compatible `/v1/videos` HTTP serving
- Hardware: Atlas 800I A3
- Maintainer: Community
This recipe adapts [MiniMax-H3.md](MiniMax-H3.md) for Ascend NPU
environments. Differences from the GPU path:
- Runs on a CPU-only (aarch64) PyTorch build with `torch_npu`; no CUDA
runtime is present.
- Audio loading does **not** require TorchCodec (whose aarch64 wheels are
built against CUDA torch and fail to load on CPU-only builds). vLLM-Omni
automatically falls back to soundfile / ffmpeg for wav/mp3/m4a/mp4 audio
inputs.
## Prerequisites
### Checkpoint
Same as the GPU recipe — Hugging Face access approval is required:
```bash
hf auth login
export MODEL_ROOT=/path/to/MiniMax-H3
hf download MiniMaxAI/MiniMax-H3 --local-dir "${MODEL_ROOT}"
```
### Environment
- Ascend driver & firmware: 25.5.1
- CANN toolkit: 9.0.1
- Python: 3.12
- PyTorch: 2.10.0+cpu
- torch_npu: 2.10.0.post2
- Install vLLM-Omni from a checkout with MiniMax H3 support:
```bash
uv venv
source .venv/bin/activate
uv pip install -e .
```
Install the **mindie-sd** operator library to enable Ascend-optimized fused
operators (`adalayernorm`, etc.):
```bash
git clone https://gitcode.com/Ascend/MindIE-SD.git && cd MindIE-SD
# Comment out the tik_ops build step (not needed for this use case)
sed -i 's|^\(\s*\)source ${current_script_dir}/build_tik_ops.sh|\1# source ${current_script_dir}/build_tik_ops.sh|' build/build_ops.sh
python setup.py bdist_wheel
cd dist
pip install mindiesd-*.whl
```
- `ffmpeg` and `ffprobe` must be available on `PATH`. They are used for
reference-video preparation and MP4 output.
- Reference-video decoding uses `decord` when available and falls back to
PyAV otherwise.
- Audio inputs do not require TorchCodec: wav/mp3/m4a/mp4 files are loaded
at native sample rate through the soundfile / ffmpeg fallback.
## Start a server
One server loads one checkpoint partition. Set `MODEL` to `FL2VA` for T2VA
and FL2VA requests, or to `Ref2VA` for Ref2VA requests.
### Multi-NPU: 768P validated configuration
Validated on eight NPUs of an Atlas 800I A3 server with Ulysses sequence
parallelism degree 8, text-encoder tensor parallelism degree 8, native tiled
VAE patch parallelism degree 8, and layerwise offload:
```bash
export ASCEND_RT_VISIBLE_DEVICES=0,1,2,3,4,5,6,7
export PORT=9098
export MODEL="${MODEL_ROOT}/FL2VA"
export VLLM_WORKER_MULTIPROC_METHOD=spawn
export VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800
export PYTHONDONTWRITEBYTECODE=1
export HF_HUB_OFFLINE=1
export TRANSFORMERS_OFFLINE=1
vllm serve "${MODEL}" \
--omni \
--host 0.0.0.0 \
--port "${PORT}" \
--trust-remote-code \
--num-gpus 8 \
--usp 8 \
--ring 1 \
--text-encoder-tp-size 8 \
--enable-layerwise-offload \
--vae-parallel-mode tile \
--vae-use-tiling \
--vae-patch-parallel-size 8 \
--diffusion-attention-backend FLASH_ATTN
```
Do not add `--enforce-eager`. The first request includes regional
compilation; warm the server once before measuring steady-state latency.
H3 is CFG-distilled, so `--cfg-parallel-size` must remain 1.
To serve Ref2VA, stop the FL2VA server and restart with:
```bash
export MODEL="${MODEL_ROOT}/Ref2VA"
```
## HTTP API examples
The request format is identical to the GPU recipe; see
[MiniMax-H3.md § HTTP API examples](MiniMax-H3.md#http-api-examples).
Use the validated 768P shapes (e.g. `width=1344 height=768`) on NPU.
## Key parameters
Same as the GPU recipe; see
[MiniMax-H3.md § Key parameters](MiniMax-H3.md#key-parameters).
The validated resolution on NPU is 768P (e.g. 1344x768).
## Validated NPU evidence
Measured on an Atlas 800I A3 server (8x NPU) with CANN 9.0.1,
PyTorch 2.10.0+cpu, and torch_npu 2.10.0.post2, using the multi-NPU
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 |
These measurements describe the validated shapes rather than a general
throughput guarantee.
## Known limitations
- Each server process loads only one checkpoint partition.
- H3 currently executes one generation request per diffusion batch.
- The first regional-compile request is a warmup and should not be included
in steady-state performance measurements.
- Image+audio Ref2VA accepts exactly one image and one audio reference.
- Video Ref2VA accepts one or more video files, but not an additional
standalone audio reference.
- VAE patch parallelism requires size 1 or the full DiT group size and
supports the H3 native `tile` mode only.
## Additional resources
- [MiniMax-H3.md](MiniMax-H3.md) — full GPU guide
- [Supported models](../../docs/models/supported_models.md)
- [Video API](../../docs/serving/videos_api.md)
+329
View File
@@ -0,0 +1,329 @@
# MiniMax H3
> Joint video and audio generation with text, first-frame, image/audio, or
> multi-video conditions
## Summary
- Vendor: MiniMaxAI
- Model: [`MiniMaxAI/MiniMax-H3`](https://huggingface.co/MiniMaxAI/MiniMax-H3)
- Tasks: T2VA, FL2VA, and Ref2VA
- Mode: OpenAI-compatible `/v1/videos` HTTP serving
- Maintainer: Community
MiniMax H3 is a CFG-distilled joint video/audio diffusion transformer. Its
checkpoint has two independently served partitions:
- `FL2VA`: text-to-video+audio (`t2va`) and first-frame-to-video+audio
(`fl2va`)
- `Ref2VA`: image+audio or one-or-more-video reference-to-video+audio
(`ref2va`)
The generated MP4 contains H.264 video and synchronized stereo audio.
## Prerequisites
The checkpoint requires Hugging Face access approval. Download it and point
`MODEL_ROOT` at the directory containing the `FL2VA` and `Ref2VA`
subdirectories:
```bash
hf auth login
export MODEL_ROOT=/path/to/MiniMax-H3
hf download MiniMaxAI/MiniMax-H3 --local-dir "${MODEL_ROOT}"
```
Install vLLM-Omni from the checkout containing MiniMax H3 support. On
Blackwell, install the optional FlashAttention-4 dependency:
```bash
uv venv
source .venv/bin/activate
uv pip install -e '.[fa4]'
```
`ffmpeg` and `ffprobe` must be available on `PATH`. They are used for
reference-video preparation and MP4 output.
## Start a server
One server loads one checkpoint partition. Set `MODEL` to `FL2VA` for T2VA
and FL2VA requests, or to `Ref2VA` for either Ref2VA request.
### Single GPU: accuracy and memory first
The single-GPU configuration uses model-level CPU offload.
This matches the accuracy-qualified reference path and prevents the Qwen3-VL
encoder and DiT from being resident on the GPU at the same time.
```bash
export MODEL="${MODEL_ROOT}/FL2VA"
export PORT=8091
CUDA_VISIBLE_DEVICES=0 \
VLLM_WORKER_MULTIPROC_METHOD=spawn \
VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800 \
vllm serve "${MODEL}" \
--omni \
--host 0.0.0.0 \
--port "${PORT}" \
--trust-remote-code \
--num-gpus 1 \
--enable-cpu-offload \
--diffusion-attention-backend FLASH_ATTN
```
Use a GPU with enough memory for the active H3 component and enough system RAM
for the offloaded components. CPU offload reduces GPU memory pressure but adds
PCIe/NVLink transfer latency.
### Four GPUs: measured best-practice throughput
The best validated four-GPU configuration on four NVIDIA B300 GPUs is:
- no CPU or layerwise offload;
- Ulysses sequence parallelism degree 4;
- native tiled VAE patch parallelism degree 4;
- regional `torch.compile` for the repeated DiT blocks;
- FlashAttention, with Ring and TP left at 1.
```bash
export MODEL="${MODEL_ROOT}/FL2VA"
export PORT=8091
CUDA_VISIBLE_DEVICES=0,1,2,3 \
FLASHINFER_DISABLE_VERSION_CHECK=1 \
VLLM_WORKER_MULTIPROC_METHOD=spawn \
VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800 \
vllm serve "${MODEL}" \
--omni \
--host 0.0.0.0 \
--port "${PORT}" \
--trust-remote-code \
--num-gpus 4 \
--usp 4 \
--ring 1 \
--vae-patch-parallel-size 4 \
--vae-parallel-mode tile \
--vae-use-tiling \
--diffusion-attention-backend FLASH_ATTN
```
Do not add `--enforce-eager` to this performance configuration. The first
request includes regional compilation; warm the server once before measuring
steady-state latency. H3 is CFG-distilled, so `--cfg-parallel-size` must remain
1. The H3 VAE supports its native `tile` mode, not
`spatial_shard_height` or `spatial_shard_width`.
### Text encoder tensor parallelism
The Qwen3-VL text encoder (~51.5 GB in BF16 for the retained 50 layers) is by
default fully resident on the DiT main rank. On multi-GPU no-offload runs that
rank becomes the peak-memory hotspot. Add `--text-encoder-tp-size N` to shard
the encoder across the first `N` DiT ranks (the encoder is implemented with
vLLM-style tensor-parallel layers and runs with distributed collectives over
its own encoder process group):
```bash
export MODEL="${MODEL_ROOT}/FL2VA"
export PORT=8091
CUDA_VISIBLE_DEVICES=0,1,2,3 \
FLASHINFER_DISABLE_VERSION_CHECK=1 \
VLLM_WORKER_MULTIPROC_METHOD=spawn \
VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800 \
vllm serve "${MODEL}" \
--omni \
--host 0.0.0.0 \
--port "${PORT}" \
--trust-remote-code \
--num-gpus 4 \
--usp 4 \
--ring 1 \
--text-encoder-tp-size 4 \
--vae-patch-parallel-size 4 \
--vae-parallel-mode tile \
--vae-use-tiling \
--diffusion-attention-backend FLASH_ATTN
```
`N` must divide the Qwen3-VL head counts (64 attention heads / 8 KV heads), so
valid values on a 4-GPU server are 1, 2, and 4 (1, 2, 4, 8 on 8 GPUs). The
encoder TP rank set is the first `N` DiT ranks; on 4 GPUs
`--text-encoder-tp-size 4` shards the encoder 4-way, dropping the DiT main
rank's no-offload peak by roughly `(N-1)/N` of the ~51.5 GB encoder while the
other ranks each gain `~51.5/N` GB. The encoder output remains identical to
the reference path within bf16 rounding: every encoder rank all-reduces the
row-parallel projections, so the full `[seq, 5120]` layer-50 hidden state is
replicated on every rank.
To serve Ref2VA, stop the FL2VA server and restart the same one- or four-GPU
command with:
```bash
export MODEL="${MODEL_ROOT}/Ref2VA"
```
## HTTP API examples
The following requests use the synchronous endpoint so the returned body can
be saved directly as an MP4. The asynchronous `POST /v1/videos` endpoint can
also be used when job polling is preferred.
All four tasks use 24 FPS, 50 sigma points, seed values from the validated
workloads, and the checkpoint-reference video/audio flow shifts of 12 and 3.
Decimal durations are passed through `extra_params`.
Set the endpoint once:
```bash
export API_URL="http://127.0.0.1:${PORT}/v1/videos/sync"
```
### 1. T2VA: text to video and audio
Run this request against the `FL2VA` partition:
```bash
curl -sS -X POST "${API_URL}" \
-F 'prompt=In a snowy blue-purple forest, Ori carefully walks past a sleeping giant; footsteps crunch in the snow while the creature breathes and softly snorts.' \
-F 'width=1344' \
-F 'height=768' \
-F 'fps=24' \
-F 'num_inference_steps=50' \
-F 'flow_shift=12' \
-F 'seed=1101' \
-F 'extra_params={"task":"t2va","duration":8.7,"audio_flow_shift":3.0}' \
-o t2va.mp4
```
### 2. FL2VA: first frame to video and audio
Run this request against the `FL2VA` partition. When width and height are
omitted, H3 preserves the first-frame aspect ratio and uses a 768-pixel short
edge.
```bash
export FIRST_FRAME=/path/to/fl2va_first_frame.png
curl -sS -X POST "${API_URL}" \
-F 'prompt=A man stands beside a yellow car at night. The car drives away; he follows it with his eyes and begins singing sadly, with synchronized voice and city ambience.' \
-F 'fps=24' \
-F 'num_inference_steps=50' \
-F 'flow_shift=12' \
-F 'seed=2101' \
-F 'extra_params={"task":"fl2va","duration":8.7,"audio_flow_shift":3.0}' \
-F "input_reference=@${FIRST_FRAME};type=image/png" \
-o fl2va.mp4
```
### 3. Ref2VA: image and audio reference
Run this request against the `Ref2VA` partition. `audio_reference` accepts an
HTTP(S) URL or a `data:` URL. In one terminal, expose the local reference
assets to the serving host:
```bash
python -m http.server 8092 \
--bind 127.0.0.1 \
--directory /path/to/reference_assets
```
Then submit the image and audio request from another terminal:
```bash
export REF_IMAGE=/path/to/reference_assets/ref2va_image.png
export AUDIO_URL=http://127.0.0.1:8092/ref2va_audio.mp3
curl -sS -X POST "${API_URL}" \
-F 'prompt=A white cat with black mustache and eyebrow markings sits on a beige couch, lip-syncing precisely to the complete reference audio before shifting from confusion to deadpan speechlessness.' \
-F 'width=1344' \
-F 'height=768' \
-F 'fps=24' \
-F 'num_inference_steps=50' \
-F 'flow_shift=12' \
-F 'seed=3101' \
-F 'extra_params={"task":"ref2va","duration":15.0,"audio_flow_shift":3.0}' \
-F "input_reference=@${REF_IMAGE};type=image/png" \
-F "audio_reference={\"audio_url\":\"${AUDIO_URL}\"}" \
-o ref2va_image_audio.mp4
```
The requested duration should cover the complete audio. If `duration` is
shorter, the reference soundtrack is truncated to the generated clip.
### 4. Ref2VA: two video references
Run this request against the `Ref2VA` partition. Repeat the
`input_references` multipart field once per source video. H3 consumes the
videos in form order and preserves their original soundtracks during
conditioning.
```bash
export SUBJECT_VIDEO=/path/to/green_screen_subject.mp4
export BACKGROUND_VIDEO=/path/to/fairytale_background.mov
curl -sS -X POST "${API_URL}" \
-F 'prompt=Remove the green screen background of Video 1 and replace it with the fairytale environment from Video 2. Match the background motion to the character actions and relight the character to fit the scene.' \
-F 'width=1344' \
-F 'height=768' \
-F 'fps=24' \
-F 'num_inference_steps=50' \
-F 'flow_shift=12' \
-F 'seed=3101' \
-F 'extra_params={"task":"ref2va","duration":15.0,"audio_flow_shift":3.0}' \
-F "input_references=@${SUBJECT_VIDEO};type=video/mp4" \
-F "input_references=@${BACKGROUND_VIDEO};type=video/quicktime" \
-o ref2va_video_video.mp4
```
The server stores uploaded multi-video references only for the lifetime of the
request and deletes the temporary files after generation. Video Ref2VA uses
the source-video soundtracks and does not accept a separate
`audio_reference`.
## Key parameters
| Parameter | Recommended value | Notes |
|-----------|-------------------|-------|
| `task` | `t2va`, `fl2va`, or `ref2va` | Passed in `extra_params`; must match the served partition |
| `duration` | Workload-specific | Decimal seconds in `extra_params`; converted to H3-compatible frame count |
| `fps` | `24` | H3 output FPS is fixed |
| `num_inference_steps` | `50` | Matches the reference accuracy workloads |
| `flow_shift` | `12` | Video sigma shift |
| `audio_flow_shift` | `3` | Audio sigma shift, passed in `extra_params` |
| `seed` | Task-specific | Use a fixed value for reproducibility |
| `width`, `height` | Multiples of 32 | Aspect ratio must be between 1:4 and 4:1 |
## Validated four-GPU evidence
The four-GPU recommendation was measured on four NVIDIA B300 GPUs with one
excluded warmup followed by three requests.
| Workload | Configuration | Observed result |
|----------|---------------|-----------------|
| FL2VA, 209 frames, 1248x768 | no offload, U4, VPP4 tile, regional compile | 86.964 s mean HTTP client latency |
| Two-video Ref2VA, 362 frames, 1344x768 | no offload, U4, VPP4 tile, regional compile | 784.394 s accounted model-stage mean |
These measurements describe the validated shapes rather than a general
throughput guarantee. Multi-video Ref2VA is much slower because the two
reference videos expand both the Qwen3-VL vision sequence and the packed DiT
attention sequence.
## Known limitations
- Each server process loads only one checkpoint partition.
- H3 currently executes one generation request per diffusion batch.
- The first regional-compile request is a warmup and should not be included in
steady-state performance measurements.
- Image+audio Ref2VA accepts exactly one image and one audio reference.
- Video Ref2VA accepts one or more video files, but not an additional standalone
audio reference.
- VAE patch parallelism requires size 1 or the full DiT group size and supports
the H3 native `tile` mode only.
## Additional resources
- [Supported models](../../docs/models/supported_models.md)
- [Video API](../../docs/serving/videos_api.md)
- [Diffusion parallelism](../../docs/user_guide/diffusion/parallelism/overview.md)
+7 -4
View File
@@ -77,7 +77,9 @@ a fallback because it would duplicate state machines and correctness paths.
The default
[`vllm_omni/deploy/minicpmo_4_5.yaml`](../../vllm_omni/deploy/minicpmo_4_5.yaml)
co-locates Thinker, codec-only Talker, and Code2Wav on GPU 0. Their
`gpu_memory_utilization` budgets are 0.55, 0.22, and 0.22. All stages admit
`gpu_memory_utilization` budgets are 0.55, 0.15, and 0.15. The remaining
device memory is left available for runtime workspaces such as the HiFi-GAN
vocoder's cuDNN kernels. All stages admit
up to four sequences. Startup video profiling is bounded to 32 frames per
video. Use the two-GPU profile for production throughput.
@@ -202,9 +204,10 @@ speech output (TTS)"** checkbox on / off.
#### Notes
- Memory budget: Thinker, Talker, and Code2Wav reserve 0.55, 0.22, and
0.22 of GPU 0. The larger Thinker share protects its multimodal KV cache;
all three model processes still share one CUDA device.
- Memory budget: Thinker, Talker, and Code2Wav reserve 0.55, 0.15, and
0.15 of GPU 0. The larger Thinker share protects its multimodal KV cache,
while the unreserved memory remains available to runtime kernels; all three
model processes still share one CUDA device.
- `--trust-remote-code` is required — the HF repo ships a custom
`MiniCPMO` config / model class.
- Stage 0 Thinker and Stage 1 Talker enable vLLM CUDA Graphs. Stage 2 remains
+1
View File
@@ -45,6 +45,7 @@ recipes/
| [`inclusionAI/Ming-flash-omni-2.0.md`](./inclusionAI/Ming-flash-omni-2.0.md) | Online serving for multimodal chat + standalone TTS | 4x H100 / 1x H100 80GB |
| [`inclusionAI/Ming-omni-tts.md`](./inclusionAI/Ming-omni-tts.md) | Offline + online dense Ming TTS/audio generation | 1x H100 80GB / 1x AMD MI300X (ROCm 7.2) |
| [`IndexTeam/IndexTTS-2.md`](./IndexTeam/IndexTTS-2.md) | Online serving for voice-cloned TTS with optional emotion control | 1x L4 24GB or larger CUDA GPU |
| [`MiniMaxAI/MiniMax-H3.md`](./MiniMaxAI/MiniMax-H3.md) | T2VA, FL2VA, image+audio Ref2VA, and multi-video Ref2VA serving | 1x GPU with CPU offload / 4x B300 |
| [`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 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -0,0 +1,249 @@
"""Tests for Daily-Omni ``--daily-omni-pack-mode`` packing recipes.
The ``minicpm-interleave`` mode is an accuracy-critical protocol: MiniCPM-o only reaches
OpenBMB's reported Daily-Omni score when frames and 1s audio segments arrive strictly
interleaved, with ``max_slice_nums=1`` / ``use_image_id=False`` and an empty MiniCPM
system message (not the Qwen system prompt). These tests pin that contract so a refactor
cannot silently regress the score.
vllm stubs are installed by tests/benchmarks/conftest.py before collection.
"""
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
import numpy as np
import pytest
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
# Load the data module directly (bypasses vllm_omni.__init__ heavy imports).
_REPO_ROOT = Path(__file__).resolve().parents[2]
_MODULE_PATH = _REPO_ROOT / "vllm_omni" / "benchmarks" / "data_modules" / "daily_omni_dataset.py"
_MODULE_NAME = "vllm_omni.benchmarks.data_modules.daily_omni_dataset"
if _MODULE_NAME not in sys.modules:
_spec = importlib.util.spec_from_file_location(_MODULE_NAME, _MODULE_PATH)
_mod = importlib.util.module_from_spec(_spec)
sys.modules[_MODULE_NAME] = _mod
_spec.loader.exec_module(_mod)
from vllm_omni.benchmarks.data_modules.daily_omni_dataset import ( # noqa: E402
DailyOmniDataset,
_uniform_sample_indices,
)
_VIDEO_ID = "vid001"
_NUM_FRAMES = 5
@pytest.fixture
def video_dir(tmp_path: Path) -> Path:
"""Official Daily-Omni ``Videos/`` layout with placeholder media files."""
per_video = tmp_path / "Videos" / _VIDEO_ID
per_video.mkdir(parents=True)
(per_video / f"{_VIDEO_ID}_video.mp4").write_bytes(b"fake-mp4")
(per_video / f"{_VIDEO_ID}_audio.wav").write_bytes(b"fake-wav")
return tmp_path / "Videos"
@pytest.fixture
def qa_json(tmp_path: Path) -> Path:
path = tmp_path / "qa.json"
path.write_text(
json.dumps(
[
{
"video_id": _VIDEO_ID,
"question": "What happens first?",
"choice": {"A": "a", "B": "b", "C": "c", "D": "d"},
"answer": "A",
"task_type": "AV Event Alignment",
"video_duration": "30s",
}
]
),
encoding="utf-8",
)
return path
def _make_dataset(qa_json: Path, video_dir: Path, **kwargs) -> DailyOmniDataset:
return DailyOmniDataset(
qa_json_path=str(qa_json),
video_dir=str(video_dir),
disable_shuffle=True,
**kwargs,
)
def _stub_extract(monkeypatch, dataset: DailyOmniDataset, counter: list[int]) -> None:
"""Replace decord/ffmpeg extraction with deterministic in-memory frames + segments."""
from PIL import Image
def fake_extract(video_path, *, audio_path, include_audio, **_kw):
counter.append(1)
frames = [Image.new("RGB", (16, 16), color=(i, i, i)) for i in range(_NUM_FRAMES)]
segments = [np.zeros(1600, dtype=np.float32) for _ in range(_NUM_FRAMES)] if include_audio else []
return frames, segments
monkeypatch.setattr(dataset, "_extract_minicpm_frame_audio_segments", fake_extract)
# ---------------------------------------------------------------------------
# _uniform_sample_indices
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(("n", "k"), [(10, 4), (100, 64), (1000, 64), (7, 7), (3, 5), (1, 1)])
def test_uniform_sample_indices_matches_openbmb_midpoint(n: int, k: int) -> None:
"""Pin OpenBMB midpoint-bin sampling (not np.linspace endpoints)."""
got = _uniform_sample_indices(n, k)
if n <= k:
assert got == list(range(n))
else:
gap = n / k
expected = [int(i * gap + gap / 2) for i in range(k)]
assert got == expected
# Endpoints differ from linspace for typical Daily-Omni downsample sizes.
linspace = [int(i) for i in np.linspace(0, n - 1, k, dtype=int)]
if n == 1000 and k == 64:
assert got[0] == 7 and got[-1] == 992
assert linspace[0] == 0 and linspace[-1] == 999
assert got != linspace
def test_uniform_sample_indices_non_positive_k() -> None:
assert _uniform_sample_indices(10, 0) == []
assert _uniform_sample_indices(10, -1) == []
# ---------------------------------------------------------------------------
# minicpm-interleave packing
# ---------------------------------------------------------------------------
def test_minicpm_interleave_alternates_image_and_audio(monkeypatch, qa_json, video_dir) -> None:
ds = _make_dataset(qa_json, video_dir, pack_mode="minicpm-interleave", input_mode="all")
_stub_extract(monkeypatch, ds, [])
parts, extra, position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None)
assert position == "first"
assert [p["type"] for p in parts] == ["image_url", "audio_url"] * _NUM_FRAMES
assert extra["mm_processor_kwargs"] == {
"use_audio_in_video": False,
"max_slice_nums": 1,
"use_image_id": False,
}
def test_minicpm_interleave_visual_mode_emits_frames_only(monkeypatch, qa_json, video_dir) -> None:
ds = _make_dataset(qa_json, video_dir, pack_mode="minicpm-interleave", input_mode="visual")
_stub_extract(monkeypatch, ds, [])
parts, _extra, _position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None)
assert [p["type"] for p in parts] == ["image_url"] * _NUM_FRAMES
def test_minicpm_interleave_writes_file_urls_under_video_dir(monkeypatch, qa_json, video_dir) -> None:
ds = _make_dataset(qa_json, video_dir, pack_mode="minicpm-interleave", input_mode="all")
_stub_extract(monkeypatch, ds, [])
parts, _extra, _position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None)
segment_dir = video_dir / ".minicpm_daily_omni_interleave" / _VIDEO_ID
for part in parts:
url = part[part["type"]]["url"]
assert url.startswith("file://")
assert Path(url[len("file://") :]).parent == segment_dir.resolve()
assert sorted(p.name for p in segment_dir.glob("frame_*.jpg")) == [f"frame_{i:04d}.jpg" for i in range(_NUM_FRAMES)]
def test_minicpm_interleave_inline_mode_emits_data_urls(monkeypatch, qa_json, video_dir) -> None:
ds = _make_dataset(
qa_json,
video_dir,
pack_mode="minicpm-interleave",
input_mode="all",
inline_local_video=True,
)
_stub_extract(monkeypatch, ds, [])
parts, _extra, _position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None)
assert parts[0]["image_url"]["url"].startswith("data:image/jpeg;base64,")
assert parts[1]["audio_url"]["url"].startswith("data:audio/wav;base64,")
assert not (video_dir / ".minicpm_daily_omni_interleave").exists()
def test_minicpm_interleave_caches_file_parts_but_not_inline_payloads(monkeypatch, qa_json, video_dir) -> None:
"""Inline base64 must not be retained: ~700 Daily-Omni videos would pin GBs of RSS."""
file_calls: list[int] = []
ds_file = _make_dataset(qa_json, video_dir, pack_mode="minicpm-interleave", input_mode="all")
_stub_extract(monkeypatch, ds_file, file_calls)
ds_file._compose_daily_omni_multimodal(_VIDEO_ID, None)
ds_file._compose_daily_omni_multimodal(_VIDEO_ID, None)
assert len(file_calls) == 1
assert ds_file._minicpm_interleave_cache
inline_calls: list[int] = []
ds_inline = _make_dataset(
qa_json,
video_dir,
pack_mode="minicpm-interleave",
input_mode="all",
inline_local_video=True,
)
_stub_extract(monkeypatch, ds_inline, inline_calls)
ds_inline._compose_daily_omni_multimodal(_VIDEO_ID, None)
ds_inline._compose_daily_omni_multimodal(_VIDEO_ID, None)
assert len(inline_calls) == 2
assert not ds_inline._minicpm_interleave_cache
def test_minicpm_interleave_missing_local_video_returns_none(qa_json, tmp_path) -> None:
empty = tmp_path / "empty_videos"
empty.mkdir()
ds = _make_dataset(qa_json, empty, pack_mode="minicpm-interleave", input_mode="all")
parts, extra, _position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None)
assert parts is None
assert extra is None
# ---------------------------------------------------------------------------
# System prompt selection
# ---------------------------------------------------------------------------
def test_minicpm_interleave_keeps_empty_system_message(monkeypatch, qa_json, video_dir) -> None:
"""Official MiniCPM omni mode still sends role=system with empty content."""
ds = _make_dataset(qa_json, video_dir, pack_mode="minicpm-interleave", input_mode="all")
_stub_extract(monkeypatch, ds, [])
parts, _extra, _position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None)
messages = ds._build_daily_omni_openai_messages(parts, "q?", {"A": "a"})
assert [m["role"] for m in messages] == ["system", "user"]
assert messages[0]["content"][0]["text"] == ""
def test_qwen_pack_mode_keeps_system_message(qa_json, video_dir) -> None:
ds = _make_dataset(qa_json, video_dir, pack_mode="qwen", input_mode="all")
messages = ds._build_daily_omni_openai_messages([], "q?", {"A": "a"})
assert [m["role"] for m in messages] == ["system", "user"]
assert "Qwen" in messages[0]["content"][0]["text"]
def test_invalid_pack_mode_rejected(qa_json, video_dir) -> None:
with pytest.raises(ValueError, match="pack_mode must be"):
_make_dataset(qa_json, video_dir, pack_mode="bogus")
+26
View File
@@ -2214,6 +2214,32 @@ class TestPlatformOverrides:
assert rocm.stages[0].enforce_eager is None
assert rocm.stages[1].enforce_eager is True
def test_minicpmo_4_5_cuda_caps_talker_kv_cache(self):
pipeline = resolve_pipeline_config("minicpmo_4_5")
assert isinstance(pipeline, PipelineConfig)
deploy_path = Path(get_deploy_config_path("minicpmo_4_5.yaml"))
cuda = _apply_platform_overrides(load_deploy_config(deploy_path), platform="cuda")
cuda_stages = merge_pipeline_deploy(pipeline, cuda)
assert cuda_stages[1].yaml_engine_args["kv_cache_memory_bytes"] == 2 * 1024**3
# The CUDA memory budget must not leak into the existing NPU profile.
npu = _apply_platform_overrides(load_deploy_config(deploy_path), platform="npu")
npu_stages = merge_pipeline_deploy(pipeline, npu)
assert "kv_cache_memory_bytes" not in npu_stages[1].yaml_engine_args
# Multi-GPU replica profiles place Talker on dedicated devices and
# preserve their previous automatic KV-cache sizing.
for filename in (
"minicpmo_4_5_3gpu_stage1_replicas.yaml",
"minicpmo_4_5_4gpu_stage1_replicas.yaml",
):
replica = _apply_platform_overrides(
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
def test_npu_overrides(self):
deploy_path = Path(get_deploy_config_path("qwen3_omni_moe.yaml"))
if not deploy_path.exists():
+1
View File
@@ -440,6 +440,7 @@ def test_sub_config_fields_match_rfc_scopes():
"ulysses_mode",
"cfg_parallel_size",
"vae_patch_parallel_size",
"text_encoder_tp_size",
"vae_parallel_mode",
"use_hsdp",
"mask_sp_padding",
@@ -0,0 +1,74 @@
"""Regression tests for vllm-project/vllm-omni#5349 (P1): normal completion
goes through _free_request(), not finish_requests() (the external
abort/cancel entry point). Without a cleanup_receiver() call there,
_active_streams never releases on ordinary completion, so the bounded-K
window fills with stale entries after K completions.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
# Imports must run in this order: vllm_omni applies patches to vllm.v1.request before
# Request / RequestStatus are bound in this module. Ruff isort would reorder them.
# isort: off
import vllm_omni # noqa: F401 - import for side effects (patch vLLM)
from vllm_omni.core.sched.omni_ar_scheduler import OmniARScheduler
# isort: on
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _make_scheduler(*, chunk_transfer_adapter=None) -> OmniARScheduler:
"""Minimal OmniARScheduler exercising _free_request()'s no-KV-transfer
happy path."""
sched = OmniARScheduler.__new__(OmniARScheduler)
sched._omits_kv_transfer_cache = {}
sched._connector_finished = lambda request: (False, None)
sched.encoder_cache_manager = MagicMock()
sched.finished_req_ids = set()
sched._new_prompt_len_snapshot = {}
sched.finished_req_ids_dict = None
sched._should_transfer_kv_for_request = lambda req_id: False
sched._free_blocks = MagicMock()
sched._free_input_coordinator_request = MagicMock()
sched.chunk_transfer_adapter = chunk_transfer_adapter
return sched
class _FakeFinishedRequest:
"""Not a SimpleNamespace: SimpleNamespace defines __eq__, which makes it
unhashable, and _free_request() puts the request in a set."""
def __init__(self, request_id: str) -> None:
self.request_id = request_id
def is_finished(self) -> bool:
return True
def _make_finished_request(request_id: str = "req-1"):
return _FakeFinishedRequest(request_id)
def test_free_request_releases_chunk_transfer_adapter_receiver_state():
adapter = MagicMock()
sched = _make_scheduler(chunk_transfer_adapter=adapter)
request = _make_finished_request("req-1")
sched._free_request(request)
adapter.cleanup_receiver.assert_called_once_with("req-1")
def test_free_request_is_safe_without_a_chunk_transfer_adapter():
"""Most stages have no chunk transfer adapter configured."""
sched = _make_scheduler(chunk_transfer_adapter=None)
request = _make_finished_request("req-1")
sched._free_request(request) # must not raise
sched._free_input_coordinator_request.assert_called_once_with("req-1")
@@ -0,0 +1,57 @@
from types import SimpleNamespace
import pytest
from vllm import SamplingParams
from vllm.v1.engine import EngineCoreRequest
from vllm_omni.core.sched.omni_ar_scheduler import OmniARScheduler
from vllm_omni.engine.async_engine_utils import apply_omni_final_stage_metadata
from vllm_omni.engine.serialization import deserialize_additional_information
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _make_engine_request() -> EngineCoreRequest:
return EngineCoreRequest(
request_id="req",
prompt_token_ids=[1],
mm_features=None,
sampling_params=SamplingParams(max_tokens=1),
pooling_params=None,
arrival_time=0.0,
lora_request=None,
cache_salt=None,
data_parallel_rank=None,
)
def _request_omits_kv_transfer(*, force_kv_transfer: bool) -> tuple[bool, dict]:
tagged = apply_omni_final_stage_metadata(
_make_engine_request(),
final_stage_id=0,
force_kv_transfer=force_kv_transfer,
)
scheduler = OmniARScheduler.__new__(OmniARScheduler)
scheduler._omits_kv_transfer_cache = {}
request = SimpleNamespace(
request_id="req",
additional_information=tagged.additional_information,
)
result = scheduler._request_omits_kv_transfer_to_next_stage(request)
metadata = deserialize_additional_information(tagged.additional_information)
return result, metadata
def test_stage_zero_request_omits_kv_transfer():
omits_transfer, metadata = _request_omits_kv_transfer(force_kv_transfer=False)
assert omits_transfer
assert "omni_force_kv_transfer" not in metadata
def test_cfg_companion_forces_kv_transfer_without_downstream_payload():
omits_transfer, metadata = _request_omits_kv_transfer(force_kv_transfer=True)
assert not omits_transfer
assert metadata["omni_final_stage_id"] == 0
assert metadata["omni_force_kv_transfer"] is True
@@ -21,6 +21,7 @@ import vllm_omni # noqa: F401 - import for side effects (patch vLLM)
from vllm.sampling_params import SamplingParams
from vllm.v1.core.sched.output import SchedulerOutput
from vllm.v1.engine import EngineCoreEventType
from vllm.v1.metrics.stats import PrefillStats
from vllm.v1.outputs import ModelRunnerOutput
from vllm.v1.request import Request, RequestStatus, StreamingUpdate
from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler
@@ -93,6 +94,17 @@ def _make_update(**kwargs) -> StreamingUpdate:
return StreamingUpdate(**defaults)
def test_generation_scheduler_records_prefill_stats_for_metrics() -> None:
request = _make_request(prompt_token_ids=[1, 2, 3, 4])
request.prefill_stats = PrefillStats()
OmniGenerationScheduler._record_prefill_stats(request)
assert request.prefill_stats.num_prompt_tokens == 4
assert request.prefill_stats.num_computed_tokens == 4
assert request.prefill_stats.num_cached_tokens == 0
def test_resumable_generation_stop_marks_segment_boundary() -> None:
session = _make_request(request_id="req-generation-segment")
session.status = RequestStatus.RUNNING
@@ -75,6 +75,7 @@ def test_ar_free_request_cleans_input_coordinator_on_normal_free() -> None:
coordinator = FakeInputCoordinator()
scheduler = OmniARScheduler.__new__(OmniARScheduler)
scheduler.input_coordinator = coordinator
scheduler.chunk_transfer_adapter = None
scheduler._omits_kv_transfer_cache = {"req-free": True}
scheduler.encoder_cache_manager = SimpleNamespace(free=lambda request: None)
scheduler.finished_req_ids = set()
+67 -22
View File
@@ -1,6 +1,8 @@
import json
import os
import threading
from collections.abc import Callable
from contextlib import AbstractContextManager, ExitStack, contextmanager
from pathlib import Path
from typing import Any
@@ -63,35 +65,78 @@ paired_benchmark_params = create_paired_omni_benchmark_pytest_params(BENCHMARK_C
_omni_server_lock = threading.Lock()
class _SingleActiveContext:
"""Reuse one active context while its configuration key is unchanged."""
def __init__(self) -> None:
self._key: Any = None
self._stack: ExitStack | None = None
self._value: Any = None
def acquire(self, key: Any, factory: Callable[[], AbstractContextManager[Any]]) -> Any:
if self._stack is not None and key == self._key:
return self._value
self.close()
stack = ExitStack()
value = stack.enter_context(factory())
self._key = key
self._stack = stack
self._value = value
return value
def close(self) -> None:
stack = self._stack
self._key = None
self._stack = None
self._value = None
if stack is not None:
stack.close()
@contextmanager
def _start_omni_server(server_param):
test_name, model, stage_config_path, stage_overrides, extra_cli_args, use_omni = server_param
print(f"Starting OmniServer with test: {test_name}, model: {model}")
server_args: list[str] = []
if use_omni:
server_args += ["--stage-init-timeout", "600", "--init-timeout", "900"]
# --deploy-config and --stage-overrides compose at the CLI (see vllm_omni/entrypoints/utils.py):
# deploy-config sets the base; stage-overrides are applied on top. Both can be set.
if stage_config_path:
server_args = ["--deploy-config", stage_config_path] + server_args
if stage_overrides:
server_args = ["--stage-overrides", stage_overrides] + server_args
if extra_cli_args:
server_args = list(extra_cli_args) + server_args
with OmniServer(model, server_args, use_omni=use_omni) as server:
server.test_name = test_name
print("OmniServer started successfully")
yield server
print("OmniServer stopping...")
print("OmniServer stopped")
@pytest.fixture(scope="module")
def omni_server(request):
def omni_server_context():
"""Start vLLM-Omni server as a subprocess with actual model weights.
Uses session scope so the server starts only once for the entire test session.
Reuse it for adjacent benchmark cases with the same server configuration.
Multi-stage initialization can take 10-20+ minutes.
"""
with _omni_server_lock:
test_name, model, stage_config_path, stage_overrides, extra_cli_args, use_omni = request.param
active_context = _SingleActiveContext()
try:
yield active_context
finally:
active_context.close()
print(f"Starting OmniServer with test: {test_name}, model: {model}")
server_args: list[str] = []
if use_omni:
server_args += ["--stage-init-timeout", "600", "--init-timeout", "900"]
# --deploy-config and --stage-overrides compose at the CLI (see vllm_omni/entrypoints/utils.py):
# deploy-config sets the base; stage-overrides are applied on top. Both can be set.
if stage_config_path:
server_args = ["--deploy-config", stage_config_path] + server_args
if stage_overrides:
server_args = ["--stage-overrides", stage_overrides] + server_args
if extra_cli_args:
server_args = list(extra_cli_args) + server_args
with OmniServer(model, server_args, use_omni=use_omni) as server:
server.test_name = test_name
print("OmniServer started successfully")
yield server
print("OmniServer stopping...")
print("OmniServer stopped")
@pytest.fixture
def omni_server(request, omni_server_context):
return omni_server_context.acquire(request.param, lambda: _start_omni_server(request.param))
@pytest.fixture
@@ -5,7 +5,8 @@
{
"hardware_marks": {
"res": {
"cuda": "H100"
"cuda": "H100",
"npu": "A3"
},
"num_cards": 4
}
@@ -27,16 +28,9 @@
"async_chunk": false,
"trust_remote_code": true,
"connectors": {
"rdma_connector": {
"name": "MooncakeTransferEngineConnector",
"extra": {
"host": "auto",
"zmq_port": 50051,
"protocol": "rdma",
"device_name": "",
"memory_pool_size": 17179869184,
"memory_pool_device": "cpu"
}
"shared_mem_connector": {
"name": "SharedMemoryConnector",
"extra": {}
}
},
"stages": [
@@ -66,7 +60,7 @@
"need_send_cache": true
},
"output_connectors": {
"to_stage_1": "rdma_connector"
"to_stage_1": "shared_mem_connector"
},
"default_sampling_params": {
"temperature": 0.0,
@@ -96,7 +90,7 @@
},
"vae_use_tiling": true,
"input_connectors": {
"from_stage_0": "rdma_connector"
"from_stage_0": "shared_mem_connector"
},
"default_sampling_params": {
"num_inference_steps": 50,
@@ -5,7 +5,8 @@
{
"hardware_marks": {
"res": {
"cuda": "H100"
"cuda": "H100",
"npu": "A3"
},
"num_cards": 4
}
@@ -5,7 +5,8 @@
{
"hardware_marks": {
"res": {
"cuda": "H100"
"cuda": "H100",
"npu": "A3"
},
"num_cards": 4
}
@@ -5,7 +5,8 @@
{
"hardware_marks": {
"res": {
"cuda": "H100"
"cuda": "H100",
"npu": "A3"
},
"num_cards": 4
}
+52 -1
View File
@@ -1,6 +1,8 @@
"""Tests for DFX runner metadata field exclusion."""
import json
from contextlib import contextmanager
from types import SimpleNamespace
import pytest
@@ -254,8 +256,9 @@ def test_benchmark_param_id_suffix_from_task_eval_phase():
]
def test_create_paired_omni_benchmark_pytest_params(tmp_path):
def test_paired_omni_benchmark_reuses_server_and_preserves_case_metadata(tmp_path, monkeypatch):
from tests.dfx.conftest import create_paired_omni_benchmark_pytest_params
from tests.dfx.perf.scripts import run_benchmark
configs = [
{
@@ -285,3 +288,51 @@ def test_create_paired_omni_benchmark_pytest_params(tmp_path):
assert bench_row == ("test_tts", 0)
assert any(m.name == "tts" for m in by_id["test_tts-p0"].marks)
assert not any(m.name == "tts" for m in by_id["test_omni-p0"].marks)
omni_p0_server = by_id["test_omni-p0"].values[0]
omni_p1_server = by_id["test_omni-p1"].values[0]
tts_server = by_id["test_tts-p0"].values[0]
assert omni_p0_server == omni_p1_server
assert omni_p0_server != tts_server
events = []
@contextmanager
def fake_start(server_param):
events.append(("start", server_param))
yield object()
events.append(("stop", server_param))
monkeypatch.setattr(run_benchmark, "_start_omni_server", fake_start)
active_context = run_benchmark._SingleActiveContext()
try:
first = run_benchmark.omni_server.__wrapped__(
SimpleNamespace(param=omni_p0_server),
active_context,
)
second = run_benchmark.omni_server.__wrapped__(
SimpleNamespace(param=omni_p1_server),
active_context,
)
assert first is second
assert events == [("start", omni_p0_server)]
third = run_benchmark.omni_server.__wrapped__(
SimpleNamespace(param=tts_server),
active_context,
)
assert third is not first
assert events == [
("start", omni_p0_server),
("stop", omni_p0_server),
("start", tts_server),
]
finally:
active_context.close()
assert events == [
("start", omni_p0_server),
("stop", omni_p0_server),
("start", tts_server),
("stop", tts_server),
]
@@ -241,12 +241,12 @@ def _fault_keywords_match_response(*, status: int, body: bytes) -> bool:
return any(key in text for key in body_fault_hints)
def _stage_config_path_from_omni_server(omni_server: _HasServeArgs) -> str | None:
def _deploy_config_path_from_omni_server(omni_server: _HasServeArgs) -> str | None:
args: list[str] = omni_server.serve_args
for i, arg in enumerate(args):
if arg == "--stage-configs-path" and i + 1 < len(args):
if arg == "--deploy-config" and i + 1 < len(args):
return args[i + 1]
if arg.startswith("--stage-configs-path="):
if arg.startswith("--deploy-config="):
return arg.split("=", 1)[1]
return None
@@ -331,7 +331,7 @@ def test_reliability_fault_gpu_oom_error_contract_consistent_chat_speech(
"""
device_spec = resolve_oom_device_spec(
OOM_INJECTION_CONFIG,
_stage_config_path_from_omni_server(omni_server_function),
_deploy_config_path_from_omni_server(omni_server_function),
)
handle = inject_gpu_oom(
device=device_spec,
@@ -394,7 +394,7 @@ def test_reliability_fault_gpu_oom_error_contract_consistent_chat_speech(
def test_reliability_fault_gpu_oom_chat_large_payload_failure(omni_server_function, openai_client_function) -> None:
device_spec = resolve_oom_device_spec(
OOM_INJECTION_CONFIG,
_stage_config_path_from_omni_server(omni_server_function),
_deploy_config_path_from_omni_server(omni_server_function),
)
handle = inject_gpu_oom(
device=device_spec,
@@ -437,7 +437,7 @@ def test_reliability_fault_gpu_oom_chat_large_payload_failure(omni_server_functi
def test_reliability_fault_gpu_oom_concurrent_pressure_failure(omni_server_function, openai_client_function) -> None:
device_spec = resolve_oom_device_spec(
OOM_INJECTION_CONFIG,
_stage_config_path_from_omni_server(omni_server_function),
_deploy_config_path_from_omni_server(omni_server_function),
)
handle = inject_gpu_oom(
device=device_spec,
@@ -791,7 +791,7 @@ def test_reliability_fault_gpu_oom_state_converges_after_fault_removed(
"""
device_spec = resolve_oom_device_spec(
OOM_RECOVER_INJECTION_CONFIG,
_stage_config_path_from_omni_server(omni_server_function),
_deploy_config_path_from_omni_server(omni_server_function),
)
handle = inject_gpu_oom(
device=device_spec,
@@ -52,6 +52,44 @@ class TestAttentionSpec:
with pytest.raises(ValueError, match="only supported by the TRTLLM_ATTN"):
AttentionSpec(backend="TORCH_SDPA", skip_softmax={"target_sparsity": 0.5})
def test_quant_serialized_with_defaults_and_overrides(self):
assert AttentionSpec(backend="TRTLLM_ATTN", quant={"dtype_qk": "fp8_e4m3"}).backend_kwargs()["quant"] == {
"dtype_qk": "fp8_e4m3",
"q_block_size": 1,
"k_block_size": 16,
}
assert AttentionSpec(
backend="TRTLLM_ATTN", quant={"dtype_qk": "int8", "q_block_size": 4, "k_block_size": 16}
).backend_kwargs()["quant"] == {"dtype_qk": "int8", "q_block_size": 4, "k_block_size": 16}
def test_quant_superset_fields_passed_through(self):
# FLASHINFER_ATTN-style config (dtype_vo / flashinfer_backend) round-trips on the shared spec.
bk = AttentionSpec(
backend="FLASHINFER_ATTN",
quant={"dtype_qk": "bfloat16", "dtype_vo": "fp8_e4m3", "flashinfer_backend": "trtllm-gen"},
).backend_kwargs()["quant"]
assert bk["dtype_qk"] == "bfloat16"
assert bk["dtype_vo"] == "fp8_e4m3"
assert bk["flashinfer_backend"] == "trtllm-gen"
def test_quant_and_skip_softmax_coexist(self):
bk = AttentionSpec(
backend="TRTLLM_ATTN", quant={"dtype_qk": "int8"}, skip_softmax={"target_sparsity": 0.5}
).backend_kwargs()
assert bk["target_sparsity"] == 0.5 and bk["quant"]["dtype_qk"] == "int8"
@pytest.mark.parametrize(
"spec, match",
[
({"backend": "TORCH_SDPA", "quant": {"dtype_qk": "int8"}}, "only supported by the TRTLLM_ATTN"),
({"backend": "TRTLLM_ATTN", "quant": {"dtype_qk": "int4"}}, "quant.dtype_qk"),
({"backend": "TRTLLM_ATTN", "quant": {"dtype_qk": "int8", "k_block_size": 8}}, "quant.k_block_size"),
],
)
def test_quant_validation_rejects(self, spec, match):
with pytest.raises(ValueError, match=match):
AttentionSpec(**spec)
class TestAttentionConfig:
def test_empty_config(self):
+42 -1
View File
@@ -20,7 +20,6 @@ from vllm_omni.platforms import current_omni_platform
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
is_gpu = current_omni_platform.is_cuda_alike() or current_omni_platform.is_xpu()
HAS_FLASH_ATTN = fa.HAS_FLASH_ATTN
flash_attn_func = fa.flash_attn_func # noqa: N813
@@ -330,6 +329,48 @@ def test_piecewise_flash_attn_uses_varlen_fallback(monkeypatch):
assert calls[0]["softmax_scale"] == 0.5
def test_packed_varlen_metadata_bypasses_mask_unpadding(monkeypatch):
calls = []
def fake_varlen_func(q, k, v, **kwargs):
calls.append((q.shape, k.shape, v.shape, kwargs))
return q
monkeypatch.setattr(fa, "HAS_FLASH_ATTN", True)
monkeypatch.setattr(fa, "flash_attn_varlen_func", fake_varlen_func)
impl = FlashAttentionImpl(num_heads=2, head_size=4, softmax_scale=0.5, causal=False)
query = torch.randn(1, 8, 2, 4)
cu_seqlens = torch.tensor([0, 6, 8], dtype=torch.int32)
metadata = AttentionMetadata(
attn_mask=torch.tensor([[True] * 6 + [False] * 2]),
extra={
"cu_seqlens_q": cu_seqlens,
"cu_seqlens_k": cu_seqlens,
"max_seqlen_q": 6,
"max_seqlen_k": 6,
},
)
output = impl.forward_cuda(query, query, query, metadata)
assert torch.equal(output, query)
assert len(calls) == 1
assert calls[0][0] == torch.Size([8, 2, 4])
assert calls[0][3]["cu_seqlens_q"] is cu_seqlens
assert calls[0][3]["max_seqlen_q"] == 6
def test_packed_varlen_metadata_must_be_complete(monkeypatch):
monkeypatch.setattr(fa, "HAS_FLASH_ATTN", True)
impl = FlashAttentionImpl(num_heads=2, head_size=4, softmax_scale=0.5, causal=False)
query = torch.randn(1, 8, 2, 4)
metadata = AttentionMetadata(extra={"cu_seqlens_q": torch.tensor([0, 8], dtype=torch.int32)})
with pytest.raises(ValueError, match="Incomplete packed FlashAttention metadata"):
impl.forward_cuda(query, query, query, metadata)
if __name__ == "__main__":
print("Running FlashAttention Padding Tests...")
print("=" * 60)
@@ -0,0 +1,74 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from types import SimpleNamespace
import pytest
import torch
from vllm_omni.diffusion.attention.backends.ring import ring_kernels
from vllm_omni.diffusion.attention.backends.ring.ring_selector import AttnType
from vllm_omni.diffusion.attention.parallel import ring as ring_parallel
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
def test_fa4_wrapper_returns_lse_and_normalizes_unbounded_window(monkeypatch):
expected_out = object()
expected_lse = object()
captured = {}
def fake_fa4(q, k, v, **kwargs):
captured.update(q=q, k=k, v=v, **kwargs)
return expected_out, expected_lse
monkeypatch.setattr(ring_kernels, "HAS_FA4", True)
monkeypatch.setattr(ring_kernels, "fa4_attn_func", fake_fa4)
out, lse = ring_kernels.flash_attn4_func_forward(
"q",
"k",
"v",
softmax_scale=0.125,
causal=True,
window_size=(-1, -1),
)
assert (out, lse) == (expected_out, expected_lse)
assert captured == {
"q": "q",
"k": "k",
"v": "v",
"softmax_scale": 0.125,
"causal": True,
"window_size": (None, None),
"softcap": 0.0,
"return_lse": True,
}
def test_ring_prefers_fa4_on_blackwell(monkeypatch):
captured = {}
def fake_ring_flash_attn_func(*args, **kwargs):
captured.update(kwargs)
return "output"
monkeypatch.setattr(ring_parallel, "HAS_FA4", True)
monkeypatch.setattr(ring_parallel, "HAS_FA3", True)
monkeypatch.setattr(torch.cuda, "get_device_capability", lambda _device: (10, 3))
from vllm_omni.diffusion.attention.backends import ring_flash_attn
monkeypatch.setattr(ring_flash_attn, "ring_flash_attn_func", fake_ring_flash_attn_func)
query = SimpleNamespace(
dtype=torch.bfloat16,
shape=(1, 16, 4, 128),
is_cuda=True,
device=torch.device("cuda:0"),
)
strategy = ring_parallel.RingParallelAttention(SimpleNamespace(ring_group=object()))
assert strategy.run_attention(query, "key", "value", None) == "output"
assert captured["attn_type"] is AttnType.FA4
@@ -47,6 +47,44 @@ def test_skip_config_pure_resolution():
assert cfg.resolve_factor(4096, timestep=None) == pytest.approx(0.01 * 4096)
def test_quant_config_from_backend_kwargs():
from vllm_omni.diffusion.attention.backends.trtllm_attn import QuantConfig
assert not QuantConfig.from_backend_kwargs(None).enabled
assert not QuantConfig.from_backend_kwargs({}).enabled
assert QuantConfig.from_backend_kwargs({"quant": {"dtype_qk": "fp8_e4m3"}}) == QuantConfig("fp8_e4m3", 1, 16)
qc = QuantConfig.from_backend_kwargs({"quant": {"dtype_qk": "int8", "q_block_size": 4, "k_block_size": 16}})
assert qc.enabled and (qc.dtype_qk, qc.q_block_size, qc.k_block_size) == ("int8", 4, 16)
def test_quant_rejects_non_sage_dtype():
with pytest.raises(RuntimeError, match="supports dtype_qk"):
_impl(quant={"dtype_qk": "bfloat16"})
def test_quant_quantize_requires_flashinfer_routine(monkeypatch):
import vllm_omni.diffusion.attention.backends.trtllm_attn as mod
monkeypatch.setattr(mod, "_sage_kernel_available", lambda: True)
monkeypatch.setattr(mod, "_sage_quantize_fn", lambda: None)
with pytest.raises(RuntimeError, match="trtllm_sage_attention_quantize"):
_impl(quant={"dtype_qk": "int8"})
def test_quant_quantize_calls_routine_and_shapes_sfs():
from vllm_omni.diffusion.attention.backends.trtllm_attn import QuantConfig
captured = {}
def fake_quantize(q, k, v, q_block_size, k_block_size, qk_quant_dtype):
captured.update(q_block_size=q_block_size, k_block_size=k_block_size, qk_quant_dtype=qk_quant_dtype)
return "qq", "kq", "vq", "qsfs", "ksfs", "vsfs"
q_q, k_q, v_q, sfs, blk = QuantConfig(dtype_qk="int8").quantize(object(), object(), object(), fake_quantize)
assert (q_q, k_q, v_q, sfs, blk) == ("qq", "kq", "vq", ("qsfs", "ksfs", None, "vsfs"), (1, 16, 0, 1))
assert captured == {"q_block_size": 1, "k_block_size": 16, "qk_quant_dtype": torch.int8}
def test_skip_factor_none_without_curve():
assert _impl(target_sparsity=0.5)._resolve_skip_factor(4096) is None
@@ -257,6 +295,32 @@ def test_bf16_dense_matches_sdpa():
assert rel < 0.01, f"BF16 dense rel err {rel:.4f} too high"
requires_sage = pytest.mark.skipif(
not (_has_trtllm_attn() and tg._sage_quantize_fn() is not None),
reason="requires Blackwell SM100+ GPU with flashinfer >= 0.6.16rc1 (trtllm_sage_attention_quantize)",
)
@requires_sage
@pytest.mark.parametrize("dtype_qk", ["fp8_e4m3", "int8"])
def test_sage_quant_matches_sdpa(dtype_qk):
torch.manual_seed(0)
B, S, H, D = 2, 256, 8, 128
scale = 1.0 / math.sqrt(D)
q, k, v = (torch.randn(B, S, H, D, device="cuda", dtype=torch.bfloat16) for _ in range(3))
impl = _impl(quant={"dtype_qk": dtype_qk, "q_block_size": 1, "k_block_size": 16})
try:
out = impl.forward_cuda(q, k, v, None)
except RuntimeError as e: # pragma: no cover - arch-dependent (int8 is SM100-only)
if "Missing TRTLLM-GEN kernel" in str(e):
pytest.skip(f"no trtllm-gen SAGE kernel for dtype_qk={dtype_qk} on this GPU arch")
raise
assert out.shape == (B, S, H, D) and torch.isfinite(out).all()
ref = _sdpa_ref(q, k, v, scale)
rel = (out.float() - ref).abs().mean() / ref.abs().mean()
assert rel < 0.1, f"SAGE({dtype_qk}) rel err {rel:.4f} too high"
@requires_trtllm_attn
def test_skip_tiny_threshold_matches_dense():
torch.manual_seed(0)
+122
View File
@@ -0,0 +1,122 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Unit tests for the MagCache hook forward numerics.
The cached MagCache residual is the residual of the whole block stack
(tail output - head input). On a skipped step the head hook applies it
once; every other hooked block must pass its inputs through unchanged,
matching the reference implementation in diffusers.hooks.mag_cache.
"""
import pytest
import torch
import torch.nn as nn
from vllm_omni.diffusion.cache.magcache.config import MagCacheConfig
from vllm_omni.diffusion.cache.magcache.hook import (
_MAG_CACHE_LEADER_BLOCK_HOOK,
apply_mag_cache_hook,
)
from vllm_omni.diffusion.hooks.base import HookRegistry
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _CountingBlock(nn.Module):
"""Toy dual-stream block: adds a constant delta to hidden_states.
Returns (encoder_hidden_states, hidden_states) to match the default
TransformerBlockMetadata(return_hidden_states_index=1,
return_encoder_hidden_states_index=0) registered by the hook.
"""
def __init__(self, delta: float):
super().__init__()
self.delta = delta
self.forward_calls = 0
def forward(self, hidden_states, encoder_hidden_states):
self.forward_calls += 1
return encoder_hidden_states, hidden_states + self.delta
class _ToyTransformer(nn.Module):
def __init__(self, n_blocks: int, delta: float = 1.0):
super().__init__()
self.blocks = nn.ModuleList(_CountingBlock(delta) for _ in range(n_blocks))
def forward(self, hidden_states, encoder_hidden_states):
for block in self.blocks:
encoder_hidden_states, hidden_states = block(hidden_states, encoder_hidden_states)
return hidden_states
@property
def total_forward_calls(self) -> int:
return sum(block.forward_calls for block in self.blocks)
def _make_config(num_inference_steps: int = 2) -> MagCacheConfig:
# retention_ratio=0.5 with 2 steps -> retention_step = 1: step 0 always
# computes, step 1 with mag_ratio 1.0 accumulates zero error, which is a
# guaranteed cache hit.
return MagCacheConfig(
transformer_type="Toy",
threshold=0.24,
max_skip_steps=5,
retention_ratio=0.5,
num_inference_steps=num_inference_steps,
mag_ratios=torch.ones(num_inference_steps),
)
def test_skipped_step_applies_full_stack_residual_once():
n_blocks, delta = 3, 1.0
model = _ToyTransformer(n_blocks=n_blocks, delta=delta)
apply_mag_cache_hook(model, _make_config())
x = torch.zeros(1, 4, 8)
e = torch.zeros(1, 2, 8)
# Step 0 computes and caches residual = tail_output - head_input.
out_computed = model(x, e)
assert torch.allclose(out_computed, x + n_blocks * delta)
assert model.total_forward_calls == n_blocks
# Step 1 is a cache hit: no block computes, and the cached residual is
# applied exactly once, so the output matches the computed step.
out_skipped = model(x, e)
assert model.total_forward_calls == n_blocks, "step 1 was not skipped"
assert torch.allclose(out_skipped, out_computed), (
f"cached residual over-applied on skip: expected "
f"{out_computed.flatten()[0].item()}, got {out_skipped.flatten()[0].item()}"
)
def test_single_block_advances_schedule_on_skip():
model = _ToyTransformer(n_blocks=1)
apply_mag_cache_hook(model, _make_config(num_inference_steps=2))
x = torch.zeros(1, 4, 8)
e = torch.zeros(1, 2, 8)
model(x, e) # step 0: computed
assert model.total_forward_calls == 1
model(x, e) # step 1: cache hit
assert model.total_forward_calls == 1, "step 1 was not skipped"
# The head hook is also the tail here, so a skipped step must still
# advance the schedule; step 1 was the last step, which resets the
# state for the next inference run.
registry = HookRegistry.check_if_exists_or_initialize(model.blocks[0])
hook = registry.get_hook(_MAG_CACHE_LEADER_BLOCK_HOOK)
state = hook.state_manager.get_state()
assert state.step_index == 0
assert state._is_first_step
assert state.previous_residual is None
# A new run therefore starts by computing, not by replaying the stale
# residual from the previous run.
model(x, e)
assert model.total_forward_calls == 2, "new run reused stale skip state"
@@ -39,7 +39,7 @@ MODEL_CASES = [
"needs_image": False,
"is_moe": False,
"mean_threshold": 3e-2,
"p99_threshold": 3e-2,
"p99_threshold": 4e-2,
},
id="wan22_t2v_a14b",
),
@@ -146,7 +146,7 @@ def _run_generate(
@pytest.mark.full_model
@pytest.mark.diffusion
@pytest.mark.parallel
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards={"cuda": 4, "rocm": 2})
@hardware_test(res={"cuda": "H100"}, num_cards={"cuda": 4})
@pytest.mark.parametrize("model_case", MODEL_CASES)
def test_vae_patch_parallel_tp2(model_case: dict[str, Any], tmp_path: Path):
if current_omni_platform.is_npu():
@@ -16,7 +16,6 @@ from PIL import Image
from torch import nn
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
from vllm_omni.platforms import current_omni_platform
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
@@ -858,10 +857,6 @@ def test_flow_unipc_reuses_scheduler_and_forwards_each_request_shift(make_cosmos
assert all(call["sigmas"] is None for call in scheduler.set_timesteps_calls)
@pytest.mark.skipif(
current_omni_platform.is_rocm(),
reason="Pytorch shipped with vLLM does not built with LAPACK support",
)
def test_flow_unipc_reproducible_with_same_seed(make_cosmos3_pipeline) -> None:
from vllm_omni.diffusion.models.schedulers.scheduling_flow_unipc_multistep import (
FlowUniPCMultistepScheduler,
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
@@ -0,0 +1,375 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from multiprocessing.reduction import ForkingPickler
from types import SimpleNamespace
from unittest.mock import Mock
import numpy as np
import pytest
import torch
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
def test_pipeline_import_registry_and_component_discovery():
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
from vllm_omni.diffusion.registry import (
_DIFFUSION_MODELS,
_DIFFUSION_POST_PROCESS_FUNCS,
)
assert _DIFFUSION_MODELS["MiniMaxH3Pipeline"] == (
"minimax_h3",
"pipeline_minimax_h3",
"MiniMaxH3Pipeline",
)
assert _DIFFUSION_POST_PROCESS_FUNCS["MiniMaxH3Pipeline"] == "get_minimax_h3_post_process_func"
assert MiniMaxH3Pipeline._dit_modules == ["transformer"]
assert MiniMaxH3Pipeline._encoder_modules == ["text_encoder"]
assert MiniMaxH3Pipeline._vae_modules == ["video_vae", "audio_vae"]
def test_joint_postprocess_is_multiprocessing_picklable():
from vllm_omni.diffusion.models.minimax_h3 import (
get_minimax_h3_post_process_func,
)
postprocess = get_minimax_h3_post_process_func(SimpleNamespace())
postprocess = ForkingPickler.loads(ForkingPickler.dumps(postprocess))
video = torch.linspace(0, 1, 2 * 3 * 2 * 4 * 5).reshape(2, 3, 2, 4, 5)
audio = torch.arange(12, dtype=torch.float32).reshape(1, 2, 6)
result = postprocess((video, audio), output_type="np")
assert isinstance(result["video"], list)
assert result["video"][0].shape == (2, 4, 5, 3)
np.testing.assert_array_equal(result["audio"], audio.numpy())
assert result["audio_sample_rate"] == 32000
assert result["fps"] == 24
def test_cfg_parallel_is_rejected_for_distilled_checkpoint():
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
od_config = SimpleNamespace(
parallel_config=SimpleNamespace(cfg_parallel_size=2),
)
with pytest.raises(ValueError, match="CFG-distilled"):
MiniMaxH3Pipeline(od_config=od_config)
def test_shape_contract_matches_three_reference_tasks():
from vllm_omni.diffusion.models.minimax_h3.time_request import (
MINIMAX_H3_SHAPE_PLANNER,
minimax_h3_align_frame_count,
)
assert minimax_h3_align_frame_count(round(8.7 * 24)) == 209
assert MINIMAX_H3_SHAPE_PLANNER.video_latent_t(209) == 62
assert MINIMAX_H3_SHAPE_PLANNER.audio_latent_t(8.7) == 348
assert minimax_h3_align_frame_count(round(5.0 * 24)) == 124
assert MINIMAX_H3_SHAPE_PLANNER.video_latent_t(124) == 37
assert MINIMAX_H3_SHAPE_PLANNER.audio_latent_t(5.0) == 200
assert MINIMAX_H3_SHAPE_PLANNER.frame_count_from_video_latent_t(62) == 209
def test_shifted_sigma_schedule_matches_reference_values():
from vllm_omni.diffusion.models.minimax_h3.time_request import (
minimax_h3_time_shift_sigmas,
)
sigmas = minimax_h3_time_shift_sigmas(num_steps=5, shift_scale=12.0)
assert sigmas == pytest.approx(
[1.0, 0.9729729891, 0.9230769277, 0.8000000119, 0.0],
abs=1e-7,
)
def test_reference_image_resize_contract():
from PIL import Image
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
_reference_image_shape,
)
assert _reference_image_shape(Image.new("RGB", (1080, 1440))) == (
2048,
2720,
)
with pytest.raises(ValueError, match="aspect ratio"):
_reference_image_shape(Image.new("RGB", (100, 501)))
def test_encoder_forward_uses_hook_compatible_encode_entrypoint():
from vllm_omni.diffusion.models.minimax_h3.encoder import (
MiniMaxH3Qwen3VLEncoder,
)
encoder = object.__new__(MiniMaxH3Qwen3VLEncoder)
torch.nn.Module.__init__(encoder)
expected = torch.ones(2, 3)
encoder.encode_ids = Mock(return_value=expected)
input_ids = torch.tensor([1, 2])
pixel_values = torch.ones(1, 4)
image_grid_thw = torch.tensor([[1, 1, 1]])
actual = encoder(
input_ids,
pixel_values=pixel_values,
image_grid_thw=image_grid_thw,
)
assert actual is expected
encoder.encode_ids.assert_called_once_with(
input_ids,
pixel_values=pixel_values,
image_grid_thw=image_grid_thw,
)
def test_encoder_forward_forwards_video_inputs():
from vllm_omni.diffusion.models.minimax_h3.encoder import (
MiniMaxH3Qwen3VLEncoder,
)
encoder = object.__new__(MiniMaxH3Qwen3VLEncoder)
torch.nn.Module.__init__(encoder)
expected = torch.ones(2, 3)
encoder.encode_ids = Mock(return_value=expected)
input_ids = torch.tensor([1, 2])
pixel_values_videos = torch.ones(1, 4)
video_grid_thw = torch.tensor([[2, 1, 1]])
actual = encoder(
input_ids,
pixel_values_videos=pixel_values_videos,
video_grid_thw=video_grid_thw,
)
assert actual is expected
encoder.encode_ids.assert_called_once_with(
input_ids,
pixel_values=None,
image_grid_thw=None,
pixel_values_videos=pixel_values_videos,
video_grid_thw=video_grid_thw,
)
def test_reference_video_shape_uses_h3_adapt_shape_policy():
from vllm_omni.diffusion.models.minimax_h3.reference_video import (
_reference_video_shape,
)
assert _reference_video_shape(1280, 720) == (1344, 768)
assert _reference_video_shape(3844, 2160) == (1344, 768)
def test_text_encoder_stub_constructs_without_group_or_weights():
from vllm_omni.diffusion.models.minimax_h3.encoder import (
MiniMaxH3Qwen3VLEncoder,
)
encoder = MiniMaxH3Qwen3VLEncoder(
"/nonexistent/text_encoder",
device=torch.device("cpu"),
load_model=False,
encoder_group=None,
)
assert not encoder.is_loaded
assert encoder.tp_size == 1
# The stub has no parameters, so it never contributes to the runner's
# strict missing-parameter check on non-encoder ranks.
assert list(encoder.named_parameters()) == []
def test_no_offload_keeps_text_encoder_resident():
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
pipeline.od_config = SimpleNamespace(
enable_cpu_offload=False,
enable_layerwise_offload=False,
)
pipeline.text_encoder = Mock()
expected = torch.ones(2, 3)
pipeline.text_encoder.encode_ids.return_value = expected
input_ids = torch.tensor([1, 2])
actual = pipeline._encode_text_hidden(input_ids, {})
assert actual is expected
pipeline.text_encoder.load_to_device.assert_called_once_with()
pipeline.text_encoder.offload_to_cpu.assert_not_called()
def test_model_offload_uses_hooked_text_encoder_call():
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
pipeline.od_config = SimpleNamespace(
enable_cpu_offload=True,
enable_layerwise_offload=False,
)
expected = torch.ones(2, 3)
pipeline.text_encoder = Mock(return_value=expected)
input_ids = torch.tensor([1, 2])
vision_kwargs = {"pixel_values": torch.ones(1, 4)}
actual = pipeline._encode_text_hidden(input_ids, vision_kwargs)
assert actual is expected
pipeline.text_encoder.assert_called_once_with(input_ids, **vision_kwargs)
pipeline.text_encoder.load_to_device.assert_not_called()
def test_layerwise_offload_releases_text_encoder():
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
pipeline.od_config = SimpleNamespace(
enable_cpu_offload=False,
enable_layerwise_offload=True,
)
pipeline.text_encoder = Mock()
expected = torch.ones(2, 3)
pipeline.text_encoder.encode_ids.return_value = expected
actual = pipeline._encode_text_hidden(torch.tensor([1, 2]), {})
assert actual is expected
pipeline.text_encoder.load_to_device.assert_called_once_with()
pipeline.text_encoder.offload_to_cpu.assert_called_once_with()
def test_video_vae_keeps_reference_fp32_weights(monkeypatch):
from vllm_omni.diffusion.models.minimax_h3 import vae as vae_module
class FakeRemote(torch.nn.Module):
def __init__(self):
super().__init__()
self.model = torch.nn.Linear(1, 1).half()
monkeypatch.setattr(
vae_module,
"_load_component_config",
lambda _path: {
"latent_channels": 1,
"latents_mean": [0.0],
"latents_std": [1.0],
},
)
monkeypatch.setattr(
vae_module,
"_load_remote_component",
lambda _path, _config: FakeRemote(),
)
video_vae = vae_module.MiniMaxH3VideoVAE(
"unused",
device=torch.device("cpu"),
)
assert next(video_vae.parameters()).dtype == torch.float32
def test_video_vae_encode_uses_configured_parallel_tiling():
from vllm_omni.diffusion.models.minimax_h3.vae import (
MiniMaxH3VideoVAE,
)
class FakeModel(torch.nn.Module):
def __init__(self):
super().__init__()
self.weight = torch.nn.Parameter(torch.ones(1))
self.parallel_tiling = True
self.encode_calls = []
def encode_videos(self, frames, *, use_fp16_latent):
assert self.parallel_tiling
self.encode_calls.append((frames, use_fp16_latent))
return [torch.ones(1, 1, 2, 2, 2)]
video_vae = object.__new__(MiniMaxH3VideoVAE)
torch.nn.Module.__init__(video_vae)
video_vae.model = FakeModel()
video_vae.config_dict = {
"latent_channels": 1,
"latents_mean": [0.0],
"latents_std": [1.0],
}
rows, shape = video_vae.encode_video("frames")
assert video_vae.model.parallel_tiling
assert video_vae.model.encode_calls == [("frames", True)]
assert rows.shape == (2, 4)
assert shape == (2, 2, 2)
def test_distributed_video_vae_encodes_references_sequentially(monkeypatch):
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
from vllm_omni.diffusion.models.minimax_h3 import (
pipeline_minimax_h3 as pipeline_module,
)
prepared = [
{"prepared_path": "video-1.mp4"},
{"prepared_path": "video-2.mp4"},
]
class FakeVideoVAE:
def __init__(self):
self.calls = []
def is_distributed_enabled(self):
return True
def encode_video(self, frames):
self.calls.append(frames)
index = len(self.calls)
return torch.full((1, 2), index, dtype=torch.float32), (index, 2, 3)
pipeline = object.__new__(MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
pipeline.device = torch.device("cpu")
pipeline.video_vae = FakeVideoVAE()
monkeypatch.setattr(
pipeline_module,
"_dit_rank_world",
lambda: ("dit-group", 1, 4),
)
def fake_broadcast_object_list(values, *, src, group, device):
assert values == [None]
assert (src, group, device) == (0, "dit-group", torch.device("cpu"))
values[0] = prepared
monkeypatch.setattr(
pipeline_module.dist,
"broadcast_object_list",
fake_broadcast_object_list,
)
monkeypatch.setattr(
pipeline_module,
"load_video_frames",
lambda path: f"frames:{path}",
)
rows, shapes = pipeline._encode_video_conditions(None, count=2)
assert pipeline.video_vae.calls == [
"frames:video-1.mp4",
"frames:video-2.mp4",
]
torch.testing.assert_close(
rows,
torch.tensor([[1.0, 1.0], [2.0, 2.0]]),
)
assert shapes == [(1, 2, 3), (2, 2, 3)]
@@ -0,0 +1,69 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import os
import numpy as np
import pytest
pytestmark = [
pytest.mark.local_model,
pytest.mark.cuda,
pytest.mark.diffusion,
pytest.mark.parallel,
]
MODEL_ENV = "VLLM_TEST_MINIMAX_H3_FL2VA_MODEL"
@pytest.mark.skipif(
not os.environ.get(MODEL_ENV),
reason=f"set {MODEL_ENV} to an authorized FL2VA checkpoint path",
)
def test_minimax_h3_t2va_ulysses8_smoke():
import torch
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
if torch.accelerator.device_count() < 8:
pytest.skip("MiniMax H3 Ulysses smoke requires eight GPUs")
engine = Omni(
model=os.environ[MODEL_ENV],
parallel_config=DiffusionParallelConfig(ulysses_degree=8),
trust_remote_code=True,
enable_cpu_offload=True,
enforce_eager=True,
)
try:
outputs = engine.generate(
"A quiet cinematic night scene with matching ambient sound.",
OmniDiffusionSamplingParams(
height=256,
width=448,
num_frames=29,
fps=24,
num_inference_steps=2,
seed=42,
output_type="np",
extra_args={
"task": "t2va",
"flow_shift": 12.0,
"audio_flow_shift": 3.0,
},
),
use_tqdm=False,
)
finally:
engine.close()
assert len(outputs) == 1
frames = np.asarray(outputs[0].images[0])
assert frames.shape == (39, 256, 448, 3)
multimodal = outputs[0].multimodal_output
assert multimodal is not None
assert np.asarray(multimodal["audio"]).shape[1] == 2
assert multimodal["audio_sample_rate"] == 32000
assert multimodal["fps"] == 24
@@ -0,0 +1,146 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
import torch
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
def test_video_patchify_round_trip_preserves_values():
from vllm_omni.diffusion.models.minimax_h3.packed_tokens import (
minimax_h3_patchify_video_latent,
minimax_h3_unpatchify_video_tokens,
)
latent = torch.arange(2 * 3 * 2 * 4 * 6).reshape(2, 3, 2, 4, 6)
rows = minimax_h3_patchify_video_latent(
latent,
patch_size=(1, 2, 2),
)
restored = minimax_h3_unpatchify_video_tokens(
rows,
latent_shape=(2, 2, 3, 3),
patch_size=(1, 2, 2),
)
torch.testing.assert_close(restored, latent)
def test_audio_pack_round_trip_preserves_channel_major_order():
from vllm_omni.diffusion.models.minimax_h3.packed_tokens import (
minimax_h3_pack_audio_latent,
minimax_h3_unpack_audio_tokens,
)
latent = torch.arange(2 * 4 * 5).reshape(2, 4, 5)
rows = minimax_h3_pack_audio_latent(latent)
restored = minimax_h3_unpack_audio_tokens(
rows,
audio_t=10,
audio_channel=2,
)
torch.testing.assert_close(restored, latent)
def test_t2va_and_fl2va_packing_keep_update_rows_separate():
from vllm_omni.diffusion.models.minimax_h3.packed_sequence import (
minimax_h3_packed_sequence,
)
common = dict(
text_len=4,
latent_t=2,
latent_h=4,
latent_w=6,
audio_t=3,
)
t2va = minimax_h3_packed_sequence(
**common,
include_keyframe_cond=False,
)
fl2va = minimax_h3_packed_sequence(
**common,
include_keyframe_cond=True,
keyframe_frame_indices=[0],
frame_count=5,
)
assert int(t2va["seq_len"]) == 64
assert t2va["img_pos"].numel() == 12
assert t2va["update_mask"].all()
assert fl2va["img_pos"].numel() == 18
assert fl2va["update_mask"].sum().item() == 12
assert (~fl2va["update_mask"]).sum().item() == 6
assert t2va["audio_pos"].numel() == fl2va["audio_pos"].numel() == 6
def test_ref2va_packing_tracks_video_and_audio_update_masks():
from vllm_omni.diffusion.models.minimax_h3.packed_sequence import (
minimax_h3_packed_sequence_ref2va_blocks,
)
packed = minimax_h3_packed_sequence_ref2va_blocks(
text_len=4,
latent_t=2,
latent_h=4,
latent_w=6,
audio_t=3,
ref_blocks=[
{"kind": "image", "latent_h": 4, "latent_w": 4},
{"kind": "audio", "ref_audio_t": 2},
],
)
assert int(packed["seq_len"]) == 64
assert packed["img_pos"].numel() == 16
assert packed["update_mask"].sum().item() == 12
assert packed["audio_pos"].numel() == 10
assert packed["audio_update_mask"].sum().item() == 6
assert packed["cu_seqlens"].tolist() == [0, 30, 64]
def test_condition_noise_is_seeded_and_keeps_clean_anchor_at_timestep_one():
from vllm_omni.diffusion.models.minimax_h3.condition_noise import (
minimax_h3_audio_cond_noise_aug_rows,
minimax_h3_imgvid_cond_noise_aug_rows,
)
image_rows = torch.randn(4, 96)
audio_rows = torch.randn(6, 32)
torch.testing.assert_close(
minimax_h3_imgvid_cond_noise_aug_rows(
image_rows,
condition_shapes=[(1, 4, 4)],
target_latent_t=2,
imgvid_cond_num_frames=1,
seed=42,
noise_aug=1.0,
),
image_rows,
)
torch.testing.assert_close(
minimax_h3_audio_cond_noise_aug_rows(
audio_rows,
condition_audio_t=[3],
seed=42,
noise_aug=1.0,
),
audio_rows,
)
first = minimax_h3_audio_cond_noise_aug_rows(
audio_rows,
condition_audio_t=[3],
seed=42,
noise_aug=0.25,
)
second = minimax_h3_audio_cond_noise_aug_rows(
audio_rows,
condition_audio_t=[3],
seed=42,
noise_aug=0.25,
)
torch.testing.assert_close(first, second)
@@ -0,0 +1,94 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
import torch
import torch.nn as nn
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
def test_grouped_qkv_checkpoint_reorder():
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
_reorder_grouped_qkv_to_qkv,
)
# Two groups with rows [q, k, v] become [q0, q1, k0, k1, v0, v1].
grouped = torch.arange(6, dtype=torch.float32).reshape(6, 1)
reordered = _reorder_grouped_qkv_to_qkv(
grouped,
num_query_groups=2,
heads_per_group=1,
head_dim=1,
)
assert reordered[:, 0].tolist() == [0, 3, 1, 4, 2, 5]
def test_transformer_declares_cache_sp_layerwise_offload_and_hsdp():
from cache_dit import ForwardPattern
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
MiniMaxH3DiTModel,
)
assert MiniMaxH3DiTModel._repeated_blocks == ["MiniMaxH3DiTBlock"]
assert MiniMaxH3DiTModel._layerwise_offload_blocks_attrs == ["blocks"]
assert MiniMaxH3DiTModel._cache_dit_adapter_config.block_forward_patterns["blocks"] == ForwardPattern.Pattern_3
assert not MiniMaxH3DiTModel._cache_dit_adapter_config.has_separate_cfg
assert set(MiniMaxH3DiTModel._sp_plan) == {"sp_prepare", "sp_gather"}
model = object.__new__(MiniMaxH3DiTModel)
nn.Module.__init__(model)
model.blocks = nn.ModuleList([nn.Linear(4, 4) for _ in range(2)])
model.token_refiner = nn.Module()
model.token_refiner.blocks = nn.ModuleList([nn.Linear(4, 4)])
model.final_layer = nn.Linear(4, 4)
matched = [
name
for name, module in model.named_modules()
if any(condition(name, module) for condition in MiniMaxH3DiTModel._hsdp_shard_conditions)
]
assert matched == ["blocks.0", "blocks.1"]
def test_packed_attention_is_a_regional_compile_boundary():
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
MiniMaxH3Attention,
)
assert getattr(MiniMaxH3Attention._run_packed_attention, "_torchdynamo_disable", False)
@pytest.mark.parametrize(
("tp_size", "message"),
[
(3, "num_attention_heads"),
(5, "num_attention_heads"),
],
)
def test_tp_rejects_non_divisible_head_counts(tp_size, message):
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
MiniMaxH3DiTArchConfig,
MiniMaxH3DiTModel,
)
model = object.__new__(MiniMaxH3DiTModel)
with pytest.raises(ValueError, match=message):
model._validate_tp_config(
arch=MiniMaxH3DiTArchConfig(),
tp_size=tp_size,
)
def test_tp_accepts_checkpoint_supported_sizes():
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
MiniMaxH3DiTArchConfig,
MiniMaxH3DiTModel,
)
model = object.__new__(MiniMaxH3DiTModel)
arch = MiniMaxH3DiTArchConfig()
for tp_size in (1, 2, 4, 7):
model._validate_tp_config(arch=arch, tp_size=tp_size)
@@ -9,7 +9,9 @@ import torch
from torch import nn
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import Wan22Pipeline
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
@@ -91,7 +93,20 @@ def _make_pipeline() -> Wan22Pipeline:
return pipeline
def test_forward_delegates_denoising_to_diffuse() -> None:
@pytest.mark.parametrize(
("sampling_params_kwargs", "expected_low", "expected_high"),
[
({}, 4.0, 4.0),
({"guidance_scale": 0.0}, 0.0, 0.0),
({"guidance_scale": 1.0}, 1.0, 1.0),
({"guidance_scale": 3.0, "guidance_scale_2": 5.0}, 3.0, 5.0),
],
)
def test_forward_delegates_denoising_to_diffuse(
sampling_params_kwargs: dict[str, float],
expected_low: float,
expected_high: float,
) -> None:
pipeline = _make_pipeline()
captured: dict[str, object] = {}
@@ -101,25 +116,15 @@ def test_forward_delegates_denoising_to_diffuse() -> None:
pipeline.diffuse = _fake_diffuse # type: ignore[method-assign]
mock_req = SimpleNamespace(
mock_req = OmniDiffusionRequest(
prompt="prompt",
request_id="test-req",
sampling_params=SimpleNamespace(
height=None,
width=None,
sampling_params=OmniDiffusionSamplingParams(
num_frames=1,
num_inference_steps=2,
guidance_scale_provided=True,
guidance_scale=1.0,
guidance_scale_2=None,
boundary_ratio=None,
generator=None,
seed=None,
num_outputs_per_prompt=1,
max_sequence_length=32,
latents=None,
output_type="latent",
extra_args={},
**sampling_params_kwargs,
),
)
batch = DiffusionRequestBatch(requests=[mock_req])
@@ -129,8 +134,8 @@ def test_forward_delegates_denoising_to_diffuse() -> None:
assert torch.equal(output.output, torch.ones((1, 4, 1, 8, 8)))
assert torch.equal(captured["prompt_embeds"], torch.zeros(1, 32, 8))
assert torch.equal(captured["timesteps"], pipeline.scheduler.timesteps)
assert captured["guidance_low"] == 1.0
assert captured["guidance_high"] == 1.0
assert captured["guidance_low"] == expected_low
assert captured["guidance_high"] == expected_high
assert captured["boundary_timestep"] == pytest.approx(875.0)
assert captured["latent_condition"] is None
assert captured["first_frame_mask"] is None
+37
View File
@@ -72,3 +72,40 @@ def test_tp_seed_same_across_ranks_and_varies_across_requests():
# Seeds must vary across requests (non-determinism preserved).
assert len(set(seeds)) == n_requests, f"Expected {n_requests} unique seeds but got {len(set(seeds))}: {seeds}"
def _request_with_guidance(**sampling_kwargs) -> OmniDiffusionRequest:
return OmniDiffusionRequest(
prompt={"prompt": "a cup of coffee on a table"},
sampling_params=OmniDiffusionSamplingParams(num_inference_steps=1, **sampling_kwargs),
request_id="request-test",
)
def test_omitted_guidance_scale_is_unset_and_resolved_to_one():
# No guidance_scale passed: the caller did not set it, so it must NOT count as
# "provided" (each pipeline applies its own default), and __post_init__ resolves the
# None "unset" sentinel to a concrete float so no consumer ever observes None.
req = _request_with_guidance()
assert req.sampling_params.guidance_scale_provided is False
assert req.sampling_params.guidance_scale == 1.0
def test_explicit_zero_guidance_scale_is_honored():
# Regression (#4998): an explicit guidance_scale=0 (disable CFG) must be honored,
# not mistaken for "unset" and overridden by the pipeline's model default. This is
# the sentinel collision that the None default + ``is not None`` check fixes.
req = _request_with_guidance(guidance_scale=0.0)
assert req.sampling_params.guidance_scale_provided is True
assert req.sampling_params.guidance_scale == 0.0
@pytest.mark.parametrize("value", [0.999, 1.0, 5.0])
def test_explicit_nonzero_guidance_scale_is_honored(value):
# Any explicitly-set value (truthy or not) is honored as provided and preserved.
req = _request_with_guidance(guidance_scale=value)
assert req.sampling_params.guidance_scale_provided is True
assert req.sampling_params.guidance_scale == value
@@ -0,0 +1,60 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for OmniDiffusionConfig HF-offline model-path resolution."""
import os
import huggingface_hub
import pytest
import vllm_omni.diffusion.data as diffusion_data
from vllm_omni.diffusion.data import OmniDiffusionConfig
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
_LOCAL_SNAPSHOT = "/root/.cache/huggingface/hub/models--foo--bar/snapshots/deadbeef"
@pytest.fixture
def record_get_model_path(monkeypatch: pytest.MonkeyPatch):
calls: list[tuple[str, str | None]] = []
def fake_get_model_path(model, revision=None):
calls.append((model, revision))
if os.path.exists(model):
return model
return _LOCAL_SNAPSHOT
monkeypatch.setattr(diffusion_data, "get_model_path", fake_get_model_path)
return calls
class TestOmniDiffusionConfigOfflineModelPath:
def test_offline_repo_id_replaced_with_local_path(
self, monkeypatch: pytest.MonkeyPatch, record_get_model_path
) -> None:
monkeypatch.setattr(huggingface_hub.constants, "HF_HUB_OFFLINE", True)
config = OmniDiffusionConfig(model="foo/bar")
assert config.model == _LOCAL_SNAPSHOT
assert record_get_model_path == [("foo/bar", None)]
def test_online_leaves_model_untouched(self, monkeypatch: pytest.MonkeyPatch, record_get_model_path) -> None:
monkeypatch.setattr(huggingface_hub.constants, "HF_HUB_OFFLINE", False)
config = OmniDiffusionConfig(model="foo/bar")
assert config.model == "foo/bar"
assert record_get_model_path == []
def test_offline_local_path_unchanged(self, monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
monkeypatch.setattr(huggingface_hub.constants, "HF_HUB_OFFLINE", True)
local = str(tmp_path)
config = OmniDiffusionConfig(model=local)
assert config.model == local
@pytest.mark.parametrize("model", [None, ""])
def test_offline_empty_model_unchanged(
self, monkeypatch: pytest.MonkeyPatch, record_get_model_path, model: str | None
) -> None:
monkeypatch.setattr(huggingface_hub.constants, "HF_HUB_OFFLINE", True)
config = OmniDiffusionConfig(model=model)
assert config.model == model
assert record_get_model_path == []
@@ -0,0 +1,69 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import numpy as np
import pytest
import torch
from vllm_omni.diffusion.utils import flow_matching
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
def test_safe_linalg_solve_uses_torch_native_path(monkeypatch: pytest.MonkeyPatch):
matrix = torch.tensor([[3.0, 1.0], [1.0, 2.0]], dtype=torch.float32)
rhs = torch.tensor([9.0, 8.0], dtype=torch.float32)
expected = torch.tensor([2.0, 3.0], dtype=torch.float32)
def fake_torch_solve(solve_matrix: torch.Tensor, solve_rhs: torch.Tensor) -> torch.Tensor:
assert torch.equal(solve_matrix, matrix)
assert torch.equal(solve_rhs, rhs)
return expected
def fail_numpy(*_args, **_kwargs):
raise AssertionError("NumPy fallback should not run")
monkeypatch.setattr(flow_matching.torch.linalg, "solve", fake_torch_solve)
monkeypatch.setattr(flow_matching.np.linalg, "solve", fail_numpy)
result = flow_matching.safe_linalg_solve(matrix, rhs)
assert result is expected
def test_safe_linalg_solve_falls_back_on_missing_cpu_lapack(monkeypatch: pytest.MonkeyPatch):
matrix = torch.tensor([[3.0, 1.0], [1.0, 2.0]], dtype=torch.float32)
rhs = torch.tensor([9.0, 8.0], dtype=torch.float32)
expected = np.array([2.0, 3.0], dtype=np.float32)
def fail_with_missing_lapack(*_args, **_kwargs):
raise RuntimeError(
"Calling torch.linalg.lu_factor on a CPU tensor requires compiling PyTorch with LAPACK. "
"Please use PyTorch built with LAPACK support."
)
monkeypatch.setattr(flow_matching.torch.linalg, "solve", fail_with_missing_lapack)
monkeypatch.setattr(flow_matching.np.linalg, "solve", lambda *_args, **_kwargs: expected)
result = flow_matching.safe_linalg_solve(matrix, rhs)
assert torch.equal(result, torch.from_numpy(expected))
assert result.dtype == matrix.dtype
assert result.device == matrix.device
def test_safe_linalg_solve_reraises_unrelated_runtime_error(monkeypatch: pytest.MonkeyPatch):
matrix = torch.tensor([[3.0, 1.0], [1.0, 2.0]], dtype=torch.float32)
rhs = torch.tensor([9.0, 8.0], dtype=torch.float32)
def fail_with_unrelated_error(*_args, **_kwargs):
raise RuntimeError("solver exploded for another reason")
def fail_numpy(*_args, **_kwargs):
raise AssertionError("NumPy fallback should not run")
monkeypatch.setattr(flow_matching.torch.linalg, "solve", fail_with_unrelated_error)
monkeypatch.setattr(flow_matching.np.linalg, "solve", fail_numpy)
with pytest.raises(RuntimeError, match="solver exploded for another reason"):
flow_matching.safe_linalg_solve(matrix, rhs)
@@ -10,8 +10,10 @@ import pytest
import torch
from pytest_mock import MockerFixture
from vllm.v1.core.sched.scheduler import Scheduler as VLLMScheduler
from vllm.v1.metrics.stats import PrefillStats, PromptTokenStats
from vllm.v1.request import RequestStatus
from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler
from vllm_omni.data_entry_keys import CodesStruct, MetaStruct, OmniPayload, OmniPayloadStruct
from vllm_omni.distributed.omni_connectors.adapter import construct_next_stage_streaming_input_prompt
from vllm_omni.distributed.omni_connectors.transfer_adapter.base import OmniTransferAdapterBase
@@ -37,8 +39,10 @@ def _req(req_id: str, status: RequestStatus, external_req_id: str | None = None)
external_req_id=external_req_id or req_id,
status=status,
prompt_token_ids=[],
num_prompt_tokens=0,
num_computed_tokens=0,
num_output_placeholders=0,
prefill_stats=None,
additional_information=None,
is_finished=lambda: status == RequestStatus.FINISHED_STOPPED,
)
@@ -192,7 +196,7 @@ def test_load_poll(build_adapter):
assert request.additional_information == payload
assert adapter.get_req_chunk["req-1"] == 1
assert "req-1" in adapter._finished_load_reqs
assert "req-1" in adapter.finished_requests
assert "req-1" in adapter.upstream_exhausted_requests
assert "req-1" not in adapter._pending_load_reqs
@@ -459,7 +463,7 @@ def test_load_poll_non_ar_merges_into_existing_additional_information(build_adap
assert request.additional_information["meta"]["phase"] == "decode"
assert request.additional_information["kv_metadata"] == {"foo": "bar"}
assert "req-non-ar" in adapter._finished_load_reqs
assert "req-non-ar" in adapter.finished_requests
assert "req-non-ar" in adapter.upstream_exhausted_requests
def test_load_poll_ar_request_additional_information_concats_tensors(build_adapter):
@@ -485,6 +489,48 @@ def test_load_poll_ar_request_additional_information_concats_tensors(build_adapt
assert request.additional_information["meta"]["finished"].item() is True
def test_non_ar_poll_reinitializes_prefill_stats_for_later_chunks(build_adapter):
adapter, connector = build_adapter(stage_id=2, model_mode="generation")
request = _req("req-later-chunk", RequestStatus.WAITING, external_req_id="ext-later-chunk")
request.prefill_stats = PrefillStats()
adapter.request_ids_mapping[request.request_id] = request.external_req_id
connector.get.return_value = (
{
"codes": {"audio": torch.tensor([7, 8], dtype=torch.long)},
"meta": {"finished": torch.tensor(False, dtype=torch.bool)},
},
8,
)
assert adapter._poll_single_request(request) is True
OmniGenerationScheduler._record_prefill_stats(request)
first_chunk_stats = request.prefill_stats
request.prefill_stats = None
connector.get.return_value = (
{
"codes": {"audio": torch.tensor([9, 10, 11], dtype=torch.long)},
"meta": {"finished": torch.tensor(True, dtype=torch.bool)},
},
8,
)
assert adapter._poll_single_request(request) is True
assert isinstance(request.prefill_stats, PrefillStats)
OmniGenerationScheduler._record_prefill_stats(request)
second_chunk_stats = request.prefill_stats
assert first_chunk_stats is not None
assert first_chunk_stats.num_prompt_tokens == 2
assert second_chunk_stats is not None
assert second_chunk_stats.num_prompt_tokens == 3
prompt_token_stats = PromptTokenStats()
prompt_token_stats.update_from_output(first_chunk_stats)
prompt_token_stats.update_from_output(second_chunk_stats)
assert prompt_token_stats.total == 5
assert prompt_token_stats.computed == 5
def test_sender_only_adapter_does_not_park_or_clear_requests(build_adapter):
adapter, _ = build_adapter(stage_id=1, connector_extra={"role": "sender"})
request = _req("req-1", RequestStatus.WAITING)
@@ -527,7 +573,7 @@ def test_process_and_restore_queues(build_adapter):
def test_fifo_promotion(build_adapter):
adapter, _ = build_adapter(stage_id=1, max_num_seqs=2, active_stream_window=2)
adapter, _ = build_adapter(stage_id=1, model_mode="generation", max_num_seqs=2, active_stream_window=2)
reqs = [_req(f"req-{idx}", RequestStatus.WAITING) for idx in range(1, 5)]
waiting_queue = DummyWaitingQueue(reqs)
running_queue = []
@@ -540,11 +586,12 @@ def test_fifo_promotion(build_adapter):
assert reqs[1].status == RequestStatus.WAITING_FOR_CHUNK
assert reqs[2].status == RequestStatus.WAITING
adapter.finished_requests.add("req-1")
# Ordinary completion calls cleanup_receiver(), not finish_requests()
# (the abort path) -- see test_omni_ar_scheduler_free_request_cleanup.py.
# Eviction is deferred to postprocess_scheduler_output in the runtime path
# (commit c4d95fd9 — otherwise the terminal chunk deadlocks at c=8 K=2).
# Simulate it here so promotion can pick up the freed slot.
adapter._evict_finished_active_streams({"req-1"})
# (commit c4d95fd9 -- otherwise the terminal chunk deadlocks at c=8 K=2).
# Simulate the restore step here so promotion can pick up the freed slot.
adapter.cleanup_receiver("req-1")
adapter.restore_queues(waiting_queue, running_queue)
adapter.process_pending_chunks(waiting_queue, running_queue)
@@ -590,7 +637,7 @@ def test_legacy_k0(build_adapter):
def test_finished_releases_slot(build_adapter):
adapter, _ = build_adapter(stage_id=1, max_num_seqs=1, active_stream_window=1)
adapter, _ = build_adapter(stage_id=1, model_mode="generation", max_num_seqs=1, active_stream_window=1)
req_1 = _req("req-1", RequestStatus.WAITING)
req_2 = _req("req-2", RequestStatus.WAITING)
waiting_queue = DummyWaitingQueue([req_1, req_2])
@@ -600,10 +647,11 @@ def test_finished_releases_slot(build_adapter):
assert list(adapter._active_streams) == ["req-1"]
assert waiting_queue == []
adapter.finished_requests.add("req-1")
# Eviction is deferred to postprocess_scheduler_output in the runtime path
# (commit c4d95fd9). Simulate it so promotion can pick up the freed slot.
adapter._evict_finished_active_streams({"req-1"})
# req-1 finishes ordinarily (see test_fifo_promotion for why this is
# cleanup_receiver(), not finish_requests()). Eviction is deferred to
# postprocess_scheduler_output in the runtime path (commit c4d95fd9);
# simulate the restore step so promotion can pick up the freed slot.
adapter.cleanup_receiver("req-1")
adapter.restore_queues(waiting_queue, running_queue)
adapter.process_pending_chunks(waiting_queue, running_queue)
@@ -631,6 +679,79 @@ def test_postprocess_scheduler_output(build_adapter):
assert adapter.requests_with_ready_chunks == {"leftover"}
@pytest.mark.parametrize("model_mode", ["ar", "generation"])
def test_active_stream_window_stalls_lone_running_request_after_upstream_finishes(build_adapter, model_mode):
"""Regression test for vllm-project/vllm-omni#5349.
upstream_exhausted_requests means the upstream sent its terminal chunk,
not that this stage's own generation is done. A downstream stage can
still have a long decode ahead after its upstream finishes. Evicting on
that signal and then permanently denying re-admission wedges the
request out of running_queue forever, with no error.
requests_with_ready_chunks is set up front so the request stays in
running_queue this tick instead of legitimately parking in
waiting_for_chunk_running_requests (a separate mechanism this test must
not conflate with the bug).
Parametrized over both worker types: the real issue hit an "ar" stage
(Qwen3-Omni's talker); must not regress "generation" (Qwen3-TTS's
Code2Wav), which this feature was designed for.
"""
adapter, _ = build_adapter(stage_id=1, model_mode=model_mode, max_num_seqs=2, active_stream_window=2)
running_req = _req("req-1", RequestStatus.RUNNING)
running_queue = [running_req]
waiting_queue = DummyWaitingQueue([])
adapter._active_streams["req-1"] = running_req
adapter.requests_with_ready_chunks.add("req-1")
# Upstream terminal chunk arrives; this stage hasn't finished.
adapter.upstream_exhausted_requests.add("req-1")
scheduler_output = SimpleNamespace(
scheduled_new_reqs=[],
scheduled_cached_reqs=SimpleNamespace(req_ids=["req-1"]),
)
adapter.postprocess_scheduler_output(scheduler_output)
assert not running_req.is_finished() # sanity: not done
# No competition for the slot; req-1 must stay schedulable.
adapter.process_pending_chunks(waiting_queue, running_queue)
assert running_queue == [running_req], "req-1 must still be schedulable -- losing it here is #5349"
assert not adapter.waiting_for_chunk_running_requests
assert running_req not in adapter._held_non_active
assert list(adapter._active_streams) == ["req-1"]
def test_cleanup_receiver_releases_multiple_slots_in_sequence(build_adapter):
"""Companion to test_fifo_promotion: after EACH of the K active streams
finishes (not just one), promotion of a new K-sized batch must still
work -- the window must not stay exhausted by stale entries. See
test_omni_ar_scheduler_free_request_cleanup.py for the scheduler-level
proof that ordinary completion calls cleanup_receiver().
"""
adapter, _ = build_adapter(stage_id=1, model_mode="generation", max_num_seqs=2, active_stream_window=2)
reqs = [_req(f"req-{idx}", RequestStatus.WAITING) for idx in range(1, 5)]
waiting_queue = DummyWaitingQueue(reqs)
running_queue = []
adapter.process_pending_chunks(waiting_queue, running_queue)
assert list(adapter._active_streams) == ["req-1", "req-2"]
adapter.cleanup_receiver("req-1")
adapter.cleanup_receiver("req-2")
# Eviction is deferred to postprocess_scheduler_output in the runtime
# path; simulate the restore step so promotion can pick up the freed
# slots (see test_fifo_promotion).
adapter.restore_queues(waiting_queue, running_queue)
adapter.process_pending_chunks(waiting_queue, running_queue)
assert list(adapter._active_streams) == ["req-3", "req-4"], (
"both freed slots must be reusable after ordinary completion"
)
# ---------------------------------------------------------------
# Cleanup tests
# ---------------------------------------------------------------
@@ -638,7 +759,7 @@ def test_postprocess_scheduler_output(build_adapter):
def _populate_adapter_state(adapter, req_id="req-1", ext_id="ext-1"):
"""Fill every per-request structure so cleanup can be verified."""
adapter.finished_requests.add(req_id)
adapter.upstream_exhausted_requests.add(req_id)
adapter._active_streams[req_id] = SimpleNamespace(request_id=req_id)
adapter.get_req_chunk[req_id] = 3
adapter.requests_with_ready_chunks.add(req_id)
@@ -659,7 +780,7 @@ def test_cleanup_clears_all_state(build_adapter):
adapter.cleanup(req_id, ext_id)
assert req_id not in adapter.finished_requests
assert req_id not in adapter.upstream_exhausted_requests
assert req_id not in adapter._active_streams
assert req_id not in adapter.get_req_chunk
assert req_id not in adapter.requests_with_ready_chunks
@@ -712,7 +833,7 @@ def test_cleanup_request_id_reuse_not_polluted(build_adapter):
adapter.cleanup(req_id, ext_id)
assert req_id not in adapter.finished_requests
assert req_id not in adapter.upstream_exhausted_requests
assert req_id not in adapter.get_req_chunk
@@ -738,7 +859,7 @@ def test_cleanup_only_affects_target_request(build_adapter):
adapter.cleanup("req-a", "ext-a")
assert "req-b" in adapter.finished_requests
assert "req-b" in adapter.upstream_exhausted_requests
assert "req-b" in adapter.get_req_chunk
assert "ext-b" in adapter.put_req_chunk
assert "ext-b" in adapter.request_payload
@@ -761,13 +882,13 @@ def test_cleanup_after_poll_flow(build_adapter):
connector.get.return_value = (payload, 8)
adapter._poll_single_request(request)
assert "req-flow" in adapter.finished_requests
assert "req-flow" in adapter.upstream_exhausted_requests
assert adapter.get_req_chunk["req-flow"] == 1
assert "req-flow" in adapter.request_ids_mapping
adapter.cleanup("req-flow", "ext-flow")
assert "req-flow" not in adapter.finished_requests
assert "req-flow" not in adapter.upstream_exhausted_requests
assert "req-flow" not in adapter.get_req_chunk
assert "req-flow" not in adapter.request_ids_mapping
assert "ext-flow" not in adapter.request_payload
@@ -801,7 +922,7 @@ def test_finish_requests_removes_zombies_from_chunk_waiting_deques(build_adapter
adapter.waiting_for_chunk_waiting_requests = deque([zombie, other])
adapter.waiting_for_chunk_running_requests = deque([other, zombie])
adapter.requests_with_ready_chunks.add("req-zombie")
adapter.finished_requests.add("req-zombie")
adapter.upstream_exhausted_requests.add("req-zombie")
requests_map = {
"req-zombie": zombie,
"req-live": other,
@@ -816,7 +937,7 @@ def test_finish_requests_removes_zombies_from_chunk_waiting_deques(build_adapter
assert [req.request_id for req in adapter.waiting_for_chunk_waiting_requests] == ["req-live"]
assert [req.request_id for req in adapter.waiting_for_chunk_running_requests] == ["req-live"]
assert "req-zombie" not in adapter.requests_with_ready_chunks
assert "req-zombie" not in adapter.finished_requests
assert "req-zombie" not in adapter.upstream_exhausted_requests
def test_finish_requests_releases_active_stream_slot(build_adapter):
@@ -886,7 +1007,7 @@ def test_generation_scheduler_calls_cleanup_on_finished(monkeypatch, mocker: Moc
cleanup_calls = []
adapter_mock = mocker.MagicMock()
adapter_mock.finished_requests = {"req-s1"}
adapter_mock.upstream_exhausted_requests = {"req-s1"}
adapter_mock.cleanup = lambda *a, **kw: cleanup_calls.append((a, kw))
from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler
@@ -1122,7 +1243,7 @@ def test_wire_round_trip_struct_to_dict_contract():
def _build_deferred_finish_scheduler(mocker, *, running, pending_finish_reqs):
"""Build a mock scheduler with requests queued for deferred finish."""
adapter_mock = mocker.MagicMock()
adapter_mock.finished_requests = {r.request_id for r in pending_finish_reqs}
adapter_mock.upstream_exhausted_requests = {r.request_id for r in pending_finish_reqs}
cleanup_calls = []
adapter_mock.cleanup = lambda *a, **kw: cleanup_calls.append((a, kw))
@@ -0,0 +1,257 @@
"""Pure-TP KV receive consensus (#5627) — CPU-testable mechanism.
In pure TP (LOCAL role) each rank fetches its own KV shard independently and
``distribute_kv_cache`` is a no-op, so one rank's silent receive timeout used
to send only that rank into the pipeline's no-KV prefill — desyncing TP
collectives until the NCCL watchdog killed the worker. These tests cover the
identical-state verdict exchange: skip guards, both rank roles, KV dropping
on any cross-rank divergence (primary or CFG companion roles), that the
exchange runs on the TP group and never on ``world`` (which also holds DP/PP
ranks working on other requests), and the wiring from both distributed-receive
entry points.
"""
from types import SimpleNamespace
import pytest
from vllm_omni.diffusion.request import DUMMY_DIFFUSION_REQUEST_ID
from vllm_omni.distributed.omni_connectors.kv_transfer_manager import (
OmniKVCacheConfig,
OmniKVTransferManager,
ReceiveRole,
_TransferTopoConfig,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _FakeGroup:
"""Scripted stand-in for one rank's view of a GroupCoordinator."""
def __init__(self, rank_in_group, world_size, peer_signatures=None, verdict=None):
self.rank_in_group = rank_in_group
self.world_size = world_size
self._peer_signatures = list(peer_signatures or [])
self._verdict = verdict
self.sent = []
self.broadcasts = []
def recv_object(self, src):
return self._peer_signatures.pop(0)
def send_object(self, obj, dst):
self.sent.append((obj, dst))
def broadcast_object(self, obj=None, src=0):
self.broadcasts.append(obj)
return obj if obj is not None else self._verdict
@property
def touched(self):
return bool(self.sent or self.broadcasts)
def _topo(role=ReceiveRole.LOCAL, tp_active=True, tp_group=None, world=None):
tp_size = tp_group.world_size if tp_group is not None else 1
return _TransferTopoConfig(
role=role,
tp_active=tp_active,
cfg_size=1,
cfg_rank=0,
cfg_group=None,
sp_size=1,
sp_rank=0,
sp_group=None,
world=world,
tp_group=tp_group,
tp_size=tp_size,
)
def _manager(topo, need_recv_cache=True):
mgr = OmniKVTransferManager(OmniKVCacheConfig(need_recv_cache=need_recv_cache))
mgr._topo_config = topo
return mgr
def _req_with_kv(rid="r1", cfg_text=False):
kv = SimpleNamespace(key_cache=[], value_cache=[])
sp = SimpleNamespace(past_key_values=kv, kv_metadata={"ropes": [3]})
if cfg_text:
sp.cfg_text_past_key_values = SimpleNamespace(key_cache=[], value_cache=[])
return SimpleNamespace(
request_id=rid,
past_key_values=kv,
kv_metadata={"ropes": [3]},
sampling_params=sp,
)
def _signature_of(req, received=True):
return (received, tuple(sorted(OmniKVTransferManager._collect_request_kv_payload(req))))
# --------------------------------------------------------------------------- #
# Skip guards — no exchange may happen (a one-sided exchange would deadlock)
# --------------------------------------------------------------------------- #
def test_pass_through_outside_pure_tp():
group = _FakeGroup(0, 2, peer_signatures=[(False, ())])
for topo in (
_topo(role=ReceiveRole.LEADER, tp_active=True, tp_group=group),
_topo(role=ReceiveRole.LOCAL, tp_active=False, tp_group=group),
_topo(role=ReceiveRole.LOCAL, tp_active=True, tp_group=None),
):
mgr = _manager(topo)
assert mgr._tp_local_receive_consensus(_req_with_kv(), True) is True
assert not group.touched
def test_pass_through_single_rank_group_no_recv_config_and_dummy():
group = _FakeGroup(0, 1)
assert _manager(_topo(tp_group=group))._tp_local_receive_consensus(_req_with_kv(), True) is True
group2 = _FakeGroup(0, 2, peer_signatures=[(False, ())])
mgr = _manager(_topo(tp_group=group2), need_recv_cache=False)
assert mgr._tp_local_receive_consensus(_req_with_kv(), True) is True
mgr3 = _manager(_topo(tp_group=group2))
dummy = _req_with_kv(rid=DUMMY_DIFFUSION_REQUEST_ID)
assert mgr3._tp_local_receive_consensus(dummy, False) is False
assert not group2.touched
def test_world_group_is_never_used_for_the_exchange():
"""DP replicas / PP stages live in ``world`` and work on other requests —
the exchange must be scoped to the TP group (review #5636, P1)."""
tp_group = _FakeGroup(0, 2, peer_signatures=[(True, ())])
world = _FakeGroup(0, 4, peer_signatures=[(False, ()), (False, ()), (False, ())])
mgr = _manager(_topo(tp_group=tp_group, world=world))
req = SimpleNamespace(request_id="r1", sampling_params=SimpleNamespace())
assert mgr._tp_local_receive_consensus(req, True) is True
assert tp_group.touched
assert not world.touched
# --------------------------------------------------------------------------- #
# Rank 0: collects signatures, decides, broadcasts
# --------------------------------------------------------------------------- #
def test_rank0_drops_kv_when_peer_missed_primary():
req = _req_with_kv()
peer_sig = (False, ())
group = _FakeGroup(0, 2, peer_signatures=[peer_sig])
mgr = _manager(_topo(tp_group=group))
assert mgr._tp_local_receive_consensus(req, True) is False
assert group.broadcasts == [False] # peers were told to fall back too
assert req.past_key_values is None
assert req.kv_metadata is None
assert req.sampling_params.past_key_values is None
assert req.sampling_params.kv_metadata is None
def test_rank0_keeps_kv_when_all_ranks_match():
req = _req_with_kv()
group = _FakeGroup(0, 2, peer_signatures=[_signature_of(req)])
mgr = _manager(_topo(tp_group=group))
assert mgr._tp_local_receive_consensus(req, True) is True
assert group.broadcasts == [True]
assert req.past_key_values is not None
assert req.sampling_params.past_key_values is not None
def test_rank0_drops_kv_on_companion_role_divergence():
"""Primary received everywhere but cfg_text only landed on this rank —
divergent branch caches would desync TP collectives (review #5636, P1)."""
req = _req_with_kv(cfg_text=True)
peer_sig = _signature_of(_req_with_kv(cfg_text=False)) # peer lacks cfg_text
group = _FakeGroup(0, 2, peer_signatures=[peer_sig])
mgr = _manager(_topo(tp_group=group))
assert mgr._tp_local_receive_consensus(req, True) is False
assert group.broadcasts == [False]
assert req.past_key_values is None
assert req.sampling_params.cfg_text_past_key_values is None
def test_rank0_keeps_symmetric_partial_state():
"""cfg_text absent on every rank is consistent — Bagel's empty
unconditional branch — and must NOT be cleared or failed."""
req = _req_with_kv(cfg_text=False)
group = _FakeGroup(0, 2, peer_signatures=[_signature_of(req)])
mgr = _manager(_topo(tp_group=group))
assert mgr._tp_local_receive_consensus(req, True) is True
assert req.past_key_values is not None
def test_rank0_own_miss_fails_verdict_even_if_peers_received():
req = SimpleNamespace(request_id="r1", sampling_params=SimpleNamespace())
group = _FakeGroup(0, 3, peer_signatures=[(True, ("past_key_values",)), (True, ("past_key_values",))])
mgr = _manager(_topo(tp_group=group))
assert mgr._tp_local_receive_consensus(req, False) is False
assert group.broadcasts == [False]
# --------------------------------------------------------------------------- #
# Rank > 0: reports signature, honors verdict
# --------------------------------------------------------------------------- #
def test_follower_rank_drops_kv_on_negative_verdict():
req = _req_with_kv()
sig_before_clear = _signature_of(req)
group = _FakeGroup(1, 2, verdict=False)
mgr = _manager(_topo(tp_group=group))
assert mgr._tp_local_receive_consensus(req, True) is False
assert group.sent == [(sig_before_clear, 0)]
assert req.past_key_values is None
assert req.sampling_params.past_key_values is None
def test_follower_rank_keeps_kv_on_positive_verdict():
req = _req_with_kv()
group = _FakeGroup(1, 2, verdict=True)
mgr = _manager(_topo(tp_group=group))
assert mgr._tp_local_receive_consensus(req, True) is True
assert group.sent == [(_signature_of(req), 0)]
assert req.past_key_values is not None
# --------------------------------------------------------------------------- #
# Wiring — both distributed-receive entry points run the consensus
# --------------------------------------------------------------------------- #
def _wired_manager(monkeypatch, consensus_calls):
mgr = _manager(_topo(tp_group=_FakeGroup(0, 2)))
def fake_consensus(req, received):
consensus_calls.append(received)
return False
monkeypatch.setattr(mgr, "_tp_local_receive_consensus", fake_consensus)
monkeypatch.setattr(mgr, "receive_multi_kv_cache", lambda *a, **k: True)
return mgr
def test_receive_multi_kv_cache_distributed_runs_consensus(monkeypatch):
calls = []
mgr = _wired_manager(monkeypatch, calls)
assert mgr.receive_multi_kv_cache_distributed(_req_with_kv()) is False
assert calls == [True]
def test_consume_and_distribute_kv_cache_runs_consensus(monkeypatch):
calls = []
mgr = _wired_manager(monkeypatch, calls)
assert mgr.consume_and_distribute_kv_cache(_req_with_kv()) is False
assert calls == [True]
@@ -682,6 +682,10 @@ class TestDistributedReceive:
mgr = _make_manager(from_tp=2, to_tp=2, local_rank=1)
req = SimpleNamespace(request_id="req-1", sampling_params=SimpleNamespace())
world_group = _MockBroadcastGroup(world_size=2, rank_in_group=1)
# broadcast_value=True: rank 0's verdict says every TP rank has the
# same KV state, so the per-rank receive result stands (see #5627
# consensus). The exchange runs on the TP group, not world.
tp_group = _MockBroadcastGroup(world_size=2, rank_in_group=1, broadcast_value=True)
mgr.receive_multi_kv_cache = MagicMock(return_value=True)
with (
@@ -704,10 +708,19 @@ class TestDistributedReceive:
return_value=0,
),
patch("vllm_omni.diffusion.distributed.parallel_state.get_sp_group", return_value=None),
patch("vllm.distributed.parallel_state.get_tp_group", return_value=tp_group),
):
assert mgr.receive_multi_kv_cache_distributed(req, target_device=torch.device("cpu")) is True
mgr.receive_multi_kv_cache.assert_called_once_with(req, None, torch.device("cpu"))
# Pure TP still fetches per-rank (no leader→follower distribution),
# but each rank now reports its KV-state signature on the TP group
# for the identical-state verdict instead of silently diverging on a
# miss (#5627). World must stay untouched — it holds DP/PP ranks.
assert len(tp_group.send_calls) == 1
dst, sig = tp_group.send_calls[0]
assert dst == 0 and sig[0] is True
assert world_group.send_calls == []
# ── SP-only scenarios ────────────────────────────────────────────
+1 -1
View File
@@ -696,7 +696,7 @@ def _dreamzero_omni_server_from_params(params):
server_args = list(params.server_args or [])
if params.stage_config_path is not None:
server_args += ["--stage-configs-path", params.stage_config_path]
server_args += ["--deploy-config", params.stage_config_path]
return OmniServer(
params.model,
server_args,
+3 -3
View File
@@ -359,12 +359,12 @@ def _run_offline(deploy_config_path: str, output_path: Path) -> tuple[Image.Imag
return image, cot_text, elapsed
def _run_online(stage_configs_path: str, output_path: Path) -> tuple[Image.Image, str, float]:
def _run_online(deploy_config_path: str, output_path: Path) -> tuple[Image.Image, str, float]:
from benchmarks.accuracy.common import decode_base64_image, pil_to_png_bytes
server_args = [
"--stage-configs-path",
stage_configs_path,
"--deploy-config",
deploy_config_path,
"--stage-init-timeout",
"300",
"--init-timeout",
@@ -32,10 +32,28 @@ PROMPT = "A brown and white dog is running on the grass."
MEAN_THRESHOLD = 3e-2
P99_THRESHOLD = 3e-1
SSIM_THRESHOLD = 0.97
PSNR_THRESHOLD = 30.0
PSNR_THRESHOLD_CUDA = 30.0
PSNR_THRESHOLD_NPU = 26.0
def _psnr_threshold() -> float:
from vllm_omni.platforms import current_omni_platform
return PSNR_THRESHOLD_NPU if current_omni_platform.is_npu() else PSNR_THRESHOLD_CUDA
BASELINE_PATH = get_asset_path("hunyuan/hunyuan_baseline.png")
_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
def _baseline_path() -> Path:
from vllm_omni.platforms import current_omni_platform
suffix = "_npu" if current_omni_platform.is_npu() else ""
path = get_asset_path(f"hunyuan/hunyuan_baseline{suffix}.png")
assert path.exists(), f"Baseline image not found at {path}"
return path
_OFFLINE_SCRIPT = _REPO_ROOT / "examples" / "offline_inference" / "hunyuan_image3" / "end2end.py"
# DiT-only deploy config with trust_remote_code (based on hunyuan_image3_dit.yaml).
@@ -74,6 +92,23 @@ _DEPLOY_CONFIG = {
},
},
],
"platforms": {
"npu": {
"stages": [
{
"stage_id": 0,
"gpu_memory_utilization": 0.65,
"devices": "0,1,2,3",
"moe_backend": "auto",
"max_num_batched_tokens": 32768,
"parallel_config": {
"tensor_parallel_size": 4,
"enable_expert_parallel": True,
},
},
],
},
},
}
@@ -216,8 +251,8 @@ def _run_vllm_omni_hunyuan_image3_offline(*, model: str, deploy_config: str, out
def _assert_against_baseline(image: Image.Image, label: str) -> None:
assert BASELINE_PATH.exists(), f"Baseline image not found at {BASELINE_PATH}"
baseline_image = Image.open(BASELINE_PATH).convert("RGB")
baseline_path = _baseline_path()
baseline_image = Image.open(baseline_path).convert("RGB")
assert_images_pixel_close(
model_name=f"{MODEL_NAME} ({label} vs baseline)",
@@ -231,11 +266,11 @@ def _assert_against_baseline(image: Image.Image, label: str) -> None:
vllm_image=image,
diffusers_image=baseline_image,
ssim_threshold=SSIM_THRESHOLD,
psnr_threshold=PSNR_THRESHOLD,
psnr_threshold=_psnr_threshold(),
)
@hardware_test(res={"cuda": "H100"}, num_cards=4)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=4)
def test_hunyuan_image3_pixel_accuracy_online(accuracy_artifact_root: Path) -> None:
model = _model_name()
output_dir = model_output_dir(accuracy_artifact_root, MODEL_NAME)
@@ -249,7 +284,7 @@ def test_hunyuan_image3_pixel_accuracy_online(accuracy_artifact_root: Path) -> N
_assert_against_baseline(image, "online")
@hardware_test(res={"cuda": "H100"}, num_cards=4)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=4)
def test_hunyuan_image3_pixel_accuracy_offline(accuracy_artifact_root: Path) -> None:
model = _model_name()
output_dir = model_output_dir(accuracy_artifact_root, MODEL_NAME)
@@ -0,0 +1,115 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Regression tests for duplex ``runtime_control`` payload redaction.
The redacted result is emitted on the WebSocket (for example
``session.created.runtime_control``), so it must always survive
``json.dumps``. Control results carry typed values straight from the engine --
``fence`` and ``accepted_fence`` are ``DuplexFence`` instances added by
``DuplexControlClient.execute`` -- which previously reached
``WebSocket.send_json`` unconverted and killed the session at handshake.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from enum import Enum
import pytest
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
from vllm_omni.experimental.fullduplex.openai.runtime_bridge import NativeRuntimeBridgeMixin
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
_redact = NativeRuntimeBridgeMixin._redact_runtime_control_result
def _control_result() -> dict[str, object]:
"""The shape ``DuplexControlClient.execute`` returns for an open."""
return {
"ok": True,
"operation": "open",
"session_id": "sess-1",
"fence": DuplexFence("sess-1", epoch=0, turn_id=0, response_seq=0, incarnation=0),
"accepted_fence": DuplexFence("sess-1", epoch=1, turn_id=2, response_seq=3, incarnation=4),
"stage_results": [{"stage_id": -1, "replica_id": -1, "result": {"supported": True}}],
}
def test_redacted_control_result_is_json_serializable() -> None:
"""The whole payload must serialize the way send_json does."""
redacted = _redact(_control_result())
# Must not raise.
json.dumps({"type": "session.created", "runtime_control": redacted})
def test_fences_are_converted_to_plain_dicts() -> None:
redacted = _redact(_control_result())
assert redacted["fence"] == {
"session_id": "sess-1",
"epoch": 0,
"turn_id": 0,
"response_seq": 0,
"incarnation": 0,
}
# Field values are preserved, not just the type.
assert redacted["accepted_fence"]["epoch"] == 1
assert redacted["accepted_fence"]["turn_id"] == 2
assert redacted["accepted_fence"]["response_seq"] == 3
assert redacted["accepted_fence"]["incarnation"] == 4
def test_nested_dataclasses_are_converted() -> None:
"""Fences can appear inside stage results, not only at the top level."""
redacted = _redact(
{
"stage_results": [
{"stage_id": 0, "result": {"fence": DuplexFence("sess-2", epoch=7)}},
]
}
)
json.dumps(redacted)
assert redacted["stage_results"][0]["result"]["fence"]["epoch"] == 7
def test_enums_are_reduced_to_values() -> None:
class _Mode(str, Enum):
APPEND = "append_audio_chunk"
redacted = _redact({"mode": _Mode.APPEND})
json.dumps(redacted)
assert redacted["mode"] == "append_audio_chunk"
def test_redaction_still_drops_heavy_payload_keys() -> None:
"""The pre-existing contract must be preserved."""
redacted = _redact(
{
"ok": True,
"stage_handoff": object(),
"tts_handoff": object(),
"omni_payload": object(),
"tts_hidden_states": object(),
"tts_token_ids": object(),
"traceback": "long trace",
}
)
assert redacted == {"ok": True}
def test_plain_values_pass_through_unchanged() -> None:
payload = {"ok": True, "count": 3, "name": "x", "missing": None, "items": [1, "two"]}
assert _redact(payload) == payload
def test_dataclass_types_are_not_treated_as_instances() -> None:
"""A class object is not a dataclass instance and must not be asdict'd."""
@dataclass
class _Shape:
x: int = 1
assert _redact(_Shape) is _Shape
@@ -265,14 +265,26 @@ class DemoState:
indices[event_type] = index
return indices
def event_order_ok(self, *, require_input_commit: bool = True) -> bool:
def event_order_ok(
self,
*,
require_input_commit: bool = True,
require_commit_before_response: bool = True,
) -> bool:
if not self.events or self.events[0].get("type") != "session.created":
return False
first_commit_index = self.first_index("input_audio_buffer.committed")
first_response_index = self.first_index("response.created")
if first_response_index is None:
return False
if require_input_commit and (first_commit_index is None or first_commit_index > first_response_index):
if require_input_commit and first_commit_index is None:
return False
if (
require_input_commit
and require_commit_before_response
and first_commit_index is not None
and first_commit_index > first_response_index
):
return False
indices_by_type = self.first_response_lifecycle_indices()
if not indices_by_type:
@@ -1409,7 +1421,10 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
require_input_commit=not continuous_input,
)
if validation_mode == "model-policy"
else state.event_order_ok(require_input_commit=not continuous_input)
else state.event_order_ok(
require_input_commit=not continuous_input,
require_commit_before_response=False,
)
)
input_transcription_ok = _input_transcription_ok(
state.input_transcription_count,
@@ -114,7 +114,7 @@ def test_duplex_single_session_response_required(omni_server, model_prefix: str,
)
)
)
assert result["ok"] is True
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["error_count"] == 0
@@ -137,7 +137,7 @@ def test_duplex_two_sessions_resume_and_takeover(omni_server, model_prefix: str,
)
)
)
assert result["ok"] is True
assert result["ok"] is True, json.dumps(result, ensure_ascii=False, indent=2)
assert result["session_count"] == 2
assert result["resume"]["ok"] is True
assert result["takeover"]["ok"] is True
@@ -150,7 +150,16 @@ def test_text_to_audio_long_output_001(omni_server, openai_client) -> None:
request_config = {
"model": omni_server.model,
"messages": messages,
"modalities": ["text", "audio"],
"stream": True,
# Delimit the assistant answer with the TTS template so the Talker
# does not spend its codec-token budget speaking hidden reasoning.
"extra_body": {
"chat_template_kwargs": {
"use_tts_template": True,
"enable_thinking": False,
}
},
"key_words": {"audio": ["Beijing"]},
}
@@ -874,6 +874,39 @@ def test_realtime_duplex_demo_model_policy_accepts_continuous_input_without_comm
assert state.model_policy_event_order_ok(expected_turns=3, require_input_commit=False)
def test_realtime_duplex_demo_distinguishes_late_and_missing_commit():
demo = _load_demo_module()
state = demo.DemoState()
response_id = "resp-1"
item_id = f"item_{response_id}"
for event in (
{"type": "session.created"},
{"type": "response.created", "response_id": response_id},
{"type": "conversation.item.added", "item": {"id": item_id}},
{"type": "response.output_item.added", "response_id": response_id},
{"type": "response.content_part.added", "response_id": response_id},
{"type": "response.speak", "response_id": response_id},
{"type": "response.audio.delta", "response_id": response_id, "delta": "AAAA"},
{"type": "response.audio.done", "response_id": response_id},
{"type": "response.content_part.done", "response_id": response_id},
{"type": "response.output_item.done", "response_id": response_id},
{"type": "response.done", "response_id": response_id},
{"type": "input_audio_buffer.committed"},
):
state.add(event)
assert not state.event_order_ok(require_input_commit=True)
assert state.event_order_ok(
require_input_commit=True,
require_commit_before_response=False,
)
state.events.pop()
assert not state.event_order_ok(
require_input_commit=True,
require_commit_before_response=False,
)
def test_realtime_duplex_demo_accepts_overlap_unit_terminating_continuous_response():
demo = _load_demo_module()
state = demo.DemoState()
@@ -468,6 +468,10 @@ def test_one_word_prompt_001(omni_server, openai_client) -> None:
"messages": messages,
"stream": True,
"key_words": {"text": ["london"]},
# A one-word English clip is too short for whisper's language detection to
# be stable; unpinned it renders "London" as 런던 / ランデ / 梁敦 and the
# containment check then fails on correct audio.
"transcript_language": "en",
}
# Retry only when assert_omni_response fails on text/audio cosine similarity (see tests/helpers/assertions.py).
@@ -58,7 +58,6 @@ def _get_default_case(model: str):
]
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.diffusion
@pytest.mark.parametrize("omni_server", _get_default_case(MODEL), indirect=True)
@@ -7,7 +7,9 @@ from vllm.v1.engine import EngineCoreRequest
from vllm_omni.distributed.omni_coordinator import ReplicaInfo, ReplicaStatus
from vllm_omni.engine import OmniEngineCoreRequest
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine, StageRuntimeInfo
from vllm_omni.engine.serialization import deserialize_additional_information
from vllm_omni.engine.stage_pool import StagePool
from vllm_omni.model_executor.stage_input_processors.bagel import ExpandedPrompt
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -107,6 +109,34 @@ def test_build_add_request_message_preserves_model_intermediate_buffer(mocker: M
assert torch.equal(info["hidden_states"]["tts"], hidden)
def test_cfg_companion_suppresses_payload_but_forces_kv_transfer(mocker: MockerFixture):
engine = object.__new__(AsyncOmniEngine)
params = SamplingParams(max_tokens=8)
engine.prompt_expand_func = lambda *_args: [
ExpandedPrompt(
prompt={"prompt": "negative"},
role="cfg_text",
request_id_suffix="__cfg_text",
)
]
engine.supported_tasks = ("generate",)
engine.input_processor = mocker.Mock()
engine.input_processor.process_inputs.return_value = _make_engine_core_request("req__cfg_text")
engine.request_queue = mocker.Mock()
engine._enqueue_cfg_companions(
parent_id="req",
original_prompt={"prompt": "positive"},
stage0_params=params,
sampling_params_list=[params],
)
message = engine.request_queue.sync_q.put.call_args.args[0]
metadata = deserialize_additional_information(message.prompt.additional_information)
assert metadata["omni_final_stage_id"] == 0
assert metadata["omni_force_kv_transfer"] is True
def test_build_add_request_message_with_resumable_streaming(mocker: MockerFixture):
engine = object.__new__(AsyncOmniEngine)
params = SamplingParams(max_tokens=8)
@@ -1,3 +1,4 @@
import concurrent.futures
import contextlib
import importlib
import os
@@ -7,6 +8,7 @@ import types
import pytest
from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec
from vllm_omni.engine import async_omni_engine as async_omni_engine_module
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
from vllm_omni.engine.stage_init_utils import (
LogicalStageInitPlan,
@@ -19,6 +21,30 @@ from vllm_omni.engine.stage_runtime import StageRuntime
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_orchestrator_startup_timeout_warns_how_to_raise_limits(monkeypatch):
engine = object.__new__(AsyncOmniEngine)
engine.orchestrator_thread = types.SimpleNamespace(is_alive=lambda: True)
monkeypatch.setattr(engine, "_try_shutdown", lambda _message: None)
ticks = iter((0.0, 1.0))
monkeypatch.setattr(async_omni_engine_module.time, "monotonic", lambda: next(ticks))
warnings: list[tuple[object, ...]] = []
monkeypatch.setattr(
async_omni_engine_module.logger,
"warning",
lambda *args: warnings.append(args),
)
with pytest.raises(TimeoutError, match="did not become ready within 1s"):
engine._wait_for_orchestrator_init(concurrent.futures.Future(), startup_timeout=1)
assert len(warnings) == 1
message = str(warnings[0][0])
assert "--init-timeout" in message
assert "--stage-init-timeout" in message
def _make_llm_metadata(
stage_id: int,
*,
@@ -146,6 +172,41 @@ def test_stage_engine_core_client_module_reload_keeps_forward_refs_deferred():
)
def test_async_omni_engine_initialize_stages_passes_log_stats_to_runtime(monkeypatch):
import vllm_omni.engine.async_omni_engine as engine_mod
engine = object.__new__(AsyncOmniEngine)
engine.stage_configs = [types.SimpleNamespace()]
engine.model = "dummy-model"
engine.config_path = "dummy-config"
engine.single_stage_mode = False
engine.diffusion_batch_size = 1
engine.async_chunk = False
engine.tokenizer = None
engine._single_stage_id_filter = None
engine._omni_master_address = None
engine._omni_master_port = None
engine._omni_dp_size_local = 1
engine._omni_heartbeat_timeout = 30.0
engine._omni_lb_policy = "random"
engine.request_queue = types.SimpleNamespace()
engine._log_stats = True
captured: dict[str, object] = {}
runtime = types.SimpleNamespace(stage_pools=[], initialize=lambda: None)
def _capture_create_stage_runtime(**kwargs):
captured.update(kwargs)
return runtime
monkeypatch.setattr(engine_mod, "create_stage_runtime", _capture_create_stage_runtime)
engine._initialize_stages(stage_init_timeout=7)
assert captured["stage_init_timeout"] == 7
assert captured["log_stats"] is True
def test_compute_replica_layout_splits_diffusion_devices_by_world_size():
stage_cfg = types.SimpleNamespace(
stage_id=0,
@@ -316,6 +377,95 @@ def test_stage_runtime_initializes_stage_pools(monkeypatch):
assert runtime.stage_pools[1].output_processor is stage1_output_processor
def test_stage_runtime_passes_log_stats_to_llm_replica_launch(monkeypatch):
import vllm_omni.engine.stage_runtime as runtime_mod
runtime = StageRuntime(
stage_configs=[types.SimpleNamespace()],
model="dummy-model",
config_path="dummy-config",
stage_init_timeout=1,
diffusion_batch_size=1,
async_chunk=False,
log_stats=True,
)
cfg = types.SimpleNamespace(model_config=types.SimpleNamespace(max_model_len=64))
plan = _make_llm_plan(0, stage_id=0, vllm_config=cfg).replicas[0]
plan.engine_args_dict = {}
captured: dict[str, object] = {}
addresses = types.SimpleNamespace(
inputs=["tcp://127.0.0.1:1"],
outputs=["tcp://127.0.0.1:2"],
frontend_stats_publish_address=None,
)
resources = types.SimpleNamespace(manager=object(), coordinator=None, addresses=addresses)
stage_client = types.SimpleNamespace()
@contextlib.contextmanager
def _capture_launch_stage_replica(**kwargs):
captured["log_stats"] = kwargs["log_stats"]
yield resources
monkeypatch.setattr(runtime_mod, "acquire_device_locks", lambda *_args, **_kwargs: [])
monkeypatch.setattr(runtime_mod, "launch_stage_replica", _capture_launch_stage_replica)
monkeypatch.setattr(
runtime_mod.StageEngineCoreClientBase,
"make_async_mp_client",
lambda **kwargs: (captured.__setitem__("client_log_stats", kwargs["log_stats"]) or stage_client),
)
assert runtime._initialize_local_llm_replica(plan, stage_init_timeout=1) is stage_client
assert captured["log_stats"] is True
assert captured["client_log_stats"] is True
def test_stage_runtime_passes_log_stats_to_output_processor(monkeypatch):
import vllm_omni.engine.stage_runtime as runtime_mod
runtime = StageRuntime(
stage_configs=[types.SimpleNamespace()],
model="dummy-model",
config_path="dummy-config",
stage_init_timeout=1,
diffusion_batch_size=1,
async_chunk=False,
log_stats=True,
)
cfg = types.SimpleNamespace(model_config=types.SimpleNamespace(max_model_len=64))
stage_plan = _make_llm_plan(0, stage_id=0, vllm_config=cfg)
stage_client = types.SimpleNamespace(
stage_type="llm",
is_comprehension=False,
final_output=True,
final_output_type=None,
default_sampling_params=types.SimpleNamespace(),
)
captured: dict[str, object] = {}
output_processor = object()
def _capture_build_llm_stage_output_processor(plan, stage_vllm_config, *, log_stats=False):
captured["plan"] = plan
captured["stage_vllm_config"] = stage_vllm_config
captured["log_stats"] = log_stats
return output_processor
monkeypatch.setattr(
runtime_mod,
"build_llm_stage_output_processor",
_capture_build_llm_stage_output_processor,
)
pools = runtime._assemble_stage_pools([stage_plan], {0: [stage_client]})
assert pools[0].output_processor is output_processor
assert captured == {
"plan": stage_plan,
"stage_vllm_config": cfg,
"log_stats": True,
}
def test_build_logical_stage_init_plans_applies_replica_device_splits(monkeypatch):
import vllm_omni.engine.stage_runtime as runtime_mod
@@ -511,10 +661,11 @@ def test_initialize_local_llm_replica_passes_stage_init_timeout_to_complete_stag
stage_configs=[],
model="dummy-model",
config_path="dummy-config",
stage_init_timeout=302,
stage_init_timeout=1,
diffusion_batch_size=1,
async_chunk=False,
)
stage_init_timeout = 302
fake_vllm_config = types.SimpleNamespace()
fake_addresses = types.SimpleNamespace(inputs=["in"], outputs=["out"], frontend_stats_publish_address=None)
@@ -561,14 +712,14 @@ def test_initialize_local_llm_replica_passes_stage_init_timeout_to_complete_stag
)
try:
runtime._initialize_local_llm_replica(plan, 302)
runtime._initialize_local_llm_replica(plan, stage_init_timeout)
finally:
if prev_device_env is None:
os.environ.pop(device_env_var, None)
else:
os.environ[device_env_var] = prev_device_env
assert captured_timeout == 302
assert captured_timeout == stage_init_timeout
def test_build_engine_args_cli_tokenizer_overrides_inferred_base_tokenizer(tmp_path):
@@ -0,0 +1,334 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Deterministic tests for the CFG-companion output lifecycle.
Regression suite for the companion-output race behind the nightly Bagel
failures: ``kv_ready`` used to mark a companion done before its processed
output was stashed, letting the parent dispatch to the diffusion stage with
0/N companion outputs (degraded CFG conditioning, and after an abort a TP
rank desync). The invariant under test: **dispatch to the diffusion stage
never happens with an incomplete companion bundle** — the parent is
re-deferred while outputs are pending and failed when the bundle can no
longer complete.
"""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
import pytest
from vllm_omni.engine.cfg_companion_tracker import CfgCompanionTracker
from vllm_omni.engine.messages import AbortRequestMessage, ErrorMessage
from vllm_omni.engine.orchestrator import Orchestrator
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
# ───────── tracker-level ordering semantics ─────────
def test_companion_first_then_parent_is_not_deferred():
tracker = CfgCompanionTracker()
tracker.register_companion("p", "neg", "p__neg")
tracker.set_companion_output("p__neg", {"o": 1})
assert tracker.on_companion_completed("p__neg") is None # parent not deferred yet
# Parent arrives afterwards: bundle already complete, no defer needed.
assert tracker.all_companions_done("p")
assert tracker.get_companion_outputs("p") == [{"o": 1}]
def test_parent_first_deferred_then_companion_flushes():
tracker = CfgCompanionTracker()
tracker.register_companion("p", "neg", "p__neg")
tracker.defer_parent("p", {"parent": True}, stage_id=0)
tracker.set_companion_output("p__neg", {"o": 1})
assert tracker.on_companion_completed("p__neg") == "p"
deferred = tracker.pop_pending_parent("p")
assert deferred is not None and deferred["engine_outputs"] == {"parent": True}
def test_duplicate_companion_completion_is_idempotent():
tracker = CfgCompanionTracker()
tracker.register_companion("p", "neg", "p__neg")
tracker.defer_parent("p", {}, stage_id=0)
tracker.set_companion_output("p__neg", {"o": 1})
assert tracker.on_companion_completed("p__neg") == "p"
assert tracker.on_companion_completed("p__neg") is None # duplicate: no re-flush
assert tracker.get_companion_outputs("p") == [{"o": 1}]
def test_bundle_survives_resubmission_until_cleanup():
"""get_companion_outputs is non-destructive: a streaming re-submission of
the parent bundles the complete set again (never a partial/empty one).
Only cleanup_parent releases the outputs."""
tracker = CfgCompanionTracker()
tracker.register_companion("p", "neg", "p__neg")
tracker.set_companion_output("p__neg", {"o": 1})
assert tracker.get_companion_outputs("p") == [{"o": 1}]
assert tracker.get_companion_outputs("p") == [{"o": 1}] # re-submit: full bundle again
tracker.cleanup_parent("p")
assert tracker.get_companion_outputs("p") == []
# ───────── orchestrator-method-level lifecycle ─────────
class _FakePool:
"""Minimal stage pool satisfying _forward_to_next_stage's preamble and cleanup."""
def __init__(self, stage_type: str = "diffusion") -> None:
self.stage_type = stage_type
self.stage_client = SimpleNamespace(requires_multimodal_data=False, custom_process_input_func=None)
self.aborted: list[list[str]] = []
async def abort_requests(self, request_ids):
self.aborted.append(list(request_ids))
def release_bindings(self, request_ids):
pass
def _make_orchestrator(num_stages: int = 2) -> Orchestrator:
orch = Orchestrator.__new__(Orchestrator)
orch._cfg_tracker = CfgCompanionTracker()
orch.request_states = {}
orch.stage_pools = [_FakePool("llm"), _FakePool("diffusion")][:num_stages]
orch.output_async_queue = asyncio.Queue()
orch.async_chunk = False
orch.duplex_control_plane = None
orch._pd_kv_params = {}
orch._pd_pair = None
orch._running_counter = None
orch._transfer_emitter = None
return orch
def _req_state(final_stage_id: int = 1) -> SimpleNamespace:
return SimpleNamespace(
final_stage_id=final_stage_id,
stage_submit_ts={},
sampling_params_list=[SimpleNamespace(output_kind=None)] * (final_stage_id + 1),
streaming=SimpleNamespace(enabled=False, segment_finished=False),
running_counter_registered=False,
prompt={"prompt": "x"},
pipeline_timings={},
)
@pytest.mark.asyncio
async def test_kv_ready_does_not_mark_companion_done():
"""kv_ready alone must not count a companion as done — only the processed
output (set_companion_output) may. Guards the original race."""
orch = _make_orchestrator()
orch._cfg_tracker.register_companion("p", "neg", "p__neg")
orch.request_states["p__neg"] = _req_state()
raw = SimpleNamespace(request_id="p__neg", kv_transfer_params={"kv_ready": True})
await orch._handle_kv_ready_raw_outputs(0, SimpleNamespace(outputs=[raw]))
assert not orch._cfg_tracker.all_companions_done("p")
assert orch._cfg_tracker.pop_pending_parent("p") is None
@pytest.mark.asyncio
async def test_parent_kv_ready_defers_until_companion_outputs_stashed():
"""Parent kv_ready with an incomplete bundle defers; the companion's
processed output later flushes it (companion-first vs parent-first order
both funnel through the same defer/flush path)."""
orch = _make_orchestrator()
orch._cfg_tracker.register_companion("p", "neg", "p__neg")
orch.request_states["p"] = _req_state()
raw = SimpleNamespace(request_id="p", kv_transfer_params={"kv_ready": True})
await orch._handle_kv_ready_raw_outputs(0, SimpleNamespace(outputs=[raw]))
deferred = orch._cfg_tracker.pop_pending_parent("p")
assert deferred is not None and deferred["stage_id"] == 0
@pytest.mark.asyncio
async def test_forward_re_defers_when_bundle_incomplete():
"""The forward gate re-defers (non-destructively) instead of dispatching
with 0/N companion outputs."""
orch = _make_orchestrator()
orch._cfg_tracker.register_companion("p", "neg", "p__neg")
orch._cfg_tracker.set_companion_output("p__neg", {"o": 1}) # stashed but NOT done-marked
state = _req_state()
orch.request_states["p"] = state
await orch._forward_to_next_stage("p", 0, {"parent": True}, state)
# Not dispatched: re-deferred with the stashed output left intact.
deferred = orch._cfg_tracker.pop_pending_parent("p")
assert deferred is not None
assert orch._cfg_tracker.get_companion_outputs("p") == [{"o": 1}]
assert orch.output_async_queue.empty()
@pytest.mark.asyncio
async def test_forward_fails_request_when_bundle_lost():
"""Companions done but outputs gone without a prior bundle: the request is
failed (terminal ErrorMessage + abort) instead of dispatching degraded CFG."""
orch = _make_orchestrator()
orch._cfg_tracker.register_companion("p", "neg", "p__neg")
orch._cfg_tracker.set_companion_output("p__neg", {"o": 1})
orch._cfg_tracker.on_companion_completed("p__neg")
orch._cfg_tracker._companion_outputs.pop("p__neg") # simulate lost output (unreachable normally)
state = _req_state()
orch.request_states["p"] = state
await orch._forward_to_next_stage("p", 0, {"parent": True}, state)
msg = orch.output_async_queue.get_nowait()
assert isinstance(msg, ErrorMessage)
assert msg.request_id == "p"
assert "incomplete" in msg.error
assert "p" not in orch.request_states
assert any("p" in batch for pool in orch.stage_pools for batch in pool.aborted)
@pytest.mark.asyncio
async def test_companion_abort_fails_deferred_parent():
"""A companion aborted directly (parent not in the batch) can never
complete the bundle — the deferred parent is failed immediately."""
orch = _make_orchestrator()
orch._cfg_tracker.register_companion("p", "neg", "p__neg")
orch._cfg_tracker.defer_parent("p", {"parent": True}, stage_id=0)
orch.request_states["p"] = _req_state()
await orch._handle_abort(AbortRequestMessage(request_ids=["p__neg"]))
msg = orch.output_async_queue.get_nowait()
assert isinstance(msg, ErrorMessage)
assert msg.request_id == "p"
assert "p__neg" in msg.error
assert "p" not in orch.request_states
assert not orch._cfg_tracker.is_companion("p__neg")
# Idempotent: a repeated abort of the same companion is a no-op.
await orch._handle_abort(AbortRequestMessage(request_ids=["p__neg"]))
assert orch.output_async_queue.empty()
@pytest.mark.asyncio
async def test_companion_abort_after_completion_does_not_fail_parent():
"""A late/duplicate abort of a companion whose output is already stashed
must not cancel the (possibly already dispatched) parent."""
orch = _make_orchestrator()
orch._cfg_tracker.register_companion("p", "neg", "p__neg")
orch._cfg_tracker.set_companion_output("p__neg", {"o": 1})
orch._cfg_tracker.on_companion_completed("p__neg")
orch.request_states["p"] = _req_state()
await orch._handle_abort(AbortRequestMessage(request_ids=["p__neg"]))
assert orch.output_async_queue.empty() # no parent failure emitted
assert "p" in orch.request_states # parent untouched
assert orch._cfg_tracker.get_companion_outputs("p") == [{"o": 1}] # bundle intact
class _CapturedDispatchError(Exception):
"""Sentinel raised by the capture hook to stop the forward after bundling."""
@pytest.mark.asyncio
async def test_repeated_forward_bundles_full_set_again():
"""A second forward of the same parent (streaming terminal update) must
dispatch the complete [parent, *companions] bundle again — never [parent]
alone. Captured at the diffusion input hook, i.e. actual dispatch content."""
orch = _make_orchestrator()
orch._cfg_tracker.register_companion("p", "neg", "p__neg")
orch._cfg_tracker.set_companion_output("p__neg", {"o": 1})
orch._cfg_tracker.on_companion_completed("p__neg")
state = _req_state()
orch.request_states["p"] = state
captured: list[list] = []
def _capture(source_outputs, prompt, requires_multimodal_data):
captured.append(list(source_outputs))
raise _CapturedDispatchError
orch.stage_pools[1].stage_client.custom_process_input_func = _capture
for _ in range(2):
with pytest.raises(_CapturedDispatchError):
await orch._forward_to_next_stage("p", 0, {"parent": True}, state)
assert captured == [[{"parent": True}, {"o": 1}], [{"parent": True}, {"o": 1}]]
@pytest.mark.asyncio
async def test_replica_loss_cleanup_releases_parent_tracker_state():
"""Replica-loss/membership teardown calls _cleanup_request_ids directly
(no cleanup_parent at the call site): the parent's tracker state must be
released and its companions folded into the same cleanup batch."""
orch = _make_orchestrator()
orch._cfg_tracker.register_companion("p", "neg", "p__neg")
orch._cfg_tracker.set_companion_output("p__neg", {"o": 1})
orch._cfg_tracker.defer_parent("p", {"parent": True}, stage_id=0)
orch.request_states["p"] = _req_state()
await orch._cleanup_request_ids(["p"], abort=True)
assert not orch._cfg_tracker.has_companions("p")
assert not orch._cfg_tracker.is_companion("p__neg")
assert orch._cfg_tracker.get_companion_outputs("p") == []
assert orch._cfg_tracker.pop_pending_parent("p") is None
aborted = {rid for pool in orch.stage_pools for b in pool.aborted for rid in b}
assert {"p", "p__neg"} <= aborted
@pytest.mark.asyncio
async def test_replica_loss_of_companion_fails_deferred_parent():
"""A companion torn down by replica loss before its output arrived means
the parent can never complete its bundle — the cleanup fails the parent."""
orch = _make_orchestrator()
orch._cfg_tracker.register_companion("p", "neg", "p__neg")
orch._cfg_tracker.defer_parent("p", {"parent": True}, stage_id=0)
orch.request_states["p"] = _req_state()
await orch._cleanup_request_ids(["p__neg"])
msg = orch.output_async_queue.get_nowait()
assert isinstance(msg, ErrorMessage)
assert msg.request_id == "p"
assert "p__neg" in msg.error
assert "p" not in orch.request_states
assert not orch._cfg_tracker.has_companions("p")
@pytest.mark.asyncio
async def test_finished_companion_cleanup_preserves_parent_bundle():
"""The normal companion-finished cleanup (_route_output cleans just the
companion id) must not disturb the parent's stashed bundle."""
orch = _make_orchestrator()
orch._cfg_tracker.register_companion("p", "neg", "p__neg")
orch._cfg_tracker.set_companion_output("p__neg", {"o": 1})
orch._cfg_tracker.on_companion_completed("p__neg")
orch.request_states["p"] = _req_state()
await orch._cleanup_request_ids(["p__neg"])
assert orch.output_async_queue.empty()
assert "p" in orch.request_states
assert orch._cfg_tracker.get_companion_outputs("p") == [{"o": 1}]
@pytest.mark.asyncio
async def test_parent_abort_still_expands_to_companions():
"""Regression-guard the existing behavior: aborting the parent aborts its
companions and clears tracker state, without emitting an error."""
orch = _make_orchestrator()
orch._cfg_tracker.register_companion("p", "neg", "p__neg")
orch._cfg_tracker.defer_parent("p", {"parent": True}, stage_id=0)
orch.request_states["p"] = _req_state()
await orch._handle_abort(AbortRequestMessage(request_ids=["p"]))
assert orch.output_async_queue.empty()
assert "p" not in orch.request_states
assert not orch._cfg_tracker.is_companion("p__neg")
aborted = {rid for pool in orch.stage_pools for batch in pool.aborted for rid in batch}
assert {"p", "p__neg"} <= aborted
+171 -32
View File
@@ -15,6 +15,7 @@ import pytest
from vllm.outputs import CompletionOutput, RequestOutput
from vllm.sampling_params import SamplingParams
from vllm.v1.engine.exceptions import EngineDeadError
from vllm.v1.metrics.stats import IterationStats
from vllm_omni.engine.messages import (
AbortRequestMessage,
@@ -145,7 +146,7 @@ class FakeStageClient:
try:
return self._engine_core_outputs.get_nowait()
except queue.Empty:
return SimpleNamespace(outputs=[])
return SimpleNamespace(outputs=[], scheduler_stats=None, finished_requests=None)
def get_diffusion_output_nowait(self):
try:
@@ -253,12 +254,40 @@ class FakeOutputProcessor:
return None
class IterationStatsOutputProcessor(FakeOutputProcessor):
def process_outputs(self, *_args, **kwargs):
iteration_stats = kwargs.get("iteration_stats")
if iteration_stats is None and len(_args) >= 3:
iteration_stats = _args[2]
assert iteration_stats is not None
iteration_stats.num_generation_tokens += 3
return super().process_outputs(*_args, **kwargs)
class RecordingOutputProcessor(FakeOutputProcessor):
def __init__(self, *, request_outputs: list[object] | None = None) -> None:
super().__init__(request_outputs=request_outputs)
self.process_calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
def process_outputs(self, *args, **kwargs):
self.process_calls.append((args, kwargs))
return super().process_outputs(*args, **kwargs)
class RecordingStatLogger:
def __init__(self) -> None:
self.records: list[tuple[Any, Any, int]] = []
def record(self, scheduler_stats, iteration_stats, *, engine_idx: int = 0) -> None:
self.records.append((scheduler_stats, iteration_stats, engine_idx))
def _sampling_params(max_tokens: int = 4) -> SamplingParams:
return SamplingParams(max_tokens=max_tokens)
def _engine_core_outputs(tag: str, timestamp: float) -> SimpleNamespace:
return SimpleNamespace(outputs=[tag], timestamp=timestamp, scheduler_stats=None)
return SimpleNamespace(outputs=[tag], timestamp=timestamp, scheduler_stats=None, finished_requests=None)
def _build_request_output(
@@ -329,6 +358,7 @@ def _build_harness(
output_processors: list[object] | None = None,
stage_vllm_configs: list[object] | None = None,
async_chunk: bool = False,
log_stats: bool = False,
stage_pools: list[StagePool] | None = None,
) -> OrchestratorFixture:
"""Build an Orchestrator test harness.
@@ -364,6 +394,7 @@ def _build_harness(
rpc_async_queue=rpc_queue.async_q,
stage_pools=stage_pools,
async_chunk=async_chunk,
log_stats=log_stats,
)
ready_future.set_result((orchestrator, request_queue, output_queue, rpc_queue))
await orchestrator.run()
@@ -2137,38 +2168,143 @@ async def test_multi_replica_cfg_companion_inherits_parent_affinity(orchestrator
await _shutdown_orchestrator(orchestrator_fixture)
def test_orchestrator_does_not_re_introduce_global_stats_throttle() -> None:
"""Regression: each (stage, replica) must independently publish its wrapped
vllm:* stats when its scheduler emits non-None scheduler_stats.
A previous version of Orchestrator carried a global self._last_stats_ts /
_stats_interval_s gate around _stat_logger.record(). Because
OmniSchedulerMixin.make_stats() already throttles at 1 Hz per scheduler
(one per (stage, replica)), the extra global gate starved every replica
other than the first to emit within each second — their {stage, replica}
gauges/counters went stale.
The fix removed the global gate entirely; the only signal needed is
'this replica's scheduler emitted non-None scheduler_stats'. This test
fails loudly if someone reintroduces the global throttle.
"""
import inspect
from vllm_omni.engine.orchestrator import Orchestrator
source = inspect.getsource(Orchestrator)
assert "_last_stats_ts" not in source, (
"Orchestrator must not gate stat recording on a global timestamp. "
"OmniSchedulerMixin.make_stats() already throttles per scheduler "
"(per (stage, replica)); an outer global gate starves all but the "
"first replica to emit within each 1s window."
@pytest.mark.asyncio
async def test_orchestrator_records_iteration_stats_without_scheduler_stats(orchestrator_factory) -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=True)
processor = IterationStatsOutputProcessor(
request_outputs=[_build_request_output("req-stats", token_ids=[1], finished=True)]
)
assert "_stats_interval_s" not in source
assert "raw_outputs.scheduler_stats is not None" in source, (
"Orchestrator must gate stat recording solely on "
"raw_outputs.scheduler_stats being non-None — the per-scheduler 1Hz "
"throttle in OmniSchedulerMixin.make_stats() is the only gate needed."
orchestrator_fixture = orchestrator_factory([stage0], output_processors=[processor], log_stats=True)
stat_logger = RecordingStatLogger()
orchestrator_fixture.orchestrator._stat_logger = stat_logger
request = SimpleNamespace(request_id="req-stats", prompt_token_ids=[1, 2])
try:
await _enqueue_add_request(
orchestrator_fixture,
request_id="req-stats",
prompt=request,
original_prompt={"prompt": "hello"},
sampling_params_list=[_sampling_params()],
final_stage_id=0,
)
await _wait_for(lambda: len(stage0.add_request_calls) == 1)
stage0.push_engine_core_outputs(_engine_core_outputs("raw-without-scheduler-stats", 1.0))
await _wait_for(lambda: len(stat_logger.records) == 1)
scheduler_stats, iteration_stats, engine_idx = stat_logger.records[0]
assert scheduler_stats is None
assert iteration_stats is not None
assert iteration_stats.num_generation_tokens == 3
assert engine_idx == 0
finally:
await _shutdown_orchestrator(orchestrator_fixture)
@pytest.mark.asyncio
async def test_orchestrator_records_scheduler_stats_without_outputs(orchestrator_factory) -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=True)
processor = RecordingOutputProcessor()
orchestrator_fixture = orchestrator_factory([stage0], output_processors=[processor], log_stats=True)
stat_logger = RecordingStatLogger()
orchestrator_fixture.orchestrator._stat_logger = stat_logger
scheduler_stats = SimpleNamespace(num_running_reqs=0, kv_cache_usage=0.5)
try:
stage0.push_engine_core_outputs(
SimpleNamespace(
outputs=[],
timestamp=1.0,
scheduler_stats=scheduler_stats,
finished_requests=None,
)
)
await _wait_for(lambda: len(stat_logger.records) == 1)
recorded_scheduler_stats, iteration_stats, engine_idx = stat_logger.records[0]
assert recorded_scheduler_stats is scheduler_stats
assert iteration_stats is None
assert engine_idx == 0
assert processor.process_calls[0][0][2] is None
finally:
await _shutdown_orchestrator(orchestrator_fixture)
@pytest.mark.asyncio
async def test_orchestrator_does_not_build_iteration_stats_for_finished_only_batch(orchestrator_factory) -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=True)
processor = RecordingOutputProcessor()
orchestrator_fixture = orchestrator_factory([stage0], output_processors=[processor], log_stats=True)
stat_logger = RecordingStatLogger()
orchestrator_fixture.orchestrator._stat_logger = stat_logger
try:
stage0.push_engine_core_outputs(
SimpleNamespace(
outputs=[],
timestamp=1.0,
scheduler_stats=None,
finished_requests={"req-finished"},
)
)
await _wait_for(lambda: bool(processor.process_calls))
assert processor.process_calls[0][0][2] is None
assert stat_logger.records == []
finally:
await _shutdown_orchestrator(orchestrator_fixture)
@pytest.mark.asyncio
async def test_orchestrator_does_not_build_iteration_stats_without_stat_logger(orchestrator_factory) -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=True)
processor = RecordingOutputProcessor()
orchestrator_fixture = orchestrator_factory([stage0], output_processors=[processor], log_stats=True)
orchestrator_fixture.orchestrator._stat_logger = None
try:
stage0.push_engine_core_outputs(_engine_core_outputs("raw-output", 1.0))
await _wait_for(lambda: bool(processor.process_calls))
assert processor.process_calls[0][0][2] is None
finally:
await _shutdown_orchestrator(orchestrator_fixture)
@pytest.mark.asyncio
async def test_stage_pool_preserves_none_iteration_stats() -> None:
client = FakeStageClient(stage_type="llm", final_output=True)
processor = RecordingOutputProcessor()
pool = StagePool(
0,
[client],
output_processor=processor,
stage_vllm_config=SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)),
)
raw_outputs = SimpleNamespace(outputs=["raw"], timestamp=1.0, scheduler_stats=None)
await pool.process_llm_raw_outputs(0, raw_outputs, iteration_stats=None)
assert processor.process_calls[0][0][2] is None
@pytest.mark.asyncio
async def test_stage_pool_process_llm_raw_outputs_mutates_iteration_stats() -> None:
client = FakeStageClient(stage_type="llm", final_output=True)
processor = IterationStatsOutputProcessor()
pool = StagePool(
0,
[client],
output_processor=processor,
stage_vllm_config=SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)),
)
raw_outputs = SimpleNamespace(outputs=["raw"], timestamp=1.0, scheduler_stats=None)
iteration_stats = IterationStats()
await pool.process_llm_raw_outputs(0, raw_outputs, iteration_stats=iteration_stats)
assert iteration_stats.num_generation_tokens == 3
@pytest.mark.asyncio
@@ -2282,12 +2418,15 @@ async def test_request_cleanup_failure_is_deferred_to_control_plane():
def finalize_closed_sessions(self, session_ids: list[str]) -> None:
self.finalized.extend(session_ids)
from vllm_omni.engine.cfg_companion_tracker import CfgCompanionTracker
orchestrator = object.__new__(Orchestrator)
orchestrator.stage_pools = [_FailingPool()]
orchestrator.duplex_control_plane = _Plane()
orchestrator._pd_kv_params = {}
orchestrator.request_states = {}
orchestrator._running_counter = None
orchestrator._cfg_tracker = CfgCompanionTracker()
with pytest.raises(RuntimeError, match="stage abort failed"):
await orchestrator._cleanup_request_ids(
+48 -1
View File
@@ -10,9 +10,11 @@ import pytest
import torch
from vllm.outputs import PoolingRequestOutput
from vllm.sampling_params import RequestOutputKind
from vllm.v1.engine import FinishReason
from vllm.v1.engine import EngineCoreEvent, EngineCoreEventType, FinishReason
from vllm.v1.engine.output_processor import OutputProcessor as VLLMOutputProcessor
from vllm.v1.metrics.stats import IterationStats, PrefillStats
from vllm_omni.engine import OmniEngineCoreOutput
from vllm_omni.outputs import output_processor
from vllm_omni.outputs.output_modality import OutputModality, OutputModalityNames
from vllm_omni.outputs.output_processor import MultimodalOutputProcessor, OmniRequestState
@@ -616,3 +618,48 @@ def test_no_detokenizer_final_only():
result = s.make_request_output([], None, FinishReason.STOP, None)
assert result is not None
assert AUDIO in result.outputs[0].multimodal_output
def test_mm_only_outputs_update_iteration_stats():
processor = MultimodalOutputProcessor(
tokenizer=None,
log_stats=True,
engine_core_output_type=AUDIO,
)
state = _make_no_detok_state(RequestOutputKind.CUMULATIVE)
processor.request_states[state.request_id] = state
processor.external_req_ids[state.external_req_id].append(state.request_id)
prefill_stats = PrefillStats()
prefill_stats.set(
num_prompt_tokens=4,
num_local_cached_tokens=0,
num_external_cached_tokens=0,
)
output = OmniEngineCoreOutput(
request_id=state.request_id,
new_token_ids=[10, 11],
finish_reason=FinishReason.STOP,
events=[
EngineCoreEvent(EngineCoreEventType.QUEUED, 1.0),
EngineCoreEvent(EngineCoreEventType.SCHEDULED, 2.0),
],
prefill_stats=prefill_stats,
multimodal_output={AUDIO: torch.ones(1, 8)},
)
iteration_stats = IterationStats()
processor.process_outputs(
[output],
engine_core_timestamp=3.0,
iteration_stats=iteration_stats,
)
assert iteration_stats.num_prompt_tokens == 4
assert iteration_stats.num_generation_tokens == 2
assert len(iteration_stats.time_to_first_tokens_iter) == 1
assert len(iteration_stats.finished_requests) == 1
finished = iteration_stats.finished_requests[0]
assert finished.finish_reason == FinishReason.STOP
assert finished.num_prompt_tokens == state.prompt_len
assert finished.num_generation_tokens == 2
+94 -1
View File
@@ -12,6 +12,7 @@ import pytest
from pytest_mock import MockerFixture
from vllm.v1.engine.utils import EngineZmqAddresses
from vllm_omni.config.config_factory import StageConfigFactory
from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
from vllm_omni.engine.stage_engine_core_client import StageEngineCoreClientBase
@@ -476,6 +477,89 @@ class TestSingleStageModeDetection:
assert not hasattr(engine, "_omni_master_server")
# ---------------------------------------------------------------------------
# Endpoint-restriction resolution vs. tri-state trust_remote_code (#5495)
# ---------------------------------------------------------------------------
class TestEndpointRestrictionsTrustRemoteCode:
"""Regression tests for issue #5495.
``AsyncOmniEngine.__init__`` takes ``trust_remote_code: bool | None`` where
``None`` means "not specified" (the CLI default, since ``--trust-remote-code``
is ``store_true`` and its absence is mapped to ``None``). Endpoint-restriction
resolution eagerly loads the HF config via vLLM's ``get_config``, which does
``trust_remote_code |= ...`` internally and raises ``TypeError`` on ``None``.
That used to be swallowed, leaving ``endpoint_restrictions`` empty so
``/v1/completions`` was never shut down and the request crashed stage-1.
``__init__`` must collapse the ``None`` "not specified" case to ``False`` at
the ``get_pipeline_config`` call site so the restriction is still computed.
"""
@pytest.fixture(autouse=True)
def clear_config_factory_caches(self):
"""Isolate the process-wide ``functools.cache`` on StageConfigFactory.
``get_hf_config`` / ``try_infer_model_type`` cache per-model results for
the whole process. These tests patch ``get_config`` with a synthetic
Qwen3-Omni config for ``"fake-model"``; without clearing on teardown the
synthetic entry would leak to later tests in the same worker (after the
mock is restored), making the suite order-dependent. Clear both before
and after.
"""
StageConfigFactory.get_hf_config.cache_clear()
StageConfigFactory.try_infer_model_type.cache_clear()
yield
StageConfigFactory.get_hf_config.cache_clear()
StageConfigFactory.try_infer_model_type.cache_clear()
def _make_engine_no_thread(self, mocker: MockerFixture, **kwargs: Any) -> AsyncOmniEngine:
mocker.patch.object(
AsyncOmniEngine,
"_resolve_stage_configs",
return_value=("/fake/path", [_make_stage_cfg(0)]),
)
mocker.patch.object(AsyncOmniEngine, "_bootstrap_orchestrator")
mocker.patch("threading.Thread")
mocker.patch("concurrent.futures.Future")
return AsyncOmniEngine(model="fake-model", **kwargs)
@staticmethod
def _install_qwen3_omni_config(mocker: MockerFixture) -> None:
"""Patch get_config to a thinker+talker config, mirroring vLLM's
``trust_remote_code |= ...`` (raises TypeError on None)."""
from transformers import Qwen3OmniMoeConfig
def fake_get_config(model, trust_remote_code, **_):
trust_remote_code |= False # TypeError if None reaches here
return Qwen3OmniMoeConfig(enable_audio_output=True)
mocker.patch("vllm_omni.config.config_factory.get_config", side_effect=fake_get_config)
@pytest.mark.parametrize("trc_kwargs", [{}, {"trust_remote_code": False}, {"trust_remote_code": True}])
def test_completions_restriction_survives(self, mocker: MockerFixture, trc_kwargs: dict):
"""COMPLETIONS restriction is computed regardless of how (or whether)
trust_remote_code is passed — including the unset case that resolves to
the engine's ``None`` default."""
from vllm_omni.config.endpoint_policy import OmniServingCapability
self._install_qwen3_omni_config(mocker)
engine = self._make_engine_no_thread(mocker, **trc_kwargs)
assert any(r.capability is OmniServingCapability.COMPLETIONS for r in engine.endpoint_restrictions), (
f"COMPLETIONS restriction dropped for {trc_kwargs or 'unset (None default)'}"
)
def test_unset_trust_remote_code_defaults_to_none(self, mocker: MockerFixture):
"""Guard the tri-state premise: the engine keeps ``None`` (not ``False``)
when trust_remote_code is not passed, so the call-site coercion is what
prevents the crash."""
import inspect
default = inspect.signature(AsyncOmniEngine.__init__).parameters["trust_remote_code"].default
assert default is None
# ---------------------------------------------------------------------------
# AsyncOmniEngine single-stage initialization paths
# ---------------------------------------------------------------------------
@@ -778,6 +862,7 @@ class TestSingleStageReplicaInitialization:
single_stage_id_filter=None,
omni_master_address="127.0.0.1",
omni_master_port=26000,
log_stats=True,
)
runtime._omni_master_server = mocker.Mock(spec=OmniMasterServer)
runtime._omni_master_server.get_stage_config.return_value = {"stage_id": 1, "stage_type": "llm"}
@@ -811,10 +896,17 @@ class TestSingleStageReplicaInitialization:
sentinel_client = SimpleNamespace()
mock_connect = mocker.patch.object(runtime_mod, "connect_remote_engine_cores", side_effect=_fake_connect)
client_kwargs: dict[str, Any] = {}
def _capture_make_async_mp_client(**kwargs):
client_kwargs.update(kwargs)
events.append("attach")
return sentinel_client
mocker.patch.object(
StageEngineCoreClientBase,
"make_async_mp_client",
side_effect=lambda **_: (events.append("attach"), sentinel_client)[1],
side_effect=_capture_make_async_mp_client,
)
result = runtime._initialize_remote_replica(plan, stage_init_timeout=60)
@@ -824,6 +916,7 @@ class TestSingleStageReplicaInitialization:
assert mock_connect.call_args.kwargs["vllm_config"].parallel_config.data_parallel_size_local == 0
assert mock_connect.call_args.kwargs["stage_id"] == 1
assert mock_connect.call_args.kwargs["replica_id"] == 0
assert client_kwargs["log_stats"] is True
assert events == ["enter", "exit", "attach"]
def test_initialize_llm_replica_remote_missing_registered_stage_config_raises(self, mocker: MockerFixture):
+13
View File
@@ -158,3 +158,16 @@ def test_extract_stage_metadata_preserves_legacy_one_argument_api():
assert metadata.stage_id == 0
assert metadata.model_stage == "thinker"
assert metadata.custom_process_input_func is operator.add
def test_extract_stage_metadata_defaults_missing_engine_input_source():
pipeline, deploy = _metadata_inputs()
legacy_config = merge_pipeline_deploy(
pipeline,
deploy,
)[0].to_omegaconf()
del legacy_config["engine_input_source"]
metadata = extract_stage_metadata(legacy_config)
assert metadata.engine_input_source == []
@@ -1301,7 +1301,11 @@ def test_generate_images_rejects_model_mismatch(test_client):
assert "model mismatch" in response.json()["detail"].lower()
def test_image_file_response_format_multiple(test_client):
@pytest.mark.parametrize(
("output_format", "expected_pil_format"),
[("png", "PNG"), ("jpeg", "JPEG"), ("webp", "WEBP")],
)
def test_image_file_response_format_multiple(test_client, output_format, expected_pil_format):
"""Test response_format=file with n>1 returns ZIP archive"""
response = test_client.post(
"/v1/images/generations",
@@ -1309,6 +1313,7 @@ def test_image_file_response_format_multiple(test_client):
"prompt": "a dog",
"n": 3,
"response_format": "file",
"output_format": output_format,
},
)
@@ -1317,23 +1322,32 @@ def test_image_file_response_format_multiple(test_client):
assert "attachment" in response.headers.get("content-disposition", "")
assert ".zip" in response.headers.get("content-disposition", "")
# Verify it's a valid ZIP with 3 PNG files
# Verify it's a valid ZIP with 3 correctly labelled image files
import zipfile
zip_buffer = io.BytesIO(response.content)
with zipfile.ZipFile(zip_buffer, "r") as zf:
files = zf.namelist()
assert len(files) == 3
assert all(f.endswith(".png") for f in files)
assert all(f.endswith(f".{output_format}") for f in files)
# Verify each file is a valid PNG
# Verify each file's bytes match its advertised format
for filename in files:
img_bytes = zf.read(filename)
img = Image.open(io.BytesIO(img_bytes))
assert img.format == "PNG"
assert img.format == expected_pil_format
def test_image_file_response_format_single(test_client):
@pytest.mark.parametrize(
("output_format", "expected_media_type", "expected_pil_format"),
[
("png", "image/png", "PNG"),
("jpg", "image/jpeg", "JPEG"),
("jpeg", "image/jpeg", "JPEG"),
("webp", "image/webp", "WEBP"),
],
)
def test_image_file_response_format_single(test_client, output_format, expected_media_type, expected_pil_format):
"""Test response_format=file with n=1 returns a single image file."""
response = test_client.post(
"/v1/images/generations",
@@ -1341,16 +1355,61 @@ def test_image_file_response_format_single(test_client):
"prompt": "a dog",
"n": 1,
"response_format": "file",
"output_format": output_format,
},
)
assert response.status_code == 200
assert response.headers["content-type"] == "image/png"
assert response.headers["content-type"] == expected_media_type
assert "attachment" in response.headers.get("content-disposition", "")
assert ".png" in response.headers.get("content-disposition", "")
assert f".{output_format}" in response.headers.get("content-disposition", "")
img = Image.open(io.BytesIO(response.content))
assert img.format == "PNG"
assert img.format == expected_pil_format
@pytest.mark.parametrize(
("output_format", "image_count", "expected_media_type", "expected_pil_format"),
[
("jpeg", 1, "image/jpeg", "JPEG"),
("webp", 2, "application/zip", "WEBP"),
],
)
def test_multistage_image_file_response_uses_requested_format(
async_omni_test_client,
output_format,
image_count,
expected_media_type,
expected_pil_format,
):
engine = async_omni_test_client.app.state.engine_client
engine._images = [Image.new("RGB", (16, 16), color="green") for _ in range(image_count)]
response = async_omni_test_client.post(
"/v1/images/generations",
json={
"prompt": "a dog",
"n": image_count,
"response_format": "file",
"output_format": output_format,
},
)
assert response.status_code == 200
assert response.headers["content-type"] == expected_media_type
if image_count == 1:
assert f".{output_format}" in response.headers["content-disposition"]
assert Image.open(io.BytesIO(response.content)).format == expected_pil_format
return
import zipfile
with zipfile.ZipFile(io.BytesIO(response.content), "r") as archive:
filenames = archive.namelist()
assert len(filenames) == image_count
assert all(filename.endswith(f".{output_format}") for filename in filenames)
for filename in filenames:
assert Image.open(io.BytesIO(archive.read(filename))).format == expected_pil_format
def make_test_image_bytes(size=(64, 64)) -> bytes:
@@ -25,6 +25,7 @@ from vllm.entrypoints.openai.engine.protocol import ErrorInfo, ErrorResponse
from vllm_omni.entrypoints.omni_base import OmniEngineDeadError
from vllm_omni.entrypoints.openai import api_server as api_server_module
from vllm_omni.entrypoints.openai import serving_speech as serving_speech_module
from vllm_omni.entrypoints.openai.audio_utils_mixin import AudioMixin
from vllm_omni.entrypoints.openai.protocol.audio import (
BatchSpeechRequest,
@@ -1390,6 +1391,20 @@ class TestTTSMethods:
# Verify speakers are normalized to lowercase
assert server.supported_speakers == {"ryan", "vivian", "aiden"}
def test_load_supported_speakers_skips_non_tts_omni_model(
self,
speech_server,
mocker: MockerFixture,
):
warning = mocker.patch.object(serving_speech_module.logger, "warning")
speech_server.engine_client.model_config = SimpleNamespace(
hf_config=SimpleNamespace(tts_config=SimpleNamespace())
)
assert speech_server._is_tts is False
assert speech_server._load_supported_speakers() == set()
warning.assert_not_called()
def test_load_supported_languages_from_config(self, speech_server):
"""Languages/dialects from codec_language_id are loaded title-cased; 'Auto' is added."""
speech_server._tts_model_type = "qwen3_tts"
@@ -48,6 +48,25 @@ def test_encode_video_bytes_exports_frames_without_interpolation(monkeypatch):
assert mux_calls[0]["audio"] is None
@pytest.mark.parametrize("frame_count", [3, 4])
def test_channel_last_video_tensor_preserves_channel_sized_frame_count(frame_count):
video = torch.arange(frame_count * 2 * 5 * 3, dtype=torch.uint8).reshape(frame_count, 2, 5, 3)
frames = video_api_utils._coerce_video_to_uint8_frames(video)
assert frames.shape == (frame_count, 2, 5, 3)
np.testing.assert_array_equal(frames, video.numpy())
def test_channel_first_video_tensor_is_converted_to_channel_last():
video = torch.arange(3 * 5 * 2 * 6, dtype=torch.uint8).reshape(3, 5, 2, 6)
frames = video_api_utils._coerce_video_to_uint8_frames(video)
assert frames.shape == (5, 2, 6, 3)
np.testing.assert_array_equal(frames, video.permute(1, 2, 3, 0).numpy())
def test_fragmented_mp4_video_encoder_reuses_single_muxer(monkeypatch):
muxers = []
@@ -12,6 +12,7 @@ import os
import threading
import time
from contextlib import asynccontextmanager
from pathlib import Path
from types import SimpleNamespace
import numpy as np
@@ -62,11 +63,19 @@ class FakeAsyncOmni:
self.stage_configs = [SimpleNamespace(stage_type="diffusion")]
self.default_sampling_params_list = [OmniDiffusionSamplingParams()]
self.captured_prompt = None
self.captured_reference_video_bytes = None
self.captured_sampling_params_list = None
async def generate(self, prompt, request_id, sampling_params_list):
self.captured_prompt = prompt
self.captured_sampling_params_list = sampling_params_list
reference_videos = prompt.get("multi_modal_data", {}).get("video")
if (
isinstance(reference_videos, list)
and reference_videos
and all(isinstance(item, str) for item in reference_videos)
):
self.captured_reference_video_bytes = [Path(item).read_bytes() for item in reference_videos]
num_outputs = sampling_params_list[0].num_outputs_per_prompt
videos = [object() for _ in range(num_outputs)]
yield MockVideoResult(videos)
@@ -501,6 +510,44 @@ def test_v2v_video_generation_with_video_reference_form(test_client, mocker: Moc
assert input_video[0].size == (32, 24)
@pytest.mark.parametrize("endpoint", ["/v1/videos", "/v1/videos/sync"])
def test_multi_video_generation_preserves_uploaded_files_until_generation(
endpoint,
test_client,
mocker: MockerFixture,
):
mocker.patch(
"vllm_omni.entrypoints.openai.serving_video._encode_video_bytes",
return_value=b"fake-video",
)
response = test_client.post(
endpoint,
data={
"prompt": "Composite the subject from the first video into the second.",
"extra_params": json.dumps({"task": "ref2va", "duration": 15.0}),
},
files=[
("input_references", ("subject.mp4", b"subject-video", "video/mp4")),
("input_references", ("background.mov", b"background-video", "video/quicktime")),
],
)
assert response.status_code == 200
if endpoint.endswith("/sync"):
assert response.content == b"fake-video"
else:
video_id = response.json()["id"]
_wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
engine = test_client.app.state.openai_serving_video._engine_client
input_videos = engine.captured_prompt["multi_modal_data"]["video"]
assert engine.captured_reference_video_bytes == [b"subject-video", b"background-video"]
assert len(input_videos) == 2
assert all(not Path(path).exists() for path in input_videos)
assert engine.captured_sampling_params_list[0].extra_args["task"] == "ref2va"
assert engine.captured_sampling_params_list[0].extra_args["duration"] == 15.0
def test_decode_video_bytes_can_keep_last_frames():
from vllm_omni.entrypoints.openai.video_api_utils import _decode_video_bytes
+24 -1
View File
@@ -46,6 +46,26 @@ def test_serve_parser_accepts_strategy_config() -> None:
assert args.get_explicit_kwargs_dict()["strategy_config"] == "/tmp/strategy.yaml"
def test_serve_parser_accepts_deploy_config() -> None:
parser = TrackingArgumentParser()
subparsers = parser.add_subparsers(dest="subcommand")
OmniServeCommand().subparser_init(subparsers)
args = parser.parse_args(["serve", "fake-model", "--omni", "--deploy-config", "/tmp/deploy.yaml"])
assert args.deploy_config == "/tmp/deploy.yaml"
assert args.get_explicit_kwargs_dict()["deploy_config"] == "/tmp/deploy.yaml"
def test_serve_parser_rejects_stage_configs_path() -> None:
parser = TrackingArgumentParser()
subparsers = parser.add_subparsers(dest="subcommand")
OmniServeCommand().subparser_init(subparsers)
with pytest.raises(SystemExit):
parser.parse_args(["serve", "fake-model", "--omni", "--stage-configs-path", "/tmp/stages.yaml"])
def test_serve_parser_accepts_four_way_cfg_parallelism() -> None:
parser = TrackingArgumentParser()
subparsers = parser.add_subparsers(dest="subcommand")
@@ -66,7 +86,6 @@ def _make_headless_args(**kwargs) -> TrackingNamespace:
"omni_replica_address": None,
"omni_dp_size_local": 1,
"worker_backend": "multi_process",
"stage_configs_path": None,
"deploy_config": None,
"log_stats": False,
"disable_log_stats": False,
@@ -154,6 +173,7 @@ def test_run_headless_parses_and_forwards_stage_overrides(mocker: MockerFixture)
captured: dict = {}
def _fake_resolve(*args, **kwargs):
captured["args"] = args
captured.update(kwargs)
# Return a stage that does NOT match stage_id=0 so run_headless stops
# right after the resolver call (we only care about how it was called).
@@ -166,12 +186,15 @@ def test_run_headless_parses_and_forwards_stage_overrides(mocker: MockerFixture)
args = _make_headless_args(
stage_id=0,
deploy_config="/tmp/deploy.yaml",
strategy_config="/tmp/strategy.yaml",
stage_overrides='{"0": {"devices": "0,1"}, "1": {"devices": "2"}}',
)
with pytest.raises(ValueError, match="No stage config found for stage_id=0"):
run_headless(args)
assert captured["args"][1] is None
assert captured["deploy_config_path"] == "/tmp/deploy.yaml"
assert captured["stage_overrides"] == {"0": {"devices": "0,1"}, "1": {"devices": "2"}}
assert captured["strategy_config_path"] == "/tmp/strategy.yaml"
+89 -11
View File
@@ -17,6 +17,7 @@ Key invariants tested:
"""
import json
import time
from unittest.mock import MagicMock
import pytest
@@ -80,28 +81,37 @@ def _make_audio_omni_output(
request_id: str = "test-req",
index: int = 0,
num_prompt_tokens: int = 3,
stage_id: int | None = None,
replica_id: int | None = None,
audio_samples: int = 0,
) -> OmniRequestOutput:
"""Build an OmniRequestOutput for audio (no torch dependency)."""
completion = CompletionOutput(
index=index,
text="",
token_ids=[],
cumulative_logprob=0.0,
logprobs=None,
finish_reason="stop",
stop_reason=None,
)
if audio_samples > 0:
completion.multimodal_output = {
"audio": [MagicMock(numel=MagicMock(return_value=audio_samples))],
"sr": 24000,
}
res = RequestOutput(
request_id=request_id,
prompt="test",
prompt_token_ids=list(range(num_prompt_tokens)),
prompt_logprobs=None,
outputs=[
CompletionOutput(
index=index,
text="",
token_ids=[],
cumulative_logprob=0.0,
logprobs=None,
finish_reason="stop",
stop_reason=None,
)
],
outputs=[completion],
finished=True,
)
return OmniRequestOutput(
request_id=request_id,
stage_id=stage_id,
replica_id=replica_id,
final_output_type="audio",
request_output=res,
finished=True,
@@ -321,6 +331,74 @@ async def test_single_modality_audio_only_one_stop():
assert finish_reasons[-1] == "stop"
@pytest.mark.parametrize(
("output_replica_id", "pool_replica_id", "expected_replica_id", "pool_lookup"),
[
(7, None, 7, False),
(None, 5, 5, True),
],
)
@pytest.mark.asyncio
async def test_streaming_audio_metrics_resolve_replica_id(
monkeypatch,
output_replica_id,
pool_replica_id,
expected_replica_id,
pool_lookup,
):
import vllm_omni.entrypoints.openai.serving_chat as serving_chat_mod
from vllm_omni.entrypoints.client_request_state import ClientRequestState
serving_chat = _build_serving_chat()
request = _make_request(modalities=["audio"])
req_state = ClientRequestState(
request_id="internal-req",
external_request_id="test-req",
)
req_state.request_arrival_ts = time.time() - 1.0
serving_chat.engine_client.request_states = {"internal-req": req_state}
serving_chat.engine_client.mod_metrics = object()
serving_chat.engine_client.engine = MagicMock()
serving_chat.engine_client.engine.stage_pools = [MagicMock(), MagicMock(), MagicMock()]
serving_chat.engine_client.engine.stage_pools[2].get_bound_replica_id.return_value = pool_replica_id
first_packet_calls = []
finalize_calls = []
def fake_observe_audio_first_packet(*args, **kwargs):
first_packet_calls.append(kwargs)
def fake_observe_audio_streaming_finalize(*args, **kwargs):
finalize_calls.append(kwargs)
monkeypatch.setattr(serving_chat_mod, "observe_audio_first_packet", fake_observe_audio_first_packet)
monkeypatch.setattr(serving_chat_mod, "observe_audio_streaming_finalize", fake_observe_audio_streaming_finalize)
async def result_generator():
yield _make_audio_omni_output(stage_id=2, replica_id=output_replica_id, audio_samples=2400)
await _collect_stream(
serving_chat.chat_completion_stream_generator(
request=request,
result_generator=result_generator(),
request_id="test-req",
model_name="test-model",
conversation=[],
tokenizer=MagicMock(),
request_metadata=MagicMock(),
)
)
if pool_lookup:
serving_chat.engine_client.engine.stage_pools[2].get_bound_replica_id.assert_called_once_with("internal-req")
else:
serving_chat.engine_client.engine.stage_pools[2].get_bound_replica_id.assert_not_called()
assert first_packet_calls[0]["stage_id"] == 2
assert first_packet_calls[0]["replica_id"] == expected_replica_id
assert finalize_calls[0]["stage_id"] == 2
assert finalize_calls[0]["replica_id"] == expected_replica_id
# ---------------------------------------------------------------------------
# Tests: fallback stop chunk when declared modality is not produced
# ---------------------------------------------------------------------------
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff. Show more