Compare commits

...
Author SHA1 Message Date
hsliuustc0106 9a7ea9b003 [Bugfix] Reconcile env-var inventory with post-#6217 main drift
Merge-window drift broke the new environment-variable ratchet on main
(build 14119): #6255 added the VLLM_OMNI_ASYNC_OUTPUT_TIMEOUT read in
the diffusion engine and #6362 removed the last package reference to
ROSVOT_SOURCE_DIR, both after #6217's CI run.

- Classify VLLM_OMNI_ASYNC_OUTPUT_TIMEOUT as PUBLIC_OMNI and document
  it in the environment-variables reference page.
- Drop the stale ROSVOT_SOURCE_DIR model-specific row now that the
  SoulX-Singer implementation is gone.
- Update the reviewed snapshot counts and disposition tables to match.

Signed-off-by: hsliuustc0106 <liuhongsheng4@huawei.com>
2026-08-26 06:06:04 +08:00
Hongsheng Liu 780e8d40aa [Doc] Add environment variables configuration reference (#6217)
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-26 04:58:54 +08:00
Satellite 6639c82a17 [CI/Build] Pin GGUF plugin for diffusion nightly tests (#6303)
Signed-off-by: Xin Wei <14196318+wxwxwwxxx@users.noreply.github.com>
2026-08-25 09:16:46 -07:00
Alex Brooks 0eb47b76d0 [Bugfix] Handle DoS Overflow Cases (#6598)
Signed-off-by: Alex Brooks <albrooks@redhat.com>
2026-08-25 16:09:03 +00:00
NATURE 497c537c6f [Benchmark] Add local OmniInteract realtime benchmark (#6522)
Signed-off-by: natureofnature <wzliu@connect.hku.hk>
2026-08-25 15:32:58 +00:00
Hongsheng Liuandhsliuustc0106 c608910d20 [Diffusion] Register HWR mmap for direct DLO H2D (#6591)
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Signed-off-by: hsliuustc0106 <hsliuustc0106@users.noreply.github.com>
Co-authored-by: hsliuustc0106 <hsliuustc0106@users.noreply.github.com>
2026-08-25 23:30:11 +08:00
Hongsheng Liu 5a8b81ebeb [Model] Optimize MiniMax-H3 DLO component lifecycle (#6526)
Signed-off-by: hsliuustc0106 <liuhongsheng4@huawei.com>
2026-08-25 23:13:41 +08:00
NumberWan dd527b4245 [CI] Reduce Qwen-Image Function and share step-execution perf server (#6613)
Signed-off-by: NumberWan <wantszkin2003@gmail.com>
2026-08-25 22:55:15 +08:00
jl9876andChendi.Xue 0d5d1115d6 [AutoRound] Add offline quantized MXFP4 model support (#5544)
Signed-off-by: jl9876 <jia.li@intel.com>
Co-authored-by: Chendi.Xue <chendi.xue@intel.com>
2026-08-25 14:44:38 +00:00
psv666 683f3c92a4 [CI/Build] Clean up Qwen3-Omni nightly tests (#6570)
Signed-off-by: psv666 <2693925048@qq.com>
2026-08-25 22:14:43 +08:00
Jae 72beeaee86 [Misc] move text-to-audio online examples to a unified folder (#4807)
Signed-off-by: zzehli <jaeli_ottawa@outlook.com>
2026-08-25 22:02:26 +08:00
Bhavyansh Sharma d6b66faba7 [CI] Fix response_format json_schema error expectation (#6290)
Signed-off-by: Bhavyansh Sharma <66562679+clumsylad21@users.noreply.github.com>
2026-08-25 19:47:09 +08:00
NumberWan bc8ea489ef [BugFix] Qwen-image performance regressed - Avoid mapping diffusion_batch_size onto scheduler max_num_seqs (#6525)
Signed-off-by: NumberWan <wantszkin2003@gmail.com>
2026-08-25 03:36:11 -07:00
zhumingjue138andGao Han 29e3165bb9 [Test] add marker for stability and add Wan2.2 npu test case (#6343)
Signed-off-by: zhumingjue <zhumingjue@huawei.com>
Co-authored-by: Gao Han <hgaoaf@connect.ust.hk>
2026-08-25 17:08:58 +08:00
Bo Li 51b7565f33 [perf] Add mask-free TRTLLM packed-padding path (#6542)
Signed-off-by: Bo Li <22713281+bobboli@users.noreply.github.com>
2026-08-25 07:45:21 +00:00
wy17003andHongsheng Liu b454e34966 [Refactor][2/N Scheduler]Replace _FULL_PAYLOAD_INPUT_STAGES with a resolved stage transport capability. (#6149)
Signed-off-by: wy17003 <yaow17003@gmail.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-25 15:24:59 +08:00
Nguyen Kha Nhat LongandSamit bae58f84b8 [minor, fix] Remove cuda sync on wake_up in AsyncOmni (#4092)
Signed-off-by: knlnguyen1802 <knlnguyen1802@gmail.com>
Signed-off-by: Samit <285365963@qq.com>
Co-authored-by: Samit <285365963@qq.com>
2026-08-25 03:13:42 +00:00
f0808c9793 [Bugfix] Fix remote replica membership lifecycle races (#5277)
Signed-off-by: hbhflw2000 <417911774@qq.com>
Co-authored-by: Gao Han <hgaoaf@connect.ust.hk>
Co-authored-by: Samit <285365963@qq.com>
2026-08-25 10:34:59 +08:00
NATURE 702a409664 [Feature][MiniCPM-o] Support configurable concurrent duplex sessions (#6021)
Signed-off-by: natureofnature <wzliu@connect.hku.hk>
2026-08-25 02:27:30 +00:00
yzong-rh 710024fa6f [Bugfix] Honor height/width overrides in LongCatImageEditPipeline (#6222)
Signed-off-by: Yifan Zong <yzong@redhat.com>
2026-08-25 02:09:00 +00:00
Pranav ThakurandZhou Taichang 53b60bd9bc [Bugfix] Invalidate stale ref_audio cache on local file modification (#4873) (#5670)
Signed-off-by: pranavthakur0-0 <pranavthakurdew@gmail.com>
Co-authored-by: Zhou Taichang <tzhouam@connect.ust.hk>
2026-08-25 10:06:32 +08:00
leoandleo 50b8a150f9 [Model] Add native SANA-Video 2B T2V and I2V support (#5508)
Signed-off-by: leo <92263211+napleon-liu@users.noreply.github.com>
Signed-off-by: leo <92263211+liuyao0322@users.noreply.github.com>
Signed-off-by: liuyao0322 <yaoliu548926@gmail.com>
Co-authored-by: leo <92263211+napleon-liu@users.noreply.github.com>
2026-08-25 09:46:02 +08:00
wangyuandamy-why-3459 4af0ddc1c2 [Test] Cleanup for helpers (#6523)
Signed-off-by: wangyu <410167048@qq.com>
Signed-off-by: [Your Name] <your.email@example.com>
Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-08-25 09:10:17 +08:00
Hongsheng Liuandhsliuustc0106 8360346a66 [Diffusion] Integrate no-AllGather DLO with Host Weight Runtime (#6486)
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Signed-off-by: hsliuustc0106 <hsliuustc0106@users.noreply.github.com>
Co-authored-by: hsliuustc0106 <hsliuustc0106@users.noreply.github.com>
2026-08-25 08:03:53 +08:00
Hongsheng Liu 8fdb719a41 [CI] Resume AR admission after sleep-mode wake (#6581)
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-25 07:18:05 +08:00
Hongsheng Liu 678aa25864 Revert "Revert "[CI]Fix Wan DMD pipeline test alignment"" (#6578) 2026-08-24 23:40:01 +08:00
Gao Han a94b2580a1 Revert "[CI]Fix Wan DMD pipeline test alignment" (#6574) 2026-08-24 22:26:55 +08:00
d150a4fde7 [Feature][MiniMax-H3] Support diffusion continuous batching (#5810)
Signed-off-by: princepride <wangzhipeng628@gmail.com>
Signed-off-by: lishunyang12 <lishunyang12@163.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: lishunyang12 <lishunyang12@163.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 21:08:53 +08:00
Mu GuanLinandHongsheng Liu aa69acbd09 [Bugfix] Prevent LTX-2.5 from silently loading unindexed Diffusers shards (#6234)
Signed-off-by: mglyn <1203789601@qq.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-24 20:57:24 +08:00
bjf-frz 0e4b28f491 [CI]Fix Wan DMD pipeline test alignment (#6557)
Signed-off-by: bjf-frz <frz123db@gmail.com>
2026-08-24 17:43:24 +08:00
WeiQing Chen f446468c03 [Doc] Update vLLM-Omni WeChat QR code (#6535)
Signed-off-by: David Chen <530634352@qq.com>
2026-08-24 16:46:02 +08:00
Nguyen Kha Nhat LongandCursor d15b7ca3e1 [Feature]: Support pause / resume and sleep / wake for AR stages in AsyncOmni (#6084)
Signed-off-by: knlnguyen1802 <knlnguyen1802@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 16:21:32 +08:00
373 changed files with 26225 additions and 6804 deletions

No files matched your search

+2 -2
View File
@@ -83,7 +83,7 @@ steps:
- export BENCHMARK_DIR=tests/dfx/perf/results
- |
set +e
pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json
pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json -m "H100 and full_model"
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
exit $$EXIT
@@ -375,7 +375,7 @@ steps:
- label: ":full_moon: Diffusion · Quantization Test with L4"
timeout_in_minutes: 60
commands:
- pip install "vllm-gguf-plugin>=0.0.3"
- pip install "vllm-gguf-plugin==0.0.4"
- pytest -sv tests/diffusion/quantization -m 'full_model and cuda and L4' --run-level "full_model"
mirror_hardwares: l4_1
+2 -26
View File
@@ -125,32 +125,8 @@ steps:
- vllm_omni/outputs/
commands:
- timeout 20m pytest -s -v tests/e2e/features/custom_pipeline/ -m "core_model"
agents:
queue: "gpu_1_queue"
plugins:
- docker#v5.2.0:
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
always-pull: true
propagate-environment: true
# vLLM 0.27 added a pre-flight capacity check in
# distributed/device_communicators/shm_broadcast.py. Without this
# the container gets Docker's 64 MiB default and every test in
# this step dies at engine start with
# RuntimeError: Insufficient space in /dev/shm:
# 240 MiB required, 64 MiB available
# (9 failed in 8.7s on build #2949). vLLM 0.26 had no such check
# and allocated regardless, so this step passed on main while
# being under-provisioned the whole time — 0.27 turned a silent
# risk into an explicit error.
#
# This is the only inline docker plugin block in .buildkite/cuda/;
# every other step inherits common/ci_mirror_hardwares.yml, which
# already sets 8gb. Matching that rather than inventing a value.
shm-size: "8gb"
environment:
- "HF_HOME=/fsx/hf_cache"
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
mirror_hardwares: l4_1
- label: "RLHF · VeRL-Omni E2E Test"
source_file_dependencies:
+13
View File
@@ -74,6 +74,19 @@ steps:
exit $$EXIT
mirror_hardwares: h100_2
- label: ":full_moon: Omni · Perf Test · Async Chunk · Random"
key: weekly-omni-performance-async-chunk-random
timeout_in_minutes: 180
commands:
- export BENCHMARK_DIR=tests/dfx/perf/results
- |
set +e
pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json -m "H100 and slow"
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
exit $$EXIT
mirror_hardwares: h100_2
- label: ":full_moon: Omni · Perf Test · Multi-Replica"
key: weekly-omni-performance-multi-replicas
timeout_in_minutes: 180
+5 -5
View File
@@ -345,8 +345,8 @@ Heavier scenarios in the same file use **`advanced_model` only** (streaming, ext
@pytest.mark.tts
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
def test_voice_clone_en_non_streaming_001(omni_server, openai_client) -> None:
openai_client.send_audio_speech_request({...})
def test_voice_clone_en_non_streaming_001(omni_server, online_client) -> None:
online_client.send_audio_speech_request({...})
```
**L4 consolidation:** Prefer parametrized `OmniServerParams` rows (`default`, `async_chunk`, feature flags) in one expansion module rather than many merge-only files ([#1832](https://github.com/vllm-project/vllm-omni/issues/1832)).
@@ -363,8 +363,8 @@ def test_voice_clone_en_non_streaming_001(omni_server, openai_client) -> None:
| Path | Fixture | Call |
|------|---------|------|
| Online `/v1/*` | `openai_client` | `openai_client.send_*_request(request_config)` |
| Offline inference | `omni_runner_handler` | `omni_runner_handler.send_*_request(request_config)` |
| Online `/v1/*` | `online_client` | `online_client.send_*_request(request_config)` |
| Offline inference | `offline_client` | `offline_client.send_*_request(request_config)` |
1. **Grep `runtime.py` first** — reuse `send_omni_request`, `send_diffusion_request`, `send_audio_speech_request` (online + offline Qwen-style TTS), `send_single_stage_tts_request` (Coqui XTTS / MOSS-TTS-Nano offline), etc.
2. **No matching helper** → add `send_<feature>_request` (or `send_<route>_http_request` for negative/dfx) in **`runtime.py`** with general `assert_*` bundled inside, **then** call it from the test.
@@ -396,7 +396,7 @@ See [vllm-omni-test skill](../vllm-omni-test/SKILL.md) § **Runtime send helpers
`/health` return non-200 until you're actually ready.
- **Mark tests per the CI Levels table** — baseline smoke: `core_model` + `advanced_model`; heavier cases: `advanced_model` only; L4 expansion: `full_model`
- **No per-model helper modules** — do not create `tests/helpers/{slug}.py`; keep constants and `request_config` payloads in the test file
- **Online and offline e2e go through `runtime.py`** — `openai_client.send_audio_speech_request` (online); `omni_runner_handler.send_audio_speech_request` (Qwen-style offline) or `send_single_stage_tts_request` (single-stage offline). Add a new `send_*_request` in `runtime.py` when none fits; do not embed `omni.generate` or HTTP in tests
- **Online and offline e2e go through `runtime.py`** — `online_client.send_audio_speech_request` (online); `offline_client.send_audio_speech_request` (Qwen-style offline) or `send_single_stage_tts_request` (single-stage offline). Add a new `send_*_request` in `runtime.py` when none fits; do not embed `omni.generate` or HTTP in tests
## Phase 4: Async Chunk (Streaming)
+47 -47
View File
@@ -94,8 +94,8 @@ Existing references: `tests/e2e/offline_inference/test_qwen2_5_omni.py` (L2-styl
| Scenario | Typical location | Fixtures / runner pattern | Baseline markers & level |
|----------|------------------|---------------------------|---------------------------|
| **Offline inference e2e** | `tests/e2e/offline_inference/` | **Module (default):** `omni_runner` + `omni_runner_handler`. **Function (isolation only):** `omni_runner_function` + `omni_runner_handler_function`. Diffusion/TTS may use `Omni(...).generate` directly | L2: `core_model` + **one of** `omni` / `tts` / `diffusion`; `@hardware_test(...)` when GPU/NPU is required |
| **Online serving e2e** | `tests/e2e/online_serving/` | **Module (default):** `omni_server` + `openai_client`. **Function (isolation only):** `omni_server_function` + `openai_client_function`. Clients: `send_omni_request` (omni), `send_audio_speech_request` (tts), `send_diffusion_request` / `send_video_diffusion_request` / `send_images_generations_request` (diffusion) | Baseline smoke: **`core_model` + `advanced_model`**; heavier paths: `advanced_model` only; L4 expansion: `full_model` |
| **Offline inference e2e** | `tests/e2e/offline_inference/` | **Module (default):** `omni_runner` + `offline_client`. **Function (isolation only):** `omni_runner_function` + `offline_client_function`. Diffusion/TTS may use `Omni(...).generate` directly | L2: `core_model` + **one of** `omni` / `tts` / `diffusion`; `@hardware_test(...)` when GPU/NPU is required |
| **Online serving e2e** | `tests/e2e/online_serving/` | **Module (default):** `omni_server` + `online_client`. **Function (isolation only):** `omni_server_function` + `online_client_function`. Clients: `send_omni_request` (omni), `send_audio_speech_request` (tts), `send_diffusion_request` / `send_video_diffusion_request` / `send_images_generations_request` (diffusion) | Baseline smoke: **`core_model` + `advanced_model`**; heavier paths: `advanced_model` only; L4 expansion: `full_model` |
| **Documentation / runnable examples** | `tests/examples/offline_inference/`, `tests/examples/online_serving/` | **Offline docs (preferred):** extract Python/Bash blocks from the doc README (e.g. `ReadmeSnippet.extract_readme_snippets`), `pytest.mark.parametrize` each snippet, run via `example_runner.run` with a stable `output_subfolder`. **Online docs:** copy client/request scripts into dedicated tests and keep them in sync with the doc page. | Usually **L4**: `advanced_model`, often `example` plus hardware marks matching the nightly docs-example job (see `.buildkite/cuda/test-nightly.yml`). Full conventions: [docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md](../../../docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md) (introduced in [PR #1910](https://github.com/vllm-project/vllm-omni/pull/1910): naming, output directory layout, skip rules, avoid trimming `num_inference_steps` without a strong CI reason). |
| **Performance / benchmark** | `tests/dfx/perf/tests/*.json` + `run_*_benchmark.py` | JSON or script-driven server + load config; assert explicit metrics / baselines | L4 Perf: JSON `mark` with `full_model` + `omni`/`tts`/`diffusion`; wire `test-nightly.yml` Perf steps |
| **Invalid parameter / negative HTTP validation** | `tests/dfx/reliability/invalid_param_test/` | Live `omni_server` + low-level `send_*_http_request` with `err_code` / `err_message` | `pytest.mark.slow` + `omni` / `tts` / `diffusion` + `@hardware_marks` (`H100` or `L4`); CI in **`test-weekly.yml`** (not ready/merge/nightly) |
@@ -110,9 +110,9 @@ After choosing offline/online/docs/perf, classify the **product under test** and
|-----------|------------------------------|----------------------------|----------------------------------------|
| **What it is** | Multimodal LLM pipeline (thinker/talker/stages; text + vision + audio I/O) | Speech synthesis / voice models | Generative diffusion (noise → image, audio, text, or video) |
| **Examples** | Qwen2.5-Omni, Qwen3-Omni | Qwen3-TTS, VoxCPM2, Higgs-Audio, Step-Audio2 | Qwen-Image, BAGEL, Wan2.2, HunyuanVideo |
| **Offline runner** | `omni_runner` + `omni_runner_handler`, `generate_multimodal` | `Omni(...).generate` with deploy YAML + TTS params | `Omni(...).generate` + `OmniDiffusionSamplingParams` |
| **Online client** | `openai_client.send_omni_request` (chat completions, modalities) | `openai_client.send_audio_speech_request` (`/v1/audio/speech`) | `send_diffusion_request` (chat/T2I), `send_video_diffusion_request` (`/v1/videos`, X2V), or `send_images_generations_http_request` / `send_images_edits_http_request` (DALL-E routes) |
| **Typical assertions** | Stage outputs, text/audio keywords via `OmniRunnerHandler` / response handler | WAV bytes, stream chunks, speech endpoint contract | Image/video dimensions, `final_output_type`, `assert_diffusion_response` |
| **Offline runner** | `omni_runner` + `offline_client`, `generate_multimodal` | `Omni(...).generate` with deploy YAML + TTS params | `Omni(...).generate` + `OmniDiffusionSamplingParams` |
| **Online client** | `online_client.send_omni_request` (chat completions, modalities) | `online_client.send_audio_speech_request` (`/v1/audio/speech`) | `send_diffusion_request` (chat/T2I), `send_video_diffusion_request` (`/v1/videos`, X2V), or `send_images_generations_http_request` / `send_images_edits_http_request` (DALL-E routes) |
| **Typical assertions** | Stage outputs, text/audio keywords via `OfflineOmniClient` / response handler | WAV bytes, stream chunks, speech endpoint contract | Image/video dimensions, `final_output_type`, `assert_diffusion_response` |
| **Deploy YAML** | Per-model deploy configs (`ci/qwen3_omni_moe.yaml`, …) | `qwen3_tts.yaml`, `voxcpm2.yaml`, … | Often default serve; parallel/offload YAML for heavy DiT |
| **Nightly group** (`test-nightly.yml`) | **Omni Model Test** — `-m "full_model and omni"` | **TTS Model Test** — `-m "full_model and tts"` | **Diffusion X2I(&A&T)** *or* **Diffusion X2V** (see Step 3 table; same `diffusion` marker, different YAML group / file shard) |
| **L4 pressure** | Expansion per modality/model as needed | Expansion + accuracy/perf in TTS group | X2I: merge feature combos per [#1832](https://github.com/vllm-project/vllm-omni/issues/1832); X2V: separate nightly group |
@@ -175,9 +175,9 @@ See **L1 unit test constraints (mocking)** below for the full do/don't list.
@pytest.mark.omni
@hardware_test(...)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_<scenario>(omni_runner, omni_runner_handler) -> None:
def test_<scenario>(omni_runner, offline_client) -> None:
request_config = {"prompts": ..., "modalities": [...]} # optional: images, videos, audios
omni_runner_handler.send_omni_request(request_config)
offline_client.send_omni_request(request_config)
```
*Offline generative e2e — **Diffusion** (representative):*
@@ -187,8 +187,8 @@ def test_<scenario>(omni_runner, omni_runner_handler) -> None:
@pytest.mark.diffusion
@hardware_test(...)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_text_to_image_001(omni_runner_handler) -> None:
omni_runner_handler.send_diffusion_request({"prompt": "...", "extra_body": {"num_inference_steps": 4, ...}})
def test_text_to_image_001(offline_client) -> None:
offline_client.send_diffusion_request({"prompt": "...", "extra_body": {"num_inference_steps": 4, ...}})
```
*Offline TTS e2e — **Qwen3-TTS** (two-stage; representative):*
@@ -198,8 +198,8 @@ def test_text_to_image_001(omni_runner_handler) -> None:
@pytest.mark.tts
@hardware_test(...)
@pytest.mark.parametrize("omni_runner", tts_server_params, indirect=True)
def test_text_to_audio_001(omni_runner, omni_runner_handler) -> None:
omni_runner_handler.send_audio_speech_request({
def test_text_to_audio_001(omni_runner, offline_client) -> None:
offline_client.send_audio_speech_request({
"input": "...",
"task_type": "Base",
"ref_audio": REF_AUDIO_URL,
@@ -214,8 +214,8 @@ def test_text_to_audio_001(omni_runner, omni_runner_handler) -> None:
@pytest.mark.tts
@hardware_test(...)
@pytest.mark.parametrize("omni_runner", tts_server_params, indirect=True)
def test_voice_clone_001(omni_runner_handler) -> None:
omni_runner_handler.send_single_stage_tts_request({
def test_voice_clone_001(offline_client) -> None:
offline_client.send_single_stage_tts_request({
"input": "...",
"language": "en",
"prompt_audio_path": REF_AUDIO_PATH,
@@ -231,9 +231,9 @@ def test_voice_clone_001(omni_runner_handler) -> None:
@pytest.mark.omni
@hardware_test(...)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_text_to_text_001(omni_server, openai_client) -> None:
def test_text_to_text_001(omni_server, online_client) -> None:
request_config = {"model": omni_server.model, "messages": ..., "modalities": ["text"]}
openai_client.send_omni_request(request_config)
online_client.send_omni_request(request_config)
```
*Online serving e2e — **TTS** (representative):*
@@ -244,9 +244,9 @@ def test_text_to_text_001(omni_server, openai_client) -> None:
@pytest.mark.tts
@hardware_test(...)
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
def test_text_to_audio_001(omni_server, openai_client) -> None:
def test_text_to_audio_001(omni_server, online_client) -> None:
request_config = {"model": omni_server.model, "input": "...", "response_format": "wav", ...}
openai_client.send_audio_speech_request(request_config)
online_client.send_audio_speech_request(request_config)
```
*Online serving e2e — **Diffusion** X2I (representative):*
@@ -255,8 +255,8 @@ def test_text_to_audio_001(omni_server, openai_client) -> None:
@pytest.mark.core_model
@pytest.mark.diffusion
@pytest.mark.parametrize("omni_server", _get_default_case(MODEL), indirect=True)
def test_text_to_image_001(omni_server, openai_client) -> None:
openai_client.send_diffusion_request({...}) # chat completions + extra_body
def test_text_to_image_001(omni_server, online_client) -> None:
online_client.send_diffusion_request({...}) # chat completions + extra_body
```
*Online serving e2e — **Diffusion** X2V (representative):*
@@ -264,8 +264,8 @@ def test_text_to_image_001(omni_server, openai_client) -> None:
```python
@pytest.mark.core_model
@pytest.mark.diffusion
def test_text_to_video_001(omni_server, openai_client) -> None:
openai_client.send_video_diffusion_request({"model": ..., "form_data": {...}}) # /v1/videos
def test_text_to_video_001(omni_server, online_client) -> None:
online_client.send_video_diffusion_request({"model": ..., "form_data": {...}}) # /v1/videos
```
*Documentation example tests:* follow the **Preferred Test Strategy** in [l4_doc_example_tests.inc.md](../../../docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md): dynamic extraction for offline READMEs; explicit copied client code for online pages until extraction is justified; use the documented **naming**, **output directory** (page folder + case id), and **skipping** rules (e.g. Gradio-only scripts).
@@ -324,21 +324,21 @@ vLLM-Omni e2e tests start a real **OmniServer** (online) or **OmniRunner** (offl
| Scope | Online fixtures | Offline fixtures | When to use |
|-------|-----------------|------------------|-------------|
| **Module** (default) | `omni_server` → `openai_client` | `omni_runner` → `omni_runner_handler` | **Default for L2/L3/L4 expansion** — amortize model/server init across `test_*` in the same module. Same `OmniServerParams` / runner config can be reused by multiple tests. |
| **Function** | `omni_server_function` → `openai_client_function` | `omni_runner_function` → `omni_runner_handler_function` | **Only when required** — each `test_*` must get a **clean** server/runner (no shared engine/GPU state). Typical: `tests/dfx/reliability/`, sleep/wakeup, crash/restart, tests that mutate global server state. |
| **Module** (default) | `omni_server` → `online_client` | `omni_runner` → `offline_client` | **Default for L2/L3/L4 expansion** — amortize model/server init across `test_*` in the same module. Same `OmniServerParams` / runner config can be reused by multiple tests. |
| **Function** | `omni_server_function` → `online_client_function` | `omni_runner_function` → `offline_client_function` | **Only when required** — each `test_*` must get a **clean** server/runner (no shared engine/GPU state). Typical: `tests/dfx/reliability/`, sleep/wakeup, crash/restart, tests that mutate global server state. |
**Rules:**
1. **Default to module scope** (`omni_server` / `omni_runner`) unless the scenario **explicitly** needs a fresh instance per test function.
2. **Indirect parametrize name must match the fixture name**: `@pytest.mark.parametrize("omni_server", ...)` with `omni_server` + `openai_client`; `@pytest.mark.parametrize("omni_server_function", ...)` with `omni_server_function` + `openai_client_function`. Do not mix module fixture with function client (or vice versa).
2. **Indirect parametrize name must match the fixture name**: `@pytest.mark.parametrize("omni_server", ...)` with `omni_server` + `online_client`; `@pytest.mark.parametrize("omni_server_function", ...)` with `omni_server_function` + `online_client_function`. Do not mix module fixture with function client (or vice versa).
3. Different `OmniServerParams` per `test_*` (e.g. default vs `--enable-cpu-offload`) is still OK with **module** `omni_server` — pytest parametrizes per test node; only switch to `_function` when isolation between tests matters, not merely because `server_args` differ.
4. One `test_*` with **many** server configs (expansion matrix) → single function + `@pytest.mark.parametrize("omni_server", [...], indirect=True)` + module `omni_server` (see in-tree `test_qwen_image_expansion.py`).
```python
# Default — module-scoped server (L4 expansion)
@pytest.mark.parametrize("omni_server", [pytest.param(OmniServerParams(model=MODEL), marks=H100)], indirect=True)
def test_foo_images_generations_default_1024(omni_server, openai_client) -> None:
openai_client.send_images_generations_request({...})
def test_foo_images_generations_default_1024(omni_server, online_client) -> None:
online_client.send_images_generations_request({...})
# Function-scoped — reliability / per-test clean state only
@pytest.mark.parametrize(
@@ -346,9 +346,9 @@ def test_foo_images_generations_default_1024(omni_server, openai_client) -> None
[pytest.param(OmniServerParams(model=MODEL), marks=H100)],
indirect=True,
)
def test_foo_sleep_wakeup_cycle(omni_server_function, openai_client_function) -> None:
openai_client_function.send_omni_sleep_http_request({...})
openai_client_function.send_omni_wakeup_http_request({...})
def test_foo_sleep_wakeup_cycle(omni_server_function, online_client_function) -> None:
online_client_function.send_omni_sleep_http_request({...})
online_client_function.send_omni_wakeup_http_request({...})
```
#### Runtime send helpers — online **and** offline (`tests/helpers/runtime.py`)
@@ -357,19 +357,19 @@ def test_foo_sleep_wakeup_cycle(omni_server_function, openai_client_function) ->
| Principle | Action |
|-----------|--------|
| **Reuse first** | Grep `runtime.py` for an existing **`send_*_request`** on `OpenAIClientHandler` (online) or `OmniRunnerHandler` (offline) that matches the endpoint / pipeline shape. |
| **Reuse first** | Grep `runtime.py` for an existing **`send_*_request`** on `OnlineOmniClient` (online) or `OfflineOmniClient` (offline) that matches the endpoint / pipeline shape. |
| **Extend when close** | If an existing `send_*_request` almost fits (e.g. missing one optional field), extend it in `runtime.py` — do not fork logic in the test file. |
| **Add when missing** | No suitable helper → add **`send_<feature>_request`** (high-level: call + general `assert_*`) or **`send_<route>_<verb>_http_request`** (low-level HTTP for negative/dfx) in **`runtime.py` first**, then call it from tests. |
| **Test module owns payload only** | In `test_*.py`: `MODEL`, deploy path, vendored media, `get_prompt()`, and inline **`request_config` dicts** only. **No** `omni.generate(...)`, raw `requests.post`, OpenAI SDK calls, or `_collect_audio()` / `_process_output()` in e2e tests. |
**Online** (`openai_client` from `omni_server`): `OpenAIClientHandler.send_*_request`.
**Online** (`online_client` from `omni_server`): `OnlineOmniClient.send_*_request`.
**Offline** (`omni_runner_handler` from `omni_runner`): `OmniRunnerHandler.send_*_request`.
**Offline** (`offline_client` from `omni_runner`): `OfflineOmniClient.send_*_request`.
| Do | Don't |
|----|-------|
| Online: `openai_client.send_omni_request`, `send_diffusion_request`, `send_audio_speech_request`, `send_video_diffusion_request`, `send_images_generations_request`, … | `requests.post(f"{base_url}/v1/...", json=…)` or `client.chat.completions.create(...)` inside a test |
| Offline: `omni_runner_handler.send_omni_request`, `send_diffusion_request`, `send_audio_speech_request`, `send_single_stage_tts_request`, `send_single_stage_tts_batch_request`, … | `omni_runner.omni.generate(...)` + hand-rolled tensor/WAV extraction in `test_*.py` |
| Online: `online_client.send_omni_request`, `send_diffusion_request`, `send_audio_speech_request`, `send_video_diffusion_request`, `send_images_generations_request`, … | `requests.post(f"{base_url}/v1/...", json=…)` or `client.chat.completions.create(...)` inside a test |
| Offline: `offline_client.send_omni_request`, `send_diffusion_request`, `send_audio_speech_request`, `send_single_stage_tts_request`, `send_single_stage_tts_batch_request`, … | `omni_runner.omni.generate(...)` + hand-rolled tensor/WAV extraction in `test_*.py` |
| Add missing `send_*` to **`runtime.py`** first; bundle general `assert_*` inside the send helper | A one-off `def _post_*` / `def _collect_audio` at the bottom of a test module |
| Mirror naming/style of neighboring `send_*` (docstring, `request_config` dict, optional `run_level`, `err_code` / `err_message` for negative cases) | Different parameter shapes per test file for the same endpoint |
@@ -392,7 +392,7 @@ def test_foo_sleep_wakeup_cycle(omni_server_function, openai_client_function) ->
Changing `request_config` fields (`size`, `n`, `seed`, server flags) is **not** special validation — the general `assert_*` should read those from `request_config` inside `send_*`.
**`send_*` ↔ `assert_*` pairing (online `OpenAIClientHandler`):**
**`send_*` ↔ `assert_*` pairing (online `OnlineOmniClient`):**
| High-level `send_*` (prefer in L2+ e2e) | Assert already invoked inside `runtime.py` | Low-level HTTP-only sibling |
|----------------------------------------|--------------------------------------------|-----------------------------|
@@ -405,7 +405,7 @@ Changing `request_config` fields (`size`, `n`, `seed`, server flags) is **not**
**Rule:** Tests call high-level `send_*_request` for the general contract. **Never** call the same bundled `assert_*` again. Call an extra `assert_*` in the test **only** for special, case-specific validation.
**Common `OpenAIClientHandler` entry points** (non-exhaustive — grep `runtime.py` before adding):
**Common `OnlineOmniClient` entry points** (non-exhaustive — grep `runtime.py` before adding):
| Area | High-level (SDK + assert) | Low-level HTTP (`*_http_request`) |
|------|---------------------------|-----------------------------------|
@@ -416,7 +416,7 @@ Changing `request_config` fields (`size`, `n`, `seed`, server flags) is **not**
| TTS (online) | `send_audio_speech_request` | `send_audio_speech_http_request`, `send_audio_generate_http_request`, … |
| Ops / meta | — | `send_health_http_request`, `send_models_http_request`, `send_omni_sleep_http_request`, … |
**Common `OmniRunnerHandler` entry points** (offline — grep `runtime.py` before adding):
**Common `OfflineOmniClient` entry points** (offline — grep `runtime.py` before adding):
| Area | High-level (`send_*_request` + assert) | Notes |
|------|----------------------------------------|-------|
@@ -426,7 +426,7 @@ Changing `request_config` fields (`size`, `n`, `seed`, server flags) is **not**
| Single-stage TTS (Coqui XTTS, MOSS-TTS-Nano, …) | `send_single_stage_tts_request`, `send_single_stage_tts_batch_request` | `prompt` + `additional_information` + `omni.generate` — **do not** duplicate in tests |
| New model family | **Add** `send_<family>_request` here first | Then call from `tests/e2e/offline_inference/test_*.py` |
L1 tests under `tests/entrypoints/` may use **FastAPI `TestClient`** or direct handler calls with mocks; they do **not** need `OpenAIClientHandler` / `OmniRunnerHandler`. **L2+ online and offline e2e** must use `runtime.py` `send_*` helpers.
L1 tests under `tests/entrypoints/` may use **FastAPI `TestClient`** or direct handler calls with mocks; they do **not** need `OnlineOmniClient` / `OfflineOmniClient`. **L2+ online and offline e2e** must use `runtime.py` `send_*` helpers.
#### Invalid parameter validation (`tests/dfx/reliability/invalid_param_test/`)
@@ -452,7 +452,7 @@ When the user asks for **invalid parameter validation**, **invalid request bodie
| **Module markers** | `pytestmark = [pytest.mark.slow, pytest.mark.<omni or tts or diffusion>]` |
| **Server fixture** | `_PARAMS` / `_QWEN3_TTS_SPEECH`-style list of `pytest.param(OmniServerParams(...), id="...", marks=hardware_marks(...))`; `@pytest.mark.parametrize("omni_server", _PARAMS, indirect=True)` |
| **Hardware** | `hardware_marks(res={"cuda": "H100"})` for heavy diffusion/omni/video; `hardware_marks(res={"cuda": "L4"})` for smaller TTS models (must match weekly `-m "slow and L4"` step) |
| **HTTP client** | **Low-level** `openai_client.send_*_http_request({..., "err_code": 400, "err_message": (...)} )` — **not** `send_*_request` (success path) |
| **HTTP client** | **Low-level** `online_client.send_*_http_request({..., "err_code": 400, "err_message": (...)} )` — **not** `send_*_request` (success path) |
| **Case shape** | Prefer **one `test_*` per route family** + `@pytest.mark.parametrize("body_spec, err_message", [...])` with stable `id=` per case; or dedicated `test_<route>_malformed_json` when not parametrized |
| **Body helpers** | `_minimal_<endpoint>_json(omni_server)` / `_minimal_*_form_data()`; merge overrides with `body.update(body_spec)` |
| **Known gaps** | `pytest.mark.skip(reason="…#3649")` as `_SKIP_ISSUE_3649` when server validation is not yet strict (mirror neighboring cases) |
@@ -485,13 +485,13 @@ When the user asks for **invalid parameter validation**, **invalid request bodie
@pytest.mark.parametrize("omni_server", _PARAMS, indirect=True)
def test_images_generations_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
body_spec: dict[str, object],
err_message: str | tuple[str, ...],
) -> None:
body = _minimal_images_gen_json(omni_server)
body.update(body_spec)
openai_client.send_images_generations_http_request(
online_client.send_images_generations_http_request(
{"json": body, "timeout": 300, "err_code": 400, "err_message": err_message}
)
```
@@ -542,18 +542,18 @@ def send_images_generations_request(self, request_config: dict[str, Any], ...) -
# tests/e2e/.../test_foo_expansion.py — one case per test_* (no case_id branching)
@pytest.mark.parametrize("omni_server", [pytest.param(OmniServerParams(model=MODEL), marks=SINGLE_L4)], indirect=True)
def test_foo_images_generations_default_1024(omni_server, openai_client) -> None:
openai_client.send_images_generations_request({"json": body, "timeout": 300})
def test_foo_images_generations_default_1024(omni_server, online_client) -> None:
online_client.send_images_generations_request({"json": body, "timeout": 300})
@pytest.mark.parametrize("omni_server", [pytest.param(OmniServerParams(model=MODEL), marks=SINGLE_L4)], indirect=True)
def test_foo_images_generations_sizes_256_512_1024(omni_server, openai_client) -> None:
def test_foo_images_generations_sizes_256_512_1024(omni_server, online_client) -> None:
for size in ("256x256", "512x512", "1024x1024"):
openai_client.send_images_generations_request({"json": {**body, "size": size}, "timeout": 300})
online_client.send_images_generations_request({"json": {**body, "size": size}, "timeout": 300})
# chat diffusion — separate test; send_diffusion_request bundles assert_diffusion_response
@pytest.mark.parametrize("omni_server", [pytest.param(OmniServerParams(model=MODEL), marks=SINGLE_L4)], indirect=True)
def test_foo_chat_completions_t2i_fallback(omni_server, openai_client) -> None:
openai_client.send_diffusion_request({...})
def test_foo_chat_completions_t2i_fallback(omni_server, online_client) -> None:
online_client.send_diffusion_request({...})
```
L1 tests under `tests/entrypoints/` may keep **minimal** asserts next to the handler under test when validating a single branch; still prefer `assertions.py` when the same JSON/media check appears in more than one file or level (e.g. L1 protocol test + L4 e2e).
@@ -279,7 +279,7 @@ When adding or modifying tests, do not stop at “where the file lives” — al
4. **API client + assert placement**: **General** validation → implement in `assertions.py`, call **inside** `send_*_request` in `runtime.py`; tests call **`send_*_request` only**. **Special** case validation → `assert_*` in `assertions.py`, called **in the test** after `send_*_request`. Low-level `send_*_http_request` is for negative/dfx tests (`err_code`), not ordinary L2+ success e2e.
5. **Shared assertions**: logic in **`tests/helpers/assertions.py`**; general checks inside `send_*_request`; special checks only in the test. No `_assert_*` in `test_*.py`.
6. **One case → one `test_*`**: function name reflects what is validated; no `if case_id == ...` mega-test merging multiple scenarios.
7. **Fixture scope**: default **`omni_server` + `openai_client`** / **`omni_runner` + `omni_runner_handler`** (module). Use **`omni_server_function` + `openai_client_function`** / **`omni_runner_function` + `omni_runner_handler_function`** only when each `test_*` must spawn a fresh instance. Parametrize name must match fixture (`omni_server` vs `omni_server_function`).
7. **Fixture scope**: default **`omni_server` + `online_client`** / **`omni_runner` + `offline_client`** (module). Use **`omni_server_function` + `online_client_function`** / **`omni_runner_function` + `offline_client_function`** only when each `test_*` must spawn a fresh instance. Parametrize name must match fixture (`omni_server` vs `omni_server_function`).
8. **Type marker**: `omni`, `tts`, or `diffusion` on every model e2e module.
9. **Diffusion L4 Function**: wire `*_expansion.py` into **X2I(&A&T)** or **X2V** **Function Test** in **`test-nightly.yml` only** — do not add `test-merge.yml` unless the user also requested L3.
10. **Diffusion L4 Perf** (only when requested): add `tests/dfx/perf/tests/test_<slug>_vllm_omni.json` + **Perf Test · &lt;Model&gt;** step (artifact upload); not part of “L4 functional cases” by default.
+21 -6
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
# ruff: noqa
# code borrowed from https://github.com/pytorch/pytorch/blob/main/torch/utils/collect_env.py
@@ -494,6 +494,16 @@ def is_xnnpack_available():
def get_env_vars():
try:
from vllm_omni.config.environment_variable_inventory import ENVIRONMENT_VARIABLE_INVENTORY
except Exception:
# Keep environment collection usable when an incomplete/broken Omni
# installation is the reason the user is running this diagnostic.
# Importing an Omni submodule executes package initialization, which
# may fail with RuntimeError, AssertionError, or OSError as well as
# ImportError when vLLM or the accelerator stack is broken.
ENVIRONMENT_VARIABLE_INVENTORY = {}
env_vars = ""
secret_terms = ("secret", "token", "api", "access", "password")
report_prefix = (
@@ -507,12 +517,17 @@ def get_env_vars():
"MKL_",
"NVIDIA",
)
for k, v in os.environ.items():
if any(term in k.lower() for term in secret_terms):
for k, v in sorted(os.environ.items()):
omni_classification = ENVIRONMENT_VARIABLE_INVENTORY.get(k)
if (omni_classification is not None and omni_classification.redact_value) or any(
term in k.lower() for term in secret_terms
):
continue
if k in environment_variables:
env_vars = env_vars + "{}={}".format(k, v) + "\n"
if k.startswith(report_prefix):
if (
k in environment_variables
or (omni_classification is not None and omni_classification.is_public_omni)
or k.startswith(report_prefix)
):
env_vars = env_vars + "{}={}".format(k, v) + "\n"
return env_vars
Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

After

Width:  |  Height:  |  Size: 269 KiB

+7 -1
View File
@@ -2,6 +2,11 @@
The CLI for vLLM-Omni inherits from vllm with some additional arguments.
Environment variables are process configuration rather than CLI-only options.
See the [Environment Variables](../configuration/environment_variables.md)
reference for ownership, precedence, evaluation time, and per-stage `env`
scoping.
## serve
Starts the vLLM-Omni OpenAI Compatible API server.
@@ -39,4 +44,5 @@ vllm bench serve --omni \
--num-prompts 5
```
See [vllm bench serve](./bench/serve.md) for the full reference of all available arguments.
See [vllm bench serve](./bench/serve.md) for serving benchmark arguments and
dataset-specific examples, including OmniInteract native-duplex sessions.
+41 -3
View File
@@ -2,7 +2,7 @@
The vllm bench command launches the vLLM-Omni benchmark to evaluate the performance of multimodal models.
## Notes
vLLM-Omni registers the `openai-chat-omni`, `openai-audio-speech`, `openai-image-edits-omni`, and `daily-omni` serving benchmark backends.
vLLM-Omni registers the `openai-chat-omni`, `openai-audio-speech`, `openai-image-edits-omni`, `daily-omni`, and `openai-realtime-duplex` serving benchmark backends. It also adds the `omniinteract` dataset.
## Basic Parameter Description
You can use `vllm bench serve --omni --help=all` to get descriptions of all parameters. The commonly used parameters are described below:
@@ -10,7 +10,7 @@ You can use `vllm bench serve --omni --help=all` to get descriptions of all para
Enable Omni (multimodal) mode, supporting multimodal inputs and outputs such as images, videos, and audio.
- `--backend`
Specify the backend adapter. vLLM-Omni adds `openai-chat-omni`, `openai-audio-speech`, `openai-image-edits-omni`, and `daily-omni` to the upstream vLLM backend choices.
Specify the backend adapter. vLLM-Omni adds `openai-chat-omni`, `openai-audio-speech`, `openai-image-edits-omni`, `daily-omni`, and `openai-realtime-duplex` to the upstream vLLM backend choices.
- `--model`
The model identifier to load, filled according to the models supported by vLLM-Omni.
@@ -19,7 +19,7 @@ You can use `vllm bench serve --omni --help=all` to get descriptions of all para
The API endpoint exposed externally, to which clients send their requests.
- `--dataset-name`
The name of the dataset used; random-mm indicates generating random multimodal inputs (images, videos, audio).
The name of the dataset used; random-mm indicates generating random multimodal inputs (images, videos, audio), while `omniinteract` replays official OmniInteract videos.
- `--num-prompts`
The total number of requests to send, an integer.
@@ -269,6 +269,44 @@ We use audio generation time / audio duration to calculate RTF.
</details>
### OmniInteract Realtime Benchmark
OmniInteract runs each video as one native-duplex WebSocket sample in the standard serving-benchmark lifecycle:
```bash
vllm bench serve --omni \
--backend openai-realtime-duplex \
--dataset-name omniinteract \
--dataset-path /path/to/OmniInteract \
--model openbmb/MiniCPM-o-4_5 \
--base-url http://127.0.0.1:8000 \
--endpoint /v1/realtime \
--omniinteract-ref-audio /path/to/reference.wav \
--omniinteract-output-dir ./omniinteract-artifacts \
--num-prompts 3 \
--num-warmups 0
```
`--dataset-path` accepts an extracted directory, `data.tar[.gz]`, or a Hugging Face dataset ID; omitting it uses
`lucky-lance/OmniInteract`. `--num-prompts` is the total across subsets and defaults to 3 for OmniInteract; explicit `0`
selects all and oversize values use all available cases. Reference audio is required, and OmniInteract uses the
`/v1/realtime` endpoint.
Audio is replayed as 16 kHz PCM16 in 200 ms chunks and video at 1 FPS with real-time pacing. All selected media is decoded
before timing and remains in client memory for the run, so `--max-concurrency` does not limit media preparation memory; use
explicit `--num-prompts 0` only when the client has enough RAM for the full dataset. Media commands are bounded by
`--omniinteract-media-timeout-s`, and concurrency defaults to 1. Standard request-rate, warmup, result-saving, and summary
options apply. Use `--omniinteract-require-response` only for functional E2E cases; LISTEN is a valid benchmark result.
Each completed case writes `output.wav`, `wav_transcript.json`, `events.json`, `result.json`, and a final `.done` marker under
`--omniinteract-output-dir`. The root also contains `batch_summary.json` and `official_eval_manifest.jsonl`; failed cases write
`.failed.json`. Runs sharing one output root are serialized. Completion validates transport, response lifecycle, and artifacts,
not answer accuracy. Transcript timestamps are serialized playback-queue times. Clipped or cancelled outputs are ineligible and
omitted from the official manifest; `audio_clipped_bytes` records output beyond the rounded video horizon.
TTFT, TTFP, and RTF start at client receipt of `response.created`. TPOT/ITL use engine stage-0 timing; ITL is emitted only when
every token interval is present.
### Multi-Modal Benchmark
<details class="admonition abstract" markdown="1">
+3
View File
@@ -7,6 +7,9 @@ For options within a vLLM Engine, please refer to the [vLLM 0.26 configuration g
Each model defines fixed topology in a registered `PipelineConfig` and runtime
overrides in a deploy YAML.
For process-level settings shared by the CLI, deploy YAML, workers, and model
integrations, see [Environment Variables](./environment_variables.md).
For a specific example, see the [Qwen2.5-Omni deploy config](gh-file:vllm_omni/deploy/qwen2_5_omni.yaml). The matching frozen pipeline topology lives at [vllm_omni/model_executor/models/qwen2_5_omni/pipeline.py](gh-file:vllm_omni/model_executor/models/qwen2_5_omni/pipeline.py).
For an introduction, see [Pipeline and deploy configurations](./stage_configs.md).
+237
View File
@@ -0,0 +1,237 @@
# Environment Variables
Environment variables are process-level configuration shared by the CLI,
deploy YAML, Python APIs, worker processes, and model integrations. They are
therefore documented under **User Guide → Configuration**, rather than under
CLI Reference.
This page describes the public vLLM-Omni environment-variable contract on the
`latest` documentation branch. Use the documentation for your installed
vLLM-Omni release when behavior differs.
!!! important "Public contract"
Only names in [Public vLLM-Omni variables](#public-vllm-omni-variables) are
public Omni configuration. A name found in source code is not automatically
supported: model-specific, benchmark, platform, and internal variables have
separate ownership and lifecycle rules.
## Naming and implementation boundary
New public variables owned by vLLM-Omni must use the `VLLM_OMNI_` prefix.
Existing public names with older prefixes remain supported for compatibility,
but they do not establish naming precedent for new settings. Inherited vLLM
and third-party names retain the prefix chosen by their owning project.
The source [environment-variable inventory](gh-file:vllm_omni/config/environment_variable_inventory.py)
records ownership, migration disposition, and redaction metadata. Unlike
`vllm.envs`, it is not an executable value resolver: existing consumers still
own parsing, defaults, precedence, and evaluation time. The public tables below
describe those live consumer contracts. Stable model or stage behavior should
move to typed configuration, and request-varying behavior should move to a
request schema instead of adding another environment variable.
## Precedence and evaluation time
There is no repository-wide rule that makes environment variables override (or
lose to) every CLI, YAML, or request option. The tables below state precedence
for each public setting. In general:
- request fields should control behavior that can vary per request;
- typed deploy or stage configuration should control stable model and stage
behavior;
- environment variables are appropriate for process launch, platform
integration, emergency diagnostics, and compatibility fallbacks.
An environment variable can be read while a module is imported, when server or
model configuration is constructed, when a worker is spawned, or whenever a
property is accessed. Set import- and startup-time variables before invoking
`vllm serve` or importing vLLM-Omni. Changing the parent shell after workers
start does not update existing worker environments.
The lifecycle labels used below are:
- **Stable** — reviewed public configuration; changes require compatibility
consideration.
- **Experimental** — public for evaluation, but its contract may still evolve.
- **Diagnostic** — supported for investigation or temporary mitigation, not as
a normal tuning interface.
- **Deprecated** — compatibility alias; migrate to the named replacement.
## Public vLLM-Omni variables
### Diffusion backend and cache selection
| Name | Type and default | Applies to and read time | Precedence and invalid values | Lifecycle |
| --- | --- | --- | --- | --- |
| `DIFFUSION_ATTENTION_BACKEND` | Backend name or `auto`; default `auto` (platform selection) | Diffusion stages; read when `OmniDiffusionConfig` is constructed | `diffusion_attention_config.default` wins. An unknown backend fails during backend resolution. | Stable fallback |
| `DIFFUSION_CACHE_BACKEND` | `none`, `cache_dit`, `tea_cache`, `mag_cache`, `step_cache`, `stepcache`, or `step_cache_dit`; default `none` | Diffusion runner startup | Explicit `cache_backend` in config wins. Otherwise this name wins over the deprecated alias. Unsupported values raise `ValueError` during runner setup. | Stable fallback |
| `DIFFUSION_CACHE_ADAPTER` | Same values as `DIFFUSION_CACHE_BACKEND`; default `none` | Diffusion runner startup | Used only when neither explicit `cache_backend` nor `DIFFUSION_CACHE_BACKEND` is set. Unsupported values raise `ValueError`. | Deprecated; use `DIFFUSION_CACHE_BACKEND` |
| `OMNI_DIFFUSION_PROMPT_EMBED_CACHE` | Boolean: `1`, `true`, `yes`, `on`, `0`, `false`, `no`, or `off`; default disabled | Each diffusion runner; resolved during model setup | A recognized environment value overrides the explicit enable setting. An unrecognized value is ignored. | Experimental |
| `OMNI_DIFFUSION_PROMPT_EMBED_CACHE_SIZE` | Positive integer; default `32` entries | Each diffusion runner; resolved during model setup | A valid environment value overrides the explicit cache size. A non-integer logs a warning; a non-positive value is ignored. | Experimental |
| `OMNI_DIFFUSION_SESSION_STATE_MANAGER` | Boolean with the same accepted spellings as the prompt cache; default disabled | Experimental diffusion session manager; model setup | A recognized environment value overrides the explicit enable setting. An unrecognized value is ignored. | Experimental |
| `OMNI_DIFFUSION_SESSION_STATE_MANAGER_MAX_SESSIONS` | Positive integer; default `64` | Experimental diffusion session manager; model setup | A valid environment value overrides the explicit maximum. A non-integer logs a warning; a non-positive value is ignored. | Experimental |
Backend names for `DIFFUSION_ATTENTION_BACKEND` are the members of
`DiffusionAttentionBackendEnum`, such as `FLASH_ATTN`, `TORCH_SDPA`,
`SAGE_ATTN`, `FLASHINFER_ATTN`, and `TRTLLM_ATTN`. Platform support still
depends on the installed kernels and model path.
### Serving and runtime
| Name | Type and default | Applies to and read time | Precedence and invalid values | Lifecycle |
| --- | --- | --- | --- | --- |
| `SPEAKER_SAMPLES_DIR` | Filesystem path; default `~/.cache/vllm-omni/speakers` | Speech server; read when speaker storage initializes | Environment-only setting. The directory is created; filesystem errors propagate. | Stable |
| `SPEAKER_MAX_UPLOADED` | Integer; default `1000` | Speech server; read when speaker storage initializes | Environment-only setting. A non-integer logs a warning and uses `1000`; range is not otherwise validated. | Stable |
| `VLLM_OMNI_ASYNC_OUTPUT_TIMEOUT` | Float seconds; default `600` | Diffusion engine async-output wait in `step_streaming`; resolved per call on the request path, not at import | Environment-only setting. A non-float or `<=0` value warns once and uses the default. | Experimental |
| `VLLM_OMNI_INPUT_WAIT_TIMEOUT_S` | Float seconds; default `600`; `<=0` disables | Full-payload input coordinator, not async-chunk transfer; read when the scheduler module imports in each worker | Environment-only setting. A non-float logs a warning and uses `600`. | Stable operational control |
| `VLLM_OMNI_ORCH_MONITOR_PATH` | Filesystem path; default `<current-working-directory>/vllm_omni_orch_monitor_<timestamp>.json` | Orchestrator monitor enabled by `--enable-orch-monitor`; read when the monitor is created | Environment-only path override. Parent directories are created; write errors are logged. | Diagnostic |
| `VLLM_OMNI_VIDEO_SYNC_TIMEOUT` | Float seconds; default `600` | Synchronous Videos API; read when the API server module imports | Environment-only setting. A non-float raises `ValueError` during import. | Experimental |
| `VLLM_VIDEO_ASYNC_CHUNK` | `on` or `off`; default `on` | Streaming video output; read on attribute access | Environment-only setting. Values are trimmed and case-normalized; an invalid value warns once and uses `on`. | Experimental |
| `VLLM_VIDEO_AUDIO_DELTA_MODE` | `fast` or `slow`; default `fast` | Streaming video audio deltas; read on attribute access | Environment-only setting. Values are trimmed and case-normalized; an invalid value warns once and uses `fast`. | Experimental |
### Server storage
Storage names use Pydantic's nested-settings delimiter (`__`). They are read
when `ServerSettings` is initialized, normally during server import/startup.
Invalid integer values raise a Pydantic validation error.
| Name | Type and default | Meaning | Lifecycle |
| --- | --- | --- | --- |
| `VLLM_OMNI_SERVER_STORAGE__PATH` | Path; default `/tmp/storage` | Directory for completed server files. | Stable |
| `VLLM_OMNI_SERVER_STORAGE__FILE_CONCURRENCY` | Integer; default `4` | Maximum concurrent file operations. No additional range validation is currently applied. | Stable |
| `VLLM_OMNI_SERVER_STORAGE__FILE_TTL` | Integer seconds or unset; default unset | Optional lifetime for locally stored files. | Stable |
| `VLLM_OMNI_SERVER_STORAGE__TTL_SWEEP_INTERVAL` | Integer seconds or unset | Sweep frequency. If file TTL is set and this is unset, the effective default is `300`. | Stable |
| `VLLM_OMNI_STORAGE_PATH` | Path | Deprecated alias for `VLLM_OMNI_SERVER_STORAGE__PATH`. The new name wins when both are set. | Deprecated |
| `VLLM_OMNI_STORAGE_MAX_CONCURRENCY` | Integer | Deprecated alias for `VLLM_OMNI_SERVER_STORAGE__FILE_CONCURRENCY`. The new name wins when both are set. | Deprecated |
The deprecated aliases emit `DeprecationWarning`. They do not affect file TTL
settings.
### Quantization diagnostics and performance
| Name | Type and default | Applies to and read time | Precedence and invalid values | Lifecycle |
| --- | --- | --- | --- | --- |
| `VLLM_OMNI_SKIP_NVFP4_NAN_CLAMP` | Boolean truthy spellings: `1`, `true`, `yes`, `on`; default false | ModelOpt NVFP4 compatibility patch; read when `vllm_omni.patch` imports | Environment-only escape hatch. Any other value means false. Set only to diagnose the upstream NaN-scale issue. | Diagnostic and temporary |
| `VLLM_OMNI_USE_QUACK_FP8` | Boolean truthy spellings: `1`, `true`, `yes`, `on`; unset means hardware auto-detection | FP8 scaled matrix multiplication; evaluated when quack capability is selected | A set value overrides auto-detection. Any non-truthy value forces quack off. If quack cannot load, vLLM-Omni warns and falls back to FlashInfer. | Experimental performance control |
`QUACK_CACHE_DIR` is owned by the external quack library and is not an
Omni-owned variable, even though vLLM-Omni supplies a persistent default when
it is unset.
## Per-stage environment
Deploy configurations can set arbitrary environment keys for one stage:
```yaml
stages:
- stage_id: 0
env:
HCCL_IF_BASE_PORT: 23000
MY_MODEL_CACHE: /mnt/model-cache
```
The legacy nested form under `runtime.env` is also accepted. vLLM-Omni converts
keys and values to strings, applies them while the stage engine or child process
is launched, logs keys but not values, and restores the parent process's prior
environment afterward. A child process inherits the scoped values. This keeps
sibling stages from permanently changing one another's parent environment.
This scoping has two consequences:
1. A setting read inside stage/model initialization can see the stage value.
2. A setting already cached by an import that occurred before stage launch
cannot be changed retroactively with stage `env`.
Stage `env` is an escape hatch for process and third-party settings, not a
replacement for typed stage configuration. Do not put request-varying behavior
there. Secret values may reach child processes; logs and diagnostics must emit
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)
for their definitions. This includes vLLM launch, cache, logging, plugin, ROCm,
XPU, ModelScope, and FlashInfer workspace settings.
An inherited name does not become Omni-owned because Omni reads or forwards it.
Where behavior differs, the Omni-specific page should document only that
difference. For example, device-visibility values can be further narrowed by a
stage's `devices` setting.
## Platform, external, and secret variables
Hardware runtimes, distributed launchers, and third-party libraries retain
ownership of their variables. Common groups include:
- device visibility (`ASCEND_RT_VISIBLE_DEVICES`, `HIP_VISIBLE_DEVICES`, and
`MUSA_VISIBLE_DEVICES`);
- distributed launch (`MASTER_ADDR`, `MASTER_PORT`, `RANK`, and `WORLD_SIZE`);
- Hugging Face cache and authentication (`HF_HOME`, `HF_TOKEN`, and related
names);
- accelerator libraries such as NCCL, FlashInfer, MindIE, MORI, and quack.
Consult the owning project's documentation for accepted values and support.
`HF_TOKEN`, `HUGGINGFACE_HUB_TOKEN`, and `OPENAI_API_KEY` are explicitly marked
for redaction in the reviewed inventory. Never print their values in environment
collection, examples, bug reports, or logs.
## Model-specific variables
The audit found 56 variables read by a single model or pipeline family. They are
not listed as public usage options here because doing so would turn implementation
escape hatches into an accidental compatibility contract.
Every audited model-specific name has a migration disposition in the
[environment-variable inventory](gh-file:vllm_omni/config/environment_variable_inventory.py):
| Disposition | Count | Required outcome |
| --- | ---: | --- |
| Promote | 33 | Move a stable setting into typed stage or model configuration. |
| Request scope | 6 | Move request-varying behavior into a declared request-option schema. |
| External | 0 | Retain only when a supported third-party library owns the contract. |
| Internalize | 11 | Keep a debug or diagnostic switch out of public documentation and configuration. |
| Deprecate/remove | 6 | Remove a compatibility escape hatch that has no continuing contract. |
The disposition is a migration target, not a statement that the existing
environment switch is stable. Promote or request-scope work should land in
model-owner-reviewed follow-up changes with validation and model-page updates.
The family-sized migration packages and their ownership gate are tracked in
[#6232](https://github.com/vllm-project/vllm-omni/issues/6232).
## Environment collection and troubleshooting
Run the repository collector when preparing a bug report:
```bash
python collect_env.py
```
The environment section includes the safe public Omni allowlist, registered
vLLM variables, and selected platform prefixes. It omits names marked for
redaction and names containing common secret terms. Review the output before
sharing it. A key supplied through stage `env` is not collected merely because
it appears in deploy configuration; the same allowlist and prefix rules apply.
If a setting appears ineffective, first check its read time in the tables
above. Restart processes after changing import- or startup-time values, verify
that the variable reaches the intended worker, and check whether an explicit
typed configuration field takes precedence.
## Benchmark and internal variables
The reviewed inventory marks 20 benchmark variables as transitional while
benchmark configuration moves to CLI/request schemas in
[issue #5376](https://github.com/vllm-project/vllm-omni/issues/5376). They are not
part of the serving/runtime contract.
Internal process-coordination and debug variables are deliberately excluded
from this page. The CI scanner resolves string literals and module-level string
constants used by direct `os` access, local wrapper helpers, aliased `os`
imports, and environment membership checks. Server-storage names generated by
Pydantic settings are checked separately against their model fields.
Names assembled from cross-module metadata and arbitrary keys supplied through
stage `env` cannot be inferred reliably. Contributors must classify those names
explicitly. CI fails when a statically resolved name has no classification or
when a reviewed public name is absent from this page.
@@ -1,8 +1,8 @@
**Scope**
#### Scope
For diffusion models, the L4 functionality test covers all or common *diffusion features* that are supported by this model, including several [parallelism acceleration methods](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/parallelism_acceleration/), [CPU offloading](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/cpu_offload/), [TeaCache](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/teacache/) and [Cache-DiT](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/cache_dit_acceleration/) cache backends, [quantization methods](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/quantization/overview/).
**Test Case Design**
#### Test Case Design
For a *high priority* model (currently listed in [issue #1832](https://github.com/vllm-project/vllm-omni/issues/1832)), we use several test cases, each with multiple features turned on, so that each supported feature is tested in at least one test case. This is to relieve the GPU workload on the CI machine. The suggested test case combination is as follows:
@@ -34,12 +34,12 @@ For a *normal priority* model, further reduce the number of test cases.
Currently all the features are available in online serving mode. Hence, only need to add `tests/e2e/online_serving/test_{model}_expansion.py`.
**Code Style**
#### Code Style
- Validation: test that the multimodal output files of your model have the correct shapes. `OpenAIClientHandler.send_diffusion_request` should have taken care of this.
- Validation: test that the multimodal output files of your model have the correct shapes. `OnlineOmniClient.send_diffusion_request` should have taken care of this.
- Test marks: always add `full_model` and `diffusion` for L4 nightly `test_*_expansion.py` cases. Add GPU-related marks if needed. Ref: [Markers for Tests](https://docs.vllm.ai/projects/vllm-omni/en/latest/contributing/ci/test_writing_guide/#markers-for-tests).
- To maximize code reuse, you may refer to
- `tests/conftest.py` for `omni_server` (running server in subprocess) and `openai_client` fixtures (sending requests and validating output), `generate_synthetic_image` and `assert_XXX_valid` helper.
- `tests/conftest.py` for `omni_server` (running server in subprocess) and `online_client` fixtures (sending requests and validating output), `generate_synthetic_image` and `assert_XXX_valid` helper.
- `tests/helpers/mark.py` for `@hardware_test(...)` and `hardware_marks`.
- [Parametrizing tests (pytest doc)](https://docs.pytest.org/en/stable/example/parametrize.html) to reuse test function implementation for different cases.
- Doc: add a concise docstring for each test function.
@@ -4,8 +4,8 @@ When you want to add L4-level ***performance test*** cases, add entries to JSON
| Model type | Runner | Example JSON files |
| ---------- | ------ | ------------------ |
| Omni (nightly) | `run_benchmark.py` | `test_qwen3_omni_no_async_chunk.json`, `test_qwen3_omni_async_chunk.json` (`full_model` in `mark`) |
| Omni (weekly) | `run_benchmark.py` | `test_qwen3_omni_vllm_text.json`, `test_qwen3_omni_multi_replicas.json` (`slow` in `mark`; **Perf Test** in `test-weekly.yml`) |
| Omni (nightly) | `run_benchmark.py` | `test_qwen3_omni_no_async_chunk.json`, `test_qwen3_omni_async_chunk.json` (`full_model` without `slow` in `mark`) |
| Omni (weekly) | `run_benchmark.py` | `test_qwen3_omni_async_chunk.json` (CUDA only), `test_qwen3_omni_vllm_text.json`, `test_qwen3_omni_multi_replicas.json` (`slow` in `mark`; **Perf Test** in `test-weekly.yml`) |
| TTS | `run_benchmark.py` | `test_tts.json`, `test_voxcpm2.json`, `test_higgs_audio_v3.json` |
| Diffusion | `run_diffusion_benchmark.py` | `test_qwen_image_vllm_omni.json`, `test_bagel_vllm_omni.json`, `test_wan22_i2v_vllm_omni.json`, `test_cosmos3_vllm_omni.json`, … |
@@ -20,7 +20,7 @@ Diffusion cases are detected when the JSON has `server_type` (typically `"vllm-o
#### Running perf cases
Pass **`--test-config-file`** to run one JSON file as-is, or omit it for the bulk scan above and filter with pytest **`-m`** on each case's JSON `mark` (for example `pytest … run_benchmark.py -m "full_model and tts and H100"` or `-m "slow and omni and H100"` for weekly Omni configs).
Pass **`--test-config-file`** to load one JSON file, or omit it for the bulk scan above. In either mode, pytest **`-m`** filters each case using its JSON `mark`. A single file can contain nightly, weekly, and platform-specific case objects. For example, the nightly object in `test_qwen3_omni_async_chunk.json` declares both H100 and A3, while its `slow` weekly object is CUDA-only; there is no NPU weekly counterpart. Use `-m "H100 and full_model and not slow"`, `-m "H100 and full_model and slow"`, or `-m "npu"` to select the intended schedule and platform.
```JSON
{
@@ -75,7 +75,7 @@ Omit `mark` only for configs not meant to be filtered by `-m`. `server_type` is
Optional top-level field on each perf JSON **case object** (one per `test_name`). `run_benchmark.py` and `run_diffusion_benchmark.py` read it via `tests.dfx.conftest.resolve_pytest_marks` and attach the marks to the corresponding `pytest.param`, so you can filter locally with `-m`.
When `mark` is present, it must be an **array** with exactly one ``hardware_marks`` object (same shape as `@hardware_test` / `hardware_marks()` in `tests/helpers/mark.py`), followed by registered pytest marker name strings such as `full_model`, `omni`, `tts`, `diffusion`, or `local_model`.
When `mark` is present, it must be an **array** with exactly one ``hardware_marks`` object (same shape as `@hardware_test` / `hardware_marks()` in `tests/helpers/mark.py`), followed by registered pytest marker name strings such as `full_model`, `slow`, `omni`, `tts`, `diffusion`, or `local_model`.
Supported form:
@@ -142,11 +142,18 @@ Examples:
pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py -m "full_model and H100 and diffusion"
pytest -s -v tests/dfx/perf/scripts/run_benchmark.py -m "full_model and omni and H100"
# Single file (same as nightly CI Perf steps)
# Single file (same selectors as the CI Perf steps)
pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py \
--test-config-file tests/dfx/perf/tests/test_bagel_vllm_omni.json
pytest -s -v tests/dfx/perf/scripts/run_benchmark.py \
--test-config-file tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json
--test-config-file tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json \
-m "H100 and full_model and not slow"
pytest -s -v tests/dfx/perf/scripts/run_benchmark.py \
--test-config-file tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json \
-m "H100 and full_model and slow"
pytest -s -v tests/dfx/perf/scripts/run_benchmark.py \
--test-config-file tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json \
-m "npu"
```
<!-- markdownlint-disable-next-line MD051 -->
@@ -20,7 +20,7 @@ implemented but has less end-to-end coverage than the primary path.
Legend: ✅ supported, ⚠️ compatibility path or limited validation, ❌ unsupported.
| Feature | DLO + AllGather | DLO without AllGather |
|---|---|---|
| --- | --- | --- |
| **DP** | ✅ Primary path; host weights are sharded across the DP group. | ✅ Each DP rank streams complete rank-local blocks. |
| **SP** | ✅ When DP=1, DLO uses the SP group for weight sharding. | ✅ SP remains active without a DLO weight collective. |
| **TP > 1** | ⚠️ Ordinary TP-aware loader only; no direct checkpoint mmap. | ⚠️ Ordinary TP-aware loader only; no direct checkpoint mmap. |
@@ -169,10 +169,236 @@ This mode means:
copy per rank.
- The scheduler does not require a synchronized DP request wave for DLO.
### Component allocator-cache retention
MiniMax-H3 DLO keeps one bounded PyTorch allocator cache across its staged
encoder, DiT, and VAE component boundaries so allocations released by one
component can be reused by the next. The DiT prefetch path and its two shared
device buffers are unchanged.
After a component is offloaded, cached-but-unallocated memory is retained only
while it is at most 25% of device capacity and at least 5% of device capacity
remains physically free. Crossing either bound releases the allocator cache.
Missing allocator telemetry also releases it conservatively. Component or
staging failure forces a release, and an out-of-memory allocation gets one
retry after release. This is a component-local policy rather than a global
`empty_cache` override, so the executor's unconditional shutdown cleanup is
preserved.
Retaining allocator blocks trades higher reserved and peak device memory for
lower lifecycle latency. The benefit is device- and workload-dependent, and a
smaller or externally loaded device may cross a bound and return to the
ordinary per-component cache-release behavior. Current performance validation
covers MiniMax-H3 no-AllGather DLO on CUDA. Other MiniMax-H3 DLO transfer
topologies and platforms receive the same bounded policy but are not claimed
performance targets; other DLO models remain unchanged unless they explicitly
adopt it.
### Final-layout Host Weight Runtime consumer
> **Status:** PR2 landed in
> [PR #6486](https://github.com/vllm-project/vllm-omni/pull/6486). PR3 adds
> registered direct H2D plus generic HWR publication/source-digest
> optimizations without changing AllGather behavior.
Final-layout Host Weight Runtime (HWR) backing is opt-in and currently applies
only to no-AllGather DLO. Enable it with
`--host-weight-runtime-mode preferred` (or `required`) and
`--host-weight-runtime-root <node-local-root>`.
The modes express operator fallback policy, not different artifact formats:
- `preferred` consumes an exact hit, but on a miss it allows canonical loading
followed by post-load publication. It is the normal population path.
- `required` consumes the same exact artifact but fails startup on a miss or
unusable artifact. It never invokes canonical DiT fallback or post-load
publication and therefore cannot populate an empty store.
PR2 has no separate prewarm command. Operators populate one matching producer
cohort per node-local storage domain in `preferred` mode, then restart the same
model revision and parallel layout with `required`. TP coordinates have distinct
identities, while equivalent DP replicas share them.
The current producer/consumer boundary is the model-declared final-layout BF16
contract (MiniMax H3 today). It supports ordinary-loader final layouts for TP1
and TP2 rank identities plus SP layout identities; online quantization, HSDP,
LoRA/adapted weights, and non-default load formats remain ineligible. HWR mode
`disabled`, DLO-disabled, and DLO AllGather configurations stop before source
identity or store construction and retain the existing checkpoint-mmap or
ordinary-loader path.
Eligibility is decided before constructing HWR, resolving canonical sources,
hashing identity inputs, probing a filesystem, or emitting an HWR observer
event:
```text
DLO disabled / HWR disabled / DLO AllGather
-> zero final-layout HWR interaction
-> preserve current checkpoint-mmap or ordinary-loader behavior
```
When final-layout HWR is explicitly enabled for no-AllGather DLO, runtime
outcomes are authoritative:
- `LOCAL_HIT`: plan and commit an exact restoration, then transfer the lease
transactionally to DLO.
- `CANONICAL_FALLBACK`: bypass checkpoint mmap, canonically materialize and
finalize the DiT, then call `publish_after_load()` for a future startup. The
current startup retains its canonical tensors.
- `FAILED`: fail startup. Preferred mode does not reinterpret nonretryable
identity, configuration, or compatibility failures as misses.
- Required mode cannot bootstrap an empty store through a `POST_LOAD_ONLY`
producer.
Checkpoint mmap remains an unchanged control path whenever final-layout HWR is
not selected.
Local source identity and publication avoid repeated full-payload passes while
preserving the same semantic contract:
- Canonical files without a trusted immutable Hub blob identity are hashed in
parallel. Their content IDs are cached per storage domain under the HWR root
and reused only when the path, inode, size, timestamps, symlink target, and
cache-record checksum still match. Corrupt entries are rebuilt; cache-lock or
cache-I/O failure falls back to hashing the canonical file directly.
- Producers that emit payload bytes in canonical storage-key order may declare
`ordered=True`. The filesystem writer then computes file and tensor SHA256
values during the write and overlaps payload `fsync` with later producer
work. Producers without that guarantee retain unordered writes and use a
parallel readback checksum fallback.
- Manifest and `READY` publication still wait for every payload `fsync` and
checksum to complete. These optimizations do not weaken artifact validation
or change lease ownership.
#### Registered mmap transport
After DLO takes an HWR lease, the no-AllGather backend may register the lease's
complete immutable mapped ranges under the existing `pin_cpu_memory` policy.
Registration is transport state: it does not change artifact identity, the
store, tensor ownership, or H2D payload. On success, each tensor view copies
directly into the existing rotating HBM block buffers and the two private host
staging slots are not allocated.
`--dlo-host-registration-limit-gib` is an optional per-worker preflight ceiling
over page-aligned registered bytes. Zero adds no ceiling. A disabled pinned
memory policy, unsupported platform/capability, over-budget mapping, or fully
rolled-back registration error selects the existing two-slot staging path. A
partial registration that cannot be rolled back aborts startup because closing
the lease would unmap memory still owned by the platform.
Direct checkpoint mmap remains unchanged and continues to use staging. It may
require loader-owned per-block transforms, while the HWR artifact already
contains final runtime bytes. DLO AllGather never receives an HWR final-layout
lease and therefore never enters this registration path.
#### Pre-service transaction boundary
The startup transaction does not end at restore commit. A lease-backed model is
disposable until backend setup and initial prefetch complete:
```text
UNRESOLVED
-> LEASE_OWNED_BY_LOADER
-> RESTORE_PLANNED
-> COMMIT_STARTED
-> CARRIER_OWNED
-> BACKEND_OWNED
-> BACKEND_READY
-> IN_SERVICE
```
Failure before `COMMIT_STARTED` closes the loader-owned lease. Preferred mode
may canonically load using the untouched skeleton; required mode fails.
Failure from `COMMIT_STARTED` through `BACKEND_READY` must:
1. synchronize or otherwise quiesce partial backend and initial-prefetch work;
2. release hook, staging, restore-plan, and model references;
3. close the lease through its current owner;
4. discard the restored model; and
5. in preferred mode, construct a fresh canonical model while bypassing HWR
lookup, HWR publication, and checkpoint mmap for that recovery attempt.
Required mode fails instead of constructing the fresh fallback. Once the model
enters service, failures follow normal runtime handling rather than startup
fallback.
#### Finalization phases
Cold canonical loading separates byte-changing work from shared runtime-state
finalization:
```text
Cold-only byte-changing:
casting, reordering, packing, quantization, generated scales,
model-specific weight transforms, and calibration that mutates
parameters or persistent buffers
Shared cold/warm non-byte:
validation, hook installation, eval state, bookkeeping,
and non-persistent runtime state
```
The warm path must snapshot restored tensor bytes and backing pointers before
shared finalization and prove both are unchanged afterward.
#### Lease ownership
Lease transfer is single-owner and single-take:
```text
loader owns
-> restore plan borrows
-> commit succeeds
-> runner carrier takes once
-> backend takes once before asynchronous work
-> backend drains pending H2D work
-> backend releases hooks, staging, and model references
-> backend unregisters every mapped range
-> backend closes lease
```
The runner carrier is process-local, rejects serialization, and rejects a
duplicate `take()`. If the carrier still owns the lease, runner cleanup closes
it. Once the backend takes ownership, only backend abort or teardown may drain
work and close it.
The implementation keeps the final-layout lease through backend setup and
initial prefetch. Successful registration copies directly from immutable HWR
views; otherwise the backend uses the existing two bounded rank-local staging
slots. Transactional cleanup drains device work, removes source references,
unregisters mappings, and only then closes the lease. An unregistration failure
retains both registration and lease for retry/process teardown. Preferred mode
then uses the runner's fresh canonical retry; required mode propagates the
failure.
#### PR2 and PR3 promotion gates
- Warm hit performs zero ordinary DiT materialization and zero producer calls.
- Shared warm finalization changes neither restored bytes nor backing pointers.
- Preferred `FAILED` outcomes do not silently fall back.
- Planning failure leaves the skeleton untouched.
- Any pre-service failure after commit begins discards the restored model;
preferred mode uses a fresh canonical model and required mode fails.
- Mixed components load normally and no required tensor remains on `meta`.
- Disabled mode and AllGather emit zero final-layout HWR interaction.
- The lease carrier rejects duplicate take and serialization.
- Backend setup or prefetch failure drains asynchronous work before lease close.
- Registration is all-or-nothing; rollback/unregistration failure never closes
a mapping still owned by the platform.
- Registered HWR transport bypasses host staging while preserving output and
H2D payload; unsupported registration retains the bounded staging path.
- Compatible checkpoint mmap remains an unchanged benchmark and control path.
- Prewarm uses one matching producer cohort per TP/SP topology and storage
domain.
- Benchmarks compare ordinary loading, cold publication, and warm staging with
output parity, startup latency, aggregate PSS, page-cache state, HBM, and H2D
payload.
## Parallelism compatibility
| Parallelism | DLO + AllGather | DLO without AllGather |
|---|---|---|
| --- | --- | --- |
| **DP** | Supported primary path. DLO shards host weights across the DP group and can run DP multi-concurrency. | Supported rank-local path. Compatible TP1 replicas can share checkpoint pages on each node; fallback runtime tensors remain private. |
| **SP** | Supported in the implementation. With DP=1, DLO uses the SP group for host-weight sharding; SP still shards sequence/activation work. | SP remains active, but DLO keeps standard-loader rank-local weights and adds no SP weight collective. |
| **TP > 1** | Outside the Phase A shared-mmap support scope. The loader falls back before mutation, preserves TP-local layouts, and DLO may apply DP/SP host sharding to those ordinary runtime tensors. | Outside the Phase A shared-mmap support scope. The ordinary TP-aware loader produces rank-local tensors, which DLO streams without an additional weight collective; DP replicas retain private runtime storage. |
@@ -185,7 +411,8 @@ This mode means:
the SP group becomes DLO's sharding group in AllGather mode.
- **DP + TP/SP without AllGather:** standard model loading defines the
rank-local tensor layout. DLO adds no cross-DP, cross-TP, or cross-SP weight
collective.
collective. When the model declares the final-layout contract, HWR keys the
reusable artifact by TP rank/size and SP layout.
- **HSDP + SP:** the general parallel configuration permits HSDP over SP, but
DLO must use `--dlo-no-use-allgather`. HSDP remains responsible for weight
materialization and synchronization.
@@ -211,10 +438,12 @@ reconstructs those tensors with their recorded layouts. Other online methods
must use `--dlo-no-use-allgather` or disable online quantization until their
runtime layouts are validated.
A normalized runtime mmap cache, built through the ordinary loader, is the
proposed general mechanism for sharing transformed TP or quantized layouts.
That cache and its publication/lifecycle protocol are intentionally outside
this phase; see [RFC #6195](https://github.com/vllm-project/vllm-omni/issues/6195).
The Host Weight Runtime representation, publication, and no-AllGather consumer
contracts are merged; see
[RFC #6414](https://github.com/vllm-project/vllm-omni/issues/6414),
[PR #6445](https://github.com/vllm-project/vllm-omni/pull/6445), and
[PR #6486](https://github.com/vllm-project/vllm-omni/pull/6486). Registered
direct H2D is an optional transport layer over that merged lease contract.
## Validation coverage
@@ -227,10 +456,16 @@ Current source-level validation includes:
- ordinary-loader fallback for per-tensor online FP8 linears followed by DLO
sharding of finalized weights and scales;
- exact loader-to-backend plan transfer and ordinary-loader fallback;
- ordered publication hashing, overlapped durability, unordered parallel
checksum fallback, and node-local source-digest reuse/invalidation;
- rank-local mmap source retention, bounded two-slot staging, and adapter
transforms without parameter-side flags;
- read-only registration preflight, direct source-to-device copies, safe
fallback, partial rollback, retryable unregistration, and lease ordering;
- resident-layer requests requiring no-AllGather;
- DP request-wave validation for denoising-step compatibility;
- bounded component allocator-cache retention, conservative/forced release,
OOM retry, and immutable encoder non-block staging;
- sharding, double-buffer, AllGather-size, and heterogeneous-block regression
tests.
@@ -243,7 +478,7 @@ VAEs at TP1. They validate the ordinary-loader fallback only, not direct mmap
or shared-mmap host-memory savings.
| Configuration | Result | Warm E2E | Peak device memory | Host PSS |
|---|---:|---:|---:|---:|
| --- | ---: | ---: | ---: | ---: |
| DP4xTP1 AllGather | Passed, 4 concurrent requests | 2.87 s / 4 requests | 13.84 GiB | 211.99 GiB |
| DP4xTP1 no-AllGather | Passed, 1 request | 15.02 s | 13.23 GiB | 187.77 GiB |
| DP2xTP2 AllGather | Passed, 2 concurrent requests | 4.16 s / 2 requests | 12.50 GiB | 211.97 GiB |
@@ -275,7 +510,7 @@ pipeline components, so each worker should be compared with the same worker in
the other storage mode.
| Worker | Ordinary RSS | mmap RSS | Ordinary PSS | mmap PSS | PSS reduction |
|---|---:|---:|---:|---:|---:|
| --- | ---: | ---: | ---: | ---: | ---: |
| DP worker 0 | 168.27 GiB | 132.76 GiB | 167.84 GiB | 101.43 GiB | 66.40 GiB |
| DP worker 1 | 116.19 GiB | 79.97 GiB | 115.73 GiB | 48.64 GiB | 67.09 GiB |
| **Two-worker total** | — | — | **283.56 GiB** | **150.08 GiB** | **133.48 GiB (47.1%)** |
+6 -1
View File
@@ -18,7 +18,7 @@ validation_paths:
- tests/diffusion/offloader/**
upstream_refs:
- torch.nn.Module.to
last_reviewed: 2026-07-16
last_reviewed: 2026-08-25
---
# Diffusion offloader
@@ -48,6 +48,11 @@ and correctness required by the selected execution mode.
**Rule:** Retained host and device copies MUST respect configured limits and
provide deterministic teardown.
Allocator-cache retention MUST remain local to an explicit component owner,
declare both cache and physical-free-memory bounds, release conservatively
when telemetry is unavailable, and force release on failure or memory
pressure. It MUST NOT replace unconditional executor shutdown cleanup.
## Safe-change guide
Test enabled and disabled paths, repeated execution, asynchronous transfer,
+14 -1
View File
@@ -16,7 +16,7 @@ validation_paths:
- tests/host_weight_runtime/**
upstream_refs:
- https://github.com/vllm-project/vllm-omni/issues/6414
last_reviewed: 2026-08-21
last_reviewed: 2026-08-25
---
# Host Weight Runtime
@@ -84,6 +84,13 @@ another. `WeightArtifactIdentity` includes:
- producer version, implementation fingerprint, manifest schema, and restorer
schema.
Loader integrations may persist content digests for canonical source shards in
the selected storage domain. Reuse requires the same path, inode, size,
timestamps, symlink target, and cache-record checksum. Corrupt records are
rebuilt, and coordination or cache-I/O failure falls back to hashing the
canonical source directly. This is an identity-computation optimization, not a
second source of truth or an artifact substitution rule.
The identity excludes transfer policy such as registered mmap versus private
pinned staging. Those paths move the same host representation and must not
produce duplicate artifacts.
@@ -237,6 +244,12 @@ Every publication computes full file and tensor hashes. Local lookup supports:
and
- `full_checksum`, which additionally reads and hashes every payload file.
An ordered producer may compute file and tensor digests while payload bytes are
written and overlap each closed payload's `fsync` with later producer work. An
unordered producer retains parallel readback hashing. In both cases manifest
and `READY` publication wait for every payload digest and `fsync`; the atomic
publication and validation contract is unchanged.
Filesystem verity is modeled but unsupported. Invalid artifacts receive an
external deny marker before their shared lookup lock is released; a build owner
then quarantines the immutable directory under the exclusive lock.
+2
View File
@@ -52,6 +52,8 @@ th {
| `LTX2DistilledOneStagePipeline` | LTX-2.5 merged-distilled one-stage T2V and I2V | `Lightricks/LTX-2.5-Diffusers` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/LTX/LTX-2.5.md) |
| `LTX2DistilledTwoStagePipeline` | LTX-2.5 merged-distilled two-stage T2V and I2V | `Lightricks/LTX-2.5-Diffusers` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/LTX/LTX-2.5.md) |
| `LingBotVideoPipeline` | LingBot-Video dense and MoE T2I, T2V, TI2V | `robbyant/lingbot-video-dense-1.3b`, `robbyant/lingbot-video-moe-30b-a3b` | ✅︎ | | | | — |
| `SanaVideoPipeline` | SANA-Video-2B T2V (native 480p/720p; Diffusers adapter validated at both resolutions) | `Efficient-Large-Model/SANA-Video_2B_480p_diffusers`, `Efficient-Large-Model/SANA-Video_2B_720p_diffusers` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/NVIDIA/SANA-Video-2B.md) |
| `SanaImageToVideoPipeline` | SANA-Video-2B I2V (native and Diffusers adapter validated at 480p/720p) | `Efficient-Large-Model/SANA-Video_2B_480p_diffusers`, `Efficient-Large-Model/SANA-Video_2B_720p_diffusers` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/NVIDIA/SANA-Video-2B.md) |
| `MiniMaxH3Pipeline` | MiniMax H3 T2VA, first/last-keyframe FL2VA, and mixed-reference Ref2VA | `MiniMaxAI/MiniMax-H3` | ✅︎ | ✅︎ | | | [Published](https://recipes.vllm.ai/MiniMaxAI/MiniMax-H3) |
| `DreamZeroPipeline` | DreamZero-DROID | `GEAR-Dreams/DreamZero-DROID` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `Pi0Pipeline` | π0 (Pi-Zero) VLA | `lerobot/pi0_base` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/lerobot/Pi0.md) |
+1 -1
View File
@@ -131,7 +131,7 @@ Content-Type: application/json
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `ref_audio` | string | null | Reference audio (HTTP URL, base64 data URL, or `file://` URI with `--allowed-local-media-path`) |
| `ref_audio` | string | null | Reference audio (HTTP URL, base64 data URL, or `file://` URI with `--allowed-local-media-path`). Local files fold `mtime_ns` and `size` into cache keys to automatically reload on-disk edits; HTTP URLs and base64 URIs remain cached by string locator. |
| `ref_text` | string | null | Transcript of reference audio |
| `x_vector_only_mode` | bool | null | Use speaker embedding only (no ICL) |
+10 -2
View File
@@ -101,8 +101,16 @@ set `DIFFUSION_ATTENTION_BACKEND=TORCH_SDPA` or configure
`--max-num-seqs >1`. See the
[HunyuanImage-3.0 recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/Tencent/HunyuanImage-3.0-Instruct.md)
for its validated configuration. Helios supports single-request step execution only: use
`--step-execution --max-num-seqs 1` for Helios. Consult the selected pipeline's
documentation and source for the latest support status.
`--step-execution --max-num-seqs 1` for Helios. MiniMax H3 supports step-wise
continuous batching by packing co-batched requests into one sequence that keeps
a separate attention document per request; that layout needs a backend which
honors the packed `cu_seqlens` metadata, so run it with
`--diffusion-attention-backend FLASH_ATTN` (other backends stay correct but
fall back to one transformer forward per request). Batching H3 does not improve
its throughput — see the measured numbers in the
[MiniMax-H3 recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/MiniMaxAI/MiniMax-H3.md)
— so keep `--max-num-seqs 1` unless you need step-level scheduling. Consult the
selected pipeline's documentation and source for the latest support status.
## Streaming Output
@@ -63,6 +63,9 @@ omni = Omni(
| `--dlo-use-allgather` | Shard host weights and reconstruct with AllGather | `true` |
| `--dlo-no-use-allgather` | Stream complete rank-local blocks without a DLO weight collective | `false` |
| `--dlo-resident-layers N` | Keep N leading main-DiT blocks on device; requires no-AllGather and model-declared resident paths | `0` |
| `--host-weight-runtime-mode {disabled,preferred,required}` | HWR policy: no interaction, populate on a miss, or require an exact hit | `disabled` |
| `--host-weight-runtime-root PATH` | Writable node-local HWR store shared by workers in one storage domain; required for `preferred` and `required` | unset |
| `--dlo-host-registration-limit-gib N` | Optional per-worker ceiling for registering an HWR mmap; zero adds no ceiling | `0` |
## Host-weight loading
@@ -104,6 +107,124 @@ process.
When the effective DLO group size is one, `dlo_use_allgather=True` does not
perform a collective and uses the same rank-local transfer behavior.
### Final-layout Host Weight Runtime
HWR is an opt-in startup optimization for models that declare the final-layout
BF16 restore contract. Use it only with no-AllGather DLO:
```bash
vllm serve /path/to/model --omni \
--enable-distributed-layerwise-offload \
--dlo-no-use-allgather \
--host-weight-runtime-mode preferred \
--host-weight-runtime-root /var/cache/vllm-omni/hwr
```
#### Choosing a mode
| Mode | Exact local artifact hit | Miss or recoverable artifact/store problem | Intended use |
| --- | --- | --- | --- |
| `disabled` | HWR is not consulted | Use the existing checkpoint-mmap or ordinary-loader path | Default compatibility path |
| `preferred` | Restore the final-layout artifact | Load canonically, serve with those tensors, and attempt to publish an artifact for the next startup | Normal deployment and store population |
| `required` | Restore the final-layout artifact | Fail startup without canonical DiT fallback or publication | Enforce a pre-populated store in controlled rollouts or CI |
Both enabled modes still fail on non-retryable configuration, identity, or
compatibility errors. `preferred` is a fallback policy for a cache miss or a
recoverable cache problem; it does not hide an invalid deployment.
#### Populating a store for `required` mode
`required` is deliberately consume-only. PR2 does not include a separate
prewarm command, so populate each node-local storage domain with one matching
`preferred` producer cohort:
1. Choose a persistent, writable root visible to every diffusion worker in the
storage domain. Do not use a process-private temporary directory.
2. Start the deployment with `preferred`, using the exact model revision,
dtype, TP size, SP configuration, and other weight-layout settings intended
for serving. No inference request is needed; publication happens during
model startup. Wait for the engine to become healthy, then shut it down
cleanly.
3. Restart with the same arguments and root, changing only the mode to
`required`. A successful startup proves that every worker acquired a valid
artifact; a miss, corrupt artifact, or incompatible identity fails startup.
4. Repeat the population start on every node or storage domain because this
store is node-local.
For example:
```bash
# First startup: canonically load and populate the exact artifacts.
vllm serve /path/to/model --omni \
--enable-distributed-layerwise-offload \
--dlo-no-use-allgather \
--host-weight-runtime-mode preferred \
--host-weight-runtime-root /var/cache/vllm-omni/hwr
# After a healthy startup and clean shutdown, enforce cache hits.
vllm serve /path/to/model --omni \
--enable-distributed-layerwise-offload \
--dlo-no-use-allgather \
--host-weight-runtime-mode required \
--host-weight-runtime-root /var/cache/vllm-omni/hwr
```
Include the same TP and SP layout flags in both commands. DP rank and DP size
are excluded from artifact identity, so the population and serving DP sizes may
differ and equivalent DP replicas share artifacts. TP rank is included, so a
TP-N deployment normally needs N rank-specific artifacts in each storage
domain; launching the matching TP cohort creates that set. If the model
revision or layout changes, run `preferred` again for the new identity before
returning to `required`.
On a cold start, the canonical loader remains authoritative and publishes a
validated final-layout artifact for later workers. A warm start restores the
DiT final tensors without ordinary DiT materialization. DLO then attempts to
register the immutable mapping for direct H2D; if registration is unavailable,
it streams through the same two bounded host staging slots. The artifact
identity includes the TP rank/size and SP layout, so TP1, TP2 rank-local shards,
and distinct SP layouts do not alias one another. Publication failure remains
separate from a `preferred` serving startup; the next `required` startup
provides the explicit artifact-availability check.
For local canonical checkpoints, the first eligible worker may hash source
shards to establish immutable identity. HWR caches those digests in the same
node-local storage domain and validates file metadata before reuse, so later
workers normally avoid repeating that read. Cold BF16 publication also hashes
ordered payloads as they are written and overlaps payload durability work with
later shards; this changes startup work only, not artifact contents or runtime
H2D behavior.
#### Registered direct H2D
Registration is attempted automatically only for an eligible warm HWR hit when
`pin_cpu_memory` is enabled. A successful path is:
```text
shared read-only HWR mmap -> existing rotating HBM block buffer -> GPU kernel
```
It removes the recurrent mmap-to-private-staging CPU copy and does not allocate
the two host staging slots. It does not reduce the H2D payload or make GPU
kernels access host memory directly.
CUDA requires read-only host-registration capability for the immutable HWR
mapping. Unsupported capability, a positive registration limit smaller than
the complete page-aligned mapping, or a safely rolled-back registration error
falls back to bounded staging. Programmatic configurations can set
`pin_cpu_memory=False` to disable registration explicitly. A successful
registration locks the complete mapped range in host memory for that worker's
lifetime, so use
`--dlo-host-registration-limit-gib` when an operator-enforced ceiling is
required.
Shutdown drains H2D work, releases hook/source references, unregisters every
range, and only then closes the HWR lease. AllGather and direct checkpoint mmap
retain their existing transfer paths.
When HWR is disabled, DLO is disabled, or AllGather is enabled, the loader does
not resolve HWR sources or construct its store.
## Declarative topology
Models may declare an `OffloadPlan` instead of embedding offload logic:
@@ -135,7 +256,9 @@ must enter each collective.
- Direct checkpoint mmap currently requires TP1. TP greater than one is
outside the Phase A shared-mmap support scope and falls back before model
mutation to the ordinary TP-aware loader. DLO can stream that runtime layout,
but it provides no shared-mmap host-memory benefit or guarantee.
and eligible no-AllGather configurations may use HWR final-layout artifacts,
but direct checkpoint mmap provides no shared-mmap host-memory guarantee for
TP greater than one.
- HSDP plus AllGather is rejected to avoid double sharding. HSDP without
AllGather has limited end-to-end validation.
- Per-tensor online FP8 linears use the ordinary loader and can run with either
@@ -147,10 +270,10 @@ must enter each collective.
`OffloadPlan` that declares eligible `resident_dit_paths`.
- DP concurrency requires an explicit, identical inference-step count.
Sharing transformed TP or quantized runtime layouts through a normalized mmap
cache is a follow-up design in
Sharing quantized or otherwise unvalidated transformed layouts through a
normalized HWR producer is a follow-up design in
[RFC #6195](https://github.com/vllm-project/vllm-omni/issues/6195), not part of
the direct-checkpoint path.
the current BF16 final-layout path.
See the [Cosmos3 DistOffload recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/cosmos3/Cosmos3-DistOffload.md)
for an end-to-end example.
+3 -2
View File
@@ -162,6 +162,7 @@ The following tables show which models support each feature:
| **LTX-2** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **LTX-2.3** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **LTX-2.5** | ❌ | ❓ (one-stage) | ❓ (Ulysses only) | ✅ (Full only) | ❓ | ❌ | ✅ | ✅ | ✅ (decode) | ❓ (FP8) | ❌ |
| **SANA-Video-2B T2V I2V** | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❌ |
| **Helios** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅* |
| **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (encode/decode) | ✅ | ❌ |
| **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
@@ -182,8 +183,8 @@ The following tables show which models support each feature:
**Frame Interpolation Support**
- **Supported**: Wan2.2 text-to-video, image-to-video, and TI2V pipelines
- **Not supported**: Wan2.1-VACE, LTX-2, LTX-2.3, LTX-2.5, Helios, HunyuanVideo-1.5, DreamID-Omni, SANA-WM
- **Supported**: Wan2.2 text-to-video, image-to-video, and TI2V pipelines; SANA-Video-2B native text-to-video and image-to-video pipelines
- **Not supported**: Wan2.1-VACE, LTX-2, LTX-2.3, LTX-2.5, Helios, HunyuanVideo-1.5, DreamID-Omni, SANA-WM; SANA-Video-2B Diffusers-adapter text-to-video and image-to-video pipelines
### AudioGen
@@ -4,8 +4,8 @@ Source <https://github.com/vllm-project/vllm-omni/tree/main/examples/offline_inf
This example demonstrates how to generate videos from images using Wan2.2,
LTX-2, and HunyuanVideo-1.5 Image-to-Video models with vLLM-Omni's
offline inference API.
LTX-2, HunyuanVideo-1.5, and SANA-Video Image-to-Video models with
vLLM-Omni's offline inference API.
## Local CLI Usage
@@ -66,9 +66,49 @@ python image_to_video.py \
See the [LTX-2 recipe](../../../../recipes/LTX/LTX-2.md) for all checkpoints,
pipeline selection, T2V, defaults, and advanced options.
### SANA-Video-2B
SANA checkpoints identify their upstream T2V pipeline in `model_index.json`.
Select the native I2V implementation explicitly:
```bash
python image_to_video.py \
--model Efficient-Large-Model/SANA-Video_2B_480p_diffusers \
--model-class-name SanaImageToVideoPipeline \
--image cherry_blossom.jpg \
--prompt "Cherry blossoms sway in the breeze as petals drift past the camera." \
--negative-prompt "blurry, low quality, temporal artifacts" \
--height 480 \
--width 832 \
--num-frames 81 \
--num-inference-steps 50 \
--guidance-scale 6.0 \
--extra-body '{"motion_score": 30}' \
--fps 16 \
--seed 42 \
--output sana_video_i2v_480p.mp4
```
For the 720p checkpoint, use
`Efficient-Large-Model/SANA-Video_2B_720p_diffusers` with
`--height 704 --width 1280`. The native I2V path supports both checkpoint
variants: the 480p checkpoint uses the Wan VAE, while the 720p checkpoint uses
the LTX-2 Video VAE.
For SANA-Video, 81 frames at 16 FPS is the standard checkpoint request
(approximately five seconds), not minute-scale long-video generation.
Minute-scale SANA generation requires the separate LongSANA/LongLive
autoregressive workflow, which this pipeline does not implement.
See the [SANA-Video recipe](../../../../recipes/NVIDIA/SANA-Video-2B.md) for
online serving, backend boundaries, and the tested hardware profile.
Key arguments:
- `--model`: Model ID (I2V-A14B for MoE, TI2V-5B for unified T2V+I2V, or LTX-2).
- `--model`: Model ID or local path (for example Wan I2V/TI2V, LTX-2, or
SANA-Video).
- `--model-class-name`: Explicit pipeline override. SANA-Video I2V requires
`SanaImageToVideoPipeline`.
- `--image`: Path to input image (required).
- `--prompt`: Text description of desired motion/animation.
- `--height/--width`: Output resolution (auto-calculated from image if not set).
@@ -16,6 +16,8 @@ For backend selection and SageAttention usage, see the [Diffusion Attention Back
| `Lightricks/LTX-2` | 512x768 | 121 | 40 | video 3.0 / audio 7.0 | Model-dependent |
| `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v` | 480x832 | 121 | 50 | 6.0 | 1×A100 80GB |
| `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_t2v` | 720x1280 | 121 | 50 | 6.0 | FP8 + VAE tiling required |
| `Efficient-Large-Model/SANA-Video_2B_480p_diffusers` | 480x832 | 81 | 50 | 6.0 | BF16 DiT + FP32 Wan VAE |
| `Efficient-Large-Model/SANA-Video_2B_720p_diffusers` | 704x1280 | 81 | 50 | 6.0 | ~23.6 GiB |
## Local CLI Usage
@@ -122,6 +124,31 @@ python text_to_video.py \
--output quick_test.mp4
```
### SANA-Video-2B
```bash
python text_to_video.py \
--model Efficient-Large-Model/SANA-Video_2B_480p_diffusers \
--model-class-name SanaVideoPipeline \
--prompt "A cinematic tracking shot of a sailboat crossing the ocean at sunset." \
--height 480 \
--width 832 \
--num-frames 81 \
--num-inference-steps 50 \
--guidance-scale 6.0 \
--extra-body '{"motion_score": 30}' \
--fps 16 \
--output sana_video_480p.mp4
```
For the 720p checkpoint, switch the model to
`Efficient-Large-Model/SANA-Video_2B_720p_diffusers`, use
`--height 704 --width 1280`, and write to a different output path. SANA-Video
accepts `motion_score`, `clean_caption`, and `use_resolution_binning` through
`--extra-body`. For image-to-video, use the shared
`examples/offline_inference/image_to_video/image_to_video.py` example with
`--model-class-name SanaImageToVideoPipeline` and `--image <path>`.
## Key Arguments
### Common
@@ -1,193 +0,0 @@
# Text-To-Audio
Source <https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/stable_audio>.
This example demonstrates how to deploy Stable Audio models for online text-to-audio generation using vLLM-Omni.
## Supported Models
| Model | Description |
|-------|-------------|
| `stabilityai/stable-audio-open-1.0` | Open-source audio generation, up to ~47 seconds, 44.1 kHz stereo |
## Start Server
### Basic Start
```bash
vllm-omni serve stabilityai/stable-audio-open-1.0 \
--host 0.0.0.0 \
--port 8091 \
--gpu-memory-utilization 0.9 \
--trust-remote-code \
--enforce-eager \
--omni
```
## API Calls
### Method 1: Using curl
```bash
# Run all curl examples
bash curl_examples.sh
# Or execute directly
curl -X POST http://localhost:8091/v1/audio/generate \
-H "Content-Type: application/json" \
-d '{
"input": "The sound of a cat purring",
"audio_length": 10.0
}' --output cat.wav
```
### Method 2: Using Python Client
```bash
cd examples/online_serving/stable_audio
# Simple generation
python stable_audio_client.py \
--text "The sound of a cat purring"
# With custom duration
python stable_audio_client.py \
--text "A dog barking" \
--audio_length 5.0
# With all parameters
python stable_audio_client.py \
--text "Thunder and rain" \
--audio_length 15.0 \
--negative_prompt "Low quality" \
--guidance_scale 7.0 \
--num_inference_steps 100 \
--seed 42 \
--output thunder.wav
```
The Python client supports the following command-line arguments:
- `--api_url`: API endpoint URL (default: `http://localhost:8091/v1/audio/generate`)
- `--text`: Text prompt for audio generation (default: `"The sound of a cat purring"`)
- `--audio_length`: Audio length in seconds (default: `10.0`, max ~47s for `stable-audio-open-1.0`)
- `--audio_start`: Audio start time in seconds (default: `0.0`)
- `--negative_prompt`: Negative prompt for classifier-free guidance (default: `"Low quality"`)
- `--guidance_scale`: Guidance scale for diffusion (default: `7.0`)
- `--num_inference_steps`: Number of inference steps (default: `100`)
- `--seed`: Random seed for reproducibility (default: `None`)
- `--response_format`: Audio output format (default: `wav`). Options: `wav`, `mp3`, `flac`, `pcm`
- `--output`: Output file path (default: `stable_audio_output.wav`)
### Method 3: Using Python httpx
```python
import httpx
response = httpx.post(
"http://localhost:8091/v1/audio/generate",
json={
"input": "The sound of ocean waves crashing on a beach",
"audio_length": 10.0,
"negative_prompt": "Low quality, distorted",
"guidance_scale": 7.0,
"num_inference_steps": 100,
},
timeout=300.0,
)
with open("ocean.wav", "wb") as f:
f.write(response.content)
```
## Request Format
### Simple Generation
```json
{
"input": "The sound of ocean waves"
}
```
### Generation with Parameters
```json
{
"input": "A piano playing a gentle melody",
"audio_length": 10.0,
"negative_prompt": "Low quality, distorted, noisy",
"guidance_scale": 8.0,
"num_inference_steps": 150,
"seed": 42,
"response_format": "wav"
}
```
## API Reference
### Endpoint
```
POST /v1/audio/generate
Content-Type: application/json
```
### Generation Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `input` | string | **required** | Text prompt describing the audio to generate |
| `model` | string | server's model | Model to use (optional, should match server if specified) |
| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, opus |
| `speed` | float | 1.0 | Playback speed (0.25 - 4.0) |
| `audio_length` | float | null | Audio duration in seconds (max ~47s for `stable-audio-open-1.0`) |
| `audio_start` | float | 0.0 | Audio start time in seconds |
| `negative_prompt` | string | null | Text describing what to avoid in generation |
| `guidance_scale` | float | model default | Classifier-free guidance scale (higher = more adherence to prompt) |
| `num_inference_steps` | int | model default | Number of denoising steps (higher = better quality, slower) |
| `seed` | int | null | Random seed for reproducible generation |
### Response Format
Returns binary audio data with appropriate `Content-Type` header (e.g., `audio/wav`).
## Tuning Tips
1. **Audio Length**: Keep under 47 seconds for `stable-audio-open-1.0`.
2. **Quality vs Speed**:
- 50 steps: Fast, decent quality (quick previews)
- 100 steps: Good balance (general purpose)
- 150+ steps: High quality, slower (final / critical audio)
3. **Guidance Scale**:
- Lower (3 - 5): More creative / varied output
- Default (7): Good balance
- Higher (10+): Strict adherence to the prompt
4. **Negative Prompts**: Use to avoid unwanted characteristics such as `"Low quality"`, `"distorted"`, `"noisy"`.
5. **Seeds**: Set a fixed seed to get deterministic, reproducible results.
## File Description
| File | Description |
|------|-------------|
| `curl_examples.sh` | Curl examples covering common use cases |
| `stable_audio_client.py` | Python client with full CLI argument support |
## Troubleshooting
1. **Audio generation model did not produce audio output**: Verify the server started successfully and the model loaded without errors.
2. **Connection refused**: Make sure the server is running on the correct port.
3. **Generation timeout**: Reduce `num_inference_steps` or `audio_length`, and check GPU memory with `nvidia-smi`.
4. **Out of memory**: Lower `--gpu-memory-utilization` or reduce `audio_length`.
5. **Audio quality issues**: Increase `num_inference_steps`, add a negative prompt, or raise `guidance_scale`.
## Example materials
??? abstract "stable_audio_client.py"
``````py
--8<-- "examples/online_serving/stable_audio/stable_audio_client.py"
``````
??? abstract "curl_examples.sh"
``````sh
--8<-- "examples/online_serving/stable_audio/curl_examples.sh"
``````
@@ -1,7 +1,7 @@
# Image-To-Video
This shared example generates videos from images with VACE, Wan2.2, LTX-2,
HunyuanVideo-1.5, Cosmos3, and other compatible pipelines.
HunyuanVideo-1.5, SANA-Video, Cosmos3, and other compatible pipelines.
- `image_to_video.py`: command-line script for single video generation with advanced options.
@@ -27,6 +27,8 @@ This folder provides a unified CLI script for image-to-video generation using vL
| `Wan-AI/Wan2.2-TI2V-5B-Diffusers` | 480 x 832 | 81 | 50 | 4.0 | Around 20–25 GiB BF16, smallest I2V model |
| `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_i2v` | 480 x 832 | 121 | 50 | 6.0 | Around 100 GiB at default settings; the example enables `--enable-cpu-offload` + VAE tiling/slicing to fit an 80 GiB card |
| `Lightricks/LTX-2` | 512 x 768 | 121 | 40 | video 3.0 / audio 7.0 | Memory use depends on frame count and tensor parallelism |
| `Efficient-Large-Model/SANA-Video_2B_480p_diffusers` | 480 x 832 | 81 | 50 | 6.0 | Native `SanaImageToVideoPipeline`; Wan VAE |
| `Efficient-Large-Model/SANA-Video_2B_720p_diffusers` | 704 x 1280 | 81 | 50 | 6.0 | Native `SanaImageToVideoPipeline`; LTX-2 Video VAE |
!!! info
Peak VRAM: based on basic single-card usage, batch size = 1, without any acceleration/optimization features. Some model weights cannot fit into one card with 80 GiB VRAM, which may need to use CPU offloading.
@@ -227,6 +229,48 @@ python image_to_video.py \
See the [LTX-2 recipe](../../../recipes/LTX/LTX-2.md) for all checkpoints,
pipeline selection, T2V, defaults, and advanced options.
### SANA-Video-2B
The checkpoint metadata selects the upstream T2V pipeline, so pass
`SanaImageToVideoPipeline` explicitly for I2V:
```bash
python image_to_video.py \
--model Efficient-Large-Model/SANA-Video_2B_480p_diffusers \
--model-class-name SanaImageToVideoPipeline \
--image cherry_blossom.jpg \
--prompt "Cherry blossoms sway in the breeze as petals drift past the camera." \
--negative-prompt "blurry, low quality, temporal artifacts" \
--height 480 \
--width 832 \
--num-frames 81 \
--num-inference-steps 50 \
--guidance-scale 6.0 \
--extra-body '{"motion_score": 30}' \
--fps 16 \
--seed 42 \
--output sana_video_i2v_480p.mp4
```
Use the 720p checkpoint with `--height 704 --width 1280`. In this example,
81 frames at 16 FPS means the standard approximately five-second SANA-Video
checkpoint request. It does not enable the separate LongSANA/LongLive
minute-scale autoregressive workflow.
The native `SanaImageToVideoPipeline` supports both checkpoint variants. The
480p path uses vLLM-Omni's `DistributedAutoencoderKLWan` wrapper and the 720p
path uses `DistributedAutoencoderKLLTX2Video`; both wrap their corresponding
Diffusers VAE implementations. The native denoising loop intentionally keeps
Diffusers' checkpoint-compatible `DPMSolverMultistepScheduler`.
The Diffusers adapter has completed end-to-end I2V serving validation at both
480p and 720p. For online adapter serving, select the matching checkpoint and
use `WIDTH=1280 HEIGHT=704` for the 720p request. Adapter T2V is also validated
at both resolutions; see the recipe for the exact backend matrix.
See the [SANA-Video recipe](../../../recipes/NVIDIA/SANA-Video-2B.md) for
online serving, native/adapter validation boundaries, and hardware notes.
## Advanced Features
### CFG Parallel
@@ -3,13 +3,14 @@
"""
Image-to-Video generation example using Wan2.2 I2V/TI2V models, LTX2/LTX-2.3,
HunyuanVideo-1.5, Cosmos3, or Wan2.1 VACE.
HunyuanVideo-1.5, SANA-Video, Cosmos3, or Wan2.1 VACE.
Supports:
- Wan2.2-I2V-A14B-Diffusers: MoE model with CLIP image encoder
- Wan2.2-TI2V-5B-Diffusers: Unified T2V+I2V model (dense 5B)
- LTX2 image-to-video pipeline
- HunyuanVideo-1.5 I2V: SigLIP + VAE dual image conditioning
- SANA-Video 2B: first-frame latent conditioning at 480p or 720p
- Wan2.1 VACE: first/last-frame, inpainting, and reference conditioning
Usage:
@@ -38,6 +39,13 @@ Usage:
--image input.jpg --prompt "A cat playing with yarn" \
--flow-shift 5.0 --guidance-scale 6.0
# SANA-Video 2B I2V (480p)
python image_to_video.py --model Efficient-Large-Model/SANA-Video_2B_480p_diffusers \
--model-class-name SanaImageToVideoPipeline \
--image input.jpg --prompt "A cat turns toward the camera." \
--height 480 --width 832 --num-frames 81 --num-inference-steps 50 \
--guidance-scale 6.0
# Cosmos3 I2V (image conditioning)
python image_to_video.py --model nvidia/Cosmos3-Nano \
--image input.jpg --prompt "The scene comes to life with smooth, natural motion." \
@@ -107,13 +115,16 @@ def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Generate a video from one or more images "
"(Wan2.2, LTX2/LTX-2.3, HunyuanVideo-1.5, Cosmos3, or Wan2.1 VACE)."
"(Wan2.2, LTX2/LTX-2.3, HunyuanVideo-1.5, SANA-Video, Cosmos3, or Wan2.1 VACE)."
)
)
parser.add_argument(
"--model",
default="Wan-AI/Wan2.2-I2V-A14B-Diffusers",
help="Diffusers I2V model ID or local path (Wan2.2, LTX2/LTX-2.3, HunyuanVideo-1.5, Cosmos3, or Wan2.1 VACE).",
help=(
"Diffusers I2V model ID or local path "
"(Wan2.2, LTX2/LTX-2.3, HunyuanVideo-1.5, SANA-Video, Cosmos3, or Wan2.1 VACE)."
),
)
parser.add_argument(
"--model-class-name",
@@ -377,6 +388,7 @@ def main():
is_ltx2_distilled = "distilled" in model_class_name_lower or "distilled" in model_name
is_ltx23 = "ltx23" in model_class_name_lower or "ltx-2.3" in model_name
is_ltx2 = is_ltx2_distilled or is_ltx23 or "ltx2" in model_class_name_lower or "ltx-2" in model_name
is_sana = model_class_name == "SanaImageToVideoPipeline" or "sana-video" in model_name
is_cosmos = "cosmos" in model_name or (model_class_name is not None and "cosmos" in model_class_name.lower())
is_cosmos_edge = is_cosmos and ("edge" in model_name or "edge" in model_class_name_lower)
@@ -434,6 +446,17 @@ def main():
512 * 768,
32,
)
elif is_sana:
is_720p = "720p" in model_name
d_fps, d_guidance, d_num_frames, d_steps, d_flow_shift, d_max_area, d_mod = (
16,
6.0,
81,
50,
5.0,
(704 * 1280) if is_720p else (480 * 832),
32 if is_720p else 16,
)
else: # Wan2.2 / HunyuanVideo-1.5
d_fps, d_guidance, d_num_frames, d_steps, d_flow_shift, d_max_area, d_mod = 16, 5.0, 81, 50, 5.0, 480 * 832, 16
@@ -14,6 +14,8 @@ A unified script for text-to-video generation. Supports multiple models with mod
| `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_t2v` | 720x1280 | 121 | 50 | 6.0 | FP8 + VAE tiling required |
| `nvidia/Cosmos3-Nano` | 720x1280 | 189 | 35 | 6.0 | ~46 GiB (peak, 720p) |
| `BestWishYsh/Helios-Base` / `Helios-Mid` / `Helios-Distilled` | 384x640 | 99 | 50 | 5.0 / 5.0 / 1.0 | — |
| `Efficient-Large-Model/SANA-Video_2B_480p_diffusers` | 480x832 | 81 | 50 | 6.0 | BF16 DiT + FP32 Wan VAE wrapper |
| `Efficient-Large-Model/SANA-Video_2B_720p_diffusers` | 704x1280 | 81 | 50 | 6.0 | BF16 DiT + LTX-2 Video VAE wrapper |
## Local CLI Usage
@@ -151,6 +153,40 @@ python text_to_video.py \
--output quick_test.mp4
```
### SANA-Video-2B
Select the native T2V pipeline explicitly:
```bash
python text_to_video.py \
--model Efficient-Large-Model/SANA-Video_2B_480p_diffusers \
--model-class-name SanaVideoPipeline \
--prompt "A cinematic tracking shot of a sailboat crossing the ocean at sunset." \
--height 480 \
--width 832 \
--num-frames 81 \
--num-inference-steps 50 \
--guidance-scale 6.0 \
--extra-body '{"motion_score": 30}' \
--fps 16 \
--output sana_video_480p.mp4
```
For the 720p checkpoint, switch the model to
`Efficient-Large-Model/SANA-Video_2B_720p_diffusers`, use
`--height 704 --width 1280`, and write to a different output path. Both native
checkpoint variants are supported. The Diffusers adapter T2V compatibility
path is also validated at both resolutions through online serving.
The native 480p path uses vLLM-Omni's `DistributedAutoencoderKLWan` wrapper;
the native 720p path uses `DistributedAutoencoderKLLTX2Video`. Both wrappers
retain their underlying Diffusers autoencoders. The pipeline intentionally
uses Diffusers' checkpoint-compatible `DPMSolverMultistepScheduler`.
For image-to-video, use the shared
[`image_to_video.py`](../image_to_video/README.md#sana-video-2b) example with
`--model-class-name SanaImageToVideoPipeline` and `--image <path>`.
### Cosmos3
```bash
@@ -114,12 +114,32 @@ _MODEL_PRESETS = {
"fps": 24,
"output": "ltx23_output.mp4",
},
"sana_480p": {
"height": 480,
"width": 832,
"num_frames": 81,
"num_inference_steps": 50,
"guidance_scale": 6.0,
"fps": 16,
"output": "sana_video_480p.mp4",
},
"sana_720p": {
"height": 704,
"width": 1280,
"num_frames": 81,
"num_inference_steps": 50,
"guidance_scale": 6.0,
"fps": 16,
"output": "sana_video_720p.mp4",
},
}
def _detect_preset(model: str, model_class_name: str | None = None) -> dict:
model_lower = model.lower()
class_lower = (model_class_name or "").lower()
if "sana-video" in model_lower or "sana_video" in model_lower or "sanavideo" in class_lower:
return _MODEL_PRESETS["sana_720p" if "720p" in model_lower else "sana_480p"]
if "lingbot" in model_lower or "lingbotvideo" in class_lower:
return _MODEL_PRESETS["lingbot"]
if "ltx" in class_lower or "ltx" in model_lower:
@@ -1,5 +1,19 @@
# Image-To-Video
This example demonstrates how to deploy image-to-video models for online
video generation using vLLM-Omni.
## Supported Models
| Model | Model ID |
|-------|----------|
| Wan2.2 I2V | `Wan-AI/Wan2.2-I2V-A14B-Diffusers` |
| SANA-Video 2B (480p) | `Efficient-Large-Model/SANA-Video_2B_480p_diffusers` |
| SANA-Video 2B (720p) | `Efficient-Large-Model/SANA-Video_2B_720p_diffusers` |
For SANA-Video 2B image-to-video online serving, see the
[SANA-Video 2B recipe](../../../recipes/NVIDIA/SANA-Video-2B.md).
This example demonstrates online image-to-video generation with vLLM-Omni.
The startup script defaults to Wan2.2, but `MODEL` can select any supported
image-to-video model. The existing curl helper remains a concrete Wan2.2
@@ -0,0 +1,67 @@
#!/bin/bash
# Submit and download a standard SANA-Video-2B image-to-video request.
set -euo pipefail
INPUT_IMAGE="${INPUT_IMAGE:-}"
BASE_URL="${BASE_URL:-http://localhost:8099}"
OUTPUT_PATH="${OUTPUT_PATH:-sana_video_i2v.mp4}"
WIDTH="${WIDTH:-832}"
HEIGHT="${HEIGHT:-480}"
POLL_INTERVAL="${POLL_INTERVAL:-2}"
if [ -z "$INPUT_IMAGE" ] || [ ! -f "$INPUT_IMAGE" ]; then
echo "Set INPUT_IMAGE to an existing image file."
exit 1
fi
create_response="$(
curl -sS -X POST "${BASE_URL}/v1/videos" \
-H "Accept: application/json" \
-F "prompt=A cat turns toward the camera with smooth, natural motion." \
-F "negative_prompt=blurry, low quality, temporal artifacts" \
-F "input_reference=@${INPUT_IMAGE}" \
-F "width=${WIDTH}" \
-F "height=${HEIGHT}" \
-F "num_frames=81" \
-F "fps=16" \
-F "num_inference_steps=50" \
-F "guidance_scale=6.0" \
-F "seed=42" \
-F 'extra_params={"motion_score":30}'
)"
video_id="$(echo "${create_response}" | jq -r '.id')"
if [ -z "${video_id}" ] || [ "${video_id}" = "null" ]; then
echo "Failed to create SANA-Video job:"
echo "${create_response}" | jq .
exit 1
fi
while true; do
status_response="$(curl -sS "${BASE_URL}/v1/videos/${video_id}")"
status="$(echo "${status_response}" | jq -r '.status')"
case "${status}" in
queued|in_progress)
echo "Video job ${video_id} status: ${status}"
sleep "${POLL_INTERVAL}"
;;
completed)
break
;;
failed)
echo "SANA-Video generation failed:"
echo "${status_response}" | jq .
exit 1
;;
*)
echo "Unexpected status response:"
echo "${status_response}" | jq .
exit 1
;;
esac
done
curl -sS -L "${BASE_URL}/v1/videos/${video_id}/content" -o "${OUTPUT_PATH}"
echo "Saved SANA-Video to ${OUTPUT_PATH}"
@@ -0,0 +1,13 @@
#!/bin/bash
# SANA-Video-2B native image-to-video serving.
set -euo pipefail
MODEL="${MODEL:-Efficient-Large-Model/SANA-Video_2B_480p_diffusers}"
PORT="${PORT:-8099}"
vllm serve "$MODEL" \
--omni \
--model-class-name SanaImageToVideoPipeline \
--dtype bfloat16 \
--port "$PORT"
@@ -0,0 +1,18 @@
#!/bin/bash
# SANA-Video-2B image-to-video serving through the Diffusers adapter.
# Both 480p and 720p checkpoints are validated. For 720p:
# MODEL=Efficient-Large-Model/SANA-Video_2B_720p_diffusers bash run_server_sana_video_diffusers.sh
# INPUT_IMAGE=input.jpg WIDTH=1280 HEIGHT=704 bash run_curl_sana_video.sh
set -euo pipefail
MODEL="${MODEL:-Efficient-Large-Model/SANA-Video_2B_480p_diffusers}"
PORT="${PORT:-8099}"
vllm serve "$MODEL" \
--omni \
--model-class-name SanaImageToVideoPipeline \
--diffusion-load-format diffusers \
--diffusion-attention-backend TORCH_SDPA \
--dtype bfloat16 \
--port "$PORT"
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Minimal single-input MiniCPM-o 4.5 Realtime duplex demo.
Run this after starting the duplex server. Strict lifecycle, overlap, and
@@ -19,8 +22,8 @@ if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from vllm_omni.experimental.fullduplex.client import ( # noqa: E402
PCM16_BYTES_PER_SAMPLE,
PCM16_SAMPLE_RATE,
PCM16_BYTES_PER_SAMPLE, # noqa: F401 - compatibility export
PCM16_SAMPLE_RATE, # noqa: F401 - compatibility export
RealtimeDuplexClient,
RealtimeEventCollector,
build_realtime_url,
@@ -28,6 +31,13 @@ from vllm_omni.experimental.fullduplex.client import ( # noqa: E402
wait_for,
write_pcm16_wav,
)
from vllm_omni.experimental.fullduplex.client import chunk_period_ms as _chunk_period_ms # noqa: E402
from vllm_omni.experimental.fullduplex.client import ( # noqa: E402
has_residual_model_unit as _has_residual_model_unit,
)
from vllm_omni.experimental.fullduplex.client import ( # noqa: E402
reference_audio_data_url as _ref_audio_data_url,
)
class _StreamingOutputWriter:
@@ -133,25 +143,6 @@ def _latest_model_decision(
return decision
def _chunk_period_ms(events: list[dict[str, object]]) -> int:
for event in reversed(events):
session = event.get("session")
if not isinstance(session, dict):
continue
capabilities = session.get("capabilities")
if not isinstance(capabilities, dict):
continue
chunk_period_ms = capabilities.get("chunk_period_ms")
if isinstance(chunk_period_ms, int) and chunk_period_ms > 0:
return chunk_period_ms
return 1000
def _has_residual_model_unit(pcm16: bytes, *, chunk_period_ms: int) -> bool:
unit_bytes = PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE * chunk_period_ms // 1000
return bool(unit_bytes > 0 and len(pcm16) % unit_bytes)
def _response_in_progress(events: list[dict[str, object]]) -> bool:
return sum(event.get("type") == "response.created" for event in events) > sum(
event.get("type") == "response.done" for event in events
@@ -168,13 +159,6 @@ def _event_count_after(
return sum(event.get("type") == event_type for event in events[index + 1 :])
def _ref_audio_data_url(path: str | None) -> str | None:
if path is None:
return None
ref_path = Path(path).expanduser()
return "data:audio/wav;base64," + base64.b64encode(ref_path.read_bytes()).decode("ascii")
async def run_demo(args: argparse.Namespace) -> dict[str, object]:
input_pcm16 = read_pcm16_wav(Path(args.input_wav))
if not input_pcm16:
@@ -1,234 +0,0 @@
# Stable Audio Online Serving
Generate audio from text prompts using Stable Audio models via an OpenAI-compatible API endpoint.
## Features
- **OpenAI-compatible API**: Use `/v1/audio/generate` endpoint
- **Flexible control**: Adjust audio length, guidance scale, inference steps
- **Quality control**: Use negative prompts to avoid unwanted characteristics
- **Reproducible**: Set random seed for deterministic generation
## Quick Start
### 1. Start the Server
```bash
vllm-omni serve stabilityai/stable-audio-open-1.0 \
--host 0.0.0.0 \
--port 8091 \
--gpu-memory-utilization 0.9 \
--trust-remote-code \
--enforce-eager \
--omni
```
### 2. Generate Audio
#### Using curl
```bash
curl -X POST http://localhost:8091/v1/audio/generate \
-H "Content-Type: application/json" \
-d '{
"input": "The sound of a cat purring",
"audio_length": 10.0
}' --output cat.wav
```
#### Using Python Client
```bash
python stable_audio_client.py \
--text "The sound of a cat purring" \
--audio_length 10.0 \
--output cat.wav
```
#### Using Bash Script
```bash
bash curl_examples.sh
```
## API Reference
### Endpoint
```
POST /v1/audio/generate
```
### Request Body
```json
{
"input": "Text description of the audio",
"audio_length": 10.0,
"audio_start": 0.0,
"negative_prompt": "Low quality",
"guidance_scale": 7.0,
"num_inference_steps": 100,
"seed": 42,
"response_format": "wav"
}
```
### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `input` | string | **required** | Text prompt describing the audio to generate |
| `audio_length` | float | ~47s | Audio duration in seconds (max ~47s for stable-audio-open-1.0) |
| `audio_start` | float | 0.0 | Audio start time in seconds |
| `negative_prompt` | string | null | Text describing what to avoid in generation |
| `guidance_scale` | float | 7.0 | Classifier-free guidance scale (higher = more adherence to prompt) |
| `num_inference_steps` | int | 50 | Number of denoising steps (higher = better quality, slower) |
| `seed` | int | null | Random seed for reproducibility |
| `response_format` | string | "wav" | Output format: wav, mp3, flac, pcm |
### Response
Returns audio data in the requested format (default: WAV).
## Usage Examples
### Basic Generation
```bash
curl -X POST http://localhost:8091/v1/audio/generate \
-H "Content-Type: application/json" \
-d '{
"input": "The sound of ocean waves"
}' --output ocean.wav
```
### Custom Duration
```bash
curl -X POST http://localhost:8091/v1/audio/generate \
-H "Content-Type: application/json" \
-d '{
"input": "A dog barking",
"audio_length": 5.0
}' --output dog_5s.wav
```
### High Quality with Negative Prompt
```bash
curl -X POST http://localhost:8091/v1/audio/generate \
-H "Content-Type: application/json" \
-d '{
"input": "A piano playing a gentle melody",
"audio_length": 10.0,
"negative_prompt": "Low quality, distorted, noisy",
"guidance_scale": 8.0,
"num_inference_steps": 150
}' --output piano_hq.wav
```
### Reproducible Generation
```bash
curl -X POST http://localhost:8091/v1/audio/generate \
-H "Content-Type: application/json" \
-d '{
"input": "Thunder and rain sounds",
"audio_length": 15.0,
"seed": 42
}' --output thunder.wav
```
### Quick Generation (Fewer Steps)
For faster generation with slightly lower quality:
```bash
curl -X POST http://localhost:8091/v1/audio/generate \
-H "Content-Type: application/json" \
-d '{
"input": "Birds chirping in a forest",
"audio_length": 8.0,
"num_inference_steps": 50
}' --output birds_quick.wav
```
## Python Client Examples
### Simple Generation
```bash
python stable_audio_client.py \
--text "The sound of a cat purring"
```
### Custom Parameters
```bash
python stable_audio_client.py \
--text "Thunder and rain" \
--audio_length 15.0 \
--negative_prompt "Low quality" \
--guidance_scale 7.0 \
--num_inference_steps 100 \
--seed 42 \
--output thunder.wav
```
### Different Output Format
```bash
python stable_audio_client.py \
--text "Guitar playing" \
--response_format mp3 \
--output guitar.mp3
```
## Tips
1. **Audio Length**: Keep under 47 seconds for `stable-audio-open-1.0`
2. **Quality vs Speed**:
- 50 steps: Fast, decent quality
- 100 steps: Good balance (default)
- 150+ steps: High quality, slower
3. **Guidance Scale**:
- Lower (3-5): More creative/varied
- Default (7): Good balance
- Higher (10+): More literal to prompt
4. **Negative Prompts**: Use to avoid "Low quality", "distorted", "noisy", etc.
5. **Seeds**: Use same seed for reproducible results
## Performance
| Inference Steps | Quality | Speed | Use Case |
|----------------|---------|-------|----------|
| 50 | Good | Fast | Quick previews |
| 100 (default) | Very Good | Medium | Production |
| 150+ | Excellent | Slow | Final/critical audio |
## Troubleshooting
### Server not responding
- Check if server is running: `curl http://localhost:8091/health`
- Check server logs for errors
### Audio quality issues
- Increase `num_inference_steps` (e.g., 150)
- Add negative prompts: `"Low quality, distorted, noisy"`
- Increase `guidance_scale` for more prompt adherence
### Generation timeout
- Reduce `num_inference_steps`
- Reduce `audio_length`
- Check GPU memory with `nvidia-smi`
### Wrong audio length
- Ensure `audio_length` is within model limits (~47s max)
- Adjust `audio_start` if trimming is needed
## See Also
- [Offline Inference Example](../../offline_inference/text_to_audio/README.md)
- [Stable Audio Model Card](https://huggingface.co/stabilityai/stable-audio-open-1.0)
- [vLLM-Omni Documentation](https://github.com/vllm-project/vllm-omni)
@@ -1,54 +0,0 @@
#!/bin/bash
# Examples for using Stable Audio with curl via /v1/audio/generate endpoint
# Example 1: Simple request with default parameters
echo "Example 1: Simple request with default parameters"
curl -X POST http://localhost:8091/v1/audio/generate \
-H "Content-Type: application/json" \
-d '{
"input": "The sound audience clapping and cheering in a stadium"
}' --output stadium.wav
# Example 2: Request with custom audio_length
echo "Example 2: Custom audio length (5 seconds)"
curl -X POST http://localhost:8091/v1/audio/generate \
-H "Content-Type: application/json" \
-d '{
"input": "The sound of a dog barking",
"audio_length": 5.0
}' --output dog_5s.wav
# Example 3: Request with negative prompt for quality control
echo "Example 3: With negative prompt"
curl -X POST http://localhost:8091/v1/audio/generate \
-H "Content-Type: application/json" \
-d '{
"input": "A piano playing a gentle melody",
"audio_length": 10.0,
"negative_prompt": "Low quality, distorted, noisy"
}' --output piano.wav
# Example 4: Full control with all parameters
echo "Example 4: Full control (custom length, guidance, steps, seed)"
curl -X POST http://localhost:8091/v1/audio/generate \
-H "Content-Type: application/json" \
-d '{
"input": "Thunder and rain sounds",
"audio_length": 15.0,
"negative_prompt": "Low quality",
"guidance_scale": 7.0,
"num_inference_steps": 100,
"seed": 42
}' --output thunder_rain.wav
# Example 5: Quick generation with fewer steps (faster but lower quality)
echo "Example 5: Quick generation (fewer steps)"
curl -X POST http://localhost:8091/v1/audio/generate \
-H "Content-Type: application/json" \
-d '{
"input": "Ocean waves crashing on a beach",
"audio_length": 8.0,
"num_inference_steps": 50
}' --output ocean.wav
echo "All examples completed!"
@@ -1,170 +0,0 @@
#!/usr/bin/env python3
"""
OpenAI-compatible client for Stable Audio via /v1/audio/generate endpoint.
This script demonstrates how to use the OpenAI-compatible speech API
to generate audio from text using Stable Audio models.
Examples:
# Simple generation
python stable_audio_client.py --text "The sound of a cat purring"
# With custom duration
python stable_audio_client.py --text "A dog barking" --audio_length 5.0
# With all parameters
python stable_audio_client.py --text "Thunder and rain" \
--audio_length 15.0 \
--negative_prompt "Low quality" \
--guidance_scale 7.0 \
--num_inference_steps 100 \
--seed 42 \
--output thunder.wav
"""
import argparse
import sys
import requests
def parse_args():
parser = argparse.ArgumentParser(description="Generate audio with Stable Audio via OpenAI-compatible API")
parser.add_argument(
"--api_url",
default="http://localhost:8091/v1/audio/generate",
help="API endpoint URL",
)
parser.add_argument(
"--text",
default="The sound of a cat purring",
help="Text prompt for audio generation",
)
parser.add_argument(
"--audio_length",
type=float,
default=10.0,
help="Audio length in seconds (max ~47s for stable-audio-open-1.0)",
)
parser.add_argument(
"--audio_start",
type=float,
default=0.0,
help="Audio start time in seconds",
)
parser.add_argument(
"--negative_prompt",
default="Low quality",
help="Negative prompt for classifier-free guidance",
)
parser.add_argument(
"--guidance_scale",
type=float,
default=7.0,
help="Guidance scale for diffusion (higher = more adherence to prompt)",
)
parser.add_argument(
"--num_inference_steps",
type=int,
default=100,
help="Number of inference steps (higher = better quality, slower)",
)
parser.add_argument(
"--seed",
type=int,
default=None,
help="Random seed for reproducibility",
)
parser.add_argument(
"--output",
default="stable_audio_output.wav",
help="Output file path",
)
parser.add_argument(
"--response_format",
default="wav",
choices=["wav", "mp3", "flac", "pcm"],
help="Audio output format",
)
return parser.parse_args()
def generate_audio(args):
"""Generate audio using the API."""
# Build request payload
payload = {
"input": args.text,
"audio_length": args.audio_length,
"audio_start": args.audio_start,
"response_format": args.response_format,
}
# Add optional parameters
if args.negative_prompt:
payload["negative_prompt"] = args.negative_prompt
if args.guidance_scale:
payload["guidance_scale"] = args.guidance_scale
if args.num_inference_steps:
payload["num_inference_steps"] = args.num_inference_steps
if args.seed is not None:
payload["seed"] = args.seed
print(f"\n{'=' * 60}")
print("Stable Audio - Text-to-Audio Generation")
print(f"{'=' * 60}")
print(f"API URL: {args.api_url}")
print(f"Prompt: {args.text}")
print(f"Audio length: {args.audio_length}s")
print(f"Negative prompt: {args.negative_prompt}")
print(f"Guidance scale: {args.guidance_scale}")
print(f"Inference steps: {args.num_inference_steps}")
if args.seed is not None:
print(f"Seed: {args.seed}")
print(f"Output: {args.output}")
print(f"{'=' * 60}\n")
try:
# Make the API request
print("Generating audio...")
response = requests.post(
args.api_url,
json=payload,
headers={"Content-Type": "application/json"},
timeout=300, # 5 minute timeout for long generations
)
# Check for errors
if response.status_code != 200:
print(f"Error: API returned status code {response.status_code}")
print(f"Response: {response.text}")
return False
# Save the audio
with open(args.output, "wb") as f:
f.write(response.content)
print(f"✓ Audio saved to {args.output}")
print(f" File size: {len(response.content) / 1024:.1f} KB")
return True
except requests.exceptions.Timeout:
print("Error: Request timed out. Try reducing inference steps or audio length.")
return False
except requests.exceptions.ConnectionError:
print(f"Error: Could not connect to {args.api_url}")
print("Make sure the server is running.")
return False
except Exception as e:
print(f"Error: {e}")
return False
def main():
args = parse_args()
success = generate_audio(args)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()
@@ -11,6 +11,11 @@ This example demonstrates how to deploy text-to-video models for online video ge
| Wan2.2 T2V | `Wan-AI/Wan2.2-T2V-A14B-Diffusers` |
| LTX-2 | `Lightricks/LTX-2` |
| Helios (Base / Mid / Distilled) | `BestWishYsh/Helios-Base`, `Helios-Mid`, `Helios-Distilled` |
| SANA-Video 2B (480p) | `Efficient-Large-Model/SANA-Video_2B_480p_diffusers` |
| SANA-Video 2B (720p) | `Efficient-Large-Model/SANA-Video_2B_720p_diffusers` |
For SANA-Video 2B text-to-video online serving, see the
[SANA-Video 2B recipe](../../../recipes/NVIDIA/SANA-Video-2B.md).
## Wan2.2 T2V
@@ -0,0 +1,16 @@
#!/bin/bash
BASE_URL="${BASE_URL:-http://localhost:8091}"
OUTPUT_PATH="${OUTPUT_PATH:-sana_video.mp4}"
curl -sS -X POST "$BASE_URL/v1/videos/sync" \
-F "prompt=A cinematic tracking shot of a sailboat crossing the ocean at sunset" \
-F "height=480" \
-F "width=832" \
-F "num_frames=81" \
-F "fps=16" \
-F "num_inference_steps=50" \
-F "guidance_scale=6.0" \
-F "seed=42" \
-F 'extra_params={"motion_score":30}' \
--output "$OUTPUT_PATH"
@@ -0,0 +1,11 @@
#!/bin/bash
# SANA-Video-2B text-to-video serving.
MODEL="${MODEL:-Efficient-Large-Model/SANA-Video_2B_480p_diffusers}"
PORT="${PORT:-8091}"
vllm serve "$MODEL" \
--omni \
--model-class-name SanaVideoPipeline \
--dtype bfloat16 \
--port "$PORT"
@@ -0,0 +1,12 @@
#!/bin/bash
# SANA-Video through the black-box Diffusers backend adapter.
MODEL="${MODEL:-Efficient-Large-Model/SANA-Video_2B_480p_diffusers}"
PORT="${PORT:-8091}"
vllm serve "$MODEL" \
--omni \
--diffusion-load-format diffusers \
--diffusion-attention-backend TORCH_SDPA \
--dtype bfloat16 \
--port "$PORT"
+3
View File
@@ -73,6 +73,8 @@ dev = [
"FlagEmbedding==1.4.0", # for hunyuan_image3 tests
"kernels==0.14.1", # for accuracy tests comparison with diffusers output (0.15.0 incompatible with diffusers 0.38.0)
"s3tokenizer==0.3.0", # for tts test
# Provides top-level cosyvoice2; do not co-install stepaudio2-minicpmo,
# whose top-level stepaudio2 package conflicts with this distribution.
"step-audio2==1.0.0", # Step-Audio2 / MiniCPM-o 4.5 Token2Wav (flashcosyvoice / hyperpyyaml)
]
@@ -266,6 +268,7 @@ markers = [
# specified computation resources marks (auto-added)
# Tag with [hardware-resource] for tests.helpers.mark.get_hardware_mark_list().
"H100: [hardware-resource] Tests that require H100 GPU",
"H800: [hardware-resource] Tests that require H800 GPU",
"H200: [hardware-resource] Tests that require H200 GPU",
"L4: [hardware-resource] Tests that require L4 GPU",
"B200: [hardware-resource] Tests that require B200 GPU",
+63 -1
View File
@@ -380,6 +380,63 @@ No restart is needed: `task=fl2va` routes to `FL2VA/transformer`, while
`task=ref2va` routes to
`Ref2VA/transformer`. T2VA uses the FL2VA DiT.
### Step execution and continuous batching
H3 implements the step-wise execution contract, so the scheduler can admit and
retire requests between denoise steps instead of running one request end to
end. Add the feature gate, then raise `--max-num-seqs` to co-batch:
```bash
--step-execution --max-num-seqs 4
```
Co-batched requests are packed into a single sequence that keeps one attention
document per request, so a batch costs one DiT forward. That packing requires
`--diffusion-attention-backend FLASH_ATTN`; other backends fall back to one
forward per request. `--max-num-seqs 1` keeps the conservative single-request
step path. Cache acceleration (`--cache-backend`) is not available in step mode.
!!! warning "Co-batching does not improve H3 throughput for large simultaneous requests"
Keep `--max-num-seqs 1` unless you specifically need scheduler-level control
(admitting and retiring requests between denoise steps). Measured on two
H100s (TP2, BF16, 672x384, 209 frames, 30 steps, 4 requests at concurrency
4 with `--request-rate inf`, i.e. all four submitted at time 0; one packed
request is 16384 rows):
| Configuration | Wall time | Mean latency | Peak memory |
|---------------|-----------|--------------|-------------|
| request mode | 174.8 s | 111.5 s | 72.4 GB |
| `--step-execution --max-num-seqs 1` | 179.0 s | 113.8 s | 72.4 GB |
| `--step-execution --max-num-seqs 4` | 182.1 s | 175.7 s | 78.3 GB |
A single H3 denoise step is a compute-bound dense GEMM over an already long
packed sequence, so fusing N requests costs N times the FLOPs and buys almost
no amortization — unlike LLM decoding, which is memory-bandwidth bound.
Going from one request per step to four cuts the per-request denoise cost
only from 1.323 s to 1.291 s (2.4%), which the step-mode bookkeeping then
spends. Mean latency degrades further because co-batched requests finish
together instead of staggered. Quantization moves the absolute numbers
without changing this: with online `int8` the same workload runs in 153.3 s
at 56.9 GB (request mode), and `--max-num-seqs 4` is still 5.0% slower than
request mode.
Two workloads outside this table are unmeasured and may behave differently:
- **Staggered arrivals (admission latency).** A single 16384-row H3 request
already saturates the GPUs, so submitting all requests at time 0 is the
one arrival pattern where co-batching cannot win: it can only bunch
completions. In request mode a new request queues behind the whole
in-flight generation (~45 s at this size); step mode admits at the next
denoise-step boundary (~1.3 s). Whether that shows up as a wall-clock
benefit under Poisson arrivals is not yet measured for H3. To reproduce,
run request mode vs `--step-execution --max-num-seqs 4` with
`diffusion_benchmark_serving.py --request-rate 0.05` (roughly one request
every 20 s) and report mean / p95 latency, which then includes queueing.
- **Small requests.** The numbers above are drawn from 672x384 / 209-frame
requests. A short clip at lower resolution packs a few thousand rows and
may not saturate the hardware; co-batching may amortize better there.
This is also unmeasured.
### Online FP8 quantization
MiniMax H3 supports online FP8 quantization of both the DiT and the Qwen3-VL
@@ -924,7 +981,12 @@ vllm serve "${MODEL_ROOT}/FL2VA" \
- TeaCache is calibrated for FL2VA only; Ref2VA requests run uncached.
- Combined serving requires sibling `FL2VA` and `Ref2VA` directories, loads
both task-specific DiTs, and loads shared components once from `FL2VA`.
- H3 currently executes one generation request per diffusion batch.
- Request mode executes one generation request per diffusion batch. Use
`--step-execution` with `--max-num-seqs N` to admit several requests at once
(see
[Execution modes](https://github.com/vllm-project/vllm-omni/blob/main/docs/user_guide/diffusion/execution_modes.md));
step
mode does not support `cache_backend`.
- The first regional-compile request is a warmup and should not be included in
steady-state performance measurements.
- The serving path accepts fewer references than the model supports. H3 documents up
+245
View File
@@ -0,0 +1,245 @@
# SANA-Video 2B
> Native text-to-video and image-to-video generation at 480p and 720p,
> plus a validated Diffusers-adapter compatibility path
## Summary
- Vendor: NVIDIA
- Model: `Efficient-Large-Model/SANA-Video_2B_480p_diffusers`,
`Efficient-Large-Model/SANA-Video_2B_720p_diffusers`
- Task: Text-to-video and image-to-video
- Mode: Offline inference and OpenAI-compatible online serving
- Maintainer: Community
## When to use this recipe
Use the native `SanaVideoPipeline` for T2V and
`SanaImageToVideoPipeline` for I2V. Both native pipelines support the 480p
and 720p checkpoints. Use `--diffusion-load-format diffusers` when you need
the black-box Diffusers compatibility baseline; adapter T2V and I2V are
validated at both resolutions.
The native pipeline loads the 480p checkpoint through
`DistributedAutoencoderKLWan` and the 720p checkpoint through
`DistributedAutoencoderKLLTX2Video`. These are vLLM-Omni distributed wrappers
around the corresponding Diffusers autoencoders, not independent VAE
implementations. The denoising loop also intentionally loads Diffusers'
`DPMSolverMultistepScheduler` from the checkpoint to preserve its scheduler
configuration and numerical behavior.
## References
- Upstream project: <https://github.com/NVlabs/Sana>
- Model cards:
[480p](https://huggingface.co/Efficient-Large-Model/SANA-Video_2B_480p_diffusers),
[720p](https://huggingface.co/Efficient-Large-Model/SANA-Video_2B_720p_diffusers)
- Diffusers documentation: <https://huggingface.co/docs/diffusers/api/pipelines/sana_video>
- Online serving guides:
[Text-to-Video](../../docs/user_guide/examples/online_serving/text_to_video.md),
[Image-to-Video](../../docs/user_guide/examples/online_serving/image_to_video.md)
- Offline T2V example:
[`examples/offline_inference/text_to_video/text_to_video.py`](../../examples/offline_inference/text_to_video/text_to_video.py)
- Offline I2V example:
[`examples/offline_inference/image_to_video/image_to_video.py`](../../examples/offline_inference/image_to_video/image_to_video.py)
- Support discussion: [vLLM-Omni issue #5432](https://github.com/vllm-project/vllm-omni/issues/5432)
## Hardware Support
## GPU
### 1x RTX 5090 32GB
#### Environment
- OS: Ubuntu 22.04.5 LTS
- Python: 3.12.3
- Driver / runtime: NVIDIA driver 580.95.05; PyTorch 2.11.0+cu130
- Diffusers: 0.38.0
- vLLM version: 0.26.0
- vLLM-Omni version or commit: PR #5508, commit `22037901`
#### Command
##### Native text-to-video inference
```bash
python examples/offline_inference/text_to_video/text_to_video.py \
--model Efficient-Large-Model/SANA-Video_2B_720p_diffusers \
--model-class-name SanaVideoPipeline \
--prompt "A cat walking on the grass, facing the camera." \
--negative-prompt "blurry, low quality, temporal artifacts" \
--height 704 --width 1280 --num-frames 81 \
--num-inference-steps 50 --guidance-scale 6 \
--extra-body '{"motion_score": 30}' \
--fps 16 --seed 42 --output sana_video_720p.mp4
```
For 480p, select `SANA-Video_2B_480p_diffusers` and use
`--height 480 --width 832`.
##### Native image-to-video inference
SANA checkpoints declare `SanaVideoPipeline` in `model_index.json`, so I2V
must be selected explicitly with `--model-class-name
SanaImageToVideoPipeline`.
```bash
python examples/offline_inference/image_to_video/image_to_video.py \
--model Efficient-Large-Model/SANA-Video_2B_480p_diffusers \
--model-class-name SanaImageToVideoPipeline \
--image input.jpg \
--prompt "A cat turns toward the camera with smooth, natural motion." \
--negative-prompt "blurry, low quality, temporal artifacts" \
--height 480 --width 832 --num-frames 81 \
--num-inference-steps 50 --guidance-scale 6 \
--extra-body '{"motion_score": 30}' \
--fps 16 --seed 42 --output sana_video_i2v_480p.mp4
```
The same pipeline class supports the 720p checkpoint through vLLM-Omni's
distributed LTX-2 VAE wrapper; use `--height 704 --width 1280`.
For online I2V serving:
```bash
MODEL=Efficient-Large-Model/SANA-Video_2B_480p_diffusers \
bash examples/online_serving/image_to_video/run_server_sana_video.sh
INPUT_IMAGE=input.jpg OUTPUT_PATH=sana_video_i2v.mp4 \
bash examples/online_serving/image_to_video/run_curl_sana_video.sh
```
##### Native online serving
```bash
MODEL=Efficient-Large-Model/SANA-Video_2B_480p_diffusers \
bash examples/online_serving/text_to_video/run_server_sana_video.sh
bash examples/online_serving/text_to_video/run_curl_sana_video.sh
```
To run the black-box compatibility backend for T2V, replace the server script
with `run_server_sana_video_diffusers.sh`. The same `/v1/videos` request
works; `num_frames` is adapted to Diffusers' `frames` argument. The script
selects `TORCH_SDPA` because SANA-Video uses an attention mask that the
AITER-backed Diffusers attention path does not accept.
##### Diffusers-adapter image-to-video serving
The validated I2V adapter commands are:
```bash
# 480p
MODEL=Efficient-Large-Model/SANA-Video_2B_480p_diffusers \
bash examples/online_serving/image_to_video/run_server_sana_video_diffusers.sh
INPUT_IMAGE=input.jpg OUTPUT_PATH=sana_video_i2v_adapter.mp4 \
bash examples/online_serving/image_to_video/run_curl_sana_video.sh
# 720p
MODEL=Efficient-Large-Model/SANA-Video_2B_720p_diffusers \
bash examples/online_serving/image_to_video/run_server_sana_video_diffusers.sh
INPUT_IMAGE=input.jpg WIDTH=1280 HEIGHT=704 \
OUTPUT_PATH=sana_video_i2v_adapter_720p.mp4 \
bash examples/online_serving/image_to_video/run_curl_sana_video.sh
```
#### Verification
Check the encoded 720p output metadata after running a generation command:
```bash
ffprobe -v error -select_streams v:0 \
-show_entries stream=width,height,r_frame_rate,nb_frames \
-of default=noprint_wrappers=1 sana_video_720p.mp4
```
The standard 720p request above should report:
```text
width=1280
height=704
r_frame_rate=16/1
nb_frames=81
```
For 480p, expect `width=832` and `height=480`.
The automated serving matrix covers both checkpoint variants:
| Backend | 480p T2V | 720p T2V | 480p I2V | 720p I2V |
| --- | --- | --- | --- | --- |
| Native vLLM-Omni | Validated | Validated | Validated | Validated |
| Diffusers adapter | Validated | Validated | Validated | Validated |
Use the native `SanaVideoPipeline` and `SanaImageToVideoPipeline` for the
primary SANA execution paths. The Diffusers adapter is retained as a
validated compatibility/reference backend.
#### Notes
- Key flags: select I2V explicitly with `--model-class-name
SanaImageToVideoPipeline`; SANA checkpoint `model_index.json` files declare
the T2V class. Pass `motion_score` through `--extra-body`. The supplied
Diffusers-adapter server scripts select `TORCH_SDPA` because the AITER-backed
path does not accept SANA's attention mask.
- Output profile: 81 frames at 16 FPS is the standard checkpoint profile and
produces approximately five seconds of video. Minute-scale generation
requires the separate LongSANA/LongLive block-autoregressive workflow.
- Backend boundary: the native pipelines and Transformer are owned by
vLLM-Omni. The 480p Wan VAE and 720p LTX-2 VAE run through vLLM-Omni
distributed wrappers derived from the corresponding Diffusers VAE classes.
The denoising loop intentionally retains the checkpoint-compatible
Diffusers `DPMSolverMultistepScheduler`.
- Known limitations:
- Sequence/tensor/CFG parallelism, Cache-DiT, TeaCache, and step execution
are not validated for the native pipeline.
- The Diffusers backend is a compatibility path and does not provide native
vLLM-Omni parallelism or continuous batching.
- Native describes pipeline and Transformer ownership, not a zero-Diffusers
dependency guarantee.
### 1x NVIDIA L20X
#### Environment
- Driver: 570.133.20
- PyTorch: 2.13.0+cu130
- Diffusers: 0.38.0
- vLLM: 0.27.0
- Nsight Systems: 2026.1
- vLLM-Omni commit: `54fcc6d`
#### Same-workload T2V comparison
Both backends used
`Efficient-Large-Model/SANA-Video_2B_480p_diffusers`, the same T2V prompt
and seeds, 832×480 output, 81 frames, and four inference steps. One warmup
request was excluded, followed by three measured requests.
| Metric | Native vLLM-Omni | Diffusers adapter | Native delta |
| --- | ---: | ---: | ---: |
| E2E latency | 7.1600 ± 0.0043 s | 6.9761 ± 0.0517 s | +2.636% |
| Throughput | 0.1397 req/s | 0.1433 req/s | -2.568% |
| Peak reserved VRAM | 23,936 MiB | 19,060 MiB | +25.582% |
| Initialization | 11.669 s | 20.236 s | -42.336% |
The peak-memory result is not a Transformer-only comparison. For the 480p
checkpoint, the native pipeline deliberately loads and decodes with the Wan
VAE in FP32. This matches the upstream SANA-Video reference and the recorded
accuracy-golden setup for this integration. The adapter launch uses
`--dtype bfloat16` as the Diffusers pipeline-wide dtype, so its Wan VAE also
runs in BF16.
Peak VRAM is the CUDA allocator's maximum reserved memory over the complete
request. It includes VAE decode activations and allocator-reserved blocks, not
only live Transformer tensors. The different VAE precision policies are a
material code-level difference and a likely contributor to the higher native
high-water mark; the available full-request measurements do not isolate how
much of the delta they account for. The 25.582% result should therefore not be
interpreted as an isolated native Transformer memory regression. Exact
attribution requires both paths to use the same VAE dtype. Changing the native
480p VAE to BF16 would instead change the recorded golden/reference setup and
requires a new accuracy study.
+2
View File
@@ -57,6 +57,8 @@ recipes/
| [`krea/Krea-2.md`](./krea/Krea-2.md) | Text-to-image (Turbo + Raw), offline + online, with LoRA | 1x H100 80GB |
| [`LTX/LTX-2.md`](./LTX/LTX-2.md) | LTX-2/LTX-2.3 text-to-video and image-to-video with synchronized audio | H200 141GB / 96GB-class GPU |
| [`LTX/LTX-2.5.md`](./LTX/LTX-2.5.md) | LTX-2.5-Diffusers: Full/SFT one-stage and distilled two-stage T2V/I2V with synchronized audio | NVIDIA B300; cuDNN-qualified |
| [`MammothModa2/MammothModa2.md`](./MammothModa2/MammothModa2.md) | Preview and Dev text-to-image (AR → DiT); Dev text/image understanding | Preview: 1x L40S 48GB / 1x ≥40GB GPU; Dev: 1x NVIDIA GPU with sufficient cache headroom |
| [`NVIDIA/SANA-Video-2B.md`](./NVIDIA/SANA-Video-2B.md) | Native and Diffusers-adapter 480p/720p T2V+I2V | 1x RTX 5090 32GB |
| [`meituan-longcat/LongCat-Video-Avatar-1.5.md`](./meituan-longcat/LongCat-Video-Avatar-1.5.md) | Audio-driven avatar video generation (AT2V / AI2V, single- and multi-speaker, AVC continuation) | 1x H100 80GB |
| [`mistralai/Voxtral-TTS.md`](./mistralai/Voxtral-TTS.md) | Online serving for TTS | 1x RTX 4090 24GB |
| [`Robbyant/LingBot-Video.md`](./Robbyant/LingBot-Video.md) | Native dense and MoE text-to-video serving | Dense: 1x L20X; MoE: 1x L20X (~67.7 GiB peak) |
+3 -5
View File
@@ -23,8 +23,6 @@ offline validation sample with TeaCache and online serving through the
- Model: <https://huggingface.co/stabilityai/stable-audio-open-1.0>
- Offline example:
[`examples/offline_inference/text_to_audio`](../../examples/offline_inference/text_to_audio)
- Online example:
[`examples/online_serving/stable_audio`](../../examples/online_serving/stable_audio)
- Audio generation API:
[`docs/serving/audio_generate_api.md`](../../docs/serving/audio_generate_api.md)
- Related issue:
@@ -103,7 +101,7 @@ curl -X POST http://localhost:8091/v1/audio/generate \
"seed": 42,
"response_format": "wav"
}' \
--output examples/online_serving/stable_audio/piano_10s.wav
--output piano_10s.wav
```
##### Verification
@@ -137,12 +135,12 @@ PY
Validate the online output:
```bash
ls -lh examples/online_serving/stable_audio/piano_10s.wav
ls -lh piano_10s.wav
python - <<'PY'
import soundfile as sf
path = "examples/online_serving/stable_audio/piano_10s.wav"
path = "piano_10s.wav"
audio, sample_rate = sf.read(path)
print("sample_rate:", sample_rate)
print("shape:", audio.shape)
+179 -1
View File
@@ -1,10 +1,12 @@
# 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 metrics.py
"""
import math
import pytest
from vllm.benchmarks.serve import TaskType
@@ -66,6 +68,22 @@ def _make_output(prompt_len: int, output_tokens: int = 10) -> MixRequestFuncOutp
return output
def _calculate_test_metrics(outputs, goodput=None):
return calculate_metrics(
input_requests=[],
outputs=outputs,
dur_s=1.0,
tokenizer=None,
selected_percentiles=[50.0],
goodput_config_dict=goodput or {},
task_type=TaskType.GENERATION,
selected_percentile_metrics=[],
max_concurrency=None,
request_rate=float("inf"),
benchmark_duration=1.0,
)[0]
# ============================================================================
# total_input Tests
# ============================================================================
@@ -126,6 +144,166 @@ def test_audio_continuity_aggregation():
assert p99 is not None and p99 > 0.4
def test_unmeasured_duplex_latency_does_not_add_zero_samples():
measured = _make_output(100, output_tokens=2)
measured.ttft = 0.1
measured.audio_ttfp = 0.2
measured.audio_rtf = 0.5
measured.duplex_session_metrics = {
"mean_ttft_ms": 100.0,
"mean_ttfp_ms": 200.0,
"mean_rtf": 0.5,
}
listen_only = _make_output(100, output_tokens=0)
listen_only.ttft = listen_only.audio_ttfp = listen_only.audio_rtf = 0.0
listen_only.duplex_session_metrics = {
"mean_ttft_ms": None,
"mean_ttfp_ms": None,
"mean_rtf": None,
}
metrics = _calculate_test_metrics([measured, listen_only], {"ttft": 150.0})
assert metrics.mean_ttft_ms == 100.0
assert metrics.mean_audio_ttfp_ms == 200.0
assert metrics.mean_audio_rtf == 0.5
assert (metrics.num_ttft_samples, metrics.num_audio_ttfp_samples, metrics.num_audio_rtf_samples) == (1, 1, 1)
assert metrics.request_goodput == 1.0
def test_all_unmeasured_duplex_latency_is_not_reported_as_zero():
listen_only = _make_output(100, output_tokens=0)
listen_only.duplex_session_metrics = {
"mean_ttft_ms": None,
"mean_ttfp_ms": None,
"mean_rtf": None,
}
metrics = _calculate_test_metrics(
[listen_only],
{"ttft": float("inf"), "audio_ttft": float("inf")},
)
assert (metrics.num_ttft_samples, metrics.num_audio_ttfp_samples, metrics.num_audio_rtf_samples) == (0, 0, 0)
assert math.isnan(metrics.mean_ttft_ms)
assert math.isnan(metrics.mean_audio_ttfp_ms)
assert math.isnan(metrics.mean_audio_rtf)
assert metrics.request_goodput == 0.0
def test_unmeasured_duplex_tpot_does_not_add_zero_or_misalign_goodput():
missing_tpot = _make_output(100, output_tokens=5)
missing_tpot.itl = []
missing_tpot.text_latency = missing_tpot.ttft = 0.1
missing_tpot.tpot_measured = False
missing_tpot.duplex_session_metrics = {"mean_ttft_ms": 100.0}
slow_ttft = _make_output(100, output_tokens=5)
slow_ttft.ttft = 1.0
slow_ttft.text_latency = 1.4
slow_ttft.itl = [0.1] * 4
slow_ttft.duplex_session_metrics = {"mean_ttft_ms": 1000.0}
metrics = _calculate_test_metrics(
[missing_tpot, slow_ttft],
{"ttft": 500.0, "tpot": 200.0},
)
assert metrics.num_tpot_samples == 1
assert metrics.mean_tpot_ms == pytest.approx(100.0)
assert metrics.request_goodput == 0.0
def test_all_unmeasured_duplex_token_timing_is_not_reported_as_zero():
output = _make_output(100, output_tokens=5)
output.itl = []
output.text_latency = output.ttft
output.tpot_measured = False
output.duplex_session_metrics = {"mean_ttft_ms": 100.0}
metrics = _calculate_test_metrics([output], {"tpot": float("inf")})
assert (metrics.num_tpot_samples, metrics.num_itl_samples) == (0, 0)
assert math.isnan(metrics.mean_tpot_ms)
assert math.isnan(metrics.mean_itl_ms)
assert metrics.request_goodput == 0.0
def test_duplex_response_timings_do_not_build_a_session_token_timeline():
output = _make_output(100, output_tokens=5)
output.latency = 101.0
output.duplex_request_metrics = [
{"response_id": "r1", "stage0_tokens": {"itls_ms": [100.0, 100.0]}},
{"response_id": "r2", "stage0_tokens": {"itls_ms": [100.0, 100.0]}},
]
output.duplex_session_metrics = {"mean_ttft_ms": 100.0}
metrics = _calculate_test_metrics([output])
assert math.isnan(metrics.max_output_tokens_per_s)
assert metrics.max_concurrent_requests == 1
assert metrics.mean_tpot_ms == pytest.approx(100.0)
assert metrics.mean_itl_ms == pytest.approx(100.0)
def test_unmeasured_tpot_stays_missing_after_tokenizer_fallback():
output = _make_output(100, output_tokens=0)
output.generated_text = "timing metadata missing"
output.itl = []
output.text_latency = output.ttft
output.tpot_measured = False
output.duplex_session_metrics = {"mean_ttft_ms": 100.0}
def tokenizer(text, *, add_special_tokens):
assert text == output.generated_text
assert add_special_tokens is False
return type("Tokenized", (), {"input_ids": [1, 2, 3]})()
metrics, _ = calculate_metrics(
input_requests=[],
outputs=[output],
dur_s=1.0,
tokenizer=tokenizer,
selected_percentiles=[50.0],
goodput_config_dict={"tpot": float("inf")},
task_type=TaskType.GENERATION,
selected_percentile_metrics=[],
max_concurrency=None,
request_rate=float("inf"),
benchmark_duration=1.0,
)
assert metrics.total_output == 3
assert metrics.num_tpot_samples == 0
assert math.isnan(metrics.mean_tpot_ms)
assert metrics.request_goodput == 0.0
def test_measured_zero_itl_is_not_treated_as_missing():
output = _make_output(100, output_tokens=3)
output.itl = [0.0, 0.0]
output.duplex_session_metrics = {"mean_ttft_ms": 100.0}
metrics = _calculate_test_metrics([output], {"tpot": 1.0})
assert (metrics.num_tpot_samples, metrics.num_itl_samples) == (1, 2)
assert metrics.mean_tpot_ms == 0.0
assert metrics.mean_itl_ms == 0.0
assert metrics.request_goodput == 1.0
def test_duplex_goodput_does_not_pair_measurements_from_different_requests():
text_only, audio_only = _make_output(100), _make_output(100)
text_only.ttft, text_only.audio_ttfp = 0.1, 0.0
text_only.duplex_session_metrics = {"mean_ttft_ms": 100.0, "mean_ttfp_ms": None, "mean_rtf": None}
audio_only.ttft, audio_only.audio_ttfp = 0.0, 0.2
audio_only.duplex_session_metrics = {"mean_ttft_ms": None, "mean_ttfp_ms": 200.0, "mean_rtf": None}
metrics = _calculate_test_metrics([text_only, audio_only], {"ttft": 500.0, "audio_ttft": 500.0})
assert metrics.request_goodput == 0.0
# ============================================================================
# TTFT suppression for pure-audio (TTS) benchmarks
# ============================================================================
+778
View File
@@ -0,0 +1,778 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import argparse
import asyncio
import base64
import io
import json
import subprocess
import tarfile
import threading
import time
import wave
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import pytest
from vllm.benchmarks.lib.endpoint_request_func import RequestFuncInput
from vllm.benchmarks.serve import TaskType
from vllm_omni.benchmarks import omniinteract as oi
from vllm_omni.benchmarks import serve as benchmark_serve
from vllm_omni.benchmarks.data_modules import omniinteract_dataset as data
from vllm_omni.benchmarks.metrics.metrics import calculate_metrics
from vllm_omni.benchmarks.patch import patch as benchmark_patch
from vllm_omni.entrypoints.cli.benchmark.cli_args import preprocess_serve_args
from vllm_omni.entrypoints.cli.benchmark.serve import OmniBenchmarkServingSubcommand
from vllm_omni.experimental.fullduplex.client import RealtimeEventCollector
from vllm_omni.utils.tracking_parser import TrackingArgumentParser
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.benchmark]
MODEL = "openbmb/MiniCPM-o-4_5"
def _case(tmp_path: Path, *, subset: str = "1q1a", name: str = "video.mp4") -> data.OmniInteractCase:
video, annotation = tmp_path / name, tmp_path / f"{Path(name).stem}.json"
video.parent.mkdir(parents=True, exist_ok=True)
video.touch()
annotation.write_text("{}")
return data.OmniInteractCase(subset, name, video, annotation, "multi_turn")
def _collector(*events: tuple[dict[str, object], float]) -> RealtimeEventCollector:
collector = RealtimeEventCollector()
for event, received_at in events:
collector.add(event, received_at_s=received_at)
return collector
def _created(response_id: str = "r1") -> dict[str, object]:
return {"type": "response.created", "response": {"id": response_id}}
def _done(response_id: str = "r1", status: str = "completed") -> dict[str, object]:
return {"type": "response.done", "response": {"id": response_id, "status": status}}
def _audio(
response_id: str = "r1",
*,
samples: int = 2400,
rate: int | None = 24_000,
value: int = 1,
) -> dict[str, object]:
event: dict[str, object] = {
"type": "response.audio.delta",
"response_id": response_id,
"format": "pcm16",
"delta": base64.b64encode(bytes((value, 0)) * samples).decode(),
}
if rate is not None:
event["sample_rate_hz"] = rate
return event
def _text(response_id: str = "r1", value: str = "hello") -> dict[str, object]:
return {"type": "response.audio_transcript.delta", "response_id": response_id, "delta": value}
def _listen(*, buffering: bool = False) -> dict[str, object]:
return {
"type": "response.listen",
"response": {"metadata": {"model_listen": True, "buffering": buffering}},
}
def _session_options(tmp_path: Path) -> data.OmniInteractSessionOptions:
return data.OmniInteractSessionOptions(tmp_path / "artifacts", 10.0, 5.0, "reference.wav", True)
def _sample(case: data.OmniInteractCase, options: data.OmniInteractSessionOptions):
return data.OmniInteractSampleRequest(
prompt="",
prompt_len=0,
expected_output_len=0,
multi_modal_data=None,
request_id="measured-0",
omniinteract_case=case,
omniinteract_options=options,
)
def _write_dataset(root: Path) -> Path:
data_root = root / "data"
for subset in ("1q1a", "1q1a_math"):
subset_root = data_root / subset
(subset_root / "videos").mkdir(parents=True)
(subset_root / "annotations").mkdir()
(subset_root / "videos" / f"{subset}.mp4").touch()
(subset_root / "annotations" / f"{subset}.json").write_text("{}")
(subset_root / "video_json_map.json").write_text(
json.dumps(
{
"entries": [
{
"video": f"videos/{subset}.mp4",
"annotation": f"annotations/{subset}.json",
"scene_type": "multi_turn",
}
]
}
)
)
one_to_many = data_root / "1qna"
(one_to_many / "videos_bench" / "nested").mkdir(parents=True)
(one_to_many / "annotations" / "nested").mkdir(parents=True)
(one_to_many / "videos_bench" / "nested" / "guide.mp4").touch()
(one_to_many / "annotations" / "nested" / "guide.json").write_text("{}")
return data_root
def _successful_output(
tmp_path: Path,
*,
audio_time: float = 10.0,
status: str = "completed",
) -> tuple[data.OmniInteractCase, RealtimeEventCollector, oi.OmniInteractCaseResult]:
case = _case(tmp_path)
collector = _collector(
(_created(), 9.9),
(_audio(), audio_time),
(_text(), audio_time + 0.01),
(_done(status=status), audio_time + 0.1),
)
result = oi.OmniInteractCaseResult(case.subset, str(case.video_path), "")
return case, collector, result
def _write_success(
root: Path,
case: data.OmniInteractCase,
collector: RealtimeEventCollector,
result: oi.OmniInteractCaseResult,
**kwargs,
):
require_response = kwargs.pop("require_response", True)
return oi.prepare_success_artifacts(
root,
case,
collector,
stream_start=10.0,
video_duration_s=1.0,
require_response=require_response,
result=result,
**kwargs,
)
def test_dataset_discovers_official_layouts_and_total_selection(tmp_path: Path):
root = _write_dataset(tmp_path)
all_cases = data.discover_omniinteract_cases(root, data.OMNIINTERACT_SUBSETS, num_prompts=0, disable_shuffle=True)
selected = data.discover_omniinteract_cases(root, data.OMNIINTERACT_SUBSETS, num_prompts=2, disable_shuffle=True)
assert [case.subset for case in all_cases] == ["1q1a", "1q1a_math", "1qna"]
assert all_cases[-1].video_rel == "videos_bench/nested/guide.mp4"
assert [case.subset for case in selected] == ["1q1a", "1q1a_math"]
def _archive(path: Path, member: str) -> None:
with tarfile.open(path, "w") as archive:
content = b"{}"
item = tarfile.TarInfo(member)
item.size = len(content)
archive.addfile(item, io.BytesIO(content))
def test_archive_is_safe_and_atomically_shared(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
bad = tmp_path / "bad.tar"
_archive(bad, "../escape")
with pytest.raises(ValueError, match="Unsafe path"):
data._extract_archive(bad, tmp_path / "bad-cache")
good = tmp_path / "good.tar"
_archive(good, "data/1q1a/video_json_map.json")
target = tmp_path / "cache"
barrier, safe_extract = threading.Barrier(2), data._safe_extract
monkeypatch.setattr(data, "_safe_extract", lambda *args: (barrier.wait(), safe_extract(*args)))
with ThreadPoolExecutor(2) as executor:
roots = list(executor.map(lambda _: data._extract_archive(good, target), range(2)))
assert roots[0] == roots[1]
assert (roots[0] / "1q1a" / "video_json_map.json").is_file()
assert not list(target.glob(".tmp-*"))
def test_hub_archive_uses_vllm_filesystem(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
source = tmp_path / "source.tar"
_archive(source, "data/1q1a/video_json_map.json")
class FS:
def get_file(self, remote: str, local: str) -> None:
assert remote == "datasets/org/repo/data.tar.gz"
Path(local).write_bytes(source.read_bytes())
monkeypatch.setenv("HF_HOME", str(tmp_path / "hf"))
monkeypatch.setattr(data, "hf_fs", lambda: FS())
assert (data.resolve_omniinteract_root(None, "org/repo") / "1q1a").is_dir()
def test_media_commands_are_bounded(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
calls: list[float] = []
commands: list[list[str]] = []
bounded_run = oi._run_media_command
def run(command, *, timeout_s, text=False):
calls.append(timeout_s)
commands.append(command)
if command[0] == "ffprobe":
return subprocess.CompletedProcess(command, 0, "1.0", "")
return subprocess.CompletedProcess(command, 0, b"", b"")
monkeypatch.setattr(oi, "_run_media_command", run)
duration, pcm, frames = oi.prepare_media(tmp_path / "video.mp4", 1.0, timeout_s=3.0, max_duration_s=1.0)
assert (duration, len(pcm), frames, calls) == (1.0, 32_000, [None], [3.0, 3.0, 3.0])
assert commands[1][commands[1].index("-t") + 1] == "1.0"
assert commands[1][commands[1].index("-fs") + 1] == "32000"
monkeypatch.setattr(subprocess, "run", lambda *a, **k: (_ for _ in ()).throw(subprocess.TimeoutExpired("x", 3)))
with pytest.raises(TimeoutError, match="timed out"):
bounded_run(["ffmpeg"], timeout_s=3.0)
def test_media_duration_is_bounded_before_decode(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
calls = 0
def run(command, *, timeout_s, text=False):
nonlocal calls
calls += 1
return subprocess.CompletedProcess(command, 0, "601.0", "")
monkeypatch.setattr(oi, "_run_media_command", run)
with pytest.raises(ValueError, match="Invalid video duration"):
oi.prepare_media(tmp_path / "video.mp4", 1.0, timeout_s=3.0, max_duration_s=600.0)
assert calls == 1
def test_final_commit_keeps_a_partial_model_unit():
"""Exact units retain a sample for the server's correlated final flush."""
events = [{"session": {"capabilities": {"chunk_period_ms": 1000}}}]
exact_unit = b"\0\0" * 16_000
assert oi._ensure_final_commit_tail(exact_unit, events) == exact_unit[:-2]
assert oi._ensure_final_commit_tail(exact_unit + b"\0\0", events) == exact_unit + b"\0\0"
@pytest.mark.parametrize(
("events", "match"),
[
(((_done(), 0.0),), "without response.created"),
(((_created(), 0.0), (_created(), 0.1)), "duplicate response.created"),
(((_created(), 0.0), (_done(), 0.1), (_done(), 0.2)), "duplicate response.done"),
(
(
(_created(), 0.0),
({"type": "response.done", "response": {"id": "r1", "status_details": {"type": "failed"}}}, 0.1),
),
"reports failure",
),
],
)
def test_response_ledger_rejects_identity_errors(events, match: str):
with pytest.raises(ValueError, match=match):
oi.response_ledger(_collector(*events))
class _CompletionClient:
def __init__(self, collector: RealtimeEventCollector):
self.events, self.acks = collector, []
def raise_if_reader_stopped(self) -> None:
return None
async def send_playback_ack(self, response_id: str, played_ms: int) -> None:
self.acks.append((response_id, played_ms))
@pytest.mark.asyncio
@pytest.mark.parametrize(
("tail", "succeeds"),
[
(((_created(), 1.1), (_done(), 1.2)), True),
(((_listen(), 1.1),), True),
(((_listen(buffering=True), 1.1),), False),
((({"type": "session.closed", "reason": "timeout"}, 1.1),), False),
],
)
async def test_completion_requires_a_final_model_decision(tail, succeeds: bool):
events = _collector(({"type": "input_audio_buffer.committed"}, 1.0), *tail)
coroutine = oi.wait_for_session_completion(
_CompletionClient(events), oi._Playback(), commit_from=0, timeout_s=0.08, settle_s=0.0
)
if succeeds:
assert await coroutine == 0
else:
with pytest.raises((TimeoutError, RuntimeError)):
await coroutine
@pytest.mark.asyncio
async def test_deferred_commit_ignores_the_prior_response_terminal():
events = _collector(
(_created("old"), 0.0),
({"type": "input_audio_buffer.committed", "event": {"overlap_deferred": True}}, 1.0),
(_done("old"), 1.1),
(_listen(), 1.2),
)
assert (
await oi.wait_for_session_completion(
_CompletionClient(events), oi._Playback(), commit_from=1, timeout_s=0.1, settle_s=0.0
)
== 1
)
@pytest.mark.asyncio
async def test_playback_acks_only_after_serial_audio_drain():
collector = _collector((_audio(samples=2400), 10.0), (_done(), 10.01))
client, playback = _CompletionClient(collector), oi._Playback()
await playback.acknowledge(client, now=10.05)
assert client.acks == []
await playback.acknowledge(client, now=10.1)
assert client.acks == [("r1", 100)]
@pytest.mark.parametrize(
("event", "match"),
[
(_audio(rate=16_000), "24000 Hz"),
({**_audio(), "format": "opus"}, "pcm16"),
({**_audio(), "delta": "!"}, "base64"),
],
)
def test_playback_rejects_non_official_audio(event: dict[str, object], match: str):
playback = oi._Playback()
with pytest.raises(ValueError, match=match):
playback.ingest(_collector((event, 1.0)))
def test_artifacts_publish_official_bundle_and_sparse_deferred_state(tmp_path: Path):
case, collector, result = _successful_output(tmp_path)
summary = _write_success(
tmp_path / "out",
case,
collector,
result,
)
context = result._artifact_context
assert result.success and result.eligible_for_official_eval
assert summary["scene_type"] == "multi_turn"
assert context and context.spans
assert all("delta" not in event for event in context.events if event["type"] == "response.audio.delta")
oi.publish_deferred_case_artifacts(tmp_path / "out", case, result)
directory = oi._output_dir(tmp_path / "out", case)
assert all((directory / name).is_file() for name in oi.SUCCESS_ARTIFACTS)
with wave.open(str(directory / "output.wav")) as output:
assert (output.getframerate(), output.getnframes()) == (24_000, 24_000)
transcript = json.loads((directory / "wav_transcript.json").read_text())
assert transcript["text"] == "hello" and transcript["chunks"][0]["timestamp"] == [0.0, 0.1]
assert "serialized playback queue" in transcript["timestamp_semantics"]
assert result._artifact_context is None
@pytest.mark.parametrize(
("audio_time", "status", "reason"),
[(10.95, "completed", "audio_clipped"), (10.0, "cancelled", "cancelled_response")],
)
def test_ineligible_outputs_are_excluded_from_manifest(tmp_path: Path, audio_time: float, status: str, reason: str):
case, collector, result = _successful_output(tmp_path, audio_time=audio_time, status=status)
_write_success(tmp_path / "out", case, collector, result, require_response=False)
assert reason in result.official_eval_ineligible_reasons
oi.write_batch_artifacts(tmp_path / "out", [case], [result])
assert (tmp_path / "out" / "official_eval_manifest.jsonl").read_text() == ""
def test_artifacts_require_complete_audio_and_transcript_response(tmp_path: Path):
case, _, result = _successful_output(tmp_path)
collector = _collector((_created(), 10.0), (_audio(), 10.0), (_done(), 10.1))
with pytest.raises(ValueError, match="audio and transcript"):
_write_success(tmp_path, case, collector, result)
def test_publication_failure_removes_partial_success_bundle(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
case, collector, result = _successful_output(tmp_path)
_write_success(tmp_path, case, collector, result)
original = oi._atomic_write_json
def fail(path: Path, value: object) -> None:
if path.name == "events.json":
raise OSError("disk full")
original(path, value)
monkeypatch.setattr(oi, "_atomic_write_json", fail)
with pytest.raises(OSError, match="disk full"):
oi.publish_deferred_case_artifacts(tmp_path, case, result)
directory = oi._output_dir(tmp_path, case)
assert not any((directory / name).exists() for name in oi.SUCCESS_ARTIFACTS)
assert not result.success and "artifact_write_failed" in result.official_eval_ineligible_reasons
def test_atomic_write_preserves_destination_on_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
destination = tmp_path / "output.wav"
destination.write_bytes(b"old")
monkeypatch.setattr(oi, "write_pcm16_wav", lambda *a, **k: (_ for _ in ()).throw(OSError("write")))
with pytest.raises(OSError):
oi._atomic_write_wav(destination, b"\0\0", 24_000)
assert destination.read_bytes() == b"old"
assert not list(tmp_path.glob(".*.tmp"))
class _RealtimeClient:
instances: list[_RealtimeClient] = []
def __init__(self, url: str, **kwargs):
self.url, self.events, self.acks, self.configure_kwargs = url, RealtimeEventCollector(), [], {}
self.instances.append(self)
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return None
async def configure(self, model: str, **kwargs) -> None:
self.configure_kwargs = kwargs
self.events.add({"type": "session.created", "session": {"capabilities": {"chunk_period_ms": 1000}}})
async def send(self, event: dict[str, object]) -> None:
return None
async def commit(self) -> None:
now = time.monotonic()
for event, offset in (
({"type": "input_audio_buffer.committed"}, 0.0),
(_created(), 0.001),
(_audio(samples=24), 0.002),
(_text(value="answer"), 0.003),
(_done(), 0.004),
):
self.events.add(event, received_at_s=now + offset)
async def send_playback_ack(self, response_id: str, played_ms: int) -> None:
self.acks.append((response_id, played_ms))
async def close_session(self, **kwargs) -> None:
self.events.add({"type": "session.closed"})
def raise_if_reader_stopped(self) -> None:
return None
@pytest.mark.asyncio
async def test_public_runner_executes_one_prepared_session(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
case = _case(tmp_path)
config = oi.OmniInteractBenchmarkConfig(
model=MODEL,
output_root=tmp_path / "out",
ref_audio="ref.wav",
require_response=True,
extra_body={"custom": "value"},
)
prepared = data.OmniInteractPreparedInput(1.0, b"\0\0\0\0", ("frame",), "data:audio/wav;base64,ref")
monkeypatch.setattr(oi, "RealtimeDuplexClient", _RealtimeClient)
monkeypatch.setattr(oi, "_COMPLETION_SETTLE_S", 0.0)
result = await oi.run_omniinteract_case(
case,
config,
request_index=0,
prepared_input=prepared,
)
assert result.success and result.transcript == "answer" and result._artifact_context is not None
assert not config.output_root.exists()
assert "autostart=0" in _RealtimeClient.instances[-1].url
assert _RealtimeClient.instances[-1].configure_kwargs["extra_body"] == {"custom": "value"}
assert _RealtimeClient.instances[-1].acks == [("r1", 1)]
def test_standard_sample_loading_prepares_media_before_timing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
_write_dataset(tmp_path)
ref = tmp_path / "ref.wav"
ref.touch()
parser = TrackingArgumentParser()
OmniBenchmarkServingSubcommand.add_cli_args(parser)
args = parser.parse_args(
[
"--backend",
"openai-realtime-duplex",
"--dataset-name",
"omniinteract",
"--dataset-path",
str(tmp_path),
"--model",
MODEL,
"--endpoint",
"/v1/realtime",
"--num-prompts",
"0",
"--omniinteract-ref-audio",
str(ref),
]
)
preprocess_serve_args(args)
assert args.endpoint == "/v1/realtime"
monkeypatch.setattr(benchmark_patch, "reference_audio_data_url", lambda _: "data:audio/wav;base64,ref")
monkeypatch.setattr(benchmark_patch, "prepare_media", lambda *a, **k: (1.0, b"pcm", ["frame"]))
samples = benchmark_patch.get_samples(args, None)
assert len(samples) == args.num_prompts == 3
assert all(sample.omniinteract_prepared_input.video_frames == ("frame",) for sample in samples)
def _request(case: data.OmniInteractCase, options: data.OmniInteractSessionOptions) -> RequestFuncInput:
request = RequestFuncInput(
model=MODEL,
model_name=MODEL,
prompt="",
api_url="http://server:8000/v1/realtime",
prompt_len=0,
output_len=0,
logprobs=None,
multi_modal_content=None,
ignore_eos=False,
request_id="measured-0",
)
request.omniinteract_case, request.omniinteract_options = case, options
return request
@pytest.mark.asyncio
async def test_adapter_requires_and_forwards_exact_prepared_payload(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
case, options = _case(tmp_path), _session_options(tmp_path)
request = _request(case, options)
output = await benchmark_patch.async_request_openai_realtime_duplex(request, session=None)
assert not output.success and "prepared before benchmark request timing" in output.error
prepared = data.OmniInteractPreparedInput(1.0, b"pcm", ("frame",), "data:audio/wav;base64,ref")
request.omniinteract_prepared_input = prepared
request.extra_headers = {"X-Proxy": "test"}
request.extra_body = {"custom": "value"}
monkeypatch.setenv("OPENAI_API_KEY", "secret")
seen = {}
async def run(_case_arg, config, **kwargs):
seen["headers"] = config.extra_headers
seen["extra_body"] = config.extra_body
seen.update(kwargs)
return oi.OmniInteractCaseResult(
case.subset,
str(case.video_path),
str(options.output_root),
success=True,
transcript="timing metadata missing",
output_tokens=0,
duplex_request_metrics=[{"request_metrics": {"ttft_ms": 1.0}}],
duplex_session_metrics={"mean_ttft_ms": 10.0},
)
monkeypatch.setattr(benchmark_patch, "run_omniinteract_case", run)
output = await benchmark_patch.async_request_openai_realtime_duplex(request, session=None)
assert output.success and seen["prepared_input"] is prepared
assert seen["headers"] == {"Authorization": "Bearer secret", "X-Proxy": "test", "x-request-id": "measured-0"}
assert seen["extra_body"] == {"custom": "value"}
assert output.itl == []
assert output.tpot_measured is False
assert seen["capture_artifacts"] is True
@pytest.mark.asyncio
@pytest.mark.parametrize(
("request_metrics", "expected_itl", "expected_text_latency"),
[
(
[
{"stage0_tokens": {"output_token_count": 3, "itls_ms": [10.0, 20.0]}},
{"stage0_tokens": {"output_token_count": 2, "itls_ms": [30.0]}},
],
[0.01, 0.02, 0.03],
0.16,
),
(
[
{"stage0_tokens": {"output_token_count": 3, "tpot_ms": 10.0}},
{"stage0_tokens": {"output_token_count": 2, "tpot_ms": 40.0}},
],
[],
0.18,
),
(
[
{"stage0_tokens": {"output_token_count": 3, "tpot_ms": 0.0}},
{"stage0_tokens": {"output_token_count": 2, "tpot_ms": 0.0}},
],
[],
0.1,
),
],
)
async def test_adapter_reports_exact_or_weighted_token_timing(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
request_metrics: list[dict[str, object]],
expected_itl: list[float],
expected_text_latency: float,
):
case, options = _case(tmp_path), _session_options(tmp_path)
request = _request(case, options)
request.omniinteract_prepared_input = data.OmniInteractPreparedInput(
1.0, b"pcm", ("frame",), "data:audio/wav;base64,ref"
)
async def run(*args, **kwargs):
return oi.OmniInteractCaseResult(
case.subset,
str(case.video_path),
str(options.output_root),
success=True,
output_tokens=5,
duplex_request_metrics=request_metrics,
duplex_session_metrics={"mean_ttft_ms": 100.0},
)
monkeypatch.setattr(benchmark_patch, "run_omniinteract_case", run)
output = await benchmark_patch.async_request_openai_realtime_duplex(request, session=None)
assert output.success
assert output.itl == expected_itl
assert output.text_latency == pytest.approx(expected_text_latency)
assert output.tpot_measured is True
def test_batch_finalization_publishes_only_measured_results(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
case, options = _case(tmp_path), _session_options(tmp_path)
options.output_root.mkdir()
sample = _sample(case, options)
result = oi.OmniInteractCaseResult(
case.subset, str(case.video_path), str(options.output_root), success=True, eligible_for_official_eval=True
)
output = benchmark_patch.MixRequestFuncOutput(success=True)
output.omniinteract_case_result = result
published = []
monkeypatch.setattr(benchmark_patch, "publish_deferred_case_artifacts", lambda *args: published.append(args))
summary = benchmark_patch._finalize_omniinteract_batch([sample], [output])
assert summary and summary["total"] == summary["success"] == summary["eligible_for_official_eval"] == 1
assert summary["failed"] == summary["successful_but_ineligible"] == summary["audio_clipped_bytes"] == 0
assert published == [(options.output_root, case, result)]
def test_case_artifact_failures_do_not_discard_serving_metrics(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
case, options = _case(tmp_path), _session_options(tmp_path)
sample = _sample(case, options)
result = oi.OmniInteractCaseResult(
case.subset, str(case.video_path), str(options.output_root), success=True, eligible_for_official_eval=True
)
output = benchmark_patch.MixRequestFuncOutput(success=True, error="inference warning")
output.omniinteract_case_result = result
monkeypatch.setattr(
benchmark_patch,
"publish_deferred_case_artifacts",
lambda *args: (_ for _ in ()).throw(OSError("case disk full")),
)
monkeypatch.setattr(
benchmark_patch,
"write_failure_artifacts",
lambda *args: (_ for _ in ()).throw(OSError("failure disk full")),
)
monkeypatch.setattr(benchmark_patch, "write_omniinteract_batch_artifacts", lambda *args: None)
summary = benchmark_patch._finalize_omniinteract_batch([sample], [output])
metrics, _ = calculate_metrics(
[],
[output],
1.0,
None,
[50.0],
{},
TaskType.GENERATION,
[],
None,
float("inf"),
1.0,
)
assert summary and summary["failed"] == 1
assert summary["artifacts_complete"] is False
assert len(summary["artifact_errors"]) == 2
assert output.success and metrics.completed == 1
assert output.error.startswith("inference warning\n")
assert "Artifact publication failed: case disk full" in output.error
def test_batch_artifact_failure_removes_partial_batch_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
case, options = _case(tmp_path), _session_options(tmp_path)
sample = _sample(case, options)
result = oi.OmniInteractCaseResult(
case.subset, str(case.video_path), str(options.output_root), success=True, eligible_for_official_eval=True
)
output = benchmark_patch.MixRequestFuncOutput(success=True)
output.omniinteract_case_result = result
monkeypatch.setattr(benchmark_patch, "publish_deferred_case_artifacts", lambda *args: None)
def fail_batch(root, *args):
root.mkdir(parents=True, exist_ok=True)
(root / "batch_summary.json").write_text("partial")
raise OSError("batch disk full")
monkeypatch.setattr(benchmark_patch, "write_omniinteract_batch_artifacts", fail_batch)
summary = benchmark_patch._finalize_omniinteract_batch([sample], [output])
assert summary and summary["success"] == 1 and summary["artifacts_complete"] is False
assert summary["artifact_errors"] == ["Batch artifact publication failed: batch disk full"]
assert output.success
assert not (options.output_root / "batch_summary.json").exists()
@pytest.mark.parametrize(
("field", "value", "match"),
[
("omniinteract_ref_audio", None, "ref-audio"),
("endpoint", "/v1/completions", "v1/realtime"),
("max_concurrency", 0, "positive"),
("skip_tokenizer_init", True, "skip-tokenizer-init"),
],
)
def test_cli_rejects_incompatible_omniinteract_options(field: str, value, match: str):
args = argparse.Namespace(
dataset_name="omniinteract",
backend="openai-realtime-duplex",
endpoint="/v1/realtime",
omniinteract_ref_audio="ref.wav",
max_concurrency=1,
skip_tokenizer_init=False,
)
setattr(args, field, value)
with pytest.raises(ValueError, match=match):
preprocess_serve_args(args)
def test_whole_benchmark_lock_serializes_a_shared_output_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
entered, release = threading.Event(), threading.Event()
calls: list[int] = []
async def fake_main_async(args):
calls.append(len(calls))
if len(calls) == 1:
entered.set()
await asyncio.to_thread(release.wait)
return {"total": 1}
monkeypatch.setattr(benchmark_serve, "main_async", fake_main_async)
args = argparse.Namespace(dataset_name="omniinteract", omniinteract_output_dir=tmp_path, extra_body=None)
with ThreadPoolExecutor(2) as executor:
first = executor.submit(benchmark_serve.main, args)
assert entered.wait(1)
second = executor.submit(benchmark_serve.main, args)
time.sleep(0.05)
assert calls == [0]
release.set()
assert first.result() == second.result() == {"total": 1}
assert calls == [0, 1]
+33
View File
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import argparse
import json
import os
@@ -22,6 +25,7 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.benchmark]
"dataset_name",
[
"daily-omni",
"omniinteract",
"seed-tts",
"seed-tts-text",
"seed-tts-design",
@@ -55,6 +59,7 @@ def test_extend_omni_choices_updates_tracking_parser_shadow(dataset_name: str) -
[
(["--print-stage"], "print_stage", True),
(["--daily-omni-input-mode", "audio"], "daily_omni_input_mode", "audio"),
(["--omniinteract-subsets", "1qna"], "omniinteract_subsets", ["1qna"]),
(["--seed-tts-locale", "zh"], "seed_tts_locale", "zh"),
],
)
@@ -80,12 +85,14 @@ def test_add_omni_args_preserves_implicit_defaults() -> None:
args = parser.parse_args([])
assert args.print_stage is False
assert args.daily_omni_input_mode == "all"
assert args.omniinteract_subsets == ["1q1a", "1q1a_math", "1qna"]
assert args.seed_tts_locale == "en"
assert args.explicit_keys == set()
def test_update_omni_help_updates_upstream_actions() -> None:
parser = TrackingArgumentParser()
parser.add_argument("--num-prompts", default=1000, help="Number of prompts.")
parser.add_argument("--percentile-metrics", help="Upstream percentile help.")
parser.add_argument("--random-mm-limit-mm-per-prompt", help="Upstream limit help.")
parser.add_argument("--random-mm-bucket-config", help="Upstream bucket help.")
@@ -93,6 +100,7 @@ def test_update_omni_help_updates_upstream_actions() -> None:
update_omni_help(parser)
actions = {action.dest: action for action in parser._actions}
assert "OmniInteract uses 3" in actions["num_prompts"].help
assert all(metric in actions["percentile_metrics"].help for metric in ("ttfc", "tpop", "audio_rtf"))
assert "probabilities are renormalized" in actions["random_mm_limit_mm_per_prompt"].help
assert "Currently allows for 3 modalities" in actions["random_mm_bucket_config"].help
@@ -120,6 +128,31 @@ def test_preprocess_serve_args_merges_bot_task_without_overriding_extra_body(
assert args.extra_body == expected
@pytest.mark.parametrize(
("argv", "expected"),
[
([], 3),
(["--num-prompts", "0"], 0),
(["--num-prompts", "12"], 12),
],
)
def test_preprocess_serve_args_applies_safe_omniinteract_prompt_default(
argv: list[str],
expected: int,
) -> None:
parser = TrackingArgumentParser()
parser.add_argument("--dataset-name", default="omniinteract")
parser.add_argument("--backend", default="openai-realtime-duplex")
parser.add_argument("--endpoint", default="/v1/realtime")
parser.add_argument("--omniinteract-ref-audio", default="reference.wav")
parser.add_argument("--num-prompts", type=int, default=1000)
args = parser.parse_args(argv)
preprocess_serve_args(args)
assert args.num_prompts == expected
@pytest.mark.parametrize(
("argv", "expected_extra_body", "expected_explicit"),
[
+8 -7
View File
@@ -2753,22 +2753,23 @@ class TestSentinelDefaultPrecedence:
)
def test_ming_flash_omni_topology(self):
"""Guard ming_flash_omni's SIP cleanup: stage 0 has no full-payload
producer hook (arch is not in ``_FULL_PAYLOAD_INPUT_STAGES``), and
stage 1 uses only ``thinker2talker_token_only`` (sync_process_input_func).
"""Guard ming_flash_omni's SIP cleanup: stage 0 has no
``custom_process_next_stage_input_func``, and stage 1 does not set
``requires_full_payload_input=True``. Stage 1 uses only
``thinker2talker_token_only`` (sync_process_input_func).
The dead ``thinker2talker`` (custom_process_input_func) was removed
because ming_flash_omni has no async_chunk support and both functions
called ``_build_talker_inputs`` identically.
Merge under either async_chunk mode must not re-introduce a
stage-0 full-payload hook."""
Merge under either async_chunk mode must not re-introduce either
full-payload transport gate."""
pipeline = resolve_pipeline_config("ming_flash_omni")
assert isinstance(pipeline, PipelineConfig)
stage0, stage1 = pipeline.stages
assert stage0.custom_process_next_stage_input_func is None, (
"ming_flash_omni stage 0 must not declare a full-payload producer "
"(connector path is not active for this arch)."
"ming_flash_omni stage 0 must not declare a full-payload producer hook."
)
assert stage1.requires_full_payload_input is False
assert stage1.custom_process_input_func is None
assert stage1.sync_process_input_func is not None
assert stage1.sync_process_input_func.endswith("thinker2talker_token_only")
+317
View File
@@ -0,0 +1,317 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Drift checks for the reviewed environment-variable inventory."""
import ast
import builtins
from collections import Counter
from pathlib import Path
import pytest
from vllm_omni.config.environment_variable_inventory import (
ENVIRONMENT_VARIABLE_INVENTORY,
EnvironmentVariableCategory,
ModelEnvironmentVariableDisposition,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
_REPO_ROOT = Path(__file__).parents[2]
_PACKAGE_ROOT = _REPO_ROOT / "vllm_omni"
_INVENTORY_MODULE = _PACKAGE_ROOT / "config" / "environment_variable_inventory.py"
_REFERENCE_PAGE = _REPO_ROOT / "docs" / "configuration" / "environment_variables.md"
def _module_string_constants(tree: ast.Module) -> dict[str, str]:
constants: dict[str, str] = {}
for statement in tree.body:
targets: list[ast.expr] = []
value: ast.expr | None = None
if isinstance(statement, ast.Assign):
targets = statement.targets
value = statement.value
elif isinstance(statement, ast.AnnAssign):
targets = [statement.target]
value = statement.value
if isinstance(value, ast.Constant) and isinstance(value.value, str):
for target in targets:
if isinstance(target, ast.Name):
constants[target.id] = value.value
return constants
def _os_import_aliases(tree: ast.Module) -> tuple[set[str], set[str], set[str]]:
os_names: set[str] = set()
getenv_names: set[str] = set()
environ_names: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for imported in node.names:
if imported.name == "os":
os_names.add(imported.asname or "os")
elif isinstance(node, ast.ImportFrom) and node.module == "os":
for imported in node.names:
if imported.name == "getenv":
getenv_names.add(imported.asname or imported.name)
elif imported.name == "environ":
environ_names.add(imported.asname or imported.name)
return os_names, getenv_names, environ_names
def _is_environ(node: ast.expr, os_names: set[str], environ_names: set[str]) -> bool:
return (
isinstance(node, ast.Attribute)
and isinstance(node.value, ast.Name)
and node.value.id in os_names
and node.attr == "environ"
) or (isinstance(node, ast.Name) and node.id in environ_names)
def _environment_key_expressions(
node: ast.AST,
os_names: set[str],
getenv_names: set[str],
environ_names: set[str],
) -> list[ast.expr]:
"""Return key expressions used by one direct environment access."""
if isinstance(node, ast.Call) and node.args:
func = node.func
if (
isinstance(func, ast.Attribute)
and isinstance(func.value, ast.Name)
and func.value.id in os_names
and func.attr == "getenv"
) or (isinstance(func, ast.Name) and func.id in getenv_names):
return [node.args[0]]
if (
isinstance(func, ast.Attribute)
and _is_environ(func.value, os_names, environ_names)
and func.attr in {"get", "pop", "setdefault"}
):
return [node.args[0]]
elif isinstance(node, ast.Subscript) and _is_environ(node.value, os_names, environ_names):
return [node.slice]
elif isinstance(node, ast.Compare):
expressions: list[ast.expr] = []
left_values = [node.left, *node.comparators[:-1]]
for left, operator, right in zip(left_values, node.ops, node.comparators):
if isinstance(operator, (ast.In, ast.NotIn)) and _is_environ(right, os_names, environ_names):
expressions.append(left)
return expressions
return []
def _resolve_environment_name(expression: ast.expr, constants: dict[str, str]) -> str | None:
value: str | None = None
if isinstance(expression, ast.Constant) and isinstance(expression.value, str):
value = expression.value
elif isinstance(expression, ast.Name):
value = constants.get(expression.id)
return value or None
def _environment_wrapper_parameters(
tree: ast.Module,
os_names: set[str],
getenv_names: set[str],
environ_names: set[str],
) -> dict[str, set[tuple[str, int | None]]]:
"""Find local helpers that forward one parameter as an environment key."""
wrappers: dict[str, set[tuple[str, int | None]]] = {}
for function in (node for node in tree.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))):
positional = [*function.args.posonlyargs, *function.args.args]
parameter_indexes: dict[str, int | None] = {parameter.arg: index for index, parameter in enumerate(positional)}
parameter_indexes.update({parameter.arg: None for parameter in function.args.kwonlyargs})
forwarded: set[tuple[str, int | None]] = set()
for node in ast.walk(function):
for expression in _environment_key_expressions(node, os_names, getenv_names, environ_names):
if isinstance(expression, ast.Name) and expression.id in parameter_indexes:
forwarded.add((expression.id, parameter_indexes[expression.id]))
if forwarded:
wrappers[function.name] = forwarded
return wrappers
def _environment_accesses(path: Path) -> set[str]:
"""Return statically resolvable names accessed through ``os``."""
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
constants = _module_string_constants(tree)
os_names, getenv_names, environ_names = _os_import_aliases(tree)
wrappers = _environment_wrapper_parameters(tree, os_names, getenv_names, environ_names)
names: set[str] = set()
for node in ast.walk(tree):
for expression in _environment_key_expressions(node, os_names, getenv_names, environ_names):
if name := _resolve_environment_name(expression, constants):
names.add(name)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
for parameter_name, position in wrappers.get(node.func.id, set()):
argument = node.args[position] if position is not None and position < len(node.args) else None
if argument is None:
argument = next(
(keyword.value for keyword in node.keywords if keyword.arg == parameter_name),
None,
)
if argument is not None and (name := _resolve_environment_name(argument, constants)):
names.add(name)
return names
def test_inventory_matches_reviewed_snapshot_counts():
"""Make an inventory expansion an explicit review decision."""
category_counts = Counter(item.category for item in ENVIRONMENT_VARIABLE_INVENTORY.values())
assert category_counts == {
EnvironmentVariableCategory.PUBLIC_OMNI: 23,
EnvironmentVariableCategory.INHERITED_VLLM: 20,
EnvironmentVariableCategory.PLATFORM_EXTERNAL: 27,
EnvironmentVariableCategory.MODEL_SPECIFIC: 56,
EnvironmentVariableCategory.BENCHMARK_TRANSITIONAL: 20,
EnvironmentVariableCategory.INTERNAL: 2,
}
disposition_counts = Counter(
item.model_disposition
for item in ENVIRONMENT_VARIABLE_INVENTORY.values()
if item.category is EnvironmentVariableCategory.MODEL_SPECIFIC
)
assert {disposition: disposition_counts[disposition] for disposition in ModelEnvironmentVariableDisposition} == {
ModelEnvironmentVariableDisposition.PROMOTE: 33,
ModelEnvironmentVariableDisposition.REQUEST_SCOPE: 6,
ModelEnvironmentVariableDisposition.EXTERNAL: 0,
ModelEnvironmentVariableDisposition.INTERNALIZE: 11,
ModelEnvironmentVariableDisposition.DEPRECATE_REMOVE: 6,
}
def test_new_public_omni_names_use_project_prefix():
"""Grandfather legacy names without allowing more prefix exceptions."""
legacy_public_names = {
"DIFFUSION_ATTENTION_BACKEND",
"DIFFUSION_CACHE_ADAPTER",
"DIFFUSION_CACHE_BACKEND",
"OMNI_DIFFUSION_PROMPT_EMBED_CACHE",
"OMNI_DIFFUSION_PROMPT_EMBED_CACHE_SIZE",
"OMNI_DIFFUSION_SESSION_STATE_MANAGER",
"OMNI_DIFFUSION_SESSION_STATE_MANAGER_MAX_SESSIONS",
"SPEAKER_MAX_UPLOADED",
"SPEAKER_SAMPLES_DIR",
"VLLM_VIDEO_ASYNC_CHUNK",
"VLLM_VIDEO_AUDIO_DELTA_MODE",
}
public_names = {
name
for name, item in ENVIRONMENT_VARIABLE_INVENTORY.items()
if item.category is EnvironmentVariableCategory.PUBLIC_OMNI
}
assert {name for name in public_names if not name.startswith("VLLM_OMNI_")} == legacy_public_names
def test_statically_resolvable_environment_accesses_are_classified():
discovered: set[str] = set()
for path in _PACKAGE_ROOT.rglob("*.py"):
discovered.update(_environment_accesses(path))
assert discovered - ENVIRONMENT_VARIABLE_INVENTORY.keys() == set()
def test_model_and_benchmark_inventory_entries_are_still_referenced():
"""Reject transitional rows after their implementation has disappeared."""
package_source = "\n".join(
path.read_text(encoding="utf-8") for path in _PACKAGE_ROOT.rglob("*.py") if path != _INVENTORY_MODULE
)
transitional_categories = {
EnvironmentVariableCategory.MODEL_SPECIFIC,
EnvironmentVariableCategory.BENCHMARK_TRANSITIONAL,
}
stale_names = {
name
for name, item in ENVIRONMENT_VARIABLE_INVENTORY.items()
if item.category in transitional_categories and name not in package_source
}
assert stale_names == set()
def test_environment_scanner_covers_indirection_aliases_membership_and_casing():
expected_by_path = {
"metrics/definitions.py": {
"VLLM_OMNI_BENCH_AUDIO_CHANNELS",
"VLLM_OMNI_BENCH_AUDIO_SAMPLE_RATE",
},
"entrypoints/openai/video_stream_envs.py": {
"VLLM_VIDEO_ASYNC_CHUNK",
"VLLM_VIDEO_AUDIO_DELTA_MODE",
},
"model_executor/models/mimo_audio/mimo_audio.py": {"model_stage"},
"model_executor/models/moss_tts/modeling_moss_tts_local_depth.py": {"MOSS_TTS_DEBUG_STOP"},
"distributed/ray_utils/utils.py": {"RAY_RAYLET_PID"},
}
for relative_path, expected in expected_by_path.items():
assert expected <= _environment_accesses(_PACKAGE_ROOT / relative_path)
def test_generated_server_storage_environment_names_are_classified():
from vllm_omni.config.server_settings import FileBackend
generated_names = {
f"VLLM_OMNI_SERVER_STORAGE__{field_name.upper()}"
for field_name in FileBackend.model_fields
if field_name != "type"
}
assert generated_names <= ENVIRONMENT_VARIABLE_INVENTORY.keys()
def test_public_omni_variables_are_in_the_reference_page():
reference = _REFERENCE_PAGE.read_text(encoding="utf-8")
missing = {
name
for name, item in ENVIRONMENT_VARIABLE_INVENTORY.items()
if item.is_public_omni and f"`{name}`" not in reference
}
assert missing == set()
def test_secret_values_are_marked_for_redaction():
assert {name for name, item in ENVIRONMENT_VARIABLE_INVENTORY.items() if item.redact_value} == {
"HF_TOKEN",
"HUGGINGFACE_HUB_TOKEN",
"OPENAI_API_KEY",
}
def test_collect_env_reports_only_safe_public_omni_values(monkeypatch):
from collect_env import get_env_vars
monkeypatch.setenv("DIFFUSION_CACHE_BACKEND", "tea_cache")
monkeypatch.setenv("HF_TOKEN", "hf-secret")
monkeypatch.setenv("OPENAI_API_KEY", "openai-secret")
monkeypatch.setenv("VLLM_OMNI_REPLICA_ID", "internal-replica")
report = get_env_vars()
assert "DIFFUSION_CACHE_BACKEND=tea_cache" in report.splitlines()
assert "hf-secret" not in report
assert "openai-secret" not in report
assert "VLLM_OMNI_REPLICA_ID" not in report
@pytest.mark.parametrize("error_type", [ImportError, OSError, AssertionError, RuntimeError])
def test_collect_env_survives_inventory_import_error(monkeypatch, error_type):
from collect_env import get_env_vars
real_import = builtins.__import__
def fail_inventory_import(name, *args, **kwargs):
if name == "vllm_omni.config.environment_variable_inventory":
raise error_type("broken vllm-omni installation")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", fail_inventory_import)
monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "0")
assert "CUDA_VISIBLE_DEVICES=0" in get_env_vars().splitlines()
+33
View File
@@ -244,6 +244,36 @@ def test_from_pipeline_config_rejects_unowned_deploy_engine_extras(engine_extras
VllmOmniConfig.from_pipeline_config(pipeline, user_deploy_config=deploy)
@pytest.mark.parametrize(
("engine_extras", "cli_overrides"),
[
({"requires_full_payload_input": False}, {}),
({}, {"stage_1_requires_full_payload_input": False}),
],
ids=["deploy", "stage-cli"],
)
def test_from_pipeline_config_rejects_full_payload_input_capability_overrides(
engine_extras,
cli_overrides,
):
pipeline = _resolve_pipeline_or_skip("qwen3_tts")
deploy = DeployConfig(
stages=[
StageDeployConfig(
stage_id=1,
engine_extras=engine_extras,
)
]
)
with pytest.raises(ValueError, match=r"no structured config owner: requires_full_payload_input"):
VllmOmniConfig.from_pipeline_config(
pipeline,
user_deploy_config=deploy,
cli_overrides=cli_overrides,
)
@pytest.mark.parametrize(
("cli_overrides", "stage_id"),
[
@@ -443,10 +473,12 @@ def test_from_pipeline_config_dispatches_async_chunk_processors_without_mutating
async_config = _from_pipeline_key("qwen3_tts")
assert async_config.stage_by_id(0).custom_process_next_stage_input_func.endswith("talker2code2wav_async_chunk")
assert async_config.stage_by_id(1).custom_process_input_func is None
assert async_config.stage_by_id(1).model_config.requires_full_payload_input is True
sync_config = _from_pipeline_key("qwen3_tts", cli_overrides={"async_chunk": False})
assert sync_config.stage_by_id(0).custom_process_next_stage_input_func.endswith("talker2code2wav_full_payload")
assert sync_config.stage_by_id(1).custom_process_input_func.endswith("talker2code2wav_token_only")
assert sync_config.stage_by_id(1).model_config.requires_full_payload_input is True
assert pipeline.get_stage(0).custom_process_next_stage_input_func.endswith("talker2code2wav_full_payload")
assert pipeline.get_stage(1).custom_process_input_func is None
@@ -567,6 +599,7 @@ def test_sub_config_fields_match_structured_scopes():
# legacy engine-args path.
"model_subdir",
"tokenizer_subdir",
"requires_full_payload_input",
}
vllm_load_fields = {f.name for f in fields(VllmLoadConfig)}
assert issubclass(OmniStageLoadConfig, VllmLoadConfig)
+5 -46
View File
@@ -1,7 +1,10 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
Root pytest entrypoint for the vLLM-Omni test suite.
- `tests/conftest.py` stays thin: plugin registration + compatibility re-exports.
- `tests/conftest.py` stays thin: plugin registration only.
- Importable utilities live under `tests/helpers/`.
- Helper unit tests live under `tests/helpers/tests/`.
- Fixtures live under `tests/helpers/fixtures/` and are loaded via `pytest_plugins`.
@@ -19,12 +22,11 @@ bootstrap_vllm_layer_custom_op_modules()
pytest_plugins = (
"tests.helpers.fixtures.config",
"tests.helpers.fixtures.env",
"tests.helpers.fixtures.clean",
"tests.helpers.fixtures.log",
"tests.helpers.fixtures.media",
"tests.helpers.fixtures.run_args",
"tests.helpers.fixtures.runtime",
"tests.helpers.fixtures.speaker_cache",
"tests.helpers.fixtures.xdist",
)
@@ -32,46 +34,3 @@ pytest_plugins = (
def pytest_terminal_summary(terminalreporter, exitstatus, config):
# Marker for Buildkite log folding before pytest summary lines.
terminalreporter.write_line("--- Running Summary")
# Backward-compatible lazy re-exports.
# (Many tests still import from `tests.conftest`; migrate these imports to `tests.helpers.*` over time.)
# Keep these lazy so conftest import does not trigger heavy helper dependencies.
_ASSERTION_EXPORT_NAMES = (
"assert_audio_speech_response",
"assert_diffusion_response",
"assert_http_error",
"assert_image_diffusion_response",
"assert_image_valid",
"assert_omni_response",
"assert_video_diffusion_response",
"assert_video_valid",
)
_MEDIA_EXPORT_NAMES = (
"convert_audio_bytes_to_text",
"convert_audio_file_to_text",
"cosine_similarity_text",
"decode_b64_image",
"generate_synthetic_audio",
"generate_synthetic_image",
"generate_synthetic_video",
)
_STAGE_CONFIG_EXPORT_NAMES = ("modify_stage_config",)
_RUNTIME_EXPORT_NAMES = (
"DiffusionResponse",
"HttpResponse",
"OmniResponse",
"OmniRunner",
"OmniRunnerHandler",
"OmniServer",
"OmniServerParams",
"OmniServerStageCli",
"OpenAIClientHandler",
"dummy_messages_from_mix_data",
)
_LAZY_EXPORT_MODULES = {
**{name: "tests.helpers.assertions" for name in _ASSERTION_EXPORT_NAMES},
**{name: "tests.helpers.media" for name in _MEDIA_EXPORT_NAMES},
**{name: "tests.helpers.stage_config" for name in _STAGE_CONFIG_EXPORT_NAMES},
**{name: "tests.helpers.runtime" for name in _RUNTIME_EXPORT_NAMES},
}
@@ -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 Omni AR streaming-session async placeholder handling."""
@@ -64,6 +64,9 @@ def _run_resumable_segment_stop(
session: Request,
*,
session_finished: bool = False,
handle_stopped=None,
chunk_transfer_adapter=None,
inter_stage_output=None,
):
sched = MagicMock()
sched.requests = {session.request_id: session}
@@ -75,11 +78,14 @@ def _run_resumable_segment_stop(
return [42], True
sched._update_request_with_output.side_effect = stop_request
sched._handle_stopped_request.return_value = session_finished
# vLLM 0.26 returns (kv_xfer_params, ec_xfer_params); an unconfigured
# MagicMock iterates empty and fails to unpack at the call site.
sched._free_request.return_value = (None, None)
sched.chunk_transfer_adapter = None
if handle_stopped is None:
sched._handle_stopped_request.return_value = session_finished
else:
sched._handle_stopped_request.side_effect = handle_stopped
sched.chunk_transfer_adapter = chunk_transfer_adapter
sched.running = [session]
sched.waiting_for_transfer_free = set()
sched.transfer_triggered_requests = set()
@@ -105,6 +111,7 @@ def _run_resumable_segment_stop(
model_runner_output.cudagraph_stats = None
model_runner_output.req_id_to_index = {session.request_id: 0}
model_runner_output.routed_experts = None
model_runner_output.inter_stage_outputs = [inter_stage_output] if inter_stage_output is not None else None
return OmniARScheduler.update_from_output(sched, scheduler_output, model_runner_output)
@@ -160,6 +167,41 @@ def test_update_from_output_settles_in_flight_tokens() -> None:
assert session.num_in_flight_tokens == 0
def test_resumable_segment_boundary_keeps_pre_transition_send_watermark() -> None:
"""The old segment's transfer must not observe the next segment's reset.
A queued streaming update can replace the same mutable Request while the
stop output is still being handled. The connector needs the confirmed
token count from before that replacement to avoid dropping the boundary.
"""
session = _make_request()
session.status = RequestStatus.RUNNING
session.resumable = True
session.num_computed_tokens = 26
adapter = MagicMock()
adapter._confirmed_num_computed_tokens.return_value = 26
inter_stage_output = {"codes": {"audio": [7]}}
def replace_with_next_segment(request: Request) -> bool:
request.num_computed_tokens = 0
request.status = RequestStatus.WAITING
return False
_run_resumable_segment_stop(
session,
handle_stopped=replace_with_next_segment,
chunk_transfer_adapter=adapter,
inter_stage_output=inter_stage_output,
)
adapter.save_async.assert_called_once_with(
inter_stage_output,
session,
True,
confirmed_num_computed_tokens=26,
)
def test_running_decode_step_without_inter_stage_payload_does_not_raise() -> None:
"""A decode step that neither stops nor carries an inter-stage payload.
@@ -4,10 +4,8 @@ from types import SimpleNamespace
import pytest
from vllm.v1.engine import FinishReason
from vllm_omni.core.sched.omni_scheduler_mixin import (
DEFAULT_INPUT_WAIT_TIMEOUT_S,
OmniSchedulerMixin,
)
from vllm_omni.core.sched import omni_scheduler_mixin
from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin
from vllm_omni.core.sched.output import OmniChunkRecvHandle
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -17,6 +15,77 @@ class _Scheduler(OmniSchedulerMixin):
pass
def test_async_chunk_adapter_initializes_for_stage_zero_sender_and_stage_one_receiver(monkeypatch):
created_adapters = []
def adapter_factory(config):
adapter = SimpleNamespace(vllm_config=config)
created_adapters.append(adapter)
return adapter
monkeypatch.setattr(omni_scheduler_mixin, "OmniChunkTransferAdapter", adapter_factory)
def init_scheduler(**model_config):
scheduler = _Scheduler()
scheduler.vllm_config = SimpleNamespace(model_config=SimpleNamespace(**model_config))
scheduler._init_omni_io_scheduling_state()
return scheduler
producer = init_scheduler(
stage_id=0,
async_chunk=True,
requires_full_payload_input=False,
custom_process_next_stage_input_func="test.pipeline.produce_async_chunk",
)
receiver = init_scheduler(
stage_id=1,
async_chunk=True,
requires_full_payload_input=True,
custom_process_next_stage_input_func=None,
)
terminal_stage = init_scheduler(
stage_id=2,
async_chunk=True,
requires_full_payload_input=False,
custom_process_next_stage_input_func=None,
)
assert producer.chunk_transfer_adapter is not None
assert receiver.chunk_transfer_adapter is not None
assert terminal_stage.chunk_transfer_adapter is not None
assert receiver.input_coordinator is None
assert len(created_adapters) == 3
@pytest.mark.parametrize(
("stage_id", "async_chunk", "required", "enabled"),
[
(0, False, True, False),
(1, True, True, False),
(1, False, False, False),
(1, False, True, True),
],
)
def test_full_payload_coordinator_matches_legacy_gate(monkeypatch, stage_id, async_chunk, required, enabled):
monkeypatch.setattr(
omni_scheduler_mixin,
"OmniChunkTransferAdapter",
lambda _config: SimpleNamespace(),
)
scheduler = _Scheduler()
scheduler.vllm_config = SimpleNamespace(
model_config=SimpleNamespace(
stage_id=stage_id,
async_chunk=async_chunk,
requires_full_payload_input=required,
)
)
scheduler._init_omni_io_scheduling_state()
assert (scheduler.input_coordinator is not None) is enabled
def test_schedule_lifecycle_helpers_process_and_restore_both_input_paths():
calls = []
scheduler = _Scheduler()
@@ -58,7 +127,7 @@ def test_schedule_lifecycle_helpers_process_and_restore_both_input_paths():
("process", scheduler.waiting, scheduler.running, scheduler.requests),
# The chunk deadline runs after chunks are applied, so a chunk that
# arrived this cycle resets the clock before it is measured (R1.1).
("chunk-timeouts", DEFAULT_INPUT_WAIT_TIMEOUT_S),
("chunk-timeouts", omni_scheduler_mixin.DEFAULT_INPUT_WAIT_TIMEOUT_S),
("failed-sends",),
("restore-chunks", scheduler.waiting, scheduler.running, scheduler.requests),
("restore-full", scheduler.waiting),
@@ -20,7 +20,6 @@ import torch
import vllm_omni.core.sched.omni_scheduling_coordinator as coord_mod
from vllm_omni.core.sched.omni_scheduling_coordinator import (
OmniSchedulingCoordinator,
uses_full_payload_input_coordinator,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -96,109 +95,6 @@ class MockQueue:
# ------------------------------------------------------------------ #
class TestFullPayloadCoordinatorSelection(unittest.TestCase):
"""Tests for the (model_arch, model_stage) whitelist gate.
The init_omni_connectors arch allowlist is keyed by ``model_arch`` and
is a superset of the stages registered here -- consumer-wait stages
must be registered explicitly in ``_FULL_PAYLOAD_INPUT_STAGES``, while
the init allowlist covers both producer- and consumer-side runners.
These tests pin which ``(arch, stage)`` pairs the gate fires for today.
"""
# Expected whitelist (model_arch, model_stage). Hardcoded to avoid the
# tautology of importing _FULL_PAYLOAD_INPUT_STAGES and asserting it
# against itself; any drift between this matrix and the whitelist will
# fail loudly here.
EXPECTED_FULL_PAYLOAD_INPUT_STAGES: frozenset[tuple[str, str]] = frozenset(
{
("Qwen3OmniMoeForConditionalGeneration", "talker"),
("Qwen3OmniMoeForConditionalGeneration", "code2wav"),
("Qwen2_5OmniForConditionalGeneration", "talker"),
("Qwen2_5OmniForConditionalGeneration", "code2wav"),
("CovoAudioForConditionalGeneration", "code2wav"),
("MiMoAudioModel", "code2wav"),
("Qwen3TTSCode2Wav", "code2wav"),
("MiniCPMO45Code2Wav", "code2wav"),
# nemotron_voicechat: sync-path RVQ-VAE decode waits for the
# talker's full [frames, 31] code-stack payload.
("NemotronVoiceChatCode2Wav", "code2wav"),
("CosyVoice3Model", "cosyvoice3_code2wav"),
# audex: sync-path TTS decoder (async_chunk: false deploys) and
# the TTA XCodec1 stage (always sync full-payload).
("AudexCode2Wav", "audex_code2wav"),
("AudexXCodec1", "audex_xcodec"),
("IndexTTS2S2MelDecoder", "indextts2_s2mel_decoder"),
("IndexTTS25S2MelDecoder", "indextts2_5_s2mel_decoder"),
("DyninOmniForConditionalGeneration", "token2image"),
("DyninOmniForConditionalGeneration", "token2audio"),
}
)
def test_whitelist_matches_expected_matrix(self):
"""_FULL_PAYLOAD_INPUT_STAGES must equal the hardcoded expected matrix.
Catches both accidental additions (which would silently enable the
consumer-wait gate for a new arch) and accidental removals (which
would silently disable an enabled arch).
"""
from vllm_omni.core.sched.omni_scheduling_coordinator import _FULL_PAYLOAD_INPUT_STAGES
self.assertEqual(
frozenset(_FULL_PAYLOAD_INPUT_STAGES),
self.EXPECTED_FULL_PAYLOAD_INPUT_STAGES,
msg="_FULL_PAYLOAD_INPUT_STAGES drifted from the expected matrix; "
"update EXPECTED_FULL_PAYLOAD_INPUT_STAGES if intentional.",
)
def test_all_whitelisted_arch_stage_pairs_fire_gate(self):
"""Every (arch, stage) pair in the expected matrix must fire
the gate when stage_id > 0 and async_chunk=False.
"""
for arch, stage in self.EXPECTED_FULL_PAYLOAD_INPUT_STAGES:
model_config = SimpleNamespace(
stage_id=1,
async_chunk=False,
model_arch=arch,
model_stage=stage,
)
self.assertTrue(
uses_full_payload_input_coordinator(model_config),
msg=f"expected gate to fire for {arch}/{stage}",
)
def test_other_arch_or_stage_or_mode_does_not_fire(self):
cases = [
SimpleNamespace(
stage_id=1, async_chunk=True, model_arch="Qwen3OmniMoeForConditionalGeneration", model_stage="talker"
),
SimpleNamespace(
stage_id=0, async_chunk=False, model_arch="Qwen3OmniMoeForConditionalGeneration", model_stage="thinker"
),
SimpleNamespace(
stage_id=1,
async_chunk=False,
model_arch="Qwen3OmniMoeForConditionalGeneration",
model_stage="some_future_stage",
),
SimpleNamespace(
stage_id=1, async_chunk=False, model_arch="Qwen3TTSForConditionalGeneration", model_stage="code2wav"
),
SimpleNamespace(
stage_id=1, async_chunk=False, model_arch="MingFlashOmniForConditionalGeneration", model_stage="talker"
),
SimpleNamespace(stage_id=1, async_chunk=False, model_arch=None, model_stage="talker"),
SimpleNamespace(
stage_id=1, async_chunk=False, model_arch="Qwen3OmniMoeForConditionalGeneration", model_stage=None
),
]
for model_config in cases:
self.assertFalse(
uses_full_payload_input_coordinator(model_config),
msg=f"expected gate OFF for {model_config}",
)
class TestCoordinatorUpdateRequestMetadata(unittest.TestCase):
"""Test update_request_metadata applies scheduling metadata to requests."""
+5 -5
View File
@@ -105,7 +105,7 @@ def create_unique_server_pytest_params(
configs: list[dict[str, Any]],
stage_configs_dir: Path,
) -> list[Any]:
"""Like :func:`create_unique_server_params`, but wrap each row in ``pytest.param`` with JSON marks."""
"""Like :func:`_create_unique_server_params`, but wrap each row in ``pytest.param`` with JSON marks."""
marks_by_name = _marks_by_test_name(configs)
return [
pytest.param(
@@ -113,7 +113,7 @@ def create_unique_server_pytest_params(
marks=marks_by_name.get(row[0], []),
id=row[0],
)
for row in create_unique_server_params(configs, stage_configs_dir)
for row in _create_unique_server_params(configs, stage_configs_dir)
]
@@ -150,7 +150,7 @@ def create_paired_omni_benchmark_pytest_params(
"""Paired params for ``run_benchmark.py`` (omni/tts)."""
mapping = create_test_parameter_mapping(configs)
marks_by_name = _marks_by_test_name(configs)
server_entries = [(row, row[0]) for row in create_unique_server_params(configs, stage_configs_dir)]
server_entries = [(row, row[0]) for row in _create_unique_server_params(configs, stage_configs_dir)]
params_by_test_name = {
test_name: get_benchmark_params_for_server(test_name, mapping) for _, test_name in server_entries
}
@@ -232,7 +232,7 @@ def _build_serve_args(serve_args: Any) -> list[str]:
return args
def create_unique_server_params(
def _create_unique_server_params(
configs: list[dict[str, Any]],
stage_configs_dir: Path,
) -> list[tuple[str, str, str | None, str | None, tuple[str, ...], bool]]:
@@ -311,7 +311,7 @@ def create_reliability_omni_server_params(
configs: list[dict[str, Any]], stage_configs_dir: Path
) -> list[OmniServerParams]:
adjusted_configs = configs_with_platform_stage_configs(configs)
unique_params = create_unique_server_params(adjusted_configs, stage_configs_dir)
unique_params = _create_unique_server_params(adjusted_configs, stage_configs_dir)
server_args_by_name = extract_server_args_by_test_name(adjusted_configs)
return [
OmniServerParams(
@@ -21,47 +21,6 @@
]
},
"benchmark_params": [
{
"dataset_name": "random",
"backend": "openai-chat-omni",
"endpoint": "/v1/chat/completions",
"num_prompts": [
4,
16,
32,
64,
128
],
"max_concurrency": [
1,
4,
8,
16,
32
],
"random_input_len": 2500,
"random_output_len": 900,
"ignore_eos": true,
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"H100": {
"mean_e2el_ms": [
11701.3818,
27510.3956,
31907.9613,
42173.9832,
63970.0751
],
"mean_audio_rtf": [
0.1175,
0.1647,
0.2003,
0.2726,
0.4195
]
}
}
},
{
"dataset_name": "random-mm",
"backend": "openai-chat-omni",
@@ -170,45 +129,6 @@
}
}
},
{
"dataset_name": "random",
"backend": "openai-chat-omni",
"endpoint": "/v1/chat/completions",
"num_prompts": [
4,
16,
32,
64,
128
],
"max_concurrency": [
1,
4,
8,
16,
32
],
"random_input_len": 2500,
"random_output_len": 900,
"ignore_eos": true,
"extra_body": {
"modalities": [
"text"
]
},
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"H100": {
"mean_e2el_ms": [
4354.8945,
7672.4248,
9861.1687,
13086.8865,
18716.709
]
}
}
},
{
"dataset_name": "random-mm",
"backend": "openai-chat-omni",
@@ -324,5 +244,61 @@
}
}
]
},
{
"test_name": "test_qwen3_omni_chunk_cuda",
"mark": [
{
"hardware_marks": {
"res": {
"cuda": "H100"
},
"num_cards": 2
}
},
"omni",
"slow"
],
"server_params": {
"model": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
"extra_cli_args": [
"--async-chunk"
]
},
"benchmark_params": [
{
"dataset_name": "random",
"backend": "openai-chat-omni",
"endpoint": "/v1/chat/completions",
"num_prompts": [
4,
32,
128
],
"max_concurrency": [
1,
8,
32
],
"random_input_len": 100,
"random_output_len": 100,
"ignore_eos": true,
"extra_body": {
"modalities": [
"text"
]
},
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"H100": {
"mean_e2el_ms": [
4354.8945,
9861.1687,
18716.709
]
}
}
}
]
}
]
@@ -29,16 +29,12 @@
"endpoint": "/v1/completions",
"num_prompts": [
4,
16,
32,
64,
128
],
"max_concurrency": [
1,
4,
8,
16,
32
],
"random_input_len": 2500,
@@ -54,9 +50,7 @@
"H100": {
"mean_e2el_ms": [
4619.0068,
7868.3523,
9760.6175,
13422.3491,
18007.6918
]
}
@@ -99,45 +93,6 @@
}
}
},
{
"dataset_name": "random-mm",
"backend": "vllm",
"model": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
"endpoint": "/v1/completions",
"num_prompts": [
40
],
"request_rate": [
0.5
],
"random_input_len": 100,
"random_output_len": 100,
"random_range_ratio": 0.0,
"ignore_eos": true,
"extra_body": {
"modalities": [
"text"
]
},
"random_mm_base_items_per_request": 2,
"random_mm_num_mm_items_range_ratio": 0.5,
"random_mm_limit_mm_per_prompt": {
"image": 1,
"video": 1
},
"random_mm_bucket_config": {
"(256, 256, 1)": 0.5,
"(720, 1280, 2)": 0.5
},
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"H100": {
"mean_e2el_ms": [
524.5321
]
}
}
},
{
"dataset_name": "random-mm",
"backend": "vllm",
@@ -74,13 +74,14 @@
"full_model",
"diffusion"
],
"description": "Single-device baseline (no parallelism) with step execution",
"description": "Single-device step execution: sequential 512/1536 plus 512 concurrency sweep. One server (max-num-seqs 8).",
"server_type": "vllm-omni",
"server_params": {
"model": "Qwen/Qwen-Image",
"serve_args": {
"enable-diffusion-pipeline-profiler": true,
"step-execution": true
"step-execution": true,
"max-num-seqs": 8
}
},
"benchmark_params": [
@@ -119,6 +120,27 @@
"peak_memory_mb_mean": 64500.0
}
}
},
{
"name": "512x512_steps20_high_concurrency",
"dataset": "random",
"task": "t2i",
"width": 512,
"height": 512,
"num-inference-steps": 20,
"num-prompts": [20, 40, 80, 160],
"max-concurrency": [1, 2, 4, 8],
"warmup-requests": 8,
"warmup-concurrency": 8,
"warmup-num-inference-steps": 20,
"enable-negative-prompt": true,
"baseline": {
"H100": {
"throughput_qps": [0.3937, 0.6606, 0.6188, 0.7752],
"latency_mean": [2.5466, 3.0235, 6.3948, 10.1944],
"peak_memory_mb_mean": [55149.0, 55150.0, 55153.1458, 55156.325]
}
}
}
]
},
@@ -243,53 +265,5 @@
}
}
]
},
{
"test_name": "test_qwen_image_single_device_step_execution_high_concurrency",
"mark": [
{
"hardware_marks": {
"res": {
"cuda": "H100"
},
"num_cards": 1
}
},
"full_model",
"diffusion"
],
"description": "Single-device Qwen-Image step-level execution benchmark at 512x512, 20 steps, concurrency sweep",
"server_type": "vllm-omni",
"server_params": {
"model": "Qwen/Qwen-Image",
"serve_args": {
"enable-diffusion-pipeline-profiler": true,
"step-execution": true,
"max-num-seqs": 8
}
},
"benchmark_params": [
{
"name": "512x512_steps20_high_concurrency",
"dataset": "random",
"task": "t2i",
"width": 512,
"height": 512,
"num-inference-steps": 20,
"num-prompts": [20, 40, 80, 160],
"max-concurrency": [1, 2, 4, 8],
"warmup-requests": 8,
"warmup-concurrency": 8,
"warmup-num-inference-steps": 20,
"enable-negative-prompt": true,
"baseline": {
"H100": {
"throughput_qps": [0.3937, 0.6606, 0.6188, 0.7752],
"latency_mean": [2.5466, 3.0235, 6.3948, 10.1944],
"peak_memory_mb_mean": [55149.0, 55150.0, 55153.1458, 55156.325]
}
}
}
]
}
]
@@ -1,13 +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
"""``POST /v1/audio/generate`` validation (live Stable Audio Open server)."""
from __future__ import annotations
from typing import Any
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
from tests.helpers.runtime import OmniServer, OmniServerParams, OnlineOmniClient
pytestmark = [pytest.mark.slow, pytest.mark.diffusion]
@@ -95,20 +97,20 @@ _PARAMS = [
@pytest.mark.parametrize("omni_server", _PARAMS, indirect=True)
def test_audio_generate_invalid_field_values(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
overrides: dict[str, object],
err_message: str | tuple[str, ...],
) -> None:
body = {"model": omni_server.model, "input": "ambient electronic pad"}
body: dict[str, Any] = {"model": omni_server.model, "input": "ambient electronic pad"}
body.update(overrides)
openai_client.send_audio_generate_http_request(
online_client.send_audio_generate_http_request(
{"json": body, "timeout": 120, "err_code": 400, "err_message": err_message}
)
@pytest.mark.parametrize("omni_server", _PARAMS, indirect=True)
def test_audio_generate_missing_input(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
openai_client.send_audio_generate_http_request(
def test_audio_generate_missing_input(omni_server: OmniServer, online_client: OnlineOmniClient) -> None:
online_client.send_audio_generate_http_request(
{
"json": {"model": omni_server.model},
"timeout": 120,
@@ -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
"""HTTP + WebSocket validation for Qwen3-TTS Base, Higgs Audio V3 and Audex TTA ``/v1/audio/speech`` (and related routes)."""
from __future__ import annotations
@@ -13,8 +13,8 @@ from typing import Any
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.media import load_test_audio_data_url
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
from tests.helpers.media import get_asset_path
from tests.helpers.runtime import OmniServer, OmniServerParams, OnlineOmniClient
from tests.helpers.stage_config import get_deploy_config_path
# Speech / voice-upload numeric caps for these tests only (not exported from vllm_omni).
@@ -26,7 +26,7 @@ _SPEECH_API_MAX_NEW_TOKENS = 4096
_VOICE_UPLOAD_MAX_CONSENT_LEN = 1024
_VOICE_UPLOAD_MAX_REF_TEXT_CHARS = 8192
_VOICE_UPLOAD_MAX_SPEAKER_DESCRIPTION_CHARS = 2048
REF_AUDIO_URL = load_test_audio_data_url("qwen3_tts/clone_2.wav")
REF_AUDIO_URL = get_asset_path("qwen3_tts/clone_2.wav", as_data_url=True)
REF_TEXT = "Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you."
# Qwen3-TTS 0.6B expects 1024-dim speaker embeddings (see serving_speech / talker hidden_size).
_QWEN3_TTS_06B_SPEAKER_EMBEDDING_DIM = 1024
@@ -57,6 +57,7 @@ _QWEN3_TTS_SPEECH = [
),
]
_HIGGS_AUDIO_V3_SPEECH = [
pytest.param(
OmniServerParams(
@@ -84,7 +85,7 @@ _AUDEX_TTA_SPEECH = [
]
def _apply_batch_overrides(body: dict[str, object], *, loc: str, overrides: dict[str, object]) -> None:
def _apply_batch_overrides(body: dict[str, Any], *, loc: str, overrides: dict[str, object]) -> None:
if loc == "batch":
body.update(overrides)
return
@@ -112,8 +113,8 @@ def _pcm_wav_mono_bytes(*, duration_s: float = 1.1, sample_rate: int = 44100) ->
@pytest.mark.parametrize("omni_server", _QWEN3_TTS_SPEECH, indirect=True)
def test_speech_malformed_json(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
openai_client.send_audio_speech_http_request(
def test_speech_malformed_json(omni_server: OmniServer, online_client: OnlineOmniClient) -> None:
online_client.send_audio_speech_http_request(
{
"raw_body": "{",
"timeout": 120,
@@ -124,8 +125,8 @@ def test_speech_malformed_json(omni_server: OmniServer, openai_client: OpenAICli
@pytest.mark.parametrize("omni_server", _QWEN3_TTS_SPEECH, indirect=True)
def test_speech_missing_required_fields(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
openai_client.send_audio_speech_http_request(
def test_speech_missing_required_fields(omni_server: OmniServer, online_client: OnlineOmniClient) -> None:
online_client.send_audio_speech_http_request(
{
"json": {"model": omni_server.model},
"timeout": 120,
@@ -156,8 +157,8 @@ def test_speech_missing_required_fields(omni_server: OmniServer, openai_client:
"ref_audio": None,
"ref_text": None,
},
("CustomVoice", "does not support"),
id="customvoice_task_not_supported",
("Invalid voice", "vivian"),
id="customvoice_invalid_voice",
),
pytest.param(
{"task_type": "InvalidEnum"}, ("task_type", "literal_error", "CustomVoice"), id="task_type_invalid"
@@ -180,13 +181,17 @@ def test_speech_missing_required_fields(omni_server: OmniServer, openai_client:
("mutually exclusive", "speaker_embedding", "ref_audio"),
id="speaker_embedding_with_ref_audio_mutually_exclusive",
),
pytest.param({"max_new_tokens": 0}, ("max_new_tokens", "least 1"), id="max_new_tokens_below_min"),
pytest.param({"max_new_tokens": 0}, ("max_new_tokens", "greater_than_equal"), id="max_new_tokens_below_min"),
pytest.param(
{"max_new_tokens": _SPEECH_API_MAX_NEW_TOKENS + 1},
("max_new_tokens", "exceed 4096"),
id="max_new_tokens_above_max",
),
pytest.param({"seed": -1}, ("seed", "greater_than_equal", "0"), id="seed_negative"),
pytest.param(
{"seed": -(2**63) - 1},
("seed", "greater_than_equal", "-9223372036854775808"),
id="seed_below_min",
),
pytest.param({"seed": 2**63}, ("seed", "less_than_equal", "9223372036854775807"), id="seed_above_max"),
pytest.param(
{"initial_codec_chunk_frames": -1},
@@ -242,13 +247,13 @@ def test_speech_missing_required_fields(omni_server: OmniServer, openai_client:
@pytest.mark.parametrize("omni_server", _QWEN3_TTS_SPEECH, indirect=True)
def test_speech_invalid_field_values(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
overrides: dict[str, object],
err_message: str | tuple[str, ...],
) -> None:
ov = dict(overrides)
embedding_only = bool(ov.pop(_SPEECH_INVALID_EMBEDDING_ONLY_BODY, False))
body = {
body: dict[str, Any] = {
"model": omni_server.model,
"input": "Hello.",
"voice": "clone",
@@ -261,7 +266,7 @@ def test_speech_invalid_field_values(
body.pop("ref_audio", None)
body.pop("ref_text", None)
body.update(ov)
openai_client.send_audio_speech_http_request(
online_client.send_audio_speech_http_request(
{"json": body, "timeout": 120, "err_code": 400, "err_message": err_message}
)
@@ -274,7 +279,9 @@ def test_speech_invalid_field_values(
pytest.param({"input": "", "response_format": "wav"}, ("input", "empty"), id="input_empty"),
pytest.param({"input": " ", "response_format": "wav"}, ("input", "empty"), id="input_whitespace_only"),
pytest.param(
{"input": "Hello world.", "max_new_tokens": 0}, ("max_new_tokens", "least 1"), id="max_new_tokens_below_min"
{"input": "Hello world.", "max_new_tokens": 0},
("max_new_tokens", "greater_than_equal"),
id="max_new_tokens_below_min",
),
pytest.param(
{
@@ -328,12 +335,12 @@ def test_speech_invalid_field_values(
@pytest.mark.parametrize("omni_server", _HIGGS_AUDIO_V3_SPEECH, indirect=True)
def test_speech_higgs_invalid_field_values(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
extra_json: dict[str, object],
err_message: str | tuple[str, ...],
) -> None:
body = {"model": omni_server.model, **extra_json}
openai_client.send_audio_speech_http_request(
online_client.send_audio_speech_http_request(
{"json": body, "timeout": 120, "err_code": 400, "err_message": err_message}
)
@@ -359,12 +366,12 @@ def test_speech_higgs_invalid_field_values(
@pytest.mark.parametrize("omni_server", _AUDEX_TTA_SPEECH, indirect=True)
def test_speech_audex_tta_invalid_field_values(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
overrides: dict[str, object],
err_message: str | tuple[str, ...],
) -> None:
body = {"model": omni_server.model, "input": _AUDEX_TTA_CAPTION, **overrides}
openai_client.send_audio_speech_http_request(
online_client.send_audio_speech_http_request(
{"json": body, "timeout": 120, "err_code": 400, "err_message": err_message}
)
@@ -375,15 +382,15 @@ def test_speech_audex_tta_invalid_field_values(
@pytest.mark.parametrize("omni_server", _QWEN3_TTS_SPEECH, indirect=True)
def test_speech_batch_empty_items(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
openai_client.send_audio_speech_batch_http_request(
def test_speech_batch_empty_items(omni_server: OmniServer, online_client: OnlineOmniClient) -> None:
online_client.send_audio_speech_batch_http_request(
{"json": {"items": []}, "timeout": 120, "err_code": (400, 422), "err_message": ("items", "least 1 item")}
)
@pytest.mark.parametrize("omni_server", _QWEN3_TTS_SPEECH, indirect=True)
def test_speech_batch_missing_items(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
openai_client.send_audio_speech_batch_http_request(
def test_speech_batch_missing_items(omni_server: OmniServer, online_client: OnlineOmniClient) -> None:
online_client.send_audio_speech_batch_http_request(
{
"json": {"model": omni_server.model},
"timeout": 120,
@@ -553,14 +560,14 @@ def test_speech_batch_missing_items(omni_server: OmniServer, openai_client: Open
pytest.param(
"batch",
{"max_new_tokens": 0},
("max_new_tokens", "least 1"),
("max_new_tokens", "greater_than_equal"),
id="batch_max_new_tokens_below_min",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"item",
{"max_new_tokens": 0},
("max_new_tokens", "least 1"),
("max_new_tokens", "greater_than_equal"),
id="item_max_new_tokens_below_min",
marks=_SKIP_ISSUE_3649,
),
@@ -618,14 +625,14 @@ def test_speech_batch_missing_items(omni_server: OmniServer, openai_client: Open
@pytest.mark.parametrize("omni_server", _QWEN3_TTS_SPEECH, indirect=True)
def test_speech_batch_invalid_field_values(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
loc: str,
overrides: dict[str, object],
err_message: str | tuple[str, ...],
) -> None:
ov = dict(overrides)
strip_clone = bool(ov.pop(_SPEECH_BATCH_NO_CLONE_FIELDS, False))
body = {
body: dict[str, Any] = {
"model": omni_server.model,
"voice": "clone",
"ref_audio": REF_AUDIO_URL,
@@ -638,7 +645,7 @@ def test_speech_batch_invalid_field_values(
body.pop("ref_audio", None)
body.pop("ref_text", None)
_apply_batch_overrides(body, loc=loc, overrides=ov)
openai_client.send_audio_speech_batch_http_request(
online_client.send_audio_speech_batch_http_request(
{"json": body, "timeout": 120, "err_code": 400, "err_message": err_message}
)
@@ -670,7 +677,7 @@ _SPEECH_STREAM_WS_SESSION_STREAM_AUDIO_WAV_FRAMES = object()
@pytest.mark.parametrize("omni_server", _QWEN3_TTS_SPEECH, indirect=True)
def test_speech_stream_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
send_frames_spec: Any,
err_message: str | tuple[str, ...],
) -> None:
@@ -687,7 +694,7 @@ def test_speech_stream_invalid_requests(
else:
send_frames = send_frames_spec
assert isinstance(send_frames, str)
openai_client.send_audio_speech_stream_ws_request(
online_client.send_audio_speech_stream_ws_request(
{
"send_frames": send_frames,
"timeout": 120,
@@ -893,7 +900,7 @@ def _voices_upload_multipart_files(kind: str | None) -> dict[str, Any] | None:
@pytest.mark.parametrize("omni_server", _QWEN3_TTS_SPEECH, indirect=True)
def test_voices_create_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
multipart_kind: str | None,
form_template: dict[str, Any],
err_message: str | tuple[str, ...],
@@ -909,7 +916,7 @@ def test_voices_create_invalid_requests(
files = _voices_upload_multipart_files(multipart_kind)
if files is not None:
cfg["files"] = files
openai_client.send_audio_voices_create_http_request(cfg)
online_client.send_audio_voices_create_http_request(cfg)
@pytest.mark.parametrize(
@@ -962,13 +969,13 @@ def test_voices_create_invalid_requests(
@pytest.mark.parametrize("omni_server", _QWEN3_TTS_SPEECH, indirect=True)
def test_voices_delete_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
voice_name: str,
err_code: int | tuple[int, ...],
err_message: str | tuple[str, ...],
) -> None:
"""DELETE ``/v1/audio/voices/{name}``: missing voice (404), odd segments (often 404), ``.``paths (405)."""
openai_client.send_audio_voices_delete_http_request(
online_client.send_audio_voices_delete_http_request(
{
"name": voice_name,
"timeout": 120,
@@ -1021,7 +1028,7 @@ _MINIMAX_MUSIC3_MAX_FRAMES = 9000
@pytest.mark.parametrize("omni_server", _MINIMAX_MUSIC3_SPEECH, indirect=True)
def test_minimax_music3_speech_invalid_field_values(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
overrides: dict[str, object],
err_message: str | tuple[str, ...],
) -> None:
@@ -1038,6 +1045,6 @@ def test_minimax_music3_speech_invalid_field_values(
body.pop(key, None)
else:
body[key] = value
openai_client.send_audio_speech_http_request(
online_client.send_audio_speech_http_request(
{"json": body, "timeout": 120, "err_code": 400, "err_message": err_message}
)
@@ -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
"""``POST /v1/images/edits`` multipart validation (live ``Qwen/Qwen-Image-Edit``)."""
from __future__ import annotations
@@ -9,7 +9,7 @@ from typing import Any
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
from tests.helpers.runtime import OmniServer, OmniServerParams, OnlineOmniClient
pytestmark = [pytest.mark.slow, pytest.mark.diffusion]
@@ -72,7 +72,7 @@ def _finalize_edits_form_data(template: dict[str, Any], server_model: str) -> di
@pytest.mark.parametrize("omni_server", _PARAMS, indirect=True)
def test_images_edits_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
tiny_png_bytes: bytes,
include_image: bool,
data_template: dict[str, Any],
@@ -87,4 +87,4 @@ def test_images_edits_invalid_requests(
}
if include_image:
cfg["files"] = {"image": ("x.png", tiny_png_bytes, "image/png")}
openai_client.send_images_edits_http_request(cfg)
online_client.send_images_edits_http_request(cfg)
@@ -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
"""``POST /v1/images/generations`` validation (live ``Qwen/Qwen-Image``)."""
from __future__ import annotations
@@ -9,7 +9,7 @@ from typing import Any
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
from tests.helpers.runtime import OmniServer, OmniServerParams, OnlineOmniClient
pytestmark = [pytest.mark.slow, pytest.mark.diffusion]
@@ -126,7 +126,7 @@ def _minimal_images_gen_json(omni_server: OmniServer) -> dict[str, object]:
@pytest.mark.parametrize("omni_server", _PARAMS, indirect=True)
def test_images_generations_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
body_spec: Any,
err_message: str | tuple[str, ...],
) -> None:
@@ -137,6 +137,6 @@ def test_images_generations_invalid_requests(
assert isinstance(body_spec, dict)
body = _minimal_images_gen_json(omni_server)
body.update(body_spec)
openai_client.send_images_generations_http_request(
online_client.send_images_generations_http_request(
{"json": body, "timeout": 300, "err_code": 400, "err_message": err_message}
)
@@ -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
"""Invalid inputs on Qwen3-Omni: ``POST /v1/chat/completions``, ``WS /v1/video/chat/stream``, ``WS /v1/realtime``."""
from __future__ import annotations
@@ -10,7 +10,7 @@ from typing import Any
import pytest
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
from tests.helpers.runtime import OmniServer, OmniServerParams, OnlineOmniClient
from tests.helpers.stage_config import get_deploy_config_path
pytestmark = [pytest.mark.slow, pytest.mark.omni]
@@ -72,6 +72,9 @@ def _chat_completions_request_without_expectations(omni_server: OmniServer, case
body["top_logprobs"] = 5
elif case_id == "speaker_unknown":
body["speaker"] = "zz_invalid_qwen3_omni_chat_speaker_xyz"
elif case_id == "audio_format_unsupported":
body["modalities"] = ["text", "audio"]
body["audio"] = {"voice": "alloy", "format": "aac"}
else:
raise AssertionError(f"unknown chat completions invalid case_id {case_id!r}")
return {"json": body, "timeout": 120}
@@ -119,7 +122,7 @@ def _chat_completions_request_without_expectations(omni_server: OmniServer, case
pytest.param(
"response_format_json_schema_incomplete",
400,
("response_format", "value_error", "json_schema"),
("response_format", "BadRequestError", "json_schema"),
id="invalid_response_format_json_schema",
),
pytest.param("logprobs_wrong_type", 400, "logprobs", id="logprobs_wrong_type", marks=_SKIP_ISSUE_3649),
@@ -135,12 +138,18 @@ def _chat_completions_request_without_expectations(omni_server: OmniServer, case
("Invalid speaker", "Supported"),
id="speaker_unknown_preset",
),
pytest.param(
"audio_format_unsupported",
400,
("Invalid audio format", "aac", "Supported formats"),
id="unsupported_audio_format",
),
],
)
@pytest.mark.parametrize("omni_server", _QWEN3_OMNI_SERVER, indirect=True)
def test_chat_completions_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
case_id: str,
err_code: int | tuple[int, ...],
err_message: str | tuple[str, ...],
@@ -148,7 +157,7 @@ def test_chat_completions_invalid_requests(
cfg = _chat_completions_request_without_expectations(omni_server, case_id)
cfg["err_code"] = err_code
cfg["err_message"] = err_message
openai_client.send_chat_completions_http_request(cfg)[0]
online_client.send_chat_completions_http_request(cfg)[0]
# ─────────────────────────────────────────────────────────────────────────────
@@ -174,7 +183,7 @@ _VIDEO_CHAT_WS_SESSION_MODALITIES_SCALAR = object()
@pytest.mark.parametrize("omni_server", _QWEN3_OMNI_SERVER, indirect=True)
def test_video_chat_stream_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
send_frames_spec: Any,
err_message: str,
) -> None:
@@ -183,7 +192,7 @@ def test_video_chat_stream_invalid_requests(
else:
send_frames = send_frames_spec
assert isinstance(send_frames, str)
openai_client.send_video_chat_stream_ws_request(
online_client.send_video_chat_stream_ws_request(
{
"send_frames": send_frames,
"timeout": 120,
@@ -243,11 +252,12 @@ _REALTIME_WS_INVALID_AUDIO_APPEND = object()
@pytest.mark.parametrize("omni_server", _QWEN3_OMNI_SERVER, indirect=True)
def test_realtime_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
send_frames_spec: Any,
ws_error_code: str,
err_message: str | tuple[str, ...],
) -> None:
send_frames: Any
if send_frames_spec is _REALTIME_WS_MODEL_MISMATCH:
send_frames = json.dumps(
{
@@ -262,7 +272,7 @@ def test_realtime_invalid_requests(
]
else:
send_frames = send_frames_spec
openai_client.send_realtime_ws_request(
online_client.send_realtime_ws_request(
{
"send_frames": send_frames,
"timeout": 120,
@@ -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
"""Omni sleep/wakeup JSON validation and level-2 wake contract (live ``Qwen/Qwen-Image``)."""
from __future__ import annotations
@@ -9,7 +9,7 @@ from typing import Any, Literal
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
from tests.helpers.runtime import OmniServer, OmniServerParams, OnlineOmniClient
pytestmark = [pytest.mark.slow, pytest.mark.diffusion, pytest.mark.omni]
@@ -86,7 +86,7 @@ _QWEN_IMAGE = [
@pytest.mark.parametrize("omni_server", _QWEN_IMAGE, indirect=True)
def test_omni_sleep_wakeup_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
endpoint: Literal["sleep", "wakeup"],
json_body: dict[str, Any],
err_message: str | tuple[str, ...],
@@ -98,9 +98,9 @@ def test_omni_sleep_wakeup_invalid_requests(
"err_message": err_message,
}
if endpoint == "sleep":
openai_client.send_omni_sleep_http_request(cfg)
online_client.send_omni_sleep_http_request(cfg)
else:
openai_client.send_omni_wakeup_http_request(cfg)
online_client.send_omni_wakeup_http_request(cfg)
# Keep this after the invalid-JSON cases: sleep(level=2) discards weights and the
@@ -108,7 +108,7 @@ def test_omni_sleep_wakeup_invalid_requests(
@pytest.mark.parametrize("omni_server", _QWEN_IMAGE, indirect=True)
def test_wakeup_after_level2_sleep_fails(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
) -> None:
"""Regression for #4473 Repro A via HTTP: wake after sleep(level=2) → 501.
@@ -116,7 +116,7 @@ def test_wakeup_after_level2_sleep_fails(
Live serve surfaces ``NotImplementedError`` as OpenAI-style JSON with code 501.
"""
assert omni_server is not None
sleep_resps = openai_client.send_omni_sleep_http_request(
sleep_resps = online_client.send_omni_sleep_http_request(
{
"json": {"stage_ids": [0], "level": 2},
"timeout": 180,
@@ -124,10 +124,11 @@ def test_wakeup_after_level2_sleep_fails(
)
sleep_resp = sleep_resps[0]
assert sleep_resp.status_code == 200, sleep_resp
assert sleep_resp.json_body is not None
assert sleep_resp.json_body.get("status") == "SUCCESS", sleep_resp.json_body
json_body = sleep_resp.json_body
assert isinstance(json_body, dict)
assert json_body.get("status") == "SUCCESS", json_body
openai_client.send_omni_wakeup_http_request(
online_client.send_omni_wakeup_http_request(
{
"json": {"stage_ids": [0]},
"timeout": 120,
@@ -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
"""HTTP video job routes (live ``Wan-AI/Wan2.2-T2V-A14B-Diffusers``).
WebSocket ``/v1/video/chat/stream`` and ``/v1/realtime`` invalid-input checks live in
@@ -13,7 +13,7 @@ from typing import Any
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
from tests.helpers.runtime import OmniServer, OmniServerParams, OnlineOmniClient
pytestmark = [pytest.mark.slow, pytest.mark.diffusion]
@@ -54,12 +54,12 @@ def _minimal_video_form_data() -> dict[str, str]:
@pytest.mark.parametrize("omni_server", _WAN_T2V, indirect=True)
def test_post_videos_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
form_extra: dict[str, str] | None,
err_message: str | tuple[str, ...],
) -> None:
data: dict[str, str] = {} if form_extra is None else {**_minimal_video_form_data(), **form_extra}
openai_client.send_videos_create_http_request(
online_client.send_videos_create_http_request(
{"data": data, "timeout": 120, "err_code": 400, "err_message": err_message}
)
@@ -85,12 +85,12 @@ def test_post_videos_invalid_requests(
@pytest.mark.parametrize("omni_server", _WAN_T2V, indirect=True)
def test_post_videos_sync_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
form_extra: dict[str, str] | None,
err_message: str | tuple[str, ...],
) -> None:
data: dict[str, str] = {} if form_extra is None else {**_minimal_video_form_data(), **form_extra}
openai_client.send_videos_sync_http_request(
online_client.send_videos_sync_http_request(
{"data": data, "timeout": 120, "err_code": 400, "err_message": err_message}
)
@@ -111,11 +111,11 @@ def test_post_videos_sync_invalid_requests(
@pytest.mark.parametrize("omni_server", _WAN_T2V, indirect=True)
def test_get_videos_list_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
params: dict[str, Any],
err_message: str | tuple[str, ...],
) -> None:
openai_client.send_videos_list_http_request(
online_client.send_videos_list_http_request(
{"params": params, "timeout": 120, "err_code": 400, "err_message": err_message}
)
@@ -126,8 +126,8 @@ def test_get_videos_list_invalid_requests(
@pytest.mark.parametrize("omni_server", _WAN_T2V, indirect=True)
def test_get_video_invalid_requests(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
openai_client.send_video_retrieve_http_request(
def test_get_video_invalid_requests(omni_server: OmniServer, online_client: OnlineOmniClient) -> None:
online_client.send_video_retrieve_http_request(
{
"video_id": "does-not-exist",
"timeout": 120,
@@ -143,8 +143,8 @@ def test_get_video_invalid_requests(omni_server: OmniServer, openai_client: Open
@pytest.mark.parametrize("omni_server", _WAN_T2V, indirect=True)
def test_delete_video_invalid_requests(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
openai_client.send_video_delete_http_request(
def test_delete_video_invalid_requests(omni_server: OmniServer, online_client: OnlineOmniClient) -> None:
online_client.send_video_delete_http_request(
{
"video_id": "does-not-exist",
"timeout": 120,
@@ -160,8 +160,8 @@ def test_delete_video_invalid_requests(omni_server: OmniServer, openai_client: O
@pytest.mark.parametrize("omni_server", _WAN_T2V, indirect=True)
def test_get_video_content_invalid_requests(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
openai_client.send_video_content_http_request(
def test_get_video_content_invalid_requests(omni_server: OmniServer, online_client: OnlineOmniClient) -> None:
online_client.send_video_content_http_request(
{
"video_id": "does-not-exist",
"timeout": 120,
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
HunyuanImage-3.0-Instruct reliability integration tests (DiT-only deploy).
@@ -35,7 +38,7 @@ from tests.dfx.reliability.helpers import (
worker_residual_timeout_after_kill_signal,
)
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OpenAIClientHandler
from tests.helpers.runtime import OnlineOmniClient
RELIABILITY_SCENARIOS: list[dict[str, Any]] = [
{
@@ -133,8 +136,8 @@ def _image_request_config(omni_server: Any) -> dict[str, Any]:
}
def _submit_image_generation(openai_client: OpenAIClientHandler, omni_server: Any) -> None:
responses = openai_client.send_images_generations_http_request(_image_request_config(omni_server))
def _submit_image_generation(online_client: OnlineOmniClient, omni_server: Any) -> None:
responses = online_client.send_images_generations_http_request(_image_request_config(omni_server))
resp = responses[0]
if resp.status_code >= 400:
raise RuntimeError(
@@ -189,7 +192,7 @@ def _assert_post_fault_health_terminal(host: str, port: int, *, scenario: str) -
@pytest.mark.parametrize("omni_server_function", HUNYUAN_PARAMS, indirect=True)
def test_reliability_fault_gpu_oom_image_large_request_failure(
omni_server_function,
openai_client_function,
online_client_function,
) -> None:
stage_config_path = getattr(omni_server_function, "stage_config_path", None)
device_spec = resolve_oom_device_spec(OOM_INJECTION_CONFIG, stage_config_path)
@@ -202,7 +205,7 @@ def test_reliability_fault_gpu_oom_image_large_request_failure(
)
try:
try:
_submit_image_generation(openai_client_function, omni_server_function)
_submit_image_generation(online_client_function, omni_server_function)
except Exception as exc:
assert_fault_exception(exc, FAULT_ERROR_KEYWORDS)
else:
@@ -222,10 +225,10 @@ def test_reliability_fault_gpu_oom_image_large_request_failure(
@pytest.mark.parametrize("omni_server_function", HUNYUAN_PARAMS, indirect=True)
def test_reliability_fault_process_kill_request_failure(
omni_server_after_fault_function,
openai_client_function,
online_client_function,
) -> None:
try:
_submit_image_generation(openai_client_function, omni_server_after_fault_function)
_submit_image_generation(online_client_function, omni_server_after_fault_function)
except Exception as exc:
assert_fault_exception(exc, PROCESS_KILL_ERROR_KEYWORDS)
else:
@@ -244,7 +247,7 @@ def test_reliability_fault_process_kill_request_failure(
@pytest.mark.parametrize("omni_server_function", HUNYUAN_PARAMS, indirect=True)
def test_reliability_fault_process_kill_health_fast_fail_and_concurrent(
omni_server_after_fault_function,
openai_client_function,
online_client_function,
) -> None:
host = omni_server_after_fault_function.host
port = omni_server_after_fault_function.port
@@ -280,7 +283,7 @@ def test_reliability_fault_process_kill_health_fast_fail_and_concurrent(
start = time.monotonic()
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
futures = [
executor.submit(_submit_image_generation, openai_client_function, omni_server_after_fault_function)
executor.submit(_submit_image_generation, online_client_function, omni_server_after_fault_function)
for _ in range(3)
]
done, pending = concurrent.futures.wait(
@@ -335,12 +338,12 @@ def test_reliability_fault_process_kill_health_fast_fail_and_concurrent(
@pytest.mark.parametrize("omni_server_function", HUNYUAN_PARAMS, indirect=True)
def test_reliability_fault_process_kill_worker_with_load_request_failure(
omni_server_function,
openai_client_function,
online_client_function,
fault_injector: FaultInjector,
) -> None:
scenario = "kill_worker_with_load"
load_result = run_fault_injection_with_rate_load(
submit_request=lambda: _submit_image_generation(openai_client_function, omni_server_function),
submit_request=lambda: _submit_image_generation(online_client_function, omni_server_function),
inject_fault=lambda: fault_injector(omni_server_function),
num_requests=INFLIGHT_INJECTION_REQUEST_COUNT,
request_rate=INFLIGHT_INJECTION_REQUEST_RATE,
@@ -363,13 +366,13 @@ def test_reliability_fault_process_kill_worker_with_load_request_failure(
@pytest.mark.parametrize("omni_server_function", HUNYUAN_PARAMS, indirect=True)
def test_reliability_fault_process_kill_serve_root_with_load_fast_fail_and_cleanup(
omni_server_function,
openai_client_function,
online_client_function,
signal_name: str,
) -> None:
scenario = f"kill_serve_root_with_load_{signal_name.lower()}"
injector = make_server_root_kill_fault_injector(signal_name=signal_name, post_kill_wait_seconds=2.0)
load_result = run_fault_injection_with_rate_load(
submit_request=lambda: _submit_image_generation(openai_client_function, omni_server_function),
submit_request=lambda: _submit_image_generation(online_client_function, omni_server_function),
inject_fault=lambda: injector(omni_server_function),
num_requests=INFLIGHT_INJECTION_REQUEST_COUNT,
request_rate=INFLIGHT_INJECTION_REQUEST_RATE,
@@ -448,7 +451,7 @@ def test_reliability_fault_process_kill_tree_no_load_fast_fail_and_cleanup(
@pytest.mark.parametrize("omni_server_function", HUNYUAN_PARAMS, indirect=True)
def test_reliability_fault_process_kill_tree_with_load_fast_fail_and_cleanup(
omni_server_function,
openai_client_function,
online_client_function,
signal_name: str,
) -> None:
scenario = f"kill_serve_tree_with_load_{signal_name.lower()}"
@@ -458,7 +461,7 @@ def test_reliability_fault_process_kill_tree_with_load_fast_fail_and_cleanup(
inter_kill_wait_seconds=0.1,
)
load_result = run_fault_injection_with_rate_load(
submit_request=lambda: _submit_image_generation(openai_client_function, omni_server_function),
submit_request=lambda: _submit_image_generation(online_client_function, omni_server_function),
inject_fault=lambda: injector(omni_server_function),
num_requests=INFLIGHT_INJECTION_REQUEST_COUNT,
request_rate=INFLIGHT_INJECTION_REQUEST_RATE,
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
Qwen3-Omni reliability integration tests.
"""
@@ -63,7 +66,7 @@ DEPLOY_CONFIGS_DIR = Path(__file__).resolve().parent.parent.parent.parent / "vll
def _default_oom_device_spec() -> str:
"""Use currently visible CUDA ordinals to avoid invalid device index in sidecar."""
count = torch.accelerator.device_count()
count = torch.accelerator.device_count() or 0
if count <= 0:
return "0"
return ",".join(str(i) for i in range(count))
@@ -414,7 +417,7 @@ def test_reliability_fault_gpu_oom_error_contract_consistent_chat_speech(
@pytest.mark.slow
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
def test_reliability_fault_gpu_oom_chat_large_payload_failure(omni_server_function, openai_client_function) -> None:
def test_reliability_fault_gpu_oom_chat_large_payload_failure(omni_server_function, online_client_function) -> None:
device_spec = resolve_oom_device_spec(
OOM_INJECTION_CONFIG,
_deploy_config_path_from_omni_server(omni_server_function),
@@ -444,7 +447,7 @@ def test_reliability_fault_gpu_oom_chat_large_payload_failure(omni_server_functi
"key_words": {"audio": ["test"]},
}
try:
openai_client_function.send_omni_request(request_config, request_num=1)
online_client_function.send_omni_request(request_config, request_num=1)
except Exception as exc:
assert_fault_exception(exc, FAULT_ERROR_KEYWORDS)
else:
@@ -456,7 +459,7 @@ def test_reliability_fault_gpu_oom_chat_large_payload_failure(omni_server_functi
@pytest.mark.slow
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
def test_reliability_fault_gpu_oom_concurrent_pressure_failure(omni_server_function, openai_client_function) -> None:
def test_reliability_fault_gpu_oom_concurrent_pressure_failure(omni_server_function, online_client_function) -> None:
device_spec = resolve_oom_device_spec(
OOM_INJECTION_CONFIG,
_deploy_config_path_from_omni_server(omni_server_function),
@@ -487,7 +490,7 @@ def test_reliability_fault_gpu_oom_concurrent_pressure_failure(omni_server_funct
"key_words": {"audio": ["test"]},
}
try:
openai_client_function.send_omni_request(request_config, request_num=4)
online_client_function.send_omni_request(request_config, request_num=4)
except Exception as exc:
assert_fault_exception(exc, FAULT_ERROR_KEYWORDS)
else:
@@ -505,7 +508,7 @@ def test_reliability_fault_gpu_oom_concurrent_pressure_failure(omni_server_funct
)
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
def test_reliability_fault_process_kill_request_failure(
omni_server_after_fault_function, openai_client_function
omni_server_after_fault_function, online_client_function
) -> None:
messages = dummy_messages_from_mix_data(
system_prompt=_get_system_prompt(),
@@ -519,7 +522,7 @@ def test_reliability_fault_process_kill_request_failure(
"key_words": {"text": ["beijing"]},
}
try:
openai_client_function.send_omni_request(request_config, request_num=1)
online_client_function.send_omni_request(request_config, request_num=1)
except Exception as exc:
assert_fault_exception(exc, PROCESS_KILL_ERROR_KEYWORDS)
else:
@@ -567,7 +570,7 @@ def test_reliability_fault_process_kill_health_fast_fail_and_concurrent(
assert elapsed <= _PROCESS_KILL_WORKER_CHAT_FF_MAX_ELAPSED_SEC + 1.0, (
f"[process_kill fast_fail] request did not fail fast after fault: {elapsed:.2f}s"
)
assert ff_status >= 500, (
assert ff_status is not None and ff_status >= 500, (
f"[process_kill fast_fail] expected server-side failure after fault, "
f"got status={ff_status}, body={ff_body[:200]!r}"
)
@@ -652,13 +655,13 @@ def test_reliability_fault_process_kill_health_fast_fail_and_concurrent(
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
def test_reliability_fault_process_kill_worker_with_load_request_failure(
omni_server_function,
openai_client_function,
online_client_function,
fault_injector: FaultInjector,
) -> None:
request_config = _chat_request_config(omni_server_function)
scenario = "kill_worker_with_load"
load_result = run_fault_injection_with_rate_load(
submit_request=lambda: openai_client_function.send_omni_request(request_config, request_num=1),
submit_request=lambda: online_client_function.send_omni_request(request_config, request_num=1),
inject_fault=lambda: fault_injector(omni_server_function),
num_requests=INFLIGHT_INJECTION_REQUEST_COUNT,
request_rate=INFLIGHT_INJECTION_REQUEST_RATE,
@@ -704,14 +707,14 @@ def test_reliability_fault_process_kill_serve_root_no_load_fast_fail_and_cleanup
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
def test_reliability_fault_process_kill_serve_root_with_load_fast_fail_and_cleanup(
omni_server_function,
openai_client_function,
online_client_function,
signal_name: str,
) -> None:
request_config = _chat_request_config(omni_server_function)
scenario = f"kill_serve_root_with_load_{signal_name.lower()}"
injector = make_server_root_kill_fault_injector(signal_name=signal_name, post_kill_wait_seconds=2.0)
load_result = run_fault_injection_with_rate_load(
submit_request=lambda: openai_client_function.send_omni_request(request_config, request_num=1),
submit_request=lambda: online_client_function.send_omni_request(request_config, request_num=1),
inject_fault=lambda: injector(omni_server_function),
num_requests=INFLIGHT_INJECTION_REQUEST_COUNT,
request_rate=INFLIGHT_INJECTION_REQUEST_RATE,
@@ -764,7 +767,7 @@ def test_reliability_fault_process_kill_tree_no_load_fast_fail_and_cleanup(
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
def test_reliability_fault_process_kill_tree_with_load_fast_fail_and_cleanup(
omni_server_function,
openai_client_function,
online_client_function,
signal_name: str,
) -> None:
request_config = _chat_request_config(omni_server_function)
@@ -775,7 +778,7 @@ def test_reliability_fault_process_kill_tree_with_load_fast_fail_and_cleanup(
inter_kill_wait_seconds=0.1,
)
load_result = run_fault_injection_with_rate_load(
submit_request=lambda: openai_client_function.send_omni_request(request_config, request_num=1),
submit_request=lambda: online_client_function.send_omni_request(request_config, request_num=1),
inject_fault=lambda: injector(omni_server_function),
num_requests=INFLIGHT_INJECTION_REQUEST_COUNT,
request_rate=INFLIGHT_INJECTION_REQUEST_RATE,
+15 -12
View File
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
Wan2.2 reliability integration tests.
"""
@@ -183,7 +186,7 @@ def _assert_post_fault_health_terminal(host: str, port: int, *, scenario: str) -
@pytest.mark.slow
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.parametrize("omni_server_function", DIFFUSION_VIDEO_PARAMS, indirect=True)
def test_reliability_fault_gpu_oom_video_large_request_failure(omni_server_function, openai_client_function) -> None:
def test_reliability_fault_gpu_oom_video_large_request_failure(omni_server_function, online_client_function) -> None:
stage_config_path = getattr(omni_server_function, "stage_config_path", None)
device_spec = resolve_oom_device_spec(OOM_INJECTION_CONFIG, stage_config_path)
handle = inject_gpu_oom(
@@ -211,7 +214,7 @@ def test_reliability_fault_gpu_oom_video_large_request_failure(omni_server_funct
"stream": False,
}
try:
openai_client_function.send_video_diffusion_request(request_config, request_num=1)
online_client_function.send_video_diffusion_request(request_config, request_num=1)
except Exception as exc:
assert_fault_exception(exc, FAULT_ERROR_KEYWORDS)
else:
@@ -230,11 +233,11 @@ def test_reliability_fault_gpu_oom_video_large_request_failure(omni_server_funct
@pytest.mark.parametrize("omni_server_function", DIFFUSION_VIDEO_PARAMS, indirect=True)
def test_reliability_fault_process_kill_request_failure(
omni_server_after_fault_function,
openai_client_function,
online_client_function,
) -> None:
request_config = _video_request_config()
try:
openai_client_function.send_video_diffusion_request(request_config, request_num=1)
online_client_function.send_video_diffusion_request(request_config, request_num=1)
except Exception as exc:
assert_fault_exception(exc, PROCESS_KILL_ERROR_KEYWORDS)
else:
@@ -251,7 +254,7 @@ def test_reliability_fault_process_kill_request_failure(
@pytest.mark.parametrize("omni_server_function", DIFFUSION_VIDEO_PARAMS, indirect=True)
def test_reliability_fault_process_kill_health_fast_fail_and_concurrent(
omni_server_after_fault_function,
openai_client_function,
online_client_function,
) -> None:
"""Black-box: after process kill, /v1/videos fails fast and concurrent calls don't hang.
@@ -308,7 +311,7 @@ def test_reliability_fault_process_kill_health_fast_fail_and_concurrent(
start = time.monotonic()
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
futures = [
executor.submit(openai_client_function.send_video_diffusion_request, request_config, 1) for _ in range(3)
executor.submit(online_client_function.send_video_diffusion_request, request_config, 1) for _ in range(3)
]
done, pending = concurrent.futures.wait(
futures,
@@ -367,13 +370,13 @@ def test_reliability_fault_process_kill_health_fast_fail_and_concurrent(
@pytest.mark.parametrize("omni_server_function", DIFFUSION_VIDEO_PARAMS, indirect=True)
def test_reliability_fault_process_kill_worker_with_load_request_failure(
omni_server_function,
openai_client_function,
online_client_function,
fault_injector: FaultInjector,
) -> None:
request_config = _video_request_config()
scenario = "kill_worker_with_load"
load_result = run_fault_injection_with_rate_load(
submit_request=lambda: openai_client_function.send_video_diffusion_request(request_config, request_num=1),
submit_request=lambda: online_client_function.send_video_diffusion_request(request_config, request_num=1),
inject_fault=lambda: fault_injector(omni_server_function),
num_requests=INFLIGHT_INJECTION_REQUEST_COUNT,
request_rate=INFLIGHT_INJECTION_REQUEST_RATE,
@@ -516,7 +519,7 @@ def test_reliability_video_oom_recovers_after_fault_removed(
@pytest.mark.parametrize("omni_server_function", DIFFUSION_VIDEO_PARAMS, indirect=True)
def test_reliability_fault_process_kill_serve_root_with_load_fast_fail_and_cleanup(
omni_server_function,
openai_client_function,
online_client_function,
signal_name: str,
) -> None:
"""Black-box: during in-flight video requests, kill serve root and verify post-fault behavior/cleanup."""
@@ -524,7 +527,7 @@ def test_reliability_fault_process_kill_serve_root_with_load_fast_fail_and_clean
scenario = f"kill_serve_root_with_load_{signal_name.lower()}"
injector = make_server_root_kill_fault_injector(signal_name=signal_name, post_kill_wait_seconds=2.0)
load_result = run_fault_injection_with_rate_load(
submit_request=lambda: openai_client_function.send_video_diffusion_request(request_config, request_num=1),
submit_request=lambda: online_client_function.send_video_diffusion_request(request_config, request_num=1),
inject_fault=lambda: injector(omni_server_function),
num_requests=INFLIGHT_INJECTION_REQUEST_COUNT,
request_rate=INFLIGHT_INJECTION_REQUEST_RATE,
@@ -601,7 +604,7 @@ def test_reliability_fault_process_kill_tree_no_load_fast_fail_and_cleanup(
@pytest.mark.parametrize("omni_server_function", DIFFUSION_VIDEO_PARAMS, indirect=True)
def test_reliability_fault_process_kill_tree_with_load_fast_fail_and_cleanup(
omni_server_function,
openai_client_function,
online_client_function,
signal_name: str,
) -> None:
"""Black-box: during in-flight video requests, kill server process tree and verify post-fault behavior/cleanup."""
@@ -613,7 +616,7 @@ def test_reliability_fault_process_kill_tree_with_load_fast_fail_and_cleanup(
inter_kill_wait_seconds=0.1,
)
load_result = run_fault_injection_with_rate_load(
submit_request=lambda: openai_client_function.send_video_diffusion_request(request_config, request_num=1),
submit_request=lambda: online_client_function.send_video_diffusion_request(request_config, request_num=1),
inject_fault=lambda: injector(omni_server_function),
num_requests=INFLIGHT_INJECTION_REQUEST_COUNT,
request_rate=INFLIGHT_INJECTION_REQUEST_RATE,
+1 -1
View File
@@ -37,7 +37,7 @@ def omni_server(request: pytest.FixtureRequest):
with _omni_server_lock:
# Same tuple and CLI composition as ``tests/dfx/perf/scripts/run_benchmark.py``;
# ``serve_args`` from JSON are folded into ``extra_cli_args`` inside
# ``create_unique_server_params``.
# ``create_unique_server_pytest_params`` (JSON ``mark`` → pytest marks).
test_name, model, deploy_path, stage_overrides, extra_cli_args, use_omni = request.param
print(f"Starting OmniServer with test: {test_name}, model: {model}")
@@ -13,7 +13,7 @@ import pytest
from tests.dfx.conftest import (
create_benchmark_indices,
create_test_parameter_mapping,
create_unique_server_params,
create_unique_server_pytest_params,
load_configs,
)
from tests.dfx.stability.helpers import _run_one_diffusion_batch, run_stability_benchmark_loop
@@ -29,7 +29,7 @@ try:
except FileNotFoundError:
BENCHMARK_CONFIGS = []
test_params = create_unique_server_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR) if BENCHMARK_CONFIGS else []
test_params = create_unique_server_pytest_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR) if BENCHMARK_CONFIGS else []
server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) if BENCHMARK_CONFIGS else {}
benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping)
@@ -13,7 +13,7 @@ import pytest
from tests.dfx.conftest import (
create_benchmark_indices,
create_test_parameter_mapping,
create_unique_server_params,
create_unique_server_pytest_params,
load_configs,
)
from tests.dfx.stability.helpers import _run_one_vllm_bench_batch, run_stability_benchmark_loop
@@ -29,7 +29,7 @@ try:
except FileNotFoundError:
BENCHMARK_CONFIGS = []
test_params = create_unique_server_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR) if BENCHMARK_CONFIGS else []
test_params = create_unique_server_pytest_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR) if BENCHMARK_CONFIGS else []
server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) if BENCHMARK_CONFIGS else {}
benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping)
@@ -13,7 +13,7 @@ import pytest
from tests.dfx.conftest import (
create_benchmark_indices,
create_test_parameter_mapping,
create_unique_server_params,
create_unique_server_pytest_params,
load_configs,
)
from tests.dfx.stability.helpers import _run_one_vllm_bench_batch, run_stability_benchmark_loop
@@ -29,7 +29,7 @@ try:
except FileNotFoundError:
BENCHMARK_CONFIGS = []
test_params = create_unique_server_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR) if BENCHMARK_CONFIGS else []
test_params = create_unique_server_pytest_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR) if BENCHMARK_CONFIGS else []
server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) if BENCHMARK_CONFIGS else {}
benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping)
@@ -13,7 +13,7 @@ import pytest
from tests.dfx.conftest import (
create_benchmark_indices,
create_test_parameter_mapping,
create_unique_server_params,
create_unique_server_pytest_params,
load_configs,
)
from tests.dfx.stability.helpers import _run_one_diffusion_batch, run_stability_benchmark_loop
@@ -29,7 +29,7 @@ try:
except FileNotFoundError:
BENCHMARK_CONFIGS = []
test_params = create_unique_server_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR) if BENCHMARK_CONFIGS else []
test_params = create_unique_server_pytest_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR) if BENCHMARK_CONFIGS else []
server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) if BENCHMARK_CONFIGS else {}
benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping)
@@ -13,7 +13,7 @@ import pytest
from tests.dfx.conftest import (
create_benchmark_indices,
create_test_parameter_mapping,
create_unique_server_params,
create_unique_server_pytest_params,
load_configs,
)
from tests.dfx.stability.helpers import _run_one_diffusion_batch, run_stability_benchmark_loop
@@ -29,7 +29,7 @@ try:
except FileNotFoundError:
BENCHMARK_CONFIGS = []
test_params = create_unique_server_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR) if BENCHMARK_CONFIGS else []
test_params = create_unique_server_pytest_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR) if BENCHMARK_CONFIGS else []
server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) if BENCHMARK_CONFIGS else {}
benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping)
@@ -1,6 +1,18 @@
[
{
"test_name": "test_hunyuan_image_stability_tp2_fp8_sp2",
"mark": [
{
"hardware_marks": {
"res": {
"cuda": "H800"
},
"num_cards": 2
}
},
"local_model",
"diffusion"
],
"server_params": {
"model": "tencent/HunyuanImage-3.0-Instruct",
"serve_args": {
@@ -1,6 +1,18 @@
[
{
"test_name": "test_qwen3_omni_stability_async_chunk",
"mark": [
{
"hardware_marks": {
"res": {
"cuda": "H800"
},
"num_cards": 2
}
},
"local_model",
"omni"
],
"server_params": {
"model": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
"extra_cli_args": ["--async-chunk"]
@@ -1,6 +1,19 @@
[
{
"test_name": "test_qwen3_tts_stability",
"mark": [
{
"hardware_marks": {
"res": {
"cuda": "H800",
"npu": "A3"
},
"num_cards": 2
}
},
"local_model",
"tts"
],
"server_params": {
"model": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
},
@@ -1,6 +1,18 @@
[
{
"test_name": "test_qwen_image_stability",
"mark": [
{
"hardware_marks": {
"res": {
"cuda": "H800"
},
"num_cards": 1
}
},
"local_model",
"diffusion"
],
"server_params": {
"model": "Qwen/Qwen-Image"
},
@@ -1,35 +0,0 @@
[
{
"test_name": "test_voxcpm2_stability",
"server_params": {
"model": "openbmb/VoxCPM2",
"trust_remote_code": true
},
"benchmark_params": [
{
"dataset_name": "random",
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"trust_remote_code": true,
"duration_sec": 86400,
"max_concurrency": 2,
"num_prompts_per_batch": 50,
"random_input_len": {
"min": 0,
"max": 1000
},
"random_output_len": {
"min": 0,
"max": 1000
},
"random_range_ratio": 0.0,
"extra_body": {
"voice": "default",
"language": "English"
},
"ignore_eos": true,
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration"
}
]
}
]
+54 -1
View File
@@ -1,6 +1,19 @@
[
{
"test_name": "test_wan22_i2v_stability_v1_videos",
"test_name": "test_wan22_i2v_stability_ulysses2_vae_patch2_hsdp_slicing_tiling",
"description": "Ulysses=2 + VAE patch parallel=2 + HSDP + VAE slicing + tiling",
"mark": [
{
"hardware_marks": {
"res": {
"cuda": "H800"
},
"num_cards": 2
}
},
"local_model",
"diffusion"
],
"server_params": {
"model": "Wan-AI/Wan2.2-I2V-A14B-Diffusers",
"serve_args": {
@@ -27,5 +40,45 @@
]
}
]
},
{
"test_name": "test_wan22_i2v_stability_usp2_vae_patch2_hsdp_slicing",
"description": "USP=2 + VAE patch parallel=2 + HSDP + VAE slicing",
"mark": [
{
"hardware_marks": {
"res": {
"npu": "A3"
},
"num_cards": 2
}
},
"local_model",
"diffusion"
],
"server_params": {
"model": "Wan-AI/Wan2.2-I2V-A14B-Diffusers",
"serve_args": {
"usp": 2,
"vae-patch-parallel-size": 2,
"use-hsdp": true,
"vae-use-slicing": true
}
},
"benchmark_params": [
{
"dataset": "random",
"task": "i2v",
"backend": "v1/videos",
"duration_sec": 86400,
"max_concurrency": 1,
"num_prompts_per_batch": 50,
"enable_negative_prompt": true,
"random_request_config": [
{"width": 832, "height": 480, "num_inference_steps": 4, "num_frames": 81, "fps": 16, "weight": 0.5},
{"width": 1280, "height": 720, "num_inference_steps": 4, "num_frames": 121, "fps": 16, "weight": 0.5}
]
}
]
}
]
+81 -48
View File
@@ -1,14 +1,18 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import math
from unittest.mock import Mock
import pytest
import torch
import torch.nn.functional as F
from vllm_omni.diffusion.attention.backends import trtllm_attn as tg
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
from vllm_omni.diffusion.attention.backends.abstract import (
AttentionMetadata,
PackedPaddingMetadata,
)
from vllm_omni.diffusion.attention.backends.trtllm_attn import TrtllmAttentionImpl
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cuda]
@@ -35,6 +39,15 @@ def _impl(**bk):
return TrtllmAttentionImpl(8, 128, 1.0 / math.sqrt(128), causal=False, num_kv_heads=8, backend_kwargs=bk)
def _packed_padding(cu_seqlens_q, cu_seqlens_k, q_length, kv_length):
return PackedPaddingMetadata(
q_length=q_length,
kv_length=kv_length,
cu_seqlens_q=cu_seqlens_q[:2],
cu_seqlens_k=cu_seqlens_k[:2],
)
def test_skip_config_pure_resolution():
from vllm_omni.diffusion.attention.backends.trtllm_attn import SkipSoftmaxConfig
@@ -75,7 +88,7 @@ def test_quant_quantize_requires_flashinfer_routine(monkeypatch):
def test_quant_quantize_calls_routine_and_shapes_sfs():
from vllm_omni.diffusion.attention.backends.trtllm_attn import QuantConfig
captured = {}
captured: dict[str, object] = {}
def fake_quantize(q, k, v, q_block_size, k_block_size, qk_quant_dtype):
captured.update(q_block_size=q_block_size, k_block_size=k_block_size, qk_quant_dtype=qk_quant_dtype)
@@ -209,62 +222,80 @@ def test_masked_layer_raises():
_impl().forward_cuda(q, k, v, _Meta())
def test_packed_metadata_trims_padding_mask(monkeypatch):
b, s, h, d = 1, 8, 8, 128
q, k, v = (torch.zeros(b, s, h, d) for _ in range(3))
mask = torch.tensor([[True, True, True, True, True, True, False, False]])
cu_seqlens = torch.tensor([0, 6, 8], dtype=torch.int32)
def test_generic_packed_metadata_preserves_ragged_batch(monkeypatch):
b, h, d = 1, 8, 128
q = torch.zeros(b, 5, h, d)
k, v = (torch.zeros(b, 7, h, d) for _ in range(2))
metadata = AttentionMetadata(
attn_mask=mask,
extra={
"cu_seqlens_q": cu_seqlens,
"cu_seqlens_k": cu_seqlens,
"max_seqlen_q": 6,
"max_seqlen_k": 6,
"cu_seqlens_q": torch.tensor([0, 2, 5], dtype=torch.int32),
"cu_seqlens_k": torch.tensor([0, 3, 7], dtype=torch.int32),
"max_seqlen_q": 3,
"max_seqlen_k": 4,
}
)
fake_attention = Mock(return_value=torch.zeros_like(q.reshape(5, h, d)))
monkeypatch.setattr(tg, "HAS_FLASHINFER", True)
monkeypatch.setattr(tg, "trtllm_ragged_attention_deepseek", fake_attention)
monkeypatch.setattr(TrtllmAttentionImpl, "_get_workspace", classmethod(lambda cls, device: torch.empty(0)))
_impl().forward_cuda(q, k, v, metadata)
captured = fake_attention.call_args.kwargs
assert captured["query"].shape[0] == 5
assert captured["key"].shape[0] == 7
assert captured["value"].shape[0] == 7
assert captured["batch_size"] == 2
assert captured["seq_lens"].tolist() == [3, 4]
assert tg.TrtllmAttentionBackend.supports_multi_doc_packed_varlen()
def test_packed_padding_metadata_trims_without_mask(monkeypatch):
q_len, kv_len = 8, 9
valid_q_tokens, valid_kv_tokens = 5, 6
b, h, d = 1, 8, 128
q = torch.zeros(b, q_len, h, d)
k, v = (torch.zeros(b, kv_len, h, d) for _ in range(2))
cu_seqlens_q = torch.tensor([0, valid_q_tokens, q_len], dtype=torch.int32)
cu_seqlens_k = torch.tensor([0, valid_kv_tokens, kv_len], dtype=torch.int32)
metadata = AttentionMetadata(
packed_padding=_packed_padding(
cu_seqlens_q,
cu_seqlens_k,
valid_q_tokens,
valid_kv_tokens,
),
extra={
"cu_seqlens_q": cu_seqlens_q,
"cu_seqlens_k": cu_seqlens_k,
"max_seqlen_q": valid_q_tokens,
"max_seqlen_k": valid_kv_tokens,
"valid_kv_length": valid_kv_tokens,
},
)
captured = {}
def fake_attention(**kwargs):
captured.update(kwargs)
return torch.zeros_like(kwargs["query"])
fake_attention = Mock(return_value=torch.ones(valid_q_tokens, h, d))
monkeypatch.setattr(tg, "HAS_FLASHINFER", True)
monkeypatch.setattr(tg, "trtllm_ragged_attention_deepseek", fake_attention)
monkeypatch.setattr(TrtllmAttentionImpl, "_get_workspace", classmethod(lambda cls, device: torch.empty(0)))
out = _impl().forward_cuda(q, k, v, metadata)
captured = fake_attention.call_args.kwargs
assert tg.TrtllmAttentionBackend.supports_packed_mask_free()
assert out.shape == q.shape
assert captured["query"].shape[0] == 6
assert captured["key"].shape[0] == 6
assert captured["value"].shape[0] == 6
assert captured["query"].shape[0] == valid_q_tokens
assert captured["key"].shape[0] == valid_kv_tokens
assert captured["value"].shape[0] == valid_kv_tokens
assert captured["batch_size"] == 1
assert captured["seq_lens"].tolist() == [6]
assert captured["cum_seq_lens_q"].tolist() == [0, 6]
assert captured["cum_seq_lens_kv"].tolist() == [0, 6]
assert captured["max_q_len"] == 6
assert captured["max_kv_len"] == 6
assert torch.count_nonzero(out[:, 6:]) == 0
def test_packed_metadata_rejects_non_prefix_mask(monkeypatch):
b, s, h, d = 1, 8, 8, 128
q, k, v = (torch.zeros(b, s, h, d) for _ in range(3))
cu_seqlens = torch.tensor([0, 6, 8], dtype=torch.int32)
metadata = AttentionMetadata(
attn_mask=torch.tensor([[True, True, False, True, True, True, False, False]]),
extra={
"cu_seqlens_q": cu_seqlens,
"cu_seqlens_k": cu_seqlens,
"max_seqlen_q": 6,
"max_seqlen_k": 6,
},
)
monkeypatch.setattr(tg, "HAS_FLASHINFER", True)
with pytest.raises(ValueError, match="prefix-valid"):
_impl().forward_cuda(q, k, v, metadata)
assert captured["seq_lens"].tolist() == [valid_kv_tokens]
assert captured["cum_seq_lens_q"].tolist() == [0, valid_q_tokens]
assert captured["cum_seq_lens_kv"].tolist() == [0, valid_kv_tokens]
assert captured["max_q_len"] == valid_q_tokens
assert captured["max_kv_len"] == valid_kv_tokens
assert torch.count_nonzero(out[:, :valid_q_tokens] != 1) == 0
assert torch.count_nonzero(out[:, valid_q_tokens:]) == 0
def test_packed_metadata_must_be_complete():
@@ -393,12 +424,13 @@ def test_bf16_packed_padding_matches_sdpa():
q, k, v = (torch.randn(b, s, h, d, device="cuda", dtype=torch.bfloat16) for _ in range(3))
cu_seqlens = torch.tensor([0, used, s], dtype=torch.int32, device="cuda")
metadata = AttentionMetadata(
attn_mask=torch.arange(s, device="cuda")[None] < used,
packed_padding=_packed_padding(cu_seqlens, cu_seqlens, used, used),
extra={
"cu_seqlens_q": cu_seqlens,
"cu_seqlens_k": cu_seqlens,
"max_seqlen_q": used,
"max_seqlen_k": used,
"valid_kv_length": used,
},
)
@@ -443,12 +475,13 @@ def test_sage_packed_non_aligned_length_matches_sdpa():
q, k, v = (torch.randn(b, s, h, d, device="cuda", dtype=torch.bfloat16) for _ in range(3))
cu_seqlens = torch.tensor([0, used, s], dtype=torch.int32, device="cuda")
metadata = AttentionMetadata(
attn_mask=torch.arange(s, device="cuda")[None] < used,
packed_padding=_packed_padding(cu_seqlens, cu_seqlens, used, used),
extra={
"cu_seqlens_q": cu_seqlens,
"cu_seqlens_k": cu_seqlens,
"max_seqlen_q": used,
"max_seqlen_k": used,
"valid_kv_length": used,
},
)
@@ -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, diffusion_batch_size=batch_size)
omni = AsyncOmni(model=model, max_num_seqs=batch_size, diffusion_batch_size=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, diffusion_batch_size=batch_size)
omni = AsyncOmni(model=model, max_num_seqs=batch_size, diffusion_batch_size=batch_size)
try:
await warmup(omni, WARMUP_PROMPTS)
@@ -524,7 +524,12 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser(description="E2E diffusion concurrent benchmark / validation")
parser.add_argument("--model", type=str, required=True, help="Model name or path")
parser.add_argument("--num-prompts", type=int, default=8, help="Number of prompts to run")
parser.add_argument("--batch-size", type=int, default=1, help="Diffusion batch size (1 = no batching)")
parser.add_argument(
"--batch-size",
type=int,
default=1,
help="Scheduler in-flight width (--max-num-seqs) and client diffusion_batch_size",
)
parser.add_argument(
"--mode",
choices=["batch", "single", "compare", "validate"],
+17 -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
"""
Model specific tests for CacheDiT enablement.
@@ -13,6 +13,7 @@ from unittest.mock import Mock, patch
import pytest
import torch
from cache_dit.caching.cache_blocks.pattern_0_1_2 import CachedBlocks_Pattern_0_1_2
from vllm.distributed import parallel_state
import vllm_omni.diffusion.cache.cachedit as cd_backend
import vllm_omni.diffusion.cache.cachedit.model_specific as cd_model_specific
@@ -61,6 +62,18 @@ def test_custom_cache_dit_enablers_are_registered_explicitly():
assert cd_backend.CUSTOM_DIT_ENABLERS == expected_enablers
@pytest.fixture()
def init_fake_tp_group(mocker):
"""Provide a fake TP group so vLLM linear layers can be instantiated."""
mock_tp = mocker.MagicMock()
mock_tp.world_size = 1
mock_tp.rank_in_group = 0
old = parallel_state._TP
parallel_state._TP = mock_tp
yield
parallel_state._TP = old
def test_wan22_vace_uses_wan22_custom_cache_dit_enabler():
assert cd_backend.CUSTOM_DIT_ENABLERS["Wan22VACEPipeline"] is cd_model_specific.enable_cache_for_wan22
@@ -110,7 +123,9 @@ def test_cachedit_consumers_use_package_api():
for module in modules:
if module == legacy_module or module.startswith(internal_prefix):
invalid_imports.append(f"{source_path.relative_to(package_root)}:{node.lineno}: {module}")
invalid_imports.append(
f"{source_path.relative_to(package_root)}:{getattr(node, 'lineno', '?')}: {module}"
)
assert not invalid_imports, "Cache-DiT consumers must use the package API:\n" + "\n".join(invalid_imports)
@@ -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 collections import namedtuple
from types import SimpleNamespace
@@ -10,7 +10,7 @@ from diffusers import DiffusionPipeline # pyright: ignore[reportPrivateImportUs
from PIL import Image
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler, dummy_messages_from_mix_data
from tests.helpers.runtime import OmniServer, OmniServerParams, OnlineOmniClient, dummy_messages_from_mix_data
from vllm_omni.diffusion.data import (
DiffusionOutput,
DiffusionParallelConfig,
@@ -651,7 +651,7 @@ class TestDiffusersBackendEndToEndExecution:
def test_t2i_random_weights(
self,
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
):
messages = dummy_messages_from_mix_data(content_text="a photo of an astronaut riding a horse on mars")
@@ -667,8 +667,10 @@ class TestDiffusersBackendEndToEndExecution:
},
}
response = openai_client.send_diffusion_request(request_config)
image: Image.Image = response[0].images[0] # pyright: ignore[reportOptionalSubscript]
response = online_client.send_diffusion_request(request_config)
images = response[0].images
assert images is not None
image: Image.Image = images[0]
# Request config has incomplete width/height, so internal assertion in `send_diffusion_request` is incomplete.
assert image.size == (512, 512)
@@ -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
"""DiT tensor-parallel parity and perf checks for diffusion models.
@@ -18,8 +18,8 @@ import pytest
import torch
from PIL import Image
from tests.helpers.env import DeviceMemoryMonitor
from tests.helpers.mark import hardware_test
from tests.helpers.monitor import DeviceMemoryMonitor
from tests.helpers.runtime import OmniRunner
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
@@ -1,16 +1,19 @@
# 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 the DiffusersPipelineLoader.
"""
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
import torch
import torch.nn as nn
from huggingface_hub import snapshot_download
from safetensors.torch import save_file
from vllm.config.load import LoadConfig
from vllm_omni.diffusion.config import get_current_diffusion_config, get_current_diffusion_config_or_none
@@ -21,7 +24,9 @@ from vllm_omni.diffusion.model_loader.host_weight_plan import (
HostWeightPlanResult,
TensorBinding,
)
from vllm_omni.diffusion.model_loader.host_weights import source_identity as source_identity_module
from vllm_omni.diffusion.models.helios import HeliosPipeline
from vllm_omni.diffusion.models.host_weight_contract import FinalLayoutModelContract
from vllm_omni.diffusion.registry import initialize_model
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
@@ -72,6 +77,73 @@ class _DummyPipelineModel(nn.Module):
return loaded
class _HWRTransformer(nn.Module):
host_weight_restore_contract = FinalLayoutModelContract(
implementation_id="test-hwr-transformer",
version="1",
)
def __init__(self):
super().__init__()
self.weight = nn.Parameter(torch.arange(4, dtype=torch.float32).to(torch.bfloat16).reshape(2, 2))
def validate_restored_host_weights(self):
assert self.weight.dtype is torch.bfloat16
class _HWRPipeline(nn.Module):
def __init__(self, source_root: Path):
super().__init__()
self.transformer = _HWRTransformer()
self.load_count = 0
self.weights_sources = [
DiffusersPipelineLoader.ComponentSource(
model_or_path=str(source_root),
subfolder=None,
revision=None,
prefix="transformer.",
fall_back_to_pt=False,
)
]
def load_weights(self, weights):
loaded: set[str] = set()
params = dict(self.named_parameters())
for name, tensor in weights:
if name in params:
params[name].data.copy_(tensor.to(dtype=params[name].dtype))
loaded.add(name)
self.load_count += 1
return loaded
def _hwr_config(model: str | Path, root: Path, *, mode: str = "preferred") -> SimpleNamespace:
return SimpleNamespace(
model=str(model),
dtype=torch.bfloat16,
host_weight_runtime_mode=mode,
host_weight_runtime_root=str(root),
enable_distributed_layerwise_offload=True,
dlo_use_allgather=False,
lora_path=None,
quantization_config=None,
diffusion_attention_config=None,
parallel_config=SimpleNamespace(
use_hsdp=False,
data_parallel_size=1,
sequence_parallel_size=1,
tensor_parallel_size=1,
pipeline_parallel_size=1,
cfg_parallel_size=1,
enable_expert_parallel=False,
ulysses_degree=1,
ring_degree=1,
allgather_degree=1,
ulysses_mode="strict",
),
)
def _make_loader_with_weights(weight_names: list[str]) -> DiffusersPipelineLoader:
od_config = SimpleNamespace(
dtype=torch.float32,
@@ -91,6 +163,92 @@ def _make_loader_with_weights(weight_names: list[str]) -> DiffusersPipelineLoade
return loader
def test_hwr_cold_publication_and_warm_restore_skip_ordinary_dit_loading(
tmp_path: Path,
monkeypatch,
):
canonical_root = tmp_path / "canonical"
canonical_root.mkdir()
save_file(
{"weight": torch.arange(4, dtype=torch.float32).to(torch.bfloat16).reshape(2, 2)},
str(canonical_root / "model.safetensors"),
)
store_root = tmp_path / "hwr-store"
hash_calls = 0
original_sha256 = source_identity_module._sha256_file
def counted_sha256(path: Path, state: object) -> str:
nonlocal hash_calls
hash_calls += 1
return original_sha256(path, state) # type: ignore[arg-type]
monkeypatch.setattr(source_identity_module, "_sha256_file", counted_sha256)
def make_loader() -> tuple[DiffusersPipelineLoader, _HWRPipeline]:
loader = DiffusersPipelineLoader(LoadConfig(), _hwr_config(canonical_root, store_root))
pipeline = _HWRPipeline(canonical_root)
monkeypatch.setattr(loader, "_init_from_load_format", lambda *args, **kwargs: pipeline)
return loader, pipeline
cold_loader, cold_model = make_loader()
cold = cold_loader.load_model(load_device="cpu", device=torch.device("cpu"))
assert cold is cold_model
assert cold_model.load_count == 1
assert cold_loader._hwr_state is not None
warm_loader, warm_model = make_loader()
monkeypatch.setattr(
warm_loader,
"_process_weights_after_loading",
lambda *args, **kwargs: pytest.fail("warm HWR restore re-entered byte-changing finalization"),
)
warm = warm_loader.load_model(load_device="cpu", device=torch.device("cpu"))
assert warm is warm_model
assert warm_model.load_count == 0
assert torch.equal(warm_model.transformer.weight, cold_model.transformer.weight)
from vllm_omni.diffusion.offloader.startup import take_offload_startup_state
startup_state = take_offload_startup_state(warm)
assert startup_state is not None
warm_plan = startup_state.host_weight_plan
assert warm_plan is not None
assert warm_plan.lease_carrier is not None
warm_plan.lease_carrier.close()
assert hash_calls == 1
assert len(tuple((store_root / "source-digests-v1" / "entries").glob("*.json"))) == 1
def test_hwr_commit_failure_discards_model_and_reloads_without_hwr_or_mmap(tmp_path: Path, monkeypatch):
from vllm_omni.diffusion.model_loader import diffusers_loader as loader_module
loader = DiffusersPipelineLoader(LoadConfig(), _hwr_config(tmp_path, tmp_path / "store"))
models: list[_DummyPipelineModel] = []
def init_model(*args, **kwargs):
del args, kwargs
model = _DummyPipelineModel(source_prefix="transformer.")
models.append(model)
return model
def commit_error(*args, **kwargs):
del args, kwargs
raise loader_module._HWRCommitError("restore commit failed")
monkeypatch.setattr(loader, "_init_from_load_format", init_model)
monkeypatch.setattr(loader, "_get_weight_sources", lambda _model: ())
monkeypatch.setattr(loader, "_resolve_hwr", commit_error)
monkeypatch.setattr(loader, "load_weights", lambda *args, **kwargs: None)
monkeypatch.setattr(loader, "_process_weights_after_loading", lambda *args, **kwargs: None)
monkeypatch.setattr(loader, "_apply_skip_softmax_calibration", lambda *args, **kwargs: None)
recovered = loader.load_model(load_device="cpu", device=torch.device("cpu"))
assert len(models) == 2
assert recovered is models[1]
assert loader.take_host_weight_plan() is None
def _make_dlo_online_quant_config() -> OmniDiffusionConfig:
return OmniDiffusionConfig(
model="",
@@ -102,6 +260,196 @@ def _make_dlo_online_quant_config() -> OmniDiffusionConfig:
)
@pytest.mark.parametrize(
("dist_offload", "use_allgather"),
[
(False, False),
(True, True),
],
)
def test_hwr_disabled_for_noneligible_dlo_paths_without_store_interaction(
monkeypatch,
tmp_path,
dist_offload,
use_allgather,
):
"""Disabled and AllGather paths must never construct or probe HWR."""
from vllm_omni.host_weight_runtime import HostWeightRuntime
root = tmp_path / "must-not-be-touched"
loader = DiffusersPipelineLoader(LoadConfig(), _hwr_config("dummy-model", root))
model = _DummyPipelineModel(source_prefix="transformer.")
modules = SimpleNamespace(dit_names=("transformer",), dits=(model.transformer,))
def unexpected_store_construction(*args, **kwargs):
raise AssertionError(f"HWR store interaction was not eligible: {args}, {kwargs}")
monkeypatch.setattr(HostWeightRuntime, "from_config", unexpected_store_construction)
assert (
loader._resolve_hwr(
model,
modules,
dist_offload=dist_offload,
use_allgather=use_allgather,
load_format="default",
sources=tuple(model.weights_sources),
)
is None
)
assert not root.exists()
def test_required_hwr_rejects_a_model_without_a_restore_contract(tmp_path):
loader = DiffusersPipelineLoader(
LoadConfig(),
_hwr_config("dummy-model", tmp_path / "store", mode="required"),
)
model = _DummyPipelineModel(source_prefix="transformer.")
modules = SimpleNamespace(dit_names=("transformer",), dits=(model.transformer,))
with pytest.raises(ValueError, match="restore contract"):
loader._resolve_hwr(
model,
modules,
dist_offload=True,
use_allgather=False,
load_format="default",
sources=tuple(model.weights_sources),
)
@pytest.mark.parametrize("offline", [False, True])
def test_prepare_weights_honors_component_index_and_explicit_override(tmp_path, mocker, offline):
import vllm_omni.diffusion.model_loader.diffusers_loader as loader_mod
snapshot = tmp_path / "snapshot"
transformer = snapshot / "transformer"
transformer.mkdir(parents=True)
indexed_files = [f"diffusion_pytorch_model-{index:05d}-of-00002.safetensors" for index in (1, 2)]
stale_file = "diffusion_pytorch_model-00001-of-00008.safetensors"
index_path = transformer / "diffusion_pytorch_model.safetensors.index.json"
index_path.write_text(
json.dumps({"weight_map": {f"weight.{index}": filename for index, filename in enumerate(indexed_files)}})
)
for filename in indexed_files + [stale_file]:
(transformer / filename).touch()
mocker.patch.object(loader_mod.huggingface_hub.constants, "HF_HUB_OFFLINE", offline)
def download_index(*, filename, **_kwargs):
if filename == "transformer/diffusion_pytorch_model.safetensors.index.json":
return str(index_path)
raise loader_mod.huggingface_hub.errors.EntryNotFoundError(filename)
hub_api = mocker.Mock()
hub_api.hf_hub_download.side_effect = download_index
mocker.patch.object(loader_mod, "hf_api", return_value=hub_api)
indexed_download = mocker.patch.object(loader_mod, "download_weights_from_hf_specific", return_value=str(snapshot))
generic_download = mocker.patch.object(loader_mod, "download_weights_from_hf", return_value=str(snapshot))
loader = _make_loader_with_weights([])
cache_dir = str(tmp_path / "cache")
loader.load_config.download_dir = cache_dir
folder, files, use_safetensors = loader._prepare_weights(
"org/model",
subfolder="transformer",
revision="revision",
fall_back_to_pt=True,
allow_patterns_overrides=None,
)
assert folder == str(transformer)
assert [str(transformer / filename) for filename in indexed_files] == files
assert use_safetensors
assert hub_api.hf_hub_download.call_count == 2
hub_api.hf_hub_download.assert_any_call(
repo_id="org/model",
filename="transformer/diffusion_pytorch_model.safetensors.index.json",
cache_dir=cache_dir,
revision="revision",
local_files_only=offline,
)
indexed_download.assert_called_once_with(
model_name_or_path="org/model",
cache_dir=cache_dir,
allow_patterns=[f"transformer/{filename}" for filename in indexed_files],
revision="revision",
ignore_patterns=loader.load_config.ignore_patterns,
require_all=True,
)
_, override_files, _ = loader._prepare_weights(
"org/model",
subfolder="transformer",
revision="revision",
fall_back_to_pt=True,
allow_patterns_overrides=[stale_file],
)
assert override_files == [str(transformer / stale_file)]
generic_download.assert_called_once_with(
"org/model",
cache_dir,
[stale_file],
"revision",
subfolder="transformer",
ignore_patterns=loader.load_config.ignore_patterns,
)
def test_prepare_local_weights_honors_component_index(tmp_path):
transformer = tmp_path / "transformer"
transformer.mkdir()
indexed_file = "diffusion_pytorch_model-00001-of-00001.safetensors"
stale_file = "diffusion_pytorch_model-00001-of-00008.safetensors"
(transformer / "diffusion_pytorch_model.safetensors.index.json").write_text(
json.dumps({"weight_map": {"weight": indexed_file}})
)
for filename in (indexed_file, stale_file):
(transformer / filename).touch()
folder, files, use_safetensors = _make_loader_with_weights([])._prepare_weights(
tmp_path,
subfolder="transformer",
revision=None,
fall_back_to_pt=True,
allow_patterns_overrides=None,
)
assert folder == str(transformer)
assert files == [str(transformer / indexed_file)]
assert use_safetensors
def test_prepare_weights_rejects_polluted_offline_cache_without_index(tmp_path, mocker):
import vllm_omni.diffusion.model_loader.diffusers_loader as loader_mod
snapshot = tmp_path / "snapshot"
transformer = snapshot / "transformer"
transformer.mkdir(parents=True)
for shard_count in (4, 8):
for shard in range(1, shard_count + 1):
(transformer / f"diffusion_pytorch_model-{shard:05d}-of-{shard_count:05d}.safetensors").touch()
mocker.patch.object(loader_mod.huggingface_hub.constants, "HF_HUB_OFFLINE", True)
hub_api = mocker.Mock()
hub_api.hf_hub_download.side_effect = loader_mod.huggingface_hub.errors.EntryNotFoundError("index is not cached")
mocker.patch.object(loader_mod, "hf_api", return_value=hub_api)
mocker.patch.object(loader_mod, "download_weights_from_hf", return_value=str(snapshot))
loader = _make_loader_with_weights([])
with pytest.raises(ValueError, match="conflicting shard totals"):
loader._prepare_weights(
"org/model",
subfolder="transformer",
revision="revision",
fall_back_to_pt=True,
allow_patterns_overrides=None,
)
assert hub_api.hf_hub_download.call_count == 2
assert all(call.kwargs["local_files_only"] for call in hub_api.hf_hub_download.call_args_list)
def test_strict_check_only_validates_source_prefix_parameters():
model = _DummyPipelineModel(source_prefix="transformer.")
loader = _make_loader_with_weights(["transformer.weight"])
@@ -6,6 +6,8 @@ from __future__ import annotations
import dataclasses
import gc
import json
import threading
from collections.abc import Sequence
from pathlib import Path
from types import SimpleNamespace
@@ -24,10 +26,12 @@ from vllm_omni.diffusion.model_loader.host_weights import (
FinalLayoutRequest,
FinalLayoutTensorRestorer,
ImplementationIdentity,
NodeSourceDigestCache,
PreparedWeightSource,
WeightSourceKind,
build_final_layout_identity,
)
from vllm_omni.diffusion.model_loader.host_weights import source_identity as source_identity_module
from vllm_omni.diffusion.model_loader.host_weights.contracts import (
FINAL_LAYOUT_TENSOR_RESTORER_SCHEMA,
FinalLayoutArtifactSpec,
@@ -380,6 +384,7 @@ def _identity(
*,
request: FinalLayoutRequest | None = None,
policy: FinalLayoutTensorPolicy = FINAL_LAYOUT_BF16_POLICY,
source_digest_cache: NodeSourceDigestCache | None = None,
) -> FinalLayoutIdentityContext:
transformer = model.get_submodule("transformer")
return build_final_layout_identity(
@@ -388,6 +393,7 @@ def _identity(
prepared_sources=(source,),
request=request or _request(),
policy=policy,
source_digest_cache=source_digest_cache,
)
@@ -564,6 +570,131 @@ def test_source_identity_resolves_longest_prefix_and_rejects_ties(tmp_path: Path
assert exc_info.value.code is FinalLayoutContractCode.SOURCE_COVERAGE_INVALID
def test_node_source_digest_cache_reuses_recovers_and_invalidates(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
model = _TinyPipeline()
source = _prepared_source(tmp_path)
cache_root = tmp_path / "store"
calls = 0
original_sha256 = source_identity_module._sha256_file
def counted_sha256(path: Path, state: object) -> str:
nonlocal calls
calls += 1
return original_sha256(path, state) # type: ignore[arg-type]
monkeypatch.setattr(source_identity_module, "_sha256_file", counted_sha256)
first = _identity(
model,
source,
source_digest_cache=NodeSourceDigestCache(cache_root, timeout_seconds=1.0),
)
second = _identity(
model,
source,
source_digest_cache=NodeSourceDigestCache(cache_root, timeout_seconds=1.0),
)
assert second.identity == first.identity
assert calls == 1
cache_entry = next((cache_root / "source-digests-v1" / "entries").glob("*.json"))
cache_entry.write_text("not-json", encoding="utf-8")
recovered = _identity(
model,
source,
source_digest_cache=NodeSourceDigestCache(cache_root, timeout_seconds=1.0),
)
assert recovered.identity == first.identity
assert calls == 2
cache_document = json.loads(cache_entry.read_text(encoding="utf-8"))
cache_document["content_id"] = f"sha256:{'0' * 64}"
cache_entry.write_text(json.dumps(cache_document), encoding="utf-8")
checksum_recovered = _identity(
model,
source,
source_digest_cache=NodeSourceDigestCache(cache_root, timeout_seconds=1.0),
)
assert checksum_recovered.identity == first.identity
assert calls == 3
source.weight_files[0].write_bytes(b"changed-canonical-source-with-a-new-size")
changed = _identity(
model,
source,
source_digest_cache=NodeSourceDigestCache(cache_root, timeout_seconds=1.0),
)
assert changed.identity != first.identity
assert calls == 4
def test_source_identity_hashes_checkpoint_shards_in_parallel(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
model = _TinyPipeline()
source = _prepared_source(tmp_path)
second_file = source.resolved_root / "model-00002.safetensors"
second_file.write_bytes(b"second-checkpoint-shard")
source = dataclasses.replace(source, weight_files=(*source.weight_files, second_file))
barrier = threading.Barrier(2)
thread_ids: set[int] = set()
thread_ids_lock = threading.Lock()
original_sha256 = source_identity_module._sha256_file
def synchronized_sha256(path: Path, state: object) -> str:
with thread_ids_lock:
thread_ids.add(threading.get_ident())
barrier.wait(timeout=5)
return original_sha256(path, state) # type: ignore[arg-type]
monkeypatch.setattr(source_identity_module, "_sha256_file", synchronized_sha256)
_identity(model, source)
assert len(thread_ids) == 2
def test_source_digest_cache_lock_timeout_falls_back_to_direct_hashing(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
model = _TinyPipeline()
source = _prepared_source(tmp_path)
cache = NodeSourceDigestCache(tmp_path / "store", timeout_seconds=0.01)
def unavailable_lock(*_args: object, **_kwargs: object) -> object:
raise source_identity_module.FileLockTimeoutError("busy")
monkeypatch.setattr(source_identity_module, "FileLock", unavailable_lock)
context = _identity(model, source, source_digest_cache=cache)
assert context.identity.source.revision.startswith("content-")
assert not tuple(cache.entries.glob("*.json"))
def test_source_digest_cache_directory_failure_falls_back_to_direct_hashing(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
model = _TinyPipeline()
source = _prepared_source(tmp_path)
original_mkdir = Path.mkdir
def failing_cache_mkdir(path: Path, *args: object, **kwargs: object) -> None:
if "source-digests-v1" in path.parts:
raise OSError("injected cache directory failure")
original_mkdir(path, *args, **kwargs) # type: ignore[arg-type]
monkeypatch.setattr(Path, "mkdir", failing_cache_mkdir)
cache = NodeSourceDigestCache(tmp_path / "store", timeout_seconds=0.01)
context = _identity(model, source, source_digest_cache=cache)
assert context.identity.source.revision.startswith("content-")
def test_local_hex_named_symlink_target_is_content_hashed_across_startups(tmp_path: Path) -> None:
model = _TinyPipeline()
source_root = tmp_path / "local-symlink-source"
@@ -726,6 +857,36 @@ def test_identity_uses_resolved_revision_and_exact_semantics(tmp_path: Path) ->
assert identity_v2.key != base_identity.key
@pytest.mark.parametrize(
"parallel",
[
FinalLayoutParallelIdentity(),
FinalLayoutParallelIdentity(tensor_parallel_size=2, tensor_parallel_rank=0),
FinalLayoutParallelIdentity(tensor_parallel_size=2, tensor_parallel_rank=1),
FinalLayoutParallelIdentity(sequence_parallel_size=2, ulysses_degree=2),
FinalLayoutParallelIdentity(sequence_parallel_size=2, ring_degree=2),
FinalLayoutParallelIdentity(
tensor_parallel_size=2,
tensor_parallel_rank=1,
sequence_parallel_size=2,
ulysses_degree=2,
),
],
)
def test_parallel_identity_matrix_is_exact_for_tp_and_sp(tmp_path: Path, parallel: FinalLayoutParallelIdentity) -> None:
"""TP rank/size and SP backend are identity coordinates for HWR artifacts."""
model = _TinyPipeline()
source = _prepared_source(tmp_path)
context = _identity(model, source, request=_request(parallel=parallel))
layout = context.identity.layout
assert layout.tensor_parallel_size == parallel.tensor_parallel_size
assert layout.tensor_parallel_rank == parallel.tensor_parallel_rank
assert layout.sequence_parallel_size == parallel.sequence_parallel_size
assert layout.sequence_parallel_backend == parallel.sequence_parallel_backend
assert "data_parallel" not in context.identity.layout.metadata.to_value().get("parallel", {})
@pytest.mark.parametrize(
("semantic_request", "message"),
[
@@ -774,6 +774,12 @@ def test_packed_attention_skips_mask_for_packed_mask_free_backend():
assert metadata.attn_mask is None
assert metadata.extra["valid_kv_length"] == 5
assert metadata.extra["npu_attn_varlen"] is True
packed_padding = metadata.packed_padding
assert packed_padding is not None
assert packed_padding.q_length == 5
assert packed_padding.kv_length == 5
assert packed_padding.cu_seqlens_q.tolist() == [0, 5]
assert packed_padding.cu_seqlens_k.tolist() == [0, 5]
def test_packed_attention_keeps_padding_mask_for_other_backends():
@@ -821,6 +827,82 @@ def test_packed_attention_keeps_padding_mask_for_other_backends():
)
def _fake_packed_attention(
backend_name: str,
*,
prefix_kv_slicing: bool = False,
supports_multi_doc: bool = False,
):
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
MiniMaxH3Attention,
)
class FakeBackend:
supports_prefix_kv_slicing = prefix_kv_slicing
@staticmethod
def get_name() -> str:
return backend_name
@classmethod
def supports_multi_doc_packed_varlen(cls) -> bool:
return supports_multi_doc
class FakeAttention(torch.nn.Module):
attn_backend = FakeBackend
def __init__(self):
super().__init__()
self.metadata = None
def forward(self, query, key, value, metadata):
self.metadata = metadata
return query
attention = object.__new__(MiniMaxH3Attention)
torch.nn.Module.__init__(attention)
attention.attention = FakeAttention()
return attention
def test_packed_attention_drops_prefix_semantics_for_a_co_batched_layout():
"""A batch's valid rows are block-diagonal, so no prefix can describe them."""
attention = _fake_packed_attention("FLASH_ATTN", supports_multi_doc=True)
q = torch.randn(12, 2, 4)
attention._run_packed_attention(
q,
q,
q,
# Two requests: rows + padding tail each.
cu_seqlens=torch.tensor([0, 5, 6, 11, 12], dtype=torch.int32),
max_seqlen=5,
packed_total=12,
num_requests=2,
)
assert attention.attention.metadata.attn_mask is None
assert attention.attention.metadata.extra["valid_kv_length"] == 12
@pytest.mark.parametrize("backend_name", ["TORCH_SDPA", "FLASH_ATTN"])
def test_packed_attention_rejects_backends_without_multi_doc_capability(backend_name):
"""Backend names alone must not allow cross-request attention."""
attention = _fake_packed_attention(backend_name, supports_multi_doc=False)
q = torch.randn(12, 2, 4)
with pytest.raises(ValueError, match=backend_name):
attention._run_packed_attention(
q,
q,
q,
cu_seqlens=torch.tensor([0, 5, 6, 11, 12], dtype=torch.int32),
max_seqlen=5,
packed_total=12,
num_requests=2,
)
def test_reference_image_resize_contract():
from PIL import Image
@@ -0,0 +1,101 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import pytest
import torch
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
class _OffloadAbort(BaseException):
pass
def test_encoder_non_block_children_use_one_shared_snapshot_stager(monkeypatch, mocker):
from vllm_omni.diffusion.models.minimax_h3 import encoder as encoder_module
class VisionStack(torch.nn.Module):
def __init__(self):
super().__init__()
self.patch_embed = torch.nn.Linear(2, 2)
self.blocks = torch.nn.ModuleList([torch.nn.Linear(2, 2), torch.nn.Linear(2, 2)])
class TextStack(torch.nn.Module):
def __init__(self):
super().__init__()
self.embed_tokens = torch.nn.Embedding(4, 2)
self.layers = torch.nn.ModuleList([torch.nn.Linear(2, 2), torch.nn.Linear(2, 2)])
encoder = object.__new__(encoder_module.MiniMaxH3Qwen3VLEncoder)
torch.nn.Module.__init__(encoder)
encoder.device_target = torch.device("cpu")
encoder.vision = VisionStack()
encoder.text_model = TextStack()
hook = mocker.Mock()
hook.pin_memory = False
encoder._omni_layerwise_hooks = [hook]
encoder._omni_layerwise_enabled = True
cache = mocker.Mock()
encoder.set_omni_component_cache(cache)
stager = mocker.Mock()
stager_cls = mocker.Mock(return_value=stager)
monkeypatch.setattr(encoder_module, "PinnedModuleStager", stager_cls)
encoder.load_to_device()
encoder.offload_to_cpu()
stager_cls.assert_called_once_with(
[encoder.vision.patch_embed, encoder.text_model.embed_tokens],
torch.device("cpu"),
pin_memory=False,
cache_retention=cache,
)
stager.load.assert_called_once_with()
stager.offload.assert_called_once_with()
hook.offload_layer.assert_called_once_with()
def test_manual_component_failure_forces_retained_cache_release(mocker):
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
pipeline.od_config = mocker.Mock()
pipeline.od_config.enable_layerwise_offload = False
pipeline.od_config.enable_distributed_layerwise_offload = True
pipeline._model_cpu_offload_modules = []
pipeline._dlo_component_cache = mocker.Mock()
component = mocker.Mock()
component.offload_to_cpu.side_effect = _OffloadAbort("offload failed")
with pytest.raises(RuntimeError, match="component failed"):
with pipeline._component_on_device(component):
raise RuntimeError("component failed")
component.load_to_device.assert_called_once_with()
component.offload_to_cpu.assert_called_once_with()
pipeline._dlo_component_cache.release_if_needed.assert_called_once_with(force=True)
def test_manual_component_offload_failure_forces_retained_cache_release(mocker):
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
pipeline.od_config = mocker.Mock()
pipeline.od_config.enable_layerwise_offload = False
pipeline.od_config.enable_distributed_layerwise_offload = True
pipeline._model_cpu_offload_modules = []
pipeline._dlo_component_cache = mocker.Mock()
component = mocker.Mock()
component.offload_to_cpu.side_effect = [None, _OffloadAbort("offload failed")]
with pipeline._component_on_device(component):
pass
with pytest.raises(_OffloadAbort, match="offload failed"):
with pipeline._component_on_device(component):
pass
assert component.load_to_device.call_count == 2
assert component.offload_to_cpu.call_count == 2
pipeline._dlo_component_cache.release_if_needed.assert_called_once_with(force=True)
@@ -0,0 +1,473 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""MiniMax H3 step-wise execution (continuous batching) contract tests.
These run on CPU against a stand-in DiT, so they cover the packing, the
per-request scheduler math, and the runner-facing state wiring without needing
checkpoint weights.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
import torch
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
_HIDDEN = 8
class _SegmentMeanModel:
"""Stand-in DiT whose output depends on packed-document boundaries.
Every row is mixed with the mean of its own ``cu_seqlens`` document and with
its own timestep, so a wrong document boundary, row offset, or timestep
assignment shows up as a numeric difference instead of passing silently.
"""
def __call__(self, **kwargs):
x = kwargs["x"][0]
audio_x = kwargs["audio_x"][0]
bounds = kwargs["packed_seq_params"]["cu_seqlens_q"].tolist()
if kwargs["packed_seq_params"].get("num_requests", 1) > 1:
assert all(start < stop for start, stop in zip(bounds[:-1], bounds[1:]))
img_pos = kwargs["img_pos_info"]["position_ids"]
audio_pos = kwargs["audio_pos_info"]["position_ids"]
pooled_video = torch.zeros_like(x)
pooled_audio = torch.zeros_like(audio_x)
for start, stop in zip(bounds[:-1], bounds[1:]):
if stop <= start:
continue
pooled_video[start:stop] = x[start:stop].mean(dim=0, keepdim=True)
pooled_audio[start:stop] = audio_x[start:stop].mean(dim=0, keepdim=True)
row_timesteps = kwargs["unique_timesteps"][kwargs["inverse_indices"]].unsqueeze(-1)
video = (pooled_video + 0.5 * x + row_timesteps)[img_pos]
audio = (pooled_audio + 0.5 * audio_x + row_timesteps)[audio_pos]
if not kwargs.get("skip_mask_out_condition", False):
video = video * kwargs["update_mask"].view(-1).unsqueeze(-1)
return video, audio
def _make_branch(*, text_len: int, latent_t: int, latent_h: int, latent_w: int, audio_t: int, seed: int):
from vllm_omni.diffusion.models.minimax_h3.denoise_loop import MiniMaxH3DenoiseBranch
from vllm_omni.diffusion.models.minimax_h3.packed_sequence import minimax_h3_packed_sequence
packed = minimax_h3_packed_sequence(
text_len=text_len,
latent_t=latent_t,
latent_h=latent_h,
latent_w=latent_w,
audio_t=audio_t,
include_keyframe_cond=False,
)
generator = torch.Generator().manual_seed(seed)
text_embeddings = torch.randn(text_len, _HIDDEN, generator=generator, dtype=torch.float32)
branch = MiniMaxH3DenoiseBranch(
packed=packed,
text_embeddings=text_embeddings,
token_tags=packed["token_tags"],
device=torch.device("cpu"),
)
video_rows = torch.randn(int(branch.img_pos.shape[0]), 96, generator=generator, dtype=torch.float32)
audio_rows = torch.randn(int(branch.audio_pos.shape[0]), 32, generator=generator, dtype=torch.float32)
return branch, video_rows, audio_rows
def _sigmas(num_points: int, shift: float) -> list[float]:
from vllm_omni.diffusion.models.minimax_h3.time_request import minimax_h3_time_shift_sigmas
return minimax_h3_time_shift_sigmas(num_steps=num_points, shift_scale=shift)
def _make_state(request_id: str, model, branch, video_rows, audio_rows, sigmas_video, sigmas_audio):
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as mod
from vllm_omni.diffusion.worker.utils import StepRequestState
state = StepRequestState(request_id=request_id, sampling=SimpleNamespace())
state.latents = video_rows.clone()
state.timesteps = torch.tensor([1.0 - sigma for sigma in sigmas_video[:-1]], dtype=torch.float32)
state.step_index = 0
state.extra = {
mod._STEP_BRANCH: branch,
# Co-batched requests must share one DiT instance, or denoise_step()
# treats the batch as mixed-task and falls back to one forward each.
mod._STEP_TRANSFORMER: model,
mod._STEP_AUDIO_ROWS: audio_rows.clone(),
mod._STEP_COND_ANCHOR: None,
mod._STEP_AUDIO_ANCHOR: None,
mod._STEP_SIGMAS_VIDEO: sigmas_video,
mod._STEP_SIGMAS_AUDIO: sigmas_audio,
}
return state
def _step_pipeline(model, *, packed_batch_supported: bool = True):
"""A pipeline instance carrying only what the step methods touch."""
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
pipeline.transformer = model
pipeline.device = torch.device("cpu")
pipeline._transformer_for_task = lambda task: model
pipeline._packed_batch_supported = lambda transformer: packed_batch_supported
return pipeline
def test_step_execution_matches_request_mode_denoise_loop():
"""Stepping through the contract must reproduce the request-mode loop."""
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as mod
from vllm_omni.diffusion.models.minimax_h3.denoise_loop import minimax_h3_denoise_loop
model = _SegmentMeanModel()
branch, video_rows, audio_rows = _make_branch(text_len=9, latent_t=2, latent_h=4, latent_w=6, audio_t=3, seed=5)
sigmas_video = _sigmas(6, 12.0)
sigmas_audio = _sigmas(6, 3.0)
reference_video, reference_audio = minimax_h3_denoise_loop(
model=model,
positive=branch,
initial_video_rows=video_rows,
initial_audio_rows=audio_rows,
keyframe_cond_rows=None,
sigmas_video=sigmas_video,
sigmas_audio=sigmas_audio,
device=torch.device("cpu"),
)
pipeline = _step_pipeline(model)
state = _make_state("req-0", model, branch, video_rows, audio_rows, sigmas_video, sigmas_audio)
input_batch = SimpleNamespace(states=(state,))
steps = 0
while not state.denoise_completed:
noise_pred = pipeline.denoise_step(input_batch, states=[state])
pipeline.step_scheduler(state, noise_pred)
steps += 1
assert steps == len(sigmas_video) - 1
assert state.total_steps == steps
torch.testing.assert_close(state.latents, reference_video)
torch.testing.assert_close(state.extra[mod._STEP_AUDIO_ROWS], reference_audio)
def test_batched_step_execution_matches_independent_requests():
"""Two co-batched requests must land where they would have landed alone."""
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as mod
model = _SegmentMeanModel()
specs = [
# Exactly 64 packed rows exercises the no-padding-tail boundary case.
dict(text_len=46, latent_t=7, latent_h=2, latent_w=4, audio_t=2, seed=7),
dict(text_len=9, latent_t=2, latent_h=4, latent_w=6, audio_t=3, seed=6),
]
# Different step counts, so the batch composition changes mid-flight.
schedules = [(_sigmas(6, 12.0), _sigmas(6, 3.0)), (_sigmas(4, 12.0), _sigmas(4, 3.0))]
pipeline = _step_pipeline(model)
alone: list[tuple[torch.Tensor, torch.Tensor]] = []
for spec, (sigmas_video, sigmas_audio) in zip(specs, schedules):
branch, video_rows, audio_rows = _make_branch(**spec)
state = _make_state("solo", model, branch, video_rows, audio_rows, sigmas_video, sigmas_audio)
while not state.denoise_completed:
pipeline.step_scheduler(state, pipeline.denoise_step(SimpleNamespace(states=(state,)), states=[state]))
alone.append((state.latents, state.extra[mod._STEP_AUDIO_ROWS]))
states = []
for index, (spec, (sigmas_video, sigmas_audio)) in enumerate(zip(specs, schedules)):
branch, video_rows, audio_rows = _make_branch(**spec)
if index == 0:
assert branch.used_len == branch.seq_len
states.append(_make_state(f"req-{index}", model, branch, video_rows, audio_rows, sigmas_video, sigmas_audio))
active = list(states)
while active:
noise_pred = pipeline.denoise_step(SimpleNamespace(states=tuple(active)), states=active)
offset = 0
for state in active:
rows = state.latents.shape[0]
pipeline.step_scheduler(state, noise_pred[offset : offset + rows])
offset += rows
assert offset == noise_pred.shape[0]
# Finished requests leave the batch, exactly like the runner drops them.
active = [state for state in active if not state.denoise_completed]
for state, (expected_video, expected_audio) in zip(states, alone):
torch.testing.assert_close(state.latents, expected_video)
torch.testing.assert_close(state.extra[mod._STEP_AUDIO_ROWS], expected_audio)
def test_both_modes_publish_denoise_progress_for_gated_attention():
"""TRTLLM's skip gate and RAINFUSION's warmup stay dense without these."""
from vllm_omni.diffusion import forward_context as fc
from vllm_omni.diffusion.models.minimax_h3.denoise_loop import minimax_h3_denoise_loop
model = _SegmentMeanModel()
branch, video_rows, audio_rows = _make_branch(text_len=9, latent_t=2, latent_h=4, latent_w=6, audio_t=3, seed=14)
sigmas_video = _sigmas(4, 12.0)
sigmas_audio = _sigmas(4, 3.0)
published: list[tuple[int | None, float | None]] = []
class _Recorder:
def __init__(self):
self.denoise_step_idx = None
self.denoise_timestep = None
def __setattr__(self, name, value):
object.__setattr__(self, name, value)
if name == "denoise_timestep":
published.append((self.denoise_step_idx, value))
recorder = _Recorder()
published.clear() # drop the pair __init__ recorded
original = fc._forward_context
fc._forward_context = recorder
try:
minimax_h3_denoise_loop(
model=model,
positive=branch,
initial_video_rows=video_rows,
initial_audio_rows=audio_rows,
keyframe_cond_rows=None,
sigmas_video=sigmas_video,
sigmas_audio=sigmas_audio,
device=torch.device("cpu"),
)
request_mode = list(published)
published.clear()
pipeline = _step_pipeline(model)
state = _make_state("req-0", model, branch, video_rows, audio_rows, sigmas_video, sigmas_audio)
while not state.denoise_completed:
pipeline.step_scheduler(state, pipeline.denoise_step(SimpleNamespace(states=(state,)), states=[state]))
step_mode = list(published)
finally:
fc._forward_context = original
num_steps = len(sigmas_video) - 1
expected = [(step, sigmas_video[step]) for step in range(num_steps)]
assert request_mode == expected + [(None, None)]
# Step mode has no loop to close, so it publishes only the per-step pairs.
assert step_mode == expected
def test_mixed_step_batch_leaves_gated_attention_dense():
"""A batch spanning different steps has no single progress point to publish."""
from vllm_omni.diffusion import forward_context as fc
model = _SegmentMeanModel()
first, first_video, first_audio = _make_branch(text_len=9, latent_t=2, latent_h=4, latent_w=6, audio_t=3, seed=15)
second, second_video, second_audio = _make_branch(
text_len=5, latent_t=3, latent_h=6, latent_w=4, audio_t=2, seed=16
)
sigmas_video, sigmas_audio = _sigmas(6, 12.0), _sigmas(6, 3.0)
states = [
_make_state("req-0", model, first, first_video, first_audio, sigmas_video, sigmas_audio),
_make_state("req-1", model, second, second_video, second_audio, sigmas_video, sigmas_audio),
]
states[1].step_index = 2 # admitted later, so it trails the batch
recorder = SimpleNamespace(denoise_step_idx="unset", denoise_timestep="unset")
original = fc._forward_context
fc._forward_context = recorder
try:
_step_pipeline(model).denoise_step(SimpleNamespace(states=tuple(states)), states=states)
finally:
fc._forward_context = original
assert recorder.denoise_step_idx is None
assert recorder.denoise_timestep is None
def test_prepare_encode_seeds_runner_visible_state(monkeypatch):
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as mod
branch, video_rows, audio_rows = _make_branch(text_len=9, latent_t=2, latent_h=4, latent_w=6, audio_t=3, seed=8)
sigmas_video = _sigmas(6, 12.0)
sigmas_audio = _sigmas(6, 3.0)
context = {
"height": 96,
"width": 64,
"latent_t": 2,
"latent_h": 4,
"latent_w": 6,
"audio_t": 3,
**{key: None for key in mod._MINIMAX_H3_DENOISE_INPUT_KEYS},
}
pipeline = _step_pipeline(_SegmentMeanModel())
monkeypatch.setattr(mod.MiniMaxH3Pipeline, "_extract_prompt", staticmethod(lambda _: ("a prompt", {})))
monkeypatch.setattr(mod.MiniMaxH3Pipeline, "_prepare_request_inputs", lambda self, **_: context)
monkeypatch.setattr(
mod.MiniMaxH3Pipeline,
"_build_denoise_inputs",
lambda self, **_: {
"branch": branch,
"video_rows": video_rows,
"audio_rows": audio_rows,
"cond_anchor": None,
"audio_anchor": None,
"sigmas_video": sigmas_video,
"sigmas_audio": sigmas_audio,
},
)
from vllm_omni.diffusion.worker.utils import StepRequestState
state = StepRequestState(
request_id="req-0",
sampling=SimpleNamespace(num_outputs_per_prompt=1),
prompt="a prompt",
)
pipeline.prepare_encode(state)
# The runner slices the batched velocity by this row count.
assert state.latents.shape == (int(branch.img_pos.shape[0]), 96)
assert state.total_steps == len(sigmas_video) - 1
assert state.step_index == 0
assert state.do_true_cfg is False
torch.testing.assert_close(state.current_timestep, torch.tensor(1.0 - sigmas_video[0]))
assert state.extra[mod._STEP_BRANCH] is branch
assert state.extra[mod._STEP_SHAPE]["height"] == 96
def test_prepare_encode_rejects_request_mode_only_features():
"""Multi-output and DLO have no representation in the step contract."""
from vllm_omni.diffusion.worker.utils import StepRequestState
from vllm_omni.errors import OmniClientError
# A request state carries exactly one latent tensor.
multi_output = StepRequestState(
request_id="req-0",
sampling=SimpleNamespace(num_outputs_per_prompt=2),
prompt="a prompt",
)
with pytest.raises(OmniClientError, match="one output per request"):
_step_pipeline(_SegmentMeanModel()).prepare_encode(multi_output)
# Distributed layerwise offload streams the DiT around a whole denoise loop.
single_output = StepRequestState(
request_id="req-1",
sampling=SimpleNamespace(num_outputs_per_prompt=1),
prompt="a prompt",
)
dlo_pipeline = _step_pipeline(_SegmentMeanModel())
dlo_pipeline._dlo_residency_controller = object()
with pytest.raises(ValueError, match="distributed layerwise offload"):
dlo_pipeline.prepare_encode(single_output)
def test_prepare_encode_rejects_high_quality_cache_dit():
"""quality=high installs a transformer-scoped Cache-DiT profile that
would leak across interleaved or co-batched step-mode requests."""
from vllm_omni.diffusion.worker.utils import StepRequestState
from vllm_omni.errors import OmniClientError
state = StepRequestState(
request_id="req-hi",
sampling=SimpleNamespace(num_outputs_per_prompt=1, quality="high"),
prompt="a prompt",
)
with pytest.raises(OmniClientError, match="quality=high"):
_step_pipeline(_SegmentMeanModel()).prepare_encode(state)
def _fake_attention_module(
*,
use_ring: bool,
backend: str = "FLASH_ATTN",
supports_multi_doc: bool = True,
):
"""Build a bare MiniMaxH3Attention with the attributes ``_packed_batch_supported`` reads.
``supports_multi_doc`` models the platform-dependent capability probe on
``AttentionBackend`` (e.g. FLASH_ATTN returns True on CUDA/ROCm/MUSA and
False on NPU/XPU); ``backend`` remains only for logging/back-compat.
"""
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import MiniMaxH3Attention
attn = object.__new__(MiniMaxH3Attention)
attn.attention = SimpleNamespace(
attn_backend=SimpleNamespace(
get_name=lambda: backend,
supports_multi_doc_packed_varlen=lambda: supports_multi_doc,
),
use_ring=use_ring,
)
return attn
class _FakeTransformer:
"""The minimal ``modules()`` protocol ``_packed_batch_supported`` walks."""
def __init__(self, attentions):
self._attentions = list(attentions)
def modules(self):
return iter([self, *self._attentions])
@pytest.mark.parametrize(
"attention",
[
_fake_attention_module(use_ring=True),
_fake_attention_module(use_ring=False, backend="XFORMERS", supports_multi_doc=False),
_fake_attention_module(use_ring=False, backend="FLASH_ATTN", supports_multi_doc=False),
],
)
def test_packed_batch_rejects_backends_that_cannot_isolate_requests(attention):
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
assert MiniMaxH3Pipeline._packed_batch_supported(_FakeTransformer([attention])) is False
def test_broadcast_rank0_exception_single_rank_reraises():
"""Single-rank execution has no group; the helper just reraises."""
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
_broadcast_rank0_exception,
)
from vllm_omni.errors import OmniClientError
_broadcast_rank0_exception(None)
with pytest.raises(OmniClientError, match="bad ref"):
_broadcast_rank0_exception(OmniClientError("bad ref"))
def test_broadcast_rank0_exception_propagates_to_non_zero_ranks(monkeypatch):
"""A rank-0 error becomes a matching client error on every other DiT rank."""
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as mod
from vllm_omni.errors import OmniClientError
def fake_rank_world(rank):
return lambda: (object(), rank, 4)
def make_broadcast(rank0_payload):
def fake_broadcast(payload_list, *, src, group):
payload_list[0] = rank0_payload
return fake_broadcast
monkeypatch.setattr(mod, "_dit_rank_world", fake_rank_world(0))
err = OmniClientError("invalid reference-video file", status_code=422, error_type="UnprocessableEntityError")
rank0_payload = {
"type": type(err).__name__,
"message": str(err),
"status_code": err.status_code,
"error_type": err.error_type,
}
monkeypatch.setattr(mod.dist, "broadcast_object_list", make_broadcast(rank0_payload))
with pytest.raises(OmniClientError) as rank0_info:
mod._broadcast_rank0_exception(err)
assert rank0_info.value is err
monkeypatch.setattr(mod, "_dit_rank_world", fake_rank_world(2))
with pytest.raises(OmniClientError) as rank2_info:
mod._broadcast_rank0_exception(None)
assert rank2_info.value.status_code == 422
assert rank2_info.value.error_type == "UnprocessableEntityError"
assert "invalid reference-video file" in str(rank2_info.value)
@@ -0,0 +1,992 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from contextlib import nullcontext
from types import SimpleNamespace
import numpy as np
import pytest
import torch
from PIL import Image
from torch import nn
from vllm_omni.diffusion.request import DUMMY_DIFFUSION_REQUEST_ID, OmniDiffusionRequest
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
class _DummyComponent:
pass
def _make_request_batch(prompt, request_id="sana-video-test", **sampling_overrides):
sampling = OmniDiffusionSamplingParams(**sampling_overrides)
request = OmniDiffusionRequest(prompt=prompt, sampling_params=sampling, request_id=request_id)
return DiffusionRequestBatch([request])
def test_sana_video_pipeline_import_and_registry():
from vllm_omni.diffusion.models.sana_video import (
SanaImageToVideoPipeline,
SanaVideoPipeline,
SanaVideoTransformer3DModel,
get_sana_video_i2v_post_process_func,
get_sana_video_i2v_pre_process_func,
get_sana_video_post_process_func,
)
from vllm_omni.diffusion.registry import (
_DIFFUSION_MODELS,
_DIFFUSION_POST_PROCESS_FUNCS,
_DIFFUSION_PRE_PROCESS_FUNCS,
)
assert SanaImageToVideoPipeline is not None
assert SanaVideoPipeline is not None
assert SanaVideoTransformer3DModel is not None
assert get_sana_video_i2v_post_process_func is not None
assert get_sana_video_i2v_pre_process_func is not None
assert get_sana_video_post_process_func is not None
assert _DIFFUSION_MODELS["SanaVideoPipeline"] == (
"sana_video",
"pipeline_sana_video",
"SanaVideoPipeline",
)
assert _DIFFUSION_POST_PROCESS_FUNCS["SanaVideoPipeline"] == "get_sana_video_post_process_func"
assert _DIFFUSION_MODELS["SanaImageToVideoPipeline"] == (
"sana_video",
"pipeline_sana_video_i2v",
"SanaImageToVideoPipeline",
)
assert _DIFFUSION_POST_PROCESS_FUNCS["SanaImageToVideoPipeline"] == "get_sana_video_i2v_post_process_func"
assert _DIFFUSION_PRE_PROCESS_FUNCS["SanaImageToVideoPipeline"] == "get_sana_video_i2v_pre_process_func"
def test_component_discovery_declarations():
from vllm_omni.diffusion.models.sana_video import SanaVideoPipeline
assert SanaVideoPipeline._dit_modules == ["transformer"]
assert SanaVideoPipeline._encoder_modules == ["text_encoder"]
assert SanaVideoPipeline._vae_modules == ["vae"]
assert SanaVideoPipeline.supports_step_execution is False
def test_sana_video_loads_concrete_gemma_tokenizer(monkeypatch):
from vllm_omni.diffusion.models.sana_video import pipeline_sana_video
captured: dict[str, object] = {}
expected = object()
def fake_from_pretrained_with_prefetch(loader, model, **kwargs):
captured.update(loader=loader, model=model, kwargs=kwargs)
return expected
monkeypatch.setattr(
pipeline_sana_video,
"from_pretrained_with_prefetch",
fake_from_pretrained_with_prefetch,
)
result = pipeline_sana_video._load_sana_tokenizer(
"Efficient-Large-Model/SANA-Video_2B_480p_diffusers",
["tokenizer", "text_encoder"],
local_files_only=False,
)
assert result is expected
assert captured["loader"] == pipeline_sana_video.GemmaTokenizer.from_pretrained
assert captured["model"] == "Efficient-Large-Model/SANA-Video_2B_480p_diffusers"
assert captured["kwargs"] == {
"subfolder": "tokenizer",
"prefetch_list": ["tokenizer", "text_encoder"],
"local_files_only": False,
}
def test_sana_video_declares_extra_body_params():
from vllm_omni.model_extras import get_extra_body_params
assert get_extra_body_params("SanaVideoPipeline") == {
"clean_caption",
"motion_score",
"use_resolution_binning",
}
assert get_extra_body_params("SanaImageToVideoPipeline") == {
"clean_caption",
"motion_score",
"use_resolution_binning",
}
def test_sana_video_i2v_preprocesses_image_and_preserves_aspect_ratio():
from vllm_omni.diffusion.models.sana_video import get_sana_video_i2v_pre_process_func
request = OmniDiffusionRequest(
prompt={"prompt": "a robot walks", "multi_modal_data": {"image": Image.new("RGB", (640, 360))}},
sampling_params=OmniDiffusionSamplingParams(),
request_id="sana-video-i2v-preprocess",
)
processed = get_sana_video_i2v_pre_process_func(SimpleNamespace())(request)
assert processed.sampling_params.height == 448
assert processed.sampling_params.width == 832
assert processed.prompt["multi_modal_data"]["image"].size == (832, 448)
def test_sana_video_i2v_remote_720p_uses_loaded_transformer_config():
from vllm_omni.diffusion.models.sana_video import get_sana_video_i2v_pre_process_func
od_config = SimpleNamespace(
model="Efficient-Large-Model/SANA-Video_2B_720p_diffusers",
tf_model_config={"sample_size": 22},
)
request = OmniDiffusionRequest(
prompt={"prompt": "a robot walks", "multi_modal_data": {"image": Image.new("RGB", (1280, 704))}},
sampling_params=OmniDiffusionSamplingParams(),
request_id="sana-video-i2v-remote-720p",
)
processed = get_sana_video_i2v_pre_process_func(od_config)(request)
assert processed.sampling_params.height == 704
assert processed.sampling_params.width == 1280
assert processed.prompt["multi_modal_data"]["image"].size == (1280, 704)
@pytest.mark.parametrize(
"frames",
[
[[Image.new("RGB", (16, 16)) for _ in range(2)]],
np.zeros((1, 2, 16, 16, 3), dtype=np.float32),
[[np.zeros((16, 16, 3), dtype=np.float32) for _ in range(2)]],
],
)
@pytest.mark.parametrize(
"factory_name",
["get_sana_video_post_process_func", "get_sana_video_i2v_post_process_func"],
)
def test_sana_video_diffusers_preserves_postprocessed_frames(frames, factory_name):
from vllm_omni.diffusion.models import sana_video
post_process = getattr(sana_video, factory_name)(SimpleNamespace(diffusion_load_format="diffusers"))
result = post_process(frames)
assert result["payload"]["video"] is frames
@pytest.mark.parametrize(
"factory_name",
["get_sana_video_post_process_func", "get_sana_video_i2v_post_process_func"],
)
def test_sana_video_diffusers_still_postprocesses_native_tensor(factory_name):
from vllm_omni.diffusion.models import sana_video
decoded_video = torch.zeros(1, 3, 2, 16, 16)
post_process = getattr(sana_video, factory_name)(SimpleNamespace(diffusion_load_format="diffusers"))
result = post_process(decoded_video)
assert isinstance(result["payload"]["video"], np.ndarray)
assert result["payload"]["video"].shape == (1, 2, 16, 16, 3)
@pytest.mark.parametrize(
"factory_name",
["get_sana_video_post_process_func", "get_sana_video_i2v_post_process_func"],
)
def test_sana_video_postprocess_preserves_requested_latents(factory_name):
from vllm_omni.diffusion.models import sana_video
latents = torch.randn(1, 4, 2, 2, 2)
post_process = getattr(sana_video, factory_name)(SimpleNamespace(diffusion_load_format="native"))
result = post_process(latents, sampling_params=SimpleNamespace(output_type="latent"))
assert result is latents
def test_sana_video_720p_model_id_fallback(monkeypatch):
from vllm_omni.diffusion.models.sana_video import pipeline_sana_video
monkeypatch.setattr(pipeline_sana_video, "get_hf_file_to_dict", lambda *_args, **_kwargs: None)
od_config = SimpleNamespace(
model="Efficient-Large-Model/SANA-Video_2B_720p_diffusers",
tf_model_config={},
)
assert pipeline_sana_video.resolve_sana_video_sample_size(od_config) == 22
@pytest.mark.parametrize(
("requested_output_type", "expected_internal_output_type"),
[
(None, "raw"),
("np", "raw"),
("latent", "latent"),
],
)
def test_sana_video_i2v_forward_maps_image_request(requested_output_type, expected_internal_output_type):
from vllm_omni.diffusion.models.sana_video import SanaImageToVideoPipeline
pipeline = object.__new__(SanaImageToVideoPipeline)
pipeline.device = torch.device("cpu")
pipeline.transformer = SimpleNamespace(config=SimpleNamespace(sample_size=30))
calls = []
def fake_generate_i2v(**kwargs):
calls.append(kwargs)
return torch.zeros(kwargs["num_videos_per_prompt"], 3, 9, 192, 320)
pipeline._generate_i2v = fake_generate_i2v
image = Image.new("RGB", (320, 192))
custom_timesteps = torch.tensor([900, 100])
req = _make_request_batch(
{"prompt": "a robot walks", "negative_prompt": "blurry", "multi_modal_data": {"image": image}},
height=192,
width=320,
num_frames=9,
num_inference_steps=2,
timesteps=custom_timesteps,
guidance_scale=4.5,
num_outputs_per_prompt=2,
eta=0.25,
seed=42,
output_type=requested_output_type,
extra_args={"clean_caption": True, "motion_score": 30, "use_resolution_binning": False},
)
output = pipeline.forward(req)
assert output.output.shape == (2, 3, 9, 192, 320)
assert calls[0]["image"] is image
assert calls[0]["prompt"] == "a robot walks motion score: 30."
assert calls[0]["negative_prompt"] == "blurry"
assert calls[0]["frames"] == 9
assert calls[0]["timesteps"] is custom_timesteps
assert calls[0]["sigmas"] is None
assert calls[0]["num_videos_per_prompt"] == 2
assert calls[0]["eta"] == 0.25
assert calls[0]["clean_caption"] is True
assert calls[0]["generator"].initial_seed() == 42
assert calls[0]["output_type"] == expected_internal_output_type
def test_sana_video_i2v_forward_maps_custom_sigmas():
from vllm_omni.diffusion.models.sana_video import SanaImageToVideoPipeline
pipeline = object.__new__(SanaImageToVideoPipeline)
pipeline.device = torch.device("cpu")
pipeline.transformer = SimpleNamespace(config=SimpleNamespace(sample_size=30))
calls = []
def fake_generate_i2v(**kwargs):
calls.append(kwargs)
return torch.zeros(1, 3, 9, 192, 320)
pipeline._generate_i2v = fake_generate_i2v
sigmas = [1.0, 0.5]
output = pipeline.forward(
_make_request_batch(
{
"prompt": "a robot walks",
"multi_modal_data": {"image": Image.new("RGB", (320, 192))},
},
height=192,
width=320,
num_frames=9,
sigmas=sigmas,
generator_device="cpu",
extra_args={"use_resolution_binning": False},
)
)
assert output.output.shape == (1, 3, 9, 192, 320)
assert calls[0]["timesteps"] is None
assert calls[0]["sigmas"] is sigmas
def test_sana_video_i2v_omitted_num_frames_uses_model_default():
from vllm_omni.diffusion.models.sana_video import SanaImageToVideoPipeline
pipeline = object.__new__(SanaImageToVideoPipeline)
pipeline.device = torch.device("cpu")
pipeline.transformer = SimpleNamespace(config=SimpleNamespace(sample_size=30))
calls = []
def fake_generate_i2v(**kwargs):
calls.append(kwargs)
return torch.zeros(1, 3, 81, 2, 2)
pipeline._generate_i2v = fake_generate_i2v
req = _make_request_batch(
{
"prompt": "a robot walks",
"multi_modal_data": {"image": Image.new("RGB", (320, 192))},
},
height=192,
width=320,
num_inference_steps=1,
generator_device="cpu",
extra_args={"use_resolution_binning": False},
)
assert req.sampling_params.num_frames == 1
output = pipeline.forward(req)
assert output.output.shape == (1, 3, 81, 2, 2)
assert calls[0]["frames"] == 81
def test_sana_video_i2v_clean_caption_request_is_explicitly_rejected():
from vllm_omni.diffusion.models.sana_video import SanaImageToVideoPipeline
pipeline = object.__new__(SanaImageToVideoPipeline)
pipeline.device = torch.device("cpu")
pipeline.transformer = SimpleNamespace(config=SimpleNamespace(sample_size=30))
pipeline.text_encoder = SimpleNamespace(dtype=torch.float32)
pipeline.tokenizer = None
pipeline.check_inputs = lambda **_kwargs: None
req = _make_request_batch(
{
"prompt": "a robot walks",
"multi_modal_data": {"image": Image.new("RGB", (320, 192))},
},
height=192,
width=320,
num_frames=9,
generator_device="cpu",
extra_args={"clean_caption": True, "use_resolution_binning": False},
)
with pytest.raises(ValueError, match="does not support `clean_caption=True`"):
pipeline.forward(req)
@pytest.mark.parametrize(
("custom_timesteps", "custom_sigmas"),
[
(torch.tensor([4.0, 2.0]), None),
(None, [1.0, 0.5]),
],
)
def test_sana_video_i2v_consumes_batch_schedule_and_step_kwargs(custom_timesteps, custom_sigmas):
from vllm_omni.diffusion.models.sana_video import SanaImageToVideoPipeline
calls = SimpleNamespace(
encode_prompt=[],
prepare_latents=[],
set_timesteps=[],
step=[],
transformer=[],
)
class StubTransformer:
dtype = torch.float32
config = SimpleNamespace(
sample_size=30,
in_channels=4,
out_channels=4,
patch_size=(1, 1, 1),
)
def __call__(
self,
hidden_states,
*,
encoder_hidden_states,
encoder_attention_mask,
timestep,
return_dict,
):
calls.transformer.append(
(
hidden_states.shape,
encoder_hidden_states.shape,
encoder_attention_mask.shape,
timestep.shape,
return_dict,
)
)
return (torch.zeros_like(hidden_states),)
class StubScheduler:
order = 1
def set_timesteps(
self,
num_inference_steps=None,
*,
device=None,
timesteps=None,
sigmas=None,
):
calls.set_timesteps.append(
{
"num_inference_steps": num_inference_steps,
"timesteps": timesteps,
"sigmas": sigmas,
}
)
schedule_length = len(timesteps if timesteps is not None else sigmas)
self.timesteps = torch.arange(schedule_length, 0, -1, dtype=torch.float32, device=device)
def step(
self,
_noise_pred,
_timestep,
current_latents,
*,
eta,
generator,
return_dict,
):
calls.step.append({"eta": eta, "generator": generator, "return_dict": return_dict})
return (current_latents + 1,)
class VaeMustNotBeUsed:
@property
def dtype(self):
pytest.fail("latent output must return before accessing the VAE")
def decode(self, *_args, **_kwargs):
pytest.fail("latent output must not call VAE decode")
pipeline = object.__new__(SanaImageToVideoPipeline)
pipeline.device = torch.device("cpu")
pipeline.transformer = StubTransformer()
pipeline.scheduler = StubScheduler()
pipeline.vae = VaeMustNotBeUsed()
pipeline.check_inputs = lambda **_kwargs: None
pipeline.video_processor = SimpleNamespace(
preprocess=lambda _image, height, width: torch.zeros(1, 3, height, width),
)
pipeline.progress_bar = lambda **_kwargs: nullcontext(SimpleNamespace(update=lambda: None))
def capture_encode_prompt(*_args, **kwargs):
calls.encode_prompt.append(kwargs)
batch_size = kwargs["num_videos_per_prompt"]
return (
torch.zeros(batch_size, 1, 1),
torch.ones(batch_size, 1, dtype=torch.bool),
torch.zeros(batch_size, 1, 1),
torch.ones(batch_size, 1, dtype=torch.bool),
)
pipeline.encode_prompt = capture_encode_prompt
initial_latents = torch.zeros(2, 4, 2, 2, 2)
def capture_prepare_latents(*_args, **kwargs):
calls.prepare_latents.append(kwargs)
return initial_latents
pipeline._prepare_i2v_latents = capture_prepare_latents
generator = torch.Generator(device="cpu").manual_seed(42)
output = pipeline._generate_i2v(
image=Image.new("RGB", (2, 2)),
prompt="test",
negative_prompt="",
height=2,
width=2,
frames=2,
num_inference_steps=50,
timesteps=custom_timesteps,
sigmas=custom_sigmas,
guidance_scale=4.5,
num_videos_per_prompt=2,
eta=0.25,
generator=generator,
latents=None,
clean_caption=True,
use_resolution_binning=False,
max_sequence_length=1,
output_type="latent",
)
assert calls.encode_prompt[0]["num_videos_per_prompt"] == 2
assert calls.encode_prompt[0]["clean_caption"] is True
assert calls.prepare_latents[0]["batch_size"] == 2
assert len(calls.set_timesteps) == 1
assert calls.set_timesteps[0]["num_inference_steps"] is None
assert calls.set_timesteps[0]["timesteps"] is custom_timesteps
assert calls.set_timesteps[0]["sigmas"] is custom_sigmas
assert len(calls.step) == 2
assert all(call == {"eta": 0.25, "generator": generator, "return_dict": False} for call in calls.step)
assert all(
call
== (
torch.Size([4, 4, 2, 2, 2]),
torch.Size([4, 1, 1]),
torch.Size([4, 1]),
torch.Size([4, 1, 2, 2, 2]),
False,
)
for call in calls.transformer
)
expected = initial_latents.clone()
expected[:, :, 1:] += 2
torch.testing.assert_close(output, expected)
def test_sana_video_i2v_uses_diffusers_complex_instruction_default():
import inspect
from diffusers import SanaImageToVideoPipeline as DiffusersSanaImageToVideoPipeline
from vllm_omni.diffusion.models.sana_video import SanaImageToVideoPipeline
reference_default = (
inspect.signature(DiffusersSanaImageToVideoPipeline.__call__).parameters["complex_human_instruction"].default
)
native_default = (
inspect.signature(SanaImageToVideoPipeline._generate_i2v).parameters["complex_human_instruction"].default
)
assert native_default == reference_default
class StopAfterPromptEncodingError(Exception):
pass
pipeline = object.__new__(SanaImageToVideoPipeline)
pipeline.device = torch.device("cpu")
pipeline.check_inputs = lambda **_kwargs: None
captured = {}
def capture_encode_prompt(*_args, **kwargs):
captured.update(kwargs)
raise StopAfterPromptEncodingError
pipeline.encode_prompt = capture_encode_prompt
with pytest.raises(StopAfterPromptEncodingError):
pipeline._generate_i2v(
image=Image.new("RGB", (320, 192)),
prompt="a robot walks",
negative_prompt="blurry",
height=192,
width=320,
frames=9,
num_inference_steps=2,
guidance_scale=4.5,
generator=torch.Generator(device="cpu").manual_seed(42),
latents=None,
use_resolution_binning=False,
max_sequence_length=300,
)
assert captured["complex_human_instruction"] == reference_default
def test_sana_video_t2v_complex_instruction_has_no_mutable_default():
import inspect
from vllm_omni.diffusion.models.sana_video.pipeline_sana_video import (
SANA_VIDEO_COMPLEX_HUMAN_INSTRUCTION,
SanaVideoPipeline,
)
parameter = inspect.signature(SanaVideoPipeline._generate).parameters["complex_human_instruction"]
assert parameter.default is None
assert isinstance(SANA_VIDEO_COMPLEX_HUMAN_INSTRUCTION, tuple)
def test_diffusers_adapter_t2v_omitted_num_frames_uses_sana_default():
from vllm_omni.diffusion.models.diffusers_adapter.pipeline_diffusers_adapter import DiffusersAdapterPipeline
from vllm_omni.diffusion.models.diffusers_adapter.pipeline_utils import SanaVideoPipelineUtils
adapter = object.__new__(DiffusersAdapterPipeline)
adapter._accept_call_kwargs = {"prompt", "negative_prompt", "frames", "generator"}
adapter._pipeline_utils = SanaVideoPipelineUtils()
adapter.od_config = SimpleNamespace(diffusers_call_kwargs={}, output_type=None)
req = _make_request_batch(
{"prompt": "a robot walks", "negative_prompt": "blurry"},
seed=42,
generator_device="cpu",
)
assert req.sampling_params.num_frames == 1
kwargs = adapter._build_call_kwargs(req)
assert kwargs["frames"] == 81
assert "num_frames" not in kwargs
assert kwargs["generator"].initial_seed() == 42
def test_diffusers_adapter_i2v_omitted_num_frames_uses_sana_default():
from vllm_omni.diffusion.models.diffusers_adapter.pipeline_diffusers_adapter import DiffusersAdapterPipeline
from vllm_omni.diffusion.models.diffusers_adapter.pipeline_utils import SanaVideoPipelineUtils
adapter = object.__new__(DiffusersAdapterPipeline)
adapter._accept_call_kwargs = {"prompt", "negative_prompt", "image", "frames", "generator"}
adapter._pipeline_utils = SanaVideoPipelineUtils()
adapter.od_config = SimpleNamespace(diffusers_call_kwargs={}, output_type=None)
image = Image.new("RGB", (320, 192))
req = _make_request_batch(
{"prompt": "a robot walks", "negative_prompt": "blurry", "multi_modal_data": {"image": image}},
seed=42,
generator_device="cpu",
)
assert req.sampling_params.num_frames == 1
kwargs = adapter._build_call_kwargs(req)
assert kwargs["image"] is image
assert kwargs["prompt"] == "a robot walks"
assert kwargs["negative_prompt"] == "blurry"
assert kwargs["frames"] == 81
assert kwargs["generator"].initial_seed() == 42
@pytest.mark.parametrize(
("sampling_overrides", "expected_frames"),
[
({}, 49),
({"num_frames": 9}, 9),
],
)
def test_diffusers_adapter_sana_resolves_frame_precedence(sampling_overrides, expected_frames):
from vllm_omni.diffusion.models.diffusers_adapter.pipeline_diffusers_adapter import DiffusersAdapterPipeline
from vllm_omni.diffusion.models.diffusers_adapter.pipeline_utils import SanaVideoPipelineUtils
adapter = object.__new__(DiffusersAdapterPipeline)
adapter._accept_call_kwargs = {"prompt", "frames", "generator"}
adapter._pipeline_utils = SanaVideoPipelineUtils()
adapter.od_config = SimpleNamespace(diffusers_call_kwargs={"frames": 49}, output_type=None)
kwargs = adapter._build_call_kwargs(
_make_request_batch(
"a robot walks",
seed=42,
generator_device="cpu",
**sampling_overrides,
)
)
assert kwargs["frames"] == expected_frames
@pytest.mark.parametrize("pipeline_class_name", ["SanaVideoPipeline", "SanaImageToVideoPipeline"])
def test_diffusers_adapter_selects_sana_pipeline_utils(pipeline_class_name):
from vllm_omni.diffusion.models.diffusers_adapter.pipeline_utils import (
SanaVideoPipelineUtils,
get_pipeline_utils,
)
assert isinstance(get_pipeline_utils(pipeline_class_name), SanaVideoPipelineUtils)
def test_diffusers_adapter_resolves_requested_sana_i2v_pipeline():
from diffusers import SanaImageToVideoPipeline, SanaVideoPipeline
from vllm_omni.diffusion.models.diffusers_adapter.pipeline_utils import (
SanaVideoPipelineUtils,
get_pipeline_utils_for_config,
resolve_diffusers_pipeline_class,
)
od_config = SimpleNamespace(
model_class_name="SanaImageToVideoPipeline",
diffusers_pipeline_cls=SanaVideoPipeline,
)
assert isinstance(get_pipeline_utils_for_config(od_config), SanaVideoPipelineUtils)
assert resolve_diffusers_pipeline_class(od_config) is SanaImageToVideoPipeline
def test_diffusers_adapter_pipeline_override_is_sana_specific():
from diffusers import DiffusionPipeline
from vllm_omni.diffusion.models.diffusers_adapter.pipeline_utils import resolve_diffusers_pipeline_class
class OtherPipeline(DiffusionPipeline):
pass
od_config = SimpleNamespace(
model_class_name="DiffusersAdapterPipeline",
diffusers_pipeline_cls=OtherPipeline,
)
assert resolve_diffusers_pipeline_class(od_config) is OtherPipeline
def test_diffusers_adapter_loads_resolved_pipeline_class(monkeypatch):
from vllm_omni.diffusion.models.diffusers_adapter import pipeline_diffusers_adapter
from vllm_omni.diffusion.models.diffusers_adapter.pipeline_diffusers_adapter import DiffusersAdapterPipeline
loaded = []
expected_pipeline = object()
class ResolvedPipeline:
@classmethod
def from_pretrained(cls, model_id, **kwargs):
loaded.append((model_id, kwargs))
return expected_pipeline
monkeypatch.setattr(
pipeline_diffusers_adapter,
"resolve_diffusers_pipeline_class",
lambda _od_config: ResolvedPipeline,
)
adapter = object.__new__(DiffusersAdapterPipeline)
adapter.od_config = SimpleNamespace(diffusers_pipeline_cls=object)
result = adapter._load_pipeline_from_pretrained("sana-checkpoint", {"torch_dtype": torch.bfloat16})
assert result is expected_pipeline
assert loaded == [("sana-checkpoint", {"torch_dtype": torch.bfloat16})]
def test_diffusers_adapter_sana_i2v_override_reports_image_capability():
from diffusers import SanaVideoPipeline
from vllm_omni.diffusion.io_support import supports_multimodal_input
od_config = SimpleNamespace(
diffusion_load_format="diffusers",
model_class_name="SanaImageToVideoPipeline",
diffusers_pipeline_cls=SanaVideoPipeline,
)
assert supports_multimodal_input(od_config) == (True, False)
def test_diffusers_adapter_sana_i2v_warmup_supplies_image():
from diffusers import SanaVideoPipeline
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine
engine = object.__new__(DiffusionEngine)
engine.od_config = SimpleNamespace(
diffusion_load_format="diffusers",
model_class_name="SanaImageToVideoPipeline",
diffusers_pipeline_cls=SanaVideoPipeline,
)
captured_requests = []
engine.pre_process_func = lambda request: request
def capture_request(request):
captured_requests.append(request)
return SimpleNamespace(error=None)
engine.add_req_and_wait_for_response = capture_request
engine._dummy_run()
assert len(captured_requests) == 1
prompt = captured_requests[0].prompt
assert isinstance(prompt, dict)
assert isinstance(prompt["multi_modal_data"]["image"], Image.Image)
def test_diffusers_adapter_disables_resolution_binning_for_warmup():
from vllm_omni.diffusion.models.diffusers_adapter.pipeline_diffusers_adapter import DiffusersAdapterPipeline
from vllm_omni.diffusion.models.diffusers_adapter.pipeline_utils import SanaVideoPipelineUtils
adapter = object.__new__(DiffusersAdapterPipeline)
adapter._accept_call_kwargs = {"prompt", "frames", "use_resolution_binning", "generator"}
adapter._pipeline_utils = SanaVideoPipelineUtils()
adapter.od_config = SimpleNamespace(diffusers_call_kwargs={}, output_type=None)
kwargs = adapter._build_call_kwargs(
_make_request_batch(
"dummy run",
request_id=DUMMY_DIFFUSION_REQUEST_ID,
generator_device="cpu",
)
)
assert kwargs["use_resolution_binning"] is False
assert kwargs["frames"] == 1
def test_pipeline_is_torch_module_and_supports_eval():
from vllm_omni.diffusion.models.sana_video import SanaVideoPipeline
pipeline = object.__new__(SanaVideoPipeline)
nn.Module.__init__(pipeline)
assert isinstance(pipeline, nn.Module)
assert pipeline.eval() is pipeline
assert pipeline.training is False
@pytest.mark.parametrize(
("vae_scale", "patch_size", "valid_size", "invalid_size", "alignment"),
[
(8, (1, 2, 2), 624, 632, 16),
(32, (1, 1, 1), 704, 712, 32),
],
)
def test_check_inputs_uses_variant_spatial_alignment(vae_scale, patch_size, valid_size, invalid_size, alignment):
from vllm_omni.diffusion.models.sana_video import SanaVideoPipeline
pipeline = object.__new__(SanaVideoPipeline)
pipeline.vae_scale_factor_spatial = vae_scale
pipeline.transformer = SimpleNamespace(config=SimpleNamespace(patch_size=patch_size))
pipeline.check_inputs(prompt="test", height=valid_size, width=valid_size)
with pytest.raises(ValueError, match=f"divisible by {alignment}"):
pipeline.check_inputs(prompt="test", height=invalid_size, width=valid_size)
@pytest.mark.parametrize(
("requested_output_type", "expected_internal_output_type"),
[
(None, "raw"),
("np", "raw"),
("latent", "latent"),
],
)
def test_forward_maps_omni_request_to_sana_generation_args(requested_output_type, expected_internal_output_type):
from vllm_omni.diffusion.models.sana_video import SanaVideoPipeline
pipeline = object.__new__(SanaVideoPipeline)
pipeline.transformer = SimpleNamespace(config=SimpleNamespace(sample_size=30))
calls = []
def fake_generate(**kwargs):
calls.append(kwargs)
return SimpleNamespace(frames=torch.zeros(1, 3, 9, 192, 320))
pipeline._generate = fake_generate
req = _make_request_batch(
{"prompt": "a robot walks", "negative_prompt": "blurry"},
height=192,
width=320,
num_frames=9,
num_inference_steps=2,
guidance_scale=4.5,
seed=42,
output_type=requested_output_type,
extra_args={"motion_score": 30, "use_resolution_binning": False},
)
output = pipeline.forward(req)
assert output.output.shape == (1, 3, 9, 192, 320)
assert calls[0]["prompt"] == "a robot walks motion score: 30."
assert calls[0]["negative_prompt"] == "blurry"
assert calls[0]["height"] == 192
assert calls[0]["width"] == 320
assert calls[0]["frames"] == 9
assert calls[0]["num_inference_steps"] == 2
assert calls[0]["guidance_scale"] == 4.5
assert calls[0]["use_resolution_binning"] is False
assert calls[0]["generator"].initial_seed() == 42
assert calls[0]["output_type"] == expected_internal_output_type
def test_sana_video_t2v_omitted_num_frames_uses_model_default():
from vllm_omni.diffusion.models.sana_video import SanaVideoPipeline
pipeline = object.__new__(SanaVideoPipeline)
pipeline.transformer = SimpleNamespace(config=SimpleNamespace(sample_size=30))
calls = []
def fake_generate(**kwargs):
calls.append(kwargs)
return SimpleNamespace(frames=torch.zeros(1, 3, 81, 2, 2))
pipeline._generate = fake_generate
req = _make_request_batch(
"a robot walks",
height=192,
width=320,
num_inference_steps=1,
generator_device="cpu",
)
assert req.sampling_params.num_frames == 1
output = pipeline.forward(req)
assert output.output.shape == (1, 3, 81, 2, 2)
assert calls[0]["frames"] == 81
def test_sana_video_t2v_dummy_run_keeps_single_frame():
from vllm_omni.diffusion.models.sana_video import SanaVideoPipeline
pipeline = object.__new__(SanaVideoPipeline)
pipeline.transformer = SimpleNamespace(config=SimpleNamespace(sample_size=30))
calls = []
def fake_generate(**kwargs):
calls.append(kwargs)
return SimpleNamespace(frames=torch.zeros(1, 3, 1, 192, 320))
pipeline._generate = fake_generate
output = pipeline.forward(
_make_request_batch(
"dummy run",
request_id=DUMMY_DIFFUSION_REQUEST_ID,
height=192,
width=320,
num_frames=1,
num_inference_steps=1,
generator_device="cpu",
)
)
assert output.output.shape == (1, 3, 1, 192, 320)
assert calls[0]["frames"] == 1
@pytest.mark.parametrize(
("sample_size", "expected_height", "expected_width"),
[
(30, 480, 832),
(22, 704, 1280),
],
)
def test_t2v_uses_variant_default_resolution(sample_size, expected_height, expected_width):
from vllm_omni.diffusion.models.sana_video import SanaVideoPipeline
pipeline = object.__new__(SanaVideoPipeline)
pipeline.transformer = SimpleNamespace(config=SimpleNamespace(sample_size=sample_size))
calls = []
def fake_generate(**kwargs):
calls.append(kwargs)
return SimpleNamespace(frames=torch.zeros(1, 3, 9, expected_height, expected_width))
pipeline._generate = fake_generate
output = pipeline.forward(
_make_request_batch(
"a robot walks",
num_frames=9,
num_inference_steps=1,
guidance_scale=1.0,
seed=42,
generator_device="cpu",
)
)
assert output.output.shape == (1, 3, 9, expected_height, expected_width)
assert calls[0]["height"] == expected_height
assert calls[0]["width"] == expected_width
def test_forward_requires_exactly_one_nonempty_prompt():
from vllm_omni.diffusion.models.sana_video import SanaVideoPipeline
pipeline = object.__new__(SanaVideoPipeline)
with pytest.raises(ValueError, match="Prompt is required"):
pipeline.forward(_make_request_batch(""))
first = OmniDiffusionRequest(
prompt="first",
sampling_params=OmniDiffusionSamplingParams(),
request_id="first",
)
second = OmniDiffusionRequest(
prompt="second",
sampling_params=OmniDiffusionSamplingParams(),
request_id="second",
)
with pytest.raises(ValueError, match="exactly one prompt"):
pipeline.forward(DiffusionRequestBatch([first, second]))
@@ -0,0 +1,602 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
import torch
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
_TINY_CONFIG = {
"in_channels": 4,
"out_channels": 4,
"num_attention_heads": 2,
"attention_head_dim": 12,
"num_layers": 1,
"num_cross_attention_heads": 2,
"cross_attention_head_dim": 12,
"cross_attention_dim": 24,
"caption_channels": 8,
"mlp_ratio": 2.0,
"patch_size": (1, 2, 2),
"sample_size": 4,
"rope_max_seq_len": 32,
}
_GOLDEN_PREFIX = torch.tensor(
[
-0.1159307063,
0.2003862262,
-0.3754127920,
-0.2403523028,
-1.2376221418,
-0.6513092518,
-0.3358457983,
-0.9894289970,
0.7858567238,
0.5089095831,
-0.5362522602,
-0.3732060790,
]
)
@pytest.mark.parametrize(
("elementwise_affine", "bias", "expected_state_keys"),
[
(False, False, set()),
(False, True, set()),
(True, False, {"weight"}),
(True, True, {"weight", "bias"}),
],
)
@pytest.mark.parametrize(
("input_dtype", "parameter_dtype"),
[
(torch.float16, torch.float16),
(torch.bfloat16, torch.bfloat16),
(torch.float32, torch.float32),
(torch.float64, torch.float64),
(torch.float16, torch.float32),
(torch.bfloat16, torch.float32),
(torch.float32, torch.float16),
(torch.float32, torch.bfloat16),
],
)
def test_sana_rms_norm_matches_diffusers(
elementwise_affine,
bias,
expected_state_keys,
input_dtype,
parameter_dtype,
):
from diffusers.models.normalization import RMSNorm as DiffusersRMSNorm
from vllm_omni.diffusion.models.sana_video.transformer_sana_video import SanaRMSNorm
reference = DiffusersRMSNorm(8, eps=1e-5, elementwise_affine=elementwise_affine, bias=bias).to(
dtype=parameter_dtype
)
actual = SanaRMSNorm(8, eps=1e-5, elementwise_affine=elementwise_affine, bias=bias).to(dtype=parameter_dtype)
assert actual.eps == reference.eps
assert actual.elementwise_affine == reference.elementwise_affine
assert actual.dim == reference.dim
assert set(actual.state_dict()) == expected_state_keys
assert set(actual.state_dict()) == set(reference.state_dict())
if elementwise_affine:
weight = torch.linspace(0.5, 1.5, 8, dtype=parameter_dtype)
actual.weight.data.copy_(weight)
reference.weight.data.copy_(weight)
if bias:
norm_bias = torch.linspace(-0.25, 0.25, 8, dtype=parameter_dtype)
actual.bias.data.copy_(norm_bias)
reference.bias.data.copy_(norm_bias)
else:
assert actual.weight is None
assert actual.bias is None
hidden_states = torch.linspace(-2.0, 2.0, 48, dtype=input_dtype).reshape(2, 3, 8)
expected = reference(hidden_states)
result = actual(hidden_states)
assert result.dtype == expected.dtype
torch.testing.assert_close(result, expected, rtol=0, atol=0)
@pytest.mark.parametrize(
("num_channels", "flip_sin_to_cos", "downscale_freq_shift", "scale"),
[
(256, True, 0, 1),
(7, False, 1, 0.5),
(12, True, 0.5, 2),
],
)
@pytest.mark.parametrize("timestep_dtype", [torch.int64, torch.float32, torch.float64])
def test_sana_timesteps_matches_diffusers(
num_channels,
flip_sin_to_cos,
downscale_freq_shift,
scale,
timestep_dtype,
):
from diffusers.models.embeddings import Timesteps
from vllm_omni.diffusion.models.sana_video.transformer_sana_video import SanaTimesteps
reference = Timesteps(
num_channels=num_channels,
flip_sin_to_cos=flip_sin_to_cos,
downscale_freq_shift=downscale_freq_shift,
scale=scale,
)
actual = SanaTimesteps(
num_channels=num_channels,
flip_sin_to_cos=flip_sin_to_cos,
downscale_freq_shift=downscale_freq_shift,
scale=scale,
)
timesteps = torch.tensor([0, 1, 500, 999], dtype=timestep_dtype)
assert actual.state_dict() == reference.state_dict() == {}
assert actual.num_channels == reference.num_channels
assert actual.flip_sin_to_cos == reference.flip_sin_to_cos
assert actual.downscale_freq_shift == reference.downscale_freq_shift
assert actual.scale == reference.scale
torch.testing.assert_close(actual(timesteps), reference(timesteps), rtol=0, atol=0)
@pytest.mark.parametrize(
("kwargs", "sample_shape", "condition_shape"),
[
({}, (2, 256), None),
(
{
"out_dim": 12,
"post_act_fn": "silu",
"cond_proj_dim": 6,
"sample_proj_bias": False,
},
(2, 256),
(2, 6),
),
],
)
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16])
def test_sana_timestep_embedding_matches_diffusers(
kwargs,
sample_shape,
condition_shape,
dtype,
):
from diffusers.models.embeddings import TimestepEmbedding
from vllm_omni.diffusion.models.sana_video.transformer_sana_video import (
SanaTimestepEmbedding,
)
torch.manual_seed(17)
reference = TimestepEmbedding(256, 24, **kwargs).to(dtype=dtype)
actual = SanaTimestepEmbedding(256, 24, **kwargs).to(dtype=dtype)
assert set(actual.state_dict()) == set(reference.state_dict())
actual.load_state_dict(reference.state_dict())
sample = torch.randn(sample_shape, dtype=dtype)
condition = torch.randn(condition_shape, dtype=dtype) if condition_shape else None
expected = reference(sample, condition)
result = actual(sample, condition)
assert result.shape == expected.shape
assert result.dtype == expected.dtype
torch.testing.assert_close(result, expected, rtol=0, atol=0)
def test_sana_timestep_embedding_rejects_unsupported_activation():
from vllm_omni.diffusion.models.sana_video.transformer_sana_video import (
SanaTimestepEmbedding,
)
with pytest.raises(ValueError, match="act_fn='silu'"):
SanaTimestepEmbedding(8, 16, act_fn="gelu")
with pytest.raises(ValueError, match="post_act_fn"):
SanaTimestepEmbedding(8, 16, post_act_fn="gelu")
module = SanaTimestepEmbedding(8, 16)
with pytest.raises(ValueError, match="cond_proj_dim"):
module(torch.randn(1, 8), torch.randn(1, 4))
@pytest.mark.parametrize("use_additional_conditions", [False, True])
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16])
def test_sana_ada_layer_norm_single_matches_diffusers(
use_additional_conditions,
dtype,
):
from diffusers.models.normalization import AdaLayerNormSingle
from vllm_omni.diffusion.models.sana_video.transformer_sana_video import (
SanaAdaLayerNormSingle,
)
torch.manual_seed(19)
reference = AdaLayerNormSingle(
24,
use_additional_conditions=use_additional_conditions,
).to(dtype=dtype)
actual = SanaAdaLayerNormSingle(
24,
use_additional_conditions=use_additional_conditions,
).to(dtype=dtype)
assert set(actual.state_dict()) == set(reference.state_dict())
actual.load_state_dict(reference.state_dict())
timestep = torch.tensor([10, 500])
added_cond_kwargs = None
if use_additional_conditions:
added_cond_kwargs = {
"resolution": torch.tensor([[480, 832], [704, 1280]]),
"aspect_ratio": torch.tensor([[832 / 480], [1280 / 704]]),
}
expected = reference(
timestep,
added_cond_kwargs=added_cond_kwargs,
batch_size=2,
hidden_dtype=dtype,
)
result = actual(
timestep,
added_cond_kwargs=added_cond_kwargs,
batch_size=2,
hidden_dtype=dtype,
)
assert len(result) == len(expected) == 2
for actual_tensor, expected_tensor in zip(result, expected):
assert actual_tensor.shape == expected_tensor.shape
assert actual_tensor.dtype == expected_tensor.dtype
torch.testing.assert_close(actual_tensor, expected_tensor, rtol=0, atol=0)
@pytest.mark.parametrize("act_fn", ["gelu_tanh", "silu", "silu_fp32"])
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16])
def test_sana_pixart_text_projection_matches_diffusers(act_fn, dtype):
from diffusers.models.embeddings import PixArtAlphaTextProjection
from vllm_omni.diffusion.models.sana_video.transformer_sana_video import (
SanaPixArtAlphaTextProjection,
)
torch.manual_seed(23)
reference = PixArtAlphaTextProjection(
in_features=8,
hidden_size=24,
out_features=12,
act_fn=act_fn,
).to(dtype=dtype)
actual = SanaPixArtAlphaTextProjection(
in_features=8,
hidden_size=24,
out_features=12,
act_fn=act_fn,
).to(dtype=dtype)
assert set(actual.state_dict()) == set(reference.state_dict())
actual.load_state_dict(reference.state_dict())
caption = torch.randn(2, 5, 8, dtype=dtype)
expected = reference(caption)
result = actual(caption)
assert result.shape == expected.shape
assert result.dtype == expected.dtype
torch.testing.assert_close(result, expected, rtol=0, atol=0)
@pytest.mark.parametrize("freqs_dtype", [torch.float32, torch.float64])
@pytest.mark.parametrize(("dim", "max_seq_len", "theta"), [(4, 8, 10000.0), (12, 32, 256.0)])
def test_native_rope_matches_diffusers(dim, max_seq_len, theta, freqs_dtype):
from diffusers.models.embeddings import get_1d_rotary_pos_embed
from vllm_omni.diffusion.models.sana_video.transformer_sana_video import (
_get_1d_rotary_pos_embed,
)
expected_cos, expected_sin = get_1d_rotary_pos_embed(
dim,
max_seq_len,
theta,
use_real=True,
repeat_interleave_real=True,
freqs_dtype=freqs_dtype,
)
actual_cos, actual_sin = _get_1d_rotary_pos_embed(dim, max_seq_len, theta, freqs_dtype)
torch.testing.assert_close(actual_cos, expected_cos, rtol=0, atol=0)
torch.testing.assert_close(actual_sin, expected_sin, rtol=0, atol=0)
def test_tiny_transformer_matches_diffusers_and_frozen_output():
from diffusers import SanaVideoTransformer3DModel as DiffusersTransformer
from diffusers.configuration_utils import ConfigMixin
from diffusers.models.modeling_utils import ModelMixin
from vllm_omni.diffusion.attention.layer import Attention
from vllm_omni.diffusion.models.sana_video import SanaVideoTransformer3DModel
from vllm_omni.diffusion.models.sana_video.transformer_sana_video import (
SanaLinearAttention,
SanaRMSNorm,
SanaVideoTransformerConfig,
SanaVideoTransformerOutput,
)
torch.manual_seed(7)
reference = DiffusersTransformer(**_TINY_CONFIG).eval()
model = SanaVideoTransformer3DModel(**_TINY_CONFIG).eval()
assert not isinstance(model, ModelMixin)
assert not isinstance(model, ConfigMixin)
assert isinstance(model.config, SanaVideoTransformerConfig)
assert set(model.state_dict()) == set(reference.state_dict())
model.load_state_dict(reference.state_dict())
block = model.transformer_blocks[0]
assert isinstance(block.attn1, SanaLinearAttention)
assert isinstance(block.attn2.attn, Attention)
assert block.attn2.attn.role == "cross"
assert isinstance(block.attn1.norm_q, SanaRMSNorm)
assert isinstance(block.attn1.norm_k, SanaRMSNorm)
assert isinstance(block.attn2.norm_q, SanaRMSNorm)
assert isinstance(block.attn2.norm_k, SanaRMSNorm)
assert isinstance(model.caption_norm, SanaRMSNorm)
torch.manual_seed(11)
hidden_states = torch.randn(1, 4, 3, 4, 4)
encoder_hidden_states = torch.randn(1, 5, 8)
encoder_attention_mask = torch.tensor([[1, 1, 1, 1, 0]])
timestep = torch.tensor([500.0])
with torch.no_grad():
expected = reference(
hidden_states,
encoder_hidden_states,
timestep,
encoder_attention_mask=encoder_attention_mask,
).sample
actual_output = model(
hidden_states,
encoder_hidden_states,
timestep,
encoder_attention_mask=encoder_attention_mask,
)
actual = actual_output.sample
assert isinstance(actual_output, SanaVideoTransformerOutput)
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
torch.testing.assert_close(actual.flatten()[: len(_GOLDEN_PREFIX)], _GOLDEN_PREFIX, rtol=1e-5, atol=1e-5)
def test_encoder_attention_mask_uses_bool_sdpa_contract(monkeypatch):
from vllm_omni.diffusion.models.sana_video import SanaVideoTransformer3DModel
torch.manual_seed(37)
model = SanaVideoTransformer3DModel(**_TINY_CONFIG).eval()
captured = {}
omni_attention = model.transformer_blocks[0].attn2.attn
def fake_sdpa_forward(query, key, value, attn_metadata):
captured["query_shape"] = query.shape
captured["key_shape"] = key.shape
captured["mask"] = attn_metadata.attn_mask.detach().clone()
return torch.zeros_like(query)
def unexpected_backend_forward(*args, **kwargs):
raise AssertionError("masked SANA cross-attention must use SDPA")
monkeypatch.setattr(omni_attention.sdpa_fallback, "forward", fake_sdpa_forward)
monkeypatch.setattr(omni_attention, "forward", unexpected_backend_forward)
hidden_states = torch.randn(2, 4, 3, 4, 4)
encoder_hidden_states = torch.randn(2, 5, 8)
input_mask = torch.tensor(
[
[1, 1, 1, 0, 0],
[1, 1, 1, 1, 0],
],
dtype=torch.int64,
)
with torch.no_grad():
output = model(
hidden_states,
encoder_hidden_states,
torch.tensor([100.0, 500.0]),
encoder_attention_mask=input_mask,
).sample
assert output.shape == hidden_states.shape
assert captured["query_shape"][:2] == (2, 12)
assert captured["key_shape"][:2] == (2, 5)
assert captured["query_shape"][1] != captured["key_shape"][1]
assert captured["mask"].shape == (2, 5)
assert captured["mask"].dtype == torch.bool
assert torch.equal(captured["mask"], input_mask.bool())
def test_encoder_attention_mask_rejects_additive_bias():
from vllm_omni.diffusion.models.sana_video import SanaVideoTransformer3DModel
model = SanaVideoTransformer3DModel(**_TINY_CONFIG).eval()
additive_mask = torch.tensor([[0.0, 0.0, 0.0, -10000.0, -10000.0]])
with pytest.raises(TypeError, match="not a floating-point additive attention bias"):
model(
torch.randn(1, 4, 3, 4, 4),
torch.randn(1, 5, 8),
torch.tensor([500.0]),
encoder_attention_mask=additive_mask,
)
def test_linear_attention_requires_rotary_embeddings():
from vllm_omni.diffusion.models.sana_video.transformer_sana_video import SanaLinearAttention
attention = SanaLinearAttention(
dim=24,
num_heads=2,
head_dim=12,
dropout=0.0,
bias=True,
qk_norm="rms_norm_across_heads",
)
with pytest.raises(ValueError, match="requires rotary_emb"):
attention(torch.randn(1, 4, 24))
def test_linear_attention_bfloat16_matches_diffusers_mixed_precision():
from diffusers.models.attention_processor import Attention as DiffusersAttention
from diffusers.models.transformers.transformer_sana_video import SanaLinearAttnProcessor3_0
from vllm_omni.diffusion.models.sana_video.transformer_sana_video import SanaLinearAttention
torch.manual_seed(17)
reference = DiffusersAttention(
query_dim=24,
heads=2,
dim_head=12,
kv_heads=2,
qk_norm="rms_norm_across_heads",
dropout=0.0,
bias=True,
cross_attention_dim=None,
processor=SanaLinearAttnProcessor3_0(),
).to(dtype=torch.bfloat16)
actual = SanaLinearAttention(
dim=24,
num_heads=2,
head_dim=12,
dropout=0.0,
bias=True,
qk_norm="rms_norm_across_heads",
).to(dtype=torch.bfloat16)
actual.load_state_dict(reference.state_dict())
hidden_states = torch.randn(1, 5, 24, dtype=torch.bfloat16)
rotary_emb = (
torch.randn(1, 5, 1, 12, dtype=torch.bfloat16),
torch.randn(1, 5, 1, 12, dtype=torch.bfloat16),
)
with torch.no_grad():
expected = reference(hidden_states, rotary_emb=rotary_emb)
result = actual(hidden_states, rotary_emb=rotary_emb)
torch.testing.assert_close(result, expected, rtol=0, atol=0)
def test_transformer_block_without_cross_attention_keeps_feed_forward_norm():
from vllm_omni.diffusion.models.sana_video.transformer_sana_video import SanaVideoTransformerBlock
block = SanaVideoTransformerBlock(
dim=24,
num_attention_heads=2,
attention_head_dim=12,
num_cross_attention_heads=2,
cross_attention_head_dim=12,
cross_attention_dim=None,
mlp_ratio=2.0,
)
assert block.attn2 is None
assert isinstance(block.norm2, torch.nn.LayerNorm)
def test_guidance_transformer_matches_diffusers_and_tuple_output():
from diffusers import SanaVideoTransformer3DModel as DiffusersTransformer
from vllm_omni.diffusion.models.sana_video import SanaVideoTransformer3DModel
from vllm_omni.diffusion.models.sana_video.transformer_sana_video import (
SanaTimestepEmbedding,
SanaTimesteps,
)
config = _TINY_CONFIG | {"guidance_embeds": True}
torch.manual_seed(29)
reference = DiffusersTransformer(**config).eval()
model = SanaVideoTransformer3DModel(**config).eval()
assert set(model.state_dict()) == set(reference.state_dict())
model.load_state_dict(reference.state_dict())
assert isinstance(model.time_embed.time_proj, SanaTimesteps)
assert isinstance(model.time_embed.timestep_embedder, SanaTimestepEmbedding)
assert isinstance(model.time_embed.guidance_condition_proj, SanaTimesteps)
assert isinstance(model.time_embed.guidance_embedder, SanaTimestepEmbedding)
torch.manual_seed(31)
hidden_states = torch.randn(1, 4, 3, 4, 4)
encoder_hidden_states = torch.randn(1, 5, 8)
encoder_attention_mask = torch.tensor([[1, 1, 1, 1, 0]])
timestep = torch.tensor([500.0])
guidance = torch.tensor([6.0])
with torch.no_grad():
expected = reference(
hidden_states,
encoder_hidden_states,
timestep,
guidance=guidance,
encoder_attention_mask=encoder_attention_mask,
return_dict=False,
)
actual = model(
hidden_states,
encoder_hidden_states,
timestep,
guidance=guidance,
encoder_attention_mask=encoder_attention_mask,
return_dict=False,
)
assert isinstance(actual, tuple)
assert len(actual) == len(expected) == 1
torch.testing.assert_close(actual[0], expected[0], rtol=0, atol=0)
def test_native_transformer_config_filters_diffusers_metadata():
from vllm_omni.diffusion.models.sana_video.transformer_sana_video import SanaVideoTransformerConfig
config = SanaVideoTransformerConfig.from_dict(
_TINY_CONFIG | {"_class_name": "SanaVideoTransformer3DModel", "_diffusers_version": "0.38.0"}
)
assert config.patch_size == (1, 2, 2)
with pytest.raises(ValueError, match="unsupported_field"):
SanaVideoTransformerConfig.from_dict(_TINY_CONFIG | {"unsupported_field": True})
def test_dual_vae_variant_resolution():
from diffusers.pipelines.pipeline_utils import DiffusionPipeline
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_ltx2 import (
DistributedAutoencoderKLLTX2Video,
)
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import (
DistributedAutoencoderKLWan,
)
from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin
from vllm_omni.diffusion.models.sana_video.pipeline_sana_video import (
SanaVideoPipeline,
_resolve_vae_class_and_dtype,
)
assert not issubclass(SanaVideoPipeline, DiffusionPipeline)
assert issubclass(SanaVideoPipeline, ProgressBarMixin)
vae_class, dtype = _resolve_vae_class_and_dtype("AutoencoderKLWan", torch.bfloat16)
assert vae_class is DistributedAutoencoderKLWan
assert dtype is torch.float32
vae_class, dtype = _resolve_vae_class_and_dtype("AutoencoderKLLTX2Video", torch.bfloat16)
assert vae_class is DistributedAutoencoderKLLTX2Video
assert dtype is torch.bfloat16
with pytest.raises(ValueError, match="Unsupported SANA-Video VAE"):
_resolve_vae_class_and_dtype("UnknownVAE", torch.bfloat16)
@@ -0,0 +1,250 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import ctypes
from types import SimpleNamespace
import pytest
import torch
import vllm_omni.diffusion.offloader.cuda_host_registration as registration_module
from vllm_omni.diffusion.offloader.cuda_host_registration import (
CudaHostRegistration,
_coalesce_ranges,
_mapped_regions,
)
from vllm_omni.diffusion.offloader.host_registration import (
HostRegistrationBudgetError,
HostRegistrationCleanupError,
HostRegistrationError,
register_host_mappings,
)
from vllm_omni.host_weight_runtime import MappedHostRegion
pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model]
class _FakeRuntime:
def __init__(
self,
register_results: list[int | Exception],
unregister_results: list[int | Exception] | None = None,
*,
read_only_supported: bool = True,
attribute_result: int = 0,
) -> None:
self._register_results = iter(register_results)
self._unregister_results = iter(unregister_results or [])
self._read_only_supported = read_only_supported
self._attribute_result = attribute_result
self.attribute_queries: list[tuple[int, int]] = []
self.registered: list[tuple[int, int, int]] = []
self.unregistered: list[int] = []
def cudaDeviceGetAttribute(self, value, attribute: int, device: int) -> int:
self.attribute_queries.append((attribute, device))
ctypes.cast(value, ctypes.POINTER(ctypes.c_int)).contents.value = int(self._read_only_supported)
return self._attribute_result
def cudaHostRegister(self, pointer: int, size: int, flags: int) -> int:
self.registered.append((pointer, size, flags))
result = next(self._register_results)
if isinstance(result, Exception):
raise result
return result
def cudaHostUnregister(self, pointer: int) -> int:
self.unregistered.append(pointer)
result = next(self._unregister_results, 0)
if isinstance(result, Exception):
raise result
return result
@staticmethod
def cudaGetErrorString(error: int) -> str:
return f"error-{error}"
def _region(
file_name: str,
address: int,
length: int,
*,
read_only: bool = True,
) -> MappedHostRegion:
return MappedHostRegion(file_name=file_name, address=address, length=length, read_only=read_only)
@pytest.fixture(autouse=True)
def _cuda_runtime_prerequisites(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(registration_module.torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(registration_module.torch.accelerator, "current_device_index", lambda: 0)
def test_platform_dispatch_and_unsupported_device(monkeypatch: pytest.MonkeyPatch) -> None:
sentinel = object()
regions = (_region("weights", 0x1000, 4096),)
def create(actual_regions, *, max_bytes):
assert actual_regions is regions
assert max_bytes == 4096
return sentinel
monkeypatch.setattr(CudaHostRegistration, "create", staticmethod(create))
assert register_host_mappings(regions, device=torch.device("cuda"), max_bytes=4096) is sentinel
with pytest.raises(HostRegistrationError, match="not supported on cpu"):
register_host_mappings(regions, device=torch.device("cpu"), max_bytes=None)
def test_ranges_align_per_file_without_merging_adjacent_files() -> None:
assert _coalesce_ranges([(0x1003, 4096), (0x2800, 1024)], page_size=4096) == (
registration_module._AddressRange(0x1000, 0x3000),
)
assert _mapped_regions(
(
_region("first.safetensors", 0x1000, 4096),
_region("second.safetensors", 0x2000, 4096),
)
) == (
registration_module._AddressRange(0x1000, 0x2000),
registration_module._AddressRange(0x2000, 0x3000),
)
def test_registration_rejects_writable_or_over_budget_before_cuda(monkeypatch: pytest.MonkeyPatch) -> None:
runtime = _FakeRuntime([0])
monkeypatch.setattr(registration_module.torch.cuda, "cudart", lambda: runtime)
with pytest.raises(HostRegistrationError, match="is writable"):
CudaHostRegistration.create((_region("weights", 0x1000, 1, read_only=False),), max_bytes=None)
with pytest.raises(HostRegistrationBudgetError, match="exceeding"):
CudaHostRegistration.create((_region("weights", 0x1003, 4096),), max_bytes=4096)
assert runtime.registered == []
def test_registration_requires_read_only_capability(monkeypatch: pytest.MonkeyPatch) -> None:
runtime = _FakeRuntime([0], read_only_supported=False)
monkeypatch.setattr(registration_module.torch.cuda, "cudart", lambda: runtime)
with pytest.raises(HostRegistrationError, match="does not support read-only"):
CudaHostRegistration.create((_region("weights", 0x1000, 4096),), max_bytes=None)
assert runtime.attribute_queries == [
(registration_module._CUDA_DEVICE_ATTRIBUTE_HOST_REGISTER_READ_ONLY_SUPPORTED, 0)
]
assert runtime.registered == []
def test_capability_error_is_consumed_before_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
runtime = _FakeRuntime([0], attribute_result=7)
monkeypatch.setattr(registration_module.torch.cuda, "cudart", lambda: runtime)
consumed: list[tuple[object, int]] = []
monkeypatch.setattr(
registration_module,
"_consume_last_cuda_error",
lambda actual_runtime, error: consumed.append((actual_runtime, error)),
)
with pytest.raises(HostRegistrationError, match="error-7"):
CudaHostRegistration.create((_region("weights", 0x1000, 4096),), max_bytes=None)
assert consumed == [(runtime, 7)]
def test_partial_registration_rolls_back(monkeypatch: pytest.MonkeyPatch) -> None:
runtime = _FakeRuntime([0, 7])
monkeypatch.setattr(registration_module.torch.cuda, "cudart", lambda: runtime)
monkeypatch.setattr(registration_module, "_consume_last_cuda_error", lambda _runtime, _error: None)
with pytest.raises(HostRegistrationError, match="error-7"):
CudaHostRegistration.create(
(
_region("first", 0x1000, 4096),
_region("second", 0x9000, 4096),
),
max_bytes=None,
)
assert runtime.unregistered == [0x1000]
def test_failed_rollback_exposes_active_registration_for_retry(monkeypatch: pytest.MonkeyPatch) -> None:
runtime = _FakeRuntime([0, 7], unregister_results=[9, 0])
monkeypatch.setattr(registration_module.torch.cuda, "cudart", lambda: runtime)
monkeypatch.setattr(registration_module, "_consume_last_cuda_error", lambda _runtime, _error: None)
with pytest.raises(HostRegistrationCleanupError, match="rollback errors") as error:
CudaHostRegistration.create(
(
_region("first", 0x1000, 4096),
_region("second", 0x9000, 4096),
),
max_bytes=None,
)
registration = error.value.active_registration
assert registration is not None
assert registration.close() == ()
assert runtime.unregistered == [0x1000, 0x1000]
def test_successful_registration_retries_failed_close(monkeypatch: pytest.MonkeyPatch) -> None:
runtime = _FakeRuntime([0], unregister_results=[9, 0])
monkeypatch.setattr(registration_module.torch.cuda, "cudart", lambda: runtime)
monkeypatch.setattr(registration_module, "_consume_last_cuda_error", lambda _runtime, _error: None)
registration = CudaHostRegistration.create((_region("weights", 0x1003, 1),), max_bytes=None)
assert registration.total_bytes == 4096
assert registration.region_count == 1
assert registration.close() == ("cudaHostUnregister(0x1000) failed: error-9",)
assert registration.close() == ()
assert runtime.unregistered == [0x1000, 0x1000]
@pytest.mark.parametrize("pending_error", [0, 801])
def test_consume_last_cuda_error_accepts_cleared_or_matching_state(
monkeypatch: pytest.MonkeyPatch,
pending_error: int,
) -> None:
runtime = SimpleNamespace(cudaGetLastError=lambda: pending_error)
monkeypatch.setattr(
registration_module.ctypes,
"CDLL",
lambda _name: pytest.fail("the cudart handle should provide cudaGetLastError"),
)
registration_module._consume_last_cuda_error(runtime, 801)
def test_consume_last_cuda_error_falls_back_to_global_symbol(monkeypatch: pytest.MonkeyPatch) -> None:
global_runtime = SimpleNamespace(cudaGetLastError=lambda: 801)
monkeypatch.setattr(registration_module.ctypes, "CDLL", lambda _name: global_runtime)
registration_module._consume_last_cuda_error(SimpleNamespace(), 801)
def test_clean_rollback_remains_recoverable_when_error_state_cannot_be_cleared(
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime = _FakeRuntime([0, 7])
monkeypatch.setattr(runtime, "cudaGetLastError", None, raising=False)
monkeypatch.setattr(registration_module.torch.cuda, "cudart", lambda: runtime)
def missing_global_symbol(_name: object) -> None:
raise OSError("cudart symbols are local")
monkeypatch.setattr(
registration_module.ctypes,
"CDLL",
missing_global_symbol,
)
with pytest.raises(HostRegistrationError, match="cannot clear") as error:
CudaHostRegistration.create(
(
_region("first", 0x1000, 4096),
_region("second", 0x9000, 4096),
),
max_bytes=None,
)
assert not isinstance(error.value, HostRegistrationCleanupError)
assert runtime.unregistered == [0x1000]
@@ -1,25 +1,29 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import gc
from typing import Any
import numpy as np
import pytest
import torch
from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
from tests.helpers.env import DeviceMemoryMonitor
from tests.helpers.mark import hardware_test
from tests.helpers.monitor import DeviceMemoryMonitor
from tests.helpers.runtime import OmniRunner
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.platforms import current_omni_platform
AUDIO_MODEL = {
AUDIO_MODEL: dict[str, dict[str, int | None]] = {
"stabilityai/stable-audio-open-1.0": {"cuda": 100, "rocm": None},
}
IMAGE_MODELS = {
IMAGE_MODELS: dict[str, dict[str, int | None]] = {
"riverclouds/qwen_image_random": {"cuda": 2200, "rocm": 2100},
}
MODELS = {**AUDIO_MODEL, **IMAGE_MODELS}
MODELS: dict[str, dict[str, int | None]] = {**AUDIO_MODEL, **IMAGE_MODELS}
MODEL_MARKS = {
"riverclouds/qwen_image_random": pytest.mark.core_model,
@@ -29,12 +33,12 @@ MODEL_MARKS = {
_GATED_MODELS = {"stabilityai/stable-audio-open-1.0"}
AUDIO_MODEL_PARAMS = {
AUDIO_MODEL_PARAMS: dict[str, dict[str, Any]] = {
"runner_params": {},
"sampler_params": {},
}
IMAGE_MODEL_PARAMS = {
IMAGE_MODEL_PARAMS: dict[str, dict[str, Any]] = {
"runner_params": {},
"sampler_params": {
"height": 256,
@@ -43,7 +47,7 @@ IMAGE_MODEL_PARAMS = {
}
def inference(model_name: str, offload: bool = True):
def inference(model_name: str, offload: bool = True) -> tuple[float, Any]:
gc.collect()
current_omni_platform.empty_cache()
device_index = current_omni_platform.current_device()
@@ -133,6 +137,7 @@ def test_cpu_offload_diffusion_model(model_name: str):
threshold = MODELS[model_name][platform]
if threshold is None:
pytest.skip(f"Threshold not defined for {platform} on {model_name}")
assert threshold is not None
assert offload_peak_memory + threshold < no_offload_peak_memory, (
f"Offload peak memory {offload_peak_memory} MB should be less than "
@@ -1,36 +1,41 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from typing import Any
import numpy as np
import pytest
import torch
from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
from tests.helpers.env import DeviceMemoryMonitor
from tests.helpers.mark import hardware_test
from tests.helpers.monitor import DeviceMemoryMonitor
from tests.helpers.runtime import OmniRunner
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.platforms import current_omni_platform
AUDIO_MODEL = {
AUDIO_MODEL: dict[str, dict[str, int | None]] = {
"stabilityai/stable-audio-open-1.0": {"cuda": 1500, "rocm": 1500},
}
IMAGE_VIDEO_MODELS = {
IMAGE_VIDEO_MODELS: dict[str, dict[str, int | None]] = {
"riverclouds/qwen_image_random": {"cuda": 4500, "rocm": None},
# "Wan-AI/Wan2.2-T2V-A14B-Diffusers": {"cuda": 45000, "rocm": None},
}
MODELS = {**AUDIO_MODEL, **IMAGE_VIDEO_MODELS}
MODELS: dict[str, dict[str, int | None]] = {**AUDIO_MODEL, **IMAGE_VIDEO_MODELS}
MODEL_MARKS = {
"riverclouds/qwen_image_random": pytest.mark.core_model,
"stabilityai/stable-audio-open-1.0": pytest.mark.full_model,
}
AUDIO_MODEL_PARAMS = {
AUDIO_MODEL_PARAMS: dict[str, dict[str, Any]] = {
"runner_params": {},
"sampler_params": {},
}
IMAGE_VIDEO_MODELS_PARAMS = {
IMAGE_VIDEO_MODELS_PARAMS: dict[str, dict[str, Any]] = {
"runner_params": {"boundary_ratio": 0.875, "flow_shift": 5.0},
"sampler_params": {"height": 480, "width": 640, "num_frames": 5},
}
@@ -55,7 +60,7 @@ def run_inference(
model_name: str,
layerwise_offload: bool = False,
num_inference_steps: int = 3,
) -> float:
) -> tuple[float, Any]:
current_omni_platform.empty_cache()
device_index = current_omni_platform.current_device()
monitor = DeviceMemoryMonitor(device_index=device_index, interval=0.02)
@@ -140,6 +145,7 @@ def test_layerwise_offload_diffusion_model(model_name: str):
if expected_saved_memory is None:
pytest.skip(f"Threshold not defined for {platform} on {model_name}")
assert expected_saved_memory is not None
# Verify that layerwise offloading significantly reduces memory usage
# Passes only if the actual savings exceeds the expected savings
@@ -5,6 +5,7 @@
import gc
import json
import weakref
from contextlib import contextmanager
from types import SimpleNamespace
@@ -17,6 +18,7 @@ from torch.distributed.tensor import DeviceMesh, DTensor, Replicate
import vllm_omni.diffusion.offloader.distributed_layerwise_backend as dist_backend_module
from tests.helpers.runtime import get_distributed_init_method
from vllm_omni.diffusion.data import validate_dlo_host_registration_options
from vllm_omni.diffusion.model_loader.host_weight_plan import (
HostWeightPlan,
TensorBinding,
@@ -33,11 +35,14 @@ from vllm_omni.diffusion.offloader.distributed_layerwise_backend import (
DistributedLayerwiseOffloadHook,
PinnedResidentLayerGroup,
)
from vllm_omni.diffusion.offloader.host_registration import HostRegistrationCleanupError
from vllm_omni.diffusion.offloader.module_residency import PinnedModuleStager
from vllm_omni.diffusion.offloader.offload_plan import (
OffloadPlan,
get_offload_plan,
)
from vllm_omni.diffusion.offloader.startup import OffloadStartupState, attach_offload_startup_state
from vllm_omni.host_weight_runtime import MappedHostRegion
from vllm_omni.platforms import current_omni_platform
pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model]
@@ -695,6 +700,268 @@ class _SingleBlockModel(nn.Module):
self.blocks = nn.ModuleList([_DummyBlock() for _ in range(num_blocks)])
class _HWRPipeline(nn.Module):
def __init__(self):
super().__init__()
self.transformer = _SingleBlockModel(num_blocks=3)
class _FakeHostWeightLease:
def __init__(self, resolution_id: str, events: list[str] | None = None):
self.closed = False
self.provenance = SimpleNamespace(resolution_id=resolution_id)
self.mapped_regions = (MappedHostRegion("weights.safetensors", 0x1000, 4096),)
self._events = events
def close(self):
if self._events is not None:
self._events.append("lease")
self.closed = True
class _FakeLeaseCarrier:
def __init__(self, lease: _FakeHostWeightLease):
self.lease = lease
self.taken = False
@property
def closed(self):
return self.taken or self.lease.closed
def take(self):
if self.taken:
raise RuntimeError("already taken")
self.taken = True
return self.lease
def close(self):
if not self.taken:
self.lease.close()
def _fake_hwr_plan(resolution_id: str = "hwr-test"):
lease = _FakeHostWeightLease(resolution_id)
carrier = _FakeLeaseCarrier(lease)
plan = HostWeightPlan(
backing_kind="host_weight_runtime",
bindings={},
lease_carrier=carrier,
)
return plan, carrier, lease
def _hwr_backend():
plan, carrier, lease = _fake_hwr_plan()
backend = DistributedLayerwiseOffloadBackend(
OffloadConfig(
strategy=OffloadStrategy.DISTRIBUTED_LAYER_WISE,
pin_cpu_memory=False,
dp_size=1,
dlo_use_allgather=False,
),
torch.device("cpu"),
host_weight_plan=plan,
)
return _HWRPipeline(), backend, carrier, lease
def test_registered_mmap_hook_bypasses_host_staging(patched_offload_runtime):
current_block = nn.Linear(2, 2, bias=False)
next_block = nn.Linear(2, 2, bias=False)
expected = torch.arange(4, dtype=torch.float32).view(2, 2)
next_block.weight.data.copy_(expected)
hook = DistributedLayerwiseOffloadHook(
next_block=next_block,
device=torch.device("cpu"),
dp_group=None,
dp_size=1,
rank=0,
pin_memory=False,
rank_local_mmap=True,
)
hook.initialize_hook(current_block)
hook.registered_mmap = True
hook._stage_mmap_sources = lambda _slot: pytest.fail("registered mmap must bypass host staging") # type: ignore[method-assign]
hook.prefetch_layer(slot=0, non_blocking=True)
assert torch.equal(next_block.weight, expected)
def test_registered_mmap_resident_group_bypasses_host_staging(patched_offload_runtime):
block = nn.Linear(2, 2, bias=False)
expected = torch.arange(4, dtype=torch.float32).view(2, 2)
block.weight.data.copy_(expected)
group = PinnedResidentLayerGroup(
[block],
device=torch.device("cpu"),
copy_stream=DummyStream(),
pin_memory=False,
rank_local_mmap=True,
)
group.registered_mmap = True
group._cpu_staging_buffers.clear()
group.load()
assert torch.equal(block.weight, expected)
def test_hwr_registration_uses_transport_budget(monkeypatch: pytest.MonkeyPatch):
dist_backend_module._ACTIVE_HWR_REGISTRATIONS.clear()
plan, _, lease = _fake_hwr_plan()
backend = DistributedLayerwiseOffloadBackend(
OffloadConfig(
strategy=OffloadStrategy.DISTRIBUTED_LAYER_WISE,
pin_cpu_memory=True,
dp_size=1,
dlo_use_allgather=False,
dlo_host_registration_limit_gib=1.5,
),
torch.device("cuda"),
host_weight_plan=plan,
)
backend._host_weight_lease = lease # type: ignore[assignment]
calls: list[tuple[object, int | None]] = []
class Registration:
total_bytes = 4096
region_count = 1
@staticmethod
def close() -> tuple[str, ...]:
return ()
def register(regions, *, device, max_bytes):
assert device == torch.device("cuda")
calls.append((regions, max_bytes))
return Registration()
monkeypatch.setattr(dist_backend_module, "register_host_mappings", register)
pinned_source = SimpleNamespace(numel=lambda: 1, is_pinned=lambda: True)
assert backend._try_register_hwr_mmap((pinned_source,)) # type: ignore[arg-type]
assert calls == [(lease.mapped_regions, int(1.5 * 1024**3))]
assert dist_backend_module._ACTIVE_HWR_REGISTRATIONS == [(backend._host_registration, lease)]
backend._release_registered_mmap()
assert dist_backend_module._ACTIVE_HWR_REGISTRATIONS == []
def test_hwr_registration_budget_validation_is_transport_scoped():
assert (
validate_dlo_host_registration_options(
limit_gib=1.5,
enable_dlo=True,
use_allgather=False,
hwr_mode="preferred",
)
== 1.5
)
invalid = (
dict(limit_gib=-1, enable_dlo=True, use_allgather=False, hwr_mode="preferred"),
dict(limit_gib=float("inf"), enable_dlo=True, use_allgather=False, hwr_mode="preferred"),
dict(limit_gib=1, enable_dlo=False, use_allgather=False, hwr_mode="preferred"),
dict(limit_gib=1, enable_dlo=True, use_allgather=True, hwr_mode="preferred"),
dict(limit_gib=1, enable_dlo=True, use_allgather=False, hwr_mode="disabled"),
)
for options in invalid:
with pytest.raises(ValueError):
validate_dlo_host_registration_options(**options)
def test_unregistration_precedes_lease_close_and_retries_failures(
monkeypatch: pytest.MonkeyPatch,
patched_offload_runtime,
):
dist_backend_module._ACTIVE_HWR_REGISTRATIONS.clear()
events: list[str] = []
responses = iter([("busy",), ()])
class Registration:
@staticmethod
def close() -> tuple[str, ...]:
events.append("unregister")
return next(responses)
backend = DistributedLayerwiseOffloadBackend(
OffloadConfig(
strategy=OffloadStrategy.DISTRIBUTED_LAYER_WISE,
pin_cpu_memory=True,
dp_size=1,
dlo_use_allgather=False,
),
torch.device("cpu"),
)
lease = _FakeHostWeightLease("retry", events)
backend._host_weight_lease = lease # type: ignore[assignment]
backend._host_registration = Registration()
backend._using_rank_local_mmap = True
backend._using_registered_mmap = True
backend.enabled = True
monkeypatch.setattr(current_omni_platform, "synchronize", lambda: None)
with pytest.raises(HostRegistrationCleanupError, match="failed to unregister"):
backend.disable()
assert not lease.closed
assert backend._host_registration is not None
assert dist_backend_module._ACTIVE_HWR_REGISTRATIONS == [(backend._host_registration, lease)]
backend.disable()
assert lease.closed
assert backend._host_registration is None
assert dist_backend_module._ACTIVE_HWR_REGISTRATIONS == []
assert events == ["unregister", "unregister", "lease"]
def test_failed_unregistration_retains_lease_after_backend_is_dropped(
monkeypatch: pytest.MonkeyPatch,
patched_offload_runtime,
):
events: list[str] = []
class Registration:
@staticmethod
def close() -> tuple[str, ...]:
events.append("unregister")
return ("busy",)
dist_backend_module._ACTIVE_HWR_REGISTRATIONS.clear()
backend = DistributedLayerwiseOffloadBackend(
OffloadConfig(
strategy=OffloadStrategy.DISTRIBUTED_LAYER_WISE,
pin_cpu_memory=True,
dp_size=1,
dlo_use_allgather=False,
),
torch.device("cpu"),
)
lease = _FakeHostWeightLease("retained", events)
registration = Registration()
backend._host_weight_lease = lease # type: ignore[assignment]
backend._host_registration = registration
backend._using_rank_local_mmap = True
backend.enabled = True
monkeypatch.setattr(current_omni_platform, "synchronize", lambda: None)
lease_ref = weakref.ref(lease)
with pytest.raises(HostRegistrationCleanupError, match="failed to unregister"):
backend.disable()
del backend
del lease
gc.collect()
retained_lease = dist_backend_module._ACTIVE_HWR_REGISTRATIONS[0][1]
assert dist_backend_module._ACTIVE_HWR_REGISTRATIONS == [(registration, retained_lease)]
assert lease_ref() is retained_lease
assert not retained_lease.closed
dist_backend_module._ACTIVE_HWR_REGISTRATIONS.clear()
retained_lease.close()
class _MultiBlockModel(nn.Module):
_layerwise_offload_blocks_attrs = ["transformer_blocks", "single_transformer_blocks"]
@@ -838,6 +1105,47 @@ class TestMmapWeightLoading:
assert all(hook.rank_local_mmap for group in backend._all_hook_groups for hook in group)
backend.disable()
def test_hwr_carrier_is_taken_and_released_after_bounded_staging(self, patched_offload_runtime):
"""A warm HWR plan uses the existing two-slot rank-local transport."""
pipeline, backend, carrier, lease = _hwr_backend()
model_bytes = sum(param.numel() * param.element_size() for param in pipeline.transformer.parameters())
backend.enable(pipeline)
assert carrier.taken
assert backend._using_rank_local_mmap
assert all(len(hook.cpu_staging_buffers) == 2 for group in backend._all_hook_groups for hook in group)
staging_bytes = sum(
buffer.numel() * buffer.element_size()
for buffer in backend._all_hook_groups[0][0].cpu_staging_buffers[0].values()
)
assert staging_bytes * 2 < model_bytes
assert not lease.closed
backend.disable()
assert lease.closed
def test_hwr_backend_failure_drains_partial_setup_before_lease_close(
self,
monkeypatch,
patched_offload_runtime,
):
pipeline, backend, carrier, lease = _hwr_backend()
monkeypatch.setattr(
backend,
"_allocate_shared_buffers",
lambda hooks: (_ for _ in ()).throw(RuntimeError("device buffer allocation failed")),
)
with pytest.raises(RuntimeError, match="device buffer allocation failed"):
backend.enable(pipeline)
assert carrier.taken
assert lease.closed
assert not backend.enabled
assert not backend._blocks
assert not backend._all_hook_groups
class TestGetBlocksFromDit:
def test_get_blocks_from_dit_single_block_attr(self):
@@ -1504,6 +1812,48 @@ class TestMmapValidation:
class TestConfigValidation:
"""Tests for configuration validation in OffloadConfig / execute_request."""
@pytest.mark.parametrize(
("dp", "sp", "tp", "use_allgather", "expected_dlo_group"),
[
(1, 1, 1, True, 1),
(2, 1, 1, True, 2),
(1, 4, 1, True, 4),
(2, 2, 2, True, 2),
(2, 2, 2, False, 1),
(1, 4, 2, False, 1),
],
)
def test_dlo_group_selection_covers_dp_sp_tp_matrix(
self,
dp,
sp,
tp,
use_allgather,
expected_dlo_group,
):
"""DLO sharding follows DP, falls back to SP, and never uses TP."""
config = OffloadConfig.from_od_config(
SimpleNamespace(
enable_cpu_offload=False,
enable_layerwise_offload=False,
enable_distributed_layerwise_offload=True,
dlo_use_allgather=use_allgather,
dlo_resident_layers=0,
pin_cpu_memory=False,
parallel_config=SimpleNamespace(
data_parallel_size=dp,
use_hsdp=False,
hsdp_shard_size=-1,
hsdp_replicate_size=1,
sequence_parallel_size=sp,
tensor_parallel_size=tp,
),
)
)
assert config.dp_size == expected_dlo_group
assert config.dlo_use_allgather is use_allgather
def test_loader_plan_cannot_be_silently_dropped_on_unsupported_platform(self, monkeypatch):
import vllm_omni.diffusion.offloader as offloader_module
@@ -1538,6 +1888,53 @@ class TestConfigValidation:
host_weight_plan=plan,
)
def test_startup_recovery_stays_inside_offloader_boundary(self, monkeypatch):
import vllm_omni.diffusion.offloader as offloader_module
class FailingBackend:
def __init__(self):
self.disabled = False
def enable(self, pipeline):
del pipeline
raise RuntimeError("initial prefetch failed")
def disable(self):
self.disabled = True
warm_pipeline = nn.Module()
canonical_pipeline = nn.Module()
plan, carrier, _lease = _fake_hwr_plan("hwr-recovery")
failing_backend = FailingBackend()
calls = []
def fake_backend(od_config, device, host_weight_plan):
del od_config, device
calls.append(host_weight_plan)
return failing_backend if host_weight_plan is plan else None
attach_offload_startup_state(
warm_pipeline,
OffloadStartupState(
host_weight_plan=plan,
fresh_model_loader=lambda: canonical_pipeline,
allow_fresh_retry=True,
),
)
monkeypatch.setattr(offloader_module, "get_offload_backend", fake_backend)
recovered, backend = offloader_module.enable_offload_backend(
SimpleNamespace(),
warm_pipeline,
device=torch.device("cpu"),
)
assert recovered is canonical_pipeline
assert backend is None
assert failing_backend.disabled
assert carrier.closed
assert calls == [plan, None]
def test_hsdp_with_allgather_rejected(self):
"""HSDP + DLO + AllGather should raise ValueError (double sharding)."""
from vllm_omni.diffusion.offloader.base import OffloadConfig
@@ -0,0 +1,230 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from contextlib import contextmanager
import pytest
import torch
from torch import nn
import vllm_omni.diffusion.offloader.module_residency as residency_module
from vllm_omni.diffusion.offloader.module_residency import (
BoundedAllocatorCache,
PinnedModuleStager,
)
pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model]
class _DummyStream:
def wait_stream(self, _stream) -> None:
return None
def wait_event(self, _event) -> None:
return None
class _DummyEvent:
def __init__(self) -> None:
self.record_count = 0
def record(self, _stream) -> None:
self.record_count += 1
@contextmanager
def _dummy_stream(_stream):
yield
@pytest.fixture
def patched_runtime(monkeypatch, mocker):
empty_cache = mocker.Mock()
synchronize = mocker.Mock()
monkeypatch.setattr(residency_module.current_omni_platform, "Stream", _DummyStream)
monkeypatch.setattr(residency_module.current_omni_platform, "Event", _DummyEvent)
monkeypatch.setattr(residency_module.current_omni_platform, "current_stream", _DummyStream)
monkeypatch.setattr(residency_module.current_omni_platform, "stream", _dummy_stream)
monkeypatch.setattr(residency_module.current_omni_platform, "empty_cache", empty_cache)
monkeypatch.setattr(residency_module.current_omni_platform, "synchronize", synchronize)
return empty_cache, synchronize
def _aliased_modules() -> tuple[nn.Module, nn.Module]:
parameter_storage = torch.arange(12, dtype=torch.float32)
buffer_storage = torch.arange(8, dtype=torch.int64)
left = nn.Module()
right = nn.Module()
left.weight = nn.Parameter(parameter_storage[:8].view(2, 4))
right.weight = nn.Parameter(parameter_storage[4:].view(2, 4))
left.register_buffer("state", buffer_storage[:6])
right.register_buffer("state", buffer_storage[2:])
return left, right
def test_module_group_preserves_parameter_and_buffer_storage_aliases(patched_runtime, mocker):
left, right = _aliased_modules()
expected_left_weight = left.weight.detach().clone()
expected_right_weight = right.weight.detach().clone()
expected_left_state = left.state.clone()
expected_right_state = right.state.clone()
retention = mocker.Mock(spec=BoundedAllocatorCache)
stager = PinnedModuleStager(
[left, right],
torch.device("cpu"),
pin_memory=False,
cache_retention=retention,
)
master_weight_ptr = left.weight.untyped_storage().data_ptr()
master_state_ptr = left.state.untyped_storage().data_ptr()
assert right.weight.untyped_storage().data_ptr() == master_weight_ptr
assert right.state.untyped_storage().data_ptr() == master_state_ptr
assert (left.weight.storage_offset(), right.weight.storage_offset()) == (0, 4)
assert (left.state.storage_offset(), right.state.storage_offset()) == (0, 2)
stager.load()
assert left.weight.untyped_storage().data_ptr() != master_weight_ptr
assert right.weight.untyped_storage().data_ptr() == left.weight.untyped_storage().data_ptr()
assert right.state.untyped_storage().data_ptr() == left.state.untyped_storage().data_ptr()
left.weight.data[1, 0] = -1
left.state[2] = -1
assert right.weight[0, 0].item() == -1
assert right.state[0].item() == -1
stager.offload()
assert left.weight.untyped_storage().data_ptr() == master_weight_ptr
assert right.weight.untyped_storage().data_ptr() == master_weight_ptr
assert left.state.untyped_storage().data_ptr() == master_state_ptr
assert right.state.untyped_storage().data_ptr() == master_state_ptr
assert torch.equal(left.weight, expected_left_weight)
assert torch.equal(right.weight, expected_right_weight)
assert torch.equal(left.state, expected_left_state)
assert torch.equal(right.state, expected_right_state)
retention.release_if_needed.assert_called_once_with(force=False)
def test_staging_transitions_are_idempotent_and_reuse_one_event(patched_runtime, mocker):
module = nn.Linear(2, 2)
retention = mocker.Mock(spec=BoundedAllocatorCache)
stager = PinnedModuleStager(
module,
torch.device("cpu"),
pin_memory=False,
cache_retention=retention,
)
ready_event = stager._ready_event
stager.load()
first_storage = module.weight.untyped_storage().data_ptr()
stager.load()
assert module.weight.untyped_storage().data_ptr() == first_storage
assert stager._ready_event is ready_event
assert ready_event.record_count == 1
stager.offload()
stager.offload()
assert not stager.loaded
retention.release_if_needed.assert_called_once_with(force=False)
def test_failed_load_restores_master_and_forces_cache_release(monkeypatch, patched_runtime, mocker):
module = nn.Linear(2, 2)
expected = module.weight.detach().clone()
retention = mocker.Mock(spec=BoundedAllocatorCache)
stager = PinnedModuleStager(
module,
torch.device("cpu"),
pin_memory=False,
cache_retention=retention,
)
master_ptr = module.weight.untyped_storage().data_ptr()
def fail_after_rebind() -> None:
device_storages = [torch.empty_like(group.master) for group in stager._groups]
stager._bind(device_storages)
module.weight.data.zero_()
raise RuntimeError("copy failed")
monkeypatch.setattr(stager, "_load_once", fail_after_rebind)
with pytest.raises(RuntimeError, match="copy failed"):
stager.load()
assert not stager.loaded
assert module.weight.untyped_storage().data_ptr() == master_ptr
assert torch.equal(module.weight, expected)
retention.release_if_needed.assert_called_once_with(force=True)
def test_out_of_memory_releases_cache_and_retries_once(monkeypatch, patched_runtime, mocker):
module = nn.Linear(2, 2)
retention = mocker.Mock(spec=BoundedAllocatorCache)
stager = PinnedModuleStager(
module,
torch.device("cpu"),
pin_memory=False,
cache_retention=retention,
)
load_once = stager._load_once
attempts = 0
def fail_once() -> None:
nonlocal attempts
attempts += 1
if attempts == 1:
raise torch.OutOfMemoryError("memory pressure")
load_once()
monkeypatch.setattr(stager, "_load_once", fail_once)
stager.load()
assert stager.loaded
assert attempts == 2
retention.release_if_needed.assert_called_once_with(force=True)
@pytest.mark.parametrize(
("reserved", "allocated", "free", "expected_release"),
[
(20, 10, 80, False),
(40, 10, 80, True),
(20, 10, 4, True),
],
)
def test_allocator_cache_policy_enforces_cache_and_free_memory_bounds(
monkeypatch,
patched_runtime,
reserved,
allocated,
free,
expected_release,
):
empty_cache, _ = patched_runtime
monkeypatch.setattr(torch.accelerator, "memory_reserved", lambda _device: reserved)
monkeypatch.setattr(torch.accelerator, "memory_allocated", lambda _device: allocated)
monkeypatch.setattr(
residency_module.current_omni_platform,
"get_device_memory",
lambda _device: (free, 100),
)
retention = BoundedAllocatorCache(torch.device("cpu"))
released = retention.release_if_needed()
assert released is expected_release
assert empty_cache.call_count == int(expected_release)
def test_allocator_cache_releases_when_telemetry_is_unavailable(monkeypatch, patched_runtime):
empty_cache, _ = patched_runtime
def unavailable(_device):
raise NotImplementedError
monkeypatch.setattr(torch.accelerator, "memory_reserved", unavailable)
retention = BoundedAllocatorCache(torch.device("cpu"))
assert retention.release_if_needed()
empty_cache.assert_called_once_with()
@@ -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 tests for GGUF quantization on diffusion models.
Validates that GGUF-quantized diffusion models generate valid images and
@@ -19,8 +19,8 @@ import numpy as np
import pytest
import torch
from tests.helpers.env import DeviceMemoryMonitor
from tests.helpers.mark import hardware_test
from tests.helpers.monitor import DeviceMemoryMonitor
from tests.helpers.runtime import OmniRunner
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
Loaded 100 of 373 files, more files were not shown because too many files have changed in this diff. Show more