Compare commits

..
Author SHA1 Message Date
hsliuustc0106 81de3f3308 [Bugfix][MiniMax-H3] Apply pre-commit formatting
Signed-off-by: hsliuustc0106 <liuhongsheng4@huawei.com>
2026-09-03 08:44:26 +08:00
hsliuustc0106 f9c9de8d16 [Bugfix][MiniMax-H3] Reconcile temporal decode plans across VAE ranks
Signed-off-by: hsliuustc0106 <liuhongsheng4@huawei.com>
(cherry picked from commit ac4ec9c0fcf3394e8476d3d2d913d7c86f5df8e3)
2026-09-03 08:20:00 +08:00
hsliuustc0106 ff2e33a329 [Docs][MiniMax-H3] Document temporal VAE chunks
Signed-off-by: hsliuustc0106 <liuhongsheng4@huawei.com>
2026-09-02 07:08:40 +08:00
hsliuustc0106 0b5b0979d8 [Bugfix][MiniMax-H3] Reconcile chunk callback ownership
Signed-off-by: hsliuustc0106 <liuhongsheng4@huawei.com>
2026-09-01 23:11:29 +08:00
hsliuustc0106 f04ff76c54 [Model][MiniMax-H3] Isolate temporal chunk coordination
Signed-off-by: hsliuustc0106 <liuhongsheng4@huawei.com>
2026-09-01 23:11:29 +08:00
hsliuustc0106 b220df8397 [Model][MiniMax-H3] Support released VAE chunks directly
Signed-off-by: hsliuustc0106 <liuhongsheng4@huawei.com>
2026-09-01 23:11:28 +08:00
hsliuustc0106 520e058b95 [Model][MiniMax-H3] Delegate temporal VAE chunks
Signed-off-by: hsliuustc0106 <liuhongsheng4@huawei.com>
2026-09-01 23:11:27 +08:00
DanaerLeeandHongsheng Liu 0288c3f56e [Model] Support SenseNova-U1.5-8B-MoT and its distilled 8-step LoRA (#6516)
Signed-off-by: MrlixiangWE <mrdanaer@gmail.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-09-01 22:53:53 +08:00
andyluo7andHongsheng Liu 477083a103 [CI][ROCm] Fix deterministic AMD L3 blockers (#6884)
Signed-off-by: andyluo7 <andy.luo@amd.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-09-01 22:53:00 +08:00
Rahul SteigerandRahul Steiger e1b2f589c1 [Bugfix][Cosmos3] Avoid logging prompts at INFO level (#6913)
Signed-off-by: Rahul Steiger <rsteiger@nvl72d233-T16.cm.cluster>
Co-authored-by: Rahul Steiger <rsteiger@nvl72d233-T16.cm.cluster>
2026-09-01 22:52:15 +08:00
Hongsheng Liuandhsliu_ustc 44c8d1c449 docs: add concise API server endpoint guide (#6114)
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: hsliu_ustc <hsliu_ustc@noreply.gitcode.com>
2026-09-01 20:34:09 +08:00
Andy Zhou 2298359f67 [Bugfix][Diffusion] Preserve stage attention backend (#6645)
Signed-off-by: AndyZhou952 <jzhoubc@connect.ust.hk>
2026-09-01 20:18:44 +08:00
Hongsheng Liu d1c8beeb18 [Doc] Update README and docs for v0.28.0 (#6858)
Signed-off-by: hsliuustc0106 <liuhongsheng4@huawei.com>
2026-09-01 19:50:26 +08:00
WeiQing Chen e2b83db683 [Test] Add MiniMax-H3 DLO DP2 variants and Turbo LoRA L3 test (#6556)
Signed-off-by: david6666666 <530634352@qq.com>
2026-09-01 10:48:52 +00:00
AuFlowandHongsheng Liu 49c6fc1540 [Frontend] Add configurable output sample rate to Speech API (#6553)
Signed-off-by: 郑倩倩 <2458983186@qq.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-09-01 17:25:09 +08:00
Alex Brooks a65e89cbd4 [Core] Validate PipelineConfig in __post_init__ (#6829)
Signed-off-by: Alex Brooks <albrooks@redhat.com>
2026-09-01 11:17:57 +08:00
Yuxuan RhoandHongsheng Liu 7992c2213e [Config] Remove diffusion_batch_size and use max_num_seqs instead. (#6484)
Signed-off-by: LyxWxj <yuxuanlou621@foxmail.com>
Signed-off-by: Yuxuan Rho <104440472+LyxWxj@users.noreply.github.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-09-01 01:02:54 +00:00
XRay2016 8e26dce1f4 [Diffusion] Add Boogu Edit-Turbo support docs and tests (#6701)
Signed-off-by: xRay2016 <1150722393@qq.com>
2026-09-01 08:41:23 +08:00
Inesh Reddy ChappidiandGao Han eb11446b7f [Bugfix][Core] Do not advance the segment watermark past an unmaintained request counter (#6834)
Signed-off-by: Inesh Reddy Chappidi <ineshreddy249@gmail.com>
Co-authored-by: Gao Han <hgaoaf@connect.ust.hk>
2026-08-31 15:27:13 +00:00
Gao Han 705e781b0e [Bugfix][MiniCPM-o] Checkpoint playback before final input commit (#6821)
Signed-off-by: Gao Han <hgaoaf@connect.ust.hk>
2026-08-31 15:18:29 +00:00
Gao Han b81aeb7b86 [CI][MiniCPM-o] Make audio consistency checks deterministic (#6828)
Signed-off-by: Gao Han <hgaoaf@connect.ust.hk>
2026-08-31 13:08:49 +00:00
wangyu 3d1ad0c0f4 [CI][Bugfix]Drop dummy-weight Qwen3-TTS Base from Ready CI (#6861)
Signed-off-by: wangyu <410167048@qq.com>
2026-08-31 12:13:26 +00:00
fe4a2a7477 [Diffusion] Add paged KV cache support for HunyuanImage3 DiT on NPU and GPU (#6563)
Signed-off-by: Acerak01-fy <wfy2003324@163.com>
Signed-off-by: zwhzzz0821 <zwhzzz0821@users.noreply.github.com>
Signed-off-by: zwhzzz0821 <2831474076@qq.com>
Co-authored-by: zwhzzz0821 <zwhzzz0821@users.noreply.github.com>
Co-authored-by: zwhzzz0821 <2831474076@qq.com>
2026-08-31 10:15:46 +00:00
Bo Li 6f16f9546d [Bugfix][Kernel] Stage strided Ulysses QKV with copy engine (#6814)
Signed-off-by: Bo Li <22713281+bobboli@users.noreply.github.com>
2026-08-31 17:28:49 +08:00
Alex Brooks 58cb8de68b [CI] Fix Speech CI Validation Messages (#6827)
Signed-off-by: Alex Brooks <albrooks@redhat.com>
2026-08-31 08:13:59 +00:00
Rakesh Kariya b864374089 [Bugfix][MiMo-Audio] Restore make_empty_intermediate_tensors on the talker for vLLM 0.28 (#6803)
Signed-off-by: rk9595 <rakesh.kariya@somaiya.edu>
2026-08-31 15:26:35 +08:00
WeiQing Chen 2052deef07 [Bugfix][Diffusion] Load plugins in spawned stage process (#6750)
Signed-off-by: david6666666 <530634352@qq.com>
2026-08-31 14:32:07 +08:00
汪志鹏andHongsheng Liu 86b85c078b [Feature][MiniMax-H3] Fuse the FastH3 four-step adapter at load time (#6714)
Signed-off-by: princepride <wangzhipeng628@gmail.com>
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-31 12:04:58 +08:00
Inesh Reddy Chappidi 5e06d2e73c [CI/Build] Align duplex stage-input deadline with the e2e client timeout (#6831)
Signed-off-by: Inesh Reddy Chappidi <ineshreddy249@gmail.com>
2026-08-31 02:55:36 +00:00
akshatvishu f321cdbe16 [Bugfix] Use Seed-TTS reference audio with MiniCPM-o 4.5 (#6628)
Signed-off-by: akshatvishu <akshatnayak197@gmail.com>
2026-08-31 10:27:34 +08:00
WeiQing Chen 4cca28e9bd [Doc] Update vLLM-Omni WeChat QR code (#6832)
Signed-off-by: David Chen <530634352@qq.com>
2026-08-31 09:44:33 +08:00
TJian ded8934626 [AMD] [CI] Fix tests for v0.28.0 (#6830)
Signed-off-by: tjtanaa <tunjian.tan@embeddedllm.com>
2026-08-31 09:10:27 +08:00
SYLAR 759aa4ffeb [Diffusion][Performance] Optimize MiniMax-H3 video output transfer (#6824)
Signed-off-by: lishunyang12 <lishunyang12@163.com>
2026-08-31 07:07:34 +08:00
mokekeandmokashliu 1a9d598d2b [Frontend] Encode interleaved video frames in parallel (#6776)
Signed-off-by: mokashliu <mokashliu@tencent.com>
Co-authored-by: mokashliu <mokashliu@tencent.com>
2026-08-31 07:04:27 +08:00
m0g3r 8fe913e461 [Core] Flag pipelines with no terminal output stage in PipelineConfig.validate() (#6291)
Signed-off-by: m0g3r <87276771+m0g3r@users.noreply.github.com>
2026-08-30 21:33:06 +00:00
Gao Han 71748388f8 [Bugfix] Recover Omni benchmark TPOT from Stage 0 metrics (#6818)
Signed-off-by: Gao Han <hgaoaf@connect.ust.hk>
2026-08-31 00:43:25 +08:00
Gao Han f7f48929db [Bugfix][Diffusion] Restore inline single-stage execution (#6813)
Signed-off-by: Gao Han <hgaoaf@connect.ust.hk>
2026-08-30 21:40:47 +08:00
18f01befa7 [NPU] upgrade to v0.28.0 (#6674)
Signed-off-by: Weiming Liao <liaowm5@gmail.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-08-30 12:28:35 +00:00
Yueqian Lin 1d75e63f7f [CI/Build] Add dots.tts weekly e2e coverage (#6174)
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
2026-08-30 11:37:08 +00:00
184 changed files with 11827 additions and 970 deletions

No files matched your search

+20 -8
View File
@@ -72,7 +72,22 @@ steps:
- export VLLM_LOGGING_LEVEL=DEBUG
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- pytest -s -v tests/diffusion/offloader/test_diffusion_cpu_offload.py
- pytest -s -v tests/diffusion/offloader/test_diffusion_layerwise_offload.py
- >-
pytest -s -v tests/diffusion/offloader/test_diffusion_layerwise_offload.py
--deselect='tests/diffusion/offloader/test_diffusion_layerwise_offload.py::test_layerwise_offload_diffusion_model[stabilityai/stable-audio-open-1.0]'
# TODO: Diagnose unstable Stable Audio layerwise-offload memory savings on ROCm.
- label: "Diffusion Stable Audio Layerwise Offload Test (quarantined)"
agent_pool: mi300_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: NonBlocking
commands:
- export VLLM_LOGGING_LEVEL=DEBUG
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- >-
pytest -s -v
'tests/diffusion/offloader/test_diffusion_layerwise_offload.py::test_layerwise_offload_diffusion_model[stabilityai/stable-audio-open-1.0]'
- label: "Diffusion Sequence Parallelism Test (Need 4 GPUs) · Shard 1/2"
agent_pool: mi300_4
@@ -223,13 +238,10 @@ steps:
grade: Blocking
timeout_in_minutes: 50
commands:
- |
timeout 40m bash -c '
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py tests/e2e/offline_inference/test_qwen3_tts_base.py -m 'advanced_model and cuda' --run-level 'advanced_model'
'
- export VLLM_LOGGING_LEVEL=DEBUG
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
- timeout 40m pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py tests/e2e/offline_inference/test_qwen3_tts_base.py -m "advanced_model and cuda" --run-level "advanced_model"
# TODO: Bagel test on ROCm is very unstable. @tjtanaa
# Need to debug before reneable numerical changes across large PRs
+41
View File
@@ -85,6 +85,9 @@ steps:
- vllm_omni/model_executor/models/common/alias_free_activation.py
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
- vllm_omni/deploy/qwen3_tts.yaml
- vllm_omni/entrypoints/openai/serving_speech.py
- vllm_omni/entrypoints/openai/tts_adapters/
- vllm_omni/engine/stage_pool.py
commands:
- |
timeout 40m bash -c "
@@ -104,6 +107,9 @@ steps:
- vllm_omni/model_executor/models/common/alias_free_activation.py
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
- vllm_omni/deploy/qwen3_tts.yaml
- vllm_omni/entrypoints/openai/serving_speech.py
- vllm_omni/entrypoints/openai/tts_adapters/
- vllm_omni/engine/stage_pool.py
- .buildkite/common/scripts/run_cov_split.sh
- pyproject.toml
# 40 -> 60: the coverage split runs the model twice.
@@ -231,3 +237,38 @@ steps:
pytest -s -v tests/e2e/online_serving/test_hunyuan_video_15_expansion.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
"
mirror_hardwares: h100_1
- label: "Diffusion · MiniMax-H3 L3 DLO DP2 and FastH3"
source_file_dependencies:
- tests/e2e/online_serving/minimax_h3/
- tests/helpers/assertions.py
- tests/helpers/mark.py
- tests/helpers/media.py
- tests/helpers/runtime.py
- vllm_omni/diffusion/models/minimax_h3/
- vllm_omni/diffusion/lora/
- vllm_omni/diffusion/offloader/
- vllm_omni/diffusion/distributed/
- vllm_omni/entrypoints/openai/
# Keep four server lifecycles in one Buildkite job, but never in one
# pytest process. DLO uses multiprocessing shared-memory queues; the
# parent's resource_tracker can retain a stale /psm_* entry during
# teardown and strand the next Ref2VA collective.
# A per-file process plus a hard timeout also makes a regression fail
# instead of leaving the merge queue stuck indefinitely.
timeout_in_minutes: 150
commands:
- |
set -eu
export VLLM_IMAGE_FETCH_TIMEOUT=60
if [ -f vllm_omni/diffusion/models/minimax_h3/lora.py ]; then
export VLLM_TEST_MINIMAX_H3_TURBO_LORA=$(python3 -c 'from huggingface_hub import hf_hub_download; print(hf_hub_download(repo_id="lightx2v/Minimax-h3-Turbo", filename="minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors"))')
fi
if [ -f vllm_omni/diffusion/models/minimax_h3/fasth3.py ]; then
export VLLM_TEST_MINIMAX_H3_FASTH3_LORA=$(python3 -c 'from huggingface_hub import hf_hub_download; print(hf_hub_download(repo_id="FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA", filename="dense-datafree/adapter_model.safetensors"))')
fi
timeout 35m pytest -s -v tests/e2e/online_serving/minimax_h3/test_minimax_h3_dlo_dp2_fl2va.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
timeout 35m pytest -s -v tests/e2e/online_serving/minimax_h3/test_minimax_h3_dlo_dp2_ref2va.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
timeout 35m pytest -s -v tests/e2e/online_serving/minimax_h3/test_minimax_h3_turbo_lora.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
timeout 35m pytest -s -v tests/e2e/online_serving/minimax_h3/test_minimax_h3_fasth3_lora.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
mirror_hardwares: h100_2
+3 -19
View File
@@ -252,6 +252,9 @@ steps:
- vllm_omni/model_executor/models/common/alias_free_activation.py
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
- vllm_omni/deploy/qwen3_tts.yaml
- vllm_omni/entrypoints/openai/serving_speech.py
- vllm_omni/entrypoints/openai/tts_adapters/
- vllm_omni/engine/stage_pool.py
commands:
- |
timeout 20m bash -c "
@@ -260,25 +263,6 @@ steps:
"
mirror_hardwares: l4_1
- label: "TTS · Qwen3-TTS Base Test"
source_file_dependencies:
- tests/e2e/online_serving/test_qwen3_tts_base.py
- vllm_omni/model_executor/models/qwen3_tts/
- vllm_omni/model_executor/models/common/qwen3_code_predictor.py
- vllm_omni/model_executor/models/common/snake_activation.py
- vllm_omni/model_executor/models/common/alias_free_activation.py
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
- vllm_omni/deploy/qwen3_tts.yaml
commands:
- |
timeout 20m bash -c "
export VLLM_WORKER_MULTIPROC_METHOD=spawn
export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1
export VLLM_OMNI_USE_V2_RUNNER=1
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py -m 'core_model' --run-level 'core_model'
"
mirror_hardwares: l4_1
- label: "Diffusion · Wan22 Test"
source_file_dependencies:
- tests/e2e/online_serving/test_wan22_t2v.py
+5 -3
View File
@@ -16,6 +16,7 @@ Easy, fast, and cheap omni-modality model serving for everyone
*Latest News* 🔥
- [2026/08] We released [0.28.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.28.0), featuring production-ready [MiniMax H3](recipes/MiniMaxAI/MiniMax-H3.md) serving on GPU and NPU, a unified AR/DiT paged KV cache runtime, and enhanced realtime full-duplex serving for the [MiniCPM-o series](recipes/OpenBMB/MiniCPM-o-4_5.md).
- [2026/08] [VeRL-Omni](https://github.com/verl-project/verl-omni) `v0.2.0` is released: faster diffusion RL powered by vLLM-Omni (request-level/step-wise batching with FA3), rebuilt Qwen3-Omni multimodal training (DPO & GSPO), plus LTX-2.3, Qwen-Image-Edit support and more. See the [release notes](https://github.com/verl-project/verl-omni/releases/tag/v0.2.0).
- [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.
@@ -57,9 +58,9 @@ vLLM-Omni is flexible and easy to use with:
vLLM-Omni seamlessly supports most popular open-source models on HuggingFace, including:
- **Omni-modality models** (e.g. Qwen3-Omni, 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. MiniMax H3, Qwen-Image, Wan2.2, FLUX)
- **Robot-policy and action models** (e.g. GR00T-N1.7, DreamZero-DROID, InternVLA-A1, Cosmos3 action policy)
- **TTS models** (e.g. Qwen3-TTS, IndexTTS 2.5, dots.tts, CosyVoice3)
- **Diffusion models** — image, video, and audio generation (e.g. MiniMax H3, LTX-2.5, SANA-Video, Wan2.2)
- **Robot-policy and action models** (e.g. π0, GR00T-N1.7, DreamZero-DROID, InternVLA-A1)
## Getting Started
@@ -67,6 +68,7 @@ Visit our [documentation](https://vllm-omni.readthedocs.io/en/latest/) to learn
- [Installation](https://vllm-omni.readthedocs.io/en/latest/getting_started/installation/)
- [Quickstart](https://vllm-omni.readthedocs.io/en/latest/getting_started/quickstart/)
- [Nightly CUDA Docker images](https://hub.docker.com/r/vllm/vllm-omni/tags?name=nightly) built from the latest `main`
- [List of Supported Models](https://vllm-omni.readthedocs.io/en/latest/models/supported_models/)
- [Deployment Recipes](https://recipes.vllm.ai) for vLLM-Omni model serving
+1
View File
@@ -30,6 +30,7 @@ RUN python3 -m pip install -v --no-deps \
"stepaudio2-minicpmo>=0.1.1" \
"step-audio2>=1.0.0"
ENV VLLM_TARGET_DEVICE=npu
ENV VLLM_WORKER_MULTIPROC_METHOD=spawn
ENTRYPOINT []
+1
View File
@@ -30,6 +30,7 @@ RUN python3 -m pip install -v --no-deps \
"stepaudio2-minicpmo>=0.1.1" \
"step-audio2>=1.0.0"
ENV VLLM_TARGET_DEVICE=npu
ENV VLLM_WORKER_MULTIPROC_METHOD=spawn
ENTRYPOINT []
+1
View File
@@ -65,6 +65,7 @@ RUN python3 -m pip install -v --no-deps \
--trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \
--default-timeout=60
ENV VLLM_TARGET_DEVICE=npu
ENV VLLM_WORKER_MULTIPROC_METHOD=spawn
ENTRYPOINT []
+1
View File
@@ -66,6 +66,7 @@ RUN python3 -m pip install -v --no-deps \
--trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \
--default-timeout=60
ENV VLLM_TARGET_DEVICE=npu
ENV VLLM_WORKER_MULTIPROC_METHOD=spawn
ENTRYPOINT []
+19 -9
View File
@@ -5,14 +5,22 @@ nav:
- getting_started/quickstart.md
- getting_started/installation/*
- Serving:
- OpenAI-Compatible API:
- Chat Completions API: serving/chat_completions_api.md
- Image Generation: serving/image_generation_api.md
- Image Edit: serving/image_edit_api.md
- Text to Speech: serving/speech_api.md
- Videos: serving/videos_api.md
- Audio Generation: serving/audio_generate_api.md
- Streaming Video Input: serving/video_stream_api.md
- API Server:
- Overview: serving/README.md
- Core APIs:
- Chat Completions: serving/chat_completions_api.md
- Text to Speech: serving/speech_api.md
- Audio Generation: serving/audio_generate_api.md
- Image Generation: serving/image_generation_api.md
- Image Edit: serving/image_edit_api.md
- Videos: serving/videos_api.md
- Streaming and Realtime:
- Realtime Audio: serving/realtime_api.md
- Full Duplex: serving/full_duplex_api.md
- Streaming Video Input: serving/video_stream_api.md
- Streaming Video Output: serving/streaming_video_output_api.md
- OpenPI Robot Policy: serving/openpi_api.md
- Standalone Experimental Servers: serving/standalone_servers.md
- Examples:
- examples/README.md
- Offline Inference:
@@ -29,7 +37,7 @@ nav:
- Image-To-Video: user_guide/examples/online_serving/image_to_video.md
- Speech-To-Video: user_guide/examples/online_serving/speech_to_video.md
- Text-To-Image: user_guide/examples/online_serving/text_to_image.md
- Text-To-Speech: user_guide/examples/online_serving/text_to_speech.md
- Text-To-Speech (Online Serving): user_guide/examples/online_serving/text_to_speech.md
- Text-To-Video: user_guide/examples/online_serving/text_to_video.md
- General:
- usage/*
@@ -119,6 +127,8 @@ nav:
- Async Omni Output Materialization: design/feature/omni_async_output_materialization.md
- Automatic Prefix Caching: design/feature/prefix_caching.md
- Realtime AR-Diffusion: design/feature/realtime_ar_diffusion.md
- Model-Specific Capabilities:
- MiniMax H3 Temporal VAE Chunks: design/feature/minimax_h3_temporal_vae_chunks.md
- Communication:
- OmniConnector Implementations:
- Mooncake Store Connector: design/feature/omni_connectors/mooncake_store_connector.md
+3 -3
View File
@@ -57,9 +57,9 @@ vLLM-Omni is flexible and easy to use with:
vLLM-Omni seamlessly supports most popular open-source models on HuggingFace, including:
- Omni-modality models (e.g. Qwen3-Omni, 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. MiniMax H3, Qwen-Image, Wan2.2, FLUX)
- Robot-policy and action models (e.g. GR00T-N1.7, DreamZero-DROID, InternVLA-A1, Cosmos3 action policy)
- TTS models (e.g. Qwen3-TTS, IndexTTS 2.5, dots.tts, CosyVoice3)
- Diffusion models — image, video, and audio generation (e.g. MiniMax H3, LTX-2.5, SANA-Video, Wan2.2)
- Robot-policy and action models (e.g. π0, GR00T-N1.7, DreamZero-DROID, InternVLA-A1)
For more information, checkout the following:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

After

Width:  |  Height:  |  Size: 274 KiB

+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 the [vLLM 0.26 configuration guide](https://docs.vllm.ai/en/v0.26.0/configuration/index.html).
For options within a vLLM Engine, please refer to the [vLLM 0.28 configuration guide](https://docs.vllm.ai/en/v0.28.0/configuration/index.html).
Each model defines fixed topology in a registered `PipelineConfig` and runtime
overrides in a deploy YAML.
+1 -1
View File
@@ -151,7 +151,7 @@ their keys only.
## Inherited vLLM variables
vLLM-Omni also reads variables through its aligned vLLM dependency. Refer to
the [vLLM 0.26 environment-variable reference](https://docs.vllm.ai/en/v0.26.0/configuration/env_vars.html)
the [vLLM 0.28 environment-variable reference](https://docs.vllm.ai/en/v0.28.0/configuration/env_vars.html)
for their definitions. This includes vLLM launch, cache, logging, plugin, ROCm,
XPU, ModelScope, and FlashInfer workspace settings.
+4
View File
@@ -170,6 +170,10 @@ because the user-visible output is diffusion media rather than a token stream.
The
[MiniMax-H3 recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/MiniMaxAI/MiniMax-H3.md)
contains the hardware-specific profiles and warmup requirements.
The internal, opt-in
[temporal VAE chunk callback](feature/minimax_h3_temporal_vae_chunks.md)
documents the narrower released-source contract for publishing committed video
frames during decode. It does not change the complete-output serving topology.
### Cosmos3: unified MoT reasoner and generator
@@ -0,0 +1,260 @@
# MiniMax H3 temporal VAE chunk callback
!!! warning "Experimental internal capability"
This feature is an opt-in model integration seam. The current MiniMax H3
pipeline still uses complete-output VAE decode, and no CLI option, serving
path, transport, or MP4 sink enables temporal chunks by default.
## Status and scope
MiniMax H3 decodes video latents in temporal windows, but the released VAE
normally exposes only the complete reconstructed video. The temporal callback
publishes frames after the released decoder has completed overlap blending and
padding removal. This gives a future runtime consumer a stable point at which
it can schedule representation conversion, D2H transfer, and encoding while
the VAE continues decoding later windows.
The implementation remains model-local:
- `vae.py` owns VAE topology and the thin
`decode_latent_with_chunks()` facade;
- `chunked_decode.py` owns the normalized callback contract, metadata
validation, and fixed VAE-group rendezvous points; and
- `temporal_chunks.py` owns the source-gated compatibility planner and the
released temporal assembly control flow.
Crop policy, typed media, transport, backpressure, encoding, and public output
remain runtime responsibilities. The model code does not schedule requests or
select a presentation format.
## Execution flow
The default and opt-in paths are intentionally separate:
```text
Default:
latent -> MiniMaxH3VideoVAE.decode_latent() -> complete RGB video
Opt-in producer seam:
latent
-> MiniMaxH3VideoVAE.decode_latent_with_chunks()
-> group-local preflight and callback-owner validation
-> released temporal decode / overlap blending / padding removal
-> borrowed decoder-domain chunk
-> normalize + clone into owned RGB float32 BCTHW [0, 1]
-> synchronous callback
-> complete RGB video is still returned
```
Calling `decode_latent_with_chunks()` is the opt-in. Supplying no callback on a
VAE peer does not select the default decoder; every rank in a VAE patch group
must enter the chunked method, and only the output rank receives the callback.
## Producer contract
The normalized callback has this structural signature:
```python
def callback(
frames: torch.Tensor,
/,
*,
chunk_index: int,
total_chunks: int,
frame_start: int,
is_final: bool,
) -> None:
...
```
For a successful decode:
- `frames` is owned, contiguous RGB float32 in `BCTHW` layout and `[0, 1]`;
- callback storage does not alias the returned complete video;
- the consumer may retain or mutate a chunk;
- `chunk_index` is zero-based, contiguous, and strictly increasing;
- `total_chunks` is positive and stable for the decode;
- `frame_start` equals the end of the previous published range;
- chunks are nonempty and contain only temporally committed frames;
- exactly one chunk has `is_final=True`; and
- concatenating chunks along time exactly reconstructs the returned video.
The callback runs synchronously on the producer's current device stream. A
consumer that reads the tensor on another stream must establish ordering, for
example with an event, and retain the allocation for that stream's lifetime.
A slow callback also blocks VAE production. An overlap-oriented runtime
consumer should therefore acquire bounded capacity, enqueue side-stream work,
and return promptly instead of encoding frames inside the callback.
Chunks retain the decoded VAE canvas dimensions. A future runtime integration
must apply the same requested-height and requested-width crop as the complete
MiniMax H3 pipeline before declaring the media transport-ready.
The raw compatibility callback is deliberately narrower. It receives borrowed
decoder-domain tensors and must not retain or mutate them. The coordinator is
the ownership boundary that normalizes and clones raw chunks before invoking
the public callback.
## Temporal planning and terminal publication
The released configuration advances five latent tokens per temporal window and
includes a two-token overlap, so each `_adaptive_decode` input normally contains
seven tokens. After temporal scaling, pre-padding removal, and five-frame
overlap blending, the normal committed unit is 17 output frames. The final
remainder depends on the requested frame plan.
Representative released frame plans include:
| Output frames | Published chunks |
| ---: | --- |
| 124 | `7 x 17 + 5` |
| 209 | `12 x 17 + 5` |
| 243 | `14 x 17 + 5` |
| 362 | `21 x 17 + 5` |
The terminal callback is held until the complete frame plan passes its logical
frame, padding, written-frame, chunk-count, and finality checks. Earlier chunks
are semantically stable, but they do not imply that the overall request has
succeeded; a consumer must discard or abort partial output if a later decoder
failure terminates the request.
## Source and configuration gate
The released VAE has no public temporal-callback API. vLLM-Omni therefore owns
a compatibility implementation for one exact released code contract. The
reference revision identifies its provenance, but runtime acceptance is based
on source content, configuration, and structure rather than repository or
weight metadata:
- reference Hugging Face revision, not independently checked at runtime:
`42ed227ee7df40d41602854ae760620d6eb651fe`;
- sorted 15-file top-level `video_vae/*.py` manifest (excluding
`__init__.py`) SHA-256:
`30e64afbaa940696bf8bfe2c86003a4b1666c84b22fa3aa12403a3e6a03f705d`;
- clip length `17`;
- token chunk, overlap, and drop `5`, `2`, and `3`;
- temporal ratio `4`;
- frame pre-padding and overlap `3` and `5`; and
- no isolated first or last frame.
The adapter does not verify Hugging Face revision metadata, repository identity,
or model weights. The manifest pin detects Python compatibility drift; it is
not a security boundary for remote-code execution. The source manifest, listed
temporal attributes, structural contract, plan, and dtype are validated before
the first `_adaptive_decode` call. A mismatch disables only the opt-in chunk path.
`decode_latent()` remains available and performs no source fingerprint or chunk
rendezvous.
## Supported models
Support in this table means the internal producer contract above, not public
serving or end-to-end transport support.
| Model or component | Source contract | Status | Validation |
| --- | --- | --- | --- |
| MiniMax H3 FL2VA video VAE | Released manifest/listed temporal attributes above (reference `42ed227e`) | Supported, opt-in | Same-environment full-output parity and L20X/B300 feasibility measured on the implementation through PR commit `60e9bbad` |
| MiniMax H3 Ref2VA video VAE | Same released manifest/listed temporal attributes | Source-compatible; not separately benchmarked | Ref2VA and FL2VA use byte-identical video-VAE source and configuration in the reference snapshot |
| MiniMax H3 with a different Python manifest or listed temporal attribute | Different enforced contract | Unsupported for chunk mode | Fails closed before decoder collectives; complete decode remains available |
| Same manifest/temporal attributes with different weights or other component metadata | Not checked by this gate | Not independently qualified | The adapter does not inspect weights, repository identity, revision metadata, or arbitrary component configuration |
| Other video models | Any | Unsupported | No generic temporal VAE producer ABI is declared by this feature |
## Feature compatibility
| Dimension or combination | Status | Constraint |
| --- | --- | --- |
| Existing complete-output decode | Supported and default | No source check, callback object, or callback rendezvous |
| Explicit chunk decode | Supported internally | Caller invokes `decode_latent_with_chunks()` directly |
| VAE patch parallel size 1 | Supported by the model seam | Caller supplies the callback only on the request output owner; no WORLD rendezvous is added |
| VAE patch parallel size equal to the full DiT group | Supported when data parallel size is 1 | Every VAE-group rank calls; exactly group rank 0 owns the callback |
| Partial VAE patch subgroup | Unsupported | MiniMax H3 currently accepts size 1 or the full DiT group only |
| Data parallel size greater than 1 with VAE patch parallel size 1 | Model seam only | Per-replica callback ownership is possible, but no serving/runtime consumer is wired |
| Data parallel size greater than 1 with VAE patch parallel size greater than 1 | Unsupported | Fails before decoder collectives because no replica-local VAE patch group is defined |
| Too few spatial tiles for the VAE group | Correctness fallback | Decodes rank-locally and restores native tiling state; slower than patch parallel decode |
| Device memory and full-output assembly | Not reduced or bounded | The compatibility path still assembles and returns the full decode while allocating owned callback chunks; no peak-HBM reduction is claimed |
| DiT cache, attention, TP, SP, HSDP, LoRA, or quantization features | No direct callback dependency | This seam runs after denoising and does not independently qualify combinations beyond the MiniMax H3 model's own matrix |
| Layerwise or distributed component offload | Not separately validated | Component residency must place the video VAE before decode |
| Step execution or request batching | Not integrated | No pipeline caller binds a runtime chunk consumer |
| Requested-size crop and device uint8 preparation | Not implemented here | Runtime-owned follow-up |
| Pinned D2H, SHM/ZMQ, or cross-process transport | Not implemented here | Runtime-owned follow-up |
| Complete MP4 or fragmented MP4 sink | Not implemented here | Existing serving behavior still waits for complete output |
| Audio VAE chunks and incremental A/V composition | Not supported | Video chunks do not imply audio or request finality |
| Cancellation and multi-output lease ownership | Not implemented here | Requires a bounded runtime lifecycle |
| NVIDIA CUDA | Feasibility evidence | L20X and B300 measurements apply to the implementation through `60e9bbad`; the current coordination refactor was not reprofiled |
| AMD ROCm | Not validated for this feature | Base MiniMax H3 support is documented separately and does not imply temporal-chunk validation |
| Ascend, Intel, or other accelerators | No feature support claim | Consult the model support table independently; this design adds no platform qualification |
## Distributed and failure semantics
When VAE patch parallelism is active, all VAE-group ranks run the same
preflight before entering checkpoint collectives. Source/planner errors and the
resolved temporal concat dtype are reconciled at fixed group rendezvous points.
Callback ownership is also a group decision. Rank `r` contributes `r + 1` when
it owns a callback and zero otherwise. A group SUM equals one only when rank 0
is the sole owner; every invalid owner configuration therefore fails
identically on all ranks before decode.
Callback exceptions are retained on the output rank. Further publication stops,
but every rank completes the same remaining native decoder collective schedule.
The original exception is then raised on the owner and
`MiniMaxH3ChunkCallbackPeerError` on peers.
Exceptions raised inside the checkpoint's native distributed decode cannot in
general be reconciled by the adapter: a peer may already be blocked inside a
checkpoint-owned collective. Resolving such failures requires native collective
handling or supervisor-level timeout and worker-group teardown. The process
group must not be assumed reusable after such a failure.
## Validation evidence
The contract suite covers ordinary and padded frame plans, blend boundaries,
owned storage and mutation isolation, source-manifest/listed-temporal-attribute
drift, callback failure, and healthy subsequent requests. Real Gloo processes cover
rank-asymmetric preflight failures, dtype disagreement, two-rank callback
failure propagation, three-rank wrong-owner rejection, insufficient-tile
fallback, and group recovery for failures that occur at adapter-owned
rendezvous points.
Four-device L20X and independent four- and eight-device B300 experiments on the
implementation through PR commit `60e9bbad` showed exact serial-versus-chunked
frame/MP4 parity within each environment and lower VAE-to-complete-MP4 latency
when an experimental consumer was attached. Hashes are not claimed to match
across different hardware/software environments. The later coordination and
module-boundary refactors were revalidated with CPU and real Gloo tests but were
not reprofiled on GPUs. These are feasibility measurements, not a promise for
default serving, because this change does not contain the transport or encoder
consumer. See [RFC #6872](https://github.com/vllm-project/vllm-omni/issues/6872)
and [PR #6885](https://github.com/vllm-project/vllm-omni/pull/6885) for the
measurement provenance and limitations.
## Generalization boundary and follow-ups
This feature does not define a repository-wide `SupportsVideoChunkDecode`
interface. H3 temporal blending, Wan causal caches, LTX temporal/spatial halos,
and pipeline-level generation chunks have different commit and failure
boundaries. A generic producer contract should be extracted only after a second
model proves the same semantic event without `Any` arguments or model-name
dispatch.
The reusable future boundary is the emitted, committed video-media chunk, not a
universal latent-to-VAE method. It should extend the typed media work in
[RFC #6541](https://github.com/vllm-project/vllm-omni/issues/6541) and
[PR #6615](https://github.com/vllm-project/vllm-omni/pull/6615), while keeping
model-specific halo, cache, padding, blending, distributed, and source-gating
logic inside each model adapter.
Runtime follow-ups own:
- typed video-plus-audio media and request/output identity;
- requested-size crop and device preparation;
- bounded leases, backpressure, cancellation, and cleanup;
- D2H/IPC transport and observability; and
- complete-MP4 and fragmented-MP4 consumers.
## Related documentation
- [Diffusion model integration](../module/diffusion/diffusion_model_integration.md)
- [VAE patch parallelism](vae_parallel.md)
- [Async diffusion output](async_diffusion_output.md)
- [MiniMax H3 recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/MiniMaxAI/MiniMax-H3.md)
+4
View File
@@ -25,6 +25,10 @@ implementation contract; it is not, by itself, a general support claim.
- [Automatic Prefix Caching in Omni Models](feature/prefix_caching.md)
- [Realtime AR-Diffusion Sessions](feature/realtime_ar_diffusion.md)
### Model-specific capabilities
- [MiniMax H3 Temporal VAE Chunks](feature/minimax_h3_temporal_vae_chunks.md)
### Communication
#### OmniConnector implementations
@@ -57,3 +57,10 @@ cross-stage routing.
Test registry selection, checkpoint loading, minimal inference, input and output
contracts, and every declared optional capability.
## Model-specific capability documents
- [MiniMax H3 temporal VAE chunk callback](../../feature/minimax_h3_temporal_vae_chunks.md)
defines the source-gated producer seam and its boundary with future runtime
media transport. It is an internal opt-in capability, not a public serving
feature.
+3 -1
View File
@@ -3,4 +3,6 @@
vLLM-Omni's examples are split into two categories:
- If you are using vLLM-Omni from within Python code, see the *Offline Inference* section.
- If you are using vLLM-Omni from an HTTP application or client, see the *Online Serving* section.
- If you are using vLLM-Omni from an HTTP application or client, start with
the [API Server guide](../serving/README.md) to choose an endpoint, then use
the *Online Serving* section for model-specific examples.
+1 -1
View File
@@ -34,7 +34,7 @@ vLLM-Omni is a Python library that supports the following GPU variants. The libr
### Pre-built wheels
Note: Pre-built wheels are currently available for vLLM-Omni 0.11.0rc1, 0.12.0rc1, 0.14.0rc1, 0.14.0, 0.16.0, 0.18.0, 0.20.0, 0.21.0, 0.22.0, 0.23.0, 0.24.0, 0.25.0, and 0.26.0; the 0.28.0 wheels are published together with the v0.28.0 release this branch produces. If you need a newer unreleased revision, please [build from source](https://docs.vllm.ai/projects/vllm-omni/en/latest/getting_started/installation/gpu/#build-wheel-from-source).
Note: Pre-built wheels are currently available for vLLM-Omni 0.11.0rc1, 0.12.0rc1, 0.14.0rc1, 0.14.0, 0.16.0, 0.18.0, 0.20.0, 0.21.0, 0.22.0, 0.23.0, 0.24.0, 0.25.0, 0.26.0, and 0.28.0. If you need a newer unreleased revision, please [build from source](https://docs.vllm.ai/projects/vllm-omni/en/latest/getting_started/installation/gpu/#build-wheel-from-source).
=== "NVIDIA CUDA"
@@ -5,7 +5,7 @@
# --8<-- [end:requirements]
# --8<-- [start:set-up-using-python]
vLLM-Omni depends on the matching major/minor release of vLLM. The vLLM-Omni 0.26.x release line uses vLLM 0.26.x.
vLLM-Omni depends on the matching major/minor release of vLLM. The vLLM-Omni 0.28.x release line uses vLLM 0.28.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.
@@ -95,6 +95,13 @@ uv pip install --no-build-isolation --editable .
vLLM-Omni offers an official docker image for deployment. These images are built on top of vLLM docker images and available on Docker Hub as [vllm/vllm-omni](https://hub.docker.com/r/vllm/vllm-omni/tags). The version of vLLM-Omni indicates which release of vLLM it is based on.
Nightly CUDA images built from the latest `main` are also available:
- [`vllm/vllm-omni:nightly`](https://hub.docker.com/r/vllm/vllm-omni/tags?name=nightly) is the rolling multi-architecture tag.
- [`vllm/vllm-omni:nightly-<commit>`](https://hub.docker.com/r/vllm/vllm-omni/tags?name=nightly-) provides commit-pinned multi-architecture tags. The newest 14 are retained.
Use a commit-pinned tag when you need a reproducible nightly environment.
Here's an example deployment command that has been verified on 2 x H100's:
```bash
docker run --runtime nvidia --gpus 2 \
@@ -9,8 +9,8 @@ The recommended way to use vLLM-Omni on NPU is through the vLLM-Ascend pre-built
```bash
# vLLM-Ascend image
# Atlas A2: quay.io/atlas-ci/vllm-ascend:v0.26.0
# Atlas A3: quay.io/atlas-ci/vllm-ascend:v0.26.0-a3
# Atlas A2: quay.io/atlas-ci/vllm-ascend:v0.28.0
# Atlas A3: quay.io/atlas-ci/vllm-ascend:v0.28.0-a3
docker run --rm \
--name vllm-omni-npu \
--shm-size=1g \
@@ -28,11 +28,11 @@ docker run --rm \
-v /etc/ascend_install.info:/etc/ascend_install.info \
-v /root/.cache:/root/.cache \
-p 8000:8000 \
-it quay.io/atlas-ci/vllm-ascend:v0.26.0-a3 bash
-it quay.io/atlas-ci/vllm-ascend:v0.28.0-a3 bash
# Inside the container, install vLLM-Omni from source
cd /vllm-workspace
git clone -b v0.26.0 https://github.com/vllm-project/vllm-omni.git
git clone -b v0.28.0 https://github.com/vllm-project/vllm-omni.git
cd vllm-omni
pip install -v -e . --no-build-isolation
# or VLLM_OMNI_TARGET_DEVICE=npu pip install -v -e .
@@ -50,21 +50,11 @@ We are keeping [issue #886](https://github.com/vllm-project/vllm-omni/issues/886
# --8<-- [start:installation-main]
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.)
You can also install vLLM-Omni from the latest main branch if you want to use the latest features or bug fixes. Use the aligned vLLM-Ascend v0.28.0 image described above, and check [issue #886](https://github.com/vllm-project/vllm-omni/issues/886) for the status of the latest vLLM-Omni commit on NPU.
```bash
# 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 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
# Inside the vLLM-Ascend v0.28.0 container, install vLLM-Omni from main
cd /vllm-workspace
git clone https://github.com/vllm-project/vllm-omni.git
cd vllm-omni
pip install -v -e . --no-build-isolation
@@ -88,8 +78,8 @@ Supported images as following.
Here's an example deployment command that has been verified on 4 x NPUs:
```bash
# Atlas A2: quay.io/ascend/vllm-omni:v0.26.0
# Atlas A3: quay.io/ascend/vllm-omni:v0.26.0-a3
# Atlas A2: quay.io/ascend/vllm-omni:v0.28.0
# Atlas A3: quay.io/ascend/vllm-omni:v0.28.0-a3
docker run --rm \
--name vllm-omni-a3 \
--shm-size=4g \
@@ -114,7 +104,7 @@ docker run --rm \
-v /etc/ascend_install.info:/etc/ascend_install.info \
-v ~/.cache:/root/.cache \
-p 8091:8091 \
-it quay.io/ascend/vllm-omni:v0.26.0-a3 bash
-it quay.io/ascend/vllm-omni:v0.28.0-a3 bash
```
!!! tip
+10 -14
View File
@@ -91,7 +91,7 @@ if __name__ == "__main__":
For more usages, please refer to [offline inference](../user_guide/examples/offline_inference/qwen2_5_omni.md)
## Online Serving with OpenAI-Completions API
## Online Serving with the API Server
Text-to-image generation quickstart with vLLM-Omni:
@@ -100,20 +100,16 @@ vllm serve Tongyi-MAI/Z-Image-Turbo --omni --port 8091
```
```bash
curl -s http://localhost:8091/v1/chat/completions \
curl -s http://localhost:8091/v1/images/generations \
-H "Content-Type: application/json" \
-d '{
"messages": [
{"role": "user", "content": "a cup of coffee on the table"}
],
"extra_body": {
"height": 1024,
"width": 1024,
"num_inference_steps": 50,
"guidance_scale": 4.0,
"seed": 42
}
}' | jq -r '.choices[0].message.content[0].image_url.url' | cut -d',' -f2 | base64 -d > coffee.png
"prompt": "a cup of coffee on the table",
"size": "1024x1024",
"response_format": "b64_json",
"seed": 42
}' | jq -r '.data[0].b64_json' | base64 -d > coffee.png
```
For more details, please refer to [online serving](../user_guide/examples/online_serving/text_to_image.md).
See the [API Server guide](../serving/README.md) to choose an endpoint. For
model-specific details, refer to the [text-to-image online serving
example](../user_guide/examples/online_serving/text_to_image.md).
+3 -3
View File
@@ -21,7 +21,7 @@ th {
|--------------|--------|-------------------|------------|---------|-----|-----------|--------|
| `Qwen3OmniMoeForConditionalGeneration` | Qwen3-Omni | `Qwen/Qwen3-Omni-30B-A3B-Instruct` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/Qwen/Qwen3-Omni.md) |
| `Qwen2_5OmniForConditionalGeneration` | Qwen2.5-Omni | `Qwen/Qwen2.5-Omni-7B`, `Qwen/Qwen2.5-Omni-3B` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `MingFlashOmniForConditionalGeneration` + `MingImagePipeline` | Ming-flash-omni-2.0 (omni-speech + imagegen<sup>1</sup>) | `Jonathan1909/Ming-flash-omni-2.0` | ✅︎ | | | | — |
| `MingFlashOmniForConditionalGeneration` + `MingImagePipeline` | Ming-flash-omni-2.0 (omni-speech + imagegen<sup>1</sup>) | `Jonathan1909/Ming-flash-omni-2.0` | ✅︎ | | | | — |
| `BagelForConditionalGeneration` | BAGEL (DiT-only) | `ByteDance-Seed/BAGEL-7B-MoT` | ✅︎ | ✅︎ | | ✅︎ | — |
| `InternVLAA1Pipeline` | InternVLA-A1 | `InternRobotics/InternVLA-A1-3B` | ✅︎ | ✅︎ | | | — |
| `Gr00tN1d7Pipeline` | GR00T N1.7 | `nvidia/GR00T-N1.7-3B` | ✅︎ | | | | — |
@@ -62,7 +62,7 @@ th {
| `LongcatImagePipeline` | LongCat-Image | `meituan-longcat/LongCat-Image` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `LongCatImageEditPipeline` | LongCat-Image-Edit | `meituan-longcat/LongCat-Image-Edit` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `LongCatVideoAvatarPipeline` | LongCat-Video-Avatar-1.5 A2V/AI2V (native single-speaker and multi-speaker AI2V/AVC) | `meituan-longcat/LongCat-Video-Avatar-1.5` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/meituan-longcat/LongCat-Video-Avatar-1.5.md) |
| `BooguImagePipeline` | Boogu-Image | `Boogu/Boogu-Image-0.1-Base`, `Boogu/Boogu-Image-0.1-Edit` | ✅︎ | | | ✅︎ | — |
| `BooguImagePipeline` | Boogu-Image | `Boogu/Boogu-Image-0.1-Base`, `Boogu/Boogu-Image-0.1-Edit`, `Boogu/Boogu-Image-0.1-Edit-Turbo` | ✅︎ | | | ✅︎ | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/Boogu/Boogu-Image.md) |
| `StableDiffusionXLPipeline` | Stable-Diffusion-XL | `stabilityai/stable-diffusion-xl-base-1.0` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `StableDiffusion3Pipeline` | Stable-Diffusion-3 | `stabilityai/stable-diffusion-3.5-medium` | ✅︎ | ✅︎ | | ✅︎ | — |
| `CosyVoice3Model` | CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | ✅︎ | ✅︎ | | ✅︎ | — |
@@ -95,7 +95,7 @@ th {
| `MiMoV2ASRForCausalLM` | MiMo-V2.5-ASR | `XiaomiMiMo/MiMo-V2.5-ASR` | ✅︎ | ✅︎ | | | — |
| `Flux2Pipeline` | FLUX.2-dev | `black-forest-labs/FLUX.2-dev` | ✅︎ | ✅︎ | | | — |
| `FishSpeechSlowARForConditionalGeneration` | Fish Speech S2 Pro | `fishaudio/s2-pro` | ✅︎ | ✅︎ | | ✅︎ | — |
| `SenseNovaU1Pipeline` | SenseNova-U1 (DiT-only) | `SenseNova/SenseNova-U1-8B-MoT` | ✅︎ | | | | — |
| `SenseNovaU1Pipeline` | SenseNova-U1 (DiT-only), SenseNova-U1.5 | `SenseNova/SenseNova-U1-8B-MoT`, `sensenova/SenseNova-U1.5-8B-MoT` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/SenseNova/SenseNova-U1.5.md) |
| `LancePipeline` | Lance | `bytedance-research/Lance` | ✅︎ | | | | — |
| `HunyuanVideo15Pipeline` | HunyuanVideo-1.5-T2V | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v`, `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_t2v` | ✅︎ | ✅︎ | | | — |
| `HunyuanVideo15ImageToVideoPipeline` | HunyuanVideo-1.5-I2V | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_i2v`, `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_i2v` | ✅︎ | ✅︎ | | | — |
+187
View File
@@ -0,0 +1,187 @@
# API Server
vLLM-Omni exposes OpenAI-compatible HTTP APIs plus vLLM-Omni extensions for
image, audio, video, realtime, and robot-policy workloads. Use this page to
choose an endpoint; follow the linked reference page for model-specific fields,
wire protocols, and response details.
Each server instance hosts one model. An endpoint is usable only when the
loaded model supports that task.
This guide focuses on vLLM-Omni's primary task endpoints. Compatible models
may also expose standard routes inherited from vLLM, such as
`POST /v1/completions`; those routes remain model- and task-dependent.
## Start and Verify the Server
Start a model with the shared serving command:
```bash
vllm serve <model> --omni --port 8091
```
Some models require additional flags or a deployment configuration. Use the
model-specific guide when one is provided.
After the server starts, check its health and served model name:
```bash
export VLLM_OMNI_BASE_URL=http://localhost:8091
curl "$VLLM_OMNI_BASE_URL/health"
curl "$VLLM_OMNI_BASE_URL/v1/models" | jq .
```
Use `http://localhost:8091/v1` as the `base_url` for an OpenAI SDK client. If
the server was started with `--api-key`, include an `Authorization` header with
`Bearer <api-key>` in requests.
## Choose a Core HTTP Endpoint
Prefer a task-specific endpoint when one matches your workload. Use Chat
Completions for conversational or heterogeneous omni pipelines, rather than as
the default wrapper for every generation task.
| Task | Endpoint | Request | Response | Details |
|------|----------|---------|----------|---------|
| Conversation or multimodal understanding/generation | `POST /v1/chat/completions` | JSON | OpenAI-style JSON or SSE | [Chat Completions](chat_completions_api.md) |
| Text-to-speech | `POST /v1/audio/speech` | JSON | Audio bytes or SSE | [Speech](speech_api.md) |
| Sound, music, or ambient audio generation | `POST /v1/audio/generate` | JSON | Audio bytes | [Audio Generation](audio_generate_api.md) |
| Text-to-image | `POST /v1/images/generations` | JSON | Base64 JSON or an image file | [Image Generation](image_generation_api.md) |
| Image editing | `POST /v1/images/edits` | Multipart form | Base64 JSON or SSE | [Image Edit](image_edit_api.md) |
| Video generation (recommended) | `POST /v1/videos` | Multipart form | Asynchronous job JSON | [Videos](videos_api.md) |
| Video generation (blocking) | `POST /v1/videos/sync` | Multipart form | Video bytes | [Videos](videos_api.md#synchronous-response) |
`POST /v1/videos/sync` is intended for tests and simple scripts. Use the
asynchronous job API for production requests so clients can poll, download,
and delete generated videos independently.
## Minimal Requests
The examples below assume a compatible model is already running. TTS voices,
media inputs, generation controls, and output capabilities vary by model.
=== "Chat"
```bash
curl "$VLLM_OMNI_BASE_URL/v1/chat/completions" \
-H "Content-Type: application/json" \
-d '{
"messages": [{"role": "user", "content": "Describe vLLM-Omni briefly."}],
"modalities": ["text"]
}'
```
=== "Speech"
```bash
curl "$VLLM_OMNI_BASE_URL/v1/audio/speech" \
-H "Content-Type: application/json" \
-d '{
"input": "Hello from vLLM-Omni.",
"voice": "vivian"
}' \
--output speech.wav
```
=== "Audio"
```bash
curl "$VLLM_OMNI_BASE_URL/v1/audio/generate" \
-H "Content-Type: application/json" \
-d '{
"input": "Ocean waves on a quiet beach",
"audio_length": 5.0
}' \
--output audio.wav
```
=== "Image"
```bash
curl "$VLLM_OMNI_BASE_URL/v1/images/generations" \
-H "Content-Type: application/json" \
-d '{
"prompt": "A small robot reading beside a window",
"size": "1024x1024",
"response_format": "file"
}' \
--output image.png
```
=== "Image edit"
```bash
curl "$VLLM_OMNI_BASE_URL/v1/images/edits" \
-F "image=@input.png" \
-F "prompt=Turn the background into a snowy mountain" \
| jq -r '.data[0].b64_json' \
| base64 --decode > edited.png
```
=== "Video"
```bash
curl "$VLLM_OMNI_BASE_URL/v1/videos" \
-F "prompt=A cinematic tracking shot of a mountain lake at sunrise" \
-F "size=1280x720" \
-F "seconds=5"
```
The response contains a video job `id`. Poll and download it with:
```bash
curl "$VLLM_OMNI_BASE_URL/v1/videos/<video_id>"
curl -L "$VLLM_OMNI_BASE_URL/v1/videos/<video_id>/content" --output video.mp4
```
## Choose a Streaming or Realtime Endpoint
These WebSocket APIs have different event schemas and cannot be used
interchangeably.
| Workload | Endpoint | Interaction model | Details |
|----------|----------|-------------------|---------|
| Incremental text input for speech synthesis | `WS /v1/audio/speech/stream` | Send text events and receive audio | [Streaming Text to Speech](speech_api.md#streaming-text-input-websocket) |
| Live video understanding | `WS /v1/video/chat/stream` | Send video frames and receive text/audio | [Streaming Video Input](video_stream_api.md) |
| Turn-based realtime audio | `WS /v1/realtime` | Stream one audio input and receive transcript/audio events | [Realtime Audio](realtime_api.md) |
| Continuous speech-to-speech interaction | `WS /v1/realtime?duplex=1` or `WS /v1/duplex` | Listen and speak concurrently with session control | [Full Duplex](full_duplex_api.md) |
| Generated video chunks | `WS /v1/realtime/video` | Start a diffusion request and receive fragmented MP4 | [Streaming Video Output](streaming_video_output_api.md) |
| Robot policy inference | `WS /v1/realtime/robot/openpi` | Send MessagePack observations and receive action arrays | [OpenPI Robot Policy](openpi_api.md) |
All six routes are model- or configuration-dependent. In particular,
`/v1/realtime` is not full duplex unless the client sets `duplex=1` and the
deployment explicitly enables duplex sessions. Clients should also verify the
duplex capability payload because the query-parameter form falls back to the
ordinary realtime handler when duplex is unavailable.
## Related Endpoints
| Purpose | Endpoints | Reference |
|---------|-----------|-----------|
| Discovery and readiness | `GET /health`, `GET /v1/models` | This page |
| Batched conversations | `POST /v1/chat/completions/batch` | [Batch requests](chat_completions_api.md#batch-requests) |
| Batched speech | `POST /v1/audio/speech/batch` | [Batch speech generation](speech_api.md#batch-speech-generation) |
| TTS voice management | `GET/POST /v1/audio/voices`, `DELETE /v1/audio/voices/{name}` | [Voices](speech_api.md#voices-endpoint) |
| Video job lifecycle | `GET /v1/videos`, `GET/DELETE /v1/videos/{video_id}`, `GET /v1/videos/{video_id}/content` | [Video endpoints](videos_api.md#endpoints) |
| Release and restore stage memory | `POST /v1/omni/sleep`, `POST /v1/omni/wakeup` | [Sleep Mode](../features/sleep_mode.md) |
## Standalone Experimental Servers
PersonaPlex also provides a Moshi-compatible WebSocket server, while JoyVL
provides a stateful interaction orchestrator in front of another model server.
These are separate processes with their own routes; they are not additional
paths on every `vllm serve --omni` instance. See [Standalone Experimental
Servers](standalone_servers.md) before deploying either one.
## Compatibility Notes
- A registered path does not mean every model supports it. Unsupported tasks
return an OpenAI-style error or a WebSocket `error` event.
- Use JSON for Chat, Speech, Audio Generation, and Image Generation. Use
`multipart/form-data` when uploading images, videos, or other files.
- Generation can take minutes for large diffusion models. Configure client
timeouts accordingly; prefer asynchronous video jobs over long blocking
requests.
- Model-specific parameters are vLLM-Omni extensions. Check the endpoint
reference before assuming an OpenAI SDK exposes them directly.
+3 -3
View File
@@ -4,14 +4,14 @@ vLLM-Omni provides an API for text-to-audio generation using diffusion-based mod
Unlike the [Speech API](speech_api.md) which targets text-to-speech synthesis, the Audio Generate API is designed for general-purpose audio generation from text descriptions (sound effects, music, ambient soundscapes, etc.).
Each server instance runs a single model (specified at startup via `vllm-omni serve <model> --omni`).
Each server instance runs a single model (specified at startup via `vllm serve <model> --omni`).
## Quick Start
### Start the Server
```bash
vllm-omni serve stabilityai/stable-audio-open-1.0 \
vllm serve stabilityai/stable-audio-open-1.0 \
--host 0.0.0.0 \
--port 8091 \
--gpu-memory-utilization 0.9 \
@@ -326,7 +326,7 @@ curl http://localhost:8091/health
Enable debug logging:
```bash
vllm-omni serve stabilityai/stable-audio-open-1.0 \
vllm serve stabilityai/stable-audio-open-1.0 \
--host 0.0.0.0 \
--port 8091 \
--gpu-memory-utilization 0.9 \
+86 -54
View File
@@ -1,78 +1,110 @@
# Chat Completions API
vLLM-Omni supports generating and editing images via the `/v1/chat/completions`
endpoint using diffusion models. This page explains how to pass generation
parameters (such as `num_inference_steps`, `height`, `width`) to diffusion
models through this endpoint.
Use `/v1/chat/completions` for conversational and multimodal pipelines that
follow the OpenAI Chat API schema. Depending on the loaded model, a request can
contain text, image, audio, or video input and can return text, audio, images,
or other model-specific output.
!!! tip
For dedicated endpoints that accept generation parameters as top-level
fields, see [Image Generation API](image_generation_api.md) and
[Image Edit API](image_edit_api.md).
Some diffusion pipelines also support image generation and editing through
Chat Completions. Prefer the dedicated [Image Generation
API](image_generation_api.md) or [Image Edit API](image_edit_api.md) for
task-specific clients; their request fields and response contracts are more
direct.
## Passing Generation Parameters
The `/v1/chat/completions` endpoint follows the OpenAI Chat API schema, which
does not natively include diffusion-specific fields like `num_inference_steps`
or `height`. How you pass these extra fields depends on your client.
### curl / Python `requests`
Wrap generation parameters inside an `"extra_body"` key in the JSON body:
## Basic Request
```bash
curl -s http://localhost:8091/v1/chat/completions \
curl http://localhost:8091/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"messages": [
{"role": "user", "content": "A beautiful landscape painting"}
{"role": "user", "content": "Describe vLLM-Omni briefly."}
],
"extra_body": {
"num_inference_steps": 50,
"seed": 42
}
"modalities": ["text"]
}'
```
### OpenAI Python SDK
Use `stream: true` for Server-Sent Events. Input media syntax, supported output
`modalities`, and response choices depend on the model; see the
[model-specific online serving examples](../user_guide/examples/online_serving/qwen3_omni.md).
Use the `extra_body` **keyword argument**. The SDK automatically merges these
fields into the top-level request body:
!!! tip
Each server hosts one model. Query `GET /v1/models` for its served model
name when your client requires the `model` field.
```python
response = client.chat.completions.create(
model="Qwen/Qwen-Image",
messages=[{"role": "user", "content": "A beautiful landscape painting"}],
extra_body={
"num_inference_steps": 50,
"seed": 42,
},
)
## vLLM Extension Parameters
Send standard and vLLM-specific Chat parameters as top-level fields in a
direct HTTP request. When using the OpenAI Python SDK, pass vLLM-specific
fields through the SDK's `extra_body` keyword; the SDK merges them into the
top-level JSON sent to the server.
=== "curl"
```bash
curl http://localhost:8091/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"messages": [{"role": "user", "content": "Write a short haiku."}],
"top_k": 40
}'
```
=== "OpenAI Python SDK"
```python
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8091/v1", api_key="none")
response = client.chat.completions.create(
model="your-served-model",
messages=[{"role": "user", "content": "Write a short haiku."}],
extra_body={"top_k": 40},
)
```
??? note "Diffusion compatibility"
Diffusion models exposed through Chat Completions can accept supported
fields such as `num_inference_steps`, `seed`, `height`, and `width`. Send
them at the top level with direct HTTP, or in the SDK's `extra_body`
keyword argument.
A literal nested `"extra_body"` JSON object is accepted for compatibility,
but it is not recommended for new direct HTTP clients. Do not provide the
same parameter in multiple locations; the server returns a `400` error for
duplicate diffusion parameters. Prefer the dedicated [Image Generation
API](image_generation_api.md) or [Image Edit API](image_edit_api.md) when
either endpoint matches the task.
## Batch Requests
`POST /v1/chat/completions/batch` accepts the same shared generation fields,
but `messages` is a list of conversations. The response contains one choice
per conversation in input order.
```bash
curl http://localhost:8091/v1/chat/completions/batch \
-H "Content-Type: application/json" \
-d '{
"messages": [
[{"role": "user", "content": "Summarize vLLM in one sentence."}],
[{"role": "user", "content": "Summarize vLLM-Omni in one sentence."}]
],
"max_tokens": 64
}'
```
!!! note "SDK `extra_body` vs. JSON `extra_body`"
These two `extra_body` usages look similar but work differently under the
hood. The SDK flattens the dict into the top-level request JSON, while the
curl/requests approach sends it as a nested `"extra_body"` key. Both are
handled correctly by the server.
!!! note "About the `ignored fields` warning"
You may see a log message like:
```
WARNING: The following fields were present in the request but ignored: {'height', 'width', ...}
```
This is **harmless**. It is emitted by vLLM's request validation layer
because these fields are not part of the standard OpenAI
`ChatCompletionRequest` schema. The fields are still stored internally
and correctly forwarded to the diffusion pipeline.
Batch Chat Completions does not support streaming, tools, beam search, or
`n > 1`.
## Model-Specific Examples
For complete examples with full request/response details, see the model-specific
guides:
For complete examples with model-specific inputs and outputs, see:
- [Qwen3-Omni](../user_guide/examples/online_serving/qwen3_omni.md)
- [Qwen2.5-Omni](../user_guide/examples/online_serving/qwen2_5_omni.md)
- [Text-to-Image (Qwen-Image)](../user_guide/examples/online_serving/text_to_image.md)
- [Image-to-Image (Qwen-Image-Edit, Qwen-Image-Layered)](../user_guide/examples/online_serving/image_to_image.md)
- [GLM-Image](../user_guide/examples/online_serving/glm_image.md)
+116
View File
@@ -0,0 +1,116 @@
# Full-Duplex WebSocket API
vLLM-Omni provides an experimental full-duplex runtime for models that can
continue receiving speech while producing speech. It adds persistent session
state, model-specific turn policy, overlap handling, playback acknowledgement,
and optional session resume.
Full duplex is distinct from the turn-based [Realtime Audio API](realtime_api.md).
## Choose an Endpoint
| Endpoint | Protocol | Recommended use |
|----------|----------|-----------------|
| `WS /v1/realtime?duplex=1` | OpenAI Realtime-style event projection | Applications and browser clients |
| `WS /v1/duplex` | Native vLLM-Omni duplex events | Runtime integration and low-level testing |
Both endpoints use the same duplex engine and require the same model-side
adapter. Prefer the Realtime projection unless the native lifecycle events are
specifically required.
## Enable Full Duplex
The route becomes usable only when the deployment configuration explicitly
sets:
```yaml
session_mode: duplex
```
The selected model pipeline must also provide a duplex serving adapter.
Model-native deployments configure the corresponding engine runtime extension
and control plane as part of their registered pipeline.
!!! warning
`WS /v1/duplex` fails with `Duplex API is not available` when duplex is not
enabled. By contrast, `/v1/realtime?duplex=1` falls back to the ordinary
turn-based realtime handler when no duplex handler exists. Confirm that
`session.created.session.capabilities` is present before treating the
connection as full duplex.
The current unified-runtime integrations are:
- MiniCPM-o 4.5, using `vllm_omni/deploy/minicpmo_4_5_duplex.yaml`;
- PersonaPlex, whose default `vllm_omni/deploy/personaplex.yaml` enables duplex.
JoyVL is a separate HTTP interaction orchestrator and does not use these
WebSocket endpoints. See [Standalone Experimental Servers](standalone_servers.md).
## MiniCPM-o Quick Start
Start the duplex deployment:
```bash
vllm serve openbmb/MiniCPM-o-4_5 --omni \
--deploy-config vllm_omni/deploy/minicpmo_4_5_duplex.yaml \
--trust-remote-code \
--port 8091
```
Stream a mono, PCM16, 16 kHz WAV file with the provided client:
```bash
python examples/online_serving/minicpmo/realtime_duplex_demo.py \
--url 'ws://localhost:8091/v1/realtime?duplex=1' \
--model openbmb/MiniCPM-o-4_5 \
--input-wav input_16k_mono.wav \
--ref-audio reference_voice.wav \
--output-dir /tmp/minicpmo-duplex
```
## Realtime Event Lifecycle
A typical `/v1/realtime?duplex=1` session follows this lifecycle:
1. Send `session.update` with the model, modalities, audio formats, and session
options.
2. Wait for `session.created`; inspect `session.capabilities` instead of
assuming every model supports the same controls.
3. Send `input_audio_buffer.append` events while microphone audio arrives.
4. Send `input_audio_buffer.commit` at a user-turn boundary when required by
the model policy.
5. Consume `response.created`, transcript deltas, `response.audio.delta`, and
`response.done` or `response.listen` events.
6. Send `playback.ack` after audio has been played when the session advertises
playback acknowledgement support.
7. Send `session.close` and wait for `session.closed`.
Unlike the turn-based realtime endpoint, input may continue while a response
is active. The server can emit `overlap.decision` to describe whether input was
deferred, treated as a short acknowledgement, or used to interrupt output.
## Capabilities and Model Differences
The `session.created` payload includes capability fields such as
`supports_barge_in`, `supports_playback_ack`, `supports_multi_session`,
`supports_session_resume`, and `chunk_period_ms`. Treat this payload as the
runtime contract.
For example, PersonaPlex supports native overlapping speech but currently
advertises `supports_barge_in=false`; destructive output interruption and
model-state rewind have not been validated for that integration. Capacity and
session-resume behavior also depend on the selected deployment configuration.
## Native Protocol
`WS /v1/duplex` exposes lower-level lifecycle names including
`session.create`, `input_audio_buffer.append`, `turn.signal`, `playback.ack`,
and `session.close`. It returns native session, input, response, overlap, and
error events. This protocol is experimental and may evolve with the runtime;
applications should use the provided Realtime client where possible.
See the [MiniCPM-o example](https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/minicpmo),
[PersonaPlex example](https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/personaplex),
and [full-duplex runtime design](https://github.com/vllm-project/vllm-omni/blob/main/vllm_omni/experimental/fullduplex/DESIGN.md)
for model-specific validation and architecture details.
+109
View File
@@ -0,0 +1,109 @@
# OpenPI Robot Policy WebSocket API
Use `WS /v1/realtime/robot/openpi` for low-latency robot-policy inference with
OpenPI-compatible clients. A persistent WebSocket carries observations to the
loaded policy model and returns action arrays.
Despite the `/realtime` prefix, this endpoint does not use the OpenAI Realtime
event schema. It uses binary MessagePack frames with NumPy extensions.
## Availability
The endpoint is available only when the loaded diffusion policy configuration
contains `policy_server_config`. The server sends that model-specific
configuration to the client as the first binary frame.
For example, DreamZero declares:
```yaml
policy_server_config:
image_resolution: [180, 320]
n_external_cameras: 2
needs_wrist_camera: true
needs_stereo_camera: false
needs_session_id: true
action_space: joint_position
```
Without this configuration, the WebSocket returns `Robot policy not available`
and closes.
## DreamZero Quick Start
Start the bundled DreamZero deployment:
```bash
vllm serve GEAR-Dreams/DreamZero-DROID --omni --port 8091 \
--served-model-name dreamzero-droid \
--deploy-config vllm_omni/deploy/dreamzero_tp1_cfg2.yaml \
--enforce-eager --disable-log-stats
```
Install the optional client dependencies, download the sample camera inputs,
and run the client:
```bash
pip install openpi-client websockets opencv-python
hf download YangshenDeng/vllm-omni-dreamzero-assets \
--repo-type dataset \
--local-dir outputs/dreamzero/assets
python examples/online_serving/dreamzero/openpi_client.py \
--host 127.0.0.1 \
--port 8091 \
--video-dir outputs/dreamzero/assets
```
## Protocol
The connection is request-response after an initial server handshake:
```text
connect
<- msgpack(policy_server_config)
infer
-> msgpack({"endpoint": "infer", "session_id": "...", ...observation})
<- msgpack(ndarray | dict[str, ndarray])
reset
-> msgpack({"endpoint": "reset"})
<- msgpack({"status": "reset successful"})
```
If `endpoint` is omitted, the server treats the message as `infer`. Each
inference message produces one action response after the engine request
completes; action tokens or intermediate tensors are not streamed.
NumPy arrays use the marker format implemented by `openpi-client`. The server
also accepts the legacy vLLM NumPy marker representation on input. JSON text
frames are not observation messages.
## Sessions and Reset
- The API layer's current-session and first-call counters are scoped to each
WebSocket connection.
- `session_id` identifies model-side state across observations on that
connection. If omitted, it defaults to `default`.
- The first inference for a session is sent to the model with `reset=true`.
- Changing `session_id`, or sending the `reset` command, causes the next
inference to start with `reset=true`.
- The policy pipeline owns observation transforms and persistent model state,
normally keyed by `session_id`; the API layer forwards the raw observation
dictionary.
## Limits and Errors
- Maximum inbound payload size is 64 MiB.
- The server closes an idle connection after 30 seconds.
- Invalid binary input returns a MessagePack
`{"type":"error","message":"Invalid request payload"}` response.
- Inference failures return a generic `Internal inference error` without
exposing an internal traceback.
- Unsupported NumPy object, structured, and complex dtypes are rejected.
Observation keys, camera layout, state tensors, and action shapes are defined
by the loaded policy rather than by this transport. Read the handshake before
constructing observations. See the [DreamZero example](https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/dreamzero)
for a complete OpenPI client and DROID simulation loop.
+82
View File
@@ -0,0 +1,82 @@
# Realtime Audio WebSocket API
Use `WS /v1/realtime` to stream PCM audio into a compatible omni model and
receive incremental transcription and synthesized response audio. This is a
turn-based streaming API: it starts one generation over an audio stream and
finishes after the client closes that input stream.
For a model that listens while it is already speaking, use the experimental
[Full-Duplex API](full_duplex_api.md) instead.
## Quick Start
Start a compatible model. The currently documented example uses Qwen3-Omni:
```bash
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
```
Run the provided client with a mono, 16-bit PCM, 16 kHz WAV file:
```bash
python examples/online_serving/qwen3_omni/openai_realtime_client.py \
--url ws://localhost:8091/v1/realtime \
--model Qwen/Qwen3-Omni-30B-A3B-Instruct \
--input-wav input_16k_mono.wav \
--output-wav response.wav \
--output-text response.txt
```
The client dependency is `websockets`.
## Protocol
Messages in both directions are JSON text frames. Audio carried inside an
event is base64-encoded raw PCM16.
| Direction | Event | Purpose |
|-----------|-------|---------|
| Server to client | `session.created` | Confirms that the WebSocket connection is ready |
| Client to server | `session.update` | Selects and validates the served model |
| Client to server | `input_audio_buffer.commit` with `final: false` | Starts generation over the incoming stream |
| Client to server | `input_audio_buffer.append` | Appends base64 PCM16 audio |
| Client to server | `input_audio_buffer.commit` with `final: true` | Marks the end of input |
| Server to client | `transcription.delta` | Carries incremental response text |
| Server to client | `transcription.done` | Carries final text and token usage |
| Server to client | `response.audio.delta` | Carries incremental PCM16 response audio |
| Server to client | `response.audio.done` | Marks the end of response audio |
| Server to client | `error` | Reports an invalid event, model, or audio payload |
A minimal client sends events in this order:
```json
{"type":"session.update","model":"Qwen/Qwen3-Omni-30B-A3B-Instruct"}
{"type":"input_audio_buffer.commit","final":false}
{"type":"input_audio_buffer.append","audio":"<base64-pcm16>"}
{"type":"input_audio_buffer.commit","final":true}
```
The initial non-final commit intentionally starts generation before all audio
has arrived. Continue sending `input_audio_buffer.append` events while the
engine is consuming the stream.
## Audio Handling
- Input is mono PCM16 at 16 kHz for the Qwen3-Omni example.
- `response.audio.delta.audio` contains base64-encoded PCM16 bytes.
- Read `sample_rate_hz` from each audio event instead of assuming an output
rate. Qwen3-Omni output is typically 24 kHz.
- Concatenate audio deltas in receive order to construct the output waveform.
- Set `input_audio_buffer.commit.final` to `true` after the last input chunk so
the server can terminate the streaming request.
## Availability and Limitations
The path is registered on the unified API server, but it is usable only when
the loaded pipeline implements realtime audio input and produces compatible
audio output. Unsupported deployments return an `error` event. The endpoint
does not provide duplex session resume, playback acknowledgement, overlap
policy, or barge-in controls.
See the [Qwen3-Omni online serving example](https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/qwen3_omni)
for concurrency options, chunk pacing, and per-delta audio debugging.
+2 -1
View File
@@ -2,7 +2,7 @@
vLLM-Omni provides an OpenAI-compatible API for text-to-speech (TTS) generation. Supported TTS models include:
- **Qwen3-TTS** (`Qwen/Qwen3-TTS-12Hz-*`) -- Qwen3-based TTS with CustomVoice, VoiceDesign, and Base (voice cloning) task types. Output: 24 kHz.
- **Qwen3-TTS** (`Qwen/Qwen3-TTS-12Hz-*`) -- Qwen3-based TTS with CustomVoice, VoiceDesign, and Base (voice cloning) task types. Native output: 24 kHz; API output can be resampled to 8 kHz.
- **Fish Speech S2 Pro** (`fishaudio/s2-pro`) -- Dual-AR TTS with DAC codec. Supports text-to-speech and voice cloning via reference audio. Output: 44.1 kHz.
- **Voxtral TTS** (`mistralai/Voxtral-4B-TTS-2603`) -- AR + FlowMatching TTS with preset voices. Output: 24 kHz.
- **CosyVoice3** (`FunAudioLLM/Fun-CosyVoice3-0.5B-2512`) -- 2-stage talker + flow-matching code2wav. Voice cloning via `ref_audio` + `ref_text` (no presets). Output: 24 kHz.
@@ -116,6 +116,7 @@ Content-Type: application/json
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `sample_rate` | integer | model native | Target output sample rate. Qwen3-TTS supports 8000 or 24000 Hz; the model remains native 24 kHz internally and is resampled before encoding. |
| `task_type` | string | "CustomVoice" | TTS task type: CustomVoice, VoiceDesign, or Base |
| `language` | string | "Auto" | Language (see supported languages below) |
| `instructions` | string | "" | Voice style/emotion instructions |
+109
View File
@@ -0,0 +1,109 @@
# Standalone Experimental Servers
Most vLLM-Omni endpoints run on the unified server started with
`vllm serve <model> --omni`. The experimental full-duplex package also contains
two separate user-facing server processes. Their routes are not added to the
unified API server.
| Server | Transport | Role |
|--------|-----------|------|
| PersonaPlex compatibility server | WebSocket | Hosts the official PersonaPlex browser protocol and a raw-PCM alternative |
| JoyVL interaction server | HTTP | Adds state, memory, proactive decisions, and delegation in front of a separate OpenAI-compatible model backend |
## PersonaPlex Compatibility Server
Use this server when a client requires the official Moshi/PersonaPlex wire
protocol or the bundled browser UI. For the engine-owned unified duplex path,
use [`WS /v1/realtime?duplex=1`](full_duplex_api.md) instead.
Start it with:
```bash
HF_TOKEN=... CUDA_VISIBLE_DEVICES=0 python -m \
vllm_omni.experimental.fullduplex.personaplex.serving.server \
--port 8091
```
| Route | Purpose |
|-------|---------|
| `GET /` | Official PersonaPlex browser client, when its assets are available |
| `GET /health` | Readiness check |
| `WS /api/chat` | Moshi-compatible tagged binary protocol with Opus audio |
| `WS /v1/audio/duplex` | Simpler float32 raw-PCM protocol |
### `/api/chat` Protocol
The first byte of every binary frame is a message tag:
| Direction | Tag | Payload |
|-----------|-----|---------|
| Server to client | `0x00` | Ready handshake |
| Client to server | `0x01` | Opus microphone audio at 24 kHz |
| Server to client | `0x01` | Opus response audio |
| Server to client | `0x02` | UTF-8 inner-monologue text |
The optional `text_prompt` and `voice_prompt` query parameters select a persona
and voice. The default server admits one conversation; use `--batch-size N` to
configure multiple lockstep slots. Connections beyond capacity close with
WebSocket code 1013.
### `/v1/audio/duplex` Protocol
Send `{"type":"open","persona":"...","voice":"..."}` as a JSON text frame,
then exchange binary float32 PCM frames. The server returns generated float32
audio as binary frames and text as `{"type":"text","text":"..."}`. Send
`{"type":"close"}` and wait for `{"type":"done"}` to flush and finish.
See the [PersonaPlex example](https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/personaplex)
for the browser and headless clients.
## JoyVL Interaction Server
JoyVL is an orchestration layer, not a model-serving engine. It calls a
separate OpenAI-compatible backend and adds per-session frame history, memory,
persona policy, and optional delegation.
Start the model backend and orchestrator separately:
```bash
vllm serve jdopensource/JoyAI-VL-Interaction-Preview \
--served-model-name JoyAI-VL-Interaction-Preview \
--port 8092 \
--max-model-len 131072 \
--enable-prefix-caching \
--limit-mm-per-prompt '{"image":256,"video":1}'
python -m vllm_omni.experimental.fullduplex.joyvl.serving.server \
--port 8091 \
--main-backend-url http://127.0.0.1:8092/v1 \
--main-model JoyAI-VL-Interaction-Preview
```
| Method and route | Purpose |
|------------------|---------|
| `GET /health` | Readiness check |
| `GET /v1/models` | Reports the configured interaction model |
| `POST /v1/chat/completions` | Processes one frame or interaction tick |
| `POST /reset` | Resets a session |
| `POST /v1/streaming/reset` | Alias for `/reset` |
| `POST /v1/streaming/persona` | Changes the session persona |
Send one multimodal Chat Completions request per video frame, normally around
one frame per second, and identify the session with `x-session-id`. Responses
include an `interaction` block whose action is `silence`, `response`, or
`delegate`.
```bash
curl http://127.0.0.1:8091/v1/chat/completions \
-H 'content-type: application/json' \
-H 'x-session-id: demo' \
-d '{
"messages": [{"role": "user", "content": [
{"type": "text", "text": "Alert me if a fire appears"},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}}
]}]
}'
```
See the [JoyVL recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/JD/JoyAI-VL-Interaction.md)
for memory, personas, delegation backends, and the browser UI.
@@ -0,0 +1,92 @@
# Streaming Video Output WebSocket API
Use `WS /v1/realtime/video` to receive fragmented MP4 chunks while a compatible
diffusion video model is generating. This endpoint streams generated video
output; it is unrelated to [`WS /v1/video/chat/stream`](video_stream_api.md),
which streams live video input into a conversational model.
## Quick Start
Start a model that supports step execution and enable diffusion streaming
output:
```bash
vllm serve BestWishYsh/Helios-Distilled --omni \
--diffusion-streaming-output \
--port 8091
```
Install the client dependency and run the provided client:
```bash
pip install av websockets
python examples/online_serving/streaming_video_generation/streaming_video_client.py \
--host 127.0.0.1 \
--port 8091 \
--model BestWishYsh/Helios-Distilled \
--prompt "A serene lakeside sunrise with mist over the water" \
--output output.mp4
```
## Protocol
Control events are JSON text frames. Each `video.chunk_metadata` event is
immediately followed by one binary WebSocket frame containing fragmented MP4
bytes.
| Direction | Event or frame | Purpose |
|-----------|----------------|---------|
| Client to server | `session.start` | Starts generation with a prompt and video parameters |
| Server to client | `video.start` | Confirms the request ID, format, and accepted configuration |
| Server to client | `video.chunk_metadata` | Describes the next binary media frame |
| Server to client | Binary frame | Carries one fragmented MP4 chunk |
| Client to server | `session.interaction` | Queues an optional prompt update during generation |
| Server to client | `session.interaction.queued` | Confirms that an interaction was queued |
| Client to server | `session.ping` | Refreshes the stall clock |
| Server to client | `session.pong` | Acknowledges a ping |
| Client to server | `session.stop` | Cancels the active generation |
| Server to client | `session.done` | Reports completion or cancellation and the chunk count |
| Server to client | `error` | Reports validation, generation, or timeout failures |
Start a minimal session with:
```json
{
"type": "session.start",
"model": "BestWishYsh/Helios-Distilled",
"prompt": "A serene lakeside sunrise",
"format": "m4s",
"width": 640,
"height": 384,
"fps": 16,
"num_frames": 99
}
```
Only `m4s` output is currently accepted. Concatenate the binary frames in
receive order, then remux the fragmented stream if the target player requires
a progressive MP4 file.
## Mid-Generation Interaction
Models that implement interaction updates can change the active prompt:
```json
{
"type": "session.interaction",
"interaction": {
"event_id": "sunlight-update",
"event": {"prompt": "Sunlight breaks through the mist"},
"transition_chunks": 2
}
}
```
This feature is pipeline-dependent. An enabled endpoint does not imply that
the loaded model supports midway prompt updates.
The default start timeout is 10 seconds and the server reports a stall after
about 60 seconds without generation progress or a `session.ping`. See the
[streaming video generation example](https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/streaming_video_generation)
for prompt-update scheduling and browser playback.
+1 -1
View File
@@ -138,7 +138,7 @@ The following tables show which models support each feature:
| **Qwen-Image-Edit** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **Qwen-Image-Edit-2509** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ (decode) | ✅ | ❌ | ❌ |
| **Qwen-Image-Layered** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **SenseNova-U1** | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **SenseNova-U1 / U1.5** | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **Stable-Diffusion-XL** | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **Stable-Diffusion3.5** | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ (decode) | ❌ | ❌ |
| **Z-Image** | ✅ | ✅ | ✅ | ❓ | ✅ (TP=2 only) | ❌ | ✅ | ❌ | ✅ (decode) | ✅ | ❌ |
+1 -1
View File
@@ -49,7 +49,7 @@ scenario below for live barge-in validation on the target GPU.
Default:
```bash
vllm-omni serve openbmb/MiniCPM-o-4_5 \
vllm serve openbmb/MiniCPM-o-4_5 \
--omni \
--deploy-config vllm_omni/deploy/minicpmo_4_5.yaml \
--trust-remote-code \
@@ -155,6 +155,23 @@ def _response_in_progress(events: list[dict[str, object]]) -> bool:
)
async def _commit_input_after_playback_checkpoint(client: RealtimeDuplexClient) -> float:
"""Commit input only after preserving already-played assistant history.
Native duplex can finish several responses while the user is still
streaming. Under the ``ack_only`` playback policy, acknowledging those
responses after the final input commit is intentionally rejected: it could
append an old assistant message after the newly committed user message.
Checkpoint playback first so the server commits each assistant item at its
original history position. A second acknowledgement after the commit can
then safely advance response-local playback that drained in the meantime.
"""
await client.acknowledge_playback()
commit_sent_at_s = time.monotonic()
await client.commit()
return commit_sent_at_s
def _event_count_after(
events: list[dict[str, object]],
event_type: str,
@@ -448,8 +465,7 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
chunk_period_ms=_chunk_period_ms(client.events.events),
)
wait_for_post_commit_decision = False
commit_sent_at_s = time.monotonic()
await client.commit()
commit_sent_at_s = await _commit_input_after_playback_checkpoint(client)
wait_error: str | None = None
committed_index: int | None = None
post_commit_decision: str | None = None
@@ -37,7 +37,7 @@ During generation the client normally sends only `session.start` and then receiv
## Install Client Dependency
```bash
pip install websockets
pip install av websockets
# For the Gradio demo:
pip install 'vllm-omni[demo]' websockets
```
@@ -8,7 +8,7 @@ the received bytes to disk when the session finishes or is interrupted.
Image/reference input is intentionally not supported in this example yet.
Requirements:
pip install websockets
pip install av websockets
"""
import argparse
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""OpenAI-compatible client for Qwen3-TTS via /v1/audio/speech endpoint.
This script demonstrates how to use the OpenAI-compatible speech API
@@ -74,6 +77,8 @@ def run_tts_generation(args) -> None:
"voice": args.speaker,
"response_format": args.response_format,
}
if args.sample_rate is not None:
payload["sample_rate"] = args.sample_rate
# Add optional parameters
if args.instructions:
@@ -246,6 +251,13 @@ def parse_args():
choices=["wav", "mp3", "flac", "pcm", "aac", "opus"],
help="Audio output format (default: wav)",
)
parser.add_argument(
"--sample-rate",
type=int,
default=None,
choices=[8000, 24000],
help="Output sample rate in Hz (default: model native)",
)
parser.add_argument(
"--output",
"-o",
+86 -13
View File
@@ -1,11 +1,14 @@
# Boogu-Image
> Text-to-image and image-editing online serving (Boogu-Image-0.1-Base / -Edit)
> Text-to-image and image-editing online serving
(Boogu-Image-0.1-Base / -Edit / -Edit-Turbo)
## Summary
- Vendor: Boogu
- Model: `Boogu/Boogu-Image-0.1-Base` (text-to-image), `Boogu/Boogu-Image-0.1-Edit` (image editing)
- Model: `Boogu/Boogu-Image-0.1-Base` (text-to-image),
`Boogu/Boogu-Image-0.1-Edit` (image editing), and
`Boogu/Boogu-Image-0.1-Edit-Turbo` (four-step image editing)
- Task: Text-to-image generation and text-guided image editing (TI2I)
- Mode: Online serving with the OpenAI-compatible API
- Maintainer: Community
@@ -13,19 +16,23 @@
## When to use this recipe
Use this recipe when you want a known-good starting point for serving
`Boogu/Boogu-Image-0.1-Base` with vLLM-Omni's native pipeline (no
`Boogu/Boogu-Image-0.1-Base`, `Boogu/Boogu-Image-0.1-Edit`, or
`Boogu/Boogu-Image-0.1-Edit-Turbo` with vLLM-Omni's native pipeline (no
`--diffusion-load-format diffusers`, and the upstream `boogu` package is not
required).
Boogu-Image-0.1 is an Apache-2.0 unified image generation and editing model
family. This recipe covers the Base text-to-image checkpoint, which pairs a
Qwen3-VL multimodal encoder with a Diffusion Transformer (DiT) and a flow-match
Euler scheduler with time-shift. It handles photorealistic generation and
Chinese/English text rendering.
family. The Base text-to-image checkpoint pairs a Qwen3-VL multimodal encoder
with a Diffusion Transformer (DiT) and a flow-match Euler scheduler with
time-shift. It handles photorealistic generation and Chinese/English text
rendering. The Edit and Edit-Turbo checkpoints use the same native
`BooguImagePipeline` for image editing.
## References
- Upstream model card: <https://huggingface.co/Boogu/Boogu-Image-0.1-Base>
- Edit model card: <https://huggingface.co/Boogu/Boogu-Image-0.1-Edit>
- Edit-Turbo 1K hotfix: <https://huggingface.co/Boogu/Boogu-Image-0.1-Edit-Turbo/tree/hotfix-1k-20260708>
- Project page: <https://boogu.org>
- GitHub: <https://github.com/boogu-project/Boogu-Image>
- Related example: [`examples/online_serving/text_to_image/`](../../examples/online_serving/text_to_image/README.md)
@@ -93,7 +100,7 @@ curl -s http://localhost:8091/v1/chat/completions \
- **Memory usage:** ~34.6 GiB on GPU. Use `--vae-use-slicing --vae-use-tiling`
to trim peak VRAM if needed.
- **Key flags:**
- `--omni` — enables vLLM-Omni diffusion serving.
- `--omni` — enables vLLM-Omni diffusion serving.
- **Guidance:** Boogu-Image uses `guidance_scale` (mapped to the upstream
`text_guidance_scale`); the default is `4.0`. Classifier-free guidance is
active whenever `guidance_scale > 1.0`.
@@ -111,13 +118,13 @@ the image-editing (TI2I) path activates automatically when a request carries a
reference image. The Base text-to-image path is unaffected (no reference image
is sent).
#### Command
### Command
```bash
vllm serve Boogu/Boogu-Image-0.1-Edit --omni --port 8091
```
#### Verification
### Verification
Edit an image with `/v1/images/edits` (the model-card example — change a photo
to a colored-pencil drawing). Diffusion parameters are plain multipart form
@@ -157,14 +164,14 @@ curl -s http://localhost:8091/v1/chat/completions \
}' | jq -r '.choices[0].message.content[0].image_url.url' | cut -d',' -f2- | base64 -d > edited.png
```
#### Notes
### Notes
- **Single reference image:** only one input image is supported for now (the
upstream "Only support 1 reference image for now" limit).
- **Guidance semantics:**
- `guidance_scale` = text guidance (upstream `text_guidance_scale`, default
- `guidance_scale` = text guidance (upstream `text_guidance_scale`, default
`4.0`); `> 1.0` enables text CFG. Editing typically uses `5.0`.
- `guidance_scale_2` = image guidance (upstream `image_guidance_scale`,
- `guidance_scale_2` = image guidance (upstream `image_guidance_scale`,
default `1.0` = off). Setting it `> 1.0` enables the double-guidance path
(3 model predictions per step), steering more strongly toward the reference
image.
@@ -178,3 +185,69 @@ curl -s http://localhost:8091/v1/chat/completions \
supports Boogu-Image-Edit directly
(`--model Boogu/Boogu-Image-0.1-Edit --guidance-scale 5.0`, optional
`--guidance-scale-2`).
## Fast image editing (Boogu-Image-0.1-Edit-Turbo)
Edit-Turbo is the distilled image-editing checkpoint. Its `model_index.json`
declares `BooguImagePipeline`, so it uses the same native TI2I path as Edit.
The upstream 1K hotfix is recommended for stable results and is pinned below
to avoid silently loading the older checkpoint from the repository's default
revision.
### Command
```bash
vllm serve Boogu/Boogu-Image-0.1-Edit-Turbo \
--omni \
--revision hotfix-1k-20260708 \
--port 8091
```
### Verification
Edit an image with the distilled four-step settings:
```bash
curl -s http://localhost:8091/v1/images/edits \
-F model="Boogu/Boogu-Image-0.1-Edit-Turbo" \
-F image="@input.png" \
-F prompt="Change the style to a colored pencil drawing." \
-F num_inference_steps=4 \
-F guidance_scale=1.0 \
-F seed=42 \
| jq -r '.data[0].b64_json' | base64 -d > edited-turbo.png
```
Or via chat completions:
```bash
curl -s http://localhost:8091/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"messages": [
{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": "data:image/png;base64,<BASE64>"}},
{"type": "text", "text": "Change the style to a colored pencil drawing."}
]}
],
"extra_body": {
"num_inference_steps": 4,
"guidance_scale": 1.0,
"seed": 42
}
}' | jq -r '.choices[0].message.content[0].image_url.url' | cut -d',' -f2- | base64 -d > edited-turbo.png
```
### Notes
- **Pinned revision:** use `hotfix-1k-20260708`; upstream recommends the 1K
hotfix over the 1.5K variant for more stable results.
- **Recommended settings:** `num_inference_steps=4` and
`guidance_scale=1.0`. Edit-Turbo is guidance-distilled, meaning the guidance
behavior is already baked into the distilled checkpoint. A scale of `1.0`
avoids applying additional classifier-free guidance; unlike the regular Edit
checkpoint, it should not be increased to `5.0`.
- **Reference images:** the same single-reference and `align_res` behavior as
the regular Edit checkpoint applies.
- **Known limitations:** the same single-GPU limitations as Base and Edit apply;
CPU offload, Cache-DiT, and multi-GPU parallelism are not yet validated.
+73 -5
View File
@@ -69,11 +69,12 @@ reference-video preparation and MP4 output.
For CUDA and ROCm deployments, non-streaming MP4 responses are encoded on the
host CPU through PyAV/libx264 after generation. The response encoder selects the
path automatically at runtime: inputs with a supported frame shape, common dtype,
and RGB per-channel contiguous layout use direct planar frames; other inputs
fall back to the legacy path before the PyAV container is opened. No CLI flag,
model declaration, or user configuration is required. Streaming fMP4 output is
unchanged.
path automatically at runtime. The server-owned parallel converter accepts
supported frame shapes and dtypes with either per-channel-contiguous or strided
RGB planes, including interleaved arrays materialized by output transport.
Standalone callers without a parallel converter retain the legacy fallback for
strided planes. No CLI flag, model declaration, or user configuration is
required. Streaming fMP4 output is unchanged.
A community benchmark on 2x Xeon 8480C reported the following comparison
between the legacy and direct planar paths
@@ -917,6 +918,72 @@ adapter and twice without it, then compare the four output digests. The adapter
is bound and deterministic when each pair matches internally and the two pairs
differ from each other.
### FastH3 adapter
[FastH3](https://haoailab.com/blogs/fasth3-preview/) is FastVideo's four-step
DMD2 student of H3-Base. It reuses H3's text encoder, VAEs, tokenizers, and
schedulers unchanged, replacing 49 denoiser evaluations with four. It is fused
into the checkpoint at load time rather than switched per request, because it
carries full-rank deltas that no LoRA layer can express.
The bundle publishes four variants, so download one and point `--lora-path` at
it; the repository root is refused rather than guessed at:
```bash
export FASTH3_DIR=/path/to/fasth3
hf download FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA \
dense-datafree/adapter_model.safetensors --local-dir "${FASTH3_DIR}"
export FASTH3_LORA="${FASTH3_DIR}/dense-datafree/adapter_model.safetensors"
```
Add `--task-type fl2va --lora-path "${FASTH3_LORA}"` to a non-offloaded server
command. T2VA is served by the FL2VA partition, so `--task-type fl2va` is
correct even though FastH3 preview v1 distills T2VA only. Because the adapter is
fused, `--lora-backend` does not apply and a request carrying a `lora=` field is
rejected rather than served without the adapter it asked for.
```bash
-F 'num_inference_steps=4' \
-F 'extra_params={"task":"t2va","duration":4.4}'
```
Requests must ask for `num_inference_steps=4` and `task=t2va`: the release's five
sigma points bound four denoiser evaluations, and that count is what the step
scheduler admits a request on. The server denoises on the release's own ladder,
keeping H3's per-modality shifts at the checkpoint values, so a request that
overrides `flow_shift` or `audio_flow_shift` is rejected - it would sample the
student at noise levels it was never distilled at.
Only a release that identifies itself as FastH3 is fused; any other
`fastvideo-lora-v2` adapter stays on the dynamic LoRA route. A claimed artifact
is then held to its own metadata: one that misdeclares its tensor counts or
leaves a transformer block unedited is refused at startup instead of serving
mostly base H3 weights on a four-step schedule. Offload is refused for the same
reason - `--enable-cpu-offload`, `--enable-layerwise-offload` and
`--enable-distributed-layerwise-offload` all bypass the fusion, so they fail fast.
> [!NOTE]
> Only the **Dense / Data-Free** variant is supported today. The three VSA
> variants carry `.set_weight` compression gates for modules that do not exist
> in vLLM-Omni's dense H3 attention, and the release states dense attention is
> not a supported substitute for them, so they are refused at startup rather
> than silently run wrong. Video Sparse Attention for H3's packed
> `[text | cond | audio | video]` sequence is follow-up work.
Measured on 8x NVIDIA B300 with USP8, VAE patch-parallel 8, `TRTLLM_ATTN`, at
1344x768, 4.4 s, seed 1101, one warmup excluded and two runs recorded:
| Adapter | Steps | End-to-end | Diffusion engine |
| --- | ---: | ---: | ---: |
| none (base H3) | 50 | 25.8 / 26.4 s | 16.22 / 16.28 s |
| FastH3 Dense | 4 (5 sigma points) | 11.7 / 11.8 s | 2.37 / 2.36 s |
The denoising speedup is 6.9x. End-to-end is 2.2x because text encoding, VAE
decoding and muxing are a fixed cost that dominates a clip this short; longer
generations move the end-to-end figure toward the denoising one. Fusing the
adapter does not measurably change startup: weight loading took 77.3 s with it
against 85.8 s without.
## Key parameters
| Parameter | Recommended value | Notes |
@@ -1075,3 +1142,4 @@ vllm serve "${MODEL_ROOT}/FL2VA" \
- [Supported models](../../docs/models/supported_models.md)
- [Video API](../../docs/serving/videos_api.md)
- [Diffusion parallelism](../../docs/user_guide/diffusion/parallelism/overview.md)
- [Temporal VAE chunk callback design](../../docs/design/feature/minimax_h3_temporal_vae_chunks.md)
+1
View File
@@ -78,6 +78,7 @@ recipes/
| [`Qwen/Qwen3-Omni.md`](./Qwen/Qwen3-Omni.md) | Online serving for multimodal chat | 1x A100 80GB |
| [`Qwen/Qwen3-TTS.md`](./Qwen/Qwen3-TTS.md) | Text-to-speech serving (CustomVoice / VoiceDesign / Base) | 1x H100/A100 80GB / 1x AMD MI300X |
| [`SenseNova/SenseNova-U1.md`](./SenseNova/SenseNova-U1.md) | Unified image generation and understanding | 1x or 2x H200 / 1x AMD MI300X |
| [`SenseNova/SenseNova-U1.5.md`](./SenseNova/SenseNova-U1.5.md) | Unified image generation and understanding, with an 8-step distilled LoRA | 1x A800 80GB |
| [`StabilityAI/Stable-Audio-Open.md`](./StabilityAI/Stable-Audio-Open.md) | Offline + online text-to-audio generation (Stable Audio Open) | 1x RTX 4090 24GB / 1x AMD MI300X |
| [`Tencent/Covo-Audio-Chat.md`](./Tencent/Covo-Audio-Chat.md) | Online serving for audio chat | 1x A100 80GB |
| [`Tencent/HunyuanImage-3.0-Instruct.md`](./Tencent/HunyuanImage-3.0-Instruct.md) | DiT-only text-to-image serving and benchmark, including ModelOpt mixed FP8/NVFP4 | 4x H100/H800 80GB / 2x B200 |
+159
View File
@@ -0,0 +1,159 @@
# SenseNova-U1.5
> Unified image generation and understanding, with an official 8-step distilled LoRA
## Summary
- Vendor: SenseNova
- Model: `sensenova/SenseNova-U1.5-8B-MoT`
- LoRA: `sensenova/SenseNova-U1.5-8B-MoT-LoRAs` (`SenseNova-U1.5-8B-MoT-LoRA-8step.safetensors`)
- Task: text2img, img2img, img2text (visual understanding), text2text (chat)
- Mode: Offline inference, Online serving (OpenAI-compatible API)
- Maintainer: Community
## When to use this recipe
U1.5 runs on the same `SenseNovaU1Pipeline` as U1 — the checkpoint keeps
`model_type: neo_chat`, so it resolves without `--model-class-name`. Relative to U1 the
config flips two fields, `use_pixel_head` to `true` (the flow-matching head becomes a
`ConvDecoder`) and `noise_scale_max_value` from 8.0 to 16.0; both are already read by
`SenseNovaU1Config`. The checkpoint is 13 shards / 50.2 GB on disk, 30.3 GB of that fp32, and
loads to roughly 34 GB in bf16.
Use this recipe for U1.5 specifically, including its distilled few-step LoRA. For U1 see
[SenseNova-U1](SenseNova-U1.md).
## Hardware Support
### GPU
#### 1x A800 80GB
- 1x NVIDIA A800 80GB PCIe with an Intel Xeon Gold 6336Y, driver 595.84, Linux
- Python 3.12.13, vLLM 0.28.0, torch 2.13.0+cu130, CUDA 13.0, diffusers 0.40.0,
transformers 5.14.1, flashinfer 0.6.16.post3, BF16, TP=1
- Peak GPU memory: 34.3 GB at 1024x1024, 36.4 GB at 1536x2720
##### Text-to-Image
```bash
python examples/offline_inference/text_to_image/text_to_image.py \
--model sensenova/SenseNova-U1.5-8B-MoT \
--prompt "Close portrait of an elderly woman by a farmhouse window, warm natural light." \
--width 1024 --height 1024 \
--seed 42 --num-inference-steps 50 --cfg-scale 4.0 \
--extra-body '{"think": false, "cfg_norm": "none", "timestep_shift": 3.0, "t_eps": 0.02}' \
--output sensenova_u15_t2i.png
```
Think mode (`"think": true`) is recommended for higher image quality.
##### Image-to-Image Editing
```bash
python examples/offline_inference/image_to_image/image_edit.py \
--model sensenova/SenseNova-U1.5-8B-MoT \
--prompt "Turn this into an oil painting" \
--image input.png --resolution 1024 \
--seed 42 --num-inference-steps 25 --cfg-scale 4.0 \
--extra-args '{"think": true, "img_cfg_scale": 1.0, "cfg_norm": "none", "timestep_shift": 3.0}' \
--output sensenova_u15_edit.png
```
##### 8-step distilled LoRA
The distilled LoRA is fused into the generation tower at load time:
```bash
python examples/offline_inference/text_to_image/text_to_image.py \
--model sensenova/SenseNova-U1.5-8B-MoT \
--lora-path SenseNova-U1.5-8B-MoT-LoRA-8step.safetensors --lora-backend distill \
--prompt "Close portrait of an elderly woman by a farmhouse window, warm natural light." \
--width 1024 --height 1024 \
--seed 42 --num-inference-steps 8 --cfg-scale 1.0 \
--extra-body '{"think": false, "cfg_norm": "none", "timestep_shift": 3.0, "t_eps": 0.02}' \
--output sensenova_u15_lora8.png
```
**Use `--cfg-scale 1.0` with this LoRA.** It is distilled with DMD and runs without
classifier-free guidance; the default `4.0` applies guidance twice and produces a
blown-out, posterised image.
##### Online serving
```bash
vllm serve sensenova/SenseNova-U1.5-8B-MoT --omni --port 8091
python examples/online_serving/sensenova_u1/openai_chat_client.py \
-s http://127.0.0.1:8091 -m img2text -i input.png -p "Describe this image."
```
`-s` takes the base URL; the client appends `/v1` itself.
#### Measured latency (1x A800 80GB, 25 steps, median of 3 after a warmup)
| Resolution | Step latency | Total |
| --- | --- | --- |
| 1024x1024 | 209.5 ms | 5.24 s |
| 1536x1536 | 458.5 ms | 11.46 s |
At 1536x2720 with 50 steps, end-to-end is 43.0 s.
#### Measured latency (1x NVIDIA H200 139GB, single run, no warmup)
Reported by @hsliuustc0106 against PR head `29c090d`, on one reserved CUDA device.
- Python 3.12.13, vLLM 0.28.0, torch 2.13.0+cu130, CUDA 13.0, diffusers 0.40.0,
transformers 5.14.1, flashinfer 0.6.16.post3, BF16, TP=1
- `sensenova/SenseNova-U1.5-8B-MoT` with `SenseNova-U1.5-8B-MoT-LoRA-8step.safetensors`,
seed 42, 1024x1024
| Case | Stage latency | Peak GPU memory |
| --- | --- | --- |
| 50 steps, cfg 4.0, think off | 9017 ms | 34384 MiB |
| 8 steps, cfg 1.0, no LoRA control | 624.00 ms, 77.85 ms/step | 34376 MiB |
| 8 steps, cfg 1.0, distilled LoRA | 623.85 ms, 77.98 ms/step | 34364 MiB |
| 50 steps, cfg 4.0, think on, `VLLM_OMNI_SENSENOVA_PAGED_DECODE=1` | 81622 ms | 34594 MiB |
Each row runs the Text-to-Image command above with `--num-inference-steps` and
`--cfg-scale` as listed and `think` set through `--extra-body`; the LoRA row adds
`--lora-path` and `--lora-backend distill`.
Single runs without a preceding warmup, so the think-on row carries the first-request
compile rather than a steady state. The distilled LoRA fused into 168 parameters, and its
image differs from the same-seed no-LoRA control in 1,048,574 of 1,048,576 pixels, MAE 58.62.
```bash
pytest -m "cpu and not cuda" tests/diffusion/models/sensenova_u1/ tests/diffusion/lora/test_loader.py -q
pytest -m "cpu and not cuda" tests/config/test_environment_variables.py tests/diffusion/test_diffusion_worker.py -q
pytest -m cuda tests/diffusion/models/sensenova_u1/test_sensenova_u1_attention_gqa.py \
tests/diffusion/models/sensenova_u1/test_sensenova_u1_paged_decode.py -q --tb=short
```
71, 13 and 9 passed. Serving reached `/health`; `img2text` returned a 1921-character
description and `text2text` the expected answer.
#### Verification
```bash
pytest -q tests/diffusion/models/sensenova_u1/
```
## Notes
- Both `use_pixel_head` and `noise_scale_max_value` come from `config.json`; no flag is needed.
- The checkpoint carries no `configuration_neo_chat.py`, so the loader logs a few
"does not appear to have a file named configuration_neo_chat.py" errors during startup and
then proceeds on the in-tree config. Generation is unaffected.
- The LoRA targets the generation tower only (`*_mot_gen`); understanding-tower weights are
untouched.
- Autoregressive decode (think, text-to-text and image-to-text) runs on a paged K/V cache under
a captured CUDA graph. It falls back to the ordinary cache when the device or the bundled
`flash_attn_varlen_func` cannot support it; set `VLLM_OMNI_SENSENOVA_PAGED_DECODE=0` to force
that fallback.
- The first request after startup costs about 0.7 s more than the steady state whether the paged
path is on or off. Measured on one A800 with the inductor, triton and vLLM compile caches all
cleared, median of three runs: 718 ms above steady with the path on, 679 ms with it off.
- Each request captures its own graphs, and a think request captures twice because the sequence
grows past the 512 bucket, so the capture cost is paid per request rather than once at
startup.
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Benchmark legacy and automatic MP4 response encoding on synthetic input."""
from __future__ import annotations
@@ -11,13 +11,16 @@ import statistics
import time
from collections.abc import Callable
from dataclasses import dataclass
from functools import partial
from pathlib import Path
from typing import cast
import numpy as np
from vllm_omni.entrypoints.openai.video_api_utils import (
_encode_video_bytes,
_encode_video_bytes_legacy,
_PlanarFrameConverter,
)
@@ -42,12 +45,19 @@ def _build_inputs(
fps: int,
audio_sample_rate: int,
seed: int,
input_layout: str = "planar-backed",
) -> tuple[np.ndarray, np.ndarray]:
rng = np.random.default_rng(seed)
planar = rng.random((3, frames, height, width), dtype=np.float32)
video = planar.transpose(1, 2, 3, 0)
if not all(video[..., channel].flags.c_contiguous for channel in range(3)):
if input_layout == "interleaved":
video = np.ascontiguousarray(video)
if not video.flags.c_contiguous:
raise RuntimeError("synthetic video is not C-contiguous")
elif input_layout == "planar-backed" and not all(video[..., channel].flags.c_contiguous for channel in range(3)):
raise RuntimeError("synthetic video does not expose contiguous channel planes")
elif input_layout != "planar-backed":
raise ValueError(f"unsupported input layout: {input_layout}")
sample_count = max(1, round(frames / fps * audio_sample_rate))
timeline = np.arange(sample_count, dtype=np.float32) / audio_sample_rate
@@ -86,8 +96,8 @@ def _measure(
def _summarize(records: list[dict[str, object]], label: str) -> dict[str, object]:
selected = [record for record in records if record["label"] == label]
wall_values = [float(record["wall_ms"]) for record in selected]
cpu_values = [float(record["process_cpu_ms"]) for record in selected]
wall_values = [cast(float, record["wall_ms"]) for record in selected]
cpu_values = [cast(float, record["process_cpu_ms"]) for record in selected]
return {
"runs": len(selected),
"wall_ms": {
@@ -113,6 +123,16 @@ def _parse_args() -> argparse.Namespace:
parser.add_argument("--warmup", type=int, default=1)
parser.add_argument("--rounds", type=_positive_int, default=3)
parser.add_argument("--seed", type=int, default=20260817)
parser.add_argument(
"--input-layout",
choices=("planar-backed", "interleaved"),
default="planar-backed",
)
parser.add_argument(
"--frame-conversion-workers",
type=_positive_int,
default=8,
)
parser.add_argument("--output-json", type=Path)
args = parser.parse_args()
if args.warmup < 0:
@@ -129,33 +149,41 @@ def main() -> None:
fps=args.fps,
audio_sample_rate=args.audio_sample_rate,
seed=args.seed,
input_layout=args.input_layout,
)
baseline = BenchmarkVariant("legacy", _encode_video_bytes_legacy)
candidate = BenchmarkVariant("automatic", _encode_video_bytes)
converter = _PlanarFrameConverter(max_workers=args.frame_conversion_workers)
candidate = BenchmarkVariant(
"automatic",
partial(_encode_video_bytes, frame_converter=converter),
)
for _ in range(args.warmup):
for variant in (baseline, candidate):
_measure(
variant,
video=video,
audio=audio,
fps=args.fps,
audio_sample_rate=args.audio_sample_rate,
)
try:
for _ in range(args.warmup):
for variant in (baseline, candidate):
_measure(
variant,
video=video,
audio=audio,
fps=args.fps,
audio_sample_rate=args.audio_sample_rate,
)
records: list[dict[str, object]] = []
for round_index in range(args.rounds):
order = (baseline, candidate) if round_index % 2 == 0 else (candidate, baseline)
for variant in order:
_, record = _measure(
variant,
video=video,
audio=audio,
fps=args.fps,
audio_sample_rate=args.audio_sample_rate,
)
record["round"] = round_index + 1
records.append(record)
records: list[dict[str, object]] = []
for round_index in range(args.rounds):
order = (baseline, candidate) if round_index % 2 == 0 else (candidate, baseline)
for variant in order:
_, record = _measure(
variant,
video=video,
audio=audio,
fps=args.fps,
audio_sample_rate=args.audio_sample_rate,
)
record["round"] = round_index + 1
records.append(record)
finally:
converter.shutdown()
output_hashes = {str(record["output_sha256"]) for record in records}
if len(output_hashes) != 1:
@@ -165,8 +193,8 @@ def main() -> None:
paired_savings_percent = []
for round_index in range(args.rounds):
pair = [record for record in records if record["round"] == round_index + 1]
baseline_ms = float(next(record["wall_ms"] for record in pair if record["label"] == baseline.label))
candidate_ms = float(next(record["wall_ms"] for record in pair if record["label"] == candidate.label))
baseline_ms = cast(float, next(record["wall_ms"] for record in pair if record["label"] == baseline.label))
candidate_ms = cast(float, next(record["wall_ms"] for record in pair if record["label"] == candidate.label))
paired_savings_ms.append(baseline_ms - candidate_ms)
paired_savings_percent.append((baseline_ms - candidate_ms) / baseline_ms * 100)
@@ -180,6 +208,8 @@ def main() -> None:
"warmup": args.warmup,
"rounds": args.rounds,
"seed": args.seed,
"input_layout": args.input_layout,
"frame_conversion_workers": args.frame_conversion_workers,
"video_shape": list(video.shape),
"video_strides": list(video.strides),
"codec_options": {"preset": "ultrafast", "threads": "0"},
+176
View File
@@ -9,23 +9,98 @@ import asyncio
import base64
import json
import time
from argparse import Namespace
from types import SimpleNamespace
import pytest
from pytest_mock import MockerFixture
from vllm.benchmarks.lib.endpoint_request_func import RequestFuncInput
from vllm_omni.benchmarks.data_modules.seed_tts_dataset import (
SeedTTSSampleRequest,
SeedTTSTextSampleRequest,
)
from vllm_omni.benchmarks.patch.patch import (
MixRequestFuncOutput,
_apply_stage0_token_timings,
_attach_seed_tts_to_request_func_input,
async_request_openai_chat_omni_completions,
async_request_openai_realtime_duplex,
should_request_stage_metrics,
)
from vllm_omni.experimental.fullduplex.client import RealtimeEventCollector
pytestmark = [pytest.mark.core_model, pytest.mark.benchmark, pytest.mark.cpu]
def _seed_tts_request_func_input() -> RequestFuncInput:
return RequestFuncInput(
model="test-model",
model_name="test-model",
prompt="target text",
api_url="http://test.com/v1/chat/completions",
prompt_len=2,
output_len=20,
extra_body={"modalities": ["text", "audio"]},
)
def test_seed_tts_chat_request_carries_reference_audio_once() -> None:
reference_audio_url = "data:audio/wav;base64,AAAA"
sample = SeedTTSSampleRequest(
prompt="target text",
prompt_len=2,
expected_output_len=20,
multi_modal_data=None,
seed_tts_system_prompt="Clone the supplied voice.",
seed_tts_speech_extra={
"ref_audio": reference_audio_url,
"ref_text": "reference text",
"task_type": "Base",
},
)
request_func_input = _seed_tts_request_func_input()
_attach_seed_tts_to_request_func_input(sample, request_func_input)
assert request_func_input.omni_chat_messages == [
{
"role": "system",
"content": [{"type": "text", "text": "Clone the supplied voice."}],
},
{"role": "user", "content": [{"type": "text", "text": "target text"}]},
]
assert request_func_input.extra_body["ref_audio"] == reference_audio_url
serialized_request = json.dumps(
{"messages": request_func_input.omni_chat_messages, **request_func_input.extra_body},
)
assert serialized_request.count(reference_audio_url) == 1
def test_seed_tts_text_chat_messages_do_not_add_reference_audio() -> None:
sample = SeedTTSTextSampleRequest(
prompt="target text",
prompt_len=2,
expected_output_len=20,
multi_modal_data=None,
seed_tts_system_prompt="Use the configured voice.",
seed_tts_speech_extra=None,
)
request_func_input = _seed_tts_request_func_input()
_attach_seed_tts_to_request_func_input(sample, request_func_input)
assert getattr(request_func_input, "seed_tts_row", False) is True
assert request_func_input.omni_chat_messages == [
{
"role": "system",
"content": [{"type": "text", "text": "Use the configured voice."}],
},
{"role": "user", "content": [{"type": "text", "text": "target text"}]},
]
assert "ref_audio" not in request_func_input.extra_body
class MockResponse:
"""Mock aiohttp response for testing"""
@@ -274,6 +349,107 @@ def create_sse_chunk(data_dict):
return f"data: {json.dumps(data_dict)}\n\n".encode()
def test_chat_text_timing_metrics_request_stage_metrics():
args = Namespace(
backend="openai-chat-omni",
percentile_metrics="ttft,tpot,itl,e2el",
print_stage=False,
extra_body={},
)
assert should_request_stage_metrics(args) is True
@pytest.mark.asyncio
async def test_bundled_first_text_chunk_uses_stage0_token_timings(mocker: MockerFixture):
"""Engine timings recover TPOT when every text token arrives together."""
request_input = RequestFuncInput(
model="test-model",
model_name="test-model",
prompt="test prompt",
api_url="http://test.com/v1/chat/completions",
prompt_len=10,
output_len=20,
)
chunks = [
create_sse_chunk(
{
"choices": [{"delta": {"content": "ABCD"}}],
"modality": "text",
"usage": {"prompt_tokens": 10, "completion_tokens": 4, "total_tokens": 14},
"metrics": {
"stage_metrics": {
"0": {
"num_tokens_out": 4,
"vllm_itls_ms": [10.0, 11.0, 12.0],
"vllm_tpot_ms": 11.0,
}
}
},
}
),
b"data: [DONE]\n\n",
]
mock_response = MockResponse(200, chunks)
mock_session = mocker.AsyncMock()
mock_session.post = mocker.MagicMock(return_value=mock_response)
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
assert output.success is True
assert output.output_tokens == 4
assert output.itl == pytest.approx([0.010, 0.011, 0.012])
assert output.text_latency - output.ttft == pytest.approx(0.033)
assert output.tpot_measured is True
@pytest.mark.asyncio
async def test_positive_client_text_timings_take_precedence_over_stage0(mocker: MockerFixture):
request_input = RequestFuncInput(
model="test-model",
model_name="test-model",
prompt="test prompt",
api_url="http://test.com/v1/chat/completions",
prompt_len=10,
output_len=20,
)
chunks = [
create_sse_chunk(
{
"choices": [{"delta": {"content": "A"}}],
"modality": "text",
"usage": {"prompt_tokens": 10, "completion_tokens": 1, "total_tokens": 11},
}
),
create_sse_chunk(
{
"choices": [{"delta": {"content": "B"}}],
"modality": "text",
"usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12},
"metrics": {
"stage_metrics": {
"0": {
"num_tokens_out": 2,
"vllm_itls_ms": [1000.0],
"vllm_tpot_ms": 1000.0,
}
}
},
}
),
b"data: [DONE]\n\n",
]
mock_response = MockResponse(200, chunks, delay_between_chunks=0.01)
mock_session = mocker.AsyncMock()
mock_session.post = mocker.MagicMock(return_value=mock_response)
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
assert output.success is True
assert len(output.itl) == 1
assert 0.0 < output.itl[0] < 0.1
# ============================================================================
# output_tokens Tests
# ============================================================================
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Tests for SeedTTSTextDataset, SeedTTSTextSampleRequest, SeedTTSDesignDataset,
and SeedTTSDesignSampleRequest.
@@ -234,34 +237,6 @@ def test_seed_tts_design_dataset_rejects_missing_description(seed_tts_design_roo
assert req.seed_tts_utterance_id == "ok"
def test_attach_sets_seed_tts_row_even_without_extra_body():
"""seed_tts_row=True must be set for SeedTTSTextSampleRequest (no extra body)."""
from vllm_omni.benchmarks.data_modules.seed_tts_dataset import SeedTTSTextSampleRequest
req = SeedTTSTextSampleRequest(
prompt="hello world",
prompt_len=2,
expected_output_len=100,
multi_modal_data=None,
request_id="test-0",
seed_tts_speech_extra=None,
seed_tts_ref_wav_path="",
)
assert req.seed_tts_speech_extra is None
assert req.seed_tts_ref_wav_path == ""
# The fix ensures that even with speech_extra=None, the function
# sets seed_tts_row=True. We verify the source code has the fix.
import inspect
import vllm_omni.benchmarks.patch.patch as patch_mod
src = inspect.getsource(patch_mod._attach_seed_tts_to_request_func_input)
# seed_tts_row must be set BEFORE the 'if not ex: return' check
row_pos = src.index("seed_tts_row")
not_ex_pos = src.index("if not ex:")
assert row_pos < not_ex_pos, "seed_tts_row must be set before 'if not ex: return'"
def test_seed_tts_whisper_transcribe_passes_attention_mask(monkeypatch):
from vllm_omni.benchmarks.data_modules import seed_tts_eval
+42
View File
@@ -0,0 +1,42 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from pathlib import Path
from shlex import split
import pytest
import yaml
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
AMD_MERGE_PIPELINE = Path(".buildkite/amd/test-amd-merge.yml")
def _find_step(label: str) -> dict:
pipeline = yaml.safe_load(AMD_MERGE_PIPELINE.read_text(encoding="utf-8"))
def walk(steps: list[dict]) -> dict | None:
for step in steps:
if step.get("label") == label:
return step
if nested := walk(step.get("steps", [])):
return nested
return None
step = walk(pipeline.get("steps", []))
assert step is not None, f"missing AMD pipeline step: {label}"
return step
def test_qwen3_tts_base_preserves_advanced_model_arguments() -> None:
step = _find_step("Qwen3-TTS Base E2E Test")
commands = step["commands"]
assert all("bash -c" not in command for command in commands)
pytest_command = next(command for command in commands if "pytest" in command)
argv = split(pytest_command)
marker_index = argv.index("-m")
run_level_index = argv.index("--run-level")
assert argv[marker_index + 1] == "advanced_model and cuda"
assert argv[run_level_index + 1] == "advanced_model"
+150 -27
View File
@@ -41,6 +41,11 @@ from vllm_omni.engine.arg_utils import SHARED_FIELDS, internal_blacklist_keys
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
SINGLE_STAGE_PIPE_CFG = (StagePipelineConfig(stage_id=0, model_stage="a", final_output=True),)
# Validation strings to match against in post init
NO_STAGES_MATCH_STR = "no stages"
NO_TERMINAL_STAGE_MATCH_STR = "No terminal stage"
@pytest.fixture(autouse=True)
def _stable_test_platform(monkeypatch):
@@ -618,6 +623,7 @@ class TestPipelineDiscovery:
p = PipelineConfig(
model_type="custom_collide",
hf_architectures=("SomeCollidingArch",),
stages=SINGLE_STAGE_PIPE_CFG,
)
assert p.hf_architectures == ("SomeCollidingArch",)
@@ -639,33 +645,63 @@ class TestStagePipelineConfig:
class TestPipelineConfigNew:
def test_frozen(self):
p = PipelineConfig(model_type="t", model_arch="A")
with pytest.raises(AttributeError):
p.model_type = "changed"
def test_simple_init(self):
# Ensure pipeline config validates in post init.
PipelineConfig(
model_type="t",
model_arch="A",
stages=SINGLE_STAGE_PIPE_CFG,
)
def test_validate_valid(self):
def test_frozen(self):
p = PipelineConfig(
model_type="t",
model_arch="A",
stages=SINGLE_STAGE_PIPE_CFG,
)
with pytest.raises(AttributeError):
p.model_type = "changed"
def test_validate_no_stages(self):
with pytest.raises(ValueError, match=NO_STAGES_MATCH_STR):
PipelineConfig(model_type="t", model_arch="A")
def test_validate_no_terminal_stage(self):
"""A pipeline with no ``final_output`` stage can never emit a result."""
with pytest.raises(ValueError, match=NO_TERMINAL_STAGE_MATCH_STR):
PipelineConfig(
model_type="t",
model_arch="A",
stages=(
StagePipelineConfig(stage_id=0, model_stage="a"),
StagePipelineConfig(stage_id=1, model_stage="b", input_sources=(0,)),
),
)
def test_pipeline_can_have_final_output_in_any_stage(self):
"""Ensure any stage may carry ``final_output``."""
PipelineConfig(
model_type="t",
model_arch="A",
stages=(
StagePipelineConfig(stage_id=0, model_stage="a"),
StagePipelineConfig(stage_id=0, model_stage="a", final_output=True),
StagePipelineConfig(stage_id=1, model_stage="b", input_sources=(0,)),
),
)
assert p.validate() == []
def test_validate_no_stages(self):
p = PipelineConfig(model_type="t", model_arch="A")
assert any("no stages" in e.lower() for e in p.validate())
class TestPipelineRegistration:
def test_resolve_pipeline_prefers_deploy_pipeline_key(self, clean_pipeline_registry, tmp_path):
deploy_key = "deploy_selected_pipeline"
model_type_key = "hf_model_type_pipeline"
deploy_pipe = PipelineConfig(model_type=deploy_key)
model_type_pipe = PipelineConfig(model_type=model_type_key)
deploy_pipe = PipelineConfig(
model_type=deploy_key,
stages=SINGLE_STAGE_PIPE_CFG,
)
model_type_pipe = PipelineConfig(
model_type=model_type_key,
stages=SINGLE_STAGE_PIPE_CFG,
)
register_pipeline(deploy_pipe)
register_pipeline(model_type_pipe)
@@ -691,7 +727,11 @@ class TestPipelineRegistration:
tmp_path,
):
model_type_key = "registered_model_type_pipeline"
register_pipeline(PipelineConfig(model_type=model_type_key))
pipeline_cfg = PipelineConfig(
model_type=model_type_key,
stages=SINGLE_STAGE_PIPE_CFG,
)
register_pipeline(pipeline_cfg)
deploy_path = tmp_path / "deploy.yaml"
deploy_path.write_text("pipeline: missing_pipeline\n", encoding="utf-8")
@@ -726,6 +766,7 @@ class TestPipelineRegistration:
pipe_cfg = PipelineConfig(
model_type=pipeline_key,
hf_architectures=("ArchitectureFallbackForTest",),
stages=SINGLE_STAGE_PIPE_CFG,
)
register_pipeline(pipe_cfg)
@@ -756,15 +797,18 @@ class TestPipelineRegistration:
model_type="rejected_by_predicate",
hf_architectures=(shared_arch,),
hf_config_predicate=rejecting_predicate,
stages=SINGLE_STAGE_PIPE_CFG,
)
raises_cfg = PipelineConfig(
model_type="raises_in_predicate",
hf_architectures=(shared_arch,),
hf_config_predicate=raising_predicate,
stages=SINGLE_STAGE_PIPE_CFG,
)
accept_cfg = PipelineConfig(
model_type="accepted_by_predicate",
hf_architectures=(shared_arch,),
stages=SINGLE_STAGE_PIPE_CFG,
)
register_pipeline(reject_cfg)
register_pipeline(raises_cfg)
@@ -798,6 +842,7 @@ class TestPipelineRegistration:
model_type="predicate_without_arch_match",
hf_architectures=("DifferentArchitectureForTest",),
hf_config_predicate=predicate,
stages=SINGLE_STAGE_PIPE_CFG,
)
register_pipeline(pipe_cfg)
@@ -821,6 +866,7 @@ class TestPipelineRegistration:
resolved_cfg = PipelineConfig(
model_type="callable_resolved_pipeline",
hf_architectures=("CallableArchitectureForTest",),
stages=SINGLE_STAGE_PIPE_CFG,
)
seen_hf_configs = []
@@ -894,7 +940,10 @@ class TestPipelineRegistration:
def test_pipeline_registration(self, clean_pipeline_registry):
"""Ensure that we can register and create a custom pipeline config."""
new_model_type = "new_model_type"
pipe_cfg = PipelineConfig(model_type=new_model_type)
pipe_cfg = PipelineConfig(
model_type=new_model_type,
stages=SINGLE_STAGE_PIPE_CFG,
)
# Register the new PipelineConfig
assert new_model_type not in OMNI_PIPELINES
@@ -937,7 +986,10 @@ class TestPipelineRegistration:
def custom_resolver(
hf_config: FakeConfig,
) -> PipelineConfig:
return PipelineConfig(model_type=resolved_type)
return PipelineConfig(
model_type=resolved_type,
stages=SINGLE_STAGE_PIPE_CFG,
)
# Register the new PipelineConfig
assert new_model_type not in OMNI_PIPELINES
@@ -994,7 +1046,7 @@ class TestPipelineRegistration:
pipe_cfg = PipelineConfig(
model_type=pipeline_key,
model_arch="DeployOnlyArch",
stages=(StagePipelineConfig(stage_id=0, model_stage="diffusion"),),
stages=SINGLE_STAGE_PIPE_CFG,
)
register_pipeline(pipe_cfg)
@@ -1027,7 +1079,11 @@ class TestPipelineRegistration:
def test_structured_path_loads_explicit_deploy_config_once(self, clean_pipeline_registry, tmp_path):
pipeline_key = "single_load_pipeline"
register_pipeline(PipelineConfig(model_type=pipeline_key))
pipeline_cfg = PipelineConfig(
model_type=pipeline_key,
stages=SINGLE_STAGE_PIPE_CFG,
)
register_pipeline(pipeline_cfg)
deploy_path = tmp_path / "single_load.yaml"
deploy_path.write_text(f"pipeline: {pipeline_key}\n", encoding="utf-8")
@@ -1057,6 +1113,7 @@ class TestPipelineRegistration:
pipeline = PipelineConfig(
model_type="pipeline_with_default",
default_deploy_config_name=default_name,
stages=SINGLE_STAGE_PIPE_CFG,
)
with patch(
@@ -1076,8 +1133,16 @@ class TestPipelineRegistration:
OmniServingCapability.COMPLETIONS,
"pipeline_a blocks completions",
)
pipe_a = PipelineConfig(model_type="detect_type", endpoint_restrictions=(restriction,))
pipe_b = PipelineConfig(model_type="override_type", endpoint_restrictions=())
pipe_a = PipelineConfig(
model_type="detect_type",
endpoint_restrictions=(restriction,),
stages=SINGLE_STAGE_PIPE_CFG,
)
pipe_b = PipelineConfig(
model_type="override_type",
endpoint_restrictions=(),
stages=SINGLE_STAGE_PIPE_CFG,
)
register_pipeline(pipe_a)
register_pipeline(pipe_b)
@@ -1617,6 +1682,7 @@ stages:
model_stage="ar",
execution_type=StageExecutionType.LLM_AR,
requires_multimodal_data=True,
final_output=True,
),
),
)
@@ -1637,6 +1703,7 @@ stages:
model_stage="ar",
execution_type=StageExecutionType.LLM_AR,
scheduler_cls=scheduler_cls,
final_output=True,
),
),
)
@@ -1882,7 +1949,6 @@ class TestQwen3OmniPipeline:
assert isinstance(p, PipelineConfig)
assert p.model_arch == "Qwen3OmniMoeForConditionalGeneration"
assert len(p.stages) == 3
assert p.validate() == []
def test_thinker(self):
p = resolve_pipeline_config(
@@ -1936,7 +2002,6 @@ class TestQwen2_5OmniPipeline:
assert isinstance(p, PipelineConfig)
assert p.model_arch == "Qwen2_5OmniForConditionalGeneration"
assert len(p.stages) == 3
assert p.validate() == []
def test_thinker(self):
p = resolve_pipeline_config("qwen2_5_omni")
@@ -1985,7 +2050,6 @@ class TestQwen3TTSPipeline:
assert p is not None
assert p.model_arch == "Qwen3TTSTalkerForConditionalGeneration"
assert len(p.stages) == 2
assert p.validate() == []
def test_talker_stage(self):
p = resolve_pipeline_config("qwen3_tts")
@@ -2065,7 +2129,6 @@ class TestMingFlashOmniPipeline:
assert isinstance(p, PipelineConfig)
assert p.model_arch == "MingFlashOmniForConditionalGeneration"
assert len(p.stages) == 2
assert p.validate() == []
def test_thinker_stage(self):
p = resolve_pipeline_config("ming_flash_omni")
@@ -2122,7 +2185,6 @@ class TestMingFlashOmniPipeline:
assert isinstance(p, PipelineConfig)
assert p.model_arch == "MingFlashOmniTalkerForConditionalGeneration"
assert len(p.stages) == 1
assert p.validate() == []
def test_tts_stage(self):
p = resolve_pipeline_config("ming_flash_omni_tts")
@@ -2179,7 +2241,6 @@ class TestMingFlashOmniPipeline:
assert isinstance(p, PipelineConfig)
assert p.model_arch == "MingFlashOmniForConditionalGeneration"
assert len(p.stages) == 1
assert p.validate() == []
def test_thinker_only_stage(self):
p = resolve_pipeline_config("ming_flash_omni_thinker_only")
@@ -2218,7 +2279,6 @@ class TestMingFlashOmniPipeline:
assert p is not None
assert p.model_arch == "MingFlashOmniForConditionalGeneration"
assert len(p.stages) == 2
assert p.validate() == []
def test_image_thinker_stage(self):
s = resolve_pipeline_config("ming_flash_omni_image").get_stage(0)
@@ -2339,6 +2399,21 @@ class TestBaseConfigInheritance:
# CI overrides max_tokens
assert s0["max_tokens"] == 150
def test_qwen3_omni_colocate_async_bounds_only_rocm_kv_cache(self):
ci_path = Path(get_deploy_config_path("ci/qwen3_omni_moe_colocate_async.yaml"))
pipeline = resolve_pipeline_config("qwen3_omni_moe_thinker_only")
assert isinstance(pipeline, PipelineConfig)
cuda = _apply_platform_overrides(load_deploy_config(ci_path), platform="cuda")
cuda_stage = merge_pipeline_deploy(pipeline, cuda)[0]
assert cuda_stage.yaml_engine_args["gpu_memory_utilization"] == 0.9
assert "kv_cache_memory_bytes" not in cuda_stage.yaml_engine_args
rocm = _apply_platform_overrides(load_deploy_config(ci_path), platform="rocm")
rocm_stage = merge_pipeline_deploy(pipeline, rocm)[0]
assert "gpu_memory_utilization" not in rocm_stage.yaml_engine_args
assert rocm_stage.yaml_engine_args["kv_cache_memory_bytes"] == 2 * 1024**3
def test_pure_inheritance_overlay(self, tmp_path):
"""An overlay with only ``base_config`` inherits everything."""
base = Path(get_deploy_config_path("qwen3_omni_moe.yaml"))
@@ -2800,6 +2875,54 @@ stages:
assert omega_config.engine_args.max_model_len == 8192
assert omega_config.engine_args.enforce_eager is True
def test_cli_attention_shorthand_replaces_yaml_structured_default(self):
stage = StageConfig(
stage_id=0,
model_stage="dit",
stage_type=StageType.DIFFUSION,
yaml_engine_args={
"diffusion_attention_config": {
"default": {"backend": "FLASH_ATTN"},
"per_role": {"cross": {"backend": "TORCH_SDPA"}},
},
},
runtime_overrides={"diffusion_attention_backend": "SAGE_ATTN"},
)
engine_args = stage.to_omegaconf().engine_args
assert engine_args.diffusion_attention_backend == "SAGE_ATTN"
assert "default" not in engine_args.diffusion_attention_config
assert engine_args.diffusion_attention_config.per_role.cross.backend == "TORCH_SDPA"
def test_cli_attention_shorthand_keeps_yaml_per_role_only_config(self):
stage = StageConfig(
stage_id=0,
model_stage="dit",
stage_type=StageType.DIFFUSION,
yaml_engine_args={"diffusion_attention_config": {"per_role": {"cross": {"backend": "TORCH_SDPA"}}}},
runtime_overrides={"diffusion_attention_backend": "SAGE_ATTN"},
)
engine_args = stage.to_omegaconf().engine_args
assert engine_args.diffusion_attention_backend == "SAGE_ATTN"
assert engine_args.diffusion_attention_config == {"per_role": {"cross": {"backend": "TORCH_SDPA"}}}
def test_cli_attention_config_replaces_yaml_shorthand(self):
stage = StageConfig(
stage_id=0,
model_stage="dit",
stage_type=StageType.DIFFUSION,
yaml_engine_args={"diffusion_attention_backend": "TORCH_SDPA"},
runtime_overrides={"diffusion_attention_config": {"default": {"backend": "SAGE_ATTN"}}},
)
engine_args = stage.to_omegaconf().engine_args
assert "diffusion_attention_backend" not in engine_args
assert engine_args.diffusion_attention_config.default.backend == "SAGE_ATTN"
class TestSentinelDefaultPrecedence:
"""Caller-typed (non-None) values win over YAML; None values fall through
+2 -2
View File
@@ -167,7 +167,7 @@ def test_inventory_matches_reviewed_snapshot_counts():
EnvironmentVariableCategory.PUBLIC_OMNI: 24,
EnvironmentVariableCategory.INHERITED_VLLM: 20,
EnvironmentVariableCategory.PLATFORM_EXTERNAL: 27,
EnvironmentVariableCategory.MODEL_SPECIFIC: 56,
EnvironmentVariableCategory.MODEL_SPECIFIC: 57,
EnvironmentVariableCategory.BENCHMARK_TRANSITIONAL: 20,
EnvironmentVariableCategory.INTERNAL: 2,
}
@@ -178,7 +178,7 @@ def test_inventory_matches_reviewed_snapshot_counts():
if item.category is EnvironmentVariableCategory.MODEL_SPECIFIC
)
assert {disposition: disposition_counts[disposition] for disposition in ModelEnvironmentVariableDisposition} == {
ModelEnvironmentVariableDisposition.PROMOTE: 33,
ModelEnvironmentVariableDisposition.PROMOTE: 34,
ModelEnvironmentVariableDisposition.REQUEST_SCOPE: 6,
ModelEnvironmentVariableDisposition.EXTERNAL: 0,
ModelEnvironmentVariableDisposition.INTERNALIZE: 11,
+6 -1
View File
@@ -45,6 +45,7 @@ from vllm_omni.config.stage_config import (
PipelineConfig,
StageDeployConfig,
StageExecutionType,
StagePipelineConfig,
load_deploy_config,
merge_pipeline_deploy,
)
@@ -1039,7 +1040,10 @@ def test_from_pipeline_config_uses_hf_config_for_callable_resolver():
def test_from_pipeline_config_accepts_pre_resolved_pipeline():
resolved_pipeline = PipelineConfig(model_type="callable_resolved_variant")
resolved_pipeline = PipelineConfig(
model_type="callable_resolved_variant",
stages=(StagePipelineConfig(stage_id=0, model_stage="a", final_output=True),),
)
omni_config = VllmOmniConfig.from_pipeline_config(resolved_pipeline)
@@ -1072,6 +1076,7 @@ def test_from_pipeline_config_default_deploy_name_ignores_cwd(monkeypatch, tmp_p
pipeline = PipelineConfig(
model_type="pipeline_with_default",
default_deploy_config_name=default_name,
stages=(StagePipelineConfig(stage_id=0, model_stage="a", final_output=True),),
)
loaded_paths = []
+11 -5
View File
@@ -1,16 +1,22 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Tests for out of tree registration to OMNI_PIPELINES."""
import pytest
from transformers import PretrainedConfig
from vllm_omni.config.pipeline_registry import OMNI_PIPELINES, register_pipeline
from vllm_omni.config.stage_config import PipelineConfig, pipeline_cfg_resolver
from vllm_omni.config.stage_config import PipelineConfig, StagePipelineConfig, pipeline_cfg_resolver
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def build_fake_pipeline_config(model_type: str) -> PipelineConfig:
return PipelineConfig(
model_type=model_type, stages=(StagePipelineConfig(stage_id=0, model_stage="a", final_output=True),)
)
@pytest.fixture
def custom_resolver():
"""Build a reusable custom resolver for PipelineConfigs."""
@@ -22,7 +28,7 @@ def custom_resolver():
def custom_resolver(
hf_config: CustomConfigType,
) -> PipelineConfig:
return PipelineConfig(model_type="resolved_type")
return build_fake_pipeline_config("resolved_type")
return custom_resolver
@@ -30,7 +36,7 @@ def custom_resolver():
def test_register_pipeline_config(clean_pipeline_registry):
"""Ensure that we can register a custom pipeline config to OMNI_PIPELINES."""
new_model_type = "new_model_type"
pipe_cfg = PipelineConfig(model_type=new_model_type)
pipe_cfg = build_fake_pipeline_config(new_model_type)
assert new_model_type not in OMNI_PIPELINES
register_pipeline(pipe_cfg)
assert new_model_type in OMNI_PIPELINES
@@ -41,7 +47,7 @@ def test_register_pipeline_config_with_model_type(clean_pipeline_registry):
"""Ensure that we can register a custom pipeline config with an explicit model_type to OMNI_PIPELINES."""
new_model_type = "new_model_type"
unused_model_type = "foo"
pipe_cfg = PipelineConfig(model_type=unused_model_type)
pipe_cfg = build_fake_pipeline_config(unused_model_type)
assert new_model_type not in OMNI_PIPELINES
assert unused_model_type not in OMNI_PIPELINES
@@ -70,10 +70,14 @@ _PARAMS = [
pytest.param({"guidance_scale": -1.0}, "guidance_scale", id="guidance_scale_negative", marks=_SKIP_ISSUE_3649),
pytest.param({"guidance_scale": 0}, "guidance_scale", id="guidance_scale_zero", marks=_SKIP_ISSUE_3649),
pytest.param(
{"num_inference_steps": 0}, "num_inference_steps", id="num_inference_steps_zero", marks=_SKIP_ISSUE_3649
{"num_inference_steps": 0},
("num_inference_steps", "greater than or equal to 1"),
id="num_inference_steps_zero",
),
pytest.param(
{"num_inference_steps": -1}, ("number of steps", "non-negative"), id="num_inference_steps_negative"
{"num_inference_steps": -1},
("num_inference_steps", "greater than or equal to 1"),
id="num_inference_steps_negative",
),
pytest.param(
{"num_inference_steps": 6000},
@@ -157,7 +157,13 @@ def test_speech_missing_required_fields(omni_server: OmniServer, online_client:
"ref_audio": None,
"ref_text": None,
},
("Invalid voice", "vivian"),
# NOTE: This is delegating down the TTS adapter, so the message is both specific
# to the Qwen3TTS adapter and dependent on whether or not the model has any speakers
# (as opposed to having valida speakers, but getting an invalid speaker). it would
# be more ideal to validate that this is landing on `.validate()` on the adapter
# and ensuring that the returned message matches what we would get from calling the
# adapter validate directly.
("no speakers configured",),
id="customvoice_invalid_voice",
),
pytest.param(
@@ -11,6 +11,7 @@ Tests cover:
"""
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
import torch
@@ -466,6 +467,120 @@ class TestCurrentDiffusionConfig:
class TestAttentionInitUsesCurrentDiffusionConfig:
@pytest.mark.parametrize("mindiesd_available", [False, True])
def test_paged_npu_memory_profile_uses_sdpa_only_without_mindiesd(self, monkeypatch, mindiesd_available):
attention = Attention.__new__(Attention)
attention._scheduler_paged_kv = True
attention.paged_kv_cache_role = "primary"
attention.backend_pref = "FLASH_ATTN"
attention.attn_backend = SimpleNamespace(supports_piecewise_spans=True, get_name=lambda: "FLASH_ATTN")
flash_output = torch.ones(1)
sdpa_output = torch.zeros(1)
dense_flash = Mock(return_value=flash_output)
sdpa = Mock(return_value=sdpa_output)
attention.attention = SimpleNamespace(forward=dense_flash)
attention.sdpa_fallback = SimpleNamespace(forward=sdpa)
monkeypatch.setattr(layer_mod, "is_forward_context_available", lambda: True)
monkeypatch.setattr(
layer_mod,
"get_forward_context",
lambda: SimpleNamespace(in_diffusion_kv_memory_profile=True),
)
monkeypatch.setattr(layer_mod.current_omni_platform, "is_npu", lambda: True)
monkeypatch.setattr(
layer_mod.current_omni_platform,
"supports_diffusion_dense_flash_attention",
lambda: mindiesd_available,
)
result = attention._run_local_attention(
torch.zeros(1, dtype=torch.bfloat16),
torch.zeros(1, dtype=torch.bfloat16),
torch.zeros(1, dtype=torch.bfloat16),
AttentionMetadata(),
)
if mindiesd_available:
assert result is flash_output
dense_flash.assert_called_once()
sdpa.assert_not_called()
else:
assert result is sdpa_output
dense_flash.assert_not_called()
sdpa.assert_called_once()
def test_dense_flash_import_error_is_not_reclassified(self, monkeypatch):
attention = Attention.__new__(Attention)
attention._scheduler_paged_kv = False
attention.paged_kv_cache_role = "primary"
attention.backend_pref = "FLASH_ATTN"
attention.attn_backend = SimpleNamespace(supports_piecewise_spans=True, get_name=lambda: "FLASH_ATTN")
attention.attention = SimpleNamespace(
forward=Mock(side_effect=ModuleNotFoundError("No module named 'mindiesd'", name="mindiesd"))
)
attention.sdpa_fallback = SimpleNamespace(forward=Mock(side_effect=AssertionError("must not fall back")))
monkeypatch.setattr(layer_mod, "is_forward_context_available", lambda: False)
with pytest.raises(ModuleNotFoundError, match="mindiesd"):
attention._run_local_attention(
torch.zeros(1, dtype=torch.bfloat16),
torch.zeros(1, dtype=torch.bfloat16),
torch.zeros(1, dtype=torch.bfloat16),
AttentionMetadata(),
)
def test_paged_marker_does_not_change_dense_backend(self, monkeypatch):
class _FakeAttentionImpl:
def __init__(self, **kwargs):
self.kwargs = kwargs
def forward(self, query, key, value, attn_metadata=None):
return query
class _DenseBackend:
supports_paged_kv = False
@staticmethod
def get_name() -> str:
return "TORCH_SDPA"
@staticmethod
def get_impl_cls():
return _FakeAttentionImpl
calls = []
def _fake_get_attn_backend_for_role(*args, **kwargs):
calls.append((args, kwargs))
return _DenseBackend, None
monkeypatch.setattr(layer_mod, "get_attn_backend_for_role", _fake_get_attn_backend_for_role)
monkeypatch.setattr(layer_mod.SDPABackend, "get_impl_cls", staticmethod(lambda: _FakeAttentionImpl))
monkeypatch.setattr(layer_mod, "build_parallel_attention_strategy", lambda **kwargs: object())
monkeypatch.setattr(layer_mod, "is_forward_context_available", lambda: False)
od_config = SimpleNamespace(
diffusion_attention_config=AttentionConfig(),
diffusion_kv_mode=layer_mod.DiffusionKVCacheMode.DENSE_LEGACY,
parallel_config=SimpleNamespace(ring_degree=1),
diffusion_kv_cache_dtype=None,
diffusion_kv_cache_skip_step_indices=None,
diffusion_kv_cache_skip_layer_indices=None,
)
with set_current_diffusion_config(od_config):
attention = Attention(
num_heads=4,
head_size=64,
causal=False,
softmax_scale=1.0,
paged_kv_cache_role="primary",
)
assert len(calls) == 1
assert attention.attn_backend is _DenseBackend
assert attention.backend_pref is None
def test_attention_init_uses_current_diffusion_config_without_forward_context(self, monkeypatch):
class _FakeAttentionImpl:
def __init__(self, **kwargs):
+9 -2
View File
@@ -353,8 +353,15 @@ def test_cross_attn_key_padding_vs_sdpa(k_len):
print(f"Max absolute difference: {max_diff:.6f}")
print(f"Mean absolute difference: {mean_diff:.6f}")
assert max_diff < 0.01, f"Max difference {max_diff} exceeds threshold 0.01"
assert mean_diff < 0.001, f"Mean difference {mean_diff} exceeds threshold 0.001"
# Match the platform-specific comparison used by test_fa_vs_sdpa above.
if current_omni_platform.is_rocm():
# AITER FlashAttention and PyTorch SDPA use different BF16 reduction
# implementations on ROCm, so individual values can differ by one BF16
# step even though the outputs agree within the expected relative error.
torch.testing.assert_close(output_fa, output_sdpa, rtol=1e-2, atol=1.5e-2)
else:
assert max_diff < 0.01, f"Max difference {max_diff} exceeds threshold 0.01"
assert mean_diff < 0.001, f"Mean difference {mean_diff} exceeds threshold 0.001"
print("✓ Case 3 PASSED: FA and SDPA cross-attention outputs are very close!")
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""End-to-end test for ``piecewise_attn`` (CPU).
Verify that running attention in segments (causal outside full-attn spans,
@@ -98,9 +98,12 @@ def test_paged_piecewise_runner_uses_packed_indices_and_kv_endpoints():
query = torch.arange(12 * 2 * 4, dtype=torch.float32).reshape(12, 2, 4)
key = query + 100
value = query + 200
calls = []
calls: list[object] = []
def run_segment(segment_query, segment_key, segment_value, segment_batch):
output_buffer = torch.empty_like(query)
def run_segment(segment_query, segment_key, segment_value, segment_batch, segment_output_buffer):
assert segment_output_buffer is None
calls.append(
(
[segment.mode for segment in segment_batch.row_segments],
@@ -119,9 +122,13 @@ def test_paged_piecewise_runner_uses_packed_indices_and_kv_endpoints():
plan,
plan.segments,
run_segment,
output_buffer=output_buffer,
)
assert output is output_buffer
torch.testing.assert_close(output, query)
assert all(segment.query_range is None for segment in plan.segments)
assert not plan.segments_cover_query_contiguously
assert calls == [
(["causal", "causal"], [2, 4], [0, 1, 6, 7]),
(["full", "full"], [5, 7], [2, 3, 4, 8, 9, 10]),
@@ -129,6 +136,161 @@ def test_paged_piecewise_runner_uses_packed_indices_and_kv_endpoints():
]
def test_paged_piecewise_runner_preserves_homogeneous_batch_without_scatter(monkeypatch: pytest.MonkeyPatch):
plan = build_paged_piecewise_plan(
[[(2, 5)], [(2, 5)]],
query_offsets=[0, 0],
query_lens=[6, 6],
seq_lens=[6, 6],
device=DEVICE,
)
assert plan.homogeneous_batch_shape == (2, 6)
query = torch.arange(12 * 2 * 4, dtype=torch.float32).reshape(12, 2, 4)
calls: list[torch.Tensor] = []
def fail_index_copy(*_args, **_kwargs):
raise AssertionError("homogeneous piecewise path must not use index_copy_")
monkeypatch.setattr(torch.Tensor, "index_copy_", fail_index_copy)
def run_segment(segment_query, segment_key, segment_value, _metadata, segment_output_buffer):
assert segment_key is None
assert segment_value is None
assert segment_output_buffer is None
calls.append(segment_query.clone())
return segment_query + 1
output_buffer = torch.empty_like(query)
output = run_paged_piecewise_plan(
query,
None,
None,
plan,
plan.segments,
run_segment,
output_buffer=output_buffer,
use_homogeneous_batch=True,
)
assert output is output_buffer
torch.testing.assert_close(output, query + 1)
assert [call.shape[0] for call in calls] == [4, 6, 2]
torch.testing.assert_close(calls[0], query[[0, 1, 6, 7]])
torch.testing.assert_close(calls[1], query[[2, 3, 4, 8, 9, 10]])
torch.testing.assert_close(calls[2], query[[5, 11]])
def test_paged_piecewise_runner_keeps_indexed_fallback_for_heterogeneous_layout():
plan = build_paged_piecewise_plan(
[[(2, 5)], [(1, 4)]],
query_offsets=[0, 0],
query_lens=[6, 6],
seq_lens=[6, 6],
device=DEVICE,
)
assert plan.homogeneous_batch_shape is None
query = torch.arange(12 * 2 * 4, dtype=torch.float32).reshape(12, 2, 4)
output = run_paged_piecewise_plan(
query,
None,
None,
plan,
plan.segments,
lambda segment_query, *_args: segment_query,
use_homogeneous_batch=True,
)
torch.testing.assert_close(output, query)
def test_paged_piecewise_runner_slices_contiguous_segments():
plan = build_paged_piecewise_plan(
[[(2, 5)]],
query_offsets=[0],
query_lens=[6],
seq_lens=[6],
device=DEVICE,
)
query = torch.arange(6 * 2 * 4, dtype=torch.float32).reshape(6, 2, 4)
key = query + 100
value = query + 200
expected_ranges = [(0, 2), (2, 5), (5, 6)]
calls: list[tuple[int, int]] = []
def run_segment(segment_query, segment_key, segment_value, _segment_batch, segment_output_buffer):
assert segment_output_buffer is None
expected_range = expected_ranges[len(calls)]
start, end = expected_range
calls.append(expected_range)
assert segment_query.untyped_storage().data_ptr() == query.untyped_storage().data_ptr()
assert segment_key.untyped_storage().data_ptr() == key.untyped_storage().data_ptr()
assert segment_value.untyped_storage().data_ptr() == value.untyped_storage().data_ptr()
torch.testing.assert_close(segment_query, query[start:end])
return segment_query
output = run_paged_piecewise_plan(query, key, value, plan, plan.segments, run_segment)
assert calls == expected_ranges
assert [segment.query_range for segment in plan.segments] == expected_ranges
assert plan.segments_cover_query_contiguously
torch.testing.assert_close(output, query)
def test_paged_piecewise_runner_writes_contiguous_segments_into_output_buffer():
plan = build_paged_piecewise_plan(
[[(2, 5)]],
query_offsets=[0],
query_lens=[6],
seq_lens=[6],
device=DEVICE,
)
query = torch.arange(6 * 2 * 4, dtype=torch.float32).reshape(6, 2, 4)
output_buffer = torch.empty(6, 2, 3)
targets = []
def run_segment(segment_query, _key, _value, _metadata, segment_output_buffer):
assert segment_output_buffer is not None
assert segment_output_buffer.untyped_storage().data_ptr() == output_buffer.untyped_storage().data_ptr()
targets.append(segment_output_buffer)
return segment_output_buffer.copy_(segment_query[..., :3])
output = run_paged_piecewise_plan(
query,
query,
query,
plan,
plan.segments,
run_segment,
output_buffer=output_buffer,
)
assert output is output_buffer
assert [target.shape[0] for target in targets] == [2, 3, 1]
torch.testing.assert_close(output, query[..., :3])
def test_paged_piecewise_runner_returns_single_full_segment_directly():
plan = build_paged_piecewise_plan(
[[]],
query_offsets=[0],
query_lens=[6],
seq_lens=[6],
device=DEVICE,
)
query = torch.randn(6, 2, 4)
runner_output = query + 1
output = run_paged_piecewise_plan(
query,
query,
query,
plan,
plan.segments,
lambda *_args: runner_output,
)
assert output is runner_output
def test_paged_piecewise_runner_uses_runner_output_shape():
plan = build_paged_piecewise_plan(
[[(2, 5)]],
@@ -7,7 +7,7 @@ End-to-end test for diffusion batching via AsyncOmni.
This test fires multiple concurrent ``AsyncOmni.generate()`` calls for a
diffusion model and validates that every caller receives its correct
individual result. When the underlying diffusion stage is configured with
``batch_size > 1`` (via stage config or ``StageDiffusionClient``), the
``max_num_seqs > 1``, the
requests will be batched internally.
Even without explicit batching config this test is useful for verifying
@@ -260,7 +260,7 @@ async def compare_single_vs_parallel(
) -> None:
"""Run the same prompts sequentially then in parallel and print a comparison."""
omni = AsyncOmni(model=model, max_num_seqs=batch_size, diffusion_batch_size=batch_size)
omni = AsyncOmni(model=model, max_num_seqs=batch_size)
try:
await warmup(omni, WARMUP_PROMPTS)
single_time = await run_single(omni, prompts)
@@ -288,7 +288,7 @@ async def main(model: str, num_prompts: int, mode: str, batch_size: int = 1) ->
await compare_single_vs_parallel(model, prompts, batch_size=batch_size)
return
omni = AsyncOmni(model=model, max_num_seqs=batch_size, diffusion_batch_size=batch_size)
omni = AsyncOmni(model=model, max_num_seqs=batch_size)
try:
await warmup(omni, WARMUP_PROMPTS)
@@ -389,7 +389,7 @@ def test_diffusion_batching_async_concurrent(model_name: str):
fired via asyncio.gather. Each request_id must appear in the results."""
async def _inner():
omni = AsyncOmni(model=model_name, diffusion_batch_size=1)
omni = AsyncOmni(model=model_name, max_num_seqs=1)
try:
prompts = TEST_PROMPTS[:4]
sp = _default_sampling_params()
@@ -430,7 +430,7 @@ def test_diffusion_batching_list_prompt_rejected(model_name: str):
"""
async def _inner():
omni = AsyncOmni(model=model_name, diffusion_batch_size=4)
omni = AsyncOmni(model=model_name, max_num_seqs=4)
try:
prompts = TEST_PROMPTS[:4]
sp = _default_sampling_params()
@@ -528,7 +528,7 @@ if __name__ == "__main__":
"--batch-size",
type=int,
default=1,
help="Scheduler in-flight width (--max-num-seqs) and client diffusion_batch_size",
help="Scheduler in-flight width (--max-num-seqs)",
)
parser.add_argument(
"--mode",
+15
View File
@@ -0,0 +1,15 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
import torch
@pytest.fixture(autouse=True)
def restore_torch_default_dtype():
"""Keep process-global dtype changes from leaking between diffusion tests."""
default_dtype = torch.get_default_dtype()
try:
yield
finally:
torch.set_default_dtype(default_dtype)
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
@@ -485,8 +485,16 @@ def test_valid_sequence_and_context_install_into_native_rows(monkeypatch: pytest
assert runner.get_diffusion_kv_row("req-0", 0, "text") == 1
sequence_binding = runner._resolve_paged_attention_row("req-0", 0, None)
context_binding = runner._resolve_paged_attention_row("req-0", None, "text")
assert (sequence_binding.row_index, sequence_binding.max_seq_len) == (0, 9)
assert (context_binding.row_index, context_binding.max_seq_len) == (1, 5)
assert (sequence_binding.row_index, sequence_binding.max_seq_len, sequence_binding.block_ids) == (
0,
9,
((1, 2, 3), (4, 5)),
)
assert (context_binding.row_index, context_binding.max_seq_len, context_binding.block_ids) == (
1,
5,
((6, 7), (8,)),
)
def test_block_table_mutations_invalidate_prepared_attention_batches(
+2 -2
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import pytest
@@ -36,7 +36,7 @@ def test_non_mapping_omni_kv_config_is_rejected() -> None:
OmniDiffusionConfig.from_kwargs(omni_kv_config="paged_scheduler")
def test_paged_scheduler_rejects_dense_legacy_kv_receive() -> None:
def test_paged_scheduler_rejects_dense_connector_kv_receive() -> None:
with pytest.raises(ValueError, match="does not support imported AR KV"):
OmniDiffusionConfig.from_kwargs(
diffusion_kv_mode="paged_scheduler",
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from types import SimpleNamespace
from unittest.mock import Mock
@@ -290,6 +290,7 @@ def test_minus_one_max_model_len_is_auto_fitted_by_native_cache_sizing() -> None
dtype=torch.bfloat16,
non_causal=True,
)
model_limit = vllm_config.model_config.max_model_len
initialization.build_native_kv_cache_configs(
vllm_config,
@@ -298,4 +299,8 @@ def test_minus_one_max_model_len_is_auto_fitted_by_native_cache_sizing() -> None
)
assert vllm_config.model_config.original_max_model_len == -1
assert vllm_config.model_config.max_model_len == 256
# Sixteen physical pages provide ``16 * block_size`` tokens. The native
# backend chooses the block geometry, so keep this assertion backend
# independent instead of assuming the generic 16-token block size.
expected_max_model_len = min(model_limit, spec.block_size * 16)
assert vllm_config.model_config.max_model_len == expected_max_model_len
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
@@ -20,12 +20,16 @@ from vllm_omni.diffusion.attention.layer import Attention
from vllm_omni.diffusion.diffusion_kv import paged_attention_adapter as adapter_module
from vllm_omni.diffusion.diffusion_kv.paged_attention_adapter import (
DiffusionPagedAttentionAdapter,
DiffusionPagedAttentionMetadata,
DiffusionPagedAttentionRow,
DiffusionPagedAttentionRowBinding,
DiffusionPagedAttentionRuntime,
)
from vllm_omni.diffusion.forward_context import (
get_forward_context,
override_paged_kv_adapter,
set_forward_context,
set_forward_context_denoise_step_idx,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
@@ -122,8 +126,8 @@ class _FakeNativeImpl:
def do_kv_cache_update(
self,
_layer,
key: torch.Tensor,
value: torch.Tensor,
key: torch.Tensor | None,
value: torch.Tensor | None,
_kv_cache,
slot_mapping: torch.Tensor,
) -> None:
@@ -155,6 +159,11 @@ def _make_adapter(
capacity: int = 16,
) -> tuple[DiffusionPagedAttentionAdapter, _FakeBlockTables, _FakeLayer, list[tuple]]:
events: list[tuple] = []
monkeypatch.setattr(
adapter_module.current_omni_platform,
"requires_diffusion_paged_kv_prewrite",
lambda: False,
)
block_tables = _FakeBlockTables()
block_tables.max_num_batched_tokens = capacity
block_tables.num_blocks.np.fill(math.ceil(capacity / block_tables.block_sizes[0]))
@@ -301,6 +310,47 @@ def test_paged_adapter_accepts_native_gqa_shapes(monkeypatch: pytest.MonkeyPatch
assert context.value_write.shape == (3, 8, 4)
def test_paged_adapter_preserves_strided_kv_for_platform_writer(monkeypatch: pytest.MonkeyPatch) -> None:
adapter, _, _, _ = _make_adapter(monkeypatch)
batch = adapter.prepare_batch(
[DiffusionPagedAttentionRow(request_id="req-0", sequence_id=0, query_len=5, seq_len=5)]
)
packed_qkv = torch.randn(1, 5, 3 * 2 * 4)
query, key, value = packed_qkv.split(2 * 4, dim=-1)
query = query.reshape(1, 5, 2, 4)
key = key.reshape(1, 5, 2, 4)
value = value.reshape(1, 5, 2, 4)
assert not key.is_contiguous()
assert not value.is_contiguous()
with adapter.activate(batch):
context = adapter.prepare_layer_context("layer-0", query, key, value)
assert context.query.is_contiguous()
assert not context.key_write.is_contiguous()
assert not context.value_write.is_contiguous()
assert context.key_write.data_ptr() == key.data_ptr()
assert context.value_write.data_ptr() == value.data_ptr()
def test_paged_adapter_packs_noncontiguous_kv_head_layout(monkeypatch: pytest.MonkeyPatch) -> None:
adapter, _, _, _ = _make_adapter(monkeypatch)
batch = adapter.prepare_batch(
[DiffusionPagedAttentionRow(request_id="req-0", sequence_id=0, query_len=5, seq_len=5)]
)
query = torch.randn(1, 5, 2, 4)
key = torch.randn(1, 5, 4, 2).transpose(-1, -2)
value = torch.randn(1, 5, 4, 2).transpose(-1, -2)
assert key.shape == (1, 5, 2, 4)
assert key.stride(-1) != 1
with adapter.activate(batch):
context = adapter.prepare_layer_context("layer-0", query, key, value)
assert context.key_write.is_contiguous()
assert context.value_write.is_contiguous()
def test_omni_paged_backend_defers_backend_owned_cache_update(monkeypatch: pytest.MonkeyPatch) -> None:
adapter, _, layer, _ = _make_adapter(monkeypatch)
layer.attn_backend.forward_includes_kv_cache_update = True
@@ -350,10 +400,48 @@ def test_omni_piecewise_backend_defers_backend_owned_cache_update(monkeypatch: p
assert [metadata.query_start_loc_cpu.tolist() for metadata in segment_metadata] == [[0, 1], [0, 2], [0, 1]]
assert [metadata.positions.tolist() for metadata in segment_metadata] == [[0], [1, 2], [3]]
assert [metadata.slot_mappings.tolist() for metadata in segment_metadata] == [[[0]], [[1, 2]], [[3]]]
for event in events[1:]:
build_kwargs = event[1]
assert build_kwargs["positions"].untyped_storage().data_ptr() == batch.positions.untyped_storage().data_ptr()
assert (
build_kwargs["slot_mappings"].untyped_storage().data_ptr()
== batch.slot_mappings.untyped_storage().data_ptr()
)
def test_platform_prewrite_updates_once_then_piecewise_reads_cache(monkeypatch: pytest.MonkeyPatch) -> None:
adapter, _, layer, _ = _make_adapter(monkeypatch)
layer.attn_backend.forward_includes_kv_cache_update = True
monkeypatch.setattr(
adapter_module.current_omni_platform,
"requires_diffusion_paged_kv_prewrite",
lambda: True,
)
adapter.resolve_row = lambda *_args: DiffusionPagedAttentionRowBinding(
row_index=2,
max_seq_len=16,
block_ids=((3, 7, 9, 11),),
)
row = DiffusionPagedAttentionRow(request_id="req-0", sequence_id=0, query_len=4, seq_len=4)
batch = adapter.prepare_batch([row])
qkv = torch.randn(1, 4, 2, 4)
metadata = SimpleNamespace(full_attn_spans=[[(1, 3)]], extra={})
with adapter.activate(batch):
context = adapter.prepare_layer_context("layer-0", qkv, qkv, qkv, omni_attn_metadata=metadata)
output = _run_omni_paged_backend(context)
assert torch.equal(output, qkv)
assert layer.native_events == ["update", "forward", "forward", "forward"]
assert len(layer.updates) == 1
assert all(call[1] is None and call[2] is None for call in layer.calls)
def test_omni_paged_backend_runs_hunyuan_piecewise_segments(monkeypatch: pytest.MonkeyPatch) -> None:
adapter, _, layer, events = _make_adapter(monkeypatch)
# Exercise the Ascend-only homogeneous batch path. CUDA keeps its native
# output-buffer contract and is covered by the GPU adapter tests.
monkeypatch.setattr(adapter_module.current_omni_platform, "is_npu", lambda: True)
batch = adapter.prepare_batch(
[
DiffusionPagedAttentionRow(
@@ -450,6 +538,48 @@ def test_omni_paged_backend_runs_hunyuan_piecewise_segments(monkeypatch: pytest.
]
def test_piecewise_metadata_snapshots_reused_native_scheduler_buffer(monkeypatch: pytest.MonkeyPatch) -> None:
adapter, _, _, _ = _make_adapter(monkeypatch)
shared_scheduler_metadata = torch.zeros(1, dtype=torch.int32)
build_id = 0
def build_metadata(**kwargs):
nonlocal build_id
shared_scheduler_metadata.fill_(build_id)
metadata = SimpleNamespace(
build_id=build_id,
causal=kwargs["causal"],
seq_lens=kwargs["seq_lens"].clone(),
query_start_loc_cpu=kwargs["query_start_loc_cpu"].clone(),
positions=kwargs["positions"].clone(),
slot_mappings=kwargs["slot_mappings"].clone(),
scheduler_metadata=shared_scheduler_metadata,
prefix_scheduler_metadata=None,
)
build_id += 1
return {"layer-0": metadata}
monkeypatch.setattr(adapter_module.current_omni_platform, "build_diffusion_kv_attn_metadata", build_metadata)
batch = adapter.prepare_batch(
[DiffusionPagedAttentionRow(request_id="req-0", sequence_id=0, query_len=4, seq_len=4)]
)
qkv = torch.randn(1, 4, 2, 4)
metadata = SimpleNamespace(full_attn_spans=[[(1, 3)]], extra={})
with adapter.activate(batch):
context = adapter.prepare_layer_context(
"layer-0",
qkv,
qkv,
qkv,
omni_attn_metadata=metadata,
)
segment_schedules = [item.scheduler_metadata for item in context.piecewise_native_metadata]
assert [item.item() for item in segment_schedules] == [1, 2, 3]
assert len({item.data_ptr() for item in segment_schedules}) == 3
def test_omni_paged_backend_treats_empty_full_spans_as_causal(monkeypatch: pytest.MonkeyPatch) -> None:
adapter, _, layer, events = _make_adapter(monkeypatch)
batch = adapter.prepare_batch(
@@ -851,6 +981,28 @@ def test_forward_rejects_batched_layout_that_does_not_match_rows(monkeypatch: py
adapter.prepare_layer_context("layer-0", qkv, qkv, qkv)
def test_forward_packs_and_restores_right_padded_heterogeneous_rows(monkeypatch: pytest.MonkeyPatch) -> None:
adapter, _, _, _ = _make_adapter(monkeypatch)
batch = adapter.prepare_batch(
[
DiffusionPagedAttentionRow(request_id="req-0", sequence_id=0, query_len=1, seq_len=1),
DiffusionPagedAttentionRow(request_id="req-1", sequence_id=1, query_len=3, seq_len=3),
]
)
qkv = torch.arange(2 * 3 * 2 * 4, dtype=torch.float32).reshape(2, 3, 2, 4)
with adapter.activate(batch):
context = adapter.prepare_layer_context("layer-0", qkv, qkv, qkv)
assert context.output_scatter_indices.tolist() == [0, 3, 4, 5]
torch.testing.assert_close(context.query, qkv.reshape(-1, 2, 4)[[0, 3, 4, 5]])
restored = context.restore_output(context.query)
assert restored.shape == qkv.shape
torch.testing.assert_close(restored[0, 0], qkv[0, 0])
assert torch.count_nonzero(restored[0, 1:]) == 0
torch.testing.assert_close(restored[1], qkv[1])
def test_forward_rejects_padded_full_kv(monkeypatch: pytest.MonkeyPatch) -> None:
adapter, _, _, _ = _make_adapter(monkeypatch)
batch = adapter.prepare_batch(
@@ -878,6 +1030,41 @@ def test_forward_rejects_padded_full_kv(monkeypatch: pytest.MonkeyPatch) -> None
adapter.prepare_layer_context("layer-0", query, full_kv, full_kv)
def test_runner_runtime_switches_from_prefill_to_reusable_denoise_metadata(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter, _, _, _ = _make_adapter(monkeypatch)
metadata = DiffusionPagedAttentionMetadata(
prefill_rows=(DiffusionPagedAttentionRow(request_id="req-0", sequence_id=0, query_len=6, seq_len=6),),
denoise_rows=(
DiffusionPagedAttentionRow(
request_id="req-0",
sequence_id=0,
query_len=2,
seq_len=5,
kv_start_pos=3,
),
),
)
runtime = DiffusionPagedAttentionRuntime(adapter, metadata)
with set_forward_context(paged_kv_runtime=runtime), runtime.activate():
set_forward_context_denoise_step_idx(0)
assert Attention._active_paged_kv_adapter() is adapter
assert adapter._active_batch.rows == metadata.prefill_rows
set_forward_context_denoise_step_idx(1)
assert Attention._active_paged_kv_adapter() is adapter
denoise_batch = adapter._active_batch
assert denoise_batch.rows == metadata.denoise_rows
set_forward_context_denoise_step_idx(2)
assert Attention._active_paged_kv_adapter() is adapter
assert adapter._active_batch is denoise_batch
assert adapter._active_batch is None
def test_causal_batch_rejects_non_suffix_write(monkeypatch: pytest.MonkeyPatch) -> None:
adapter, _, _, _ = _make_adapter(monkeypatch, non_causal=False)
row = DiffusionPagedAttentionRow(
@@ -992,7 +1179,8 @@ def test_layer_adapter_accepts_platform_native_backend_and_uses_rank_local_heads
monkeypatch: pytest.MonkeyPatch,
) -> None:
selected_backends = []
impl_cls = Mock(return_value=SimpleNamespace(forward=Mock()))
specialized_backends = []
impl_cls = Mock(return_value=SimpleNamespace(forward=Mock(), do_kv_cache_update=Mock()))
native_backend = SimpleNamespace(
get_name=lambda: "ASCEND",
indexes_kv_by_block_stride=lambda: True,
@@ -1003,16 +1191,28 @@ def test_layer_adapter_accepts_platform_native_backend_and_uses_rank_local_heads
original_backend_per_kind = {"full": object()}
config = SimpleNamespace(
attention_config=SimpleNamespace(backend=None, backend_per_kind=original_backend_per_kind),
parallel_config=SimpleNamespace(prefill_context_parallel_size=2),
model_config=SimpleNamespace(dtype=torch.float16),
cache_config=SimpleNamespace(cache_dtype="auto"),
)
def select_backend(**_kwargs):
selected_backends.append((config.attention_config.backend, config.attention_config.backend_per_kind))
selected_backends.append(
(
config.attention_config.backend,
config.attention_config.backend_per_kind,
config.parallel_config.prefill_context_parallel_size,
)
)
return native_backend
monkeypatch.setattr(adapter_module, "get_attn_backend", select_backend)
monkeypatch.setattr(adapter_module, "set_current_vllm_config", lambda _config: nullcontext())
monkeypatch.setattr(
adapter_module.current_omni_platform,
"get_diffusion_paged_kv_attn_backend",
lambda backend, *, ulysses_degree: specialized_backends.append((backend, ulysses_degree)) or backend,
)
layer = SimpleNamespace(num_heads=8, softmax_scale=0.125)
spec = FullAttentionSpec(
block_size=16,
@@ -1031,9 +1231,11 @@ def test_layer_adapter_accepts_platform_native_backend_and_uses_rank_local_heads
ulysses_degree=2,
)
assert selected_backends == [(adapter_module.AttentionBackendEnum.FLASH_ATTN, {})]
assert selected_backends == [(adapter_module.AttentionBackendEnum.FLASH_ATTN, {}, 2)]
assert specialized_backends == [(native_backend, 2)]
assert config.attention_config.backend is None
assert config.attention_config.backend_per_kind is original_backend_per_kind
assert config.parallel_config.prefill_context_parallel_size == 2
assert native_layer.num_heads == 4
assert native_layer.num_kv_heads == 2
assert native_layer.spec.num_kv_heads == 2
@@ -1094,6 +1296,51 @@ def test_omni_attention_wraps_paged_kernel_with_sp_hooks() -> None:
assert torch.equal(output, torch.full_like(original, 6))
def test_omni_attention_strips_paged_ulysses_padding_around_kernel() -> None:
events: list[tuple[str, tuple[int, ...]]] = []
class Strategy:
name = "ulysses"
def pre_attention(self, query, key, value, metadata):
return query, key, value, metadata, SimpleNamespace(joint_len=0, joint_strategy="front")
def post_attention(self, output, _ctx):
events.append(("post", tuple(output.shape)))
return output
class Adapter:
def prepare_layer_context(self, _layer_name, query, key, value, *, omni_attn_metadata):
assert omni_attn_metadata is None
events.append(("kernel_input", tuple(query.shape)))
assert query[:, -1].tolist() == [[[3.0]]]
assert torch.equal(query, key)
assert torch.equal(query, value)
return SimpleNamespace(query=query)
class Backend:
def forward_paged(self, context):
return context.query
layer = Attention.__new__(Attention)
nn.Module.__init__(layer)
layer.prefix = "layer-0"
layer.paged_kv_cache_role = "primary"
layer.attn_backend = SimpleNamespace(supports_paged_kv=True, get_name=lambda: "FLASH_ATTN")
layer.attention = Backend()
layer.use_ring = False
layer._no_parallel_strategy = object()
layer._get_active_parallel_strategy = lambda: Strategy()
tensor = torch.arange(5, dtype=torch.float32).reshape(1, 5, 1, 1)
with set_forward_context(), override_paged_kv_adapter(Adapter()):
get_forward_context().sp_padding_size = 1
output = layer._forward_impl(tensor, tensor, tensor)
assert events == [("kernel_input", (1, 4, 1, 1)), ("post", (1, 5, 1, 1))]
assert output.flatten().tolist() == [0.0, 1.0, 2.0, 3.0, 0.0]
def test_omni_attention_rejects_paged_request_for_backend_without_paged_support() -> None:
class Adapter:
def prepare_layer_context(self, *args, **kwargs):
@@ -1145,3 +1392,15 @@ def test_omni_attention_keeps_dense_kernel_without_active_adapter() -> None:
assert events == ["pre", "dense", "post"]
assert output is qkv
def test_omni_attention_rejects_scheduler_paged_forward_without_active_adapter() -> None:
layer = Attention.__new__(Attention)
nn.Module.__init__(layer)
layer._scheduler_paged_kv = True
layer.paged_kv_cache_role = "primary"
layer._get_active_parallel_strategy = lambda: object()
qkv = torch.zeros(1, 2, 2, 4)
with set_forward_context(), pytest.raises(RuntimeError, match="without an active Worker adapter"):
layer._forward_impl(qkv, qkv, qkv)
@@ -154,8 +154,17 @@ def test_adapter_executes_native_paged_attention_on_non_contiguous_blocks() -> N
torch.manual_seed(1)
query = torch.randn(19, _NUM_HEADS, _HEAD_SIZE, dtype=torch.float16, device=device)
key = torch.randn_like(query)
value = torch.randn_like(query)
packed_qkv = torch.randn(
19,
3 * _NUM_HEADS * _HEAD_SIZE,
dtype=torch.float16,
device=device,
)
_, key_flat, value_flat = packed_qkv.split(_NUM_HEADS * _HEAD_SIZE, dim=-1)
key = key_flat.reshape(19, _NUM_HEADS, _HEAD_SIZE)
value = value_flat.reshape(19, _NUM_HEADS, _HEAD_SIZE)
assert not key.is_contiguous()
assert not value.is_contiguous()
omni_backend = FlashAttentionImpl(
num_heads=_NUM_HEADS,
head_size=_HEAD_SIZE,
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from contextlib import nullcontext
from types import SimpleNamespace
@@ -42,9 +42,10 @@ class _RunnerKVBackend:
self.kv_cache_config = config
def _attention(*, enabled: bool) -> Attention:
def _attention(*, enabled: bool, prefix: str = "image_attention") -> Attention:
attention = Attention.__new__(Attention)
nn.Module.__init__(attention)
attention.prefix = prefix
attention.paged_kv_cache_role = "primary" if enabled else None
attention.paged_kv_cache_dtype = torch.bfloat16
attention.num_kv_heads = 2
@@ -82,6 +83,23 @@ def test_runner_discovers_native_spec_from_loaded_attention() -> None:
assert spec.non_causal is True
def test_runner_uses_attention_prefix_as_canonical_layer_name() -> None:
runner = _runner(_attention(enabled=True, prefix="layers.0.self_attn.image_attn.attn"))
specs = runner.get_kv_cache_spec()
assert set(specs) == {"layers.0.self_attn.image_attn.attn"}
def test_runner_rejects_duplicate_attention_prefixes() -> None:
runner = _runner(_attention(enabled=True, prefix="shared"))
second_attention = _attention(enabled=True, prefix="shared")
runner.pipeline.second_image_attention = second_attention
with pytest.raises(RuntimeError, match="Duplicate canonical paged Diffusion Attention prefix 'shared'"):
runner.get_kv_cache_spec()
def test_runner_rejects_paged_mode_without_cache_enabled_attention() -> None:
runner = _runner(_attention(enabled=False))
@@ -42,7 +42,10 @@ def make_metadata(request_id: str = "req-0") -> DiffusionKVMetadata:
def make_runner(mode: DiffusionKVCacheMode) -> DiffusionModelRunner:
runner = object.__new__(DiffusionModelRunner)
runner.od_config = SimpleNamespace(diffusion_kv_mode=mode)
runner.od_config = SimpleNamespace(
diffusion_kv_mode=mode,
parallel_config=SimpleNamespace(cfg_parallel_size=1),
)
return runner
@@ -85,6 +88,51 @@ def test_new_request_data_carries_scheduler_allocation_atomically() -> None:
assert new_req.diffusion_kv_metadata is metadata
def test_runner_builds_prefill_and_denoise_rows_from_scheduler_metadata() -> None:
runner = make_runner(DiffusionKVCacheMode.PAGED_SCHEDULER)
metadata = DiffusionKVMetadata(
request_id="req-0",
allocation_generation=1,
sequences=(
DiffusionKVSequenceMetadata(0, 4, 2, 8, ([1, 2],)),
DiffusionKVSequenceMetadata(1, 5, 2, 9, ([3, 4],)),
),
)
attn_metadata = runner._build_paged_attention_metadata([metadata])
assert [
(row.request_id, row.sequence_id, row.kv_start_pos, row.query_len, row.seq_len)
for row in attn_metadata.prefill_rows
] == [("req-0", 0, 0, 8, 8), ("req-0", 1, 0, 9, 9)]
assert [
(row.request_id, row.sequence_id, row.kv_start_pos, row.query_len, row.seq_len)
for row in attn_metadata.denoise_rows
] == [("req-0", 0, 4, 2, 6), ("req-0", 1, 5, 2, 7)]
def test_runner_selects_only_local_cfg_parallel_row(monkeypatch: pytest.MonkeyPatch) -> None:
runner = make_runner(DiffusionKVCacheMode.PAGED_SCHEDULER)
runner.od_config.parallel_config.cfg_parallel_size = 2
monkeypatch.setattr(
"vllm_omni.diffusion.worker.diffusion_model_runner.get_classifier_free_guidance_rank",
lambda: 1,
)
metadata = DiffusionKVMetadata(
request_id="req-0",
allocation_generation=1,
sequences=(
DiffusionKVSequenceMetadata(0, 4, 2, 8, ([1, 2],)),
DiffusionKVSequenceMetadata(1, 5, 2, 9, ([3, 4],)),
),
)
attn_metadata = runner._build_paged_attention_metadata([metadata])
assert [row.sequence_id for row in attn_metadata.prefill_rows] == [1]
assert [row.sequence_id for row in attn_metadata.denoise_rows] == [1]
def test_paged_request_without_metadata_fails_before_request_forward() -> None:
runner = make_runner(DiffusionKVCacheMode.PAGED_SCHEDULER)
runner._execute_request_list = Mock()
+14 -15
View File
@@ -43,21 +43,16 @@ def test_jit_build_includes_cuda_headers_from_nvidia_wheels(tmp_path, monkeypatc
@dataclass
class _FakeAllocation:
"""Byte workspace stand-in that slices and reinterprets like ``symm_mem.empty()``."""
size: int
device: torch.device
def __post_init__(self) -> None:
self.buffer = torch.empty(self.size, dtype=torch.uint8)
class _FakeHandle:
rank = 0
def __init__(self, allocation: _FakeAllocation) -> None:
self.allocation = allocation
self.views: list[tuple[tuple[int, ...], torch.dtype]] = []
def get_buffer(self, rank, shape, dtype):
assert rank == self.rank
self.views.append((shape, dtype))
return self.allocation, shape, dtype
def __getitem__(self, item: slice) -> torch.Tensor:
return self.buffer[item]
def test_workspace_reuses_peak_capacity_across_shapes(monkeypatch) -> None:
@@ -93,16 +88,20 @@ def test_workspace_reuses_peak_capacity_across_shapes(monkeypatch) -> None:
return allocation
monkeypatch.setattr(a2a_permute.symm_mem, "empty", empty)
monkeypatch.setattr(a2a_permute.symm_mem, "rendezvous", lambda allocation, _group: _FakeHandle(allocation))
# The handle is only retained to keep the rendezvous registration alive.
monkeypatch.setattr(a2a_permute.symm_mem, "rendezvous", lambda _allocation, _group: object())
device = torch.device("cuda:0")
first = a2a_permute._get_symm_buffer((2, 3), torch.float16, device, "group")
smaller = a2a_permute._get_symm_buffer((1, 4), torch.float16, device, "group")
larger = a2a_permute._get_symm_buffer((4, 3), torch.float16, device, "group")
assert first[0].size == 12
assert smaller[0] is first[0]
assert larger[0].size == 24
assert (first.shape, first.dtype) == ((2, 3), torch.float16)
assert (smaller.shape, smaller.dtype) == ((1, 4), torch.float16)
assert (larger.shape, larger.dtype) == ((4, 3), torch.float16)
# A sub-capacity request reuses the workspace; only growth allocates again.
assert smaller.untyped_storage().data_ptr() == first.untyped_storage().data_ptr()
assert larger.untyped_storage().data_ptr() != first.untyped_storage().data_ptr()
assert [allocation.size for allocation in allocations] == [12, 24]
assert len(a2a_permute._SYMM_WORKSPACES) == 1
assert len(all_reduce_calls) == 1
+55 -1
View File
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from collections import defaultdict
import pytest
@@ -106,7 +109,7 @@ def make_dummy_lora_state_dict(
"""
key_a = f"{base_key}.{lora_a_suffix}"
key_b = f"{base_key}.{lora_b_suffix}"
key_bias = f"{base_key}.{lora_bias_suffix}" if bias else None
key_bias = f"{base_key}.{lora_bias_suffix}"
if weight_a is None:
weight_a = torch.randn(RANK, HEAD_DIM)
@@ -436,3 +439,54 @@ def test_wan_load_lora_weights_targets_both_transformers(mocker: MockerFixture):
assert_params_equal(pipeline.transformer, original_high)
assert_params_equal(pipeline.transformer_2, original_low)
assert "adapter0" not in pipeline.lora_loaded
def test_prepare_lora_delta_disambiguates_prefix_overlapping_stacked_names():
"""A fused name can be a prefix of another fused name.
SenseNova-U1 carries both `.qkv_proj` (understanding) and `.qkv_proj_mot_gen`
(generation). Selecting stacked mappings by substring matched both rules for
the generation weight and concatenated six shards into a delta twice as tall
as the parameter.
"""
param_to_weight_names = {
".qkv_proj_mot_gen": [".q_proj_mot_gen", ".k_proj_mot_gen", ".v_proj_mot_gen"],
".qkv_proj": [".q_proj", ".k_proj", ".v_proj"],
}
state_dict = {}
for part in ("q", "k", "v"):
for tower in ("_mot_gen", ""):
base = f"layers.0.self_attn.{part}_proj{tower}"
state_dict[f"{base}.lora_A.weight"] = torch.ones(RANK, HEAD_DIM)
state_dict[f"{base}.lora_B.weight"] = torch.ones(HEAD_DIM, RANK)
for base_key in ("layers.0.self_attn.qkv_proj_mot_gen", "layers.0.self_attn.qkv_proj"):
delta, _ = _prepare_lora_delta(state_dict, base_key, param_to_weight_names)
assert delta.shape == (3 * HEAD_DIM, HEAD_DIM), base_key
@pytest.mark.parametrize(
"fused, shards",
[
# qwen_image_transformer.py stacked_params_mapping
(".to_qkv", (".to_q", ".to_k", ".to_v")),
(".add_kv_proj", (".add_q_proj", ".add_k_proj", ".add_v_proj")),
# wan2_2_transformer.py stacked_params_mapping
(".attn1.to_qkv", (".attn1.to_q", ".attn1.to_k", ".attn1.to_v")),
# wan2_2_s2v_transformer.py stacked_params_mapping
(".self_attn.to_qkv", (".self_attn.q", ".self_attn.k", ".self_attn.v")),
],
)
def test_prepare_lora_delta_unchanged_for_existing_fused_layouts(fused, shards):
"""None of the fused names shipped today overlap by prefix, so matching on the
tail must select exactly the shards substring matching selected."""
param_to_weight_names = {fused: list(shards)}
base_key = f"blocks.0{fused}"
state_dict = {}
for shard in shards:
state_dict[f"blocks.0{shard}.lora_A.weight"] = torch.ones(RANK, HEAD_DIM)
state_dict[f"blocks.0{shard}.lora_B.weight"] = torch.ones(HEAD_DIM, RANK)
delta, used_keys = _prepare_lora_delta(state_dict, base_key, param_to_weight_names)
assert delta.shape == (len(shards) * HEAD_DIM, HEAD_DIM)
assert len(used_keys) == 2 * len(shards)
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""L1 unit tests for the native Boogu-Image pipeline.
@@ -133,6 +133,51 @@ def test_constructor_strips_mllm_lm_head(boogu_pipeline, mock_dependencies):
assert boogu_pipeline.mllm is mock_dependencies["inner_encoder"]
def test_constructor_forwards_revision_to_all_component_loaders(mock_dependencies, mocker):
from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import (
BooguImagePipeline,
)
revision = "hotfix-1k-20260708"
prefetch = mocker.patch(f"{_MODULE}.prefetch_subfolders")
scheduler_loader = mocker.patch(
f"{_MODULE}.FlowMatchEulerDiscreteScheduler.from_pretrained",
return_value=mock_dependencies["scheduler"],
)
mllm_loader = mocker.patch(
f"{_MODULE}.Qwen3VLForConditionalGeneration.from_pretrained",
return_value=mock_dependencies["mllm_wrapper"],
)
processor_loader = mocker.patch(
f"{_MODULE}.Qwen3VLProcessor.from_pretrained",
return_value=mock_dependencies["processor"],
)
vae_loader = mocker.patch(
f"{_MODULE}.AutoencoderKL.from_pretrained",
return_value=mock_dependencies["vae"],
)
od_config = OmniDiffusionConfig(
model="dummy-boogu",
revision=revision,
tf_model_config=TransformerConfig(params={}),
dtype=torch.float32,
num_gpus=1,
)
pipeline = BooguImagePipeline(od_config=od_config)
(source,) = pipeline.weights_sources
assert source.revision == revision
prefetch.assert_called_once_with(
"dummy-boogu",
["scheduler", "vae", "mllm", "processor"],
local_files_only=True,
revision=revision,
)
for loader in (scheduler_loader, mllm_loader, processor_loader, vae_loader):
assert loader.call_args.kwargs["revision"] == revision
def test_constructor_weights_sources(boogu_pipeline):
(source,) = boogu_pipeline.weights_sources
assert source.model_or_path == "dummy-boogu"
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from types import SimpleNamespace
@@ -270,6 +270,17 @@ def test_grouped_denoise_allows_sdpa_attention_backend():
pipeline._ensure_grouped_attention_backend_supported(2)
def test_scheduler_paged_step_execution_is_rejected():
pipeline = _pipeline()
pipeline.od_config.diffusion_kv_mode = hy3_module.DiffusionKVCacheMode.PAGED_SCHEDULER
state = _state("paged-step", 0)
with pytest.raises(ValueError, match="request-level execution only"):
pipeline.prepare_encode(state)
with pytest.raises(ValueError, match="request-level execution only"):
pipeline.denoise_step(InputBatch.make_batch([state]))
def test_step_scheduler_preserves_latent_dtype_for_mixed_progress_batches():
pipeline = _pipeline()
pipeline._pipeline = SimpleNamespace(prepare_extra_func_kwargs=lambda step, kwargs: {})
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Unit tests for ImageKVCacheManager.
Covers: cache → reuse flow, AR KV injection, CFG (sequential & parallel), SP, cross-request isolation.
@@ -37,12 +37,21 @@ class MockAttention(nn.Module):
super().__init__()
self.paged_kv_active = False
self.calls = []
self.paged_calls = []
self.paged_metadata = []
def is_paged_kv_active(self):
return self.paged_kv_active
def forward(self, query, key, value, attn_metadata=None, **kwargs):
self.calls.append((query, key, value))
from vllm_omni.diffusion.forward_context import get_forward_context, is_forward_context_available
if is_forward_context_available() and get_forward_context().paged_kv_adapter is not None:
self.paged_calls.append((query, key, value))
self.paged_metadata.append(attn_metadata)
if attn_metadata is not None and attn_metadata.joint_query is not None:
return torch.cat((attn_metadata.joint_query, query), dim=1)
return query
@@ -105,6 +114,7 @@ def _call_mgr(
shard_image_size=None,
gen_timestep_scatter_index=None,
position_ids=None,
full_attn_spans=None,
):
query = torch.randn(bs * q_len, NUM_HEADS, HEAD_DIM)
attn_mask = torch.zeros(bs, 1, seq_len, seq_len)
@@ -121,6 +131,7 @@ def _call_mgr(
shard_image_size=shard_image_size,
gen_timestep_scatter_index=gen_timestep_scatter_index,
position_ids=position_ids,
full_attn_spans=full_attn_spans,
)
@@ -132,6 +143,83 @@ def test_cache_manager_registers_attention_without_adding_dense_state() -> None:
assert mgr.state_dict() == {}
def test_scheduler_paged_kv_runs_piecewise_for_first_and_later_steps() -> None:
from vllm_omni.diffusion.forward_context import override_paged_kv_adapter, set_forward_context
mgr = _make_cache_mgr()
mgr.attn.paged_kv_active = True
bs = 2
prefix_lens = torch.tensor([[2], [4]], dtype=torch.long)
first_q_len = 12
first_k, first_v = _make_known_kv(bs * first_q_len, base=1.0)
full_attn_spans = [[(2, 12)], [(2, 12)]]
with set_forward_context(), override_paged_kv_adapter(object()):
_call_mgr(
mgr,
bs,
q_len=first_q_len,
seq_len=first_q_len,
key_flat=first_k,
value_flat=first_v,
first_step=True,
gen_timestep_scatter_index=prefix_lens,
full_attn_spans=full_attn_spans,
)
assert mgr.image_kv_cache_map is None
assert mgr.image_kv_cache_lens is None
assert len(mgr.attn.paged_calls) == 1
assert mgr.attn.paged_calls[0][0].shape == (bs, first_q_len, NUM_HEADS, HEAD_DIM)
assert mgr.attn.paged_calls[0][1].shape == (bs, first_q_len, NUM_KV_HEADS, HEAD_DIM)
assert mgr.attn.paged_calls[0][2].shape == (bs, first_q_len, NUM_KV_HEADS, HEAD_DIM)
assert mgr.attn.paged_metadata[0].full_attn_spans == full_attn_spans
current_q_len = IMAGE_TOKEN_LEN
current_k, current_v = _make_known_kv(bs * current_q_len, base=50.0)
_call_mgr(
mgr,
bs,
q_len=current_q_len,
seq_len=prefix_lens[-1].item() + current_q_len,
key_flat=current_k,
value_flat=current_v,
position_ids=torch.arange(4, 4 + current_q_len).repeat(bs, 1),
full_attn_spans=full_attn_spans,
)
assert len(mgr.attn.paged_calls) == 2
assert mgr.attn.paged_calls[1][0].shape == (bs, current_q_len, NUM_HEADS, HEAD_DIM)
assert mgr.attn.paged_calls[1][1].shape == (bs, current_q_len, NUM_KV_HEADS, HEAD_DIM)
assert mgr.attn.paged_calls[1][2].shape == (bs, current_q_len, NUM_KV_HEADS, HEAD_DIM)
assert mgr.attn.paged_metadata[1].full_attn_spans == full_attn_spans
def test_cache_manager_rejects_dense_imported_prefix_for_paged_attention() -> None:
from vllm_omni.diffusion.forward_context import override_paged_kv_adapter, set_forward_context
mgr = _make_cache_mgr()
mgr.attn.paged_kv_active = True
prefix_len = 4
prefix_key, prefix_value = _make_known_kv(prefix_len, base=100.0)
mgr._injected_ar_kv = [(prefix_key, prefix_value)]
current_key, current_value = _make_known_kv(3, base=1.0)
with set_forward_context(), override_paged_kv_adapter(object()):
with pytest.raises(NotImplementedError, match="does not support imported AR KV"):
_call_mgr(
mgr,
bs=1,
q_len=3,
seq_len=7,
key_flat=current_key,
value_flat=current_value,
first_step=True,
gen_timestep_scatter_index=torch.tensor([[0]]),
full_attn_spans=[[(4, 7)]],
)
@pytest.mark.parametrize(
("paged_kv_active", "expected_kv_heads"),
[(False, NUM_HEADS), (True, NUM_KV_HEADS)],
@@ -155,6 +243,7 @@ def test_gqa_kv_stays_compressed_for_paged_attention(
first_step=True,
num_image_tokens=0,
gen_timestep_scatter_index=_gen_timestep_index(1, 0),
full_attn_spans=[[(0, q_len)]] if paged_kv_active else None,
)
_, key_input, value_input = mgr.attn.calls[-1]
@@ -57,7 +57,8 @@ def test_h3_prepares_resolved_cache_state_immediately_before_denoise():
return torch.zeros(1), torch.zeros(1)
pipeline.diffuse = diffuse
pipeline.decode = Mock(return_value=(torch.zeros(1), torch.zeros(1)))
decoded_audio = torch.zeros(1)
pipeline.decode = Mock(return_value=(torch.zeros(1, 3, 1, 1, 1), decoded_audio))
sampling = OmniDiffusionSamplingParams(
quality="high",
width=1344,
@@ -86,7 +87,9 @@ def test_h3_prepares_resolved_cache_state_immediately_before_denoise():
num_inference_steps=50,
extra_args={"task": "t2va", "aspect_ratio": "16:9"},
)
assert output.output == pipeline.decode.return_value
assert output.output[0].shape == (1, 1, 1, 1, 3)
assert output.output[0].dtype == torch.uint8
assert output.output[1] is decoded_audio
def test_pipeline_import_registry_and_component_discovery():
@@ -493,18 +496,41 @@ def test_joint_postprocess_is_multiprocessing_picklable():
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)
# decode() hands over quantised frames already laid out as (B, T, H, W, C).
video = torch.arange(2 * 2 * 4 * 5 * 3, dtype=torch.uint8).reshape(2, 2, 4, 5, 3)
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].dtype == np.uint8
assert result["video"][0].shape == (2, 4, 5, 3)
np.testing.assert_array_equal(result["video"][0], video[0].numpy())
np.testing.assert_array_equal(result["audio"], audio.numpy())
assert result["audio_sample_rate"] == 32000
assert result["fps"] == 24
def test_joint_video_output_is_quantized_before_transfer():
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
_minimax_h3_post_process,
_prepare_minimax_h3_video_output,
)
video = torch.linspace(-0.1, 1.1, 2 * 3 * 2 * 4 * 5, dtype=torch.float64).reshape(2, 3, 2, 4, 5)
expected = torch.round(video.float().clamp(0, 1) * 255).to(torch.uint8).permute(0, 2, 3, 4, 1)
prepared = _prepare_minimax_h3_video_output(video)
assert prepared.dtype == torch.uint8
assert prepared.is_contiguous()
assert prepared.shape == (2, 2, 4, 5, 3)
torch.testing.assert_close(prepared, expected, rtol=0, atol=0)
result = _minimax_h3_post_process((prepared, torch.zeros(1, 2, 6)))
assert result["video"][0].dtype == np.uint8
np.testing.assert_array_equal(result["video"][0], expected[0].numpy())
def test_cfg_parallel_is_rejected_for_distilled_checkpoint():
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
@@ -572,7 +598,7 @@ def test_base_schedule_overrides_the_uniform_sigma_positions():
@pytest.mark.parametrize(
"base_schedule",
[
[0.9, 0.5, 0.0],
[1.5, 0.5, 0.0],
[1.0, 0.5, 0.1],
[1.0, 0.5, 0.5, 0.0],
[1.0],
@@ -627,7 +653,7 @@ def _distilled_pipeline(diffuse_calls, base_schedule_by_partition):
return torch.zeros(1), torch.zeros(1)
pipeline.diffuse = diffuse
pipeline.decode = Mock(return_value=(torch.zeros(1), torch.zeros(1)))
pipeline.decode = Mock(return_value=(torch.zeros(1, 3, 1, 1, 1), torch.zeros(1)))
return pipeline
@@ -0,0 +1,498 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import json
from types import SimpleNamespace
import pytest
import torch
from safetensors.torch import save_file
from vllm_omni.diffusion.models.minimax_h3.fasth3 import (
FASTH3_BASE_MODEL,
FASTH3_BASE_SCHEDULE,
FASTH3_DENOISE_STEPS,
FASTH3_FORMAT,
FastH3AdapterError,
FastH3WeightFusion,
resolve_fasth3_fusion,
)
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import _reorder_grouped_qkv_to_qkv
from vllm_omni.diffusion.models.minimax_h3.time_request import minimax_h3_time_shift_sigmas
from vllm_omni.errors import OmniClientError
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
_RANK = 2
_HIDDEN = 4
_HEAD_DIM = 2
_HEADS = 3
_INNER = _HEAD_DIM * _HEADS # attention inner size
_FFN = 5
def _factors(out_dim: int, in_dim: int) -> tuple[torch.Tensor, torch.Tensor]:
"""A rank-2 pair whose product is reproducible and non-symmetric."""
a = torch.arange(_RANK * in_dim, dtype=torch.float32).reshape(_RANK, in_dim) / (in_dim * _RANK)
b = torch.arange(out_dim * _RANK, dtype=torch.float32).reshape(out_dim, _RANK) / (out_dim * _RANK)
return a, b
def _write_adapter(
path,
*,
tensors=None,
drop: str | None = None,
blocks: int = 1,
metadata: dict[str, str | None] | None = None,
) -> None:
"""Write an artifact in the published ``fastvideo-lora-v2`` shape."""
payload: dict[str, torch.Tensor] = {}
for block in range(blocks):
for suffix, (out_dim, in_dim) in {
"attn.to_q": (_INNER, _HIDDEN),
"attn.to_k": (_INNER, _HIDDEN),
"attn.to_v": (_INNER, _HIDDEN),
"attn.to_out.0": (_HIDDEN, _INNER),
"ff.net.0.proj": (2 * _FFN, _HIDDEN),
"ff.net.2": (_HIDDEN, _FFN),
}.items():
a, b = _factors(out_dim, in_dim)
payload[f"transformer_blocks.{block}.{suffix}.lora_A.weight"] = a
payload[f"transformer_blocks.{block}.{suffix}.lora_B.weight"] = b
payload.update(tensors or {})
if drop is not None:
payload.pop(drop, None)
path.parent.mkdir(parents=True, exist_ok=True)
# The published writer records what it emitted; mirror it so the fixtures
# are as self-describing as the real artifact.
declared = {
"format": FASTH3_FORMAT,
"finetuned_model": "FastVideo/FastVideo-FastH3-Dense-4-step-v1",
"base_model": FASTH3_BASE_MODEL,
"rank": str(_RANK),
"low_rank_tensors": str(sum(1 for key in payload if key.endswith((".lora_A.weight", ".lora_B.weight")))),
"diff_tensors": str(sum(1 for key in payload if key.endswith((".diff", ".diff_b")))),
"set_weight_tensors": str(sum(1 for key in payload if key.endswith(".set_weight"))),
}
# safetensors metadata is string-only, so a ``None`` override drops the key.
declared.update(metadata or {})
save_file(payload, str(path), metadata={key: value for key, value in declared.items() if value is not None})
def _claim(path, **kwargs) -> FastH3WeightFusion | None:
"""Whatever from_path decides, including declining the artifact."""
kwargs.setdefault("head_dim", _HEAD_DIM)
kwargs.setdefault("num_blocks", 1)
kwargs.setdefault("num_refiner_blocks", 0)
return FastH3WeightFusion.from_path(path, **kwargs)
def _load(path, **kwargs) -> FastH3WeightFusion:
"""The same, for the tests that only make sense on a claimed adapter."""
fusion = _claim(path, **kwargs)
assert fusion is not None, f"{path} was not claimed as a FastH3 adapter"
return fusion
def test_only_an_artifact_carrying_the_release_identity_is_claimed(tmp_path):
plain = tmp_path / "peft" / "adapter_model.safetensors"
plain.parent.mkdir(parents=True)
save_file({"transformer_blocks.0.attn.to_q.lora_A.weight": torch.ones((_RANK, _HIDDEN))}, str(plain))
# No fastvideo-lora-v2 metadata: this is somebody else's LoRA and has to
# stay on the dynamic route rather than being fused.
assert _claim(plain.parent) is None
assert _claim(tmp_path / "missing") is None
claimed = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(claimed)
assert _claim(claimed.parent) is not None
@pytest.mark.parametrize(
"identity",
[
# An ordinary H3 adapter out of FastVideo's own extraction tools.
{"finetuned_model": "someone/minimax-h3-style-lora"},
# Somebody else's adapter that merely names the student it imitates.
{"finetuned_model": "someone/fasth3-style-lora-for-h3"},
{"finetuned_model": ""},
{"finetuned_model": None},
# A FastH3 name over a different base model.
{"base_model": "Wan-AI/Wan2.2-TI2V-5B"},
],
)
def test_a_generic_fastvideo_h3_adapter_stays_on_the_dynamic_route(identity, tmp_path):
"""The fixture edits every block, so only the identity separates it from FastH3."""
path = tmp_path / "generic" / "adapter_model.safetensors"
_write_adapter(path, metadata=identity)
assert _claim(path.parent) is None
def test_a_claimed_artifact_must_declare_every_tensor_count(tmp_path):
"""Declining is for other people's adapters; a FastH3 file is held to its word."""
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, metadata={"diff_tensors": None})
with pytest.raises(FastH3AdapterError, match="omits diff_tensors"):
_claim(path.parent)
def test_the_published_bundle_root_is_refused_rather_than_guessed(tmp_path):
root = tmp_path / "FastVideo-FastH3-4-step-Preview-v1-LoRA"
for slug in ("dense-datafree", "vsa-datafree"):
_write_adapter(root / slug / "adapter_model.safetensors")
(root / "adapter_manifest.json").write_text(json.dumps({"schema_version": "fasth3-lora-bundle-v1"}))
with pytest.raises(FastH3AdapterError, match="point --lora-path at one variant"):
_load(root)
# One variant inside it is unambiguous.
assert _claim(root / "dense-datafree") is not None
def test_a_multi_shard_non_fasth3_adapter_stays_on_the_dynamic_route(tmp_path):
# A plain PEFT LoRA saved as several shards must not hard-fail startup with a
# FastH3-specific message; it has to fall through to the dynamic LoRA route.
directory = tmp_path / "peft-sharded"
directory.mkdir()
for shard in ("adapter_model-00001-of-00002.safetensors", "adapter_model-00002-of-00002.safetensors"):
save_file(
{"transformer_blocks.0.attn.to_q.lora_A.weight": torch.ones((_RANK, _HIDDEN))}, str(directory / shard)
)
assert _claim(directory) is None
def test_low_rank_factors_reach_the_fused_projections(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path)
fusion = _load(path.parent)
qkv = torch.zeros((3 * _INNER, _HIDDEN), dtype=torch.float32)
fused = fusion.fuse("blocks.0.attn.qkv_proj.weight", qkv).cpu()
# The checkpoint stores one head group at a time as [q, k, v], so the delta
# has to survive the loader's own unpacking as three separate projections.
q, k, v = torch.split(
_reorder_grouped_qkv_to_qkv(fused, num_query_groups=_HEADS, heads_per_group=1, head_dim=_HEAD_DIM),
[_INNER, _INNER, _INNER],
)
a, b = _factors(_INNER, _HIDDEN)
expected = b @ a
for got in (q, k, v):
assert torch.allclose(got, expected, atol=1e-5)
def test_the_fused_mlp_delta_is_swapped_into_gate_first_order(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path)
fusion = _load(path.parent)
fused = fusion.fuse("blocks.0.mlp.fc1.weight", torch.zeros((2 * _FFN, _HIDDEN))).cpu()
a, b = _factors(2 * _FFN, _HIDDEN)
value, gate = (b @ a).chunk(2, dim=0)
# The diffusers export is value-first; H3's fc1 is gate-first.
assert torch.allclose(fused, torch.cat((gate, value), dim=0), atol=1e-5)
def test_diff_and_diff_b_edit_weights_and_biases(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(
path,
tensors={
"transformer_blocks.0.norm1.diff": torch.full((_HIDDEN,), 0.25),
"transformer_blocks.0.adaln_proj.linear.diff_b": torch.full((_HIDDEN,), -0.5),
"context_embedder.diff": torch.full((_HIDDEN, _HIDDEN), 0.125),
"context_embedder.diff_b": torch.full((_HIDDEN,), 2.0),
},
)
fusion = _load(path.parent)
# A full-rank delta on an RMSNorm vector is exactly what a LoRA layer
# cannot express, and why this release is fused instead of switched.
assert torch.allclose(fusion.fuse("blocks.0.norm1.weight", torch.ones(_HIDDEN)).cpu(), torch.full((_HIDDEN,), 1.25))
assert torch.allclose(
fusion.fuse("blocks.0.adaln_proj.linear.bias", torch.ones(_HIDDEN)).cpu(), torch.full((_HIDDEN,), 0.5)
)
assert torch.allclose(
fusion.fuse("condition_proj.weight", torch.zeros((_HIDDEN, _HIDDEN))).cpu(),
torch.full((_HIDDEN, _HIDDEN), 0.125),
)
assert torch.allclose(fusion.fuse("condition_proj.bias", torch.zeros(_HIDDEN)).cpu(), torch.full((_HIDDEN,), 2.0))
def test_a_streamed_checkpoint_keeps_the_parameters_the_adapter_never_edits(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, tensors={"transformer_blocks.0.norm1.diff": torch.full((_HIDDEN,), 3.0)})
fusion = _load(path.parent)
untouched = torch.arange(6, dtype=torch.bfloat16).reshape(2, 3)
streamed = dict(
fusion.apply([("blocks.0.norm1.weight", torch.zeros(_HIDDEN)), ("blocks.0.attn.q_norm.weight", untouched)])
)
assert torch.allclose(streamed["blocks.0.norm1.weight"].cpu(), torch.full((_HIDDEN,), 3.0))
assert streamed["blocks.0.attn.q_norm.weight"] is untouched
# validate_fully_applied() releases the deltas, so a second stream would
# fuse nothing at all; it has to fail rather than serve base H3 weights.
with pytest.raises(FastH3AdapterError, match="already been fused"):
next(fusion.apply([("blocks.0.norm1.weight", torch.zeros(_HIDDEN))]))
def test_the_fused_result_keeps_the_parameter_dtype(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, tensors={"transformer_blocks.0.norm1.diff": torch.full((_HIDDEN,), 0.5)})
fusion = _load(path.parent)
fused = fusion.fuse("blocks.0.norm1.weight", torch.ones(_HIDDEN, dtype=torch.bfloat16))
assert fused.dtype == torch.bfloat16
def test_a_vsa_variant_is_recognised_by_its_compression_gates(tmp_path):
dense = tmp_path / "dense" / "adapter_model.safetensors"
_write_adapter(dense)
assert _load(dense.parent).requires_vsa is False
sparse = tmp_path / "vsa" / "adapter_model.safetensors"
_write_adapter(sparse, tensors={"transformer_blocks.0.attn.to_gate_compress.set_weight": torch.ones((2, 2))})
assert _load(sparse.parent).requires_vsa is True
def test_a_tensor_naming_no_h3_parameter_is_an_error(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, tensors={"transformer_blocks.0.attn.norm_q.lora_A.weight": torch.ones((_RANK, _HIDDEN))})
# Dropping it silently would load a model that is not the distilled student.
with pytest.raises(FastH3AdapterError, match="name no known"):
_load(path.parent)
def test_an_unpaired_low_rank_factor_is_an_error(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, drop="transformer_blocks.0.attn.to_q.lora_B.weight")
with pytest.raises(FastH3AdapterError, match="unpaired factor"):
_load(path.parent)
def test_a_delta_the_checkpoint_never_offered_is_reported(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path)
fusion = _load(path.parent)
fusion.fuse("blocks.0.attn.qkv_proj.weight", torch.zeros((3 * _INNER, _HIDDEN)))
# An unapplied delta is the failure that matters: the model would load and
# generate, just not as the distilled student.
with pytest.raises(FastH3AdapterError, match="never provided"):
fusion.validate_fully_applied()
for name, shape in (
("blocks.0.attn.out_proj.weight", (_HIDDEN, _INNER)),
("blocks.0.mlp.fc1.weight", (2 * _FFN, _HIDDEN)),
("blocks.0.mlp.fc2.weight", (_HIDDEN, _FFN)),
):
fusion.fuse(name, torch.zeros(shape))
fusion.validate_fully_applied()
def _pipeline_stub(fusion=None):
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
pipeline.partition = "combined"
pipeline.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"})
pipeline._turbo_lora_adapter_ids = set()
pipeline._fasth3 = fusion
pipeline.od_config = SimpleNamespace()
pipeline.default_video_shift, pipeline.default_audio_shift = 12.0, 3.0
return pipeline
def _sampling(steps=FASTH3_DENOISE_STEPS, **kwargs) -> SimpleNamespace:
fields = {"num_inference_steps": steps, "extra_args": {}, "lora_request": None}
return SimpleNamespace(**{**fields, **kwargs})
def _check_request(fusion, sampling) -> None:
fusion.check_request(sampling, video_shift=12.0, audio_shift=3.0)
def _fusion(tmp_path, **kwargs) -> FastH3WeightFusion:
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, **kwargs)
return _load(path.parent)
@pytest.mark.parametrize("task", ["fl2va", "ref2va"])
def test_an_active_fasth3_fusion_restricts_requests_to_t2va(task, tmp_path):
pipeline = _pipeline_stub(_fusion(tmp_path))
with pytest.raises(OmniClientError, match="distills \\['t2va'\\] only"):
pipeline._resolve_task(task, {})
assert pipeline._resolve_task("t2va", {}) == "t2va"
assert _pipeline_stub()._resolve_task(task, {}) == task
@pytest.mark.parametrize("num_inference_steps", [None, 5, 50])
def test_fasth3_requires_one_forward_per_sigma_interval(num_inference_steps, tmp_path):
fusion = _fusion(tmp_path)
# The five sigma points bound four forwards, and a request states forwards:
# the interval contract the pinned schedules use, and the count the step
# scheduler admits a request on before any pipeline hook runs.
with pytest.raises(OmniClientError, match=f"num_inference_steps={FASTH3_DENOISE_STEPS}"):
_check_request(fusion, _sampling(num_inference_steps))
_check_request(fusion, _sampling())
# The pinned branch coerces the string form; this one cannot disagree.
_check_request(fusion, _sampling(str(FASTH3_DENOISE_STEPS)))
def test_the_request_denoises_on_the_release_ladder(tmp_path):
pipeline = _pipeline_stub(_fusion(tmp_path))
positions, num_steps = pipeline._resolve_sigma_positions("t2va", _sampling())
# dmd_denoising_steps [999, 749, 500, 250] out of 1000, closed with the
# terminal 0.0: the pre-shift positions the student was distilled at, not
# the uniform ladder num_inference_steps would otherwise derive.
assert positions == (0.999, 0.749, 0.5, 0.25, 0.0)
# Five sigma points bound four transformer forwards, and forwards is the
# unit num_steps carries downstream (Cache-DiT, quality hints).
assert num_steps == FASTH3_DENOISE_STEPS == len(positions) - 1
# H3's own per-modality shifts still apply on top, and are what turn those
# positions into the noise levels the four forwards run at.
assert minimax_h3_time_shift_sigmas(shift_scale=12.0, base_schedule=positions) == pytest.approx(
[0.999917, 0.972833, 0.923077, 0.8, 0.0], abs=1e-6
)
assert minimax_h3_time_shift_sigmas(shift_scale=3.0, base_schedule=positions) == pytest.approx(
[0.999666, 0.899520, 0.75, 0.5, 0.0], abs=1e-6
)
# Without a fused adapter the same request keeps the undistilled ladder.
assert _pipeline_stub()._resolve_sigma_positions("t2va", _sampling()) == (None, FASTH3_DENOISE_STEPS)
def test_the_contract_leaves_the_checkpoint_shifts_alone(tmp_path):
pipeline = _pipeline_stub(_fusion(tmp_path))
pipeline.partition = "fl2va"
pipeline._fasth3.check_serving_contract(
partition=pipeline.partition,
od_config=pipeline.od_config,
video_shift=pipeline.default_video_shift,
audio_shift=pipeline.default_audio_shift,
)
# The ladder is stated pre-shift, so H3's 12/3 shifts are what place the
# student on the levels it was distilled at; the contract must not touch them.
assert pipeline.default_video_shift == 12.0
assert pipeline.default_audio_shift == 3.0
@pytest.mark.parametrize(
"extra",
[{"flow_shift": 1.0}, {"audio_flow_shift": 1.0}, {"flow_shift": "bad"}],
)
def test_a_request_may_not_move_the_student_off_its_rungs(extra, tmp_path):
fusion = _fusion(tmp_path)
with pytest.raises(OmniClientError, match="FastH3 requires"):
_check_request(fusion, _sampling(extra_args=extra))
_check_request(fusion, _sampling(extra_args={"flow_shift": 12.0, "audio_flow_shift": 3.0}))
def test_a_request_may_not_carry_a_lora_on_a_fused_server(tmp_path):
fusion = _fusion(tmp_path)
# The dynamic LoRA manager is skipped for a fused adapter, so an adapter
# asked for per request would be neither applied nor reported.
with pytest.raises(OmniClientError, match="per-request lora is unavailable"):
_check_request(fusion, _sampling(lora_request=SimpleNamespace(lora_int_id=1)))
def test_offload_is_refused_because_it_bypasses_the_fusion(tmp_path):
fusion = _fusion(tmp_path)
for flag in ("enable_cpu_offload", "enable_layerwise_offload", "enable_distributed_layerwise_offload"):
# A host-weight plan installs the transformer without load_weights(),
# so the fusion and its completeness check would both be skipped.
with pytest.raises(ValueError, match="cannot be combined with"):
fusion.check_serving_contract(
partition="fl2va",
od_config=SimpleNamespace(**{flag: True}),
video_shift=12.0,
audio_shift=3.0,
)
def test_adopting_the_contract_refuses_a_vsa_variant_and_ref2va(tmp_path):
sparse = tmp_path / "vsa" / "adapter_model.safetensors"
_write_adapter(sparse, tensors={"transformer_blocks.0.attn.to_gate_compress.set_weight": torch.ones((2, 2))})
contract = {"od_config": SimpleNamespace(), "video_shift": 12.0, "audio_shift": 3.0}
with pytest.raises(ValueError, match="Video Sparse Attention variant"):
_load(sparse.parent).check_serving_contract(partition="fl2va", **contract)
with pytest.raises(ValueError, match="cannot serve a Ref2VA partition"):
_fusion(tmp_path).check_serving_contract(partition="ref2va", **contract)
def test_a_full_rank_delta_on_a_fused_parameter_is_refused(tmp_path):
# Only low-rank factors are placed into H3's fused QKV and gate/up layouts,
# so a .diff aimed at one would otherwise be added transposed.
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, tensors={"transformer_blocks.0.attn.to_q.diff": torch.ones((_INNER, _HIDDEN))})
with pytest.raises(FastH3AdapterError, match="fused layout"):
_claim(path.parent)
def test_an_adapter_that_leaves_blocks_untouched_is_refused(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, blocks=2)
# Every tensor in this file is well formed and pairs up, so the fusion's
# own completeness check passes; only the model's depth reveals that a
# third of it would have been served as base H3 weights.
with pytest.raises(FastH3AdapterError, match="missing=\\[2\\]"):
_claim(path.parent, num_blocks=3)
with pytest.raises(FastH3AdapterError, match="unknown=\\[1\\]"):
_claim(path.parent, num_blocks=1)
assert _claim(path.parent, num_blocks=2) is not None
def test_an_adapter_shorter_than_it_declares_is_refused(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, metadata={"low_rank_tensors": "724", "diff_tensors": "85"})
# The writer records what it emitted, which is the file's one statement
# about its own completeness.
with pytest.raises(FastH3AdapterError, match="declares low_rank_tensors=724 but carries 12"):
_claim(path.parent)
def test_only_a_configured_adapter_path_is_read(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path)
transformer = SimpleNamespace(
arch=SimpleNamespace(attention_head_dim=_HEAD_DIM, num_layers=1, token_refiner_num_layers=0)
)
unbuilt = object() # a transformer whose arch must not be touched
assert resolve_fasth3_fusion(SimpleNamespace(lora_path=str(path)), transformer) is not None
assert resolve_fasth3_fusion(SimpleNamespace(lora_path=[str(path)]), transformer) is not None
# Nothing configured, or more than one artifact, stays on the dynamic route
# without reading anything off the model.
assert resolve_fasth3_fusion(SimpleNamespace(lora_path=None), unbuilt) is None
assert resolve_fasth3_fusion(SimpleNamespace(), unbuilt) is None
assert resolve_fasth3_fusion(SimpleNamespace(lora_path=[str(path), str(path)]), unbuilt) is None
def test_a_pipeline_that_fused_its_adapter_needs_no_lora_manager(tmp_path):
assert _pipeline_stub(_fusion(tmp_path)).lora_is_fused is True
assert _pipeline_stub().lora_is_fused is False
def test_the_ladder_is_the_one_the_release_publishes():
# FASTH3_DENOISE_STEPS is derived from it, so the two cannot drift apart.
assert FASTH3_BASE_SCHEDULE.base_schedule == (0.999, 0.749, 0.5, 0.25, 0.0)
assert FASTH3_BASE_SCHEDULE.num_inference_steps == FASTH3_DENOISE_STEPS == 4
@@ -259,7 +259,11 @@ def test_h3_native_rejects_schedule_with_wrong_interval_count(tmp_path):
("", "must not be empty"),
(",,", "must not be empty"),
("1.0,oops,0.15,0.0", "is malformed"),
("0.9,0.7,0.4,0.15,0.0", "is malformed"),
# A capped opening rung below 1.0 is a legal DMD2 schedule, so the
# out-of-range cases are a first position above 1.0 and a tail that
# never reaches clean latents.
("1.5,0.7,0.4,0.15,0.0", "is malformed"),
("1.0,0.7,0.4,0.15", "is malformed"),
],
)
def test_h3_native_rejects_malformed_schedule_metadata(tmp_path, raw_schedule, message):
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,195 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""CPU tests for SenseNova-U1 attention head handling.
The model used to expand K/V from 8 heads to 32 with ``repeat_interleave``
before calling the attention backend, so the backend's own GQA dispatch never
saw a GQA shape, and it passed an all-zeros mask during single-token decode,
which only served to keep SDPA off its fused kernels. These pin both contracts:
K/V must reach the backend uncompressed, decode must carry no mask, and the
compressed path must compute the same thing the expanded one did.
"""
import pytest
import torch
from vllm_omni.diffusion.attention.backends.sdpa import SDPAImpl
from vllm_omni.diffusion.models.sensenova_u1.sensenova_u1_transformer import (
SenseNovaU1Attention,
SenseNovaU1Model,
)
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
N_HEADS, N_KV_HEADS, HEAD_DIM, SEQ = 32, 8, 8, 6
GROUPS = N_HEADS // N_KV_HEADS
class _Recorder:
"""Stands in for the Attention module and records what it was handed."""
def __init__(self):
self.calls: list[tuple[torch.Size, torch.Size, torch.Tensor | None]] = []
def __call__(self, query, key, value, attn_metadata):
mask = None if attn_metadata is None else attn_metadata.attn_mask
self.calls.append((query.shape, key.shape, mask))
return query
class _AttnHost:
"""Carries only the attributes ``_run_attn``/``_run_attn_bshd`` touch."""
num_kv_groups = GROUPS
_align_mask_dtype = staticmethod(SenseNovaU1Attention._align_mask_dtype)
_run_attn = SenseNovaU1Attention._run_attn
_run_attn_bshd = SenseNovaU1Attention._run_attn_bshd
def __init__(self):
self.attn = _Recorder()
def test_bhsd_path_hands_the_backend_compressed_kv():
host = _AttnHost()
q = torch.randn(1, N_HEADS, SEQ, HEAD_DIM)
k = torch.randn(1, N_KV_HEADS, SEQ, HEAD_DIM)
host._run_attn(q, k, k.clone(), None)
_, key_shape, _ = host.attn.calls[-1]
# [B, S, H, D] after the transpose, so heads sit on dim 2.
assert key_shape[2] == N_KV_HEADS, f"K reached the backend with {key_shape[2]} heads"
def test_bshd_path_hands_the_backend_compressed_kv():
host = _AttnHost()
q = torch.randn(1, SEQ, N_HEADS, HEAD_DIM)
k = torch.randn(1, SEQ, N_KV_HEADS, HEAD_DIM)
host._run_attn_bshd(q, k, k.clone(), None)
_, key_shape, _ = host.attn.calls[-1]
assert key_shape[2] == N_KV_HEADS, f"K reached the backend with {key_shape[2]} heads"
def test_compressed_kv_computes_what_expansion_computed():
"""The backend must return the same tensor either way, or this is a bugfix
dressed up as a perf change."""
torch.manual_seed(0)
q = torch.randn(1, SEQ, N_HEADS, HEAD_DIM)
k = torch.randn(1, SEQ, N_KV_HEADS, HEAD_DIM)
v = torch.randn(1, SEQ, N_KV_HEADS, HEAD_DIM)
impl = SDPAImpl(
num_heads=N_HEADS,
head_size=HEAD_DIM,
softmax_scale=HEAD_DIM**-0.5,
num_kv_heads=N_KV_HEADS,
)
compressed = impl._forward_impl(q, k, v)
expanded = impl._forward_impl(q, k.repeat_interleave(GROUPS, dim=2), v.repeat_interleave(GROUPS, dim=2))
torch.testing.assert_close(compressed, expanded, atol=1e-6, rtol=1e-6)
class _MaskProbe(torch.nn.Module):
def __init__(self):
super().__init__()
self.masks: list[torch.Tensor | None] = []
def forward(self, hidden_states, **kwargs):
self.masks.append(kwargs["attention_mask"]["full_attention"])
return hidden_states
def _fake_rope(hidden_states, position_ids):
"""The forward builds the three RoPE tables before the layer loop; the probe
ignores them, so any 2-tuple will do."""
return hidden_states, hidden_states
class _ModelHost:
"""Carries only the attributes ``SenseNovaU1Model.forward`` touches."""
forward = SenseNovaU1Model.forward
def __init__(self, probe):
self.layers = [probe]
self.norm = torch.nn.Identity()
self.norm_mot_gen = torch.nn.Identity()
self.rotary_emb = _fake_rope
self.rotary_emb_hw = _fake_rope
def _run_model(seq_len: int):
probe = _MaskProbe()
_ModelHost(probe).forward(
inputs_embeds=torch.zeros(1, seq_len, 4),
indexes=torch.zeros(3, seq_len, dtype=torch.long),
)
return probe.masks[-1]
def test_single_token_decode_carries_no_mask():
assert _run_model(1) is None, "decode still builds the all-zeros mask"
def test_prefill_still_gets_a_causal_mask():
mask = _run_model(3)
assert mask is not None and mask.shape == (1, 1, 3, 3)
assert mask[0, 0, 0, 0] == 0.0
assert torch.isinf(mask[0, 0, 0, 1]) and mask[0, 0, 0, 1] < 0
assert mask[0, 0, 2, 1] == 0.0
# ---------------------------------------------------------------------------
# The decode path changes which SDPA kernel runs, so it is not bit-identical to
# what it replaced. "Different" would be a regression if the new result were
# worse, so pin the direction against an independent float64 reference. This
# needs a fused GQA kernel, which only exists on CUDA.
# ---------------------------------------------------------------------------
def _float64_attention(q, k, v):
q64 = q.double()
k64 = k.double().repeat_interleave(GROUPS, dim=1)
v64 = v.double().repeat_interleave(GROUPS, dim=1)
scores = (q64 @ k64.transpose(-1, -2)) * (q.shape[-1] ** -0.5)
return torch.softmax(scores, dim=-1) @ v64
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required for fused GQA")
@pytest.mark.cuda
@pytest.mark.L4
@pytest.mark.parametrize("kv_len", [271, 512, 2048])
def test_decode_without_the_no_op_mask_is_closer_to_float64(kv_len):
"""The old decode path expanded K/V and passed an all-zeros mask, which kept
SDPA off its fused kernels. Dropping the mask is only defensible if the
kernel it unlocks is at least as accurate."""
dev, dtype = "cuda", torch.bfloat16
heads, dim = 32, 128
new_errs: list[float] = []
old_errs: list[float] = []
trials = 8
for t in range(trials):
torch.manual_seed(2000 + t)
q = torch.randn(1, heads, 1, dim, device=dev, dtype=dtype)
k = torch.randn(1, heads // GROUPS, kv_len, dim, device=dev, dtype=dtype)
v = torch.randn(1, heads // GROUPS, kv_len, dim, device=dev, dtype=dtype)
ref = _float64_attention(q, k, v)
zeros_mask = torch.zeros(1, 1, 1, kv_len, device=dev, dtype=dtype)
old = torch.nn.functional.scaled_dot_product_attention(
q,
k.repeat_interleave(GROUPS, dim=1),
v.repeat_interleave(GROUPS, dim=1),
attn_mask=zeros_mask,
)
new = torch.nn.functional.scaled_dot_product_attention(q, k, v, enable_gqa=True)
new_errs.append((new.double() - ref).abs().mean().item())
old_errs.append((old.double() - ref).abs().mean().item())
# A single trial can tie either way depending on the GPU and the CUDA build,
# so gate on the aggregate: the maskless path must not be worse on average.
new_mean = sum(new_errs) / trials
old_mean = sum(old_errs) / trials
wins = sum(n <= o for n, o in zip(new_errs, old_errs))
assert new_mean <= old_mean, (
f"kv_len={kv_len}: maskless mean error {new_mean:.4e} is worse than masked {old_mean:.4e} "
f"({wins}/{trials} per-trial wins)"
)
@@ -0,0 +1,196 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""CPU tests for SenseNova-U1 distilled-LoRA fusion.
The checkpoints ship unfused ``q_proj_mot_gen`` / ``gate_proj`` keys while the
pipeline runs fused layers, so each delta lands in its own row slice.
"""
import pytest
import torch
import torch.nn as nn
from safetensors.torch import save_file
from vllm_omni.diffusion.models.sensenova_u1.pipeline_sensenova_u1 import SenseNovaU1Pipeline
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
HIDDEN, KV, INTER, RANK, ALPHA = 8, 4, 16, 2, 4.0
class _Layer(nn.Module):
def __init__(self):
super().__init__()
self.self_attn = nn.Module()
self.self_attn.qkv_proj_mot_gen = nn.Linear(HIDDEN, HIDDEN + 2 * KV, bias=False)
self.self_attn.o_proj_mot_gen = nn.Linear(HIDDEN, HIDDEN, bias=False)
self.mlp_mot_gen = nn.Module()
self.mlp_mot_gen.gate_up_proj = nn.Linear(HIDDEN, 2 * INTER, bias=False)
self.mlp_mot_gen.down_proj = nn.Linear(INTER, HIDDEN, bias=False)
class _Stub(SenseNovaU1Pipeline):
"""Only the parameter tree matters here, so skip the real __init__."""
def __init__(self):
nn.Module.__init__(self)
self.language_model = nn.Module()
self.language_model.model = nn.Module()
self.language_model.model.layers = nn.ModuleList([_Layer()])
def _triple(out: int, in_: int, fill: float) -> dict[str, torch.Tensor]:
return {
"lora_down.weight": torch.full((RANK, in_), fill, dtype=torch.float32),
"lora_up.weight": torch.full((out, RANK), 1.0, dtype=torch.float32),
"alpha": torch.tensor(ALPHA),
}
def _write(tmp_path, entries: dict[str, dict[str, torch.Tensor]]):
flat = {f"{mod}.{leaf}": t for mod, parts in entries.items() for leaf, t in parts.items()}
path = tmp_path / "lora.safetensors"
save_file(flat, str(path))
return str(path)
def _prefix(i: int = 0) -> str:
return f"language_model.model.layers.{i}"
def test_qkv_shards_land_in_their_own_row_slices(tmp_path):
pipe = _Stub()
qkv = pipe.language_model.model.layers[0].self_attn.qkv_proj_mot_gen.weight
with torch.no_grad():
qkv.zero_()
entries = {
f"{_prefix()}.self_attn.q_proj_mot_gen": _triple(HIDDEN, HIDDEN, 1.0),
f"{_prefix()}.self_attn.k_proj_mot_gen": _triple(KV, HIDDEN, 2.0),
f"{_prefix()}.self_attn.v_proj_mot_gen": _triple(KV, HIDDEN, 3.0),
}
pipe.load_lora_weights(_write(tmp_path, entries))
scale = ALPHA / RANK
assert torch.allclose(qkv[:HIDDEN], torch.full((HIDDEN, HIDDEN), scale * RANK * 1.0))
assert torch.allclose(qkv[HIDDEN : HIDDEN + KV], torch.full((KV, HIDDEN), scale * RANK * 2.0))
assert torch.allclose(qkv[HIDDEN + KV :], torch.full((KV, HIDDEN), scale * RANK * 3.0))
def test_gate_and_up_land_in_their_own_halves(tmp_path):
pipe = _Stub()
gu = pipe.language_model.model.layers[0].mlp_mot_gen.gate_up_proj.weight
with torch.no_grad():
gu.zero_()
entries = {
f"{_prefix()}.mlp_mot_gen.gate_proj": _triple(INTER, HIDDEN, 1.0),
f"{_prefix()}.mlp_mot_gen.up_proj": _triple(INTER, HIDDEN, 5.0),
}
pipe.load_lora_weights(_write(tmp_path, entries))
scale = ALPHA / RANK
assert torch.allclose(gu[:INTER], torch.full((INTER, HIDDEN), scale * RANK * 1.0))
assert torch.allclose(gu[INTER:], torch.full((INTER, HIDDEN), scale * RANK * 5.0))
def test_unfused_targets_are_added_whole(tmp_path):
pipe = _Stub()
o = pipe.language_model.model.layers[0].self_attn.o_proj_mot_gen.weight
before = o.detach().clone()
entries = {f"{_prefix()}.self_attn.o_proj_mot_gen": _triple(HIDDEN, HIDDEN, 1.0)}
pipe.load_lora_weights(_write(tmp_path, entries))
assert torch.allclose(o - before, torch.full((HIDDEN, HIDDEN), (ALPHA / RANK) * RANK))
def test_lora_that_matches_nothing_raises_instead_of_no_op(tmp_path):
"""Without a loader the worker only warned and generated without the
adapter, so a wrong path produced a plausible image and no error."""
pipe = _Stub()
entries = {f"{_prefix()}.self_attn.does_not_exist": _triple(HIDDEN, HIDDEN, 1.0)}
with pytest.raises(ValueError, match="matched no parameter"):
pipe.load_lora_weights(_write(tmp_path, entries))
def test_shape_mismatch_raises(tmp_path):
pipe = _Stub()
entries = {f"{_prefix()}.self_attn.o_proj_mot_gen": _triple(HIDDEN + 1, HIDDEN, 1.0)}
with pytest.raises(RuntimeError, match="must match the size"):
pipe.load_lora_weights(_write(tmp_path, entries))
def test_multiple_files_rejected(tmp_path):
pipe = _Stub()
path = _write(tmp_path, {f"{_prefix()}.self_attn.o_proj_mot_gen": _triple(HIDDEN, HIDDEN, 1.0)})
with pytest.raises(ValueError, match="exactly one"):
pipe.load_lora_weights([path, path])
class _ShardedLayer(nn.Module):
"""One rank of a two-way sharded fused QKV, with a vLLM-style weight loader.
The parameter holds half of each of q, k and v. The loader is handed the whole
layer's tensor and keeps only this rank's slice, which is what vLLM does.
"""
def __init__(self):
super().__init__()
self.weight = nn.Parameter(torch.zeros(HIDDEN // 2 + KV, HIDDEN))
self.seen: list[tuple[str, tuple[int, ...]]] = []
self.weight.weight_loader = self._weight_loader
def _weight_loader(self, param, loaded_weight, shard_id):
self.seen.append((shard_id, tuple(loaded_weight.shape)))
sizes = {"q": HIDDEN // 2, "k": KV // 2, "v": KV // 2}
offsets = {"q": 0, "k": HIDDEN // 2, "v": HIDDEN // 2 + KV // 2}
rows = sizes[shard_id]
param.data[offsets[shard_id] : offsets[shard_id] + rows] = loaded_weight[:rows]
class _ShardedStub(SenseNovaU1Pipeline):
def __init__(self):
nn.Module.__init__(self)
self.language_model = nn.Module()
self.language_model.model = nn.Module()
layer = nn.Module()
layer.self_attn = nn.Module()
layer.self_attn.qkv_proj_mot_gen = _ShardedLayer()
self.language_model.model.layers = nn.ModuleList([layer])
def test_fused_delta_is_sharded_through_the_weight_loader(tmp_path):
"""Under tensor parallelism the parameter is a rank-local shard while the
checkpoint delta spans the whole layer, so the delta has to go through the
layer's weight loader. Adding it directly raises a shape error."""
pipe = _ShardedStub()
layer = pipe.language_model.model.layers[0].self_attn.qkv_proj_mot_gen
entries = {
f"{_prefix()}.self_attn.q_proj_mot_gen": _triple(HIDDEN, HIDDEN, 1.0),
f"{_prefix()}.self_attn.k_proj_mot_gen": _triple(KV, HIDDEN, 2.0),
f"{_prefix()}.self_attn.v_proj_mot_gen": _triple(KV, HIDDEN, 3.0),
}
pipe.load_lora_weights(_write(tmp_path, entries))
# the loader saw the whole layer's tensors, not a pre-sharded slice
assert layer.seen == [("q", (HIDDEN, HIDDEN)), ("k", (KV, HIDDEN)), ("v", (KV, HIDDEN))]
scale = ALPHA / RANK * RANK
got = layer.weight
assert torch.allclose(got[: HIDDEN // 2], torch.full((HIDDEN // 2, HIDDEN), scale * 1.0))
assert torch.allclose(got[HIDDEN // 2 : HIDDEN // 2 + KV // 2], torch.full((KV // 2, HIDDEN), scale * 2.0))
assert torch.allclose(got[HIDDEN // 2 + KV // 2 :], torch.full((KV // 2, HIDDEN), scale * 3.0))
def test_fusion_does_not_retain_the_adapter_tensors(tmp_path):
"""Fusion is one-way and this pipeline has no unload path, so holding the
fp32 state dict would keep about 1.5 GiB alive for the shipped checkpoint."""
pipe = _Stub()
path = _write(tmp_path, {f"{_prefix()}.self_attn.o_proj_mot_gen": _triple(HIDDEN, HIDDEN, 1.0)})
pipe.load_lora_weights(path, adapter_name="distill")
kept = pipe.lora_loaded["distill"]
assert not isinstance(kept, dict)
assert not isinstance(kept, torch.Tensor)
# the sentinel still makes a second load a no-op
before = pipe.language_model.model.layers[0].self_attn.o_proj_mot_gen.weight.detach().clone()
pipe.load_lora_weights(path, adapter_name="distill")
torch.testing.assert_close(pipe.language_model.model.layers[0].self_attn.o_proj_mot_gen.weight, before)
@@ -0,0 +1,800 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Tests for the paged decode cache and its CUDA graph.
The cache exists so a decode step has static shapes and can be captured. That
puts the burden on two things a normal test would not look at: the prefix must
survive the hand-off from the ordinary cache, and every quantity that varies
between steps must live in a device tensor. A Python-level index is correct
eagerly and silently wrong once captured, because capture bakes it in -- which
is exactly what the last test here pins.
"""
from types import SimpleNamespace
import pytest
import torch
from vllm.lora.layers import BaseLayerWithLoRA
import vllm_omni.diffusion.models.sensenova_u1.paged_decode as paged_decode
from vllm_omni.diffusion.models.sensenova_u1.paged_decode import (
BLOCK_SIZE,
BUCKETS,
TAIL_STEP,
PagedDecodeCache,
_bucket_for,
)
from vllm_omni.diffusion.models.sensenova_u1.sensenova_u1_transformer import (
SenseNovaU1Attention,
)
from vllm_omni.platforms import current_omni_platform
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
LAYERS, KV_HEADS, HEAD_DIM = 2, 4, 8
N_HEADS = KV_HEADS * 2
class _Layer:
def __init__(self, keys=None, values=None):
self.keys, self.values = keys, values
class _Cache:
"""Stands in for the transformers cache: layers with [B, H, S, D] tensors."""
def __init__(self, layers):
self.layers = layers
def _dyn_cache(prefix, device=None):
torch.manual_seed(0)
return _Cache(
[
_Layer(
torch.randn(1, KV_HEADS, prefix, HEAD_DIM, device=device),
torch.randn(1, KV_HEADS, prefix, HEAD_DIM, device=device),
)
for _ in range(LAYERS)
]
)
def test_bucket_rounds_up_and_is_block_aligned():
assert _bucket_for(1) == 512
assert _bucket_for(512) == 512
assert _bucket_for(513) == 1024
assert _bucket_for(100_000) % BLOCK_SIZE == 0
def test_past_the_last_bucket_the_schedule_grows_in_steps():
"""Every bucket change reallocates the cache and re-captures the graph, so
the tail of the schedule must not step by BLOCK_SIZE. A think edit with two
2048x2048 inputs runs to about 9,100 tokens, so this regime is reachable,
and a request decodes at most 1024 steps.
"""
last = BUCKETS[-1]
assert _bucket_for(last + 1) == last + TAIL_STEP
assert _bucket_for(100_000) % BLOCK_SIZE == 0
for start in (last + 1, last + TAIL_STEP - 1, last + TAIL_STEP + 7):
bucket, grows = _bucket_for(start), 0
for n in range(start, start + 1025):
if _bucket_for(n) != bucket:
bucket = _bucket_for(n)
grows += 1
assert grows <= 1, f"start {start}: {grows} reallocations over 1024 decode steps"
def test_prefix_survives_the_hand_off():
dyn = _dyn_cache(37)
paged = PagedDecodeCache.from_dynamic_cache(dyn, LAYERS, torch.device("cpu"), torch.float32)
assert paged.length == 37
for i in range(LAYERS):
stored = paged.k[i].view(-1, KV_HEADS, HEAD_DIM)[:37]
torch.testing.assert_close(stored, dyn.layers[i].keys[0].transpose(0, 1))
def test_write_back_restores_what_was_handed_over():
"""Decode is paged but the stage after it reads the ordinary cache."""
dyn = _dyn_cache(21)
original = [layer.keys.clone() for layer in dyn.layers]
paged = PagedDecodeCache.from_dynamic_cache(dyn, LAYERS, torch.device("cpu"), torch.float32)
paged.to_dynamic_cache(dyn)
for before, layer in zip(original, dyn.layers):
torch.testing.assert_close(layer.keys, before)
def test_growing_keeps_the_tokens_already_stored():
dyn = _dyn_cache(500)
paged = PagedDecodeCache.from_dynamic_cache(dyn, LAYERS, torch.device("cpu"), torch.float32)
assert paged.bucket == 512
before = [k.view(-1, KV_HEADS, HEAD_DIM)[:500].clone() for k in paged.k]
gen = paged.generation
assert paged.grow(513) is True
assert paged.bucket == 1024
assert paged.generation > gen, "a reallocation must invalidate captured graphs"
for old, new in zip(before, paged.k):
torch.testing.assert_close(new.view(-1, KV_HEADS, HEAD_DIM)[:500], old)
def test_a_failed_grow_leaves_the_cache_on_its_old_buffers(monkeypatch):
"""A half-applied resize would strand a capture on freed storage.
``generation`` is half of the key a captured graph is stored under, so
publishing new K without publishing V, the block table, the bucket and the
generation leaves the old key selecting a graph whose recorded addresses
have just been freed. A later request replays it. Injecting a failure into
the last allocation is the cheapest way to reach that window.
"""
dyn = _dyn_cache(500)
paged = PagedDecodeCache.from_dynamic_cache(dyn, LAYERS, torch.device("cpu"), torch.float32)
before_k, before_v = list(paged.k), list(paged.v)
before_table, before_bucket = paged.block_table, paged.bucket
before_generation, before_length = paged.generation, paged.length
real_zeros = torch.zeros
calls = []
def flaky(*args, **kwargs):
calls.append(1)
if len(calls) == 2 * LAYERS: # the last of the two allocations per layer
raise RuntimeError("injected allocation failure")
return real_zeros(*args, **kwargs)
monkeypatch.setattr(torch, "zeros", flaky)
with pytest.raises(RuntimeError, match="injected"):
paged.grow(513)
monkeypatch.undo()
assert [id(t) for t in paged.k] == [id(t) for t in before_k], "K was published on its own"
assert [id(t) for t in paged.v] == [id(t) for t in before_v], "V was published on its own"
assert paged.block_table is before_table
assert paged.bucket == before_bucket
assert paged.generation == before_generation, "the graph key moved without new buffers"
assert paged.length == before_length
def test_length_lives_in_device_tensors():
"""Both the attended length and the write slot must be tensors.
A captured graph reads them at replay; a Python int would be frozen at
capture time and every replay would attend the wrong span and overwrite the
same slot.
"""
dyn = _dyn_cache(10)
paged = PagedDecodeCache.from_dynamic_cache(dyn, LAYERS, torch.device("cpu"), torch.float32)
paged.set_length(15)
assert torch.is_tensor(paged.seqused) and int(paged.seqused) == 15
assert torch.is_tensor(paged.pos) and int(paged.pos) == 14
# ---------------------------------------------------------------------------
# The capture contract. These need CUDA and the bundled flash-attention kernel.
# ---------------------------------------------------------------------------
cuda_only = pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
@cuda_only
@pytest.mark.cuda
@pytest.mark.L4
def test_paged_attention_matches_sdpa_over_the_same_buffer():
import torch.nn.functional as F
from vllm_omni.diffusion.models.sensenova_u1.paged_decode import PagedDecodeCache
heads, kv_heads, dim, n = 8, 2, 64, 100
dev, dt = torch.device("cuda"), torch.bfloat16
torch.manual_seed(0)
dyn = _Cache(
[
_Layer(
torch.randn(1, kv_heads, n, dim, device=dev, dtype=dt),
torch.randn(1, kv_heads, n, dim, device=dev, dtype=dt),
)
]
)
paged = PagedDecodeCache.from_dynamic_cache(dyn, 1, dev, dt)
paged.set_length(n)
q = torch.randn(1, heads, 1, dim, device=dev, dtype=dt)
k = torch.zeros(1, kv_heads, 1, dim, device=dev, dtype=dt)
scale = dim**-0.5
# write the last slot back as itself so attend() is a pure read
k[0, :, 0] = dyn.layers[0].keys[0, :, n - 1]
v = torch.zeros_like(k)
v[0, :, 0] = dyn.layers[0].values[0, :, n - 1]
got = paged.attend(0, q, k, v, scale)
flat_k = paged.k[0].view(-1, kv_heads, dim)[:n]
flat_v = paged.v[0].view(-1, kv_heads, dim)[:n]
want = F.scaled_dot_product_attention(
q,
flat_k.unsqueeze(0).transpose(1, 2).contiguous(),
flat_v.unsqueeze(0).transpose(1, 2).contiguous(),
enable_gqa=True,
scale=scale,
).transpose(1, 2)
torch.testing.assert_close(got.float(), want.float(), atol=2e-3, rtol=2e-3)
@cuda_only
@pytest.mark.cuda
@pytest.mark.L4
def test_a_captured_graph_follows_a_later_length():
"""One capture has to serve every length in the bucket.
If the attended span were a Python value the graph would keep attending the
capture-time length, and the run would still look fast.
"""
import torch.nn.functional as F
from vllm_omni.diffusion.models.sensenova_u1.paged_decode import PagedDecodeCache
heads, kv_heads, dim, n = 8, 2, 64, 200
dev, dt = torch.device("cuda"), torch.bfloat16
torch.manual_seed(0)
dyn = _Cache(
[
_Layer(
torch.randn(1, kv_heads, n, dim, device=dev, dtype=dt),
torch.randn(1, kv_heads, n, dim, device=dev, dtype=dt),
)
]
)
paged = PagedDecodeCache.from_dynamic_cache(dyn, 1, dev, dt)
q = torch.randn(1, heads, 1, dim, device=dev, dtype=dt)
k = torch.zeros(1, kv_heads, 1, dim, device=dev, dtype=dt)
v = torch.zeros_like(k)
scale = dim**-0.5
paged.set_length(n)
k[0, :, 0] = dyn.layers[0].keys[0, :, n - 1]
v[0, :, 0] = dyn.layers[0].values[0, :, n - 1]
side = torch.cuda.Stream()
side.wait_stream(torch.cuda.current_stream())
with torch.cuda.stream(side):
for _ in range(3):
paged.attend(0, q, k, v, scale)
torch.cuda.current_stream().wait_stream(side)
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph):
out = paged.attend(0, q, k, v, scale)
shorter = n - 43
paged.set_length(shorter)
graph.replay()
torch.accelerator.synchronize()
flat_k = paged.k[0].view(-1, kv_heads, dim)[:shorter]
flat_v = paged.v[0].view(-1, kv_heads, dim)[:shorter]
want = F.scaled_dot_product_attention(
q,
flat_k.unsqueeze(0).transpose(1, 2).contiguous(),
flat_v.unsqueeze(0).transpose(1, 2).contiguous(),
enable_gqa=True,
scale=scale,
).transpose(1, 2)
torch.testing.assert_close(out.float(), want.float(), atol=2e-3, rtol=2e-3)
@cuda_only
@pytest.mark.cuda
@pytest.mark.L4
def test_a_captured_graph_writes_each_step_to_its_own_slot():
"""The write slot has to be a device tensor, not a Python index.
``test_a_captured_graph_follows_a_later_length`` pins the attended span.
The slot written is the other half: a Python index is frozen at capture, so
every replay would overwrite the capture-time slot and the cache would stop
growing -- correct eagerly, silently wrong once captured.
"""
from vllm_omni.diffusion.models.sensenova_u1.paged_decode import PagedDecodeCache
heads, kv_heads, dim, n = 8, 2, 64, 100
dev, dt = torch.device("cuda"), torch.bfloat16
torch.manual_seed(0)
dyn = _Cache(
[
_Layer(
torch.randn(1, kv_heads, n, dim, device=dev, dtype=dt),
torch.randn(1, kv_heads, n, dim, device=dev, dtype=dt),
)
]
)
paged = PagedDecodeCache.from_dynamic_cache(dyn, 1, dev, dt)
q = torch.randn(1, heads, 1, dim, device=dev, dtype=dt)
k = torch.zeros(1, kv_heads, 1, dim, device=dev, dtype=dt)
v = torch.zeros_like(k)
scale = dim**-0.5
# Capture on the step that writes slot n-1, restoring what is already there
# so the capture itself changes nothing.
paged.set_length(n)
k[0, :, 0] = dyn.layers[0].keys[0, :, n - 1]
v[0, :, 0] = dyn.layers[0].values[0, :, n - 1]
side = torch.cuda.Stream()
side.wait_stream(torch.cuda.current_stream())
with torch.cuda.stream(side):
for _ in range(3):
paged.attend(0, q, k, v, scale)
torch.cuda.current_stream().wait_stream(side)
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph):
paged.attend(0, q, k, v, scale)
kept = paged.k[0].view(-1, kv_heads, dim)[n - 1].clone()
# The next decode step: one token further, different K/V.
paged.set_length(n + 1)
k[0, :, 0] = 1.5
v[0, :, 0] = -2.5
graph.replay()
current_omni_platform.synchronize()
flat_k = paged.k[0].view(-1, kv_heads, dim)
flat_v = paged.v[0].view(-1, kv_heads, dim)
torch.testing.assert_close(flat_k[n].float(), torch.full_like(flat_k[n], 1.5).float())
torch.testing.assert_close(flat_v[n].float(), torch.full_like(flat_v[n], -2.5).float())
torch.testing.assert_close(flat_k[n - 1].float(), kept.float())
@cuda_only
@pytest.mark.cuda
@pytest.mark.L4
def test_a_whole_decode_loop_matches_the_unpaged_path():
"""Per-step kernel equivalence is not loop equivalence.
With the switch off a single-token step attends over a cache grown by
``torch.cat`` and no mask; with it on the same step reads a bucket-sized
buffer through ``seqused_k``. Run the same sequence of steps through both,
crossing a bucket boundary on the way, and require the same argmax at every
step off a fixed projection -- which is the token id the pipeline would emit.
"""
import torch.nn.functional as F
from vllm_omni.diffusion.models.sensenova_u1.paged_decode import PagedDecodeCache
heads, kv_heads, dim = 8, 2, 64
prefill, steps = 500, 40 # crosses the 512 bucket at step 13
dev, dt = torch.device("cuda"), torch.bfloat16
scale = dim**-0.5
torch.manual_seed(7)
keys = torch.randn(1, kv_heads, prefill, dim, device=dev, dtype=dt)
values = torch.randn(1, kv_heads, prefill, dim, device=dev, dtype=dt)
paged = PagedDecodeCache.from_dynamic_cache(_Cache([_Layer(keys, values)]), 1, dev, dt)
ref_k, ref_v = keys.clone(), values.clone()
proj = torch.randn(dim * heads, 128, device=dev, dtype=torch.float32)
paged_ids, ref_ids, worst = [], [], 0.0
for step in range(steps):
q = torch.randn(1, heads, 1, dim, device=dev, dtype=dt)
k = torch.randn(1, kv_heads, 1, dim, device=dev, dtype=dt)
v = torch.randn(1, kv_heads, 1, dim, device=dev, dtype=dt)
if paged.length + 1 > paged.bucket:
paged.grow(paged.length + 1)
paged.set_length(paged.length + 1)
got = paged.attend(0, q, k, v, scale)
ref_k = torch.cat([ref_k, k], dim=2)
ref_v = torch.cat([ref_v, v], dim=2)
want = F.scaled_dot_product_attention(q, ref_k, ref_v, enable_gqa=True, scale=scale).transpose(1, 2)
worst = max(worst, (got.float() - want.float()).abs().max().item())
paged_ids.append(int((got.float().reshape(1, -1) @ proj).argmax()))
ref_ids.append(int((want.float().reshape(1, -1) @ proj).argmax()))
assert paged.bucket == 1024, "the loop never crossed a bucket boundary"
assert paged_ids == ref_ids, (
f"argmax diverges at step {next(i for i, (a, b) in enumerate(zip(paged_ids, ref_ids)) if a != b)}"
)
assert worst < 5e-2, f"largest per-step deviation {worst:.3e}"
class _StubLM(torch.nn.Module):
"""Just enough CUDA work inside the capture for a pool to be allocated."""
def __init__(self, device):
super().__init__()
self.w = torch.randn(16, 64, device=device)
def forward(self, input_ids, indexes, past_key_values=None, use_cache=False, paged_cache=None):
x = input_ids.float().expand(1, 16) @ self.w
return SimpleNamespace(logits=x.unsqueeze(0))
@cuda_only
@pytest.mark.cuda
@pytest.mark.L4
def test_the_decode_graph_captures_into_the_shared_platform_pool(monkeypatch):
"""``_decode_context`` builds a runner per request, so a private capture pool
hands every request its own arena and never gives it back. Measured on a
real model that was about 40 MiB of device memory per request, rising
linearly over twelve sequential think requests while the same twelve with
the switch off held flat.
"""
from vllm.platforms import current_platform
from vllm_omni.diffusion.models.sensenova_u1.paged_decode import DecodeGraphRunner
pools = []
real_graph = torch.cuda.graph
def spy(graph, pool=None, **kwargs):
pools.append(pool)
return real_graph(graph, pool=pool, **kwargs)
monkeypatch.setattr(torch.cuda, "graph", spy)
dev = torch.device("cuda")
cache = PagedDecodeCache.from_dynamic_cache(_dyn_cache(8, dev), LAYERS, dev, torch.float32)
runner = DecodeGraphRunner(_StubLM(dev), cache, dev)
runner.step(0, 0)
assert pools, "no graph was captured"
assert pools[0] is not None, "captured into a fresh private pool"
assert pools[0] == current_platform.get_global_graph_pool()
assert not hasattr(runner, "_pool"), "the runner still keeps a pool of its own"
# ---------------------------------------------------------------------------
# The cache is only worth anything if production actually reaches it, and the
# kwargs it needs are not in every wheel that exports the kernel. The tests
# above would all still pass with the `forward_und` branch deleted, so these
# drive the real dispatch instead of the cache object.
# ---------------------------------------------------------------------------
class _PagedProbe:
def __init__(self, out):
self.out = out
self.calls: list[tuple[int, torch.Size, float]] = []
def attend(self, layer_idx, query, key, value, scaling):
self.calls.append((layer_idx, query.shape, scaling))
return self.out
class _AttnHost:
"""Carries only what ``forward_und`` touches on the way to the branch."""
forward_und = SenseNovaU1Attention.forward_und
qkv_proj = q_norm = k_norm = q_norm_hw = k_norm_hw = None
layer_idx = 3
scaling = HEAD_DIM**-0.5
def __init__(self, seq, paged_out):
self._qkv = (
torch.randn(1, N_HEADS, seq, HEAD_DIM),
torch.randn(1, KV_HEADS, seq, HEAD_DIM),
torch.randn(1, KV_HEADS, seq, HEAD_DIM),
)
self.paged = _PagedProbe(paged_out)
self.sdpa_calls: list[torch.Tensor | None] = []
def _project_and_rope(self, *args, **kwargs):
return self._qkv
def _run_attn(self, query, key, value, mask):
self.sdpa_calls.append(mask)
return torch.zeros(1, N_HEADS, query.shape[2], HEAD_DIM)
def o_proj(self, hidden_states):
return hidden_states * 2, None
def test_forward_und_sends_a_single_token_decode_to_the_paged_cache():
"""Goes through the real dispatch, so deleting the branch turns this red."""
out = torch.randn(1, N_HEADS, 1, HEAD_DIM)
host = _AttnHost(seq=1, paged_out=out)
got = host.forward_und(torch.zeros(1, 1, 16), None, None, position_embeddings=None, paged_cache=host.paged)
assert len(host.paged.calls) == 1, "the paged hook was never reached"
assert host.sdpa_calls == [], "the same step also ran the SDPA path"
assert host.paged.calls[0][0] == host.layer_idx
torch.testing.assert_close(got, out.reshape(1, 1, -1).contiguous() * 2)
@pytest.mark.parametrize(
"seq,mask",
[(2, None), (1, torch.zeros(1, 1, 1, 1))],
ids=["multi_token", "masked"],
)
def test_only_an_unmasked_single_token_step_takes_the_paged_path(seq, mask):
"""Both other shapes are uncapturable, and the cache holds one row per step."""
host = _AttnHost(seq=seq, paged_out=torch.zeros(1, N_HEADS, seq, HEAD_DIM))
host.forward_und(torch.zeros(1, seq, 16), None, mask, position_embeddings=None, paged_cache=host.paged)
assert host.paged.calls == [], "a step that cannot be captured reached the paged cache"
assert len(host.sdpa_calls) == 1
def test_a_kernel_missing_any_kwarg_we_pass_is_not_supported(monkeypatch):
"""The call names every argument, so the probe has to cover every name.
An older wheel exports ``flash_attn_varlen_func`` without the paged kwargs,
and a future one may rename the standard ones. Either way the probe is what
keeps that install on SDPA instead of raising TypeError at the first decode
step, with the feature on by default.
"""
def no_paged_kwargs(q, k, v, max_seqlen_q, cu_seqlens_q, max_seqlen_k, cu_seqlens_k, causal=False):
raise AssertionError("must not be called") # pragma: no cover
def renamed_standard_args(q, k, v, max_q, cu_q, max_k, seqused_k=None, block_table=None, causal=False):
raise AssertionError("must not be called") # pragma: no cover
for kernel in (no_paged_kwargs, renamed_standard_args):
monkeypatch.setattr(paged_decode, "_flash_varlen", lambda kernel=kernel: kernel)
assert paged_decode.paged_decode_supported(torch.device("cuda"), HEAD_DIM) is False
def current_kernel(
q,
k,
v,
max_seqlen_q,
cu_seqlens_q,
max_seqlen_k,
seqused_k=None,
block_table=None,
softmax_scale=None,
causal=False,
):
raise AssertionError("must not be called") # pragma: no cover
monkeypatch.setattr(paged_decode, "_flash_varlen", lambda: current_kernel)
assert paged_decode.paged_decode_supported(torch.device("cuda"), HEAD_DIM) is True
def test_only_q_k_and_v_reach_the_kernel_positionally(monkeypatch):
"""A positional standard argument binds to whatever a future wheel puts in
that slot, and the probe above only checks that the names exist."""
seen = {}
def recorder(*args, **kwargs):
seen["args"], seen["kwargs"] = args, kwargs
return torch.zeros(1, N_HEADS, HEAD_DIM)
monkeypatch.setattr(paged_decode, "_flash_varlen", lambda: recorder)
cache = PagedDecodeCache(1, KV_HEADS, HEAD_DIM, 8, torch.device("cpu"), torch.float32)
cache.set_length(4)
cache.attend(
0,
torch.zeros(1, N_HEADS, 1, HEAD_DIM),
torch.zeros(1, KV_HEADS, 1, HEAD_DIM),
torch.zeros(1, KV_HEADS, 1, HEAD_DIM),
1.0,
)
assert len(seen["args"]) == 3, "only q, k and v may be positional"
assert set(seen["kwargs"]) == {
"max_seqlen_q",
"cu_seqlens_q",
"max_seqlen_k",
"seqused_k",
"block_table",
"softmax_scale",
"causal",
}
def test_the_kill_switch_returns_no_cache(monkeypatch):
"""The recipe documents `VLLM_OMNI_SENSENOVA_PAGED_DECODE=0` as the way back
to the ordinary cache, so the switch needs a test rather than only prose."""
from vllm_omni.diffusion.models.sensenova_u1.pipeline_sensenova_u1 import (
SenseNovaU1Pipeline,
)
monkeypatch.setenv("VLLM_OMNI_SENSENOVA_PAGED_DECODE", "0")
# The env check returns before anything else on the pipeline is touched.
host = object.__new__(SenseNovaU1Pipeline)
assert SenseNovaU1Pipeline._decode_context(host, object()) is None
def _pipeline_host(monkeypatch, device="cpu", language_model=None):
"""A bare pipeline carrying only what ``_decode_context`` touches."""
from vllm_omni.diffusion.models.sensenova_u1 import pipeline_sensenova_u1 as pipe_mod
monkeypatch.setenv("VLLM_OMNI_SENSENOVA_PAGED_DECODE", "1")
monkeypatch.setattr(pipe_mod, "paged_decode_supported", lambda dev, head_dim: True)
layer = SimpleNamespace(self_attn=SimpleNamespace(head_dim=HEAD_DIM))
host = object.__new__(pipe_mod.SenseNovaU1Pipeline)
# The pipeline is an nn.Module, and the language model assigned below is one
# too, so the bookkeeping has to exist before the assignment.
torch.nn.Module.__init__(host)
host.device = device
lm = language_model if language_model is not None else torch.nn.Module()
lm.model = SimpleNamespace(layers=[layer] * LAYERS)
host.language_model = lm
return pipe_mod, host
def test_the_decode_context_is_reused_across_requests(monkeypatch):
"""A capture binds the addresses it recorded, so a cache built per request
forces a capture per request. Two requests whose prefill fits the same
bucket have to come back with the same cache and the same runner, and with
the generation unchanged so the captures stay valid.
"""
pipe_mod, host = _pipeline_host(monkeypatch)
monkeypatch.setattr(pipe_mod, "DecodeGraphRunner", lambda lm, cache, dev: SimpleNamespace(cache=cache))
ctx = pipe_mod.SenseNovaU1Pipeline._decode_context
first = ctx(host, _dyn_cache(10))
assert first is not None
second_input = _dyn_cache(20)
second = ctx(host, second_input)
assert second[0] is first[0], "the cache was rebuilt for a request that fits it"
assert second[1] is first[1], "the runner was rebuilt, so its captures are gone"
assert second[0].generation == first[0].generation, "reuse must not invalidate a capture"
assert second[0].length == 20
# The reused buffers must carry the new request's prefill, not the old one.
flat = second[0].k[0].view(-1, KV_HEADS, HEAD_DIM)[:20]
torch.testing.assert_close(flat, second_input.layers[0].keys[0].transpose(0, 1))
# A prefill past the bucket cannot share the buffers.
bigger = ctx(host, _dyn_cache(BUCKETS[0] + 1))
assert bigger[0] is not first[0], "a prefill past the bucket must get new buffers"
def test_releasing_the_captures_forces_a_rebuild(monkeypatch):
"""Sleep level 2 discards the memory a capture recorded, so what the
pipeline reuses across requests has to be droppable."""
pipe_mod, host = _pipeline_host(monkeypatch)
monkeypatch.setattr(pipe_mod, "DecodeGraphRunner", lambda lm, cache, dev: SimpleNamespace(cache=cache))
ctx = pipe_mod.SenseNovaU1Pipeline._decode_context
first = ctx(host, _dyn_cache(10))
assert ctx(host, _dyn_cache(10))[0] is first[0], "the fixture is not reusing to begin with"
pipe_mod.SenseNovaU1Pipeline.release_captured_graphs(host)
after = ctx(host, _dyn_cache(10))
assert after[0] is not first[0], "the cache survived the release"
assert after[1] is not first[1], "the runner survived the release"
class _FakeLoRAWrapper(BaseLayerWithLoRA):
"""Stands in for what ``replace_submodule`` leaves in the module tree."""
def __init__(self):
super().__init__()
def test_no_capture_crosses_a_request_once_lora_wrappers_are_installed(monkeypatch):
"""Base -> LoRA -> base. The reuse test cannot see an adapter change.
``DiffusionLoRAManager`` replaces the decode path's linear layers with
``BaseLayerWithLoRA`` and binds, rescales or resets slot 0 per request, and
the wrappers stay in the tree afterwards. A graph captured before an adapter
was bound would replay without its matmuls; one captured with it bound would
survive its removal. Neither moves the shape, dtype or bucket that decide
reuse, so the wrappers themselves have to end it.
"""
pipe_mod, host = _pipeline_host(monkeypatch)
monkeypatch.setattr(pipe_mod, "DecodeGraphRunner", lambda lm, cache, dev: SimpleNamespace(cache=cache))
ctx = pipe_mod.SenseNovaU1Pipeline._decode_context
base = ctx(host, _dyn_cache(10))
assert ctx(host, _dyn_cache(10))[0] is base[0], "the fixture is not reusing to begin with"
host.language_model.add_module("q_proj", _FakeLoRAWrapper())
adapted = ctx(host, _dyn_cache(10))
assert adapted[0] is not base[0], "an adapted request reused the base capture"
assert adapted[1] is not base[1], "an adapted request reused the base runner"
back = ctx(host, _dyn_cache(10))
assert back[0] is not adapted[0], "a later request reused the adapted capture"
assert back[1] is not adapted[1], "a later request reused the adapted runner"
assert getattr(host, "_paged_decode", None) is None, "an adapted capture was left on the pipeline"
@cuda_only
@pytest.mark.cuda
@pytest.mark.L4
def test_a_second_request_replays_the_first_capture(monkeypatch):
"""Reuse is only worth anything if the capture survives it."""
dev = torch.device("cuda")
pipe_mod, host = _pipeline_host(monkeypatch, device="cuda", language_model=_StubLM(dev))
ctx = pipe_mod.SenseNovaU1Pipeline._decode_context
cache, runner = ctx(host, _dyn_cache(10, dev))
cache.set_length(cache.length + 1)
runner.step(0, 0)
assert runner.captures == 1
cache2, runner2 = ctx(host, _dyn_cache(12, dev))
assert cache2 is cache and runner2 is runner
cache2.set_length(cache2.length + 1)
runner2.step(0, 0)
assert runner.captures == 1, "the second request captured the graph again"
class _Tok:
"""The two tokenizer calls ``_generate_text`` makes."""
EOS = 7
def convert_tokens_to_ids(self, token):
return self.EOS
def decode(self, ids, skip_special_tokens=False):
return " ".join(str(i) for i in ids)
class _Out:
def __init__(self, logits, past_key_values):
self.logits = logits
self.past_key_values = past_key_values
class _TextHost:
"""Carries only what ``_generate_text`` touches."""
def __init__(self, context):
self._context = context
self.seen: list[object] = []
self.tokenizer = _Tok()
def _decode_context(self, past_key_values):
return self._context
def _ar_step(self, next_token, t_idx, past_key_values, decode=None):
self.seen.append(decode)
# Stop the loop: argmax of this row is the EOS id.
logits = torch.full((1, 1, 8), -1.0)
logits[0, 0, _Tok.EOS] = 1.0
return _Out(logits, past_key_values)
def test_text_decoding_uses_the_paged_context_too():
"""Image-to-text and text-to-text run their own decode loop. It used to call
``_ar_step`` without the context, so the paged path was documented but never
reached outside think."""
from vllm_omni.diffusion.models.sensenova_u1.pipeline_sensenova_u1 import (
SenseNovaU1Pipeline,
)
context = object()
host = _TextHost(context)
prefix = torch.full((1, 1, 8), -1.0)
prefix[0, 0, 5] = 1.0 # not EOS, so one step runs
SenseNovaU1Pipeline._generate_text(host, prefix, past_key_values=None, t_idx=0)
assert host.seen == [context], f"text decoding ran with decode={host.seen}"
class _WarmupReq:
"""The engine's dummy request, which carries the reserved id."""
def __init__(self, request_id):
self.request_id = request_id
self.prompts = [{"prompt": "", "modalities": ["image"]}]
def test_the_dummy_warmup_request_drives_one_decode_step(monkeypatch):
"""The dummy request is a text-to-image run with think off, so it exercises
the prefill shape and never the decode one. Without this hook whatever the
deploy config compiles for decode lands on the first real request."""
from vllm_omni.diffusion.models.sensenova_u1.pipeline_sensenova_u1 import (
SenseNovaU1Pipeline,
)
from vllm_omni.diffusion.request import DUMMY_DIFFUSION_REQUEST_ID
calls: list[str] = []
host = object.__new__(SenseNovaU1Pipeline)
monkeypatch.setattr(host, "_warm_ar_decode", lambda: calls.append("warm"), raising=False)
monkeypatch.setattr(
SenseNovaU1Pipeline,
"_parse_request",
lambda self, req: (_ for _ in ()).throw(_StopForwardError()),
raising=False,
)
with pytest.raises(_StopForwardError):
SenseNovaU1Pipeline.forward(host, _WarmupReq(DUMMY_DIFFUSION_REQUEST_ID))
assert calls == ["warm"], "the dummy warmup request did not drive a decode step"
calls.clear()
with pytest.raises(_StopForwardError):
SenseNovaU1Pipeline.forward(host, _WarmupReq("a-real-request"))
assert calls == [], "a real request paid for the warmup"
class _StopForwardError(Exception):
"""Stops ``forward`` right after the warmup hook, so the test needs no model."""
@@ -0,0 +1,107 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""CPU tests for SenseNova-U1 RMSNorm and the U1.5 config surface.
``Qwen3RMSNorm`` was a hand-rolled fp32 up/down-cast chain; it now calls the fused
``F.rms_norm``. These pin the mathematics against an independent reference so a
future kernel swap cannot silently change what the layer computes, and pin the two
config fields that distinguish U1.5 from U1.
"""
import pytest
import torch
from vllm_omni.diffusion.models.sensenova_u1.sensenova_u1_transformer import Qwen3RMSNorm
from vllm_omni.transformers_utils.configs.sensenova_u1 import SenseNovaU1Config
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
def _reference(x: torch.Tensor, weight: torch.Tensor, eps: float) -> torch.Tensor:
"""RMSNorm written out in float64 so it is independent of the implementation."""
x64 = x.to(torch.float64)
var = x64.pow(2).mean(-1, keepdim=True)
return (weight.to(torch.float64) * (x64 * torch.rsqrt(var + eps))).to(x.dtype)
@pytest.mark.parametrize("hidden", [8, 128, 4096])
@pytest.mark.parametrize(
"dtype,atol",
[(torch.float32, 1e-5), (torch.bfloat16, 3e-2), (torch.float16, 4e-3)],
)
def test_rmsnorm_matches_float64_reference(hidden: int, dtype: torch.dtype, atol: float):
torch.manual_seed(0)
norm = Qwen3RMSNorm(hidden, eps=1e-6).to(dtype)
with torch.no_grad():
norm.weight.copy_(torch.rand(hidden, dtype=dtype) + 0.5)
x = torch.randn(7, hidden, dtype=dtype)
got = norm(x)
want = _reference(x, norm.weight, norm.variance_epsilon)
assert got.dtype == dtype
assert got.shape == x.shape
torch.testing.assert_close(got.to(torch.float32), want.to(torch.float32), atol=atol, rtol=atol)
def _cast_chain(x: torch.Tensor, weight: torch.Tensor, eps: float) -> torch.Tensor:
"""The implementation this PR replaced, kept so its accuracy can be compared."""
var = x.to(torch.float32).pow(2).mean(-1, keepdim=True)
return weight * (x.to(torch.float32) * torch.rsqrt(var + eps)).to(weight.dtype)
@pytest.mark.parametrize("hidden", [512, 4096])
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
def test_rmsnorm_is_closer_to_float64_than_the_cast_chain(hidden: int, dtype: torch.dtype):
"""The cast chain rounds to ``dtype`` before the weight multiply and so rounds
twice, while ``F.rms_norm`` rounds once. Restoring the cast chain fails here."""
torch.manual_seed(0)
norm = Qwen3RMSNorm(hidden, eps=1e-6).to(dtype)
with torch.no_grad():
norm.weight.copy_(torch.rand(hidden, dtype=dtype) + 0.5)
x = torch.randn(64, hidden, dtype=dtype)
reference = _reference(x, norm.weight, norm.variance_epsilon).to(torch.float64)
scale = reference.abs().mean()
def relative_error(out: torch.Tensor) -> float:
return ((out.to(torch.float64) - reference).abs().mean() / scale).item()
fused = relative_error(norm(x))
legacy = relative_error(_cast_chain(x, norm.weight, norm.variance_epsilon))
assert fused < legacy, f"{dtype} hidden={hidden}: fused {fused:.3e} not better than {legacy:.3e}"
def test_rmsnorm_is_scale_equivariant():
"""RMSNorm divides by the RMS, so scaling the input must not change the output."""
torch.manual_seed(0)
norm = Qwen3RMSNorm(64, eps=1e-12)
x = torch.randn(4, 64)
torch.testing.assert_close(norm(x), norm(x * 7.5), atol=1e-4, rtol=1e-4)
def test_rmsnorm_preserves_leading_dims():
norm = Qwen3RMSNorm(16, eps=1e-6)
for shape in [(16,), (3, 16), (2, 3, 16)]:
assert norm(torch.randn(*shape)).shape == shape
def test_rmsnorm_weight_is_applied():
norm = Qwen3RMSNorm(4, eps=1e-6)
x = torch.randn(2, 4)
base = norm(x)
with torch.no_grad():
norm.weight.mul_(3.0)
torch.testing.assert_close(norm(x), base * 3.0, atol=1e-5, rtol=1e-5)
def test_u15_config_fields_default_to_u1_values():
"""U1.5 flips exactly these two; the defaults must stay U1's so old checkpoints
keep their behaviour when the keys are absent."""
cfg = SenseNovaU1Config()
assert cfg.use_pixel_head is False
assert cfg.noise_scale_max_value == 10.0
def test_u15_config_fields_are_read_from_kwargs():
cfg = SenseNovaU1Config(use_pixel_head=True, noise_scale_max_value=16.0)
assert cfg.use_pixel_head is True
assert cfg.noise_scale_max_value == 16.0
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import pytest
from vllm_omni.diffusion.sched import DMD2SigmaSchedule
@@ -18,7 +18,8 @@ def test_step_count_is_intervals_not_boundaries():
[
[],
[1.0],
[0.9, 0.5, 0.0],
[1.5, 0.5, 0.0],
[0.0, 0.5, 0.0],
[1.0, 0.5, 0.1],
[1.0, 0.4, 0.6, 0.0],
[1.0, 0.5, 0.5, 0.0],
@@ -31,6 +32,15 @@ def test_malformed_positions_are_rejected(positions):
DMD2SigmaSchedule.from_positions(positions)
def test_a_capped_first_position_is_a_schedule_not_a_malformed_one():
# A DMD2 student trained with max_timestep_ratio < 1 never sees pure noise,
# so its opening rung sits just below 1.0 (FastH3: 999/1000).
schedule = DMD2SigmaSchedule.from_positions([0.999, 0.749, 0.5, 0.25, 0.0])
assert schedule.base_schedule[0] == 0.999
assert schedule.num_inference_steps == 4
def test_one_base_schedule_drives_several_shift_scales():
schedule = DMD2SigmaSchedule.from_positions([1.0, 0.7, 0.4, 0.15, 0.0])
+45 -3
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from contextlib import contextmanager
from types import SimpleNamespace
@@ -179,6 +179,30 @@ def test_request_scoped_cache_dit_lifecycle_is_pipeline_opt_in():
assert events == [backend]
def test_release_captured_graphs_clears_runners_and_delegates_to_the_pipeline():
"""Both halves in one place, so a pipeline that keeps captures is collected
without the caller knowing which pipelines have one."""
released = []
runner = object.__new__(DiffusionModelRunner)
runner.graph_runners = {"decode": object()}
runner.pipeline = SimpleNamespace(release_captured_graphs=lambda: released.append(True))
runner.release_captured_graphs()
assert runner.graph_runners == {}
assert released == [True]
def test_release_captured_graphs_tolerates_a_pipeline_without_captures():
"""Most pipelines keep none, and the runner may not carry `graph_runners`."""
runner = object.__new__(DiffusionModelRunner)
runner.pipeline = object()
runner.release_captured_graphs()
assert not hasattr(runner, "graph_runners")
def _make_runner(cache_backend, cache_backend_name: str, enable_cache_dit_summary: bool = True):
runner = object.__new__(DiffusionModelRunner)
runner.vllm_config = object()
@@ -533,7 +557,14 @@ def test_profile_run_executes_forward_without_scheduler_kv_validation(monkeypatc
return original_execute(*args, **kwargs)
runner._execute_request_list = execute
monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context)
forward_context_calls = []
@contextmanager
def record_forward_context(*args, **kwargs):
forward_context_calls.append((args, kwargs))
yield
monkeypatch.setattr(model_runner_module, "set_forward_context", record_forward_context)
reset_peak_memory_stats = Mock()
monkeypatch.setattr(
model_runner_module.current_omni_platform,
@@ -549,6 +580,8 @@ def test_profile_run_executes_forward_without_scheduler_kv_validation(monkeypatc
assert runner.pipeline.forward_calls == 1
assert record_names == ["pipeline_memory_profile"]
assert len(forward_context_calls) == 1
assert forward_context_calls[0][1]["in_diffusion_kv_memory_profile"] is True
runner._validate_diffusion_kv_metadata.assert_not_called()
reset_peak_memory_stats.assert_not_called()
synchronize.assert_called_once_with()
@@ -575,7 +608,14 @@ def test_profile_run_executes_maximum_step_batch_without_resetting_peak(monkeypa
runner.pipeline.denoise_step = denoise_step
runner._validate_diffusion_kv_metadata = Mock(side_effect=AssertionError("profile must bypass admission"))
monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context)
forward_context_calls = []
@contextmanager
def record_forward_context(*args, **kwargs):
forward_context_calls.append((args, kwargs))
yield
monkeypatch.setattr(model_runner_module, "set_forward_context", record_forward_context)
reset_peak_memory_stats = Mock()
monkeypatch.setattr(
model_runner_module.current_omni_platform,
@@ -592,6 +632,8 @@ def test_profile_run_executes_maximum_step_batch_without_resetting_peak(monkeypa
assert observed_batch_rows == [(2, 2)]
assert runner.state_cache == {}
assert runner.input_batch is None
assert len(forward_context_calls) == 1
assert forward_context_calls[0][1]["in_diffusion_kv_memory_profile"] is True
runner._validate_diffusion_kv_metadata.assert_not_called()
reset_peak_memory_stats.assert_not_called()
synchronize.assert_called_once_with()
@@ -441,7 +441,6 @@ class TestPipelineStreamingOutputToEntrypoint:
model="test_model",
od_config=od_config,
metadata=metadata,
batch_size=1,
)
@staticmethod
@@ -526,6 +525,7 @@ class TestSupportedPipelines:
runner = object.__new__(DiffusionModelRunner)
runner.vllm_config = _make_vllm_config()
runner.od_config = SimpleNamespace(
max_num_seqs=1,
enable_cpu_offload=False,
enable_layerwise_offload=False,
enforce_eager=True,
+16 -1
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
Unit tests for DiffusionWorker class.
@@ -143,6 +143,21 @@ class TestDiffusionWorkerSleep:
assert "buffer1" in mock_gpu_worker._sleep_saved_buffers
assert "buffer2" in mock_gpu_worker._sleep_saved_buffers
def test_sleep_level_2_releases_captures_through_the_model_runner(self, mocker: MockerFixture, mock_gpu_worker):
"""Level 2 discards the memory captures were recorded against, and the
runner owns the release. The worker asks it once and does not reach past
it into the pipeline."""
mock_platform = mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform")
mock_platform.get_free_memory.side_effect = [10 * 1024**3, 12 * 1024**3]
mock_platform.get_device_total_memory.return_value = 80 * 1024**3
mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.get_process_gpu_memory", side_effect=[0, 0])
mock_gpu_worker.model_runner.pipeline.named_buffers = mocker.Mock(return_value=[])
mock_gpu_worker.sleep(level=2)
mock_gpu_worker.model_runner.release_captured_graphs.assert_called_once_with()
mock_gpu_worker.model_runner.pipeline.release_captured_graphs.assert_not_called()
def test_sleep_memory_freed_validation(self, mocker: MockerFixture, mock_gpu_worker):
"""Test that sleep validates memory was actually freed."""
mock_allocator_class = patch_cumem_allocator(mocker)
@@ -47,7 +47,7 @@ def client(mock_engine):
with patch.object(InlineStageDiffusionClient, "_enrich_config"):
od_config = MagicMock(spec=OmniDiffusionConfig)
od_config.streaming_output = False
c = InlineStageDiffusionClient(model="test_model", od_config=od_config, metadata=metadata, batch_size=1)
c = InlineStageDiffusionClient(model="test_model", od_config=od_config, metadata=metadata)
yield c
c.shutdown()
@@ -234,5 +234,4 @@ def test_inline_client_requires_replica_id(mock_engine):
model="test_model",
od_config=od_config,
metadata=metadata,
batch_size=1,
)
@@ -1171,7 +1171,6 @@ class TestStageDiffusionClientErrorPropagation:
metadata,
"tcp://req",
"tcp://resp",
batch_size=1,
)
@pytest.mark.asyncio
-1
View File
@@ -387,7 +387,6 @@ class TestPromptUpdateIntegration:
model="test_model",
od_config=od_config,
metadata=metadata,
batch_size=1,
)
@staticmethod
+30 -1
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import asyncio
import time
@@ -7,6 +7,8 @@ from dataclasses import asdict, dataclass
import pytest
import vllm_omni.diffusion.stage_diffusion_proc as stage_diffusion_proc
import vllm_omni.plugins as omni_plugins
from vllm_omni.diffusion.stage_diffusion_proc import StageDiffusionProc
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
@@ -26,6 +28,33 @@ BASE_INFER_STEPS = 10
DELAY_BASE = 0.01
def test_run_diffusion_proc_sets_lifecycle_before_loading_plugins(monkeypatch: pytest.MonkeyPatch) -> None:
events: list[str] = []
class StopProcessError(Exception):
pass
class TestStageDiffusionProc(StageDiffusionProc):
def __init__(self, model, od_config):
events.append("proc")
raise StopProcessError
monkeypatch.setattr(omni_plugins, "load_omni_general_plugins", lambda: events.append("plugins"))
monkeypatch.setattr(stage_diffusion_proc, "set_death_signal", lambda _: events.append("death_signal"))
monkeypatch.setattr(stage_diffusion_proc.signal, "signal", lambda *_: events.append("signal_handler"))
with pytest.raises(StopProcessError):
TestStageDiffusionProc.run_diffusion_proc(
model="test-model",
od_config=None,
handshake_address="test-address",
local_client=True,
headless=False,
)
assert events == ["death_signal", "signal_handler", "signal_handler", "plugins", "proc"]
class MockDiffusionEngine:
async def step_streaming(self, request):
def simulate_step_delay(height, width, num_inference_steps) -> float:
@@ -2959,3 +2959,34 @@ def test_abort_clears_native_codec_state_before_external_id_reuse(build_adapter)
)
assert torch.equal(replacement_payload.codes.audio, torch.full((1, 31), 202, dtype=torch.long))
def test_save_async_boundary_holds_generation_without_request_counter(build_adapter):
"""A boundary must not advance past a generation nothing can catch up to.
When the producing stage maintains ``_omni_segment_generation`` on the
request, a boundary may start the next segment's watermark at
``generation + 1`` because the following frame will carry it. On paths
where the request has no such counter, that speculative advance makes every
subsequent frame look stale and starves the stream permanently
(vllm-project/vllm-omni#6816).
"""
adapter, _ = build_adapter(stage_id=1)
request = _req("req-nogen", RequestStatus.WAITING, external_req_id="ext-nogen")
request.resumable = True
request._omni_segment_generation = None
adapter.save_async(multimodal_output=None, request=request, is_segment_finished=True)
assert adapter._segment_generation["ext-nogen"] == 0
follow_up = _req("req-nogen-next", RequestStatus.WAITING, external_req_id="ext-nogen")
follow_up.resumable = True
follow_up.num_computed_tokens = 3
follow_up._omni_segment_generation = None
queued_before = len(adapter._pending_save_reqs)
adapter.save_async(multimodal_output=None, request=follow_up, is_segment_finished=False)
assert len(adapter._pending_save_reqs) == queued_before + 1
assert adapter._pending_save_reqs[-1]["request"] is follow_up
@@ -22,7 +22,7 @@ from vllm.inputs import TokensPrompt
from tests.helpers.clean import wait_for_gpu_memory_to_clear
from tests.helpers.mark import hardware_test
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
from tests.helpers.stage_config import get_deploy_config_path
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.platforms import current_omni_platform
@@ -52,26 +52,7 @@ def _colocate_async_deploy() -> str:
CI yaml plus CuMem on every stage fails engine-core startup in the L3
merge job: Stage 1 resolves ``devices: "1"`` while only GPU 0 is visible.
"""
return modify_stage_config(
get_deploy_config_path("qwen3_omni_moe_thinking.yaml"),
updates={
"stages": {
0: {
"enable_sleep_mode": True,
"enforce_eager": True,
"trust_remote_code": True,
"tensor_parallel_size": 1,
"devices": "0",
"gpu_memory_utilization": 0.9,
"max_num_seqs": 1,
"max_model_len": 8192,
"max_num_batched_tokens": 8192,
"enable_prefix_caching": False,
"skip_mm_profiling": True,
},
},
},
)
return get_deploy_config_path("ci/qwen3_omni_moe_colocate_async.yaml")
@pytest.mark.advanced_model
@@ -0,0 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""MiniMax online-serving tests."""
@@ -0,0 +1,369 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Shared request and server helpers for MiniMax-H3 L3 tests."""
from __future__ import annotations
import base64
import concurrent.futures
import io
import json
import os
from collections.abc import Callable
from pathlib import Path
from typing import Any
import av
import requests
from tests.helpers.assertions import assert_video_valid
from tests.helpers.media import generate_synthetic_image
from tests.helpers.runtime import OmniServerParams, OpenAIClientHandler
# DLO starts worker processes and uses shared-memory queues. Always select
# spawn for these cases so a parent fork cannot inherit stale resource-tracker
# state from a preceding server lifecycle.
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
MODEL = os.environ.get("VLLM_TEST_MINIMAX_H3_MODEL", "MiniMaxAI/MiniMax-H3")
WIDTH = 1344
HEIGHT = 768
FPS = 24
NUM_INFERENCE_STEPS = 4
# Keep a failed collective/preprocessing regression bounded well below the
# per-file Buildkite timeout. Healthy H100 runs complete in under two minutes
# after startup; ten minutes leaves ample room for a cold compile.
REQUEST_TIMEOUT_SECONDS = 600
# FastH3's four-step preview is a load-time fusion adapter. Its model-level
# deltas cannot use the dynamic PEFT LoRA manager, so this test deliberately
# skips on branches that predate PR #6714 instead of starting a base-H3 server.
_REPO_ROOT = Path(__file__).resolve().parents[4]
FASTH3_SUPPORTED = (_REPO_ROOT / "vllm_omni/diffusion/models/minimax_h3/fasth3.py").is_file()
# The Turbo artifact is handled by the MiniMax-specific loader introduced in
# PR #6550; the generic diffusion LoRA manager cannot parse that checkpoint.
TURBO_SUPPORTED = (_REPO_ROOT / "vllm_omni/diffusion/models/minimax_h3/lora.py").is_file()
FASTH3_WIDTH = 1024
FASTH3_HEIGHT = 576
FASTH3_DURATION = 4.4
DLO_SERVER_ARGS = [
"--trust-remote-code",
"--num-gpus",
"2",
"--tensor-parallel-size",
"1",
"--data-parallel-size",
"2",
"--request-batch-max-wait-ms",
"500",
"--usp",
"1",
"--ring",
"1",
"--text-encoder-tp-size",
"1",
"--vae-patch-parallel-size",
"1",
"--vae-parallel-mode",
"tile",
"--vae-use-tiling",
"--enable-distributed-layerwise-offload",
]
_FL2VA_IMAGE = base64.b64decode(generate_synthetic_image(WIDTH, HEIGHT, seed=42)["base64"])
# Keep the DLO/DP2 Ref2VA wave on the image-only path. The separate video
# reference path performs per-request ffmpeg/Qwen video preprocessing and has
# repeatedly stalled this merge wave before the first pipeline log; it remains
# covered by the Ref2VA contract/accuracy suites.
_REF2VA_IMAGE = base64.b64decode(generate_synthetic_image(512, 288, seed=43)["base64"])
def resolve_turbo_lora() -> str | None:
"""Resolve the legacy Turbo LoRA artifact without network at collection."""
configured = os.environ.get("VLLM_TEST_MINIMAX_H3_TURBO_LORA")
if configured and Path(configured).is_file():
return configured
try:
from huggingface_hub import hf_hub_download
return hf_hub_download(
repo_id="lightx2v/Minimax-h3-Turbo",
filename="minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors",
local_files_only=True,
)
except Exception:
return None
def resolve_fasth3_lora() -> str | None:
"""Resolve the dense/data-free FastH3 artifact without network at collection."""
configured = os.environ.get("VLLM_TEST_MINIMAX_H3_FASTH3_LORA")
if configured and Path(configured).is_file():
return configured
try:
from huggingface_hub import hf_hub_download
return hf_hub_download(
repo_id="FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA",
filename="dense-datafree/adapter_model.safetensors",
local_files_only=True,
)
except Exception:
# Buildkite downloads the artifact before pytest. Local collection
# must stay offline-friendly when that optional cache is absent.
return None
FASTH3_LORA = resolve_fasth3_lora()
TURBO_LORA = resolve_turbo_lora()
def _assert_audio_stream_present(video: bytes) -> None:
"""Assert that the generated MP4 contains decodable audio samples."""
with av.open(io.BytesIO(video)) as container:
audio_streams = [stream for stream in container.streams if stream.type == "audio"]
assert audio_streams, "MiniMax-H3 MP4 has no audio stream"
audio_frame = next(container.decode(audio=0), None)
assert audio_frame is not None and audio_frame.samples > 0, "MiniMax-H3 MP4 audio stream is empty"
def assert_h3_video(video: bytes, *, width: int, height: int) -> None:
"""Check the common H3 video/audio response contract."""
assert_video_valid(video, width=width, height=height, fps=FPS)
_assert_audio_stream_present(video)
def post_sync(
client: OpenAIClientHandler,
form_data: dict[str, str],
files: Any = None,
) -> bytes:
"""Submit one synchronous video request and return its MP4 body."""
response = requests.post(
f"{client.base_url.rstrip('/')}/v1/videos/sync",
data=form_data,
files=files,
headers={"Accept": "video/mp4"},
timeout=REQUEST_TIMEOUT_SECONDS,
)
response.raise_for_status()
assert response.headers.get("content-type", "").startswith("video/mp4")
assert response.content, "MiniMax-H3 returned an empty video body"
return response.content
def _h3_form(task: str, seed: int) -> dict[str, str]:
return {
"model": MODEL,
"prompt": (
"A cinematic live-action scene with a clear subject moving naturally; "
"the atmosphere includes synchronized environmental sound."
),
"width": str(WIDTH),
"height": str(HEIGHT),
"fps": str(FPS),
"num_inference_steps": str(NUM_INFERENCE_STEPS),
"flow_shift": "12",
"seed": str(seed),
"extra_params": json.dumps(
{
"task": task,
"duration": 4.0,
"aspect_ratio": "16:9",
"audio_flow_shift": 3.0,
},
separators=(",", ":"),
),
}
def run_fl2va(client: OpenAIClientHandler, seed: int) -> bytes:
return post_sync(
client,
_h3_form("fl2va", seed),
files=fl2va_files(),
)
def run_ref2va(client: OpenAIClientHandler, seed: int) -> bytes:
return post_sync(
client,
_h3_form("ref2va", seed),
files=[("input_references", ("reference.jpg", io.BytesIO(_REF2VA_IMAGE), "image/jpeg"))],
)
def fl2va_files() -> dict[str, tuple[str, io.BytesIO, str]]:
"""Return a fresh multipart first-frame payload for an FL2VA request."""
return {"input_reference": ("first_frame.jpg", io.BytesIO(_FL2VA_IMAGE), "image/jpeg")}
def run_dlo_wave(
client: OpenAIClientHandler,
request_fn: Callable[[OpenAIClientHandler, int], bytes],
) -> list[bytes]:
"""Run one complete two-request DLO/DP2 wave concurrently."""
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
return list(executor.map(request_fn, (client, client), (2101, 2102)))
def dlo_params(task_type: str) -> OmniServerParams:
return OmniServerParams(
model=MODEL,
server_args=[*DLO_SERVER_ARGS, "--task-type", task_type],
stage_init_timeout=1800,
init_timeout=1800,
)
def turbo_params(lora_path: str | None) -> OmniServerParams:
"""Build the two-card TP2 + DLO-no-AllGather Turbo layout."""
return OmniServerParams(
model=MODEL,
server_args=[
"--trust-remote-code",
"--task-type",
"fl2va",
"--num-gpus",
"2",
"--tensor-parallel-size",
"2",
"--usp",
"1",
"--ring",
"1",
"--text-encoder-tp-size",
"2",
"--vae-patch-parallel-size",
"2",
"--vae-parallel-mode",
"tile",
"--vae-use-tiling",
"--enable-distributed-layerwise-offload",
"--dlo-no-use-allgather",
"--lora-backend",
"peft",
"--lora-path",
lora_path or "",
],
stage_init_timeout=1800,
init_timeout=1800,
)
def turbo_form(seed: int) -> dict[str, str]:
"""Build the official five-sigma-point Turbo FL2VA request."""
return {
"model": MODEL,
"prompt": (
"A man stands beside a yellow car at night; the car drives away "
"as he follows it with his eyes and begins singing sadly."
),
"width": str(WIDTH),
"height": str(HEIGHT),
"fps": str(FPS),
# The public Turbo artifact has four denoiser evaluations but its API
# contract requests five sigma points.
"num_inference_steps": "5",
"flow_shift": "6",
"seed": str(seed),
"extra_params": json.dumps(
{"task": "fl2va", "duration": FASTH3_DURATION, "aspect_ratio": "16:9", "audio_flow_shift": 3.0},
separators=(",", ":"),
),
"lora": json.dumps(
{"name": "h3-turbo-v1.0", "path": TURBO_LORA, "scale": 1.0},
separators=(",", ":"),
),
}
def fasth3_params(lora_path: str | None) -> OmniServerParams:
"""Build the requested two-card FastH3 parallel layout.
HSDP owns DiT sharding (the model tensor-parallel degree stays at its
default 1), USP2 shards the sequence, text-encoder TP2 shards Qwen3-VL,
and VAE patch parallel 2 is the project's VPP2 setting.
"""
return OmniServerParams(
model=MODEL,
server_args=[
"--trust-remote-code",
"--task-type",
"fl2va",
"--num-gpus",
"2",
"--usp",
"2",
"--ring",
"1",
"--use-hsdp",
"--hsdp-shard-size",
"2",
"--text-encoder-tp-size",
"2",
"--vae-patch-parallel-size",
"2",
"--vae-parallel-mode",
"tile",
"--vae-use-tiling",
"--lora-path",
lora_path or "",
],
stage_init_timeout=1800,
init_timeout=1800,
)
def fasth3_form(seed: int) -> dict[str, str]:
"""Build the four-step T2VA request accepted by the FastH3 preview."""
return {
"model": MODEL,
"prompt": (
"A cinematic live-action scene with a clear subject moving naturally; "
"the atmosphere includes synchronized environmental sound."
),
"width": str(FASTH3_WIDTH),
"height": str(FASTH3_HEIGHT),
"fps": str(FPS),
"num_inference_steps": "4",
"seed": str(seed),
# FastH3 is fused at startup, so a request-level `lora` field is both
# unnecessary and rejected by the load-time adapter contract.
"extra_params": json.dumps(
{
"task": "t2va",
"duration": FASTH3_DURATION,
"aspect_ratio": "16:9",
},
separators=(",", ":"),
),
}
__all__ = [
"FASTH3_LORA",
"FASTH3_SUPPORTED",
"FASTH3_DURATION",
"FASTH3_HEIGHT",
"FASTH3_WIDTH",
"FPS",
"MODEL",
"assert_h3_video",
"dlo_params",
"fasth3_form",
"fasth3_params",
"fl2va_files",
"post_sync",
"run_dlo_wave",
"run_fl2va",
"run_ref2va",
"TURBO_LORA",
"TURBO_SUPPORTED",
"turbo_form",
"turbo_params",
]
@@ -0,0 +1,27 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""MiniMax-H3 FL2VA DLO + DP2 L3 online-serving case."""
from __future__ import annotations
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import OmniServer, OpenAIClientHandler
from ._common import assert_h3_video, dlo_params, run_dlo_wave, run_fl2va
pytestmark = [pytest.mark.core_model, pytest.mark.advanced_model, pytest.mark.diffusion, pytest.mark.slow]
H100_TWO_CARD_MARKS = hardware_marks(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize(
"omni_server",
[pytest.param(dlo_params("fl2va"), id="minimax_h3_dlo_dp2_fl2va", marks=H100_TWO_CARD_MARKS)],
indirect=True,
)
def test_minimax_h3_dlo_dp2_fl2va(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
"""Validate a complete concurrent DLO/DP2 wave with two FL2VA jobs."""
for video in run_dlo_wave(openai_client, run_fl2va):
assert_h3_video(video, width=1344, height=768)
@@ -0,0 +1,27 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""MiniMax-H3 Ref2VA DLO + DP2 L3 online-serving case."""
from __future__ import annotations
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import OmniServer, OpenAIClientHandler
from ._common import assert_h3_video, dlo_params, run_dlo_wave, run_ref2va
pytestmark = [pytest.mark.core_model, pytest.mark.advanced_model, pytest.mark.diffusion, pytest.mark.slow]
H100_TWO_CARD_MARKS = hardware_marks(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize(
"omni_server",
[pytest.param(dlo_params("ref2va"), id="minimax_h3_dlo_dp2_ref2va", marks=H100_TWO_CARD_MARKS)],
indirect=True,
)
def test_minimax_h3_dlo_dp2_ref2va(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
"""Validate a complete concurrent DLO/DP2 wave with two image-only Ref2VA jobs."""
for video in run_dlo_wave(openai_client, run_ref2va):
assert_h3_video(video, width=1344, height=768)
@@ -0,0 +1,56 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""MiniMax-H3 FastH3 four-step LoRA L3 online-serving case."""
from __future__ import annotations
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import OmniServer, OpenAIClientHandler
from ._common import (
FASTH3_HEIGHT,
FASTH3_LORA,
FASTH3_SUPPORTED,
FASTH3_WIDTH,
assert_h3_video,
fasth3_form,
fasth3_params,
post_sync,
)
pytestmark = [pytest.mark.core_model, pytest.mark.advanced_model, pytest.mark.diffusion, pytest.mark.slow]
H100_TWO_CARD_MARKS = hardware_marks(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.skipif(not FASTH3_SUPPORTED, reason="requires FastH3 support from MiniMax-H3 PR #6714")
@pytest.mark.skipif(
FASTH3_LORA is None,
reason="set VLLM_TEST_MINIMAX_H3_FASTH3_LORA or populate the local HF cache",
)
@pytest.mark.parametrize(
"omni_server",
[
pytest.param(
fasth3_params(FASTH3_LORA),
id="minimax_h3_fasth3_lora_hsdp2_usp2_vpp2",
marks=H100_TWO_CARD_MARKS,
)
],
indirect=True,
)
def test_minimax_h3_fasth3_lora_hsdp2_usp2_vpp2(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
) -> None:
"""Run the dense/data-free FastH3 adapter on two H100-class cards.
The preview adapter is T2VA-only and must be fused through ``--lora-path``;
it is not a request-switchable PEFT adapter. 1024x576 is intentionally
below the 1344x768 H3 reference shape to stay inside an 80 GiB H100 after
regional compile cache growth.
"""
video = post_sync(openai_client, fasth3_form(seed=3101))
assert_h3_video(video, width=FASTH3_WIDTH, height=FASTH3_HEIGHT)
@@ -0,0 +1,56 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""MiniMax-H3 Turbo LoRA + DLO L3 online-serving case."""
from __future__ import annotations
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import OmniServer, OpenAIClientHandler
from ._common import (
TURBO_LORA,
TURBO_SUPPORTED,
assert_h3_video,
fl2va_files,
post_sync,
turbo_form,
turbo_params,
)
pytestmark = [pytest.mark.core_model, pytest.mark.advanced_model, pytest.mark.diffusion, pytest.mark.slow]
H100_TWO_CARD_MARKS = hardware_marks(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.skipif(
not TURBO_SUPPORTED,
reason="requires the MiniMax-H3 Turbo loader from PR #6550",
)
@pytest.mark.skipif(
TURBO_LORA is None,
reason="set VLLM_TEST_MINIMAX_H3_TURBO_LORA or populate the local HF cache",
)
@pytest.mark.parametrize(
"omni_server",
[
pytest.param(
turbo_params(TURBO_LORA),
id="minimax_h3_dlo_turbo_lora_tp2",
marks=H100_TWO_CARD_MARKS,
)
],
indirect=True,
)
def test_minimax_h3_dlo_turbo_lora_fl2va(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
) -> None:
"""Validate the official four-evaluation Turbo LoRA + DLO FL2VA request."""
video = post_sync(
openai_client,
turbo_form(seed=2201),
files=fl2va_files(),
)
assert_h3_video(video, width=1344, height=768)
@@ -1,15 +1,15 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
Online serving tests for Boogu-Image-0.1-Edit (image-to-image via chat completions).
Online serving tests for Boogu-Image-0.1-Edit and Edit-Turbo.
The Edit checkpoint shares the ``BooguImagePipeline`` class with the Base
Both Edit checkpoints share the ``BooguImagePipeline`` class with the Base
text-to-image checkpoint; the editing (TI2I) path activates when the request
carries a reference image. Text guidance rides on ``guidance_scale`` (upstream
default 4.0), and image guidance rides on ``guidance_scale_2`` (upstream default
1.0 = off; a value > 1 enables the double-guidance path). Only a single
reference image is supported.
carries a reference image. The regular Edit checkpoint uses text guidance via
``guidance_scale`` and optional image guidance via ``guidance_scale_2``.
Edit-Turbo is guidance-distilled and uses four steps with guidance scale 1.0.
Only a single reference image is supported.
- ``test_single_image_to_image_001``: one reference image, text guidance only.
- ``test_double_guidance_001``: one reference image, text + image guidance.
@@ -40,6 +40,10 @@ pytestmark = [pytest.mark.diffusion, pytest.mark.slow]
EDIT_PROMPT = "Change the style to a colored pencil drawing."
SINGLE_CARD_FEATURE_MARKS = hardware_marks(res={"cuda": "H100"})
EDIT_MODEL = "Boogu/Boogu-Image-0.1-Edit"
EDIT_TURBO_MODEL = "Boogu/Boogu-Image-0.1-Edit-Turbo"
EDIT_TURBO_REVISION = "hotfix-1k-20260708"
def _get_diffusion_feature_cases(model: str):
"""Return one ``default`` ``OmniServerParams`` row for ``model`` (no extra ``server_args``)."""
@@ -54,13 +58,44 @@ def _get_diffusion_feature_cases(model: str):
]
def _get_single_edit_cases():
"""Return the regular and distilled Edit server/request configurations."""
return [
pytest.param(
OmniServerParams(model=EDIT_MODEL),
{
"num_inference_steps": 2,
"guidance_scale": 5.0,
},
id="edit",
marks=SINGLE_CARD_FEATURE_MARKS,
),
pytest.param(
OmniServerParams(
model=EDIT_TURBO_MODEL,
server_args=["--revision", EDIT_TURBO_REVISION],
),
{
"num_inference_steps": 4,
"guidance_scale": 1.0,
},
id="edit-turbo-hotfix-1k",
marks=SINGLE_CARD_FEATURE_MARKS,
),
]
@pytest.mark.parametrize(
"omni_server",
_get_diffusion_feature_cases("Boogu/Boogu-Image-0.1-Edit"),
indirect=True,
("omni_server", "sampling_params"),
_get_single_edit_cases(),
indirect=["omni_server"],
)
def test_single_image_to_image_001(omni_server: OmniServer, openai_client: OpenAIClientHandler):
"""Single-reference text-guided edit smoke for ``Boogu/Boogu-Image-0.1-Edit``."""
def test_single_image_to_image_001(
omni_server: OmniServer,
sampling_params: dict,
openai_client: OpenAIClientHandler,
):
"""Single-reference text-guided edit smoke for regular and Turbo Edit."""
image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(512, 512)['base64']}"
messages = dummy_messages_from_mix_data(image_data_url=image_data_url, content_text=EDIT_PROMPT)
@@ -69,9 +104,7 @@ def test_single_image_to_image_001(omni_server: OmniServer, openai_client: OpenA
"model": omni_server.model,
"messages": messages,
"extra_body": {
"num_inference_steps": 2,
# Boogu text guidance (upstream default 4.0); > 1 enables CFG.
"guidance_scale": 5.0,
**sampling_params,
"seed": 42,
},
}
@@ -81,7 +114,7 @@ def test_single_image_to_image_001(omni_server: OmniServer, openai_client: OpenA
@pytest.mark.parametrize(
"omni_server",
_get_diffusion_feature_cases("Boogu/Boogu-Image-0.1-Edit"),
_get_diffusion_feature_cases(EDIT_MODEL),
indirect=True,
)
def test_double_guidance_001(omni_server: OmniServer, openai_client: OpenAIClientHandler):
@@ -105,15 +138,20 @@ def test_double_guidance_001(omni_server: OmniServer, openai_client: OpenAIClien
openai_client.send_diffusion_request(request_config)
def _edits_request_config(model: str, *, guidance_scale_2: float | None = None) -> dict:
def _edits_request_config(
model: str,
*,
num_inference_steps: int = 2,
guidance_scale: float = 5.0,
guidance_scale_2: float | None = None,
) -> dict:
"""Multipart ``/v1/images/edits`` config: one synthetic reference + smoke-depth params."""
image_bytes = base64.b64decode(generate_synthetic_image(512, 512)["base64"])
data = {
"model": model,
"prompt": EDIT_PROMPT,
"num_inference_steps": 2,
# Boogu text guidance (upstream default 4.0); > 1 enables CFG.
"guidance_scale": 5.0,
"num_inference_steps": num_inference_steps,
"guidance_scale": guidance_scale,
"seed": 42,
}
if guidance_scale_2 is not None:
@@ -133,19 +171,25 @@ def _assert_edits_response_has_image(responses) -> None:
@pytest.mark.parametrize(
"omni_server",
_get_diffusion_feature_cases("Boogu/Boogu-Image-0.1-Edit"),
indirect=True,
("omni_server", "sampling_params"),
_get_single_edit_cases(),
indirect=["omni_server"],
)
def test_images_edits_endpoint_001(omni_server: OmniServer, openai_client: OpenAIClientHandler):
"""Single-reference text-guided edit via the OpenAI-compatible ``/v1/images/edits`` endpoint."""
responses = openai_client.send_images_edits_http_request(_edits_request_config(omni_server.model))
def test_images_edits_endpoint_001(
omni_server: OmniServer,
sampling_params: dict,
openai_client: OpenAIClientHandler,
):
"""Single-reference regular and Turbo Edit via ``/v1/images/edits``."""
responses = openai_client.send_images_edits_http_request(
_edits_request_config(omni_server.model, **sampling_params)
)
_assert_edits_response_has_image(responses)
@pytest.mark.parametrize(
"omni_server",
_get_diffusion_feature_cases("Boogu/Boogu-Image-0.1-Edit"),
_get_diffusion_feature_cases(EDIT_MODEL),
indirect=True,
)
def test_images_edits_endpoint_double_guidance_001(omni_server: OmniServer, openai_client: OpenAIClientHandler):
@@ -1,8 +1,8 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
L4 expansion coverage for ``Boogu/Boogu-Image-0.1-Edit`` (image editing / TI2I).
L4 expansion coverage for Boogu-Image Edit and Edit-Turbo (image editing / TI2I).
The nightly smoke module (``test_boogu_image_edit.py``) already covers single-image
text-guided editing and the text+image double-guidance path. This expansion
@@ -16,11 +16,12 @@ module adds the two edit paths smoke does not exercise:
``height`` / ``width``: asserts the output resolution follows the reference
(upstream ``align_res``, on by default for a single reference).
The Edit checkpoint shares ``BooguImagePipeline`` with the Base checkpoint; the
TI2I path activates when the request carries a reference image. Only a single
reference image is supported. Cases stay smoke-depth (``num_inference_steps=2``).
CPU offload, Cache-DiT, and multi-GPU parallelism are not yet supported for this
model and are intentionally omitted.
Both Edit checkpoints share ``BooguImagePipeline`` with the Base checkpoint;
the TI2I path activates when the request carries a reference image. Only a
single reference image is supported. The regular Edit cases stay smoke-depth
(``num_inference_steps=2``), while Edit-Turbo uses its tuned four-step
configuration. CPU offload, Cache-DiT, and multi-GPU parallelism are not yet
supported for these models and are intentionally omitted.
From ``tests/``::
@@ -41,6 +42,8 @@ os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
pytestmark = [pytest.mark.diffusion, pytest.mark.slow]
MODEL = "Boogu/Boogu-Image-0.1-Edit"
EDIT_TURBO_MODEL = "Boogu/Boogu-Image-0.1-Edit-Turbo"
EDIT_TURBO_REVISION = "hotfix-1k-20260708"
EDIT_PROMPT = "Change the style to a colored pencil drawing."
SINGLE_CARD_FEATURE_MARKS = hardware_marks(res={"cuda": "H100"})
@@ -57,6 +60,33 @@ def _get_diffusion_feature_cases(model: str):
]
def _get_align_res_cases():
"""Return regular and Turbo Edit configurations for align-res coverage."""
return [
pytest.param(
OmniServerParams(model=MODEL),
{
"num_inference_steps": 2,
"guidance_scale": 5.0,
},
id="edit",
marks=SINGLE_CARD_FEATURE_MARKS,
),
pytest.param(
OmniServerParams(
model=EDIT_TURBO_MODEL,
server_args=["--revision", EDIT_TURBO_REVISION],
),
{
"num_inference_steps": 4,
"guidance_scale": 1.0,
},
id="edit-turbo-hotfix-1k",
marks=SINGLE_CARD_FEATURE_MARKS,
),
]
@pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True)
def test_image_only_guidance(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
"""Image-only guidance: text CFG off (1.0), image CFG on (2.0) -> image-only CFG branch."""
@@ -76,8 +106,16 @@ def test_image_only_guidance(omni_server: OmniServer, openai_client: OpenAIClien
openai_client.send_diffusion_request(request_config)
@pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True)
def test_align_res_output_size(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
@pytest.mark.parametrize(
("omni_server", "sampling_params"),
_get_align_res_cases(),
indirect=["omni_server"],
)
def test_align_res_output_size(
omni_server: OmniServer,
sampling_params: dict,
openai_client: OpenAIClientHandler,
) -> None:
"""Output resolution follows the reference image (upstream ``align_res``).
No ``height`` / ``width`` is requested. The reference is 512x384 — both
@@ -92,8 +130,7 @@ def test_align_res_output_size(omni_server: OmniServer, openai_client: OpenAICli
"model": omni_server.model,
"messages": messages,
"extra_body": {
"num_inference_steps": 2,
"guidance_scale": 5.0,
**sampling_params,
"seed": 42,
},
}
+96 -1
View File
@@ -1,12 +1,20 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""E2E online tests for MiniCPM-o 4.5 multimodal input and audio/text output.
Exercises async chunk streaming (``--async-chunk``) across separate Thinker,
Talker, and Code2Wav stages.
"""
import json
import os
import tempfile
from copy import deepcopy
from pathlib import Path
import pytest
from vllm.logger import DEFAULT_LOGGING_CONFIG
from tests.helpers.mark import hardware_test
from tests.helpers.media import generate_synthetic_audio, generate_synthetic_image, generate_synthetic_video
@@ -19,10 +27,36 @@ _MODEL = "openbmb/MiniCPM-o-4_5"
_CI_DEPLOY = modify_stage_config(
get_deploy_config_path("minicpmo_4_5.yaml"),
updates={
"stages": {0: {"default_sampling_params.max_tokens": 64}, 1: {"default_sampling_params.max_tokens": 1024}}
"stages": {
0: {"default_sampling_params.max_tokens": 64},
1: {
"default_sampling_params.max_tokens": 1024,
# Content-consistency assertions must not depend on a random
# codec trajectory: different valid TTS samples can append an
# audible tail even when Thinker text is identical.
"default_sampling_params.temperature": 0.0,
},
},
},
)
_PROMPT_LOG_TEMP_DIR = tempfile.TemporaryDirectory(prefix="minicpmo45-prompt-selection-")
_PROMPT_SELECTION_LOG = Path(_PROMPT_LOG_TEMP_DIR.name) / "prompt-selection.log"
_PROMPT_LOGGING_CONFIG_PATH = Path(_PROMPT_LOG_TEMP_DIR.name) / "logging.json"
_PROMPT_LOGGING_CONFIG = deepcopy(DEFAULT_LOGGING_CONFIG)
_PROMPT_LOGGING_CONFIG["handlers"]["minicpmo_prompt_selection"] = {
"class": "logging.FileHandler",
"filename": str(_PROMPT_SELECTION_LOG),
"formatter": "vllm",
"level": "DEBUG",
}
_PROMPT_LOGGING_CONFIG["loggers"]["vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_code2wav"] = {
"handlers": ["minicpmo_prompt_selection"],
"level": "DEBUG",
"propagate": True,
}
_PROMPT_LOGGING_CONFIG_PATH.write_text(json.dumps(_PROMPT_LOGGING_CONFIG), encoding="utf-8")
test_params = [
pytest.param(
OmniServerParams(
@@ -30,6 +64,10 @@ test_params = [
stage_config_path=_CI_DEPLOY,
use_stage_cli=False,
server_args=["--trust-remote-code", "--async-chunk"],
env_dict={
"VLLM_CONFIGURE_LOGGING": "1",
"VLLM_LOGGING_CONFIG_PATH": str(_PROMPT_LOGGING_CONFIG_PATH),
},
),
id="async_chunk",
),
@@ -74,6 +112,18 @@ _TTS_EXTRA_BODY = {
}
def _prompt_selection_log_offset() -> int:
return _PROMPT_SELECTION_LOG.stat().st_size if _PROMPT_SELECTION_LOG.exists() else 0
def _read_prompt_selection_log(offset: int) -> str:
if not _PROMPT_SELECTION_LOG.exists():
return ""
with open(_PROMPT_SELECTION_LOG, "rb") as log_file:
log_file.seek(offset)
return log_file.read().decode("utf-8")
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.omni
@@ -126,6 +176,51 @@ def test_text_to_audio_001(omni_server, openai_client) -> None:
openai_client.send_omni_request(request_config)
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_text_to_audio_with_reference_audio(omni_server, openai_client) -> None:
request_config = {
"model": omni_server.model,
"messages": dummy_messages_from_mix_data(system_prompt=get_system_prompt(), content_text=get_prompt()),
"stream": True,
"modalities": ["text", "audio"],
"extra_body": {
**_TTS_EXTRA_BODY,
"ref_audio": f"data:audio/wav;base64,{generate_synthetic_audio(5, 1)['base64']}",
},
}
log_offset = _prompt_selection_log_offset()
responses = openai_client.send_omni_request(request_config)
prompt_log = _read_prompt_selection_log(log_offset)
assert responses[0].success
assert responses[0].audio_bytes
assert "prompt_cache_id=runtime-ref-" in prompt_log
assert "minicpmo45_ref_" in prompt_log
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_text_to_audio_with_default_reference(omni_server, openai_client) -> None:
request_config = {
"model": omni_server.model,
"messages": dummy_messages_from_mix_data(system_prompt=get_system_prompt(), content_text=get_prompt()),
"stream": True,
"modalities": ["text", "audio"],
"extra_body": _TTS_EXTRA_BODY,
}
responses = openai_client.send_omni_request(request_config)
assert responses[0].success
assert responses[0].audio_bytes
@pytest.mark.full_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@@ -18,6 +18,17 @@ from tests.helpers.stage_config import get_deploy_config_path
MODEL = os.environ.get("VLLM_TEST_NEMOTRON_VOICECHAT_MODEL", "nvidia/NVIDIA-NemotronLabs-VoiceChat-11B")
DEPLOY_CONFIG = get_deploy_config_path("nemotron_labs_voicechat_duplex.yaml")
TOKENIZER = os.environ.get("VLLM_TEST_NEMOTRON_VOICECHAT_LLM_PATH")
# The client gives up after CLIENT_TIMEOUT_S. The server-side stage-input
# deadline must fire first, otherwise a stalled stream surfaces as a bare
# client TimeoutError with no server-side error at all. See #6816.
CLIENT_TIMEOUT_S = 300
SERVER_INPUT_WAIT_TIMEOUT_S = 240
_SERVER_ENV: dict[str, str] = {"VLLM_OMNI_INPUT_WAIT_TIMEOUT_S": str(SERVER_INPUT_WAIT_TIMEOUT_S)}
if TOKENIZER:
_SERVER_ENV["NEMOTRON_VOICECHAT_LLM_PATH"] = TOKENIZER
pytestmark = [pytest.mark.full_model, pytest.mark.omni]
@@ -29,7 +40,7 @@ pytestmark = [pytest.mark.full_model, pytest.mark.omni]
OmniServerParams(
model=MODEL,
stage_config_path=DEPLOY_CONFIG,
env_dict={"NEMOTRON_VOICECHAT_LLM_PATH": TOKENIZER} if TOKENIZER else None,
env_dict=_SERVER_ENV,
),
id="native-duplex",
)
@@ -45,7 +56,7 @@ def test_native_duplex_turn_taking_streams_model_audio(omni_server, tmp_path: Pa
(
f"--url ws://{omni_server.host}:{omni_server.port}/v1/realtime --model {omni_server.model} "
f"--input-wav {root / 'turn_taking.wav'} --input-channel 0 --max-frames 190 "
f"--minimum-audio-chunks 48 --minimum-audio-rms 0.001 --no-realtime --timeout-s 300 "
f"--minimum-audio-chunks 48 --minimum-audio-rms 0.001 --no-realtime --timeout-s {CLIENT_TIMEOUT_S} "
f"--output-dir {tmp_path / 'native_duplex'}"
).split()
)
@@ -55,7 +55,6 @@ tts_server_params = [
@pytest.mark.advanced_model
@pytest.mark.core_model
@pytest.mark.tts
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
@@ -138,6 +138,23 @@ def test_voice_003(omni_server, online_client) -> None:
online_client.send_audio_speech_request(request_config)
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
def test_sample_rate_001(omni_server, online_client) -> None:
"""The speech API resamples Qwen3-TTS output from native 24 kHz to 8 kHz."""
request_config = {
"model": omni_server.model,
"input": "This response should be encoded as eight kilohertz audio.",
"stream": False,
"response_format": "wav",
"sample_rate": 8000,
"task_type": "CustomVoice",
"voice": "vivian",
}
online_client.send_audio_speech_request(request_config)
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
def test_language_001(omni_server, online_client) -> None:
+22
View File
@@ -286,6 +286,28 @@ def test_qwen3_tts_code2wav_injects_max_position_embeddings(monkeypatch):
}
def test_remote_tokenizer_subfolder_download_does_not_report_failure(tmp_path, monkeypatch, mocker):
"""A successful remote tokenizer download must not enter the error path."""
tokenizer_dir = tmp_path / "CosyVoice-BlankEN"
tokenizer_dir.mkdir()
baseline_config = Mock()
warning = mocker.patch("vllm_omni.engine.arg_utils.logger.warning")
monkeypatch.setattr("huggingface_hub.snapshot_download", lambda *args, **kwargs: str(tmp_path))
monkeypatch.setattr(OmniEngineArgs, "_patch_empty_hf_config", lambda *args, **kwargs: None)
monkeypatch.setattr(EngineArgs, "create_model_config", lambda _self: baseline_config)
monkeypatch.setattr(
OmniModelConfig,
"from_vllm_model_config",
classmethod(lambda cls, model_config, **omni_kwargs: model_config),
)
args = OmniEngineArgs(model="remote/cosyvoice3", model_arch="CosyVoice3Model")
assert args.create_model_config() is baseline_config
assert args.tokenizer == str(tokenizer_dir)
warning.assert_not_called()
def test_patch_missing_local_hf_config(tmp_path):
"""Native model bundles may only contain a checkpoints/ directory."""
(tmp_path / "checkpoints").mkdir()
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff. Show more