Compare commits

...
Author SHA1 Message Date
Samit d9bb1c9158 [Doc] Add Qwen-Image-2.1 recipe to recipes index (#8070)
Signed-off-by: SamitHuang <285365963@qq.com>
2026-09-23 17:01:47 +08:00
Isotr0py 44ea27c809 fix pre-commit
Signed-off-by: Isotr0py <Isotr0py@outlook.com>
2026-09-23 06:22:17 +00:00
Isotr0py 0c82bb1315 Gate persistent DiT staging on decode-graph models
ModelLevelOffloadBackend passed persistent_dit_staging=(device is CUDA) for
every model, so Qwen-Image 2.0/FLUX/WAN-style sequential offload kept DiT
device storage alive across swaps instead of freeing VRAM. Only retain
staging when a DiT actually captures weight pointers (enable_cuda_graph_decode);
other models keep plain move semantics. Add tests pinning both behaviors.
2026-09-22 08:58:32 +00:00
Isotr0py be8a72f0a6 Stack torch.compile with CUDA graph decode instead of choosing one
Regionally compile the DiT blocks even when enable_cuda_graph_decode is set
and let graph capture record the compiled (fused) kernels; inductor's own
cudagraphs stay off so the two graph layers never nest. Measured on GB200
(1024x1024, 50 steps, seed 42, BF16): combo 3.04s vs compile-only 3.2s,
graph-only 4.0s, eager 4.3s per image; 44.5-45.6 dB PSNR vs eager.
2026-09-22 08:58:32 +00:00
Isotr0py 45aa5b16d9 Rename internal temperal_upsample to temporal_upsample in Qwen-Image-2.1 VAE
Match the Qwen-Image 2.0 split: decoder kwargs/attrs use the correct
temporal_upsample spelling while the checkpoint-facing temperal_downsample
config key is kept as-is. Fixes pre-commit typos rewriting the name.
2026-09-22 08:58:31 +00:00
Isotr0py 8e0695f362 Merge branch 'main' into qwen-image-2.1
Signed-off-by: Isotr0py <Isotr0py@outlook.com>
2026-09-22 10:11:12 +08:00
Hoseung KimandHoseung Kim d4570afabf [Frontend] Reject built-in name collisions in voice upload and add VLLM_OMNI_SPEAKER_REGISTRATION_POLICY (#6848)
Signed-off-by: Hoseung Kim <hoseung.kim@navercorp.com>
Co-authored-by: Hoseung Kim <hoseung.kim@navercorp.com>
2026-09-21 18:35:07 -07:00
085cf0ff34 [Bugfix] Catch asyncio.TimeoutError so duplex serving starts on Python 3.10 (#7412)
Signed-off-by: Tianyao Wu <rayroy31@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alicia <115451386+congw729@users.noreply.github.com>
2026-09-21 18:34:58 -07:00
Nick CaoandCodex 1d3dc8bf83 [Misc] Refactor static stage metadata lookups (#7602)
Signed-off-by: Nick Cao <ncao@redhat.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-21 18:34:50 -07:00
andyluo7 1586c41ae4 [CI][ROCm] Shorten the blocking CosyVoice test path (#7748)
Signed-off-by: andyluo7 <andy.luo@amd.com>
2026-09-21 18:34:40 -07:00
Armaan Amatya cb31e0774f [Misc] Deprecate VLLM_OMNI_VOXCPM_CODE_PATH (#7779)
Signed-off-by: Armaan Amatya <armaanamatya2014@gmail.com>
2026-09-21 18:34:31 -07:00
0z5aand0z5a 8bc0c38b90 [Bugfix] Add opt-in MiniCPM-o 4.5 Stage-0 sliding window (#7631)
Signed-off-by: 0z5a <dezhen.lu@student.uni-tuebingen.de>
Signed-off-by: 0z5a <dezhen.lu@uni-tuebingen.de>
Co-authored-by: 0z5a <dezhen.lu@uni-tuebingen.de>
2026-09-21 18:34:22 -07:00
shihongzhi c11596c637 [Model] MammothModa2: enable Cache-DiT acceleration for the DiT stage (#7291)
Signed-off-by: shihongzhi <shi65881583@gmail.com>
2026-09-21 18:34:12 -07:00
Isotr0py c05a3527f5 ooops
Signed-off-by: Isotr0py <Isotr0py@outlook.com>
2026-09-22 01:26:57 +00:00
Isotr0py 3e274a0024 Merge remote-tracking branch 'origin/main' into qwen-image-2.1
# Conflicts:
#	tests/entrypoints/openai_api/test_image_server.py
#	vllm_omni/entrypoints/openai/api_server.py
2026-09-22 01:23:45 +00:00
jingchengtian 0b5f8832b9 [tts][feature][NPU] Add 2-NPU and 3-NPU disaggregated deploy profiles for MOSS-TTS-Local (#7052)
Signed-off-by: jingchengtian <tjc1995@126.com>
2026-09-21 23:14:52 +00:00
Sun 381b12fa87 [Bugfix] Keep --no-guardrails out of diffusion stage config (#7917)
Signed-off-by: levius <2114377220@qq.com>
2026-09-21 19:18:30 +00:00
Sy03 473da2cf82 [Perf][Qwen3-TTS] Enable event-driven orchestration by default (#7088)
Signed-off-by: Sy03 <1370724210@qq.com>
2026-09-21 12:09:19 -07:00
Sy03 60a933b9ab [Model] Default Qwen3-TTS to experimental Model Runner V2 (#7930)
Signed-off-by: Sy03 <1370724210@qq.com>
2026-09-21 18:56:06 +00:00
vOv d8d162d810 [Core][Diffusion] Add pause_generation(mode="keep") for diffusion stages (#7685)
Signed-off-by: cr-gao <gaochenrui@sjtu.edu.cn>
2026-09-22 01:31:22 +08:00
NumberWan 62b15142bd [Model] Integrate AURA into Full-Duplex Runtime (#7633) 2026-09-21 09:26:54 -07:00
wuli666 d397a2f547 [Bugfix] Refresh duplex client defaults after session updates (#7784)
Signed-off-by: wuli666 <421774554@qq.com>
2026-09-21 16:24:42 +00:00
wangyu 279b1b9687 [Bugfix] Isolate weekly CPU tests from inherited attention env and hf_api mocks (#7565)
Signed-off-by: wangyu <410167048@qq.com>
Signed-off-by: [Your Name] <your.email@example.com>
Signed-off-by: [Your Name] [Your Email]
2026-09-21 15:55:00 +00:00
0z5a 2c2cffd2aa [Bugfix] Preserve NextStep rectangular latent dimensions (#7853)
Signed-off-by: 0z5a <dezhen.lu@student.uni-tuebingen.de>
2026-09-21 08:26:14 -07:00
akshatvishu e01655f4a9 [BugFix] Fix diffusion TTS voice error responses (#7798)
Signed-off-by: akshatvishu <akshatnayak197@gmail.com>
2026-09-21 15:09:32 +00:00
wangyu ef644b6112 [CI/Build] Promote LingBot-Video dense smoke to ready/merge and wire L4 expansion (#7892)
Signed-off-by: wangyu <410167048@qq.com>
2026-09-21 07:45:32 -07:00
WeiQing Chen 44ea368843 [Doc] Update vLLM-Omni WeChat QR code (#7911)
Signed-off-by: David Chen <530634352@qq.com>
2026-09-21 14:18:28 +00:00
Isotr0py 3d6a1c4d9a Address review: examples policy, SPDX headers, recipe docs, test coverage
- Register QwenImage21Pipeline in model_extras _EXTRA_SPECS with an
  image-to-image prompt builder that forwards height/width
- Rename run_qwen_image_21_edit.sh to run_qwen_image_21.sh
- Fix SPDX-FileCopyrightText headers
- Recipe: document mixed-KV-phase admission, add head-vs-diffusers A/B
  comparison section (measurements pending)
- Add test_registry.py and test_pipeline.py (preprocess, prepare_latents
  ordering, mixed-phase _assemble_kv_cache, post-process); extend
  test_batch_compatibility.py
2026-09-21 08:36:42 +00:00
Isotr0py d3cf26c1b1 Wire enable_cuda_graph_decode through engine args, stage config and CLI
Follows the vae_use_tiling path: OmniDiffusionConfig field, OmniEngineArgs,
StageDeployConfig, _DiffusionConfigProjection and --[no-]enable-cuda-graph-decode.
Documents interplay with enforce_eager and cudagraph_mode.
2026-09-21 08:36:34 +00:00
Isotr0py a04b95bf6b Address review: tuple image input, NPU index_select rope, per-key decode graph ownership
- Normalize tuple image inputs in pre_process_func (empty tuple -> T2I path)
- Use torch.index_select in QwenImage21Rope for NPU compatibility (#6320 style)
- Refuse concurrent aliasing of shared decode-graph prefix KV entries: track a
  single live owner per key, non-owners fall back to eager decode
2026-09-21 08:36:28 +00:00
Isotr0py 9174554e40 Merge remote-tracking branch 'origin/main' into qwen-image-2.1
# Conflicts:
#	vllm_omni/diffusion/executor/multiproc_executor.py
#	vllm_omni/diffusion/offloader/sequential_backend.py
2026-09-21 07:32:04 +00:00
Tianyu Guo 3ea9e60501 Fix diffusion API initialization (#7868)
Signed-off-by: Tianyu Guo <guoty9@mail2.sysu.edu.cn>
2026-09-20 12:08:50 +08:00
Tianyu Guo 9dabaf8b80 Default Qwen-Image 2.1 CFG scale to 1.0 (#7859)
Signed-off-by: Tianyu Guo <guoty9@mail2.sysu.edu.cn>
2026-09-20 11:51:35 +08:00
Isotr0py 5b540f55d3 Fix decode cuda graph for Qwen-Image-2.1 model-level cpu offload (#7801) 2026-09-19 00:10:53 +08:00
Isotr0py 47e6acc1e3 Add FP8 KV cache support for Qwen-Image-2.1 DiT (#7797) 2026-09-19 00:06:58 +08:00
Isotr0py 2a92b71cb9 Add FP8 online qunatization support for Qwen-Image-2.1 (#7792)
Signed-off-by: Isotr0py <mozf@inferact.ai>
2026-09-18 22:33:16 +08:00
Tianyu Guo ab4cf24b3e Optimize Qwen-Image 2.1 attention and decode graph reuse (#7790)
Signed-off-by: Tianyu Guo <guoty9@mail2.sysu.edu.cn>
2026-09-18 19:37:19 +08:00
Tianyu Guo f930348e79 Preserve Qwen-Image 2.1 condition latent order (#7766)
Signed-off-by: Tianyu Guo <guoty9@mail2.sysu.edu.cn>
2026-09-18 18:07:45 +08:00
Tianyu Guo b0a309e5e7 Isolate Qwen-Image 2.1 prefill and decode scheduling (#7770)
Signed-off-by: Tianyu Guo <guoty9@mail2.sysu.edu.cn>
2026-09-18 14:11:22 +08:00
Tianyu GuoandIsotr0py a26013145c Align Qwen-Image 2.1 preprocessing, CFG defaults and prefix cache (#7769)
Signed-off-by: Tianyu Guo <guoty9@mail2.sysu.edu.cn>
Signed-off-by: Isotr0py <mozf@mail2.sysu.edu.cn>
Co-authored-by: Isotr0py <mozf@mail2.sysu.edu.cn>
2026-09-18 13:53:15 +08:00
Tianyu Guo 936c0cc7fe Align Qwen-Image-2.1 VAE normalization with Diffusers (#7768)
Signed-off-by: Tianyu Guo <guoty9@mail2.sysu.edu.cn>
2026-09-18 13:51:06 +08:00
Tianyu Guo 93d7a06e7f Use pre-norm text features for Qwen-Image 2.1 (#7765)
Signed-off-by: Tianyu Guo <guoty9@mail2.sysu.edu.cn>
2026-09-18 13:50:36 +08:00
Tianyu Guo eb54a3186b Separate Qwen-Image 2.1 batches by condition image layout (#7764)
Signed-off-by: Tianyu Guo <guoty9@mail2.sysu.edu.cn>
2026-09-18 13:47:35 +08:00
Isotr0py 3affc7766d Enable VAE tiling for Qwen-Image-2.1 (#5)
* init

Signed-off-by: Isotr0py <mozf@inferact.ai>

* fix

Signed-off-by: Isotr0py <mozf@inferact.ai>

* test(diffusion): add Qwen-Image-2.1 unit tests

Signed-off-by: Isotr0py <mozf@inferact.ai>

* fix(diffusion): Qwen-Image-2.1 VAE spatial ratio, tiling e2e verification

- AutoencoderKLQwenImage21: derive spatial_compression_ratio from the
  architecture (2 ** len(temperal_downsample) = 16x) instead of trusting
  the checkpoint's scale_factor_spatial=8, which corrupted tiled
  decode/encode geometry (output cropped to half size) and tiling gating.
- pipeline: _read_vae_scale_factor prefers the architecture-derived value
  for the same reason.
- models/qwen_image_21/__init__: lazy exports to break the circular import
  distributed VAE -> package __init__ -> pipeline -> distributed VAE.
- Add VAE unit tests: ratio derivation, tiled vs untiled decode/encode
  consistency, distributed tile_split/exec/merge == tiled_decode.
- e2e matrix (GB200, seed 42, 20 steps) all green; see e2e_out/qwen21_vae/.
- examples: qwen_image_21_edit.py gains --vae-use-tiling.
- docs: mark VAE patch-parallel verified for Qwen-Image-2.1; recipe notes
  the default-tile banding limitation at 2048px+.

* clean tests

Signed-off-by: Isotr0py <mozf@inferact.ai>

* fix cudnn determination

Signed-off-by: Isotr0py <mozf@inferact.ai>

---------

Signed-off-by: Isotr0py <mozf@inferact.ai>
2026-09-18 10:02:50 +08:00
Isotr0py 4fc3cf8c60 Enable CUDA Graph for DiT decode layers (#4)
* init

Signed-off-by: Isotr0py <mozf@inferact.ai>

* fix

Signed-off-by: Isotr0py <mozf@inferact.ai>

* test(diffusion): add Qwen-Image-2.1 unit tests

Signed-off-by: Isotr0py <mozf@inferact.ai>

* feat(qwen_image_21): opt-in CUDA graph capture for KV-cache decode steps

Decode steps (1..N) recompute only the target image's tokens at a fixed
shape, but the per-request dict KV cache made addresses unstable, so the
step could not be captured. Prefill now copies prefix K/V into persistent
fixed-address buffers (keyed by branch/batch/prefix_len/target_tokens/
dtype/device/backend) and re-points the caller's cache dicts at them;
decode replays a graph captured per key over the full 32-block forward.
Eager fallback (logged) for non-CUDA, SP/ring/TP>1, HSDP, compiled or
offload-hooked blocks, KV-cache quantization, dynamic LoRA wrappers,
padded text masks, unknown keys, and capture failure.

Off by default; opt in via OmniDiffusionConfig.enable_cuda_graph_decode
or the QwenImage21Transformer2DModel constructor kwarg.

E2E (GB200, T2I 1024x1024, 50 steps, CFG 4.0, CUDNN_ATTN):
171.2 -> 142.1 ms/step, 8.70s -> 7.24s total (1.20x), 44.9 dB PSNR
vs eager output.

* clean

Signed-off-by: Isotr0py <mozf@inferact.ai>

---------

Signed-off-by: Isotr0py <mozf@inferact.ai>
2026-09-18 10:02:39 +08:00
Isotr0py 2912672b2e Initialize QMI pipeline implementation (#2)
* init

Signed-off-by: Isotr0py <mozf@inferact.ai>

* fix

Signed-off-by: Isotr0py <mozf@inferact.ai>

---------

Signed-off-by: Isotr0py <mozf@inferact.ai>
2026-09-16 15:14:23 +08:00
230 changed files with 19597 additions and 988 deletions

No files matched your search

+18
View File
@@ -64,3 +64,21 @@ steps:
commands:
- export VLLM_ROCM_USE_AITER=1
- pytest -s -v tests/e2e/online_serving/test_qwen3_omni_expansion.py::test_text_to_text_audio_001 -m "full_model and rocm and MI325 and cards_2" --run-level "full_model"
- group: ":speaker: CosyVoice3 MI300 Nightly"
steps:
# Exercise all sync/async and Chinese/English cases with the platform's
# default attention backend. On ROCm this retains end-to-end AITER
# coverage without putting its cold JIT build on the READY critical path.
- label: "CosyVoice3-TTS E2E Test"
agent_pool: mi300_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: NonBlocking
timeout_in_minutes: 90
retry:
automatic:
- exit_status: 134
limit: 1
commands:
- pytest -s -v tests/e2e/online_serving/test_cosyvoice3_tts_expansion.py -m "slow" --run-level "core_model"
+6 -2
View File
@@ -328,7 +328,10 @@ steps:
- export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
- timeout 30m pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py -m "core_model" --run-level "core_model"
- label: "CosyVoice3-TTS E2E Test"
# Keep a fast model/server integration check in the blocking lane. The
# full default-backend suite remains in the nightly pipeline because a
# cold ROCm AITER MHA build can add more than 25 minutes to this job.
- label: "CosyVoice3-TTS E2E Smoke (SDPA)"
agent_pool: mi300_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
@@ -340,7 +343,8 @@ steps:
- exit_status: 134
limit: 1
commands:
- pytest -s -v tests/e2e/online_serving/test_cosyvoice3_tts_expansion.py -m "slow" --run-level "core_model"
- export DIFFUSION_ATTENTION_BACKEND=TORCH_SDPA
- pytest -s -v tests/e2e/online_serving/test_cosyvoice3_tts_expansion.py::test_voice_clone_zh_002 -m "slow" --run-level "core_model"
# TODO: Bagel test on ROCm is very unstable. @tjtanaa
# Need to debug before reneable numerical changes across large PRs
@@ -255,6 +255,8 @@ source_file_dependencies:
- *minicpmo_4_5_duplex
- tests/e2e/online_serving/test_minicpmo_4_5_duplex.py
- tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py
- tests/e2e/online_serving/test_minicpmo_4_5_window.py
- tests/e2e/online_serving/helpers/minicpmo_window_e2e.py
- tests/e2e/online_serving/test_duplex_client_live.py
- tests/e2e/online_serving/test_minicpmo_realtime_duplex_drivers.py
- tests/e2e/online_serving/helpers/minicpmo_realtime_duplex_scenarios.py
@@ -453,6 +455,7 @@ source_file_dependencies:
- *lingbot_video
- *lingbot_world
- tests/e2e/online_serving/test_lingbot_video.py
- tests/e2e/online_serving/test_lingbot_video_expansion.py
- tests/e2e/online_serving/test_lingbot_video_moe.py
- tests/e2e/offline_inference/test_lingbot_world_v2.py
+23 -1
View File
@@ -130,7 +130,19 @@ steps:
source_file_dependencies: omni_minicpmo_4_5_duplex_function
timeout_in_minutes: 50
commands:
- pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5_duplex.py -m 'advanced_model and cuda' --run-level 'advanced_model'
- pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5_duplex.py tests/e2e/online_serving/test_minicpmo_4_5_window.py -m 'advanced_model and cuda' --run-level 'advanced_model'
mirror_hardwares: h100_1
- label: "Diffusion · Qwen Image 2.1 Test"
source_file_dependencies:
- tests/e2e/online_serving/test_qwen_image_21.py
- tests/helpers/
- vllm_omni/diffusion/models/qwen_image_21/
- vllm_omni/diffusion/distributed/autoencoders/
- vllm_omni/diffusion/sched/
- vllm_omni/entrypoints/openai/
commands:
- timeout 30m pytest -s -v tests/e2e/online_serving/test_qwen_image_21.py -m 'advanced_model and cuda' --run-level 'advanced_model'
mirror_hardwares: h100_1
- label: "Diffusion · Qwen Image Test"
@@ -163,6 +175,16 @@ steps:
"
mirror_hardwares: h100_1
- label: "Diffusion · LingBot-Video Test"
source_file_dependencies: diffusion_lingbot_function
commands:
- |
timeout 40m bash -c "
export VLLM_IMAGE_FETCH_TIMEOUT=60
pytest -s -v tests/e2e/online_serving/test_lingbot_video.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
"
mirror_hardwares: h100_1
- label: "Diffusion · MiniMax-H3 L3 DLO DP2 and FastH3"
source_file_dependencies: diffusion_minimax_h3_function
# Keep four server lifecycles in one Buildkite job, but never in one
+1 -1
View File
@@ -349,7 +349,7 @@ steps:
commands:
- >-
pytest -s -v
tests/e2e/online_serving/test_lingbot_video.py
tests/e2e/online_serving/test_lingbot_video_expansion.py
tests/e2e/online_serving/test_lingbot_video_moe.py
tests/e2e/offline_inference/test_lingbot_world_v2.py
-m "full_model and diffusion and H100 and B200 and cards_1"
+22
View File
@@ -229,6 +229,18 @@ steps:
"
mirror_hardwares: h100_2
- label: "Diffusion · Qwen Image 2.1 Test"
source_file_dependencies:
- tests/e2e/online_serving/test_qwen_image_21.py
- tests/helpers/
- vllm_omni/diffusion/models/qwen_image_21/
- vllm_omni/diffusion/distributed/autoencoders/
- vllm_omni/diffusion/sched/
- vllm_omni/entrypoints/openai/
commands:
- timeout 30m pytest -s -v tests/e2e/online_serving/test_qwen_image_21.py -m 'core_model and cuda' --run-level 'core_model'
mirror_hardwares: h100_1
- label: "Diffusion · Qwen Image Test"
source_file_dependencies: diffusion_qwen_image_function
commands:
@@ -248,3 +260,13 @@ steps:
pytest -s -v tests/e2e/online_serving/test_hunyuan_video_15_expansion.py -m 'core_model and diffusion' --run-level 'core_model'
"
mirror_hardwares: h100_1
- label: "Diffusion · LingBot-Video Test"
source_file_dependencies: diffusion_lingbot_function
commands:
- |
timeout 40m bash -c "
export VLLM_IMAGE_FETCH_TIMEOUT=60
pytest -s -v tests/e2e/online_serving/test_lingbot_video.py -m 'core_model and cuda' --run-level 'core_model'
"
mirror_hardwares: h100_1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 244 KiB

+2 -1
View File
@@ -85,9 +85,10 @@ depends on the installed kernels and model path.
| `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_ABORT_TIMEOUT` | Float seconds; default `2` | Engine abort wait for Videos DELETE and `generate()` cancel/error cleanup; read when `async_omni` imports | Environment-only setting. A non-float raises `ValueError` during import. | Experimental |
| `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_EVENT_DRIVEN_ORCH` | `1`, `true`, `yes` or `on` enables; default `0` (off) | Orchestration loop and the serving-side final-output drain; read once when the `Orchestrator` is constructed | Environment-only setting. Values are stripped and case-normalized; any unrecognized value leaves the legacy poll loop selected. | Experimental |
| `VLLM_OMNI_EVENT_DRIVEN_ORCH` | `1`, `true`, `yes` or `on` enables; defaults to on for Qwen3-TTS and off for other pipelines | Pipeline default computed from `pipeline_config.model_type` at engine initialization; env override resolved at `Orchestrator` construction and separately when the serving-side final-output drain starts | An explicit env value wins; otherwise both consumers use the engine's pipeline default. Values are stripped and case-normalized; any unrecognized value selects the legacy poll loop. Set before server startup. | 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_SPEAKER_REGISTRATION_POLICY` | `overwrite` or `immutable`; default `overwrite` | Speech server; read when speaker storage initializes | Environment-only setting. `immutable` rejects re-registering an existing uploaded voice name until it is deleted; any other value raises `ValueError` at startup. | Experimental |
| `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 |
+11 -5
View File
@@ -293,19 +293,25 @@ vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 --deploy-config /path/to/depl
## Qwen3-TTS with Model Runner V2
Qwen3-TTS can opt into the native CUDA Model Runner V2 pipeline on vLLM
0.29.0. Select one of these deployment profiles:
Qwen3-TTS runs the native CUDA Model Runner V2 pipeline by default on vLLM
0.29.0. MRV2 is an experimental feature for this model: the bundled default
profile selects it on CUDA only, and its scheduler and delivery paths are
still being qualified. Set `model_runner: v1` in a copy of the deploy config
to opt out; the `platforms:` sections of `qwen3_tts.yaml` keep V1 on NPU, XPU,
ROCm and MUSA. Select one of these deployment profiles:
| Profile | Runner | Code2Wav graph batches | Intended use |
| --- | --- | --- | --- |
| `qwen3_tts.yaml` | V1 | Existing defaults | Existing deployment / regression control |
| `qwen3_tts_mrv2.yaml` | V2 | B1 | Native runner validation |
| `qwen3_tts.yaml` | V2 (default) | Existing defaults | Shipped default; experimental |
| `qwen3_tts_mrv2.yaml` | V2 | B1 | Explicit MRV2 profile (same runner selection as the default) |
| `qwen3_tts_high_concurrency_mrv2.yaml` | V2 | B1, B2 | Opt-in throughput tuning |
| `qwen3_tts_high_concurrency_mrv2_b4.yaml` | V2 | B1, B2, B3, B4 | Experimental throughput / buffered playback |
| `qwen3_tts_high_concurrency.yaml` | V1 | Existing defaults | V1 high-concurrency control |
```bash
# MRV2 is the default; pass a copy with `model_runner: v1` to force V1.
vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-Base --omni \
--deploy-config vllm_omni/deploy/qwen3_tts_mrv2.yaml
--deploy-config /path/to/qwen3_tts_v1.yaml
```
The V2 profiles bound Talker prefill to 512 tokens per step and select the
+55 -3
View File
@@ -22,7 +22,7 @@ profiler_config:
Supported fields:
| Field | Description |
|---|---|
| --- | --- |
| `profiler` | Profiler backend. Supported values: `torch`, `cuda`. Use `torch` for `trace.json`, Excel operator tables, and optional memory snapshots. Use `cuda` for Nsight Systems only. |
| `torch_profiler_dir` | Output directory for torch-profiler artifacts. Required when `profiler: torch`. |
| `torch_profiler_use_gzip` | Compress `trace_rank*.json` into `trace_rank*.json.gz`. |
@@ -287,6 +287,52 @@ Recommended viewers:
For upstream background on the underlying vLLM profiling infrastructure, see the [vLLM profiling guide](https://docs.vllm.ai/en/stable/contributing/profiling/).
## 6. Profiling Omni AR/TTS stages
For Omni AR and TTS stages, put `profiler_config` under each stage that should
be captured. The serving API then controls a narrow window with
`POST /start_profile` and `POST /stop_profile`; model initialization and
warmup stay outside the trace.
Example stage override for a host-stack torch trace:
```bash
PROFILE='{"profiler":"torch","torch_profiler_dir":"/tmp/qwen3_tts_profile",'\
'"torch_profiler_with_stack":true,"torch_profiler_record_shapes":false,'\
'"torch_profiler_use_gzip":true,"torch_profiler_dump_cuda_time_total":true}'
vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice --omni \
--deploy-config vllm_omni/deploy/qwen3_tts_high_concurrency.yaml \
--stage-overrides "{\"0\":{\"profiler_config\":$PROFILE},\"1\":{\"profiler_config\":$PROFILE}}"
```
After one or more warmup requests, capture one short measured request:
```bash
curl -sS -X POST http://127.0.0.1:8000/start_profile \
-H 'Content-Type: application/json' -d '{"stages":[0,1]}'
# send one fixed /v1/audio/speech or /v1/chat/completions request here
curl -sS -X POST http://127.0.0.1:8000/stop_profile \
-H 'Content-Type: application/json' -d '{"stages":[0,1]}'
```
For an Nsight Systems CUDA timeline, use the same stage override with
`{"profiler":"cuda"}` and wrap the server:
```bash
nsys profile --trace=cuda,nvtx,osrt --sample=process-tree \
--cpuctxsw=process-tree --trace-fork-before-exec=true \
--capture-range=cudaProfilerApi --capture-range-end=stop-shutdown \
--cuda-graph-trace=node -o /tmp/qwen3_tts_nsys \
vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice --omni ...
```
For Omni AR workers, `profiler="cuda"` requires the native CUDA profiler
wrapper and uses `cudaProfilerStart/Stop`; the report is only valid if the
`/start_profile` and `/stop_profile` calls both succeed. Use `nsys stats` for
CUDA API, GPU kernel, OS runtime, and NVTX summaries. Treat profiler latency as
diagnostic only; use a separate no-profiler run for performance numbers.
## 7. Orchestrator Monitor
> **Warning:** Diagnostic only. Adds lightweight counters on the orchestrator poll loop and per-replica queue depth. Disable in production unless you are actively debugging orchestrator saturation.
@@ -314,9 +360,11 @@ If unset, the monitor writes `./vllm_omni_orch_monitor_<MMDDHHMM>.json` at shutd
Each 1-second window records:
| Series | Description |
|---|---|
| --- | --- |
| `windows.duration_s` | Wall time covered by the window |
| `windows.loop_idle` / `windows.loop_active` | Orchestrator poll-loop iterations with no work vs. active forwarding |
| `windows.dispatch_queue_size` | Ready-queue size sampled at each monitor window boundary |
| `windows.dispatch_queue_high_water` | Maximum Event Driven ready-queue size observed in each window |
| `replicas.<stage,replica>.outputs_queue_size` | MP client `outputs_queue` backlog for that replica |
| `replicas.<stage,replica>.inflight` | Requests currently bound/routed to the replica |
@@ -331,12 +379,16 @@ orchestrator wakes only once per 0.5 s reconcile timeout, while a busy one still
records one iteration per routed output. Window counts and the `loop_active_pct`
summary are therefore not comparable across the two modes.
For Event Driven runs, use `dispatch_queue_high_water` rather than only the
window-boundary `dispatch_queue_size`: a serial dispatcher can drain a burst
before the window closes, hiding a transient ready-queue backlog in the latter.
### Relationship to other diagnostics
This monitor is intentionally separate from the existing profiling tools:
| Tool | Scope | Output |
|---|---|---|
| --- | --- | --- |
| `--enable-diffusion-pipeline-profiler` | Diffusion stage functions (`vae.decode`, `diffuse`, …) | Per-request `stage_durations` / logs |
| `--enable-ar-profiler` | AR stage generation time | Per-request `stage_durations` |
| `profiler_config` (`torch` / `cuda`) | GPU/CPU kernels inside a stage worker | `trace.json`, Nsight, operator tables |
+42 -6
View File
@@ -180,17 +180,52 @@ cache_dit.enable_cache(
> **Note:** For single transformer with multiple block lists, `refresh_context` works the same as standard models.
### Example 3: Sequential-CFG Diffusion Pipeline (MammothModa2)
MammothModa2's DiT stage runs on the diffusion runner, which enables the
configured backend at startup and transfers ownership to the pipeline through
the request-scoped protocol (`adopt_cache_dit_backend` / `is_cache_dit_enabled`);
`forward()` then reconciles per-request state through
`RequestScopedCacheDiTRuntime` (MiniMax H3 precedent).
**Key difference:** The enabler caches only the repeated main-layer stack and
marks the pipeline as requiring paired CFG forwards, because cache-dit's
separate-CFG accounting distinguishes cond/uncond passes purely by forward
parity:
```python
pipeline._cache_dit_requires_paired_cfg = True
block_adapter = BlockAdapter(
transformer=pipeline.gen_transformer,
blocks=[pipeline.gen_transformer.layers], # Refiners stay outside
forward_pattern=[ForwardPattern.Pattern_3],
has_separate_cfg=True,
check_forward_pattern=True,
)
```
Per request, the pipeline calls `runtime.prepare(spec)` for CFG requests
(refreshing the step context) and `runtime.prepare(None)` for `guidance=1.0`
requests (disabling hooks — their single forward per step cannot be expressed
by the parity accounting).
### Registering Custom Implementations
After writing your custom enabler, register it in `CUSTOM_DIT_ENABLERS` in
After writing your custom enabler, register it in
`CUSTOM_DIT_ENABLERS` via `register_custom_dit_enablers()` in
`vllm_omni/diffusion/cache/cachedit/model_specific.py`:
```python
CUSTOM_DIT_ENABLERS = {
"Wan22Pipeline": enable_cache_for_wan22,
"LongCatImagePipeline": enable_cache_for_longcat_image,
"YourCustomPipeline": enable_cache_for_your_model, # Add here
}
CUSTOM_DIT_ENABLERS.update(
{
"Wan22Pipeline": enable_cache_for_wan22,
"Cosmos3OmniDiffusersPipeline": enable_cache_for_cosmos3,
"Krea2Pipeline": enable_cache_for_krea2,
"Magi2Pipeline": enable_cache_for_magi2,
"MammothModa2DiTPipeline": enable_cache_for_mammothmoda2,
"YourCustomPipeline": enable_cache_for_your_model, # Add here
}
)
```
---
@@ -274,6 +309,7 @@ Complete examples in the codebase:
| **Wan2.2** | `cachedit.model_specific::enable_cache_for_wan22` | Single or dual-transformer | Auto-detects mode based on transformer_2 presence |
| **LongCat** | `cachedit.config::CacheDiTAdapterConfig` | Declarative block adapter | Two block lists in one transformer |
| **BAGEL** | `cachedit.model_specific::BagelCachedAdapter` | Custom cached adapter | Complex architecture |
| **MammothModa2** | `cachedit.model_specific::enable_cache_for_mammothmoda2` | Sub-stack + paired CFG | Generation-runner pipeline; lifecycle owned by `pipeline_mammothmoda2_dit.py` |
---
+1 -1
View File
@@ -79,7 +79,7 @@ the boundary affected by the in-flight stage client/process refactor in
responsibilities proposed only by that PR are not current contracts.
The orchestration loop also has an opt-in event-driven mode
(`VLLM_OMNI_EVENT_DRIVEN_ORCH=1`, default off) proposed in
(`VLLM_OMNI_EVENT_DRIVEN_ORCH=1`, default off except for Qwen3-TTS) proposed in
[#5221](https://github.com/vllm-project/vllm-omni/pull/5221). It changes poll
cadence only: the routing, ordering, and terminal-state contracts below hold
identically on both loops.
+63 -17
View File
@@ -9,9 +9,11 @@ This feature is inherited from [vLLM’s Sleep Mode](https://blog.vllm.ai/2025/1
## 1. Feature Documentation
### Overview
Omni Sleep Mode provides a mechanism to "sleep" specific model stages. When a stage enters sleep, its physical VRAM is reclaimed by the system, while the process state is preserved for rapid "wake-up" without full re-initialization.
### Sleep Levels
We support two levels of hibernation to balance recovery speed and memory efficiency:
| Level | Name | Mechanism | Recovery Speed | Memory Freed |
@@ -29,15 +31,67 @@ Omni Sleep Mode is optimized for high-performance computing backends:
* **Huawei NPU**: Supported via Ascend memory scavenging.
### Hardware Requirements
* **Memory Considerations**: System RAM must be sufficient to hold offloaded weights during sleep.
* **TP Support**: Tensor Parallel groups synchronize sleep/wake transitions across all workers.
---
### Quiescing a busy diffusion stage: `pause_generation(mode="keep")`
`sleep()` on a diffusion stage is a worker-level RPC: it offloads memory but does not stop the
stage's scheduler, so a stage that still has queued requests must be made quiet first. Aborting
discards the batch that is running. `pause_generation(mode="keep")` keeps it instead:
* The batch that is already running finishes on the weights it started with and its outputs are
delivered normally.
* Requests that have not started stay queued and run after `resume_generation()`, on whatever
weights are loaded by then. A request never continues denoising across a weight change.
* The call returns only after every replica has acknowledged: the batch that was running has
finished executing, its asynchronous output copies are done, and the device is synchronized; its
outputs are then delivered on the normal path. On a non-preemptible backend that acknowledgement
arrives at the next batch boundary, so expect it to take as long as the remaining batch.
* Control RPCs (`list_loras`, `sleep`, `wake_up`, `resume_generation`) keep working while paused.
Recommended order for a weight update on a diffusion stage:
```python
await engine.pause_generation(mode="keep", clear_cache=False)
await engine.sleep(level=1) # optional: free memory for the trainer
await engine.wake_up() # admission stays closed until resume
# install new weights here
await engine.resume_generation()
```
Support matrix and limits:
| Configuration | `mode="keep"` |
| :--- | :--- |
| Request-level execution (`step_execution=False`) with the in-process (`uni`) executor | Supported |
| Request-level execution with the multi-process (`mp`) executor, including asynchronous output | Supported; the acknowledgement waits for the output copies |
| Request-level execution with asynchronous KV prefetch | Supported; the acknowledgement joins any prefetch still in flight |
| Step-level execution (`step_execution=True` / streaming output) | Not supported: the call raises `NotImplementedError` |
| `mode="abort"` / `mode="wait"` / `wait_for_inflight_requests=True` on a diffusion stage | Unchanged: frontend admission is paused only |
Behavior notes:
* Callers that already passed `mode="keep"` for a diffusion stage used to get an immediate return
that only closed frontend admission. They now also stop the backend and wait for the ACK.
* A timeout or error from `pause_generation` means the pause did **not** complete; the scheduler
stays closed and the caller must retry the pause or call `resume_generation()` explicitly. Do not
sleep or mutate weights after a failed pause.
* `resume_generation(stage_ids=...)` that leaves another paused stage closed (an AR stage, or a
keep-paused diffusion stage) keeps frontend admission closed as well, so new requests cannot
queue on a stage that will not schedule them. Admission reopens once every stage paused by
`pause_generation` has been resumed.
* `clear_cache` has no diffusion-specific effect.
* Without a pause call nothing changes: no barrier runs and batching, abort and output ordering are
as before.
---
## 2. Usage Examples
### Python API Example
You can programmatically control the lifecycle of stages using the `AsyncOmni` engine.
```python
@@ -65,6 +119,7 @@ if __name__ == "__main__":
```
### server command Example
Start the server with sleep mode enabled:
The first method
@@ -92,9 +147,6 @@ python3 -m vllm_omni.entrypoints.openai.api_server \
```
### Test Scenarios & Commands
#### Scenario 1: LLM Engine Sleep
@@ -113,9 +165,8 @@ curl -X POST http://localhost:8000/v1/omni/sleep \
Tip: Open a new terminal and run rocm-smi or nvidia-smi or to observe the immediate drop in VRAM usage.
#### Scenario 2: Diffusion Sleep
Objective: Verify VRAM reclamation for Stage 1 (Diffusion).
Trigger sleep (Level 1 or Level 2) via client:
@@ -128,9 +179,8 @@ curl -X POST http://localhost:8000/v1/omni/sleep \
```
#### Scenario 3: Multi-Stage Coordinated Stress Test
Objective: Test concurrent sleep and rapid wake-up across multiple stages.
Concurrent Sleep (Stage 0 & 1):
@@ -143,7 +193,6 @@ curl -X POST http://localhost:8000/v1/omni/sleep \
```
Rapid Wake-up:
```
@@ -154,8 +203,8 @@ curl -X POST http://localhost:8000/v1/omni/wakeup \
```
#### Scenario 4: Full Lifecycle Memory Audit & Functional Integrity
Objective: Audit the complete flow from Sleep to Wake-up followed by an Inference validation.
Check Initial State: Observe baseline VRAM usage.
@@ -196,20 +245,16 @@ curl -X POST http://localhost:8000/v1/images/generations \
```
## 3. API Reference
### Methods
| Method | Arguments | Return Type | Description |
| :--- | :--- | :--- | :--- |
| **sleep** | `stage_ids: List[int], level: int` | `List[OmniACK]` | Triggers hibernation for specified stages. |
| **wake_up** | `stage_ids: List[int]` | `List[OmniACK]` | Reloads weights and re-maps memory. |
| **pause_generation** | `mode: str, stage_ids: List[int]` | `None` | Stops admission; with `mode="keep"` also stops diffusion schedulers and returns after their ACK. |
| **resume_generation** | `stage_ids: List[int]` | `None` | Reopens paused schedulers, then admission. |
### OmniACK Dataclass Fields
@@ -233,7 +278,8 @@ The metadata field is a dynamic dictionary containing hardware-specific telemetr
}
```
#### Core Utility:
#### Core Utility
**VRAM Reclamation Audit (total_freed_gib)**: Converts raw freed_bytes into human-readable GiB. It serves as the primary metric to verify that Level 2 sleep has successfully purged model weights from VRAM.
**Residual & Fragmentation Monitoring (rank_residual_gib)**: Reports the remaining VRAM footprint after memory de-mapping. A low residual value (e.g., 2.07 GiB) confirms a successful "clean" state, ensuring the device is ready for high-memory co-located tasks like training or diffusion pipelines.
+1
View File
@@ -32,6 +32,7 @@ th {
| `QwenImageEditPlusPipeline` | Qwen-Image-Edit-2509 | `Qwen/Qwen-Image-Edit-2509` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `QwenImageLayeredPipeline` | Qwen-Image-Layered | `Qwen/Qwen-Image-Layered` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `QwenImageEditPlusPipeline` | Qwen-Image-Edit-2511 | `Qwen/Qwen-Image-Edit-2511` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `QwenImage21Pipeline` | Qwen-Image-2.1 (T2I + image-conditioned generation, up to 4 condition images) | `Qwen/Qwen-Image-2.1` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/Qwen/Qwen-Image-2.1.md) |
| `GlmImagePipeline` | GLM-Image | `zai-org/GLM-Image` | ✅︎ | ✅︎ | | | — |
| `ZImagePipeline` | Z-Image | `Tongyi-MAI/Z-Image-Turbo` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `Krea2Pipeline` | Krea 2 (Raw + Turbo) | `krea/Krea-2-Raw`, `krea/Krea-2-Turbo` | ✅︎ | | | ✅︎ | — |
+26 -9
View File
@@ -289,6 +289,19 @@ Upload a new voice sample for voice cloning in Base task TTS requests.
Fields `ref_text` and `speaker_description` are omitted when not provided at upload time.
**Naming rules:**
- Names that collide with one of the model's built-in or precomputed voices are rejected (400). Voice
files already on disk under such a name are ignored at startup with a warning, so an upload can never
shadow a built-in voice.
- Re-uploading an existing uploaded name overwrites it in place (the previous audio file is
deleted). Set `VLLM_OMNI_SPEAKER_REGISTRATION_POLICY=immutable` on the server to reject duplicates instead,
requiring an explicit `DELETE /v1/audio/voices/{name}` before re-registering — useful when the
endpoint is reachable by multiple writers and silent overwrites are a risk.
- The voice registry has no per-user ownership: any client that can reach the endpoint can
overwrite (default policy) or delete any uploaded voice. For multi-tenant deployments, add
authentication at a proxy and namespace voice names per user (e.g. `{user}.{name}`).
**Usage Example:**
```bash
@@ -577,6 +590,7 @@ by `GET /v1/audio/voices`. Valid precomputed voices can be used in
| ---------- | --------- | ------------- |
| `SPEAKER_SAMPLES_DIR` | `~/.cache/vllm-omni/speakers` | Directory for persisted uploaded speakers (`.safetensors` files). |
| `SPEAKER_MAX_UPLOADED` | `1000` | Maximum number of uploaded speakers kept on disk. Upload requests past the cap return 400. |
| `VLLM_OMNI_SPEAKER_REGISTRATION_POLICY` | `overwrite` | `immutable` rejects re-uploading an existing uploaded name (400) until it is deleted; any other value fails startup. |
The in-memory LRU has a fixed 512 MiB byte budget.
@@ -957,22 +971,24 @@ Use `/v1/audio/voices` to list available voices for the loaded model.
## Orchestration Loop (experimental)
Multi-stage omni deployments route stage outputs through a single orchestrator
loop. By default that loop polls every stage replica on a 1 ms cadence. An
opt-in event-driven mode replaces the poll with one reader task per live stage
loop. The legacy loop polls every stage replica on a 1 ms cadence. The
event-driven mode replaces the poll with one reader task per live stage
replica awaiting its client directly, and switches the serving-side
final-output drain to a condition-variable wakeup at the same time.
final-output drain to a condition-variable wakeup at the same time. Qwen3-TTS
uses this mode by default; other pipelines keep the legacy poll unless enabled.
**Configuration (environment variables):**
| Variable | Default | Description |
| --- | --- | --- |
| `VLLM_OMNI_EVENT_DRIVEN_ORCH` | `0` (off) | Switches the orchestration loop and the final-output drain from the legacy 1 ms poll to event-driven wakeups. Enabled by `1`, `true`, `yes`, or `on`, matched case-insensitively after surrounding whitespace is stripped; any other value leaves it off. |
| `VLLM_OMNI_EVENT_DRIVEN_ORCH` | On for Qwen3-TTS; off for other pipelines | Switches the orchestration loop and the final-output drain from the legacy 1 ms poll to event-driven wakeups. An explicit value wins; otherwise the pipeline default computed at engine initialization is used. The override is resolved at orchestrator construction and separately when the final-output drain starts. `1`, `true`, `yes`, or `on` enables it, ignoring case and surrounding whitespace; other values select the legacy poll loop. |
Set it on the process that runs the orchestrator (stage 0 of an omni
deployment) before starting the server:
Set it on the process that runs the orchestrator (stage 0 of an omni deployment)
before starting the server when overriding the pipeline default. For example,
explicitly disable event-driven orchestration for Qwen3-TTS:
```bash
export VLLM_OMNI_EVENT_DRIVEN_ORCH=1
VLLM_OMNI_EVENT_DRIVEN_ORCH=0 \
vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-Base \
--omni \
--port 8091
@@ -982,8 +998,9 @@ The server logs the selected loop mode and its reader/poller counts once at
startup, so you can confirm which loop is live.
Routing, output ordering, and terminal-state behavior are identical on both
loops; only the poll cadence changes. Leaving the variable unset keeps the
legacy poll loop, which is the supported default.
loops; only the poll cadence changes. Leaving the variable unset selects the
pipeline default: event-driven for Qwen3-TTS, and legacy polling for other
pipelines, including Qwen3-Omni.
**Known limitations:**
@@ -72,6 +72,39 @@ omni = Omni(
## Example Script
### Multi-Stage Models (MammothModa2)
MammothModa2 is a multi-stage AR→DiT pipeline; the diffusion runner owns the
Cache-DiT lifecycle of its DiT stage. Configure it on the **DiT stage entry**
of the deploy YAML so the acceleration only applies to the denoising stage:
```yaml
# deploy YAML (see vllm_omni/deploy/mammoth_moda2.yaml)
stages:
- stage_id: 1
# ... other DiT stage settings ...
cache_backend: cache_dit
cache_config:
Fn_compute_blocks: 1
Bn_compute_blocks: 0
max_warmup_steps: 4
residual_diff_threshold: 0.24
max_continuous_cached_steps: 3
enable_cache_dit_summary: true # log skip-ratio stats per request
```
Behavior notes:
- Only the repeated main-layer stack is cached; the Q-Former refiners always run.
- Sequential-CFG parity requires the unconditional pass on every denoise step,
so the `cfg_range` skip optimization is not used: outside the interval CFG is
neutralized with `scale=1.0` instead of skipping the uncond forward.
- Requests with `text_guidance_scale = 1.0` run with cache hooks disabled.
- `residual_diff_threshold` defaults to `0.24` (measured ~1.7-2.2x at 50 steps,
PSNR 27-34 dB vs uncached). For 20-step generation — especially with a
partial `cfg_range` — use `0.12` for noticeably closer parity at a modest
speed cost.
### Offline Inference
Use the example script under `examples/offline_inference/text_to_image`:
+4 -1
View File
@@ -130,7 +130,7 @@ The following tables show which models support each feature:
| **Krea 2** | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **LongCat-Image** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **LongCat-Image-Edit** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **MammothModa2(T2I)** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **MammothModa2(T2I)** | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Nextstep_1(T2I)** | ❓ | ❓ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **OmniGen2** | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Ovis-Image** | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
@@ -139,6 +139,7 @@ The following tables show which models support each feature:
| **Qwen-Image-Edit** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **Qwen-Image-Edit-2509** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ (decode) | ✅ | ❌ | ❌ |
| **Qwen-Image-Layered** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **Qwen-Image-2.1** | ❌ | ❌ | ✅ (Ulysses only) | ✅ | ❓ | ❌ | ❓ | ❓ | ✅ (decode) | ✅ | ✅ |
| **SenseNova-U1 / U1.5** | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **Stable-Diffusion-XL** | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **Stable-Diffusion3.5** | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ (decode) | ❌ | ❌ |
@@ -154,6 +155,8 @@ The following tables show which models support each feature:
> 4. Krea 2 currently supports single-GPU inference plus LoRA, Cache-DiT, HSDP, CPU/layerwise offload, and VAE-patch-parallel (decode). TP/SP/CFG-Parallel are not yet wired. The few-step distilled (Turbo) checkpoint uses `is_distilled=true` (fixed timestep shift `mu=1.15`); generate at 2048x2048 by default with `num_inference_steps≈8` and `guidance_scale=0`. The Raw checkpoint uses 1024x1024, `num_inference_steps=28`, and `guidance_scale=4.5`.
> 5. HunyuanImage3 supports step execution. Multi-request step execution requires `TORCH_SDPA`; see [Diffusion Execution Modes](diffusion/execution_modes.md#step-execution).
> 6. BAGEL step execution supports image generation with `bagel.yaml`, `bagel_think.yaml`, and `bagel_single_stage.yaml`; two-stage Thinker execution and explicit single-stage text output remain on their existing complete-request paths. Image requests require `num_inference_steps >= 2`. BAGEL step execution cannot currently be combined with sequence parallelism or a diffusion cache backend; see [Diffusion Execution Modes](diffusion/execution_modes.md#step-execution).
> 7. MammothModa2 runs its DiT stage on the diffusion runner (`StageExecutionType.DIFFUSION`); Cache-DiT is enabled through the standard diffusion-stage knobs on the stage entry of the deploy YAML (`cache_backend: cache_dit`, plus optional `cache_config` / `enable_cache_dit_summary`). The runner installs the backend at startup and the pipeline adopts it per request. Only the repeated main-layer stack is cached; requests with `text_guidance_scale = 1.0` bypass the cache hooks.
> 8. Qwen-Image-2.1 (`QwenImage21Pipeline`) handles both text-to-image and image-conditioned generation (up to 4 condition images) and supports LoRA through `QwenImageLoraLoaderMixin`. Sequence parallelism is Ulysses-only (ring attention is not supported), and cache acceleration (TeaCache / Cache-DiT) is unavailable; its built-in KV cache for the text/condition prefix is a separate feature from those cache backends.
### VideoGen
@@ -2,7 +2,6 @@
Source <https://github.com/vllm-project/vllm-omni/tree/main/examples/offline_inference/text_to_speech>.
vLLM-Omni supports several autoregressive TTS models. They share a common
CLI shape (`--text`, `--ref-audio`, `--ref-text`, `--output-dir`) and live
together in this hub. Each model has its own subdirectory containing a
@@ -15,7 +14,7 @@ list of supported architectures across all modalities, see
## Supported Models
| Model | HuggingFace repo | Stages | Voice cloning | Streaming | Special modes | Sample rate |
|---|---|---|---|---|---|---|
| --- | --- | --- | --- | --- | --- | --- |
| VoxCPM2 | `openbmb/VoxCPM2` | single (native AR) | ✓ | — | continuation (`--ref-audio` + `--ref-text`) | 48 kHz |
| CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | 2 (talker + code2wav) | ✓ | ✓ (`async_chunk: true` default) | — | 22.05 kHz |
| Fish Speech S2 Pro | `fishaudio/s2-pro` | dual-AR | ✓ | ✓ (`--streaming`) | — | 44.1 kHz |
@@ -45,13 +44,15 @@ python examples/offline_inference/text_to_speech/<model>/end2end.py \
Single-stage native AR TTS at 48 kHz. Pipeline: `feat_encoder → MiniCPM4 → FSQ → residual_lm → LocDiT → AudioVAE`.
### Prerequisites
```bash
pip install voxcpm
# or, for a local source checkout:
export VLLM_OMNI_VOXCPM_CODE_PATH=/path/to/voxcpm
pip install -e /path/to/voxcpm
```
### Quick start
```bash
python examples/offline_inference/text_to_speech/voxcpm2/end2end.py \
--model openbmb/VoxCPM2 \
@@ -59,7 +60,9 @@ python examples/offline_inference/text_to_speech/voxcpm2/end2end.py \
```
### Voice cloning
Pass a reference audio for isolated cloning, or both `--ref-audio` + `--ref-text` for prompt continuation:
```bash
python examples/offline_inference/text_to_speech/voxcpm2/end2end.py \
--text "Hello, this is a voice clone demo." \
@@ -68,6 +71,7 @@ python examples/offline_inference/text_to_speech/voxcpm2/end2end.py \
```
### Notes
- Output: 48 kHz mono WAV.
- Deploy config: `vllm_omni/deploy/voxcpm2.yaml` (default).
@@ -78,12 +82,14 @@ python examples/offline_inference/text_to_speech/voxcpm2/end2end.py \
2-stage TTS pipeline (`talker` + `code2wav`) at 22.05 kHz.
### Prerequisites
```bash
uv pip install -e .
# Includes soundfile, onnxruntime, x-transformers, einops via requirements.
```
Download the model snapshot:
```python
from huggingface_hub import snapshot_download
snapshot_download('FunAudioLLM/Fun-CosyVoice3-0.5B-2512',
@@ -91,15 +97,18 @@ snapshot_download('FunAudioLLM/Fun-CosyVoice3-0.5B-2512',
```
If your downloaded checkpoint lacks `config.json`, add it:
```json
{
"model_type": "cosyvoice3",
"architectures": ["CosyVoice3Model"]
}
```
This is required because `AutoConfig.register("cosyvoice3", CosyVoice3Config)` only registers the class mapping; the loader still reads `model_type` from `config.json` to select the class.
### Quick start
```bash
python examples/offline_inference/text_to_speech/cosyvoice3/end2end.py \
--model pretrained_models/Fun-CosyVoice3-0.5B \
@@ -107,7 +116,9 @@ python examples/offline_inference/text_to_speech/cosyvoice3/end2end.py \
```
### Voice cloning
Pass a reference audio. Note that CosyVoice3's `--prompt-text` is a system-style prompt for the GPT stage, not a reference transcript:
```bash
python examples/offline_inference/text_to_speech/cosyvoice3/end2end.py \
--model pretrained_models/Fun-CosyVoice3-0.5B \
@@ -117,6 +128,7 @@ python examples/offline_inference/text_to_speech/cosyvoice3/end2end.py \
```
### Notes
- Stage 0 (`talker`) emits speech tokens; stage 1 (`code2wav`) runs flow matching + HiFiGAN to synthesize waveform.
- Deploy config auto-loads from `vllm_omni/deploy/cosyvoice3.yaml` based on HF `model_type`. Pass `--deploy-config <path>` to override.
- `async_chunk: true` is the default; pass `--no-async-chunk` to switch to the legacy synchronous path.
@@ -129,12 +141,14 @@ python examples/offline_inference/text_to_speech/cosyvoice3/end2end.py \
No extra packages are required; the DAC codec is vendored in vLLM-Omni.
### Quick start
```bash
python examples/offline_inference/text_to_speech/fish_speech/end2end.py \
--text "Hello, this is a test of the Fish Speech text to speech system."
```
### Voice cloning
```bash
python examples/offline_inference/text_to_speech/fish_speech/end2end.py \
--text "Hello, this is a cloned voice." \
@@ -143,14 +157,17 @@ python examples/offline_inference/text_to_speech/fish_speech/end2end.py \
```
### Streaming
```bash
python examples/offline_inference/text_to_speech/fish_speech/end2end.py \
--text "Hello, this is a streaming test." \
--streaming
```
Streaming requires `async_chunk: true` in the deploy config.
### Notes
- Output: 44.1 kHz mono WAV.
- DAC codec weights (`codec.pth`) are loaded lazily from the model directory.
@@ -161,6 +178,7 @@ Streaming requires `async_chunk: true` in the deploy config.
2-stage TTS pipeline (AR + DiT flow-matching) at 24 kHz. Every request requires reference audio and its transcript for zero-shot voice cloning.
### Quick start
```bash
python examples/offline_inference/text_to_speech/glm_tts/end2end.py \
--model zai-org/GLM-TTS \
@@ -171,12 +189,14 @@ python examples/offline_inference/text_to_speech/glm_tts/end2end.py \
```
### Architecture
```
```text
Text → [Stage 0: AR] → Speech Tokens → [Stage 1: DiT + HiFT] → Audio (24 kHz)
(Llama-based) (32k vocab) (Flow Matching)
```
### Notes
- `--ref-audio` and `--ref-text` are **required** together; GLM-TTS does not support text-only synthesis.
- Reference audio should be 3-10 seconds.
- First run may be slow due to lazy loading of WhisperVQ tokenizer and CampPlus ONNX speaker embedder.
@@ -190,12 +210,15 @@ Text → [Stage 0: AR] → Speech Tokens → [Stage 1: DiT + HiFT] → Audio (24
Zero-shot multilingual TTS supporting 600+ languages, with three modes (auto / clone / design).
### Prerequisites
```bash
huggingface-cli download k2-fsa/OmniVoice
```
Voice cloning requires `transformers>=5.3.0`. Auto and design modes work with `transformers>=4.57.0`.
### Quick start (auto voice)
```bash
python examples/offline_inference/text_to_speech/omnivoice/end2end.py \
--model k2-fsa/OmniVoice \
@@ -203,6 +226,7 @@ python examples/offline_inference/text_to_speech/omnivoice/end2end.py \
```
### Voice cloning
```bash
python examples/offline_inference/text_to_speech/omnivoice/end2end.py \
--model k2-fsa/OmniVoice \
@@ -212,6 +236,7 @@ python examples/offline_inference/text_to_speech/omnivoice/end2end.py \
```
### Voice design
```bash
python examples/offline_inference/text_to_speech/omnivoice/end2end.py \
--model k2-fsa/OmniVoice \
@@ -220,6 +245,7 @@ python examples/offline_inference/text_to_speech/omnivoice/end2end.py \
```
### Language hint
```bash
python examples/offline_inference/text_to_speech/omnivoice/end2end.py \
--model k2-fsa/OmniVoice \
@@ -228,6 +254,7 @@ python examples/offline_inference/text_to_speech/omnivoice/end2end.py \
```
### Notes
- Stage 0 (Generator): Qwen3-0.6B with 32-step iterative unmasking.
- Stage 1 (Decoder): HiggsAudioV2 RVQ + DAC at 24 kHz.
@@ -238,13 +265,16 @@ python examples/offline_inference/text_to_speech/omnivoice/end2end.py \
3-task-variant TTS with 24 kHz output. Has its own argparse surface (this script does not follow the common `--text` / `--ref-audio` shape).
### Prerequisites
For ROCm builds, replace `onnxruntime` with `onnxruntime-rocm`:
```bash
pip uninstall onnxruntime
pip install onnxruntime-rocm
```
### Task variants
- `CustomVoice`: predefined speaker (speaker ID) with optional style instruction.
- `VoiceDesign`: text + descriptive instruction designs a new voice.
- `Base`: voice cloning from reference audio + transcript.
@@ -264,16 +294,20 @@ python examples/offline_inference/text_to_speech/qwen3_tts/end2end.py --query-ty
```
### Streaming
```bash
python examples/offline_inference/text_to_speech/qwen3_tts/end2end.py \
--query-type CustomVoice \
--streaming \
--output-dir /tmp/out_stream
```
Streaming requires `async_chunk: true` in the deploy config.
### Batched decoding
The Code2Wav stage supports batched decoding through the SpeechTokenizer. Configure both stages with `max_num_seqs > 1` via `--stage-overrides` and pass multiple prompts via `--txt-prompts`:
```bash
python examples/offline_inference/text_to_speech/qwen3_tts/end2end.py \
--query-type CustomVoice \
@@ -281,9 +315,11 @@ python examples/offline_inference/text_to_speech/qwen3_tts/end2end.py \
--batch-size 4 \
--stage-overrides '{"0":{"max_num_seqs":4,"gpu_memory_utilization":0.2},"1":{"max_num_seqs":4,"gpu_memory_utilization":0.2}}'
```
`--batch-size` must match a CUDA-graph capture size (1, 2, 4, 8, 16…).
### Notes
- Run `--help` for the full argument surface.
- See `qwen3_tts/end2end.py` for the prompt-length-estimation logic the Talker uses.
@@ -294,12 +330,15 @@ python examples/offline_inference/text_to_speech/qwen3_tts/end2end.py \
Voxtral-4B-TTS (Mistral). Has its own argparse surface; uses voice presets and the `mistral_common` `SpeechRequest` protocol.
### Prerequisites
Latest `mistral_common` with `SpeechRequest` support:
```bash
pip install -e /path/to/mistral-common # or upgrade from PyPI when available
```
### Quick start (voice preset)
```bash
python examples/offline_inference/text_to_speech/voxtral_tts/end2end.py \
--write-audio --voice cheerful_female \
@@ -308,6 +347,7 @@ python examples/offline_inference/text_to_speech/voxtral_tts/end2end.py \
```
### Voice cloning (capability gated upstream)
```bash
python examples/offline_inference/text_to_speech/voxtral_tts/end2end.py \
--write-audio \
@@ -317,15 +357,18 @@ python examples/offline_inference/text_to_speech/voxtral_tts/end2end.py \
```
### Streaming + concurrency
```bash
python examples/offline_inference/text_to_speech/voxtral_tts/end2end.py \
--num-prompts 32 --concurrency 8 --streaming --write-audio --voice neutral_female \
--model mistralai/Voxtral-4B-TTS-2603 \
--text "..."
```
Available voice presets are listed on the HF model card (`mistralai/Voxtral-4B-TTS-2603`).
### Notes
- `--num-prompts N` replicates the prompt for performance measurement.
- `--concurrency M` requires `--streaming` and must evenly divide `--num-prompts`.
- Run `--help` for the full argument surface.
@@ -6,29 +6,29 @@
ASR -> AURA -> Qwen3-TTS Talker -> Code2Wav
```
Qwen3-TTS remains two engine stages so the pipeline reuses the existing native
Talker and Code2Wav implementation.
**Supported online path:** Realtime duplex at `/v1/realtime?duplex=1`.
The default deploy profile (`vllm_omni/deploy/aura_omni.yaml`) sets
`session_mode: duplex`. The `aura_omni` pipeline declares `duplex_plugin`, and
`DuplexOmniEngine` requires that mode (same pattern as MiniCPM-o duplex
deploys). Turn-based `chat/completions` is not a supported AURA online mode
against this profile.
`async_chunk` defaults to `false` in that file (safer if a turn-based
`OmniOrchestrator` ever loads it). Duplex Realtime Stage2→3 still needs
async chunks — use the smoke deploy below, or set `async_chunk: true`
yourself.
```bash
vllm serve aurateam/AURA \
--omni \
--deploy-config vllm_omni/deploy/aura_omni.yaml \
--served-model-name aurateam/AURA \
--trust-remote-code
bash examples/online_serving/aura_omni/run_duplex_smoke_serve.sh
python examples/online_serving/aura_omni/smoke_duplex_realtime_client.py
```
Configure local checkpoints by editing per-stage `model` values in
`vllm_omni/deploy/aura_omni.yaml`. The deploy file sets
`pipeline: aura_omni`, so the four-stage topology is used even if the
command-line `--model` points at one of the component checkpoints.
Configure local checkpoints by editing per-stage `model` values in the deploy
or smoke YAML. The file sets `pipeline: aura_omni`, so the four-stage topology
is used even if the command-line `--model` points at one component checkpoint.
Send requests with `"model": "aurateam/AURA"`. The ASR, AURA, and Qwen3-TTS
checkpoint paths are internal stage models from the deploy YAML, not the
OpenAI-facing served model name.
The AURA stage can emit `<|silent|>`. Silent outputs are treated as a gate:
they produce no Qwen3-TTS Talker input, so no audio is synthesized for that
turn.
Silent Stage1 outputs (`<|silent|>` / id `151669`) skip TTS for that turn.
## GPU Utilization Recommendation
@@ -40,26 +40,21 @@ VRAM each stage can reserve. Start with this split for a single GPU:
- Stage 2 (Qwen3-TTS Talker): `0.20`
- Stage 3 (Qwen3-TTS Code2Wav): `0.20`
## TTS Modes
## TTS modes (stage extras)
`aura_omni` can pass AURA text to Qwen3-TTS in two task modes:
When the duplex session supplies TTS extras, AURA text can feed Qwen3-TTS as:
- `Base`: voice clone from `tts_ref_audio` with ICL enabled in the AURA
pipeline. Provide both `tts_ref_audio` and `tts_ref_text`. Set
`tts_x_vector_only_mode=true` to disable ICL and use speaker embedding only.
- `CustomVoice`: predefined speaker mode. Use a Qwen3-TTS CustomVoice
checkpoint for stages 2 and 3 in `aura_omni.yaml`, then pass
`tts_task_type=CustomVoice` and `tts_speaker`.
- `Base`: voice clone from `tts_ref_audio` (optional x-vector-only mode)
- `CustomVoice`: predefined speaker (`tts_speaker`) with a CustomVoice checkpoint
on stages 2 and 3
By default, AURA responses are passed to Qwen3-TTS as text. Set
`additional_information.tts_pass_token_ids=true` to pass AURA-generated
assistant token ids directly instead. Even when token passthrough is disabled,
the stage processor uses AURA token ids when available to estimate the Talker
prefill length, so scheduling does not rely on raw character length.
Optional `tts_pass_token_ids` passes AURA assistant token ids into Talker
instead of detokenized text.
The example client exposes this as:
## Old chat / Gradio / curl scripts (unsupported)
```bash
python examples/online_serving/aura_omni/openai_chat_completion_client.py \
--tts-pass-token-ids
```
`examples/online_serving/aura_omni/` still contains chat-completions, curl, and
Gradio helpers from an older turn-based path. They do **not** work against the
shipped duplex profile. That path was an incomplete stand-in before streaming
I/O and session history were available; it is not a supported AURA mode. Use
the Realtime duplex smoke client for online verification.
+2 -1
View File
@@ -1,4 +1,4 @@
# FP8 Quantization
# FP8 Quantization
## Overview
@@ -85,6 +85,7 @@ warmup_quack_fp8([(14040, 2048, 6144), (14040, 2048, 2048)])
| Model | HF models | Online | Pre-calibrated | Recommendation | `ignored_layers` | Text-Encoder quantization |
|-------|-----------|:-------:|:------:|----------------|------------------|------------------|
| Qwen-Image | `Qwen/Qwen-Image`, `Qwen/Qwen-Image-2512` | Yes | Yes | Skip sensitive image-stream MLPs when quality regresses | `img_mlp` | |
| Qwen-Image-2.1 | `Qwen/Qwen-Image-2.1` | Yes | Not validated | Skip image-stream MLPs for best fidelity; all-layer FP8 is loadable but composition drifts on some prompts. On Blackwell GB200 FP8 saves ~17% peak memory but is not faster than BF16 at 1024x1024. Text encoder FP8 covers the language model only — the vision tower and LM head stay BF16 | `img_mlp` | ✅︎ (language model only) |
| Wan2.2 | Wan2.2 diffusion pipelines | Not validated | Not validated | Validate against BF16 before documenting as supported | TBD | |
| LTX-2 | `Lightricks/LTX-2`, `rootonchair/LTX-2-19b-distilled` | Yes | Not validated | Transformer only; use dynamic phase LoRA for ordinary two-stage | None | |
| LTX-2.3 | `diffusers/LTX-2.3-Diffusers`, `diffusers/LTX-2.3-Distilled-Diffusers` | Yes | Not validated | Transformer only; use dynamic phase LoRA for ordinary two-stage | None | |
@@ -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
"""
Example script for image editing with OmniGen2.
@@ -209,14 +209,14 @@ def parse_profiler_config(value: str) -> dict[str, Any]:
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Edit an image with Qwen-Image-Edit.")
parser = argparse.ArgumentParser(description="Edit images with vLLM-Omni.")
parser.add_argument(
"--model",
default="Qwen/Qwen-Image-Edit",
help=(
"Diffusion model name or local path. "
"For multiple image inputs, use Qwen/Qwen-Image-Edit-2509 or Qwen/Qwen-Image-Edit-2511"
"which supports QwenImageEditPlusPipeline."
"For multiple image inputs, use Qwen/Qwen-Image-Edit-2509, "
"Qwen/Qwen-Image-Edit-2511, or Qwen/Qwen-Image-2.1."
),
)
parser.add_argument(
@@ -368,7 +368,7 @@ def parse_args() -> argparse.Namespace:
"--color-format",
type=str,
default="RGB",
help="For Qwen-Image-Layered, set to RGBA.",
help="For Qwen-Image-Layered or Qwen-Image-2.1, set to RGBA to preserve transparency.",
)
# Cache-DiT specific parameters
@@ -647,6 +647,7 @@ def main():
diffusion_params = OmniDiffusionSamplingParams(
generator=generator,
seed=args.seed,
true_cfg_scale=args.cfg_scale,
guidance_scale=args.guidance_scale,
guidance_scale_2=args.guidance_scale_2,
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
set -euo pipefail
python "$(dirname "${BASH_SOURCE[0]}")/image_edit.py" \
--model Qwen/Qwen-Image-2.1 \
--color-format RGBA \
--seed 42 \
--image qwen_bear.png \
--prompt "Let this mascot dance under the moon, surrounded by floating stars and poetic bubbles such as 'Be Kind'" \
--output qwen_image_21_edit.png \
--num-inference-steps 50 \
--cfg-scale 1.0 \
"$@"
@@ -89,7 +89,8 @@ def parse_args() -> argparse.Namespace:
"--model",
default="Qwen/Qwen-Image",
help="Diffusion model name or local path. Supported models: "
"Qwen/Qwen-Image, Tongyi-MAI/Z-Image-Turbo, Qwen/Qwen-Image-2512, stepfun-ai/NextStep-1.1, "
"Qwen/Qwen-Image, Tongyi-MAI/Z-Image-Turbo, Qwen/Qwen-Image-2512, Qwen/Qwen-Image-2.1, "
"stepfun-ai/NextStep-1.1, "
"black-forest-labs/FLUX.1-dev, black-forest-labs/FLUX.2-klein-9B, "
"black-forest-labs/FLUX.2-dev, tencent/HunyuanImage-3.0-Instruct, "
"HiDream-ai/HiDream-O1-Image, meituan-longcat/LongCat-Image, OvisAI/Ovis-Image, "
@@ -157,7 +158,11 @@ def parse_args() -> argparse.Namespace:
parser.add_argument(
"--enable-cache-dit-summary",
action="store_true",
help="Enable cache-dit summary logging after diffusion forward passes.",
default=None,
help=(
"Enable cache-dit summary logging after diffusion forward passes. "
"Default: unset (defer to the deploy YAML's enable_cache_dit_summary)."
),
)
parser.add_argument(
"--ulysses-degree",
@@ -563,7 +563,7 @@ Single-stage native AR TTS at 48 kHz. Pipeline: `feat_encoder → MiniCPM4 → F
```bash
pip install voxcpm
# or, for a local source checkout:
export VLLM_OMNI_VOXCPM_CODE_PATH=/path/to/voxcpm
pip install -e /path/to/voxcpm
```
### Quick start
@@ -1,7 +1,10 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Offline VoxCPM2 inference example (native AR pipeline).
Uses the single-stage native AR config (voxcpm2.yaml).
Requires the `voxcpm` package or VLLM_OMNI_VOXCPM_CODE_PATH env var.
Requires the `voxcpm` package (pip install voxcpm).
"""
from __future__ import annotations
+63 -113
View File
@@ -6,139 +6,89 @@
Qwen3-ASR -> AURA/Qwen3-VL -> Qwen3-TTS Talker -> Qwen3-TTS Code2Wav
```
The pipeline has three semantic modules, but four engine stages because the
existing Qwen3-TTS implementation is natively split into Talker and Code2Wav.
**AURA's supported online path is Realtime duplex** (`/v1/realtime?duplex=1`).
The default deploy profile sets `session_mode: duplex` because the pipeline
declares `duplex_plugin` and `DuplexOmniEngine` requires that mode.
Start the server with the deploy profile:
This is AURA v1 / Qwen3-VL (silent / ChatML ids `151669` / `151645`), not
AURA v2 / Qwen3.5-VL.
## Duplex Realtime (only supported online path)
Stage2→3 codec handoff needs `async_chunk: true`. The default
`vllm_omni/deploy/aura_omni.yaml` keeps `async_chunk: false` as a safer
baseline (turn-based `OmniOrchestrator` never sees the duplex async-chunk
gate). For Realtime, use the smoke deploy (already `async_chunk: true`) or
set that flag yourself:
```bash
bash examples/online_serving/aura_omni/run_duplex_smoke_serve.sh
python examples/online_serving/aura_omni/smoke_duplex_realtime_client.py
```
Smoke deploy file: `examples/online_serving/aura_omni/aura_omni_duplex_smoke.yaml`.
Equivalent serve with the default topology and async chunks enabled:
```bash
# Copy or edit so async_chunk: true, then:
vllm serve aurateam/AURA \
--omni \
--port 8091 \
--deploy-config vllm_omni/deploy/aura_omni.yaml \
--deploy-config examples/online_serving/aura_omni/aura_omni_duplex_smoke.yaml \
--served-model-name aurateam/AURA \
--trust-remote-code
```
The deploy file sets per-stage model repos:
Connect clients to `/v1/realtime?duplex=1`. Silent Stage1 outputs gate TTS (no
audio for that turn). Overlapped input and vision-follow are AURA duplex
capabilities.
### Browser UI
AURA has no client VAD: control is **push-to-talk** on the shared shell from
[#7585](https://github.com/vllm-project/vllm-omni/pull/7585)
([`examples/online_serving/realtime_web/`](../realtime_web/README.md)), profile
`aura-ptt` (hold = `is_speech=true` PCM + sticky frames; release = `commit`;
silent+frames for vision-follow). Thin wrapper:
```bash
python -m examples.online_serving.aura_omni.realtime_web \
--ws-backend ws://127.0.0.1:8099 --model aurateam/AURA --port 7862
```
Do **not** fork a separate `aura_omni/realtime_web` asset tree; MiniCPM / Qwen
profiles are unchanged and hide the PTT control.
### Per-stage models
Edit `model` on each stage in `vllm_omni/deploy/aura_omni.yaml` (or the smoke
YAML) for local checkpoints:
- Stage 0 ASR: `Qwen/Qwen3-ASR-1.7B`
- Stage 1 AURA: `aurateam/AURA`
- Stage 2/3 TTS: `Qwen/Qwen3-TTS-12Hz-1.7B-Base`
- Stage 2/3 TTS: `Qwen/Qwen3-TTS-12Hz-1.7B-Base` (or CustomVoice)
For local weights, edit the `model` value on each stage in
`vllm_omni/deploy/aura_omni.yaml`. The deploy profile includes
`pipeline: aura_omni`, so the server uses this four-stage topology even when
the command-line model path points at one component checkpoint.
### GPU utilization
Expected request shape:
- Send microphone audio as the Stage 0 multimodal audio input.
- Include video frames in the original request `multi_modal_data`; the
`asr2aura` processor carries them forward to AURA.
- Optional `additional_information` keys:
- `aura_system_prompt`
- `tts_task_type`
- `tts_language`
- `tts_speaker`
- `tts_instruct`
- `tts_ref_audio`
- `tts_ref_text`
- `tts_x_vector_only_mode`
- `tts_pass_token_ids`
If AURA emits `<|silent|>`, the `aura2tts` processor returns no TTS request, so
the TTS stages are skipped for that turn.
## GPU Utilization Recommendation
Tune `gpu_memory_utilization` per stage in `vllm_omni/deploy/aura_omni.yaml`.
Recommended baseline on one GPU for H200
Tune `gpu_memory_utilization` per stage. Baseline on one large GPU:
- Stage 0 (ASR): `0.10`
- Stage 1 (AURA): `0.4`
- Stage 2 (Qwen3-TTS Talker): `0.20`
- Stage 3 (Qwen3-TTS Code2Wav): `0.20`
- Stage 1 (AURA): `0.40`
- Stage 2 (Talker): `0.20`
- Stage 3 (Code2Wav): `0.20`
## Python Client
## Old chat / Gradio / curl scripts (unsupported)
```bash
python examples/online_serving/aura_omni/openai_chat_completion_client.py \
--host localhost \
--port 8091 \
--model aurateam/AURA \
--modalities text,audio
```
`openai_chat_completion_client.py`, `run_curl_multimodal_generation.sh`, and
`run_gradio_demo.sh` target the older turn-based Omni path. They do **not**
work against the shipped `session_mode: duplex` profile.
Use local media:
```bash
python examples/online_serving/aura_omni/openai_chat_completion_client.py \
--audio-path /path/to/input.wav \
--video-path /path/to/video.mp4 \
--output-dir output_aura_omni_online
```
Base voice clone mode (default, recommended as x-vector while debugging ICL):
```bash
python examples/online_serving/aura_omni/openai_chat_completion_client.py \
--tts-task-type Base \
--tts-ref-audio vllm-omni/tests/assets/qwen3_tts/clone_2.wav \
--tts-ref-text "Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you."
```
Enable AURA token-id passthrough explicitly:
```bash
python examples/online_serving/aura_omni/openai_chat_completion_client.py \
--tts-pass-token-ids
```
CustomVoice mode requires stages 2 and 3 in `aura_omni.yaml` to point at a
Qwen3-TTS CustomVoice checkpoint:
```bash
python examples/online_serving/aura_omni/openai_chat_completion_client.py \
--tts-task-type CustomVoice \
--tts-speaker Vivian
```
By default, AURA responses are passed to Qwen3-TTS as text. Set
`tts_pass_token_ids=true` to pass AURA-generated assistant token ids directly
to Qwen3-TTS instead. The processor still uses AURA token ids, when available,
to estimate the Talker prompt length in the default text path.
## Curl
```bash
cd examples/online_serving/aura_omni
bash run_curl_multimodal_generation.sh
```
Set `PORT`, `MODEL`, or `OUTPUT_DIR` to override defaults:
```bash
PORT=8666 MODEL=aurateam/AURA bash run_curl_multimodal_generation.sh
TTS_PASS_TOKEN_IDS=true PORT=8666 MODEL=aurateam/AURA bash run_curl_multimodal_generation.sh
```
## Gradio
Launch the server and Gradio UI together:
```bash
cd examples/online_serving/aura_omni
bash run_gradio_demo.sh
```
If the server is already running:
```bash
python examples/online_serving/aura_omni/gradio_demo.py \
--model aurateam/AURA \
--api-base http://localhost:8091/v1
```
That turn-based path was an incomplete June-era stand-in: vLLM-Omni did not
yet support streaming I/O for this stack, and adding session history then would
have required a much larger orchestrator change. It is not a supported AURA
mode. Do not run those scripts against the default deploy. Prefer the duplex
smoke client above.
## Offline
@@ -0,0 +1,119 @@
# AURA duplex Realtime smoke (local weights, session_mode: duplex).
# Stage1 = /workspace/models/AURA + AuraQwen3VLForConditionalGeneration (AURA v1 / Qwen3-VL).
pipeline: aura_omni
# Qwen3-TTS Stage2→3 codec handoff requires async_chunk=true (same as native /
# duplex explore). async_chunk=false reaches talker/code_predictor then never
# delivers audio to Stage3/client.
async_chunk: true
session_mode: duplex
duplex_session:
max_sessions: 1
connectors:
connector_of_shared_memory:
name: SharedMemoryConnector
extra:
codec_streaming: true
connector_get_sleep_s: 0.01
connector_get_max_wait_first_chunk: 3000
connector_get_max_wait: 300
codec_chunk_frames: 25
codec_left_context_frames: 72
# Match native/duplex explore. initial=1 can stall Code2Wav on the first
# undersized chunk (observed hang after "Code2Wav codec: frames=1").
initial_codec_chunk_frames: 8
decode_cudagraph_capture_sizes: [25, 73, 97, 169, 325]
stages:
- stage_id: 0
max_num_seqs: 1
gpu_memory_utilization: 0.08
enforce_eager: true
trust_remote_code: true
enable_prefix_caching: true
async_scheduling: true
max_model_len: 4096
max_num_batched_tokens: 2048
devices: "0"
model: /workspace/models/Qwen3-ASR-1.7B
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
max_tokens: 256
- stage_id: 1
max_num_seqs: 1
gpu_memory_utilization: 0.55
enforce_eager: true
trust_remote_code: true
enable_prefix_caching: true
async_scheduling: true
max_model_len: 32768
max_num_batched_tokens: 8192
devices: "0"
model: /workspace/models/AURA
model_arch: AuraQwen3VLForConditionalGeneration
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
max_tokens: 256
repetition_penalty: 1.0
stop_token_ids: [151669, 151645]
- stage_id: 2
# Allow prior TTS to drain while the next Talker starts (not assumed 1).
max_num_seqs: 2
gpu_memory_utilization: 0.12
enforce_eager: true
trust_remote_code: true
enable_prefix_caching: false
async_scheduling: true
max_num_batched_tokens: 4096
max_model_len: 4096
devices: "0"
# Stage1→2 is orchestrator-fed via aura2tts (custom_process_input_func).
# A from_stage_1 SHM edge parks Talker in WAITING_FOR_CHUNK and clears
# additional_information.text → Missing Qwen3-TTS text conditioning.
# Only Stage2→3 codec handoff needs a connector.
output_connectors:
to_stage_3: connector_of_shared_memory
model: /workspace/models/Qwen3-TTS-12Hz-1.7B-CustomVoice
default_sampling_params:
temperature: 0.5
top_k: 50
# 12Hz × 240 ≈ 20s. Stay under the smoke 25s audio gate so a missed
# 2150 (English/quotes humming) cannot overlap the next vision-follow.
max_tokens: 240
min_tokens: 8
repetition_penalty: 1.05
# Qwen3-TTS Talker codec EOS — without this Talker can run to max_tokens
# (~26s garbage audio while Stage1 text was correct).
stop_token_ids: [2150]
subtalker_sampling_params:
do_sample: true
temperature: 0.5
top_k: 50
top_p: 1.0
- stage_id: 3
max_num_seqs: 2
gpu_memory_utilization: 0.12
enforce_eager: true
trust_remote_code: true
enable_prefix_caching: false
async_scheduling: true
max_num_batched_tokens: 16384
max_model_len: 16384
devices: "0"
input_connectors:
from_stage_2: connector_of_shared_memory
model: /workspace/models/Qwen3-TTS-12Hz-1.7B-CustomVoice
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
max_tokens: 16384
seed: 42
repetition_penalty: 1.0
@@ -0,0 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""AURA push-to-talk browser call UI."""
@@ -0,0 +1,7 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from examples.online_serving.realtime_web.server import main
if __name__ == "__main__":
main(default_profile="aura-ptt")
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Minimal AURA duplex Realtime server for smoke.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
cd "$REPO_ROOT"
PORT="${PORT:-8099}"
MODEL="${MODEL:-/workspace/models/AURA}"
DEPLOY_CONFIG="${DEPLOY_CONFIG:-$REPO_ROOT/examples/online_serving/aura_omni/aura_omni_duplex_smoke.yaml}"
# AURA v1 / Qwen3-VL ChatML ids (do not use AURA v2 248070/248046 here).
export VLLM_AURA_SILENT_TOKEN_ID="${VLLM_AURA_SILENT_TOKEN_ID:-151669}"
export VLLM_AURA_IM_END_TOKEN_ID="${VLLM_AURA_IM_END_TOKEN_ID:-151645}"
export VLLM_AURA_IM_START_TOKEN_ID="${VLLM_AURA_IM_START_TOKEN_ID:-151644}"
export VLLM_AURA_ASSISTANT_TOKEN_ID="${VLLM_AURA_ASSISTANT_TOKEN_ID:-77091}"
# Multi-stage colocated init can make earlier stages' Process.sentinel spuriously
# readable; prefer spawn for stage engine subprocesses (override if needed).
export VLLM_WORKER_MULTIPROC_METHOD="${VLLM_WORKER_MULTIPROC_METHOD:-spawn}"
# Do NOT set TORCHDYNAMO_DISABLE here: it breaks Stage0/1 AOT when those
# stages are not enforce_eager. Stage2/3 use enforce_eager; code_predictor
# honors that and skips its own torch.compile.
exec "$REPO_ROOT/.venv/bin/vllm-omni" serve "$MODEL" \
--omni \
--deploy-config "$DEPLOY_CONFIG" \
--trust-remote-code \
--host 0.0.0.0 \
--port "$PORT"
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""Minimal AURA duplex Realtime smoke via DuplexClient."""
from __future__ import annotations
import argparse
import asyncio
import base64
import json
import sys
import time
from io import BytesIO
from pathlib import Path
from PIL import Image
REPO_ROOT = Path(__file__).resolve().parents[3]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from vllm_omni.clients.duplex import ( # noqa: E402
DuplexClient,
EventCollector,
SessionConfig,
read_pcm16_wav,
)
PROGRESS_TYPES = {
"response.created",
"response.done",
"response.audio.delta",
"response.output_audio.delta",
"response.audio_transcript.delta",
"response.output_text.delta",
"response.text.delta",
"response.listen",
"response.model_listen",
"error",
}
def _tiny_jpeg_b64() -> str:
img = Image.new("RGB", (64, 64), color=(200, 40, 40))
buf = BytesIO()
img.save(buf, format="JPEG", quality=85)
return base64.b64encode(buf.getvalue()).decode("ascii")
async def run(url: str, model: str, wav: Path, timeout_s: float) -> int:
pcm16 = read_pcm16_wav(wav)
if not pcm16:
print("empty wav", file=sys.stderr)
return 2
frame = _tiny_jpeg_b64()
collector = EventCollector()
config = SessionConfig(
modalities=("text", "audio"),
instructions="You are AURA. Reply briefly in Chinese.",
extra_body={
"aura_system_prompt": "You are AURA. Reply briefly in Chinese.",
"tts_task_type": "CustomVoice",
"tts_language": "Chinese",
"tts_speaker": "Vivian",
},
)
started = time.time()
client = DuplexClient(
url,
model=model,
config=config,
reconnect=None,
heartbeat_interval_s=None,
handshake_timeout_s=min(60.0, timeout_s),
)
async with client:
consume_task = asyncio.create_task(collector.consume(client))
collector.add({"type": "session.created", "session": getattr(client, "session_info", {})})
await client.append_audio(pcm16, is_speech=True, video_frames=[frame])
await client.commit(create_response=True)
progress = False
got_done = False
while time.time() - started < timeout_s:
types = [e.get("type") for e in collector.events]
if any(t in PROGRESS_TYPES for t in types):
progress = True
if "response.done" in types or "response.completed" in types:
got_done = True
progress = True
break
for e in collector.events:
meta = e.get("metadata") if isinstance(e.get("metadata"), dict) else {}
if meta.get("model_listen") or meta.get("listen_source") == "aura_silent":
progress = True
break
if collector.count("error") > 0 or collector.count("session.closed") > 0:
break
# Keep draining until response.done so TTS is not truncated.
await asyncio.sleep(0.2)
try:
await client.close(timeout_s=30.0)
except Exception as exc: # noqa: BLE001
print(f"close_warn={exc}", file=sys.stderr)
try:
await asyncio.wait_for(consume_task, timeout=5.0)
except (TimeoutError, asyncio.CancelledError):
consume_task.cancel()
event_types = [e.get("type") for e in collector.events]
errors = [json.dumps(e, ensure_ascii=False)[:500] for e in collector.errors()]
result = {
"ok": progress and (got_done or collector.count("response.done") > 0),
"got_response_progress": progress,
"got_response_done": got_done or collector.count("response.done") > 0,
"errors": errors,
"event_types": event_types,
"n_events": len(event_types),
"audio_bytes": len(collector.audio_bytes()),
"llm_text": collector.response_text(collector.response_ids[0]) if collector.response_ids else "",
"elapsed_s": round(time.time() - started, 2),
}
print(json.dumps(result, ensure_ascii=False, indent=2))
log_path = Path("/tmp/aura_duplex_smoke/client_events.jsonl")
log_path.parent.mkdir(parents=True, exist_ok=True)
log_path.write_text(
"".join(json.dumps(e, ensure_ascii=False) + "\n" for e in collector.events),
encoding="utf-8",
)
print(f"events_log={log_path}", file=sys.stderr)
return 0 if progress else 1
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--url", default="ws://127.0.0.1:8099/v1/realtime")
p.add_argument("--model", default="/workspace/models/AURA")
p.add_argument(
"--wav",
default=str(REPO_ROOT / "tests/assets/minicpmo_4_5/response_required_16k.wav"),
)
p.add_argument("--timeout-s", type=float, default=300.0)
args = p.parse_args()
return asyncio.run(run(args.url, args.model, Path(args.wav), args.timeout_s))
if __name__ == "__main__":
raise SystemExit(main())
+36 -2
View File
@@ -9,6 +9,7 @@ This implements [RFC #7222](https://github.com/vllm-project/vllm-omni/issues/722
| `minicpm-native` | Model-controlled listen/speak, continuous audio input | Frames accompany audio | Yes |
| `qwen3-turn --stt` (default) | User presses **Send turn** | No | No |
| `qwen3-turn --vad` | Server detects trailing silence; speech interrupts replies | Sampled frames with each spoken turn | Yes |
| `aura-ptt` | Hold **Hold to talk**; release commits. Hold also stops local playback | Sticky frames with speech; vision-follow every 2 frames only while unlocked (locked from PTT release until text-final / listen) | Yes |
Qwen3 VAD uses an engine-owned duplex plugin: microphone upload continues
while replies stream, and speech can interrupt generation and playback. The
@@ -29,6 +30,38 @@ The compatibility wrapper serves the shared assets with `minicpm-native`.
The current native query, `extra_body`, reference voice, continuous microphone
upload, camera frames and playback acknowledgements are retained.
## AURA: push-to-talk duplex
AURA has no client VAD. Start the duplex serve, then the thin wrapper:
```bash
bash examples/online_serving/aura_omni/run_duplex_smoke_serve.sh
python -m examples.online_serving.aura_omni.realtime_web \
--ws-backend ws://127.0.0.1:8099 --model aurateam/AURA --port 7862
```
Or select the profile on the shared host:
```bash
python -m examples.online_serving.realtime_web --profile aura-ptt \
--ws-backend ws://127.0.0.1:8099 --model aurateam/AURA --port 7862
```
Open the UI, start a session, optionally enable **Camera**, then **hold**
**Hold to talk** to stream `is_speech=true` PCM (with sticky camera frames).
**Release** to `input_audio_buffer.commit`. Holding the button stops playback
in the browser only; the in-flight response is not cancelled on the server.
With the camera on, frames are still captured at 2 fps. While the button is
up they are committed once per two frames (`is_speech=false` + silent PCM).
Both frames of that commit are sent and packed as one video (`<|video_pad|>`).
Turns still open from this client commit, not from a server-side auto trigger.
but only after the prior spoken turn unlocks (text-final or `response.listen`).
Release itself locks vision immediately so a follow-up frame cannot abort the
just-committed speech turn on Stage0/1 (`max_num_seqs=1`). Audio still playing
does not hold the next vision turn once text is done.
MiniCPM / Qwen profiles do not set `pushToTalk`, so the PTT control stays hidden.
## Qwen3: explicit-turn STT adapter
Start the backend, then the UI in a second terminal:
@@ -49,6 +82,7 @@ accepts only model selection, so system-prompt controls are hidden in this mode.
The adapter follows the shipped `qwen3_omni/openai_realtime_client.py` and
`vllm_omni/entrypoints/openai/realtime_connection.py`:
1. Explicit `duplex=0` selects the legacy STT handler on a turn deployment.
It does not enable STT on a duplex deployment.
2. Send `{type: "session.update", model: ...}` and `commit(final=false)`.
@@ -181,7 +215,7 @@ conversation API. This demo does not add WebRTC, semantic VAD, or tool calling.
## Shared host options
The shared entry point can select either profile explicitly:
The shared entry point can select a profile explicitly:
```bash
python -m examples.online_serving.realtime_web --profile qwen3-turn \
@@ -193,7 +227,7 @@ python -m examples.online_serving.realtime_web --profile qwen3-turn \
- `--public-realtime-url`: optional browser-visible WebSocket URL; otherwise the
static host proxies `/v1/realtime` on the same origin.
- `--host`, `--port`: UI bind address and port (default port 7862).
- `--ref-audio`: required for MiniCPM; rejected for Qwen3.
- `--ref-audio`: required for MiniCPM; rejected for Qwen3 and AURA.
Microphone and camera access require `localhost` or HTTPS. For a remote backend,
use an SSH tunnel to the UI host or an HTTPS reverse proxy with WebSocket support.
@@ -22,6 +22,7 @@
<div class="call-actions">
<button id="callButton" class="primary" type="button">Start session</button>
<button id="sendTurnButton" class="secondary" type="button" hidden disabled>Send turn</button>
<button id="pttButton" class="secondary" type="button" hidden disabled>Hold to talk</button>
<button id="muteButton" class="secondary" type="button" disabled>Mute</button>
<button id="cameraButton" class="secondary" type="button" disabled>Camera</button>
<video id="cameraPreview" muted playsinline style="display:none;width:96px;height:72px;border-radius:8px;object-fit:cover;background:#000"></video>
@@ -75,6 +76,7 @@
<script src="static/profiles/common.js?v=__FULL_DUPLEX_APP_VERSION__"></script>
<script src="static/profiles/minicpm_native.js?v=__FULL_DUPLEX_APP_VERSION__"></script>
<script src="static/profiles/qwen3_turn.js?v=__FULL_DUPLEX_APP_VERSION__"></script>
<script src="static/profiles/aura_ptt.js?v=__FULL_DUPLEX_APP_VERSION__"></script>
<script src="static/app.js?v=__FULL_DUPLEX_APP_VERSION__"></script>
</body>
</html>
@@ -5,6 +5,7 @@
const profile = window.OmniRealtimeProfiles[config.profile || 'minicpm-native'](config);
const callButton = document.getElementById('callButton');
const sendTurnButton = document.getElementById('sendTurnButton');
const pttButton = document.getElementById('pttButton');
const muteButton = document.getElementById('muteButton');
const cameraButton = document.getElementById('cameraButton');
const cameraPreview = document.getElementById('cameraPreview');
@@ -28,6 +29,7 @@
const ECHO_GUARD_MS = 300;
const INITIAL_PLAYBACK_BUFFER_MS = 400;
const SESSION_CLOSE_TIMEOUT_MS = 1000;
const SILENT_PCM_SAMPLES = 160;
const PROMPT_PRESETS = profile.presets;
document.title = profile.title;
@@ -36,7 +38,21 @@
document.getElementById('profileDescription').textContent = profile.description;
document.getElementById('policyLabel').textContent = profile.policy;
cameraButton.hidden = !profile.camera;
if (profile.cameraPreviewLarge) {
cameraPreview.classList.add('camera-preview-large');
// Inline, so a cached stylesheet cannot leave the 96×72 HTML default.
cameraPreview.style.width = '100%';
cameraPreview.style.height = 'auto';
cameraPreview.style.maxHeight = '480px';
cameraPreview.style.flex = '0 0 100%';
const cameraActions = cameraPreview.closest('.call-actions');
if (cameraActions) {
cameraActions.style.flexWrap = 'wrap';
cameraActions.style.gridColumn = '1 / -1';
}
}
sendTurnButton.hidden = !profile.clientCommit;
pttButton.hidden = !profile.pushToTalk;
promptPreset.replaceChildren();
for (const name of [...Object.keys(PROMPT_PRESETS), 'custom']) {
const option = document.createElement('option');
@@ -64,6 +80,12 @@
let cameraStream = null;
let cameraTimer = null;
let cameraPendingFrame = null;
let cameraLastFrame = null;
// AURA vision clock only. Other profiles leave this empty and never read it.
let visionFollowQueue = [];
let visionTurnLocked = false;
let suppressedPlaybackId = null;
let pttHeld = false;
const cameraCanvas = document.createElement('canvas');
let playbackRate = OUTPUT_RATE;
let pendingCapture = [];
@@ -87,6 +109,8 @@
const interruptedResponses = new Set();
const pendingEvents = new Set();
let assistantTextChannel = null;
const assistantRowByResponse = new Map();
let lastClosedAssistantText = '';
let connectionReady = false;
let turnCounter = 0;
let stopping = null;
@@ -134,7 +158,7 @@
assistantTextChannel = null;
if (profile.halfDuplex) pendingCapture = [];
sendTurnButton.disabled = !profile.clientCommit;
setModel(profile.waiting);
if (!pttHeld) setModel(profile.waiting);
}, ECHO_GUARD_MS);
}
@@ -240,6 +264,7 @@
current.value = finalText;
current.text.textContent = finalText;
}
if (role === 'assistant' && current.value) lastClosedAssistantText = current.value;
current.row.classList.remove('turn-live');
if (role === 'user') liveUserTurn = null;
else liveAssistantTurn = null;
@@ -317,11 +342,67 @@
}
function microphoneUploadEnabled() {
return running && connectionReady && !muted && (!profile.halfDuplex || !assistantActive);
if (!running || !connectionReady || muted) return false;
if (profile.pushToTalk) return pttHeld;
return !profile.halfDuplex || !assistantActive;
}
function queueVisionFrame(frame) {
visionFollowQueue.push(frame);
if (visionFollowQueue.length > 2) visionFollowQueue.shift();
}
function commitVisionFollow() {
// Two frames still gate the turn (client commit). Both frames are one clip;
// the server packs them as a single <|video_pad|>, same as Native.
const frames = visionFollowQueue.slice();
visionFollowQueue = [];
const silent = new Int16Array(SILENT_PCM_SAMPLES);
const event = profile.append(int16ToBase64(silent), frames, { isSpeech: false });
socket.send(JSON.stringify(event));
for (const message of profile.commitMessages()) socket.send(JSON.stringify(message));
}
function releaseVisionTurn(responseId) {
if (!profile.visionFollowWhileSpeaking || !visionTurnLocked) return;
if (responseId && currentResponseId && responseId !== currentResponseId) return;
visionTurnLocked = false;
}
function suppressLocalPlayback() {
// Hold-to-talk cuts the speaker only. The in-flight response stays on the server.
if (currentResponseId) suppressedPlaybackId = currentResponseId;
if (playbackNode) playbackNode.port.postMessage({ type: 'clear' });
playbackComplete = true;
responseHasAudio = false;
setPlayback('Idle');
}
function playbackSuppressed(responseId) {
return Boolean(suppressedPlaybackId) && responseId === suppressedPlaybackId;
}
function flushCapture() {
if (!socket || socket.readyState !== WebSocket.OPEN || pendingCapture.length === 0) return;
if (!socket || socket.readyState !== WebSocket.OPEN) return;
// AURA: capture stays 2 fps, but a turn opens only every 2 frames, and
// not until the previous response's text is done (or it listened).
// Playback still running is not the lock. PTT speech is not batched here.
if (profile.pushToTalk && profile.visionFollowWhileSpeaking && !pttHeld) {
if (cameraPendingFrame) {
queueVisionFrame(cameraPendingFrame);
cameraPendingFrame = null;
}
if (!visionTurnLocked && visionFollowQueue.length >= 2) commitVisionFollow();
return;
}
if (profile.pushToTalk && !pttHeld) {
pendingCapture = [];
return;
}
if (pendingCapture.length === 0) return;
if (!microphoneUploadEnabled()) {
pendingCapture = [];
return;
@@ -335,11 +416,44 @@
}
pendingCapture = [];
const pcm = resampleInt16(merged, captureRate, profile.inputSampleRate || INPUT_RATE);
const appendEvent = profile.append(int16ToBase64(pcm), cameraPendingFrame);
const frame = cameraPendingFrame || (profile.stickyCamera ? cameraLastFrame : null);
cameraPendingFrame = null;
const appendEvent = profile.pushToTalk
? profile.append(int16ToBase64(pcm), frame, { isSpeech: true })
: profile.append(int16ToBase64(pcm), frame);
socket.send(JSON.stringify(appendEvent));
}
function setPttHeld(held) {
if (!profile.pushToTalk || !running || pttHeld === held) return;
if (held) {
pttHeld = true;
suppressLocalPlayback();
pttButton.classList.toggle('is-active', true);
pttButton.textContent = 'Release to send';
setModel('Talking');
appendLog('PTT down');
return;
}
// Flush remaining speech while still held, then commit.
flushCapture();
if (socket && socket.readyState === WebSocket.OPEN) {
for (const event of profile.commitMessages()) socket.send(JSON.stringify(event));
}
// Lock vision before clearing pttHeld. Stage0/1 are max_num_seqs=1: a
// vision-follow commit in the gap before response.begin aborts the speech
// turn (server sees a new ephemeral and kills the prior ASR request).
if (profile.visionFollowWhileSpeaking) {
visionTurnLocked = true;
visionFollowQueue = [];
}
pttHeld = false;
pttButton.classList.toggle('is-active', false);
pttButton.textContent = 'Hold to talk';
setModel(profile.waiting);
appendLog('PTT up · commit');
}
function beginAssistant(responseId) {
currentResponseId = responseId || currentResponseId;
responseHasAudio = false;
@@ -351,6 +465,7 @@
}
function feedPlayback(decoded, responseId) {
if (playbackSuppressed(responseId || currentResponseId)) return;
if (!decoded || !decoded.pcm || decoded.pcm.length === 0 || !playbackNode) return;
const pcm = resampleInt16(decoded.pcm, decoded.sourceRate, playbackRate);
responseHasAudio = true;
@@ -392,13 +507,18 @@
}
async function handleAudioEvent(action) {
const responseId = action.responseId || currentResponseId || `turn-${turnCounter}`;
if (playbackSuppressed(responseId)) return;
markBusy();
currentResponseId = action.responseId || currentResponseId || `turn-${turnCounter}`;
currentResponseId = responseId;
setModel('Speaking');
const generation = sessionGeneration;
const responseId = currentResponseId;
const decoded = await decodeAudioDelta(action.event);
if (generation === sessionGeneration && !interruptedResponses.has(responseId)) feedPlayback(decoded, responseId);
if (
generation === sessionGeneration
&& !interruptedResponses.has(responseId)
&& !playbackSuppressed(responseId)
) feedPlayback(decoded, responseId);
}
function handleTranscriptEvent(action) {
@@ -408,12 +528,43 @@
if (profile.deduplicateTranscript && assistantTextChannel && action.channel !== assistantTextChannel) return;
if (action.text) assistantTextChannel = action.channel || assistantTextChannel;
}
if (action.kind === 'text') addTranscript(action.role, action.text);
else if (profile.deduplicateTranscript && action.role === 'assistant') {
const responseId = action.responseId || null;
const existing = responseId && action.role === 'assistant' ? assistantRowByResponse.get(responseId) : null;
if (existing) {
if (action.kind === 'text') {
if (!action.text) return;
existing.value += action.text;
} else if (action.text) {
existing.value = action.text;
}
existing.text.textContent = existing.value;
liveAssistantTurn = existing;
return;
}
if (
action.role === 'assistant'
&& !liveAssistantTurn
&& action.text
&& action.text === lastClosedAssistantText
) {
// response.done already closed this sentence. A later full transcript,
// including one stamped with a newer vision-follow response id, must
// not open a second bubble with the same words.
return;
}
if (action.kind === 'text') {
addTranscript(action.role, action.text);
if (action.role === 'assistant' && responseId && liveAssistantTurn) {
assistantRowByResponse.set(responseId, liveAssistantTurn);
}
return;
}
if (profile.deduplicateTranscript && action.role === 'assistant') {
if (action.text) {
const turn = ensureTurn('assistant');
turn.value = action.text;
turn.text.textContent = action.text;
if (responseId) assistantRowByResponse.set(responseId, turn);
}
} else finishTranscript(action.role, action.text);
}
@@ -459,12 +610,14 @@
}
case 'listen':
assistantActive = false;
releaseVisionTurn(responseId);
setModel(profile.waiting);
break;
case 'begin':
if (profile.halfDuplex) armTurnTimeout();
if (echoTimer !== null) { clearTimeout(echoTimer); echoTimer = null; }
beginAssistant(responseId);
if (profile.visionFollowWhileSpeaking) visionTurnLocked = true;
break;
case 'audio':
await handleAudioEvent(action);
@@ -474,11 +627,13 @@
break;
case 'text': case 'text-final':
handleTranscriptEvent(action);
if (action.kind === 'text-final' && action.role === 'assistant') releaseVisionTurn(responseId);
break;
case 'done':
clearTimeout(turnTimeout);
responseComplete = true;
finishTranscript('assistant');
releaseVisionTurn(responseId);
requestPlaybackDrain(responseId);
finishResponseIfReady();
break;
@@ -671,7 +826,11 @@
await openSocket();
running = true;
muted = false;
pttHeld = false;
assistantActive = false;
visionFollowQueue = [];
visionTurnLocked = false;
suppressedPlaybackId = null;
sendTimer = window.setInterval(flushCapture, profile.sendIntervalMs || SEND_INTERVAL_MS);
startClock();
callButton.textContent = 'End session';
@@ -679,6 +838,7 @@
muteButton.disabled = false;
cameraButton.disabled = !profile.camera;
sendTurnButton.disabled = !profile.clientCommit;
pttButton.disabled = !profile.pushToTalk;
setConnection('Connected', 'online');
setModel(profile.waiting);
appendLog('session started');
@@ -698,8 +858,9 @@
cameraPreview.srcObject = cameraStream;
cameraPreview.style.display = '';
await cameraPreview.play().catch(() => {});
// Official omni-duplex cadence: one JPEG (quality 0.7) per ~1 s chunk,
// no client-side resize (the server normalizes at scale_resolution=448).
// JPEG quality 0.7; no client-side resize (server scale_resolution=448).
// Interval is per-profile: AURA 2 fps, MiniCPM/Qwen stay at 1 s.
const cameraIntervalMs = profile.cameraIntervalMs || 1000;
const captureCameraFrame = () => {
if (!cameraStream || cameraPreview.videoWidth === 0) return;
const scale = profile.cameraMaxDimension
@@ -712,14 +873,17 @@
if (connectionReady && socket?.readyState === WebSocket.OPEN) {
for (const event of profile.imageMessages(frame)) socket.send(JSON.stringify(event));
}
} else cameraPendingFrame = frame;
} else {
cameraPendingFrame = frame;
if (profile.stickyCamera) cameraLastFrame = frame;
}
};
// Do not make the first spoken turn race a one-second timer.
captureCameraFrame();
cameraTimer = window.setInterval(captureCameraFrame, 1000);
cameraTimer = window.setInterval(captureCameraFrame, cameraIntervalMs);
cameraButton.textContent = 'Camera off';
cameraButton.classList.add('is-active');
appendLog('camera on (1 fps omni frames)');
appendLog(`camera on (${(1000 / cameraIntervalMs).toFixed(0)} fps omni frames)`);
}
function stopCamera() {
@@ -730,6 +894,7 @@
}
cameraStream = null;
cameraPendingFrame = null;
cameraLastFrame = null;
cameraPreview.srcObject = null;
cameraPreview.style.display = 'none';
cameraButton.textContent = 'Camera';
@@ -778,9 +943,18 @@
playbackComplete = true;
turnSubmitted = false;
assistantTextChannel = null;
assistantRowByResponse.clear();
lastClosedAssistantText = '';
sendTurnButton.disabled = true;
pttHeld = false;
pttButton.disabled = true;
pttButton.classList.remove('is-active');
pttButton.textContent = 'Hold to talk';
running = false;
assistantActive = false;
visionFollowQueue = [];
visionTurnLocked = false;
suppressedPlaybackId = null;
pendingCapture = [];
if (sendTimer !== null) clearInterval(sendTimer);
if (clockTimer !== null) clearInterval(clockTimer);
@@ -854,6 +1028,23 @@
setModel('Thinking');
appendLog('turn submitted');
});
const pttDown = (event) => {
event.preventDefault();
setPttHeld(true);
};
const pttUp = (event) => {
event.preventDefault();
setPttHeld(false);
};
pttButton.addEventListener('pointerdown', pttDown);
pttButton.addEventListener('pointerup', pttUp);
pttButton.addEventListener('pointercancel', pttUp);
pttButton.addEventListener('pointerleave', () => {
if (pttHeld) setPttHeld(false);
});
window.addEventListener('pointerup', () => {
if (pttHeld) setPttHeld(false);
});
muteButton.addEventListener('click', toggleMute);
clearLogButton.addEventListener('click', () => {
eventLog.textContent = '';
@@ -0,0 +1,79 @@
(function (root) {
'use strict';
const profiles = root.OmniRealtimeProfiles;
profiles['aura-ptt'] = () => ({
title: 'AURA Realtime',
eyebrow: 'Push-to-talk duplex',
policy: 'Hold to talk • release to commit',
description:
'Hold to talk, release to send. Camera stays on whether you speak or not. '
+ 'Holding the button stops local playback.',
waiting: 'Ready',
camera: true,
// Display only. Shared CSS puts .camera-preview-large on its own row.
// Other profiles keep the 96×72 preview in index.html.
cameraPreviewLarge: true,
playbackAck: true,
clientCommit: false,
pushToTalk: true,
stickyCamera: true,
// OmniInteract / Native AURA clock: 2 fps. Other profiles omit this and stay at 1 s.
cameraIntervalMs: 500,
visionFollowWhileSpeaking: true,
deduplicateTranscript: true,
halfDuplex: false,
closeSession: true,
reconnectEachTurn: false,
readyEvent: 'session.updated',
instructions: true,
sendIntervalMs: 200,
presets: {
omni: 'You are receiving a live video stream where the final frame is the present moment. Respond only when a response is needed based on the user\'s message or the visual context. Otherwise, output `<|silent|>` to signify silence.',
},
url(config, location) {
const url = profiles.url(config, location);
url.searchParams.set('duplex', '1');
url.searchParams.set('model', config.model || 'aurateam/AURA');
return url.toString();
},
initialMessages(config, instructions) {
const session = {
modalities: ['audio', 'text'],
extra_body: { auto_response: true },
};
if (instructions) session.instructions = instructions;
return [{ type: 'session.update', session }];
},
append(audio, frame, opts) {
const isSpeech = !opts || opts.isSpeech !== false;
const event = {
type: 'input_audio_buffer.append',
audio,
format: 'pcm16',
sample_rate_hz: 16000,
is_speech: isSpeech,
};
const frames = Array.isArray(frame) ? frame.filter(Boolean) : (frame ? [frame] : []);
if (frames.length) event.video_frames = frames;
return event;
},
commitMessages: () => [{ type: 'input_audio_buffer.commit', create_response: true }],
ack(responseId, playedMs) {
return {
type: 'playback.ack',
response_id: responseId,
item_id: `item_${responseId}`,
played_ms: playedMs,
committed_ms: playedMs,
};
},
mapEvent(event) {
if (event.type === 'response.listen') return { kind: 'listen' };
if (event.type === 'playback.acknowledged') {
return { kind: 'ack', committedMs: (event.event || event).committed_ms || 0 };
}
return profiles.event(event);
},
connectionHint: 'Check the AURA duplex serve (run_duplex_smoke_serve.sh) and --ws-backend port.',
});
})(globalThis);
@@ -71,6 +71,18 @@ main { width: min(1040px, calc(100% - 40px)); margin: 28px auto 48px; }
}
.call-actions { display: flex; gap: 8px; }
/* AURA only: class is added solely by the aura-ptt profile. */
.call-bar:has(.camera-preview-large) .call-actions { grid-column: 1 / -1; }
.call-actions:has(.camera-preview-large) { flex-wrap: wrap; align-items: center; }
.call-actions:has(.camera-preview-large) .camera-preview-large {
order: -1;
flex: 0 0 100%;
width: 100% !important;
height: auto !important;
max-height: 480px;
aspect-ratio: 4 / 3;
}
.call-actions:has(.camera-preview-large) button { flex: 0 0 auto; }
button { border-radius: 5px; border: 1px solid transparent; cursor: pointer; }
button:disabled { opacity: .46; cursor: default; }
.primary { padding: 10px 15px; color: #ffffff; background: #16834b; font-weight: 700; }
+14 -6
View File
@@ -76,15 +76,20 @@ def build_app(
profile: str = "minicpm-native",
adapter: str = "stt",
) -> FastAPI:
if profile not in {"minicpm-native", "qwen3-turn"}:
if profile not in {"minicpm-native", "qwen3-turn", "aura-ptt"}:
raise ValueError(f"Unknown profile: {profile}")
if adapter not in {"stt", "vad"}:
raise ValueError(f"Unknown adapter: {adapter}")
if profile == "qwen3-turn" and ref_audio:
raise ValueError("Qwen3 profile does not accept --ref-audio")
if profile == "aura-ptt" and ref_audio:
raise ValueError("AURA profile does not accept --ref-audio")
native = profile == "minicpm-native"
ws_backend = ws_backend or ("ws://127.0.0.1:8099" if native else "ws://127.0.0.1:8091")
model = model or ("openbmb/MiniCPM-o-4_5" if native else "Qwen/Qwen3-Omni-30B-A3B-Instruct")
aura = profile == "aura-ptt"
ws_backend = ws_backend or ("ws://127.0.0.1:8099" if native or aura else "ws://127.0.0.1:8091")
model = model or (
"openbmb/MiniCPM-o-4_5" if native else "aurateam/AURA" if aura else "Qwen/Qwen3-Omni-30B-A3B-Instruct"
)
app = FastAPI(title="Omni Realtime Web UI")
index_path = APP_DIR / "index.html"
app_version_hash = hashlib.sha256()
@@ -182,7 +187,7 @@ def main(default_profile: str = "minicpm-native") -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=7862)
parser.add_argument("--profile", choices=("minicpm-native", "qwen3-turn"), default=default_profile)
parser.add_argument("--profile", choices=("minicpm-native", "qwen3-turn", "aura-ptt"), default=default_profile)
parser.add_argument("--ws-backend", "--backend", dest="ws_backend")
mode = parser.add_mutually_exclusive_group()
mode.add_argument("--stt", dest="adapter", action="store_const", const="stt")
@@ -202,12 +207,15 @@ def main(default_profile: str = "minicpm-native") -> None:
)
args = parser.parse_args()
native = args.profile == "minicpm-native"
aura = args.profile == "aura-ptt"
if native and not args.ref_audio:
parser.error("--ref-audio is required for the minicpm-native profile")
if not native and args.ref_audio:
parser.error("--ref-audio is only supported by minicpm-native")
args.model = args.model or ("openbmb/MiniCPM-o-4_5" if native else "Qwen/Qwen3-Omni-30B-A3B-Instruct")
args.ws_backend = args.ws_backend or ("ws://127.0.0.1:8099" if native else "ws://127.0.0.1:8091")
args.model = args.model or (
"openbmb/MiniCPM-o-4_5" if native else "aurateam/AURA" if aura else "Qwen/Qwen3-Omni-30B-A3B-Instruct"
)
args.ws_backend = args.ws_backend or ("ws://127.0.0.1:8099" if native or aura else "ws://127.0.0.1:8091")
logging.basicConfig(level=logging.INFO)
uvicorn.run(
+398
View File
@@ -0,0 +1,398 @@
# Qwen-Image-2.1
> Text-to-image and image-conditioned generation with Qwen-Image 2.1
## Summary
- Vendor: Qwen
- Model: `Qwen/Qwen-Image-2.1`
- Task: Text-to-image generation and image-conditioned generation (editing)
- Mode: Offline inference and online serving with optional step-wise execution
- Maintainer: Community
> **Model id:** `Qwen/Qwen-Image-2.1` follows the reference diffusers
> documentation and may change before the official release — the final
> checkpoint name is subject to the official release.
## When to use this recipe
Use this recipe as a starting point for running `Qwen/Qwen-Image-2.1`. A single
pipeline class, `QwenImage21Pipeline`, handles both pure text-to-image requests
(no condition image) and image-conditioned requests with up to 4 condition
images: prompt and condition images are encoded together by a Qwen3-VL text
encoder, so editing-style requests go through the same serving path as
text-to-image.
## References
- Related offline examples:
[`examples/offline_inference/text_to_image/text_to_image.py`](../../examples/offline_inference/text_to_image/text_to_image.py),
[`examples/offline_inference/image_to_image/image_edit.py`](../../examples/offline_inference/image_to_image/image_edit.py)
- Supported models table:
[`docs/models/supported_models.md`](../../docs/models/supported_models.md)
- Feature compatibility matrix:
[`docs/user_guide/diffusion_features.md`](../../docs/user_guide/diffusion_features.md)
- Sibling recipes: [`Qwen-Image.md`](./Qwen-Image.md),
[`Qwen-Image-Edit.md`](./Qwen-Image-Edit.md)
## Offline Inference
### Text-to-image
```bash
python examples/offline_inference/text_to_image/text_to_image.py \
--model Qwen/Qwen-Image-2.1 \
--prompt "A ceramic teapot on a wooden table" \
--output qwen_image_21_t2i.png \
--num-inference-steps 50 \
--cfg-scale 1.0
```
### Image-conditioned generation (editing)
```bash
python examples/offline_inference/image_to_image/image_edit.py \
--model Qwen/Qwen-Image-2.1 \
--color-format RGBA \
--seed 42 \
--image qwen_bear.png \
--prompt "Let this mascot dance under the moon, surrounded by floating stars" \
--output qwen_image_21_edit.png \
--num-inference-steps 50 \
--cfg-scale 1.0
```
or via the bundled launcher:
```bash
bash examples/offline_inference/image_to_image/run_qwen_image_21.sh
```
Up to 4 condition images can be passed via `--image`:
```bash
python examples/offline_inference/image_to_image/image_edit.py \
--model Qwen/Qwen-Image-2.1 \
--color-format RGBA \
--seed 42 \
--image input1.png input2.png \
--prompt "Combine these images into a single scene" \
--output qwen_image_21_multi.png \
--num-inference-steps 50 \
--cfg-scale 1.0
```
Use `--color-format RGBA` to preserve transparency in condition images. The
shared example otherwise loads inputs as RGB. Add `--width` and `--height`
(multiples of 32) to set the output size; when omitted, Qwen-Image 2.1 derives
it from the last condition image's aspect ratio at approximately 1024×1024.
Use `--num-outputs-per-prompt` to generate multiple images and `--vae-use-tiling`
to reduce VAE memory usage. The launcher accepts these options as well.
## Online Serving
Start the server:
```bash
vllm serve Qwen/Qwen-Image-2.1 --omni --port 8091
```
To enable the step-wise runtime:
```bash
vllm serve Qwen/Qwen-Image-2.1 --omni \
--port 8091 \
--step-execution \
--max-num-seqs 8
```
Step execution batches compatible requests at the same KV-cache phase. The
pipeline tags every request with a `batch_compatibility_key` (conditioning
mode plus condition-image layout), so a newly arriving request that would
otherwise join a batch already past its first denoising step is isolated by
the scheduler and deferred until the in-flight batch drains — it no longer
fails the whole batch. `--max-num-seqs` controls batch capacity.
### CUDA Graph decode
Qwen-Image-2.1 automatically uses CUDA Graph for supported fixed-shape denoising
decode steps when `enable_cuda_graph_decode=True` (the default) and
`enforce_eager=False`. Prefill remains eager. This works with both request
execution and `--step-execution`.
To disable graph capture while keeping the rest of the compilation setup:
```bash
vllm serve Qwen/Qwen-Image-2.1 --omni --no-enable-cuda-graph-decode
```
The same control is available as `Omni(..., enable_cuda_graph_decode=False)`
and as `enable_cuda_graph_decode: false` on a stage in the deployment YAML.
`--enforce-eager` / `enforce_eager: true` remains the broader switch: it
disables both graph capture and automatic `torch.compile`. The two
optimizations stack: the DiT blocks are regionally `torch.compile`'d and
graph capture records the compiled (fused) kernels, while inductor's own
cudagraphs stay disabled so the two graph layers never nest. Measured on a
single GB200 (1024×1024, 50 steps, seed 42, BF16, `true_cfg_scale=1.0`,
warmup 1 + median of 3): compile+graph 3.04 s, compile-only 3.2 s,
graph-only 4.0 s, eager 4.3 s end-to-end per image; combo output vs eager is
44.5–45.6 dB PSNR (the same magnitude as pure `torch.compile` fusion
divergence), and graph-only vs eager is bit-identical.
The autoregressive engine's `compilation_config.cudagraph_mode` does not
control this diffusion path; use `enable_cuda_graph_decode` (or
`enforce_eager` for everything) to force eager decode execution.
TP/SP/ring parallelism, HSDP, offload/cache hooks, quantized KV caches,
dynamic LoRA, padded text masks, and a second in-flight request whose cache
aliases an already-owned graph key fall back to eager decode.
Graphs keep separate entries for different image layouts and copy
request-owned prefix K/V into static buffers before replay; each graph key
has a single live owner at a time, so concurrent same-shape requests never
overwrite each other's prefix. Those buffers require additional memory; graph
entries are bounded by the transformer's cache limit.
### Verification
For a direct API smoke test:
```bash
curl http://localhost:8091/v1/images/generations \
-H "Content-Type: application/json" \
-d '{
"model": "Qwen/Qwen-Image-2.1",
"prompt": "A ceramic teapot on a wooden table",
"size": "1024x1024",
"num_inference_steps": 50,
"true_cfg_scale": 1.0,
"seed": 42
}'
```
For image-conditioned generation, pass condition images through the
OpenAI-compatible multimodal endpoint (`/v1/chat/completions` with
`image_url` content parts, or `/v1/images/edits`), same as
[`Qwen-Image-Edit.md`](./Qwen-Image-Edit.md).
## Parameters
| Parameter | Default | Notes |
| --- | --- | --- |
| `true_cfg_scale` | 1.0 | Disables the negative-prompt branch by default. Set above 1 with a `negative_prompt` to enable true CFG. Qwen-Image 2.1 supports true CFG only — there is no `guidance_scale` parameter. |
| `num_inference_steps` | 50 | Number of denoising steps. |
| `height` / `width` | 1024×1024 | Must be multiples of 32. For image-conditioned requests, leaving them unset derives the output size from the last condition image's aspect ratio at ~1024×1024. |
| `negative_prompt` | None | Ignored at the default CFG scale of 1.0; used only when `true_cfg_scale > 1`. |
| condition images | — | Up to 4 input images per request; more than 4 raises an error. |
| `seed` | — | Fix for reproducible outputs. |
The default CFG scale of 1.0 runs only the positive-prompt branch at each
denoising step. To enable negative-prompt guidance, explicitly set
`--cfg-scale` (offline) or `true_cfg_scale` (API) above 1 and provide a
negative prompt. This changes the generated output as well as compute cost.
### Prefix KV cache
The transformer caches the text/condition-image prefix across denoising steps
(a prefill/decode split keyed by CFG branch). The cache is enabled
automatically when the checkpoint declares `causal_condition: true` and is
re-created per generation, so no state leaks between requests. It changes the
compute path — cached prefix vs. full recompute at every step — so exact
numerical reproduction against a no-cache reference (e.g. the diffusers
pipeline) requires matching the cache setting on both sides. The prefix KV
cache is orthogonal to weight quantization and works unchanged under FP8.
### FP8 quantization
Online FP8 quantization of the transformer is supported; the fused
`to_qkv` projections load correctly, and the prefix KV cache path is
unaffected. Only block-internal linears are eligible — the boundary
projections (`img_in`/`txt_in`/`modulation`/`proj_out`/`time_text_embed`) stay
in BF16 by construction:
```bash
python examples/offline_inference/text_to_image/text_to_image.py \
--model Qwen/Qwen-Image-2.1 \
--prompt "A ceramic teapot on a wooden table" \
--output qwen_image_21_fp8.png \
--num-inference-steps 50 --cfg-scale 1.0 \
--quantization fp8 --ignored-layers "img_mlp"
```
`ignored_layers` entries are matched as name patterns for this model (e.g.
`img_mlp` matches every block's `img_mlp.proj`/`img_mlp.gate_layer`/
`img_mlp.out`); exact full prefixes like
`transformer.transformer_blocks.3.attn.to_qkv` also work.
A per-module sensitivity sweep (fixed prompt embeds / latent / timestep, 5
timesteps across the trajectory, relative L2 of the noise prediction vs BF16)
ranks the groups: `img_mlp` is the most sensitive (3.8% max error on its own,
driven by `img_mlp.proj` 2.8% and `img_mlp.out` 2.5%), then `attn.to_out`
(2.7%), then `attn.to_qkv` (1.6%); errors are largest at the late (low-sigma)
steps. End-to-end on 4 fixed prompts at 1024x1024, 50 steps, seed 42
(GB200, single GPU):
| Config | Quantized block linears | Avg PSNR vs BF16 | Min PSNR | Steady-state time/image | Peak memory |
| --- | --- | --- | --- | --- | --- |
| BF16 | 0 / 160 | — | — | 7.2 s | 40.0 GB |
| FP8 all layers | 160 / 160 | 26.1 dB | 19.1 dB | 8.4 s | 33.4 GB |
| FP8, `ignored_layers=["img_mlp"]` | 64 / 160 | 29.7 dB | 21.6 dB | 8.8 s | 38.3 GB |
All-layer FP8 is usable but its composition can drift on some prompts
(fine detail and layout shift, worst case ~19 dB); keeping `img_mlp` in BF16
preserves composition noticeably better and is the recommended setting. On
Blackwell (GB200) FP8 here is a memory optimization, not a speedup: dynamic
per-token activation quantization costs more than the FP8 GEMM saves at these
shapes, so BF16 remains the fastest option.
## Quantization
Online FP8 is supported per component via `quantization_config`:
```python
from vllm_omni import Omni
omni = Omni(
model="Qwen/Qwen-Image-2.1",
quantization_config={
# DiT: skip the sensitive image-stream MLPs if quality regresses.
"transformer": {"method": "fp8", "ignored_layers": ["img_mlp"]},
# Qwen3-VL text encoder: only the language-model linear layers are
# quantized; the vision tower and the (unused) LM head stay BF16.
"text_encoder": {"method": "fp8"},
},
)
```
Either component can be quantized on its own. Measured on GB200 at 1024×1024
(seed 42, 50 steps): text-encoder FP8 lowers peak GPU memory from ~41.0 GiB to
~34.4 GiB (−6.6 GiB); adding DiT FP8 reaches ~28.1 GiB. Output quality stays
close to BF16 (T2I PSNR vs BF16 ≈ 28–32 dB with text-encoder FP8; the
image-conditioned edit path ≈ 36 dB). FP8 saves memory but does not speed up
generation on this hardware. The edit path routes condition images through the
BF16 vision tower, so it is unaffected by text-encoder FP8. See
[`docs/user_guide/quantization/fp8.md`](../../docs/user_guide/quantization/fp8.md)
for the scope rules.
### FP8 prefix KV storage
The cached prefix K/V can be stored in FP8 E4M3 (with per-token-per-head fp32
scales), halving the prefix-cache memory — relevant for long prompts (up to
8192 text tokens) and up to 4 condition images (~4096 latent tokens each).
Prefill quantizes once; decode dequantizes per step, so attention compute is
unchanged. Enable it through the model-specific `extras` config (e.g. in the
deploy YAML's stage `extras` or as an `Omni(...)` engine kwarg):
```yaml
extras:
prefix_kv_cache_dtype: "fp8"
```
Accepted values: `"fp8"` / `"fp8_e4m3"` (K and V quantized), `"fp8_v"` (V
only — K stays in the native dtype), and `None` / `"auto"` (default, native
dtype — behavior unchanged). This is independent of
`diffusion_kv_cache_dtype`, which quantizes attention Q/K/V *compute* per
forward pass on supported backends.
Measured on T2I 1024x1024 (seed 42, 50 steps, true CFG 4.0, PSNR vs. the
bf16 baseline): **`"fp8"` 34.9 dB**, **`"fp8_v"` 40.9 dB** — same composition
and semantics, with texture-level drift in fine detail for `"fp8"`, and
visually indistinguishable output for `"fp8_v"`. The error is dominated by
K quantization: post-RoPE keys are the precision-sensitive half of the
cache, so `"fp8_v"` buys back ~6 dB at 75% (instead of 50%) of the original
cache size. The residual error is inherent e4m3 precision accumulated
coherently over the denoising trajectory; finer scale granularity
(per-tensor/per-head/per-token were compared) or Hadamard-rotated V did not
improve it. The saving scales with prefix length — at the limit
(8192 text tokens + 4 condition images, ~24.6k prefix tokens) the prefix
cache is ~12.9 GB per CFG branch in bf16, ~6.6 GB in `"fp8"` and ~9.7 GB in
`"fp8_v"`, which is where this option matters. Treat it as an opt-in for
memory-bound long-prompt / multi-image workloads, not a free lunch;
`"fp8_v"` is the better default trade-off when quality matters.
Note: a quantized prefix cache is not CUDA-graph capturable — requests using
this option stay on the eager decode path (a warning is logged once).
### Why not the scheduler-managed paged KV (`DiffusionKVCacheMode.PAGED_SCHEDULER`)?
Evaluated and deliberately not adopted (see the `qwen21-p0-kv` commit message
for the full analysis): the paged scheduler targets *cross-request* KV
management — a statically sized page pool, block sharing/dedup, and eviction —
whereas this prefix cache lives exactly as long as one generation and is
sized once per request. Migrating would require routing decode attention
through the worker paged adapter (writing fresh target K/V into pages every
step), per-request page tables, and re-validating the block-causal
piecewise-span masking and Ulysses/CFG constraints, for no memory benefit over
the per-request dense tensors. The FP8 storage above captures the actual
memory win at ~1% of the integration cost.
## Quality Comparison (head vs diffusers)
The reference is the diffusers Qwen-Image 2.1 pipeline
([huggingface/diffusers#14804](https://github.com/huggingface/diffusers/pull/14804)).
Both sides must pin the same checkpoint, inputs, seed, precision (BF16), and
hardware.
vLLM-Omni (this recipe):
```bash
python examples/offline_inference/text_to_image/text_to_image.py \
--model Qwen/Qwen-Image-2.1 \
--prompt "A ceramic teapot on a wooden table" \
--seed 42 \
--output qwen_image_21_vllm_omni.png \
--num-inference-steps 50 \
--cfg-scale 1.0
```
diffusers reference:
```python
import torch
from diffusers import QwenImage21Pipeline # diffusers PR #14804
pipe = QwenImage21Pipeline.from_pretrained(
"Qwen/Qwen-Image-2.1", torch_dtype=torch.bfloat16
).to("cuda")
image = pipe(
prompt="A ceramic teapot on a wooden table",
num_inference_steps=50,
true_cfg_scale=1.0,
generator=torch.Generator(device="cuda").manual_seed(42),
height=1024,
width=1024,
).images[0]
image.save("qwen_image_21_diffusers.png")
```
| Metric | vLLM-Omni | diffusers (#14804) | Status |
| --- | --- | --- | --- |
| Output image (1024×1024, 50 steps, seed 42, BF16) | pending measurement | pending measurement | not yet run |
| PSNR / LPIPS vs reference | pending measurement | — | not yet run |
| Latency (time/image, same GPU) | pending measurement | pending measurement | not yet run |
The prefix KV cache changes the compute path (cached prefix vs. full recompute
at every step), so exact numerical parity is not expected; match the cache
setting on both sides or compare with PSNR/LPIPS rather than exact pixels.
The following capabilities are implemented but the PR-body checkboxes predate
any attached measurements; treat them as unverified until the A/B above is
filled in:
- [ ] TP=2/4 output parity vs single-card — pending measurement.
- [ ] VAE tiling vs non-tiled decode — pending measurement.
- [x] CUDA graph decode vs eager — measured on GB200 (1024×1024, 50 steps,
seed 42, BF16): compile+graph 3.04 s/image vs eager 4.3 s/image (~1.4×),
44.5–45.6 dB PSNR vs eager; graph-only is bit-identical to eager. See
"CUDA Graph decode" above.
## Known Limitations
- Cache acceleration backends (`cache_dit`, `tea_cache`) are not supported;
`QwenImage21Pipeline` is registered in `_NO_CACHE_ACCELERATION`.
- Sequence parallelism supports Ulysses only; ring attention is not supported.
- Only true CFG is exposed; there is no `guidance_scale` knob.
- VAE tiling (`--vae-use-tiling`, also implied by `--vae-patch-parallel-size > 1`)
decodes in 512px tiles with 384px stride (32 latent pixels per tile at 16x
spatial compression). On CUDA OOM the tile size is automatically halved
(keeping 25% overlap) and decoding retried, down to 128px tiles. Tile sizes
are not yet exposed as engine arguments.
+4 -2
View File
@@ -71,8 +71,10 @@ Alternatively, use the convenience script:
```
The bundled deploy config (`vllm_omni/deploy/qwen3_tts.yaml`) enables async
chunking for low first-audio latency. For advanced deployment tuning, pass a
custom deploy config:
chunking for low first-audio latency, and selects the experimental Model
Runner V2 on CUDA. Copy it and set `model_runner: v1` to run V1; NPU, XPU,
ROCm and MUSA keep V1 through the `platforms:` sections. For advanced
deployment tuning, pass a custom deploy config:
```bash
vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
+1
View File
@@ -83,6 +83,7 @@ recipes/
| [`NVIDIA/NemotronLabs-VoiceChat.md`](./NVIDIA/NemotronLabs-VoiceChat.md) | Offline speech-to-speech voice chat (11B, frame-locked 12.5 Hz, 3-stage thinker/talker/code2wav) | 1x H100 80GB |
| [`NVIDIA/SANA-WM.md`](./NVIDIA/SANA-WM.md) | First-frame image-to-video serving with camera control | 1x 24GB+ CUDA GPU; ~40GB disk |
| [`Qwen/Qwen-Image.md`](./Qwen/Qwen-Image.md) | Text-to-image serving with step-wise continuous batching replay and ModelOpt mixed FP8/NVFP4 | 1x A100 80GB / 2x B200 |
| [`Qwen/Qwen-Image-2.1.md`](./Qwen/Qwen-Image-2.1.md) | Text-to-image and image-conditioned generation (editing) with prefix KV cache and CUDA graph decode | 1x GPU with CPU offload / 1x GB200 |
| [`Qwen/Qwen-Image-2512.md`](./Qwen/Qwen-Image-2512.md) | Text-to-image serving with step-wise continuous batching replay and ModelOpt FP8 / mixed FP8/NVFP4 | 1x A800 80GB / 2x B200 |
| [`Qwen/Qwen-Image-Edit.md`](./Qwen/Qwen-Image-Edit.md) | Text-guided single-image editing | 1x or 2x H200 141GB |
| [`Qwen/Qwen3-Omni.md`](./Qwen/Qwen3-Omni.md) | Online serving for multimodal chat | 1x A100 80GB |
@@ -0,0 +1,157 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import asyncio
from pathlib import Path
import pytest
from vllm_omni.benchmarks.omniinteract import _session_capabilities, _wants_annotation_video_clock
from vllm_omni.model_executor.models.aura_omni.benchmarks.omniinteract_clock import (
stream_annotation_clock,
wants_annotation_video_clock,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _Events:
def __init__(self, events):
self.events = events
class _Client:
def __init__(self, capabilities):
self.events = _Events([{"type": "session.created", "session": {"capabilities": capabilities}}])
self._client = None
def test_annotation_clock_follows_capabilities_not_model_name():
aura = {"required_input_modalities": ["video"], "supports_turn_commit_only": True}
minicpm = {"required_input_modalities": ["audio"], "supports_turn_commit_only": False}
audio_commit = {"required_input_modalities": ["audio"], "supports_turn_commit_only": True}
assert wants_annotation_video_clock(aura)
assert not wants_annotation_video_clock(minicpm)
assert not wants_annotation_video_clock(audio_commit)
assert not wants_annotation_video_clock(None)
assert _wants_annotation_video_clock(_Client(minicpm)) is False
assert _wants_annotation_video_clock(_Client(aura)) is True
assert _session_capabilities(_Client(aura))["required_input_modalities"] == ["video"]
def test_annotation_clock_commits_only_the_due_wav(monkeypatch, tmp_path: Path):
video = tmp_path / "videos" / "0002.mp4"
video.parent.mkdir()
video.write_bytes(b"")
sent = []
class Client:
async def send(self, event):
sent.append(event)
async def commit(self):
sent.append({"type": "commit"})
class Playback:
async def acknowledge(self, client):
del client
monkeypatch.setattr(
"vllm_omni.model_executor.models.aura_omni.benchmarks.omniinteract_clock.load_annotation_utterances",
lambda path: [{"at_sec": 0.0, "pcm": b"\x01\x00" * 160, "sent": False, "qa_index": 0}],
)
chunks, frames, _, _ = asyncio.run(
stream_annotation_clock(
Client(),
["frame-a", "frame-b"],
Playback(),
video_path=video,
fps=2.0,
)
)
assert (chunks, frames) == (2, 2)
assert sent[0]["is_speech"] is True
assert sent[0]["video_frames"] == ["frame-a"]
assert sent[1] == {"type": "commit"}
# The trailing silent frame is one vision turn, committed after the clip.
assert sent[2]["is_speech"] is False
assert sent[2]["video_frames"] == ["frame-b"]
assert sent[3] == {"type": "commit"}
assert sent.count({"type": "commit"}) == 2
def test_annotation_clock_commits_silent_frames_in_pairs(monkeypatch, tmp_path: Path):
video = tmp_path / "videos" / "0002.mp4"
video.parent.mkdir()
video.write_bytes(b"")
sent = []
class Client:
async def send(self, event):
sent.append(event)
async def commit(self):
sent.append({"type": "commit"})
class Playback:
async def acknowledge(self, client):
del client
monkeypatch.setattr(
"vllm_omni.model_executor.models.aura_omni.benchmarks.omniinteract_clock.load_annotation_utterances",
lambda path: [{"at_sec": 100.0, "pcm": b"\x01\x00" * 160, "sent": False, "qa_index": 0}],
)
asyncio.run(
stream_annotation_clock(
Client(),
["f1", "f2", "f3", "f4"],
Playback(),
video_path=video,
fps=2.0,
)
)
vision = [event for event in sent if event.get("is_speech") is False]
assert [event["video_frames"] for event in vision] == [["f1", "f2"], ["f3", "f4"]]
assert sent.count({"type": "commit"}) == 2
def test_annotation_clock_holds_vision_while_a_response_is_open(monkeypatch, tmp_path: Path):
video = tmp_path / "videos" / "0002.mp4"
video.parent.mkdir()
video.write_bytes(b"")
sent = []
class Events:
def __init__(self):
self.events = [{"type": "response.created", "response": {"id": "resp-open"}}]
class Client:
def __init__(self):
self.events = Events()
async def send(self, event):
sent.append(event)
async def commit(self):
sent.append({"type": "commit"})
class Playback:
async def acknowledge(self, client):
del client
monkeypatch.setattr(
"vllm_omni.model_executor.models.aura_omni.benchmarks.omniinteract_clock.load_annotation_utterances",
lambda path: [{"at_sec": 100.0, "pcm": b"\x01\x00" * 160, "sent": False, "qa_index": 0}],
)
asyncio.run(
stream_annotation_clock(
Client(),
["f1", "f2", "f3", "f4"],
Playback(),
video_path=video,
fps=2.0,
)
)
assert sent == []
+22 -2
View File
@@ -67,11 +67,31 @@ def test_ready_diffusion_cpu_suite_is_sharded() -> None:
assert "--shard-id=$$BUILDKITE_PARALLEL_JOB" in pytest_command
def test_cosyvoice_gpu_abort_gets_one_fresh_job_retry() -> None:
step = _find_step("CosyVoice3-TTS E2E Test", AMD_READY_PIPELINE)
def test_cosyvoice_ready_smoke_uses_sdpa() -> None:
step = _find_step("CosyVoice3-TTS E2E Smoke (SDPA)", AMD_READY_PIPELINE)
assert step["grade"] == "Blocking"
assert step["retry"] == {"automatic": [{"exit_status": 134, "limit": 1}]}
assert "export DIFFUSION_ATTENTION_BACKEND=TORCH_SDPA" in step["commands"]
pytest_command = next(command for command in step["commands"] if "pytest" in command)
assert "tests/e2e/online_serving/test_cosyvoice3_tts_expansion.py::test_voice_clone_zh_002" in pytest_command
def test_cosyvoice_full_default_backend_suite_runs_nightly() -> None:
step = _find_step("CosyVoice3-TTS E2E Test", AMD_NIGHTLY_PIPELINE)
assert step["grade"] == "NonBlocking"
assert step["timeout_in_minutes"] == 90
assert step["retry"] == {"automatic": [{"exit_status": 134, "limit": 1}]}
assert all("DIFFUSION_ATTENTION_BACKEND" not in command for command in step["commands"])
pytest_command = next(command for command in step["commands"] if "pytest" in command)
assert "tests/e2e/online_serving/test_cosyvoice3_tts_expansion.py" in pytest_command
assert "::" not in pytest_command
def test_amd_template_preserves_step_retry_policy() -> None:
template = AMD_TEMPLATE.read_text(encoding="utf-8")
# Both grouped and top-level AMD steps must preserve an explicit retry
# policy when the source suite is rendered into the uploaded pipeline.
+48
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import asyncio
import base64
import json
import struct
import wave
from collections.abc import Callable
from typing import Any
@@ -33,6 +34,7 @@ from vllm_omni.clients.duplex import (
ReconnectPolicy,
SessionConfig,
SessionResumed,
SessionUpdated,
build_realtime_url,
chunk_period_ms,
duplex_unit_boundary_ms,
@@ -797,6 +799,52 @@ async def test_inline_client_submits_typed_commands_in_order():
assert ack.played_ms == 120 and ack.response_id == "resp-1"
@pytest.mark.asyncio
@pytest.mark.parametrize("accepted", [True, False])
@pytest.mark.parametrize(
"patch",
[
{"input_audio_format": "pcm_f32le", "sample_rate_hz": 24_000},
{"audio": {"input": {"format": "pcm_f32le", "sample_rate_hz": 24_000}}},
],
)
async def test_inline_input_defaults_change_only_after_session_updated(accepted, patch):
client, omni = make_inline_client()
patch = {**patch, "overlap_silence_rms": 0.1}
async with client:
handle = omni.handles[0]
previous_session = dict(client.session_info)
await client.send({"type": "session.update", "session": patch})
# Sending a patch is not an acknowledgement of its acceptance.
assert client.session_info == previous_session
assert client._input_defaults().input_sample_rate_hz == 16_000
updated_session = {**previous_session, **patch}
event: dict[str, object] = (
{"type": "session.updated", "session": updated_session}
if accepted
else {"type": "error", "error": {"code": "invalid_session_update", "message": "rejected"}}
)
handle.feed(event)
received = await client.wait_for("session.updated" if accepted else "error", timeout_s=2.0)
assert isinstance(received, SessionUpdated if accepted else ErrorEvent)
pcm = struct.pack("<f", 0.05) * 16
await client.send({"type": "input_audio_buffer.append", "audio": _b64(pcm)})
command = handle.commands[-1]
assert isinstance(command, duplex_commands.AppendAudio)
assert command.format == "pcm_f32le"
if accepted:
assert client.session_info == updated_session
assert command.audio == pcm
assert command.sample_rate_hz == 24_000
assert command.is_speech is False
else:
assert client.session_info == previous_session
assert len(command.audio) == 2 * len(pcm) # Still decoded as pcm16.
assert command.sample_rate_hz == 16_000
assert command.is_speech is True
@pytest.mark.asyncio
async def test_inline_session_close_maps_to_handle_close():
client, omni = make_inline_client()
+2 -2
View File
@@ -167,7 +167,7 @@ def test_inventory_matches_reviewed_snapshot_counts():
EnvironmentVariableCategory.PUBLIC_OMNI: 29,
EnvironmentVariableCategory.INHERITED_VLLM: 20,
EnvironmentVariableCategory.PLATFORM_EXTERNAL: 27,
EnvironmentVariableCategory.MODEL_SPECIFIC: 57,
EnvironmentVariableCategory.MODEL_SPECIFIC: 59,
EnvironmentVariableCategory.BENCHMARK_TRANSITIONAL: 21,
EnvironmentVariableCategory.INTERNAL: 2,
}
@@ -181,7 +181,7 @@ def test_inventory_matches_reviewed_snapshot_counts():
ModelEnvironmentVariableDisposition.PROMOTE: 31,
ModelEnvironmentVariableDisposition.REQUEST_SCOPE: 5,
ModelEnvironmentVariableDisposition.EXTERNAL: 0,
ModelEnvironmentVariableDisposition.INTERNALIZE: 14,
ModelEnvironmentVariableDisposition.INTERNALIZE: 16,
ModelEnvironmentVariableDisposition.DEPRECATE_REMOVE: 7,
}
+19 -13
View File
@@ -816,19 +816,25 @@ def test_mrv2_fails_fast_on_platforms_without_native_workers(platform: str):
_apply_platform_overrides(DeployConfig(model_runner="v2"), platform=platform)
@pytest.mark.parametrize(
("default_name", "mrv2_name"),
[
("qwen3_tts.yaml", "qwen3_tts_mrv2.yaml"),
(
"qwen3_tts_high_concurrency.yaml",
"qwen3_tts_high_concurrency_mrv2.yaml",
),
],
)
def test_qwen3_mrv2_profiles_are_explicit_opt_in(default_name: str, mrv2_name: str):
assert load_deploy_config(_DEPLOY_DIR / default_name).model_runner == "v1"
assert load_deploy_config(_DEPLOY_DIR / mrv2_name).model_runner == "v2"
def test_qwen3_tts_high_concurrency_mrv2_profile_is_explicit_opt_in():
assert load_deploy_config(_DEPLOY_DIR / "qwen3_tts_high_concurrency.yaml").model_runner == "v1"
assert load_deploy_config(_DEPLOY_DIR / "qwen3_tts_high_concurrency_mrv2.yaml").model_runner == "v2"
def test_qwen3_tts_default_profile_is_experimental_mrv2_with_v1_platform_fallback():
deploy_path = _DEPLOY_DIR / "qwen3_tts.yaml"
assert load_deploy_config(deploy_path).model_runner == "v2"
pipeline = _resolve_pipeline_or_skip("qwen3_tts")
stages = merge_pipeline_deploy(pipeline, load_deploy_config(deploy_path))
assert all(stage.yaml_engine_args["use_v2_model_runner"] is True for stage in stages)
assert _apply_platform_overrides(load_deploy_config(deploy_path), platform="cuda").model_runner == "v2"
for platform in ("npu", "xpu", "rocm", "musa"):
assert _apply_platform_overrides(load_deploy_config(deploy_path), platform=platform).model_runner == "v1"
# The explicit MRV2 profile resolves to the same runner selection.
assert load_deploy_config(_DEPLOY_DIR / "qwen3_tts_mrv2.yaml").model_runner == "v2"
def test_qwen3_tts_mrv2_retunes_do_not_change_default_mrv1_profile():
@@ -69,6 +69,39 @@ def _make_update(prompt_token_ids: list[int] | None = None) -> StreamingUpdate:
)
def _make_minicpm_window_update(
*,
seq: int,
mode: str,
high: int = 15,
low: int = 10,
context_max_units: int = 1,
) -> StreamingUpdate:
update = _make_update([0] * 8)
update.model_intermediate_buffer = {
"duplex": {
"data_plane": True,
"seq": seq,
"runtime_config": {
"duplex_scheduler_token_id": 0,
"duplex_first_append_context_tokens": 3,
"duplex_window_prefix_tokens": 2,
"duplex_window_suffix_token_ids": [3],
"duplex_window_previous_marker_token_ids": [70, 71],
"duplex_window_special_token_ids": [99],
"duplex_window_config": {
"sliding_window_mode": mode,
"basic_window_high_tokens": high,
"basic_window_low_tokens": low,
"context_previous_max_tokens": 4,
"context_max_units": context_max_units,
},
},
}
}
return update
def _make_talker_adapter(
*,
max_model_len: int = 100,
@@ -510,6 +543,225 @@ def test_stage0_streaming_update_keeps_all_computed_tokens_without_placeholder()
assert sched._new_prompt_len_snapshot[session.request_id] == 2
def test_stage0_basic_window_rebuilds_below_low_watermark() -> None:
sched = _make_scheduler(stage_id=0)
session = _make_request()
session.prompt_token_ids = [0] * 9
session._all_token_ids.clear()
session._all_token_ids.extend(session.prompt_token_ids)
session.num_prompt_tokens = 9
session.append_output_token_ids([40])
session.num_computed_tokens = 10
session.status = RequestStatus.WAITING_FOR_STREAMING_REQ
update = _make_minicpm_window_update(seq=2, mode="basic")
sched._update_request_as_session(session, update)
assert session.prompt_token_ids == [0] * 9
assert session.num_computed_tokens == 0
assert update.model_intermediate_buffer["meta"]["replace_streaming_prompt"] is True
plan = update.model_intermediate_buffer["duplex"]["stage0_window"]
assert plan == {
"completed_token_ids": [40],
"replace": True,
"mode": "basic",
"drop_units": 1,
"dropped_tokens": 9,
"previous_token_ids": [],
"previous_marker_token_ids": [70, 71],
"replacement_prompt_len": 9,
}
sched._free_request_blocks.assert_called_once_with(session)
def test_stage0_context_window_compacts_dropped_speech() -> None:
sched = _make_scheduler(stage_id=0)
session = _make_request()
session.prompt_token_ids = [0] * 9
session._all_token_ids.clear()
session._all_token_ids.extend(session.prompt_token_ids)
session.num_prompt_tokens = 9
session.append_output_token_ids([40])
session.num_computed_tokens = 10
session.status = RequestStatus.WAITING_FOR_STREAMING_REQ
first = _make_minicpm_window_update(seq=2, mode="context")
sched._update_request_as_session(session, first)
assert session.num_prompt_tokens == 18
assert first.model_intermediate_buffer["duplex"]["stage0_window"] == {"completed_token_ids": [40]}
session.append_output_token_ids([50])
session.num_computed_tokens = 19
session.status = RequestStatus.WAITING_FOR_STREAMING_REQ
second = _make_minicpm_window_update(seq=3, mode="context")
sched._update_request_as_session(session, second)
assert session.prompt_token_ids == [0] * 21
plan = second.model_intermediate_buffer["duplex"]["stage0_window"]
assert plan["completed_token_ids"] == [50]
assert plan["drop_units"] == 1
assert plan["previous_token_ids"] == [40]
# The worker embeds this exact marker, not a re-tokenized one, so the
# previous region is len(marker) + len(previous).
assert plan["previous_marker_token_ids"] == [70, 71]
assert plan["replacement_prompt_len"] == 21
assert getattr(session, "_minicpmo45_window_previous_len") == 3
@pytest.mark.parametrize("cleared_outputs", [False, True])
@pytest.mark.parametrize("in_flight", [0, 1])
def test_stage0_window_uses_confirmed_span_and_terminator(cleared_outputs, in_flight) -> None:
sched = _make_scheduler(stage_id=0)
session = _make_request()
session.prompt_token_ids = [0] * 9
session._all_token_ids[:] = session.prompt_token_ids
session.num_prompt_tokens = 9
session.append_output_token_ids([40, 99])
session.num_computed_tokens = 10 + in_flight
session.num_output_placeholders = in_flight
session.status = RequestStatus.WAITING_FOR_STREAMING_REQ
if cleared_outputs:
# A stop without a queued append clears this before the next update.
session._output_token_ids.clear()
update = _make_minicpm_window_update(seq=2, mode="basic")
sched._update_request_as_session(session, update)
plan = update.model_intermediate_buffer["duplex"]["stage0_window"]
assert plan["completed_token_ids"] == [40]
assert plan["completed_terminator_token_id"] == 99
assert plan["replacement_prompt_len"] == 9
assert plan["dropped_tokens"] == 9
def test_stage0_window_rebuild_that_overflows_max_model_len_finishes_the_session() -> None:
"""A rebuilt window prompt is bounded by the client's window settings, not
by the model: a unit carrying camera frames is hundreds of tokens, and a
``context`` window adds the ``previous`` region on top. The replacement
branch must therefore check ``replacement_prompt_len`` against
``max_model_len - sample_room`` and finish the session with
``context_length_exceeded``, exactly like a plain extension."""
sched = _make_live_session_scheduler(max_model_len=21)
session = _make_request()
session.prompt_token_ids = [0] * 9
session._all_token_ids[:] = [0] * 9
session.num_prompt_tokens = 9
session._output_token_ids[:] = [40]
session._all_token_ids.append(40)
session.num_computed_tokens = 10
session.num_output_placeholders = 0
_park_session(sched, session)
# The parked append state above is the frame before this update: one
# confirmed output token, fully computed.
session._output_token_ids[:] = [40]
session._all_token_ids[:] = [*session.prompt_token_ids, 40]
session.num_computed_tokens = 10
session.num_in_flight_tokens = 0
sched.num_sampled_tokens_per_step = 1
# The replacement plan rebuilds 21 tokens (retained context plus the
# append) and one step samples one more, so 21 leaves no room.
update = _make_minicpm_window_update(seq=2, mode="basic")
update.prompt_token_ids = [0] * 20
original_prompt = list(session.prompt_token_ids)
sched._update_request_as_session(session, update)
assert update.model_intermediate_buffer["duplex"]["stage0_window"]["replacement_prompt_len"] == 21
assert session.prompt_token_ids == original_prompt
assert session.num_prompt_tokens == 9
assert session.status == RequestStatus.FINISHED_ERROR
assert session.request_id not in sched.requests
assert sched.finished_req_ids == {session.request_id}
sched._free_request_blocks.assert_called_once_with(session)
client_index, reason = sched._streaming_context_overflow[session.request_id]
assert client_index == session.client_index
assert reason.startswith("context_length_exceeded: ")
assert "21 tokens" in reason and "max_model_len 21" in reason
engine_core_outputs = _run_idle_step(sched)
(output,) = engine_core_outputs[session.client_index].outputs
assert output.request_id == session.request_id
assert output.finish_reason == FinishReason.ERROR
assert output.stop_reason == reason
def test_stage0_window_rebuild_that_leaves_room_to_sample_replaces_the_prompt() -> None:
"""One slot above the plan fits: the replacement applies through the normal
replacement path."""
sched = _make_scheduler(stage_id=0)
sched.max_model_len = 25
sched.num_sampled_tokens_per_step = 1
session = _make_request()
session.prompt_token_ids = [0] * 9
session._all_token_ids[:] = session.prompt_token_ids
session.num_prompt_tokens = 9
session.append_output_token_ids([40])
session.num_computed_tokens = 10
session.num_output_placeholders = 0
session.status = RequestStatus.WAITING_FOR_STREAMING_REQ
update = _make_minicpm_window_update(seq=2, mode="basic")
update.prompt_token_ids = [0] * 20
sched._update_request_as_session(session, update)
assert update.model_intermediate_buffer["meta"]["replace_streaming_prompt"] is True
assert update.model_intermediate_buffer["duplex"]["stage0_window"]["replacement_prompt_len"] == 21
assert session.prompt_token_ids == [0] * 21
assert session.num_prompt_tokens == 21
assert session.num_computed_tokens == 0
assert session.status == RequestStatus.WAITING
sched._free_request_blocks.assert_called_once_with(session)
assert not getattr(sched, "_streaming_context_overflow", {})
def test_stage0_window_open_start_zero_is_not_replaced_by_the_context_reserve() -> None:
"""A recorded ``open_start`` of 0 is a legitimate empty context prefix. The
fallback must be an explicit ``is None`` check: treating 0 as missing
substitutes the context reserve, counts the suffix twice, and the worker's
rebuild-length check raises on the first replacement."""
session = SimpleNamespace(
num_prompt_tokens=9,
_all_token_ids=[0] * 9 + [40],
num_computed_tokens=10,
num_output_placeholders=0,
_minicpmo45_window_open_start=0,
)
update = _make_minicpm_window_update(seq=2, mode="basic")
update.prompt_token_ids = [0] * 21
assert OmniARScheduler._prepare_minicpmo45_stage0_window(
session, update, segment_output_ids=[40], completed_terminator=3
)
# open_start stays 0, so the reported unit spans the whole 12-token
# boundary instead of the 9 the context reserve would fold in.
plan = update.model_intermediate_buffer["duplex"]["stage0_window"]
assert plan["dropped_tokens"] == 12
assert plan["replacement_prompt_len"] == 19
def test_stage0_window_open_start_falls_back_to_the_context_reserve_when_unset() -> None:
session = SimpleNamespace(
num_prompt_tokens=9,
_all_token_ids=[0] * 9 + [40],
num_computed_tokens=10,
num_output_placeholders=0,
)
update = _make_minicpm_window_update(seq=2, mode="basic")
update.prompt_token_ids = [0] * 21
assert OmniARScheduler._prepare_minicpmo45_stage0_window(
session, update, segment_output_ids=[40], completed_terminator=3
)
# fallback open_start is preserve_len 3 from duplex_first_append_context_tokens,
# so the unit is three tokens shorter.
plan = update.model_intermediate_buffer["duplex"]["stage0_window"]
assert plan["dropped_tokens"] == 9
assert plan["replacement_prompt_len"] == 22
def test_explicit_streaming_payload_replaces_placeholder_prompt() -> None:
sched = _make_scheduler(stage_id=1)
sched.chunk_transfer_adapter = SimpleNamespace(
@@ -42,6 +42,14 @@ from vllm_omni.diffusion.data import (
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
@pytest.fixture(autouse=True)
def _clear_inherited_attention_backend(monkeypatch):
# Weekly CPU exports DIFFUSION_ATTENTION_BACKEND; OmniDiffusionConfig
# treats that as an explicit default so auto-backend cases break.
# Tests that setenv the same variable still work: this fixture runs first.
monkeypatch.delenv("DIFFUSION_ATTENTION_BACKEND", raising=False)
class TestAttentionSpec:
def test_construct_no_skip_softmax(self):
spec = AttentionSpec(backend="FLASH_ATTN")
@@ -416,13 +424,6 @@ class TestBuildAttentionConfig:
class TestOmniDiffusionConfigAttentionParsing:
"""Test OmniDiffusionConfig attention shorthand and structured config."""
@pytest.fixture(autouse=True)
def _clear_diffusion_attention_backend_env(self, monkeypatch):
# OmniDiffusionConfig.__post_init__ applies DIFFUSION_ATTENTION_BACKEND via
# build_attention_config(); clear it so these tests assert config defaults,
# not whatever the process inherited from CI / sibling tests.
monkeypatch.delenv("DIFFUSION_ATTENTION_BACKEND", raising=False)
def test_diffusion_attention_backend_sets_default(self):
config = OmniDiffusionConfig.from_kwargs(diffusion_attention_backend="SAGE_ATTN")
assert isinstance(config.diffusion_attention_config, AttentionConfig)
+91 -1
View File
@@ -12,9 +12,10 @@ import vllm_omni.diffusion.attention.backends.cudnn_attn as cudnn_backend
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
from vllm_omni.diffusion.attention.backends.cudnn_attn import CuDNNAttentionBackend, CuDNNAttentionImpl
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
@pytest.mark.cpu
def test_cudnn_backend_uses_math_for_kv_seq_len_one(monkeypatch):
"""Automatic CUDNN_ATTN (platform default) may use MATH for singleton K/V."""
selected_backends = []
@@ -40,6 +41,7 @@ def test_cudnn_backend_uses_math_for_kv_seq_len_one(monkeypatch):
assert selected_backends == [(SDPBackend.MATH,)]
@pytest.mark.cpu
def test_explicit_cudnn_rejects_kv_seq_len_one():
impl = CuDNNAttentionImpl(
num_heads=2,
@@ -54,6 +56,7 @@ def test_explicit_cudnn_rejects_kv_seq_len_one():
impl.forward_cuda(query, singleton_kv, singleton_kv)
@pytest.mark.cpu
def test_cudnn_backend_pins_cudnn_only_when_kv_seq_len_gt_one(monkeypatch):
selected_backends = []
@@ -77,6 +80,7 @@ def test_cudnn_backend_pins_cudnn_only_when_kv_seq_len_gt_one(monkeypatch):
assert selected_backends == [(SDPBackend.CUDNN_ATTENTION,)]
@pytest.mark.cpu
def test_cudnn_slices_valid_kv_prefix_without_padding_mask(monkeypatch):
observed: dict[str, Any] = {}
@@ -112,6 +116,7 @@ def test_cudnn_slices_valid_kv_prefix_without_padding_mask(monkeypatch):
assert observed["kwargs"]["attn_mask"] is None
@pytest.mark.cpu
def test_cudnn_rejects_invalid_valid_kv_length():
impl = CuDNNAttentionImpl(
num_heads=2,
@@ -130,12 +135,97 @@ def test_cudnn_rejects_invalid_valid_kv_length():
@pytest.mark.parametrize("head_size", [8, 64, 128, 256])
@pytest.mark.cpu
def test_cudnn_backend_accepts_blackwell_fmha_head_sizes(head_size):
assert CuDNNAttentionBackend.supports_head_size(head_size)
assert head_size in CuDNNAttentionBackend.get_supported_head_sizes()
@pytest.mark.parametrize("head_size", [0, 7, 12, 320])
@pytest.mark.cpu
def test_cudnn_backend_rejects_incompatible_head_sizes(head_size):
assert not CuDNNAttentionBackend.supports_head_size(head_size)
assert head_size not in CuDNNAttentionBackend.get_supported_head_sizes()
@pytest.mark.parametrize("batch_size", [1, 2])
@pytest.mark.parametrize("kv_heads", [1, 2])
@pytest.mark.parametrize("query_offset", [0, 4])
@pytest.mark.cpu
def test_piecewise_matches_dense_block_causal_mask(monkeypatch, batch_size, kv_heads, query_offset):
monkeypatch.setattr(cudnn_backend, "sdpa_kernel", lambda _: nullcontext())
torch.manual_seed(42)
query = torch.randn(batch_size, 12, 2, 8)
key = torch.randn(batch_size, 12, kv_heads, 8)
value = torch.randn_like(key)
spans = [(2, 5), (7, 10)]
positions = torch.arange(12)
mask = positions[:, None] >= positions[None, :]
for start, end in spans:
mask[start:end, start:end] = True
expected = torch.nn.functional.scaled_dot_product_attention(
query[:, query_offset:].transpose(1, 2),
key.transpose(1, 2),
value.transpose(1, 2),
attn_mask=mask[None, None, query_offset:],
scale=0.5,
enable_gqa=kv_heads == 1,
).transpose(1, 2)
impl = CuDNNAttentionImpl(num_heads=2, head_size=8, softmax_scale=0.5)
actual = impl.forward_cuda(
query[:, query_offset:], key, value, AttentionMetadata(full_attn_spans=[spans] * batch_size)
)
torch.testing.assert_close(actual, expected)
@pytest.mark.cpu
def test_piecewise_preserves_discontinuous_query_ranges(monkeypatch):
from vllm_omni.diffusion.attention.backends.abstract import QueryRange
monkeypatch.setattr(cudnn_backend, "sdpa_kernel", lambda _: nullcontext())
query, key, value = [torch.randn(2, 12, 2, 8) for _ in range(3)]
impl = CuDNNAttentionImpl(num_heads=2, head_size=8, softmax_scale=0.5)
metadata = AttentionMetadata(full_attn_spans=[[(2, 5), (7, 12)]] * 2)
expected = impl.forward_cuda(query, key, value, metadata)
metadata.query_ranges = (QueryRange(0, 3, 0), QueryRange(3, 6, 8))
actual = impl.forward_cuda(torch.cat([query[:, :3], query[:, 8:11]], dim=1), key, value, metadata)
torch.testing.assert_close(actual, torch.cat([expected[:, :3], expected[:, 8:11]], dim=1))
@pytest.mark.cpu
def test_piecewise_rejects_padding_mask(monkeypatch):
impl = CuDNNAttentionImpl(num_heads=2, head_size=8, softmax_scale=0.5)
query = torch.randn(1, 8, 2, 8)
metadata = AttentionMetadata(full_attn_spans=[[(2, 8)]], attn_mask=torch.ones(1, 8, dtype=torch.bool))
with pytest.raises(ValueError, match="padding mask"):
impl.forward_cuda(query, query, query, metadata)
@pytest.mark.cpu
def test_dense_mask_takes_precedence_over_spans(monkeypatch):
monkeypatch.setattr(cudnn_backend, "sdpa_kernel", lambda _: nullcontext())
query, key, value = [torch.randn(2, 8, 2, 8) for _ in range(3)]
mask = torch.ones(2, 1, 8, 8, dtype=torch.bool).tril()
mask[1, :, :, 3] = False
impl = CuDNNAttentionImpl(num_heads=2, head_size=8, softmax_scale=0.5)
expected = impl.forward_cuda(query, key, value, AttentionMetadata(attn_mask=mask))
actual = impl.forward_cuda(query, key, value, AttentionMetadata(attn_mask=mask, full_attn_spans=[[(2, 8)]] * 2))
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
@pytest.mark.cuda
@pytest.mark.gpu
@pytest.mark.parametrize("batch_size", [1, 2])
def test_cudnn_piecewise_matches_dense_on_device(batch_size):
generator = torch.Generator(device="cuda").manual_seed(42)
query, key, value = [
torch.randn(batch_size, 96, 2, 128, generator=generator, device="cuda", dtype=torch.bfloat16) for _ in range(3)
]
spans = [(8, 40), (48, 96)]
mask = torch.ones(96, 96, device="cuda", dtype=torch.bool).tril()
for start, end in spans:
mask[start:end, start:end] = True
impl = CuDNNAttentionImpl(num_heads=2, head_size=128, softmax_scale=128**-0.5)
actual = impl.forward_cuda(query, key, value, AttentionMetadata(full_attn_spans=[spans] * batch_size))
expected = impl.forward_cuda(query, key, value, AttentionMetadata(attn_mask=mask[None, None]))
torch.testing.assert_close(actual, expected, rtol=0.02, atol=0.004)
+108
View File
@@ -60,6 +60,7 @@ def test_custom_cache_dit_enablers_are_registered_explicitly():
"Cosmos3OmniPipeline": cd_model_specific.enable_cache_for_cosmos3,
"Krea2Pipeline": cd_model_specific.enable_cache_for_krea2,
"Magi2Pipeline": cd_model_specific.enable_cache_for_magi2,
"MammothModa2DiTPipeline": cd_model_specific.enable_cache_for_mammothmoda2,
}
with patch.dict(cd_backend.CUSTOM_DIT_ENABLERS, {}, clear=True):
@@ -116,6 +117,33 @@ def test_magi2_cache_dit_targets_only_nested_repeated_layers(mock_block_adapter,
assert get_transformer(pipeline) is transformer_block
@patch("vllm_omni.diffusion.cache.cachedit.model_specific.enable_cache_for_dit")
@patch("vllm_omni.diffusion.cache.cachedit.model_specific.BlockAdapter")
def test_mammothmoda2_cache_dit_targets_only_main_layers(mock_block_adapter, mock_enable_cache):
pipeline = Mock()
transformer = pipeline.gen_transformer
layers = torch.nn.ModuleList([torch.nn.Identity()])
transformer.layers = layers
adapter = mock_block_adapter.return_value
refresh = Mock()
mock_enable_cache.return_value = refresh
result = cd_model_specific.enable_cache_for_mammothmoda2(pipeline, SAMPLE_CACHE_CONFIG)
mock_block_adapter.assert_called_once()
adapter_kwargs = mock_block_adapter.call_args.kwargs
assert adapter_kwargs["transformer"] is transformer
assert adapter_kwargs["blocks"] == [layers]
assert adapter_kwargs["has_separate_cfg"] is True
assert adapter_kwargs["check_forward_pattern"] is True
assert result.refresh is refresh
assert result.targets == (adapter,)
get_transformer = mock_enable_cache.call_args.kwargs["get_pipeline_transformer"]
assert get_transformer(pipeline) is transformer
# Sequential-CFG parity is mandatory while cache-dit is installed.
assert pipeline._cache_dit_requires_paired_cfg is True
@patch("vllm_omni.diffusion.cache.cachedit.backend.cache_dit.summary")
@patch("vllm_omni.diffusion.cache.cachedit.backend.BlockAdapter.is_cached", return_value=True)
def test_cache_summary_uses_custom_nested_targets(mock_is_cached, mock_summary):
@@ -338,3 +366,83 @@ def test_summary_with_no_transformer_is_nonfatal():
pass
cache_summary(pipeline=FakePipeline())
# This test is skipped on ROCm since rocm_unquantized_gemm doesn't support CPU backend
@pytest.mark.skipif(
current_omni_platform.is_rocm(),
reason="vLLM ROCm custom ops lack CPU fallback",
)
def test_mammothmoda2_cache_dit_runs_end_to_end_on_tiny_model(request: pytest.FixtureRequest):
"""A tiny MammothModa2 DiT runs through Cache-DiT with paired CFG forwards.
Exercises the Pattern_3 contract end to end: after enabling the custom
enabler, blocks must be callable as ``block(hidden_states, **kwargs)``
and the pipeline's two-forwards-per-step CFG cadence must drive the
cache context without shape or parity errors.
"""
from cache_dit import BlockAdapter
from vllm_omni.diffusion.models.mammoth_moda2.mammothmoda2_dit_model import Transformer2DModel
from vllm_omni.diffusion.models.mammoth_moda2.rope_real import RotaryPosEmbedReal
# MammothModa2 attention resolves through the shared Omni attention layer,
# which picks the platform-default backend at construction time. Pin
# TORCH_SDPA so a CUDA-visible host does not select FA3 for CPU tensors.
with _force_torch_sdpa():
model = Transformer2DModel(
patch_size=2,
in_channels=4,
hidden_size=96,
num_layers=4,
num_refiner_layers=1,
num_attention_heads=2,
num_kv_heads=2,
multiple_of=8,
axes_dim_rope=(16, 16, 16),
axes_lens=(300, 512, 512),
text_feat_dim=16,
)
model.eval()
MammothModa2Pipeline = type("MammothModa2DiTPipeline", (), {})
pipeline = MammothModa2Pipeline()
pipeline.gen_transformer = model
backend = CacheDiTBackend(DiffusionCacheConfig())
backend.enable(pipeline)
request.addfinalizer(lambda: backend.disable(pipeline))
assert BlockAdapter.is_cached(model)
num_inference_steps = 8
backend.refresh(pipeline, num_inference_steps=num_inference_steps)
freqs_cis = RotaryPosEmbedReal.get_freqs_real((16, 16, 16), (300, 512, 512), theta=10000)
latents = torch.randn(1, 4, 8, 8)
text = torch.randn(1, 5, 16)
negative = torch.randn(1, 3, 16)
text_mask = torch.ones(1, 5, dtype=torch.bool)
negative_mask = torch.ones(1, 3, dtype=torch.bool)
with torch.no_grad():
for step in range(num_inference_steps):
timestep = torch.full((1,), 1.0 - step / num_inference_steps)
# Conditional pass ...
cond = model(
hidden_states=latents,
timestep=timestep,
text_hidden_states=text,
freqs_cis=freqs_cis,
text_attention_mask=text_mask,
)
# ... always followed by the unconditional pass (parity).
uncond = model(
hidden_states=latents,
timestep=timestep,
text_hidden_states=negative,
freqs_cis=freqs_cis,
text_attention_mask=negative_mask,
)
latents = uncond + 4.0 * (cond - uncond)
assert latents.shape == (1, 4, 8, 8)
assert torch.isfinite(latents).all()
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from dataclasses import dataclass
from types import SimpleNamespace
@@ -62,6 +65,7 @@ class E2EOperator:
@dataclass
class FakeWorldGroup:
device_group: object
cpu_group: object = None
class DummyMixin(DistributedVaeMixin):
@@ -0,0 +1,81 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
Two-rank ``pause_generation(mode="keep")``: every rank runs the pause barrier
before the ACK, and a barrier failure on a non-zero rank fails the pause
without aborting or dropping the running batch.
"""
from __future__ import annotations
import asyncio
from collections import defaultdict
from contextlib import ExitStack
import pytest
from tests.e2e.features.custom_pipeline.test_diffusion_pause_keep import (
CUSTOM_PIPELINE_CLASS,
MODEL,
WORKER_EXTENSION_CLASS,
_generate,
_pause_round,
_wait_for,
)
from tests.helpers.mark import hardware_test
from vllm_omni.entrypoints.async_omni import AsyncOmni
def _all_true(results) -> bool:
flat = []
stack = list(results)
while stack:
item = stack.pop()
if isinstance(item, list):
stack.extend(item)
else:
flat.append(item)
return bool(flat) and all(item is True for item in flat)
@pytest.mark.core_model
@pytest.mark.diffusion
@hardware_test(res={"cuda": "L4"}, num_cards=2)
@pytest.mark.asyncio
async def test_keep_pause_two_ranks_ack_and_rank_failure():
with ExitStack() as after:
engine = AsyncOmni(
model=MODEL,
custom_pipeline_args={"pipeline_class": CUSTOM_PIPELINE_CLASS},
worker_extension_cls=WORKER_EXTENSION_CLASS,
enforce_eager=True,
enable_sleep_mode=True,
num_gpus=2,
parallel_config={"ulysses_degree": 2, "ulysses_mode": "advanced_uaa"},
)
after.callback(engine.shutdown)
events: dict[str, dict[str, float]] = defaultdict(dict)
assert _all_true(await engine.collective_rpc(method="start_counting_synchronize_device"))
pause_latencies = [await _pause_round(engine, events, round_index) for round_index in range(3)]
# The result is the all-rank AND, so True means both ranks ran the barrier.
assert _all_true(await engine.collective_rpc(method="synchronize_device_seen"))
assert _all_true(await engine.collective_rpc(method="set_synchronize_device_failure", args=(1, True)))
a = asyncio.create_task(_generate(engine, "a-fail", events))
await _wait_for(lambda: "started" in events["a-fail"])
with pytest.raises(RuntimeError, match="pause_scheduler failed"):
await engine.pause_generation(mode="keep", clear_cache=False)
assert await engine.is_paused()
a_output = await asyncio.wait_for(a, 60.0)
assert a_output.images, "a failed barrier must not abort the running batch"
assert _all_true(await engine.collective_rpc(method="set_synchronize_device_failure", args=(1, False)))
await engine.resume_generation()
assert not await engine.is_paused()
recovered = await asyncio.wait_for(_generate(engine, "after-recovery", events), 60.0)
assert recovered.images
assert not engine.request_states
print(f"[sp2] rounds=3 pause_latency_s min/max={min(pause_latencies):.3f}/{max(pause_latencies):.3f}")
@@ -0,0 +1,71 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from datetime import timedelta
from types import SimpleNamespace
import pytest
import torch
import torch.distributed as dist
from vllm_omni.diffusion.distributed.autoencoders import distributed_vae_executor as vae_executor
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
class TileOperator:
def split(self, tensor):
return [vae_executor.TileTask(i, (i,), row[None]) for i, row in enumerate(tensor)], vae_executor.GridSpec(
split_dims=(0,), grid_shape=(tensor.shape[0],)
)
def exec(self, task):
return task.tensor * 2
def merge(self, tiles, grid):
return torch.cat([tiles[(i,)] for i in range(grid.grid_shape[0])])
def run_recovery_worker(rank, init_file):
torch.set_num_threads(1)
dist.init_process_group(
"gloo", init_method=f"file://{init_file}", rank=rank, world_size=2, timeout=timedelta(seconds=30)
)
try:
group = SimpleNamespace(device_group=dist.group.WORLD, cpu_group=dist.group.WORLD)
with pytest.MonkeyPatch.context() as patch:
patch.setattr(vae_executor, "get_world_group", lambda: group)
executor = vae_executor.DistributedVaeExecutor()
executor.set_parallel_size(2)
tensor = torch.arange(12, dtype=torch.float32).reshape(4, 3)
operator = TileOperator()
for stage, failing_rank in [("decode", 1), ("packing", 1), ("merge", 0), ("gather", 1)]:
with pytest.MonkeyPatch.context() as patch:
if rank == failing_rank:
def fail(*args, **kwargs):
raise torch.OutOfMemoryError("injected OOM")
target, attribute = {
"decode": (operator, "exec"),
"packing": (executor, "_pack_local_tiles"),
"merge": (operator, "merge"),
"gather": (torch, "empty_like"),
}[stage]
patch.setattr(target, attribute, fail)
with pytest.raises(torch.OutOfMemoryError, match="Distributed VAE"):
executor.execute(tensor, operator)
torch.testing.assert_close(executor.execute(tensor, operator), tensor * 2)
with pytest.raises(RuntimeError, match="Distributed VAE mixed failures failed"):
with executor._sync_errors("mixed failures"):
if rank == 0:
raise torch.OutOfMemoryError("retryable")
raise ValueError("not retryable")
torch.testing.assert_close(executor.execute(tensor, operator), tensor * 2)
finally:
dist.destroy_process_group()
def test_all_ranks_observe_failures_and_can_retry(tmp_path):
torch.multiprocessing.spawn(run_recovery_worker, args=(str(tmp_path / "rendezvous"),), nprocs=2, join=True)
@@ -3,21 +3,21 @@
import contextlib
import huggingface_hub
import pytest
from tests.helpers.mock import patch_hf_snapshot_download
from vllm_omni.diffusion.model_loader import hub_prefetch
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
def test_prefetch_subfolders_propagates_revision(monkeypatch):
def test_prefetch_subfolders_propagates_revision(monkeypatch, tmp_path):
calls = []
def fake_snapshot_download(self, **kwargs):
def fake_snapshot_download(**kwargs):
calls.append(kwargs)
monkeypatch.setattr(huggingface_hub.HfApi, "snapshot_download", fake_snapshot_download)
patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path)
monkeypatch.setattr(hub_prefetch, "_repo_prefetch_lock", lambda _model: contextlib.nullcontext())
hub_prefetch.prefetch_subfolders(
@@ -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
"""Multi-image input regression for HunyuanImage3 IT2I prompt construction.
The official HunyuanImage-3.0-Instruct supports up to 3 reference images
@@ -41,7 +41,7 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class FakeTokenizer:
"""Recording fake tokenizer mirroring the one in test_prompt_utils.
"""Recording fake tokenizer mirroring the one in test_hunyuan_image3_prompt_utils.
Special token ids: `<|startoftext|>`=1, `<img>`=2, `<think>`=3,
`<recaption>`=4. encode() returns one id per character starting at
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import ast
@@ -171,7 +171,7 @@ def test_huggingface_url_resolves_pinned_snapshot(tmp_path, monkeypatch):
else pytest.fail("unexpected snapshot request")
)
monkeypatch.setattr(
"vllm.transformers_utils.repo_utils.hf_api",
"vllm_omni.transformers_utils.repo_utils.hf_api",
lambda: api,
)
assert _resolve_checkpoint_root(
@@ -1,13 +1,27 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from contextlib import contextmanager
from dataclasses import dataclass
from types import SimpleNamespace
import cache_dit
import pytest
import torch
from cache_dit import BlockAdapter
from torch import nn
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig, TransformerConfig
from vllm_omni.diffusion.cache.cachedit import CacheDiTBackend, RequestScopedCacheDiTRuntime
from vllm_omni.diffusion.cache.selector import get_cache_backend
from vllm_omni.diffusion.config import set_current_diffusion_config
from vllm_omni.diffusion.data import (
AttentionConfig,
DiffusionCacheConfig,
DiffusionOutput,
OmniDiffusionConfig,
TransformerConfig,
)
from vllm_omni.diffusion.models.interface import adopt_request_scoped_cache_dit
from vllm_omni.diffusion.models.mammoth_moda2 import pipeline_mammothmoda2_dit
from vllm_omni.diffusion.models.mammoth_moda2.pipeline_mammothmoda2_dit import (
MammothModa2DiTPipeline,
@@ -17,6 +31,7 @@ from vllm_omni.diffusion.models.mammoth_moda2.pipeline_mammothmoda2_dit import (
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.platforms import current_omni_platform
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -110,6 +125,10 @@ def _pipeline_shell() -> MammothModa2DiTPipeline:
pipeline.device = torch.device("cpu")
pipeline.config = _build_mammoth_config(_od_config())
pipeline._llm_hidden_size = 8
# Cache-DiT runtime state that __init__ normally creates (disabled here,
# matching a pipeline built without a cache_backend).
pipeline._cache_dit_runtime = RequestScopedCacheDiTRuntime(pipeline)
pipeline._cache_dit_config = None
return pipeline
@@ -118,12 +137,14 @@ def _batch(
request_id: str = "req-7",
prompt: object | None = None,
sampling: OmniDiffusionSamplingParams | None = None,
height: int = 32,
width: int = 48,
) -> DiffusionRequestBatch:
if prompt is None:
prompt = {
"prompt": "",
"height": 32,
"width": 48,
"height": height,
"width": width,
"additional_information": {
"full_hidden_states": torch.arange(32, dtype=torch.float32).reshape(4, 8),
"full_token_ids": [10, 11, 100, 101],
@@ -390,3 +411,162 @@ def test_forward_rejects_missing_visual_tokens_before_model_access() -> None:
}
with pytest.raises(ValueError, match="no visual-token hidden states.*req-empty"):
_pipeline_shell().forward(_batch(request_id="req-empty", prompt=prompt))
@contextmanager
def _force_torch_sdpa():
"""Pin TORCH_SDPA so CPU shape tests do not pick CUDA-only backends (FA3)."""
od_config = SimpleNamespace(
diffusion_attention_config=AttentionConfig(default="TORCH_SDPA"),
parallel_config=SimpleNamespace(ring_degree=1),
)
with set_current_diffusion_config(od_config):
yield
def _constructible_raw_config() -> dict:
"""Checkpoint config whose DiT/VAE are small enough to actually run on CPU."""
return {
"model_type": "mammothmoda2",
"llm_config": {
"model_type": "mammothmoda2_qwen2_5_vl",
"text_config": {
"model_type": "mammothmoda2_qwen2_5_vl_text",
"hidden_size": 8,
"gen_vocab_start_index": 100,
},
},
"gen_vae_config": {"block_out_channels": [8, 8], "norm_num_groups": 8},
"gen_dit_config": {
"patch_size": 2,
"in_channels": 4,
"hidden_size": 96,
"num_layers": 4,
"num_refiner_layers": 1,
"num_attention_heads": 2,
"num_kv_heads": 2,
"multiple_of": 8,
"axes_dim_rope": [16, 16, 16],
"axes_lens": [300, 512, 512],
"text_feat_dim": 16,
},
# Top-level axes feed the pipeline-owned rotary table (see pipeline __init__).
"gen_axes_dim_rope": [16, 16, 16],
"gen_axes_lens": [300, 512, 512],
}
def _cache_dit_od_config() -> OmniDiffusionConfig:
return OmniDiffusionConfig(
model="/models/MammothModa2-Preview",
model_class_name="MammothModa2DiTPipeline",
tf_model_config=TransformerConfig.from_dict(_constructible_raw_config()),
cache_backend="cache_dit",
cache_config=DiffusionCacheConfig(),
)
@pytest.mark.skipif(
current_omni_platform.is_rocm(),
reason="vLLM ROCm custom ops lack CPU fallback",
)
def test_forward_transitions_request_scoped_cache_dit_across_requests(request: pytest.FixtureRequest) -> None:
"""Cache-DiT request lifecycle on one production-built pipeline instance.
Mirrors the diffusion runner startup (``get_cache_backend`` → ``enable`` →
``adopt_request_scoped_cache_dit``) on a tiny pipeline built through the
real ``__init__``, then drives three requests through the real ``forward()``:
1. a partial-CFG request installs the request-scoped cache context:
cond/uncond forwards stay paired on every denoise step (parity
accounting), even outside ``cfg_range``, and blocks record cached steps;
2. a guidance=1.0 request runs with hooks disabled (``prepare(None)``) and
its output is bit-identical to a reference captured before the backend
was ever enabled, proving a clean uninstall with no stale module state;
3. a CFG request with a different step count and image size re-installs the
hooks through a fresh context, so no stale shape or step count survives.
"""
torch.manual_seed(0) # Deterministic random init → deterministic cache hits.
with _force_torch_sdpa():
pipeline = MammothModa2DiTPipeline(od_config=_cache_dit_od_config())
pipeline.eval()
assert pipeline._cache_dit_config is not None
request.addfinalizer(pipeline._cache_dit_runtime.disable)
# The default prompt carries 2 text + 2 visual condition tokens, so the
# conditional pass sees 4 tokens and the uncond pass (empty prompt) sees 0.
cond_tokens, uncond_tokens = 4, 0
seq_lens: list[int] = []
def record_call(_module, _args, kwargs):
text = kwargs.get("text_hidden_states")
seq_lens.append(int(text.shape[1]) if text is not None else -1)
hook = pipeline.gen_transformer.register_forward_pre_hook(record_call, with_kwargs=True)
unguided_sampling = OmniDiffusionSamplingParams(
height=32, width=48, seed=4321, guidance_scale=1.0, num_inference_steps=8
)
partial_cfg_sampling = OmniDiffusionSamplingParams(
height=32,
width=48,
seed=1234,
guidance_scale=4.0,
num_inference_steps=8,
extra_args={"cfg_range": [0.2, 0.8]},
)
changed_cfg_sampling = OmniDiffusionSamplingParams(
height=48,
width=64,
seed=777,
guidance_scale=6.0,
num_inference_steps=10,
extra_args={"cfg_range": [0.1, 0.9]},
)
# Reference run for phase 2: identical request while hooks were never
# installed on this pipeline.
assert not pipeline.is_cache_dit_enabled()
reference = pipeline.forward(_batch(sampling=unguided_sampling)).output
assert seq_lens == [cond_tokens] * 8
assert torch.isfinite(reference.float()).all()
# Runner startup: enable once, then the pipeline owns all transitions.
backend = get_cache_backend("cache_dit", DiffusionCacheConfig())
assert isinstance(backend, CacheDiTBackend)
backend.enable(pipeline)
assert adopt_request_scoped_cache_dit(pipeline, backend)
assert pipeline.is_cache_dit_enabled()
# Phase 1: partial CFG keeps cond/uncond paired on every step, including
# the steps whose normalized index falls outside cfg_range [0.2, 0.8].
seq_lens.clear()
pipeline.forward(_batch(sampling=partial_cfg_sampling))
assert seq_lens == [cond_tokens, uncond_tokens] * 8
assert pipeline.is_cache_dit_enabled()
assert BlockAdapter.is_cached(pipeline.gen_transformer)
hits = cache_dit.summary(pipeline.gen_transformer, logging=False)[0]
assert hits.cached_steps or hits.cfg_cached_steps
# Phase 2: no-CFG request disables the hooks and must reproduce the
# never-enabled reference bit-for-bit.
seq_lens.clear()
unguided = pipeline.forward(_batch(sampling=unguided_sampling)).output
assert seq_lens == [cond_tokens] * 8
assert not pipeline.is_cache_dit_enabled()
assert not BlockAdapter.is_cached(pipeline.gen_transformer)
assert torch.equal(unguided, reference)
# Phase 3: CFG with a new step count and image size re-installs the hooks
# on a fresh context; output shape follows the new request geometry.
seq_lens.clear()
regenerated = pipeline.forward(_batch(sampling=changed_cfg_sampling, height=48, width=64)).output
assert seq_lens == [cond_tokens, uncond_tokens] * 10
assert pipeline.is_cache_dit_enabled()
assert BlockAdapter.is_cached(pipeline.gen_transformer)
hits = cache_dit.summary(pipeline.gen_transformer, logging=False)[0]
assert hits.cached_steps or hits.cfg_cached_steps
assert regenerated.shape == (1, 3, 12, 16)
assert torch.isfinite(regenerated.float()).all()
hook.remove()
@@ -16,6 +16,7 @@ from tests.diffusion.quantization.test_quantization_quality import (
_maybe_save_output,
)
from tests.helpers.mark import hardware_marks
from tests.helpers.mock import patch_hf_snapshot_download
_MINIMAX_H3_REPO = "MiniMaxAI/MiniMax-H3"
_MINIMAX_H3_REVISION = "48d93ede732756e404a3b1b2f3b3a9b5a22f6cfc"
@@ -258,13 +259,13 @@ def test_resolve_fl2va_model_ref(tmp_path, monkeypatch):
fl2va_root.mkdir()
(fl2va_root / "model_index.json").write_text("{}", encoding="utf-8")
def fake_snapshot_download(self, *, repo_id, revision, allow_patterns):
def fake_snapshot_download(*, repo_id, revision, allow_patterns):
assert repo_id == _MINIMAX_H3_REPO
assert revision == _MINIMAX_H3_REVISION
assert allow_patterns == ["FL2VA/**"]
return str(tmp_path)
monkeypatch.setattr("huggingface_hub.HfApi.snapshot_download", fake_snapshot_download)
patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path)
assert _resolve_fl2va_model_ref() == str(fl2va_root)
@@ -0,0 +1,52 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from types import MethodType, SimpleNamespace
from unittest.mock import Mock
import pytest
import torch
from vllm_omni.diffusion.models.nextstep_1_1 import pipeline_nextstep_1_1 as pipeline_module
from vllm_omni.diffusion.models.nextstep_1_1.modeling_nextstep import NextStepModel
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
@pytest.mark.parametrize("height,width", [(32, 32), (32, 48), (48, 32)])
@pytest.mark.parametrize("batch", [1, 2])
def test_forward_preserves_latent_grid(monkeypatch, height, width, batch):
pipeline = object.__new__(pipeline_module.NextStep11Pipeline)
torch.nn.Module.__init__(pipeline)
pipeline._NextStep11Pipeline__device = torch.device("cpu")
pipeline.config = SimpleNamespace(use_gen_pos_embed=False)
pipeline.down_factor = 16
pipeline.scaling_factor = 2.0
pipeline.shift_factor = 0.25
grid = SimpleNamespace(config=SimpleNamespace(latent_channels=2, latent_patch_size=2))
expected = torch.arange(batch * 2 * (height // 8) * (width // 8), dtype=torch.float32)
expected = expected.reshape(batch, 2, height // 8, width // 8)
tokens = NextStepModel.patchify(grid, expected)
pipeline.model = Mock(
return_value=SimpleNamespace(last_hidden_state=torch.zeros(batch, 1, 4), past_key_values=None)
)
pipeline.model.forward_model.return_value = pipeline.model.return_value
pipeline.model.unpatchify = MethodType(NextStepModel.unpatchify, grid)
pipeline.tokenizer = Mock(
bos_token=None,
return_value=SimpleNamespace(
input_ids=torch.ones(batch, 2, dtype=torch.long), attention_mask=torch.ones(batch, 2, dtype=torch.long)
),
)
pipeline._check_input = Mock(return_value=(["teapot"], None))
pipeline._build_captions = Mock(return_value=(["teapot"] * batch, None, 1, 1.0))
pipeline._add_prefix_ids = lambda _hw, ids, mask: (ids, mask)
pipeline.decoding = Mock(return_value=tokens)
pipeline.vae = SimpleNamespace(dtype=torch.float32, decode=lambda latent: SimpleNamespace(sample=latent))
monkeypatch.setattr(pipeline_module, "StaticCache", lambda **_kwargs: None)
params = OmniDiffusionSamplingParams(
height=height, width=width, guidance_scale=1.0, num_outputs_per_prompt=batch, seed=None
)
output = pipeline.forward(SimpleNamespace(prompts=["teapot"], sampling_params=params))
torch.testing.assert_close(output.output, expected / 2 + 0.25, rtol=0, atol=0)
@@ -0,0 +1,192 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import json
from types import SimpleNamespace
import pytest
from PIL import Image
from vllm_omni.diffusion.models.qwen_image_21.pipeline_qwen_image_21 import (
get_qwen_image_21_pre_process_func,
)
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.sched import DiffusionRequestStatus, RequestScheduler, StepScheduler
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
@pytest.fixture
def preprocess(tmp_path):
vae_dir = tmp_path / "vae"
vae_dir.mkdir()
(vae_dir / "config.json").write_text(json.dumps({"temperal_downsample": [False] * 4}))
return get_qwen_image_21_pre_process_func(SimpleNamespace(model=str(tmp_path)))
def make_request(request_id, sizes, color="red"):
return OmniDiffusionRequest(
request_id=request_id,
prompt={
"prompt": "A ceramic teapot",
"multi_modal_data": {"image": [Image.new("RGB", size, color) for size in sizes]},
},
sampling_params=OmniDiffusionSamplingParams(
height=1024, width=1024, num_inference_steps=2, true_cfg_scale=1.0, seed=42
),
)
@pytest.mark.parametrize("scheduler_class", [RequestScheduler, StepScheduler])
@pytest.mark.parametrize(
"first_sizes,second_sizes,compatible",
[
pytest.param([], [(64, 64)], False, id="text-and-edit"),
pytest.param([(64, 64)], [], False, id="edit-and-text"),
pytest.param([(64, 64)], [(64, 64), (64, 64)], False, id="image-count"),
pytest.param([(64, 64)], [(128, 64)], False, id="image-aspect-ratio"),
pytest.param([(64, 64), (128, 64)], [(128, 64), (64, 64)], False, id="slot-order"),
pytest.param([], [], True, id="two-text-requests"),
pytest.param([(64, 64)], [(64, 64)], True, id="different-image-content"),
pytest.param([(64, 64)], [(128, 128)], True, id="same-resized-layout"),
pytest.param([(64, 64), (128, 64)], [(128, 128), (256, 128)], True, id="same-multi-image-layout"),
],
)
def test_scheduler_groups_by_condition_layout(preprocess, scheduler_class, first_sizes, second_sizes, compatible):
first = preprocess(make_request("first", first_sizes))
second = preprocess(make_request("second", second_sizes, color="blue"))
scheduler = scheduler_class()
scheduler.initialize(SimpleNamespace(max_num_seqs=2))
scheduler.add_request(first)
scheduler.add_request(second)
scheduled = scheduler.schedule()
expected_ids = ["first", "second"] if compatible else ["first"]
assert scheduled.scheduled_request_ids == expected_ids
scheduler.finish_requests(expected_ids, DiffusionRequestStatus.FINISHED_COMPLETED)
if not compatible:
assert scheduler.schedule().scheduled_request_ids == ["second"]
scheduler.finish_requests("second", DiffusionRequestStatus.FINISHED_COMPLETED)
assert not scheduler.has_requests()
@pytest.mark.parametrize(
"prompt", ["A teapot", {"prompt": "A teapot"}, {"prompt": "A teapot", "multi_modal_data": {"image": None}}]
)
def test_text_input_forms_share_compatibility_key(preprocess, prompt):
request = make_request("text", [])
request.prompt = prompt
processed = preprocess(request)
empty_list_request = preprocess(make_request("empty-list", []))
assert processed.batch_compatibility_key is not None
assert processed.batch_compatibility_key == empty_list_request.batch_compatibility_key
@pytest.mark.parametrize("image_type", ["numpy", "tensor"])
def test_preprocess_accepts_array_images_without_truth_testing(preprocess, image_type):
import numpy as np
import torch
pixels = np.full((32, 32, 4), 255, dtype=np.uint8)
image = torch.from_numpy(pixels).permute(2, 0, 1) if image_type == "tensor" else pixels
request = make_request("array", [])
request.prompt["multi_modal_data"]["image"] = image
processed = preprocess(request)
assert processed.batch_compatibility_key is not None
assert processed.allow_mixed_step_phases is False
def test_late_prefill_waits_until_all_decode_requests_finish(preprocess):
from vllm_omni.diffusion.worker.utils import BatchRunnerOutput, RunnerOutput
scheduler = StepScheduler()
scheduler.initialize(SimpleNamespace(max_num_seqs=3))
for request_id in ("a", "b"):
scheduler.add_request(preprocess(make_request(request_id, [])))
wave = scheduler.schedule()
assert wave.scheduled_request_ids == ["a", "b"]
scheduler.update_from_output(
wave,
BatchRunnerOutput.from_list(
[RunnerOutput(request_id=request_id, step_index=1, finished=False) for request_id in ("a", "b")]
),
)
scheduler.add_request(preprocess(make_request("late", [])))
assert scheduler.schedule().scheduled_request_ids == ["a", "b"]
scheduler.finish_requests("a", DiffusionRequestStatus.FINISHED_COMPLETED)
assert scheduler.schedule().scheduled_request_ids == ["b"]
scheduler.finish_requests("b", DiffusionRequestStatus.FINISHED_COMPLETED)
assert scheduler.schedule().scheduled_request_ids == ["late"]
def test_preprocess_normalizes_tuple_images_like_a_list(preprocess):
image = Image.new("RGB", (64, 64), "red")
tuple_request = make_request("tuple", [])
tuple_request.prompt["multi_modal_data"]["image"] = (image,)
processed = preprocess(tuple_request)
reference = preprocess(make_request("list", [(64, 64)]))
assert processed.batch_compatibility_key == reference.batch_compatibility_key
assert processed.batch_compatibility_key[0] == "qwen_image_21"
assert len(processed.batch_compatibility_key[1]) == 1
info = processed.prompt["additional_information"]
assert len(info["prompt_image"]) == 1
assert len(info["vae_images"]) == 1
assert len(info["input_image_sizes"]) == 1
def test_preprocess_rejects_more_images_than_the_model_supports(preprocess):
from vllm_omni.diffusion.models.qwen_image_21.pipeline_qwen_image_21 import (
MAX_QWEN_IMAGE_21_INPUT_IMAGES,
)
request = make_request("too-many", [(64, 64)] * (MAX_QWEN_IMAGE_21_INPUT_IMAGES + 1))
with pytest.raises(ValueError, match="At most"):
preprocess(request)
def test_preprocess_defaults_true_cfg_scale(preprocess):
request = make_request("cfg", [])
request.sampling_params.true_cfg_scale = None
processed = preprocess(request)
assert processed.sampling_params.true_cfg_scale == 1.0
request = make_request("cfg-set", [])
request.sampling_params.true_cfg_scale = 4.0
assert preprocess(request).sampling_params.true_cfg_scale == 4.0
@pytest.mark.parametrize("size,expected_wider", [((128, 64), True), ((64, 128), False)])
def test_preprocess_derives_output_size_from_condition_aspect(preprocess, size, expected_wider):
request = make_request("aspect", [size])
request.sampling_params.height = None
request.sampling_params.width = None
processed = preprocess(request)
height = processed.sampling_params.height
width = processed.sampling_params.width
assert height % 32 == 0 and width % 32 == 0
assert (width > height) is expected_wider
info = processed.prompt["additional_information"]
assert info["calculated_height"] == height
assert info["calculated_width"] == width
def test_preprocess_stages_rgba_vae_images_and_resized_prompt_images(preprocess):
processed = preprocess(make_request("staged", [(96, 64)]))
info = processed.prompt["additional_information"]
(vae_image,) = info["vae_images"]
# RGB input gains an opaque alpha channel for the RGBA VAE; the frame dim is
# inserted for the 3D (video-capable) VAE.
assert vae_image.shape[1] == 4
assert vae_image.shape[2] == 1
(prompt_image,) = info["prompt_image"]
assert isinstance(prompt_image, Image.Image)
(width, height) = info["input_image_sizes"][0]
assert prompt_image.size == (width, height)
assert processed.batch_compatibility_key == ("qwen_image_21", ((width, height),))
@@ -0,0 +1,318 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import weakref
import pytest
import torch
from vllm_omni.diffusion.hooks import HookRegistry, ModelHook
from vllm_omni.diffusion.models.qwen_image_21 import decode_graph
from vllm_omni.diffusion.offloader.sequential_backend import SequentialOffloadHook
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
class DecodeModel(torch.nn.Module):
in_channels = 4
def _decode_graph_forward(self, entry):
return (
entry.hidden.sin()
+ entry.k[0].mean(dim=(1, 2, 3))[:, None, None]
+ entry.v[0].mean(dim=(1, 2, 3))[:, None, None]
+ entry.freqs[None, :, :]
)
@pytest.fixture
def graph_case(request, monkeypatch):
device = "cuda" if request.node.get_closest_marker("cuda") else "cpu"
manager = decode_graph.QwenImage21DecodeGraphManager(DecodeModel(), max_entries=2)
monkeypatch.setattr(manager, "eligible", lambda: True)
monkeypatch.setattr(manager, "_backend_name", lambda: "test")
cleanup = []
monkeypatch.setattr(decode_graph.current_omni_platform, "empty_cache", lambda: cleanup.append(True))
return manager, device, cleanup
def make_cache(device, value):
return [
{
"cond": {
"key": torch.full((1, 2, 1, 4), value, device=device),
"value": torch.full((1, 2, 1, 4), value + 1, device=device),
}
}
]
def register(manager, cache, layout=(1, 2, 2)):
device = cache[0]["cond"]["key"].device
manager.register_prefill(
kv_cache=cache,
cache_branch="cond",
prefix_len=2,
img_shapes=[layout],
target_freqs=torch.full((4, 4), float(layout[1]), device=device),
joint_key_valid=None,
dtype=torch.float32,
)
def decode(manager, cache, layout=(1, 2, 2)):
device = cache[0]["cond"]["key"].device
return manager.try_decode(
hidden_states=torch.ones(1, 4, 4, device=device),
timestep=torch.ones(1, device=device),
kv_cache=cache,
cache_branch="cond",
img_shapes=[layout],
img_mask=torch.tensor([[False, False, True]], device=device),
encoder_hidden_states_mask=None,
)
@pytest.mark.cpu
def test_model_level_offload_without_staging_disables_decode_graphs():
manager = decode_graph.QwenImage21DecodeGraphManager(DecodeModel(), model_level_offload=True)
assert manager._offload_reason() is not None
assert manager.eligible() is False
@pytest.mark.cpu
def test_top_level_offload_hook_without_staging_disables_decode_graphs():
model = DecodeModel()
manager = decode_graph.QwenImage21DecodeGraphManager(model)
registry = HookRegistry.get_or_create(model)
# A present-but-empty registry (e.g. after hooks were removed) must not disqualify.
assert manager._offload_reason() is None
registry.register_hook("sequential_offload", ModelHook())
assert manager._offload_reason() is not None
assert manager.eligible() is False
@pytest.mark.cpu
def test_persistent_staging_makes_model_level_offload_graph_eligible():
model = DecodeModel()
manager = decode_graph.QwenImage21DecodeGraphManager(model, model_level_offload=True)
hook = SequentialOffloadHook(offload_targets=[], device=torch.device("cpu"))
HookRegistry.get_or_create(model).register_hook(SequentialOffloadHook._HOOK_NAME, hook)
# No staging storage yet: a swap would allocate fresh storage per generation.
assert manager._offload_reason() is not None
# Staging established: fixed device storage keeps captured pointers valid.
hook._stager = object()
assert manager._offload_reason() is None
# A second, non-offload hook on the top-level module still disqualifies.
model._hook_registry.register_hook("teacache", ModelHook())
assert manager._offload_reason() is not None
@pytest.mark.cpu
def test_allocation_oom_releases_partial_buffers_and_remembers_failure(graph_case, monkeypatch):
manager, device, cleanup = graph_case
cache = make_cache(device, 1.0)
constructor = decode_graph.QwenImage21DecodeGraphEntry
allocated = []
def fail_allocation(**kwargs):
entry = constructor(**kwargs)
allocated.append(weakref.ref(entry.k[0]))
raise torch.OutOfMemoryError("injected allocation OOM")
monkeypatch.setattr(decode_graph, "QwenImage21DecodeGraphEntry", fail_allocation)
register(manager, cache)
register(manager, cache)
assert len(allocated) == 1
assert allocated[0]() is None
assert list(manager.entries.values()) == [None]
assert len(cleanup) == 1
assert torch.equal(cache[0]["cond"]["key"], torch.ones(1, 2, 1, 4))
@pytest.mark.cpu
def test_evict_before_allocating_replacement(graph_case, monkeypatch):
manager, device, _ = graph_case
manager.max_entries = 1
cache = make_cache(device, 1.0)
register(manager, cache)
old_buffer = weakref.ref(next(iter(manager.entries.values())).k[0])
constructor = decode_graph.QwenImage21DecodeGraphEntry
def allocate_after_release(**kwargs):
assert old_buffer() is None
return constructor(**kwargs)
monkeypatch.setattr(decode_graph, "QwenImage21DecodeGraphEntry", allocate_after_release)
register(manager, cache, (1, 1, 4))
assert len(manager.entries) == 1
@pytest.mark.cpu
def test_registration_preserves_request_cache_and_separates_layouts(graph_case):
manager, device, _ = graph_case
first, second = make_cache(device, 1.0), make_cache(device, 5.0)
original = first[0]["cond"]["key"]
register(manager, first)
register(manager, second)
assert first[0]["cond"]["key"] is original
assert torch.equal(original, torch.ones_like(original))
register(manager, second, (1, 1, 4))
assert len(manager.entries) == 2
entries = list(manager.entries.values())
assert not torch.equal(entries[0].freqs, entries[1].freqs)
@pytest.mark.cuda
@pytest.mark.gpu
@pytest.mark.parametrize("failure_phase", ["warmup", "capture"])
def test_capture_failure_releases_buffers_and_keeps_request_cache(graph_case, monkeypatch, failure_phase):
manager, device, cleanup = graph_case
cache = make_cache(device, 1.0)
register(manager, cache)
entry = next(iter(manager.entries.values()))
entry_ref, buffer_ref = weakref.ref(entry), weakref.ref(entry.k[0])
del entry
original = manager.model._decode_graph_forward
def fail_capture(entry):
output = original(entry)
if failure_phase == "warmup" or torch.cuda.is_current_stream_capturing():
raise torch.OutOfMemoryError("injected capture OOM")
return output
monkeypatch.setattr(manager.model, "_decode_graph_forward", fail_capture)
assert decode(manager, cache) is None
assert entry_ref() is None and buffer_ref() is None
assert list(manager.entries.values()) == [None]
register(manager, cache)
assert decode(manager, cache) is None
assert len(cleanup) == 1
assert bool((cache[0]["cond"]["key"] == 1).all())
@pytest.mark.cuda
@pytest.mark.gpu
def test_replay_uses_active_request_and_layout(graph_case):
manager, device, _ = graph_case
first, second = make_cache(device, 1.0), make_cache(device, 5.0)
first_key = weakref.ref(first[0]["cond"]["key"])
with torch.inference_mode():
register(manager, first)
# A second live request aliasing the same key is refused: it keeps its
# own cache and decodes eagerly while the owner is in flight.
register(manager, second)
assert decode(manager, second) is None
output = decode(manager, first)
expected = torch.ones_like(output).sin() + first[0]["cond"]["key"].mean() + first[0]["cond"]["value"].mean() + 2
torch.testing.assert_close(output, expected)
# Once the owner's cache is released, the key is free to adopt.
del first
assert first_key() is None
register(manager, second)
output = decode(manager, second)
expected = (
torch.ones_like(output).sin() + second[0]["cond"]["key"].mean() + second[0]["cond"]["value"].mean() + 2
)
torch.testing.assert_close(output, expected)
original = decode(manager, second).clone()
register(manager, second, (1, 1, 4))
other = decode(manager, second, (1, 1, 4))
torch.testing.assert_close(other, torch.ones_like(other).sin() + 5 + 6 + 1)
torch.testing.assert_close(decode(manager, second), original)
assert all(entry.captures == 1 for entry in manager.entries.values())
@pytest.mark.cpu
@pytest.mark.parametrize("inference", [False, True])
def test_prefix_reuse_refreshes_every_layer_without_retaining_requests(graph_case, inference):
manager, device, _ = graph_case
with torch.inference_mode(inference):
cache = make_cache(device, 1.0) + make_cache(device, 2.0)
register(manager, cache)
entry = next(iter(manager.entries.values()))
entry.refresh_prefix(cache)
versions = [tensor._version for tensor in entry.k + entry.v]
entry.refresh_prefix(cache)
assert versions == [tensor._version for tensor in entry.k + entry.v]
cache[1]["cond"]["value"] = torch.full_like(cache[1]["cond"]["value"], 7.0)
entry.refresh_prefix(cache)
assert bool((entry.v[1] == 7).all())
source = weakref.ref(cache[0]["cond"]["key"])
del cache
assert source() is None
@pytest.mark.cpu
def test_prefix_reuse_detects_inplace_changes_and_prefill_registration(graph_case):
manager, device, _ = graph_case
cache = make_cache(device, 1.0)
register(manager, cache)
entry = next(iter(manager.entries.values()))
entry.refresh_prefix(cache)
cache[0]["cond"]["value"].add_(3)
entry.refresh_prefix(cache)
assert bool((entry.v[0] == 5).all())
register(manager, cache)
assert not entry.prefix_sources
@pytest.mark.cpu
def test_all_valid_mask_shares_graph_entry_and_padding_falls_back(graph_case):
manager, device, _ = graph_case
cache = make_cache(device, 1.0)
register(manager, cache)
entry = next(iter(manager.entries.values()))
for valid in (True, False):
manager.register_prefill(
kv_cache=cache,
cache_branch="cond",
prefix_len=2,
img_shapes=[(1, 2, 2)],
target_freqs=torch.ones(4, 4),
joint_key_valid=torch.full((1, 6), valid),
dtype=torch.float32,
)
assert list(manager.entries.values()) == [entry]
assert entry.attn_metadata is None
@pytest.mark.cuda
@pytest.mark.gpu
def test_batch_changes_and_padding_do_not_reuse_stale_prefix(graph_case):
manager, device, _ = graph_case
first = make_cache(device, 1.0)
second = make_cache(device, 4.0)
merged = [
{"cond": {part: torch.cat([first[0]["cond"][part], second[0]["cond"][part]]) for part in ("key", "value")}}
]
with torch.inference_mode():
register(manager, first)
single = decode(manager, first).clone()
register(manager, merged)
kwargs = dict(
hidden_states=torch.ones(2, 4, 4, device=device),
timestep=torch.ones(2, device=device),
kv_cache=merged,
cache_branch="cond",
img_shapes=[(1, 2, 2)],
img_mask=torch.tensor([[False, False, True]] * 2, device=device),
encoder_hidden_states_mask=torch.ones(2, 3, dtype=torch.bool, device=device),
)
result = manager.try_decode(**kwargs)
assert result is not None and result.shape == (2, 4, 4)
expected = torch.ones_like(result).sin() + torch.tensor([5.0, 11.0], device=device)[:, None, None]
torch.testing.assert_close(result, expected)
# A rebuilt cache (e.g. reordered rows) is a different live cache
# aliasing the same key: decode refuses the shared entry until the
# previous owner's tensors are released.
flipped = [{"cond": {part: tensor.flip(0) for part, tensor in merged[0]["cond"].items()}}]
kwargs["kv_cache"] = flipped
assert manager.try_decode(**kwargs) is None
del merged
register(manager, flipped)
torch.testing.assert_close(manager.try_decode(**kwargs), expected.flip(0))
kwargs["encoder_hidden_states_mask"][1, 0] = False
assert manager.try_decode(**kwargs) is None
torch.testing.assert_close(decode(manager, first), single, rtol=0, atol=0)
@@ -0,0 +1,292 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""CPU unit tests for QwenImage21Pipeline helpers: prepare_latents duplication
order, KV-cache assembly across step phases, and the post-process func."""
import json
from types import SimpleNamespace
import pytest
import torch
from torch import nn
from vllm_omni.diffusion.models.qwen_image_21.pipeline_qwen_image_21 import (
QwenImage21Pipeline,
get_qwen_image_21_post_process_func,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
def _toy_pipeline() -> QwenImage21Pipeline:
"""A pipeline shell with the attributes prepare_latents reads, no weights."""
pipeline = QwenImage21Pipeline.__new__(QwenImage21Pipeline)
nn.Module.__init__(pipeline)
pipeline.vae_scale_factor = 16
pipeline.latent_channels = 4
return pipeline
# ---------------------------------------------------------------------------
# prepare_latents
# ---------------------------------------------------------------------------
def test_prepare_latents_duplicates_condition_latents_request_major():
"""batch=2 prompts x n=2 outputs must order condition latents A,A,B,B.
``encode_prompt`` expands prompt embeddings request-major (A,A,B,B); the
condition latents must follow the same order or generations 2 and 3 swap
condition images. ``repeat`` would produce the wrong (A,B,A,B) tile-major
order; ``repeat_interleave`` is correct.
"""
pipeline = _toy_pipeline()
cond_a = torch.full((1, 4, 1, 8, 8), 1.0)
cond_b = torch.full((1, 4, 1, 8, 8), 2.0)
stacked = torch.cat([cond_a, cond_b], dim=0) # one row per request
latents, image_latents = pipeline.prepare_latents(
[stacked],
batch_size=4,
num_channels_latents=4,
height=64,
width=64,
dtype=torch.float32,
device=torch.device("cpu"),
generator=torch.Generator().manual_seed(0),
)
assert latents.shape == (4, 16, 4) # packed: 4x4 latent tokens, 4 channels
assert image_latents.shape == (4, 64, 4) # packed: 8x8 condition tokens
row_means = image_latents.mean(dim=(1, 2))
torch.testing.assert_close(row_means, torch.tensor([1.0, 1.0, 2.0, 2.0]))
def test_prepare_latents_without_duplication_when_rows_match_batch():
pipeline = _toy_pipeline()
stacked = torch.arange(4, dtype=torch.float32).view(4, 1, 1, 1, 1).expand(4, 4, 1, 2, 2).contiguous()
_, image_latents = pipeline.prepare_latents(
[stacked],
batch_size=4,
num_channels_latents=4,
height=32,
width=32,
dtype=torch.float32,
device=torch.device("cpu"),
generator=torch.Generator().manual_seed(0),
)
torch.testing.assert_close(image_latents.mean(dim=(1, 2)), torch.arange(4, dtype=torch.float32))
def test_prepare_latents_rejects_non_divisible_duplication():
pipeline = _toy_pipeline()
stacked = torch.zeros(2, 4, 1, 8, 8)
with pytest.raises(ValueError, match="Cannot duplicate"):
pipeline.prepare_latents(
[stacked],
batch_size=3,
num_channels_latents=4,
height=64,
width=64,
dtype=torch.float32,
device=torch.device("cpu"),
generator=torch.Generator().manual_seed(0),
)
def test_prepare_latents_rejects_generator_list_length_mismatch():
pipeline = _toy_pipeline()
with pytest.raises(ValueError, match="list of generators"):
pipeline.prepare_latents(
None,
batch_size=2,
num_channels_latents=4,
height=32,
width=32,
dtype=torch.float32,
device=torch.device("cpu"),
generator=[torch.Generator().manual_seed(0)],
)
def test_prepare_latents_concatenates_multiple_condition_images():
pipeline = _toy_pipeline()
first = torch.full((2, 4, 1, 4, 4), 1.0)
second = torch.full((2, 4, 1, 2, 2), 3.0)
_, image_latents = pipeline.prepare_latents(
[first, second],
batch_size=2,
num_channels_latents=4,
height=32,
width=32,
dtype=torch.float32,
device=torch.device("cpu"),
generator=torch.Generator().manual_seed(0),
)
# Token dim concatenates the packed condition images: 16 + 4 per row.
assert image_latents.shape == (2, 20, 4)
torch.testing.assert_close(image_latents[:, :16].mean(), torch.tensor(1.0))
torch.testing.assert_close(image_latents[:, 16:].mean(), torch.tensor(3.0))
# ---------------------------------------------------------------------------
# KV-cache assembly across step phases
# ---------------------------------------------------------------------------
def _state(cache) -> SimpleNamespace:
return SimpleNamespace(extra={"kv_cache": cache})
def _decode_cache(rows: int, value: float) -> list[dict]:
return [
{
"cond": {
"key": torch.full((rows, 2), value),
"value": torch.full((rows, 2), value + 0.5),
}
}
]
def test_kv_cache_phase_classification():
assert QwenImage21Pipeline._kv_cache_phase(None) == "none"
assert QwenImage21Pipeline._kv_cache_phase([{}]) == "prefill"
assert QwenImage21Pipeline._kv_cache_phase(_decode_cache(1, 0.0)) == "decode"
def test_assemble_kv_cache_all_none():
assert QwenImage21Pipeline._assemble_kv_cache([_state(None), _state(None)]) == (None, False)
def test_assemble_kv_cache_prefill_returns_fresh_owned_cache():
states = [_state([{} for _ in range(3)]), _state([{} for _ in range(3)])]
kv_cache, take_ownership = QwenImage21Pipeline._assemble_kv_cache(states)
assert take_ownership is True
assert kv_cache == [{}, {}, {}]
# Fresh blocks, not aliases of either request's cache: the batched prefill
# writes into these and _scatter_kv_cache splits them back per request.
for block in kv_cache:
assert all(block is not request_block for state in states for request_block in state.extra["kv_cache"])
def test_assemble_kv_cache_decode_concatenates_in_request_order():
first, second = _decode_cache(2, 1.0), _decode_cache(1, 3.0)
states = [_state(first), _state(second)]
kv_cache, take_ownership = QwenImage21Pipeline._assemble_kv_cache(states)
assert take_ownership is False
assert len(kv_cache) == 1
cond = kv_cache[0]["cond"]
torch.testing.assert_close(cond["key"], torch.tensor([[1.0, 1.0], [1.0, 1.0], [3.0, 3.0]]))
torch.testing.assert_close(cond["value"], torch.tensor([[1.5, 1.5], [1.5, 1.5], [3.5, 3.5]]))
# Decode only reads the cache: per-request tensors must stay untouched.
assert first[0]["cond"]["key"].shape == (2, 2)
assert second[0]["cond"]["key"].shape == (1, 2)
def test_assemble_kv_cache_decode_merges_every_part():
first = [{"cond": {"key": torch.ones(1, 1), "value": torch.ones(1, 1), "key_scale": torch.ones(1)}}]
second = [{"cond": {"key": torch.zeros(1, 1), "value": torch.zeros(1, 1), "key_scale": torch.zeros(1)}}]
kv_cache, take_ownership = QwenImage21Pipeline._assemble_kv_cache([_state(first), _state(second)])
assert take_ownership is False
assert set(kv_cache[0]["cond"]) == {"key", "value", "key_scale"}
torch.testing.assert_close(kv_cache[0]["cond"]["key_scale"], torch.tensor([1.0, 0.0]))
def test_assemble_kv_cache_mixed_phases_raises():
"""Defensive backstop: a late prefill must never join a decoding batch.
The preprocessor sets ``allow_mixed_step_phases = False`` so the
StepScheduler defers late requests until the batch finishes decoding; this
raise is the last line of defense if a mixed batch is ever assembled.
"""
states = [_state([{}]), _state(_decode_cache(1, 0.0))]
with pytest.raises(ValueError, match="mixed KV-cache phases"):
QwenImage21Pipeline._assemble_kv_cache(states)
with pytest.raises(ValueError, match="mixed KV-cache phases"):
QwenImage21Pipeline._assemble_kv_cache([_state(None), _state(_decode_cache(1, 0.0))])
def test_scatter_kv_cache_splits_rows_back_per_request():
states = [_state([{}]), _state([{}])]
merged = [
{
"cond": {
"key": torch.tensor([[1.0], [2.0], [3.0]]),
"value": torch.tensor([[4.0], [5.0], [6.0]]),
}
}
]
QwenImage21Pipeline._scatter_kv_cache(states, [1, 2], merged)
torch.testing.assert_close(states[0].extra["kv_cache"][0]["cond"]["key"], torch.tensor([[1.0]]))
torch.testing.assert_close(states[0].extra["kv_cache"][0]["cond"]["value"], torch.tensor([[4.0]]))
torch.testing.assert_close(states[1].extra["kv_cache"][0]["cond"]["key"], torch.tensor([[2.0], [3.0]]))
assert states[1].extra["kv_cache"][0]["cond"]["key"].is_contiguous()
# ---------------------------------------------------------------------------
# post-process func
# ---------------------------------------------------------------------------
@pytest.fixture
def postprocess(tmp_path):
vae_dir = tmp_path / "vae"
vae_dir.mkdir()
(vae_dir / "config.json").write_text(json.dumps({"temperal_downsample": [False] * 4}))
return get_qwen_image_21_post_process_func(SimpleNamespace(model=str(tmp_path)))
def test_postprocess_tensor_returns_pil_images(postprocess):
from PIL import Image
images = torch.rand(2, 4, 16, 16) * 2 - 1
result = postprocess(images)
assert len(result) == 2
assert all(isinstance(image, Image.Image) for image in result)
def test_postprocess_envelope_unwraps_payload_and_keeps_metadata(postprocess):
from PIL import Image
envelope = {
"payload": {"image": torch.rand(1, 4, 16, 16), "seed": 42},
"metadata": {"request_id": "abc"},
}
result = postprocess(envelope)
assert result["metadata"] == {"request_id": "abc"}
assert result["payload"]["seed"] == 42
assert len(result["payload"]["image"]) == 1
assert isinstance(result["payload"]["image"][0], Image.Image)
def test_postprocess_envelope_requires_image_payload(postprocess):
with pytest.raises(ValueError, match=r"payload\['image'\]"):
postprocess({"payload": {"seed": 42}, "metadata": {}})
def test_postprocess_envelope_normalizes_non_dict_metadata(postprocess):
result = postprocess({"payload": {"image": torch.rand(1, 4, 16, 16)}, "metadata": "bogus"})
assert result["metadata"] == {}
@@ -0,0 +1,64 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""L1 tests for checkpoint-to-pipeline routing of Qwen-Image 2.1."""
import json
import pytest
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
def test_registry_entry_loads_the_pipeline_class():
from vllm_omni.diffusion.models.qwen_image_21 import QwenImage21Pipeline
from vllm_omni.diffusion.registry import DiffusionModelRegistry
assert DiffusionModelRegistry._try_load_model_cls("QwenImage21Pipeline") is QwenImage21Pipeline
def test_pipeline_declares_request_batching_and_step_execution():
from vllm_omni.diffusion.models.qwen_image_21 import QwenImage21Pipeline
assert QwenImage21Pipeline.supports_request_batch
assert QwenImage21Pipeline.supports_step_execution
def test_process_funcs_resolve_to_the_pipeline_module():
from vllm_omni.diffusion.models.qwen_image_21 import pipeline_qwen_image_21
from vllm_omni.diffusion.registry import _DIFFUSION_POST_PROCESS_FUNCS, _DIFFUSION_PRE_PROCESS_FUNCS
pre_name = _DIFFUSION_PRE_PROCESS_FUNCS["QwenImage21Pipeline"]
post_name = _DIFFUSION_POST_PROCESS_FUNCS["QwenImage21Pipeline"]
assert pre_name == "get_qwen_image_21_pre_process_func"
assert post_name == "get_qwen_image_21_post_process_func"
assert callable(getattr(pipeline_qwen_image_21, pre_name))
assert callable(getattr(pipeline_qwen_image_21, post_name))
def test_multimodal_metadata_caps_condition_images_at_four():
from vllm_omni.diffusion.model_metadata import QWEN_IMAGE_21_MAX_INPUT_IMAGES, get_diffusion_model_metadata
metadata = get_diffusion_model_metadata("QwenImage21Pipeline")
assert metadata.supports_multimodal_inputs
assert metadata.max_multimodal_image_inputs == 4
assert metadata.max_multimodal_image_inputs == QWEN_IMAGE_21_MAX_INPUT_IMAGES
def test_model_index_resolves_and_enriches(tmp_path):
from vllm_omni.diffusion.data import OmniDiffusionConfig, resolve_model_class_name
(tmp_path / "model_index.json").write_text(
json.dumps({"_class_name": "QwenImage21Pipeline"}),
encoding="utf-8",
)
assert resolve_model_class_name(str(tmp_path)) == "QwenImage21Pipeline"
config = OmniDiffusionConfig(model=str(tmp_path))
config.enrich_config()
assert config.model_class_name == "QwenImage21Pipeline"
assert config.supports_multimodal_inputs
assert config.max_multimodal_image_inputs == 4
@@ -0,0 +1,82 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from types import SimpleNamespace
import pytest
import torch
from vllm_omni.diffusion.models.qwen_image_21 import qwen_image_21_transformer as transformer
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
@pytest.mark.parametrize(
"device",
[pytest.param("cpu", marks=pytest.mark.cpu), pytest.param("cuda", marks=[pytest.mark.cuda, pytest.mark.gpu])],
)
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16])
def test_qk_norm_matches_diffusers_rounding(monkeypatch, device, dtype):
monkeypatch.setattr(
"vllm.distributed.parallel_state.get_tp_group", lambda: SimpleNamespace(rank_in_group=0, world_size=1)
)
monkeypatch.setattr(transformer, "Attention", lambda **kwargs: torch.nn.Identity())
attention = transformer.QwenImage21Attention(dim=256, heads=2, dim_head=128).to(device=device, dtype=dtype)
values = torch.randn(2, 17, 2, 128, generator=torch.Generator().manual_seed(42)).to(device=device, dtype=dtype)
for norm in (attention.norm_q, attention.norm_k):
with torch.no_grad():
norm.weight.copy_(torch.linspace(0.5, 1.5, 128, device=device, dtype=dtype))
variance = values.float().square().mean(-1, keepdim=True)
expected = (values * torch.rsqrt(variance + norm.eps)).to(dtype) * norm.weight
torch.testing.assert_close(norm(values), expected, rtol=0, atol=0)
@pytest.mark.parametrize(
"device",
[pytest.param("meta", marks=pytest.mark.cpu), pytest.param("cuda", marks=[pytest.mark.cuda, pytest.mark.gpu])],
)
def test_rope_frequencies_do_not_depend_on_loader_device(device):
reference = transformer.QwenImage21Rope(theta=10000, axes_dim=[16, 56, 56])
with torch.device(device):
loaded = transformer.QwenImage21Rope(theta=10000, axes_dim=[16, 56, 56])
for expected, actual in zip(reference.freqs, loaded.freqs):
assert actual.device.type == "cpu"
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
@pytest.mark.cuda
@pytest.mark.gpu
def test_scheduler_matches_diffusers_on_latent_device():
import numpy as np
from diffusers import FlowMatchEulerDiscreteScheduler
from vllm_omni.diffusion.models.qwen_image_21.pipeline_qwen_image_21 import QwenImage21Pipeline
pipeline = object.__new__(QwenImage21Pipeline)
torch.nn.Module.__init__(pipeline)
pipeline.device = torch.device("cuda", 0)
pipeline.scheduler = FlowMatchEulerDiscreteScheduler(use_dynamic_shifting=True)
timesteps, _ = pipeline.prepare_timesteps(50, None, 4096)
reference = FlowMatchEulerDiscreteScheduler.from_config(pipeline.scheduler.config)
reference.set_timesteps(sigmas=np.linspace(1.0, 1 / 50, 50), device=pipeline.device, mu=1.15)
sample = torch.randn(1, 16, 64, generator=torch.Generator().manual_seed(42)).to(
device=pipeline.device, dtype=torch.bfloat16
)
noise = sample.cos()
actual = pipeline.scheduler.step(noise, timesteps[0], sample, return_dict=False)[0]
expected = reference.step(noise, reference.timesteps[0], sample, return_dict=False)[0]
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
assert pipeline.scheduler.sigmas.device == sample.device
assert timesteps.device == sample.device
@pytest.mark.cuda
@pytest.mark.gpu
def test_timestep_frequencies_match_cpu_initialization():
reference = transformer.QwenImage21TemporalTimesteps(timestep_dim=256)
with torch.device("cuda"):
loaded = transformer.QwenImage21TemporalTimesteps(timestep_dim=256)
assert loaded.freqs.device.type == "cuda"
torch.testing.assert_close(loaded.freqs.cpu(), reference.freqs, rtol=0, atol=0)
timesteps = torch.tensor([0.0, 0.984375, 1.0], device="cuda")
torch.testing.assert_close(loaded(timesteps), reference.to("cuda")(timesteps), rtol=0, atol=0)
@@ -0,0 +1,206 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import math
import pytest
import torch
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_qwenimage21 import (
DistributedAutoencoderKLQwenImage21,
)
from vllm_omni.diffusion.models.qwen_image_21.autoencoder_kl_qwenimage21 import (
AutoencoderKLQwenImage21,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
@pytest.fixture(autouse=True)
def single_thread():
previous = torch.get_num_threads()
torch.set_num_threads(1)
yield
torch.set_num_threads(previous)
def _make_vae(cls=AutoencoderKLQwenImage21, **overrides):
"""Toy VAE with the 2.1 residual architecture; 4x spatial compression."""
kwargs = dict(
base_dim=16,
decoder_base_dim=16,
z_dim=4,
dim_mult=[1, 2, 2],
num_res_blocks=1,
temperal_downsample=[False, False],
is_residual=True,
in_channels=4,
out_channels=4,
latents_mean=[0.0] * 4,
latents_std=[1.0] * 4,
scale_factor_spatial=None,
scale_factor_temporal=None,
)
kwargs.update(overrides)
return cls(**kwargs)
def test_spatial_compression_ratio_derived_from_architecture():
# One 2x downsampling stage per `temperal_downsample` entry.
vae = _make_vae()
assert vae.spatial_compression_ratio == 4
# The real Qwen-Image-2.1 checkpoint ships scale_factor_spatial=8, but its
# four downsampling stages compress 16x; the architecture wins.
with torch.device("meta"):
vae = AutoencoderKLQwenImage21(
base_dim=96,
decoder_base_dim=144,
z_dim=64,
dim_mult=[1, 2, 4, 8, 8],
num_res_blocks=2,
temperal_downsample=[False, True, True, True],
is_residual=True,
in_channels=4,
out_channels=4,
scale_factor_spatial=8,
)
assert vae.spatial_compression_ratio == 16
def test_tiled_decode_output_shape_and_closeness():
torch.manual_seed(0)
vae = _make_vae()
z = torch.randn(1, 4, 1, 16, 16)
ratio = vae.spatial_compression_ratio
with torch.no_grad():
ref = vae._decode(z, return_dict=False)[0]
vae.enable_tiling(
tile_sample_min_height=32,
tile_sample_min_width=32,
tile_sample_stride_height=24,
tile_sample_stride_width=24,
)
tiled = vae.decode(z, return_dict=False)[0]
# Regression: with the wrong (config-derived) ratio the tiled output was
# cropped to a fraction of the sample size.
assert tiled.shape == (1, 4, 1, 16 * ratio, 16 * ratio)
assert tiled.shape == ref.shape
# Tiled decode blends overlapping tiles, so it is not bitwise-identical to
# untiled decode; on this toy model the seam error stays small.
mse = ((ref - tiled) ** 2).mean().item()
psnr = 10 * math.log10(4.0 / mse)
assert psnr > 20.0
# Tiled output is clamped to [-1, 1] like the untiled path.
assert tiled.min() >= -1.0 and tiled.max() <= 1.0
def test_distributed_tile_hooks_match_tiled_decode():
"""tile_split -> tile_exec -> tile_merge must reproduce tiled_decode exactly."""
torch.manual_seed(0)
vae = _make_vae()
vae.enable_tiling(
tile_sample_min_height=32,
tile_sample_min_width=32,
tile_sample_stride_height=24,
tile_sample_stride_width=24,
)
distributed = _make_vae(DistributedAutoencoderKLQwenImage21)
distributed.enable_tiling(
tile_sample_min_height=32,
tile_sample_min_width=32,
tile_sample_stride_height=24,
tile_sample_stride_width=24,
)
distributed.load_state_dict(vae.state_dict())
z = torch.randn(1, 4, 1, 16, 16)
with torch.no_grad():
tiled = vae.tiled_decode(z, return_dict=False)[0]
tasks, grid_spec = DistributedAutoencoderKLQwenImage21.tile_split(distributed, z)
# 16x16 latent, 8x8 latent tiles, stride 6 -> range(0, 16, 6) = 3 stops/axis.
assert len(tasks) == 9
assert grid_spec.grid_shape == (3, 3)
assert grid_spec.split_dims == (3, 4)
# Every tile carries all frames (T=1 for images).
assert all(len(task.tensor) == z.shape[2] for task in tasks)
coord_tensor_map = {task.grid_coord: distributed.tile_exec(task) for task in tasks}
merged = distributed.tile_merge(coord_tensor_map, grid_spec)
assert merged.shape == tiled.shape
torch.testing.assert_close(merged, tiled)
def test_tiled_encode_output_shape_and_closeness():
"""tiled_encode is engaged for image-conditioned generation when tiling is on."""
torch.manual_seed(0)
vae = _make_vae()
x = torch.randn(1, 4, 1, 64, 64)
with torch.no_grad():
ref = vae._encode(x)
vae.enable_tiling(
tile_sample_min_height=32,
tile_sample_min_width=32,
tile_sample_stride_height=24,
tile_sample_stride_width=24,
)
tiled = vae._encode(x)
ratio = vae.spatial_compression_ratio
assert tiled.shape == (1, 8, 1, 64 // ratio, 64 // ratio)
assert tiled.shape == ref.shape
# Blended tile borders deviate from untiled encode but stay small.
assert (ref - tiled).abs().max().item() < 1.0
def test_encode_decode_round_trip_shape():
torch.manual_seed(0)
vae = _make_vae()
x = torch.randn(1, 4, 1, 32, 32)
with torch.no_grad():
latents = vae.encode(x, return_dict=False)[0].mode()
decoded = vae.decode(latents, return_dict=False)[0]
ratio = vae.spatial_compression_ratio
assert latents.shape == (1, 4, 1, 32 // ratio, 32 // ratio)
assert decoded.shape == x.shape
@pytest.mark.parametrize("persistent", [False, True])
def test_adaptive_oom_restores_tile_configuration(monkeypatch, persistent):
vae = _make_vae()
vae.enable_tiling(
tile_sample_min_height=512,
tile_sample_min_width=512,
tile_sample_stride_height=384,
tile_sample_stride_width=384,
)
attempts = []
z = torch.zeros(1, 4, 1, 4, 4)
def decode_tile(z, return_dict):
attempts.append(vae.tile_sample_min_height)
if persistent or vae.tile_sample_min_height > 128:
raise torch.OutOfMemoryError("injected tile OOM")
return (z,)
monkeypatch.setattr(vae, "tiled_decode", decode_tile)
monkeypatch.setattr(torch.accelerator, "empty_cache", lambda: None)
if persistent:
with pytest.raises(torch.OutOfMemoryError):
vae._tiled_decode_adaptive(z, return_dict=False)
else:
assert vae._tiled_decode_adaptive(z, return_dict=False)[0] is z
assert attempts == [512, 256, 128]
assert (
vae.tile_sample_min_height,
vae.tile_sample_min_width,
vae.tile_sample_stride_height,
vae.tile_sample_stride_width,
) == (512, 512, 384, 384)
@@ -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 SequentialOffloadBackend."""
@@ -284,6 +284,97 @@ def test_component_selective_model_offload_requires_swap_counterpart(component,
backend.enable(pipeline)
def test_persistent_dit_staging_reuses_fixed_device_storage(accelerator_device) -> None:
dit = _create_simple_module().to(accelerator_device)
encoder = _create_simple_module().to(accelerator_device)
original_weight = dit.linear.weight.detach().clone()
apply_sequential_offload(
dit_modules=[dit],
encoder_modules=[encoder],
device=accelerator_device,
pin_memory=False,
persistent_dit_staging=True,
)
dit_hook = dit._hook_registry.get_hook(SequentialOffloadHook._HOOK_NAME)
encoder_hook = encoder._hook_registry.get_hook(SequentialOffloadHook._HOOK_NAME)
assert dit_hook._stager is not None
assert encoder_hook._stager is None # encoders keep plain move semantics
# Registration snapshots the CPU master and rebinds to it.
assert dit.linear.weight.device.type == "cpu"
dit_hook._to_gpu(dit)
first_ptr = dit.linear.weight.data_ptr()
assert dit.linear.weight.device == accelerator_device
torch.testing.assert_close(dit.linear.weight.data, original_weight)
# Offload via a *different* module's hook (the cross-module swap path),
# then reload: the same fixed device storage must be reused so that
# CUDA-graph-captured weight pointers stay valid.
encoder_hook._to_cpu(dit)
assert dit.linear.weight.device.type == "cpu"
dit_hook._to_gpu(dit)
assert dit.linear.weight.data_ptr() == first_ptr
torch.testing.assert_close(dit.linear.weight.data, original_weight)
# A second offload/load cycle through the DiT hook itself stays put too.
dit_hook._to_cpu(dit)
dit_hook._to_gpu(dit)
assert dit.linear.weight.data_ptr() == first_ptr
remove_sequential_offload([dit, encoder])
def test_default_sequential_offload_releases_device_storage(accelerator_device) -> None:
"""Without persistent staging, offloading keeps plain move semantics."""
dit = _create_simple_module().to(accelerator_device)
encoder = _create_simple_module().to(accelerator_device)
apply_sequential_offload(
dit_modules=[dit],
encoder_modules=[encoder],
device=accelerator_device,
pin_memory=False,
)
dit_hook = dit._hook_registry.get_hook(SequentialOffloadHook._HOOK_NAME)
assert dit_hook._stager is None # no retained staging: GPU storage is freed on offload
dit_hook._to_gpu(dit)
assert dit.linear.weight.device == accelerator_device
dit_hook._to_cpu(dit)
assert dit.linear.weight.device.type == "cpu"
remove_sequential_offload([dit, encoder])
@pytest.mark.parametrize(
("decode_graphs", "expect_stager"),
[(True, True), (False, False)],
)
def test_model_level_persistent_staging_gated_on_decode_graph(
accelerator_device, decode_graphs: bool, expect_stager: bool
) -> None:
"""ModelLevelOffloadBackend only retains DiT staging for decode-graph models."""
pipeline = nn.Module()
pipeline.transformer = _create_simple_module()
if decode_graphs:
pipeline.transformer.enable_cuda_graph_decode = True
pipeline.text_encoder = _create_simple_module()
backend = ModelLevelOffloadBackend(
OffloadConfig(strategy=OffloadStrategy.MODEL_LEVEL, pin_cpu_memory=False),
accelerator_device,
)
backend.enable(pipeline)
hook = pipeline.transformer._hook_registry.get_hook(SequentialOffloadHook._HOOK_NAME)
assert (hook._stager is not None) is expect_stager
backend.disable()
def test_sequential_offload_can_begin_with_dit_on_cpu(monkeypatch: pytest.MonkeyPatch) -> None:
dit = _create_simple_module()
encoder = _create_simple_module()
@@ -497,3 +497,99 @@ class TestWorkerProcShutdown:
result_mq.shutdown.assert_called_once_with()
assert proc.mq is None
assert proc.result_mq is None
class TestSynchronizeDeviceBarrier:
"""The pause barrier must not report a quiet device while outputs still pack."""
@staticmethod
def _rpc(timeout):
return {
"method": "synchronize_device",
"args": (),
"kwargs": {"timeout": timeout},
"output_rank": 0,
"exec_all_ranks": False,
"collect_rank_status": False,
}
def test_rejects_undrained_output(self, mocker):
proc = _make_worker_proc(step_execution=False)
proc.worker = MagicMock()
mocker.patch.object(proc, "drain_async_outputs", return_value=False)
with pytest.raises(TimeoutError, match="did not drain"):
proc._execute_rpc(self._rpc(0.01))
proc.worker.execute_method.assert_not_called()
@pytest.mark.parametrize("timeout", [0.5, None])
def test_drains_with_the_call_timeout_then_synchronizes(self, mocker, timeout):
proc = _make_worker_proc(step_execution=False)
proc.worker = MagicMock()
call_order: list = []
mocker.patch.object(
proc,
"drain_async_outputs",
side_effect=lambda **kw: call_order.append(("drain", kw)) or True,
)
proc.worker.execute_method.side_effect = lambda *a, **kw: call_order.append(("sync", a, kw))
_, should_reply = proc._execute_rpc(self._rpc(timeout))
assert call_order == [
("drain", {"timeout": timeout}),
("sync", ("synchronize_device",), {"timeout": timeout}),
]
assert should_reply is True
def test_completes_only_after_output_ready_is_published(self, mocker):
proc = _make_worker_proc(step_execution=False)
proc.worker = MagicMock()
mock_platform = mocker.MagicMock()
mock_platform.record_device_event.return_value = MagicMock()
mocker.patch(
"vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform",
mock_platform,
)
mock_torch = mocker.MagicMock()
mock_torch.accelerator.current_accelerator.return_value.type = "cpu"
mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.torch", mock_torch)
release = threading.Event()
mocker.patch(
"vllm_omni.diffusion.worker.diffusion_worker.pack_diffusion_output_shm",
side_effect=lambda *a, **kw: release.wait(timeout=5.0),
)
proc._return_result(DiffusionOutput(output="data"), rpc_id="1")
loop_thread = threading.Thread(target=proc._async_output_loop, daemon=True)
loop_thread.start()
barrier_done = threading.Event()
def run_barrier():
proc._execute_rpc(self._rpc(5.0))
barrier_done.set()
barrier_thread = threading.Thread(target=run_barrier, daemon=True)
barrier_thread.start()
try:
assert not barrier_done.wait(timeout=0.2)
proc.worker.execute_method.assert_not_called()
release.set()
assert barrier_done.wait(timeout=5.0)
kinds = [
call.args[0].kind
for call in proc.result_mq.enqueue.call_args_list
if isinstance(call.args[0], AsyncDiffusionOutput)
]
assert kinds == [AsyncOutputKind.COMPUTE_DONE, AsyncOutputKind.OUTPUT_READY]
proc.worker.execute_method.assert_called_once_with("synchronize_device", timeout=5.0)
finally:
release.set()
proc._async_output_queue.put(None)
loop_thread.join(timeout=2.0)
barrier_thread.join(timeout=2.0)
@@ -45,6 +45,21 @@ def _roundtrip_diffusion_config(**kwargs) -> OmniDiffusionConfig:
return OmniDiffusionConfig(**{name: value for name, value in diffusion_kwargs.items() if value is not None})
def test_direct_config_extras_are_independent_dicts():
first = OmniDiffusionConfig(model="test-model")
second = OmniDiffusionConfig(model="test-model")
assert first.extras.get("prefix_kv_cache_dtype") is None
first.extras["prefix_kv_cache_dtype"] = "fp8"
assert second.extras == {}
def test_direct_config_preserves_explicit_extras():
config = OmniDiffusionConfig(model="test-model", extras={"prefix_kv_cache_dtype": "fp8"})
assert config.extras["prefix_kv_cache_dtype"] == "fp8"
class TestParallelConfigPropagation:
"""Core regression tests: parallel_config must survive serialization."""
@@ -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
"""Regression tests for the busy-loop-based RPC routing in DiffusionEngine.
These tests guard the invariants established when ``collective_rpc`` was
@@ -60,6 +60,12 @@ class _ConcurrencyTrackingExecutor:
self.is_failed = False
self._closed = False
self.od_config = SimpleNamespace()
# Pause tests: a call whose gate is registered (by request id, or by
# method name for ``synchronize_device``) blocks inside the executor
# until the gate is set; ``version`` tags each call so a test can tell
# which "weights" a request ran on.
self.gates: dict[str, threading.Event] = {}
self.version = "v1"
def collective_rpc(
self,
@@ -81,16 +87,25 @@ class _ConcurrencyTrackingExecutor:
"kwargs": kwargs,
"unique_reply_rank": unique_reply_rank,
"thread": threading.get_ident(),
"version": self.version,
}
)
try:
if self.rpc_delay:
time.sleep(self.rpc_delay)
if method == "synchronize_device":
gate = self.gates.get(method)
if gate is not None:
gate.wait(timeout=5.0)
return [None]
# Distinguish per-request execution from raw RPC by method name.
if method in {"execute_model", "execute_stepwise", "generate"}:
# args[0] is the request-like object for execute_model.
req = args[0] if args else None
tag = req.request_id if req is not None and hasattr(req, "request_id") else "unknown"
gate = self.gates.get(tag)
if gate is not None:
gate.wait(timeout=5.0)
return DiffusionOutput(error=f"result_for_{tag}", finished=True)
tag = args[0] if args else method
return DiffusionOutput(error=f"rpc_result_for_{tag}")
@@ -133,21 +148,27 @@ def _make_request(tag: str):
def _make_engine_with_loop(
loop: asyncio.AbstractEventLoop,
loop: asyncio.AbstractEventLoop | None,
rpc_delay: float = 0.0,
*,
scheduler: RequestScheduler | None = None,
start_loop: bool = True,
):
"""Construct a ``DiffusionEngine`` skeleton with a real busy loop.
The engine is wired with a fake executor that asserts no concurrent
calls and a real ``RequestScheduler``.
calls and a real ``RequestScheduler``. ``start_loop=False`` leaves the
engine in its bootstrap state (no busy-loop thread).
"""
engine = DiffusionEngine.__new__(DiffusionEngine)
engine._closed = False
engine.od_config = SimpleNamespace(streaming_output=False)
engine.executor = _ConcurrencyTrackingExecutor(rpc_delay=rpc_delay)
sched = RequestScheduler()
sched.initialize(SimpleNamespace(max_num_seqs=1, request_batch_max_wait_ms=0.0))
sched = scheduler
if sched is None:
sched = RequestScheduler()
sched.initialize(SimpleNamespace(max_num_seqs=1, request_batch_max_wait_ms=0.0))
engine.scheduler = sched
engine.step_execution = False
engine.supports_request_batch = False
@@ -160,9 +181,14 @@ def _make_engine_with_loop(
engine._closed = False
engine.abort_queue = queue.Queue()
engine._rpc_queue = queue.Queue()
engine._scheduling_paused = False
engine.main_loop = loop
engine.stop_event = threading.Event()
if not start_loop:
engine._loop_started = False
engine.worker_thread = None
return engine
engine._loop_started = True
engine.worker_thread = threading.Thread(target=engine._busy_loop, daemon=True)
engine.worker_thread.start()
@@ -170,6 +196,10 @@ def _make_engine_with_loop(
def _stop_engine(engine: DiffusionEngine) -> None:
if engine.worker_thread is None:
return
for gate in engine.executor.gates.values():
gate.set()
with engine._cv:
engine.stop_event.set()
engine._cv.notify_all()
@@ -565,3 +595,319 @@ async def test_rpc_and_request_results_do_not_swap():
assert req_res.error == "result_for_req1"
# Belt and braces: never overlapped on the executor.
assert engine.executor.max_concurrent == 1
# ───────────────────────── scheduler pause (keep) ─────────────────────────
class _GatedAdmissionScheduler(RequestScheduler):
"""Batching wait that only ends when the test opens admission."""
def __init__(self) -> None:
super().__init__()
self.admission_open = threading.Event()
def get_admission_wait_decision(self, *, now: float, dp_concurrent: bool = False):
decision = super().get_admission_wait_decision(now=now, dp_concurrent=dp_concurrent)
return decision.__class__(should_wait=True, deadline=None, max_batch=self.max_num_running_reqs)
def should_end_admission_wait(self, decision, *, now: float, stable_since: float) -> bool:
return self.admission_open.is_set()
def _pause(engine: DiffusionEngine, timeout: float | None = None):
return engine.async_collective_rpc("pause_scheduler", timeout=timeout, kwargs={"mode": "keep"})
def _resume(engine: DiffusionEngine):
return engine.async_collective_rpc("resume_scheduler")
def _methods(engine: DiffusionEngine) -> list[str]:
return [call["method"] for call in engine.executor.calls]
def _executed(engine: DiffusionEngine) -> list[tuple[str, str]]:
return [
(call["args"][0].request_id, call["version"])
for call in engine.executor.calls
if call["method"] == "execute_model"
]
def _submit(engine: DiffusionEngine, tag: str) -> asyncio.Task:
return asyncio.create_task(_consume_final_output(engine.async_add_req_and_stream_response(_make_request(tag))))
async def _wait_until(predicate, timeout: float = 3.0) -> None:
deadline = time.monotonic() + timeout
while not predicate():
if time.monotonic() > deadline:
raise AssertionError("condition not reached in time")
await asyncio.sleep(0.005)
@pytest.mark.asyncio
async def test_keep_pause_retains_waiting_request_and_serves_control_rpcs():
loop = asyncio.get_running_loop()
engine = _make_engine_with_loop(loop)
try:
assert await _pause(engine) is None
queued = _submit(engine, "queued")
result = await engine.async_collective_rpc("list_loras", unique_reply_rank=0)
assert result.error == "rpc_result_for_list_loras"
await asyncio.sleep(0.05)
assert engine.scheduler.num_waiting_requests() == 1
assert _executed(engine) == []
assert not queued.done()
await _resume(engine)
assert (await asyncio.wait_for(queued, 3.0)).error == "result_for_queued"
assert _methods(engine) == ["synchronize_device", "list_loras", "execute_model"]
finally:
_stop_engine(engine)
@pytest.mark.asyncio
async def test_keep_pause_acks_after_running_batch_and_freezes_queue():
loop = asyncio.get_running_loop()
engine = _make_engine_with_loop(loop)
gate = engine.executor.gates["A"] = threading.Event()
try:
a = _submit(engine, "A")
await _wait_until(lambda: _executed(engine) == [("A", "v1")])
b = _submit(engine, "B")
await _wait_until(lambda: engine.scheduler.num_waiting_requests() == 1)
pause = asyncio.create_task(_pause(engine))
await asyncio.sleep(0.1)
assert not pause.done()
gate.set()
await asyncio.wait_for(pause, 3.0)
assert _methods(engine) == ["execute_model", "synchronize_device"]
assert (await asyncio.wait_for(a, 3.0)).error == "result_for_A"
assert engine.scheduler.num_waiting_requests() == 1
assert not b.done()
engine.executor.version = "v2"
await _resume(engine)
assert (await asyncio.wait_for(b, 3.0)).error == "result_for_B"
assert _executed(engine) == [("A", "v1"), ("B", "v2")]
finally:
_stop_engine(engine)
@pytest.mark.asyncio
async def test_keep_pause_ack_does_not_release_later_rpcs_before_the_batch_is_emitted():
"""A pause ACK must not open the queue to RPCs that touch device memory
(sleep) before the batch that just ran has been delivered.
"""
loop = asyncio.get_running_loop()
engine = _make_engine_with_loop(loop)
barrier_gate = engine.executor.gates["synchronize_device"] = threading.Event()
methods_at_emit: list[list[str]] = []
emit_outputs = engine._emit_outputs
def _recording_emit(*args, **kwargs):
methods_at_emit.append(_methods(engine))
return emit_outputs(*args, **kwargs)
engine._emit_outputs = _recording_emit
try:
gate = engine.executor.gates["A"] = threading.Event()
a = _submit(engine, "A")
await _wait_until(lambda: _executed(engine) == [("A", "v1")])
pause = asyncio.create_task(_pause(engine))
await _wait_until(lambda: engine._scheduling_paused)
gate.set()
# The barrier holds the busy loop inside the post-execute RPC drain,
# so the sleep is queued before the pause ACK is published.
await _wait_until(lambda: "synchronize_device" in _methods(engine))
sleep_rpc = asyncio.create_task(engine.async_collective_rpc("handle_sleep_task", args=("t",)))
await _wait_until(lambda: not engine._rpc_queue.empty())
barrier_gate.set()
await asyncio.wait_for(pause, 3.0)
assert (await asyncio.wait_for(a, 3.0)).error == "result_for_A"
assert (await asyncio.wait_for(sleep_rpc, 3.0)).error == "rpc_result_for_t"
finally:
_stop_engine(engine)
assert _methods(engine) == ["execute_model", "synchronize_device", "handle_sleep_task"]
assert methods_at_emit and "handle_sleep_task" not in methods_at_emit[0]
@pytest.mark.asyncio
async def test_timed_out_pause_still_ends_the_drain_before_the_batch_is_emitted():
"""A pause whose caller already gave up is still a drain barrier: its
cancelled task must not let a later RPC run before the batch is emitted.
"""
loop = asyncio.get_running_loop()
engine = _make_engine_with_loop(loop)
methods_at_emit: list[list[str]] = []
emit_outputs = engine._emit_outputs
def _recording_emit(*args, **kwargs):
methods_at_emit.append(_methods(engine))
return emit_outputs(*args, **kwargs)
engine._emit_outputs = _recording_emit
try:
gate = engine.executor.gates["A"] = threading.Event()
a = _submit(engine, "A")
await _wait_until(lambda: _executed(engine) == [("A", "v1")])
# The caller times out while A is still executing: the pause future is
# cancelled, but its task stays queued ahead of the sleep.
with pytest.raises(TimeoutError):
await _pause(engine, timeout=0.1)
sleep_rpc = asyncio.create_task(engine.async_collective_rpc("handle_sleep_task", args=("t",)))
await _wait_until(lambda: engine._rpc_queue.qsize() == 2)
gate.set()
assert (await asyncio.wait_for(a, 3.0)).error == "result_for_A"
assert (await asyncio.wait_for(sleep_rpc, 3.0)).error == "rpc_result_for_t"
finally:
_stop_engine(engine)
assert _methods(engine) == ["execute_model", "handle_sleep_task"]
assert methods_at_emit and "handle_sleep_task" not in methods_at_emit[0]
@pytest.mark.asyncio
async def test_pause_rejects_unsupported_modes_without_closing_the_gate():
loop = asyncio.get_running_loop()
engine = _make_engine_with_loop(loop)
try:
for kwargs in ({"mode": "abort"}, {"mode": "wait"}, {}):
with pytest.raises(ValueError):
await engine.async_collective_rpc("pause_scheduler", kwargs=kwargs)
engine.execution_mode = DiffusionExecutionMode.STEP_BATCH
with pytest.raises(NotImplementedError):
await _pause(engine)
engine.execution_mode = DiffusionExecutionMode.REQUEST_BATCH
assert (await asyncio.wait_for(_submit(engine, "still-runs"), 3.0)).error == "result_for_still-runs"
assert _methods(engine) == ["execute_model"]
finally:
_stop_engine(engine)
@pytest.mark.asyncio
async def test_keep_pause_during_batching_wait_holds_the_batch():
loop = asyncio.get_running_loop()
sched = _GatedAdmissionScheduler()
sched.initialize(SimpleNamespace(max_num_seqs=4, request_batch_max_wait_ms=1000.0))
engine = _make_engine_with_loop(loop, scheduler=sched)
try:
waiting = _submit(engine, "W")
await _wait_until(lambda: engine.scheduler.num_waiting_requests() == 1)
await asyncio.sleep(0.05)
await asyncio.wait_for(_pause(engine), 3.0)
assert _methods(engine) == ["synchronize_device"]
assert engine.scheduler.num_waiting_requests() == 1
await _resume(engine)
await asyncio.sleep(0.05)
assert _executed(engine) == []
sched.admission_open.set()
assert (await asyncio.wait_for(waiting, 3.0)).error == "result_for_W"
finally:
sched.admission_open.set()
_stop_engine(engine)
@pytest.mark.asyncio
async def test_pause_timeout_leaves_gate_closed_until_explicit_resume():
loop = asyncio.get_running_loop()
engine = _make_engine_with_loop(loop)
gate = engine.executor.gates["A"] = threading.Event()
try:
a = _submit(engine, "A")
await _wait_until(lambda: _executed(engine) == [("A", "v1")])
b = _submit(engine, "B")
await _wait_until(lambda: engine.scheduler.num_waiting_requests() == 1)
with pytest.raises(TimeoutError):
await _pause(engine, timeout=0.1)
gate.set()
assert (await asyncio.wait_for(a, 3.0)).error == "result_for_A"
await asyncio.sleep(0.1)
assert _executed(engine) == [("A", "v1")]
assert not b.done()
await _resume(engine)
assert (await asyncio.wait_for(b, 3.0)).error == "result_for_B"
assert "synchronize_device" not in _methods(engine)
finally:
_stop_engine(engine)
@pytest.mark.asyncio
async def test_repeated_pause_and_resume_are_idempotent():
loop = asyncio.get_running_loop()
engine = _make_engine_with_loop(loop)
try:
await _resume(engine)
await _pause(engine)
await _pause(engine)
queued = _submit(engine, "Q")
await asyncio.sleep(0.05)
assert _executed(engine) == []
await _resume(engine)
await _resume(engine)
assert (await asyncio.wait_for(queued, 3.0)).error == "result_for_Q"
assert _methods(engine) == ["synchronize_device", "synchronize_device", "execute_model"]
finally:
_stop_engine(engine)
@pytest.mark.asyncio
async def test_abort_of_waiting_request_while_paused_delivers_terminal():
loop = asyncio.get_running_loop()
engine = _make_engine_with_loop(loop)
try:
await _pause(engine)
b = _submit(engine, "B")
await _wait_until(lambda: engine.scheduler.num_waiting_requests() == 1)
engine.abort("B")
result = await asyncio.wait_for(b, 3.0)
assert result.aborted is True
assert engine.scheduler.get_request_state("B") is None
assert _executed(engine) == []
await _resume(engine)
assert (await asyncio.wait_for(_submit(engine, "C"), 3.0)).error == "result_for_C"
assert _executed(engine) == [("C", "v1")]
assert engine.worker_thread.is_alive()
finally:
_stop_engine(engine)
def test_pause_and_resume_before_loop_starts_use_engine_local_path():
engine = _make_engine_with_loop(None, start_loop=False)
assert engine.collective_rpc("pause_scheduler", kwargs={"mode": "keep"}) is None
assert _methods(engine) == ["synchronize_device"]
assert engine.collective_rpc("resume_scheduler") is None
assert engine.add_req_and_wait_for_response(_make_request("X")).error == "result_for_X"
assert _executed(engine) == [("X", "v1")]
@pytest.mark.asyncio
async def test_requests_without_pause_never_trigger_the_barrier():
loop = asyncio.get_running_loop()
engine = _make_engine_with_loop(loop)
try:
first = await asyncio.wait_for(_submit(engine, "r1"), 3.0)
await engine.async_collective_rpc("ping", args=("p",), unique_reply_rank=0)
second = await asyncio.wait_for(_submit(engine, "r2"), 3.0)
finally:
_stop_engine(engine)
assert (first.error, second.error) == ("result_for_r1", "result_for_r2")
assert _methods(engine) == ["execute_model", "ping", "execute_model"]
@@ -2092,3 +2092,19 @@ class TestStepScheduler:
with pytest.raises(ValueError):
self.scheduler.add_request(request)
class TestPendingFinishedRequestIds:
def test_reports_finished_ids_that_still_hold_state_without_clearing_them(self):
sched = RequestScheduler()
sched.initialize(SimpleNamespace(max_num_seqs=1, request_batch_max_wait_ms=0.0))
sched.add_request(_make_request("a"))
sched.add_request(_make_request("b"))
sched.finish_requests("a", DiffusionRequestStatus.FINISHED_ABORTED)
assert sched.pending_finished_request_ids() == {"a"}
sched.pop_request_state("a")
assert sched.pending_finished_request_ids() == set()
# The next wave still ships the id to the worker for its own cleanup.
assert sched.schedule().finished_req_ids == {"a"}
@@ -0,0 +1,53 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Unit tests for the device step of the diffusion pause barrier."""
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from vllm_omni.diffusion.worker.diffusion_worker import DiffusionWorker
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _make_worker(runner: object) -> DiffusionWorker:
worker = object.__new__(DiffusionWorker)
worker.model_runner = runner
worker.rank = 0
return worker
def test_synchronize_device_waits_for_prefetch_before_device_sync(mocker):
platform = mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform")
order: list = []
manager = MagicMock()
manager.wait_prefetch.side_effect = lambda timeout: order.append(("prefetch", timeout)) or True
platform.synchronize.side_effect = lambda: order.append("sync")
worker = _make_worker(SimpleNamespace(kv_transfer_manager=manager))
worker.synchronize_device(timeout=2.5)
assert order == [("prefetch", 2.5), "sync"]
def test_synchronize_device_fails_when_prefetch_is_still_outstanding(mocker):
platform = mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform")
manager = MagicMock()
manager.wait_prefetch.return_value = False
worker = _make_worker(SimpleNamespace(kv_transfer_manager=manager))
with pytest.raises(TimeoutError, match="prefetch"):
worker.synchronize_device(timeout=0.01)
platform.synchronize.assert_not_called()
def test_synchronize_device_without_transfer_manager_only_syncs(mocker):
platform = mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform")
worker = _make_worker(SimpleNamespace(kv_transfer_manager=None))
worker.synchronize_device(timeout=None)
platform.synchronize.assert_called_once_with()
@@ -17,7 +17,7 @@ import zmq
from vllm.v1.engine.exceptions import EngineDeadError
import vllm_omni.diffusion.worker.diffusion_worker as diffusion_worker_module
from vllm_omni.diffusion.data import DiffusionOutput
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine
from vllm_omni.diffusion.executor.multiproc_executor import MultiprocDiffusionExecutor
from vllm_omni.diffusion.ipc import DIFFUSION_RPC_RESULT_ENVELOPE
@@ -37,6 +37,38 @@ from vllm_omni.outputs import OmniRequestOutput
pytestmark = [pytest.mark.diffusion, pytest.mark.core_model, pytest.mark.cpu]
def _signal_worker_ready(_rank, _config, writer, _broadcast, wake_event, *_args):
assert mp.get_start_method() == "spawn"
wake_event.set()
writer.send({"status": "ready", "result_handle": "ready"})
writer.close()
@pytest.mark.skipif("fork" not in mp.get_all_start_methods(), reason="Requires a fork default context")
def test_executor_spawns_workers_with_compatible_events(mocker):
previous_method = mp.get_start_method(allow_none=True)
executor = object.__new__(MultiprocDiffusionExecutor)
executor.od_config = OmniDiffusionConfig(model="test-model", step_execution=True)
broadcast = mocker.Mock()
broadcast.export_handle.return_value = None
mocker.patch.object(executor, "_init_broadcast_queue", return_value=broadcast)
mocker.patch.object(executor, "_init_result_queue")
mocker.patch.object(executor, "_start_worker_monitor")
mocker.patch.object(WorkerProc, "worker_main", new=_signal_worker_ready)
try:
mp.set_start_method("fork", force=True)
executor._init_executor()
for process in executor._processes:
process.join(timeout=30)
assert process.exitcode == 0
assert all(event.is_set() for event in executor.wake_events)
assert mp.get_start_method() == "fork"
finally:
if getattr(executor, "_finalizer", None) is not None:
executor.shutdown()
mp.set_start_method(previous_method, force=True)
# ───────────────────────────────────────────── helpers ─────────────────────
@@ -5,6 +5,7 @@ mock connector, dedup, per-call sender_info isolation, role classification, and
miss/abort paths. GPU H2D and D2D paths need CUDA (integration tests).
"""
import threading
from types import SimpleNamespace
import pytest
@@ -354,3 +355,41 @@ def test_consume_then_apply_attaches_payload():
receiver._record_stream_for_prefetched(data)
receiver.apply_kv_cache_to_request(req, data)
assert req.past_key_values is not None
# --------------------------------------------------------------------------- #
# wait_prefetch: the pause barrier's view of outstanding background loads
# --------------------------------------------------------------------------- #
def test_wait_prefetch_is_true_when_nothing_outstanding():
_, receiver, _ = _make_sender_receiver()
assert receiver.wait_prefetch(timeout=0.01) is True
def test_wait_prefetch_is_true_when_prefetch_disabled():
_, receiver, _ = _make_sender_receiver(async_prefetch=False)
assert receiver.wait_prefetch(timeout=0.01) is True
def test_wait_prefetch_reports_a_running_prefetch_and_keeps_its_payload():
sender, receiver, connector = _make_sender_receiver()
_seed_payload(sender, "rid-w")
gate = threading.Event()
real_get = connector.get
def slow_get(*args, **kwargs):
gate.wait(timeout=5.0)
return real_get(*args, **kwargs)
connector.get = slow_get
receiver.start_prefetch({"request_id": "rid-w", "kv_sender_info": _SENDER_INFO})
try:
assert receiver.wait_prefetch(timeout=0.05) is False
gate.set()
assert receiver.wait_prefetch(timeout=5.0) is True
data, _ = receiver.consume_prefetched_kv(_req("rid-w"))
assert data is not None
finally:
gate.set()
receiver.shutdown_prefetch()
@@ -32,7 +32,6 @@ from huggingface_hub import hf_hub_download, snapshot_download
from torchmetrics.image import PeakSignalNoiseRatio, StructuralSimilarityIndexMeasure
from tests.e2e.accuracy.helpers import reset_artifact_dir
from tests.helpers import skip_if_gated_repo_inaccessible
from tests.helpers.mark import hardware_test
OFFICIAL_REPOSITORY = "https://github.com/Lightricks/LTX-2.git"
@@ -303,32 +302,6 @@ def _resolve_ltx25_image() -> Path:
)
def _require_ltx25_model_access() -> None:
configured_omni_model = os.environ.get("VLLM_TEST_LTX25_MODEL")
if not (configured_omni_model and Path(configured_omni_model).exists()):
omni_model = configured_omni_model or LTX25_OMNI_MODEL_ID
omni_revision = os.environ.get("VLLM_TEST_LTX25_MODEL_REVISION")
if omni_revision is None and omni_model == LTX25_OMNI_MODEL_ID:
omni_revision = LTX25_OMNI_MODEL_REVISION
skip_if_gated_repo_inaccessible(
omni_model,
revision=omni_revision,
filename="model_index.json",
)
configured_official_model = os.environ.get("VLLM_TEST_LTX25_OFFICIAL_MODEL")
if not (configured_official_model and Path(configured_official_model).exists()):
official_model = configured_official_model or LTX25_OFFICIAL_MODEL_ID
official_revision = os.environ.get("VLLM_TEST_LTX25_OFFICIAL_MODEL_REVISION")
if official_revision is None and official_model == LTX25_OFFICIAL_MODEL_ID:
official_revision = LTX25_OFFICIAL_MODEL_REVISION
skip_if_gated_repo_inaccessible(
official_model,
revision=official_revision,
filename=LTX25_OFFICIAL_COMMON_FILES["text_encoder"],
)
def _ltx25_request(image: Path | None = None) -> dict[str, object]:
request: dict[str, object] = {
"prompt": LTX25_PROMPT,
@@ -475,7 +448,6 @@ def _assert_strict_similarity(
@pytest.mark.parametrize("task", ["t2v", "i2v"])
def test_ltx25_distilled_two_stage_matches_official(accuracy_artifact_root: Path, task: str) -> None:
"""Compare Omni with official canonical DiffVAE and matching connector weights."""
_require_ltx25_model_access()
artifact_parent = accuracy_artifact_root / "ltx_official"
output_root = reset_artifact_dir(artifact_parent / f"ltx2_5_distilled_{task}")
official_root, official_revision = _ltx25_official_source(artifact_parent)
@@ -600,7 +572,6 @@ def test_ltx25_distilled_two_stage_matches_official(accuracy_artifact_root: Path
@pytest.mark.parametrize("pipeline_mode", ["full_one_stage", "full_two_stage"])
def test_ltx25_full_matches_official(accuracy_artifact_root: Path, task: str, pipeline_mode: str) -> None:
"""Compare raw official Full/SFT and DiffVAE weights with Omni."""
_require_ltx25_model_access()
artifact_parent = accuracy_artifact_root / "ltx_official"
output_root = reset_artifact_dir(artifact_parent / f"ltx2_5_{pipeline_mode}_{task}")
model_class_name = {
@@ -0,0 +1,183 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
Lifecycle test for ``AsyncOmni.pause_generation(mode="keep")`` on a diffusion stage.
Per round, request A is started, request B is queued behind it, and the pause
is issued while A is still running. The pause must return only after A has
been delivered, B must stay queued through ``sleep`` / ``wake_up``, and B may
start only after ``resume_generation``.
A second test follows the order the docs recommend for a weight update: sleep
is issued as soon as the pause returns, without awaiting the request that was
running, which must still be delivered intact.
Both single-GPU executor backends are covered explicitly: ``uni`` (in-process
worker) and ``mp`` (worker process with the asynchronous D2H output thread).
Usage:
pytest tests/e2e/features/custom_pipeline/test_diffusion_pause_keep.py -v -s
"""
from __future__ import annotations
import asyncio
import os
import time
from collections import defaultdict
from contextlib import ExitStack
import pytest
from tests.helpers.mark import hardware_test
from vllm_omni.diffusion.data import is_diffusion_request_started_output
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
MODEL = "tiny-random/Qwen-Image"
CUSTOM_PIPELINE_CLASS = "tests.e2e.features.helpers.custom_pipeline.QwenImagePipelineWithLogProbForTest"
WORKER_EXTENSION_CLASS = "tests.e2e.features.helpers.custom_pipeline.vLLMOmniColocateWorkerExtensionForTest"
# CI runs a couple of rounds; set PAUSE_KEEP_ROUNDS=10 for a soak run.
ROUNDS = int(os.environ.get("PAUSE_KEEP_ROUNDS", "2"))
# Enough denoising steps that the pause lands while A is still executing.
NUM_INFERENCE_STEPS = 40
def _sampling_params() -> OmniDiffusionSamplingParams:
return OmniDiffusionSamplingParams(
num_inference_steps=NUM_INFERENCE_STEPS,
guidance_scale=0.0,
height=256,
width=256,
seed=42,
emit_request_lifecycle=True,
)
async def _generate(engine: AsyncOmni, request_id: str, events: dict[str, dict[str, float]]):
final = None
async for output in engine.generate(
prompt={"prompt_ids": list(range(50))},
request_id=request_id,
sampling_params_list=[_sampling_params()],
output_modalities=["image"],
):
if is_diffusion_request_started_output(output):
events[request_id]["started"] = time.monotonic()
continue
final = output
events[request_id]["finished"] = time.monotonic()
return final
async def _wait_for(predicate, timeout: float = 30.0) -> None:
deadline = time.monotonic() + timeout
while not predicate():
assert time.monotonic() < deadline, "condition not reached in time"
await asyncio.sleep(0.005)
async def _pause_round(engine: AsyncOmni, events: dict[str, dict[str, float]], round_index: int) -> float:
"""A runs, B queues, keep-pause lands while A runs; B starts only after resume.
Returns the pause latency.
"""
a_id, b_id = f"a{round_index}", f"b{round_index}"
a = asyncio.create_task(_generate(engine, a_id, events))
await _wait_for(lambda: "started" in events[a_id])
b = asyncio.create_task(_generate(engine, b_id, events))
await asyncio.sleep(0.05)
assert not a.done(), "A finished before the pause was issued; raise NUM_INFERENCE_STEPS"
pause_start = time.monotonic()
await engine.pause_generation(mode="keep", clear_cache=False)
pause_latency = time.monotonic() - pause_start
assert await engine.is_paused()
a_output = await asyncio.wait_for(a, 60.0)
assert a_output.images, "A must be delivered, not aborted, by a keep pause"
assert "started" not in events[b_id], "B must not start while paused"
# Control RPCs and the sleep cycle stay available on the paused stage.
assert await engine.list_loras() == []
await engine.sleep(level=1)
assert await engine.is_sleeping()
await engine.wake_up()
assert not await engine.is_sleeping()
assert await engine.is_paused()
assert not b.done() and "started" not in events[b_id]
resume_at = time.monotonic()
await engine.resume_generation()
b_output = await asyncio.wait_for(b, 60.0)
assert b_output.images, "B must run after resume"
assert events[b_id]["started"] >= resume_at
return pause_latency
@pytest.mark.core_model
@pytest.mark.diffusion
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ["uni", "mp"])
async def test_keep_pause_sleep_wake_resume_lifecycle(backend: str):
with ExitStack() as after:
engine = AsyncOmni(
model=MODEL,
custom_pipeline_args={"pipeline_class": CUSTOM_PIPELINE_CLASS},
worker_extension_cls=WORKER_EXTENSION_CLASS,
enforce_eager=True,
enable_sleep_mode=True,
distributed_executor_backend=backend,
)
after.callback(engine.shutdown)
events: dict[str, dict[str, float]] = defaultdict(dict)
pause_latencies = [await _pause_round(engine, events, round_index) for round_index in range(ROUNDS)]
assert not engine.request_states, "every request must be finalized"
print(
f"[{backend}] rounds={ROUNDS} steps={NUM_INFERENCE_STEPS} "
f"pause_latency_s min/median/max="
f"{min(pause_latencies):.3f}/{sorted(pause_latencies)[ROUNDS // 2]:.3f}/{max(pause_latencies):.3f}"
)
@pytest.mark.core_model
@pytest.mark.diffusion
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ["uni", "mp"])
async def test_sleep_right_after_keep_pause_keeps_the_running_batch(backend: str):
"""The documented weight-update order: sleep is issued as soon as the pause
returns, with the request that was running still un-awaited.
"""
with ExitStack() as after:
engine = AsyncOmni(
model=MODEL,
custom_pipeline_args={"pipeline_class": CUSTOM_PIPELINE_CLASS},
worker_extension_cls=WORKER_EXTENSION_CLASS,
enforce_eager=True,
enable_sleep_mode=True,
distributed_executor_backend=backend,
)
after.callback(engine.shutdown)
events: dict[str, dict[str, float]] = defaultdict(dict)
a = asyncio.create_task(_generate(engine, "a", events))
await _wait_for(lambda: "started" in events["a"])
await asyncio.sleep(0.05)
assert not a.done(), "A finished before the pause was issued; raise NUM_INFERENCE_STEPS"
await engine.pause_generation(mode="keep", clear_cache=False)
await engine.sleep(level=1)
a_output = await asyncio.wait_for(a, 60.0)
assert a_output.images, "A must survive a sleep issued right after the pause ACK"
await engine.wake_up()
await engine.resume_generation()
b_output = await asyncio.wait_for(_generate(engine, "b", events), 120.0)
assert b_output.images, "the stage must generate again after wake and resume"
+31 -1
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Test doubles for custom-pipeline E2E and RLHF integration.
@@ -290,6 +290,36 @@ class vLLMOmniColocateWorkerExtensionForTest(CustomPipelineWorkerExtension):
"""Return a stable identifier for assertions in unit tests."""
return "vllm-omni-colocate-worker-extension-for-test"
def start_counting_synchronize_device(self) -> bool:
"""Count pause-barrier runs on this rank (test-only)."""
self._synchronize_device_calls = 0
inner = type(self).synchronize_device
def _counted(timeout=None):
self._synchronize_device_calls += 1
return inner(self, timeout=timeout)
self.synchronize_device = _counted
return True
def synchronize_device_seen(self) -> bool:
"""True from every rank only when each rank ran the barrier at least once."""
return getattr(self, "_synchronize_device_calls", 0) > 0
def set_synchronize_device_failure(self, rank: int, fail: bool) -> bool:
"""Make (or stop making) the pause barrier fail on ``rank`` (test-only)."""
if self.rank != rank:
return True
if fail:
def _fail(timeout=None):
raise RuntimeError(f"injected barrier failure on rank {rank}")
self.synchronize_device = _fail
else:
self.__dict__.pop("synchronize_device", None)
return True
# ---------------------------------------------------------------------------
# Pipeline layer
@@ -558,7 +558,7 @@ def test_shared_script_ar_path_reaches_generation(
# safe against MODEL_NAME's base checkpoint missing the Instruct-only
# <img_ratio_33>/<img_ratio_36> tokens. Wiring it here exercises the
# real production path (get_ar_tokenizer_validator via the registry),
# not just the isolated fake-tokenizer coverage in test_prompt_utils.py
# not just the isolated fake-tokenizer coverage in test_hunyuan_image3_prompt_utils.py
# / test_shared_script_ar_integration.py.
validate_tokenizer=get_ar_tokenizer_validator(model_class_name),
)
@@ -0,0 +1,89 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Drive complete native duplex turns while Stage-0 repeatedly rebuilds KV."""
from __future__ import annotations
import asyncio
import base64
from pathlib import Path
from vllm_omni.clients.duplex import DuplexClient, EventCollector, read_pcm16_wav, wait_for_condition
from vllm_omni.clients.minicpmo_4_5 import create_duplex_session_config
async def run_window_turn(
*,
url: str,
model: str,
input_wav: Path,
mode: str,
ref_audio: Path | None = None,
repeats: int = 2,
video_frames: list[str] | None = None,
buffered_flush: bool = False,
timeout_s: float = 240.0,
) -> dict[str, object]:
"""Use aggressive limits to exercise multiple windows in one audio turn."""
pcm = read_pcm16_wav(input_wav) * repeats
collector = EventCollector()
reference = (
"data:audio/wav;base64," + base64.b64encode(ref_audio.read_bytes()).decode("ascii")
if ref_audio is not None
else None
)
config = create_duplex_session_config(
modalities=("text", "audio") if ref_audio is not None else ("text",),
ref_audio=reference,
temperature=0.0,
extra_body={
"sliding_window_mode": mode,
"context_max_units": 1,
"context_previous_max_tokens": 16,
"basic_window_high_tokens": 64,
"basic_window_low_tokens": 32,
},
)
client = DuplexClient(
url, model=model, config=config, reconnect=None, heartbeat_interval_s=None, handshake_timeout_s=timeout_s
)
async with client:
reader = asyncio.create_task(collector.consume(client))
collector.add({"type": "session.created", "session": client.session_info})
try:
frames_sent = await client.stream_pcm(
pcm,
chunk_ms=2500 if buffered_flush else 200,
realtime=not buffered_flush,
is_speech=True,
video_frames=video_frames,
)
await client.commit(final=True)
await wait_for_condition(
lambda: collector.count("input_audio_buffer.committed") > 0 or bool(collector.errors()),
timeout_s=timeout_s,
label="final input commit",
)
await wait_for_condition(
lambda: collector.count("response.done") > 0 or bool(collector.errors()),
timeout_s=timeout_s,
label="completed windowed response",
)
# Final execution follows the commit acknowledgement asynchronously.
await asyncio.sleep(3.0)
await client.close(timeout_s=timeout_s)
await asyncio.wait_for(reader, timeout=5.0)
finally:
if not reader.done():
reader.cancel()
await asyncio.gather(reader, return_exceptions=True)
return {
"created": collector.count("session.created"),
"committed": collector.count("input_audio_buffer.committed"),
"done": collector.count("response.done"),
"closed": collector.count("session.closed"),
"errors": collector.errors(),
"audio_bytes": len(collector.audio_bytes()),
"frames_sent": frames_sent,
"input_seconds": len(pcm) / 32000,
}
+17 -3
View File
@@ -1,7 +1,19 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Online serving smoke coverage for the dense LingBot-Video checkpoint."""
"""
Online serving smoke for ``robbyant/lingbot-video-dense-1.3b``.
``test_text_to_image_001`` and ``test_video_generation_modes_001`` are the
baseline T2I / T2V+TI2V cases (``core_model`` + ``advanced_model``) shared by
ready (L2) and merge (L3). Heavier MoE and World coverage stays on
``full_model`` in ``test_lingbot_video_moe.py`` / ``test_lingbot_world_v2.py``.
From ``tests/``::
pytest -s -v e2e/online_serving/test_lingbot_video.py -m "core_model and diffusion" --run-level=core_model
pytest -s -v e2e/online_serving/test_lingbot_video.py -m "advanced_model and diffusion" --run-level=advanced_model
"""
import json
import os
@@ -41,7 +53,8 @@ def _get_diffusion_feature_cases(model: str):
]
@pytest.mark.full_model
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.diffusion
@pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True)
def test_text_to_image_001(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
@@ -63,7 +76,8 @@ def test_text_to_image_001(omni_server: OmniServer, openai_client: OpenAIClientH
)
@pytest.mark.full_model
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.diffusion
@pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True)
def test_video_generation_modes_001(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
@@ -4,9 +4,9 @@
"""
L4 expansion coverage for ``robbyant/lingbot-video-dense-1.3b``.
This file remains dense-only. Basic T2I/T2V/TI2V serving for both dense and MoE
checkpoints is covered by ``test_lingbot_video.py`` and
``test_lingbot_video_moe.py``. Distributed feature rows belong in follow-up
This file remains dense-only. Baseline T2I/T2V/TI2V for the dense checkpoint
lives in ``test_lingbot_video.py`` (L2/L3). MoE smoke stays in
``test_lingbot_video_moe.py`` (L4). Distributed feature rows belong in follow-up
PRs.
"""
-11
View File
@@ -7,7 +7,6 @@ import os
import pytest
from tests.helpers import skip_if_gated_repo_inaccessible
from tests.helpers.mark import hardware_marks
from tests.helpers.media import generate_synthetic_image
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
@@ -24,16 +23,6 @@ pytestmark = [pytest.mark.diffusion, pytest.mark.slow]
SINGLE_CARD_MARKS = hardware_marks(res={"cuda": "H100"})
@pytest.fixture(scope="module", autouse=True)
def require_ltx25_model_access() -> None:
if not os.path.isdir(MODEL):
skip_if_gated_repo_inaccessible(
MODEL,
revision=MODEL_REVISION or None,
filename="model_index.json",
)
def _server(model_class_name: str) -> OmniServerParams:
return OmniServerParams(
model=MODEL,
@@ -0,0 +1,88 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Real-model regression for the scheduler-owned Thinker sliding window."""
import asyncio
import pytest
from tests.e2e.online_serving.helpers.minicpmo_4_5_duplex import (
SERVER_PARAMS,
duplex_camera_frames,
realtime_url,
resolve_ref_audio,
validated_input_wav,
)
from tests.e2e.online_serving.helpers.minicpmo_window_e2e import run_window_turn
from tests.helpers.mark import hardware_test
pytestmark = [pytest.mark.omni, pytest.mark.advanced_model]
def _assert_complete(result, *, require_audio=False):
assert result["created"] == 1
assert result["committed"] > 0
assert result["done"] > 0
assert result["closed"] == 1
assert result["errors"] == []
if require_audio:
assert result["audio_bytes"] > 0
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
@pytest.mark.parametrize("mode,reference", [("basic", False), ("context", False), ("context", True)])
def test_window_rebuild_and_next_session(omni_server, mode, reference):
# Two independent sessions exercise admission after final-unit execution.
for _ in range(2):
result = asyncio.run(
run_window_turn(
url=realtime_url(omni_server),
model=omni_server.model,
input_wav=validated_input_wav(),
mode=mode,
ref_audio=resolve_ref_audio() if reference else None,
)
)
_assert_complete(result, require_audio=reference)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
@pytest.mark.parametrize("mode,camera", [("basic", False), ("context", False), ("context", True)])
def test_window_continuous_input(omni_server, tmp_path, mode, camera):
# 24 repeats cover over two minutes and many canonical KV replacements.
frames = duplex_camera_frames(seconds=4, cache_dir=tmp_path / "camera") if camera else None
result = asyncio.run(
run_window_turn(
url=realtime_url(omni_server),
model=omni_server.model,
input_wav=validated_input_wav(),
mode=mode,
repeats=24,
ref_audio=resolve_ref_audio(),
video_frames=frames,
)
)
_assert_complete(result, require_audio=True)
assert isinstance(result["input_seconds"], (int, float)) and result["input_seconds"] > 120
if camera:
assert isinstance(result["frames_sent"], int) and result["frames_sent"] >= 120
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
@pytest.mark.parametrize("mode", ["off", "basic", "context"])
def test_window_buffered_flush(omni_server, mode):
# Burst appends cross processor chunk boundaries and leave a final tail.
result = asyncio.run(
run_window_turn(
url=realtime_url(omni_server),
model=omni_server.model,
input_wav=validated_input_wav(),
mode=mode,
ref_audio=resolve_ref_audio(),
buffered_flush=True,
)
)
_assert_complete(result, require_audio=True)
@@ -0,0 +1,66 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import os
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.media import generate_synthetic_image
from tests.helpers.runtime import OmniServer, OmniServerParams, OnlineOmniClient, dummy_messages_from_mix_data
MODEL = os.environ.get("QWEN_IMAGE_21_TEST_MODEL", "Qwen/Qwen-Image-2.1")
SERVER_CASES = [
pytest.param(
OmniServerParams(model=MODEL, server_args=["--vae-use-tiling", "--max-num-seqs", "2"] + extra),
id=name,
marks=hardware_marks(res={"cuda": "H100"}),
)
for name, extra in [("request", []), ("step", ["--step-execution"])]
]
pytestmark = [
pytest.mark.core_model,
pytest.mark.advanced_model,
pytest.mark.diffusion,
pytest.mark.skipif(
not os.environ.get("QWEN_IMAGE_21_TEST_MODEL"),
reason="Set QWEN_IMAGE_21_TEST_MODEL to a provisioned checkpoint until public weights are available.",
),
]
@pytest.mark.parametrize("omni_server", SERVER_CASES, indirect=True)
def test_text_to_image(omni_server: OmniServer, online_client: OnlineOmniClient):
online_client.send_diffusion_request(
{
"model": omni_server.model,
"messages": dummy_messages_from_mix_data(content_text="A red ceramic teapot on a wooden table."),
"extra_body": {
"height": 512,
"width": 512,
"num_inference_steps": 4,
"seed": 42,
"negative_prompt": "blurry, low quality",
"true_cfg_scale": 4.0,
},
}
)
@pytest.mark.parametrize("omni_server", SERVER_CASES, indirect=True)
def test_image_edit(omni_server: OmniServer, online_client: OnlineOmniClient):
image = f"data:image/jpeg;base64,{generate_synthetic_image(512, 512)['base64']}"
online_client.send_diffusion_request(
{
"model": omni_server.model,
"messages": dummy_messages_from_mix_data(content_text="Make the image blue.", image_data_url=image),
"extra_body": {
"height": 512,
"width": 512,
"num_inference_steps": 4,
"seed": 42,
"negative_prompt": "blurry, low quality",
"true_cfg_scale": 4.0,
},
}
)
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
E2E online serving test for Stable Audio Open text-to-audio diffusion.
@@ -11,7 +11,6 @@ import os
import pytest
from tests.helpers import skip_if_gated_repo_inaccessible
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
@@ -32,17 +31,10 @@ def _stable_audio_server_cases(model: str):
]
@pytest.fixture
def _require_stable_audio_access() -> None:
"""Skip cleanly (before the server boots) if the gated checkpoint is inaccessible."""
skip_if_gated_repo_inaccessible(STABLE_AUDIO_TEST_MODEL)
@pytest.mark.slow
@pytest.mark.diffusion
@pytest.mark.parametrize("omni_server", _stable_audio_server_cases(STABLE_AUDIO_TEST_MODEL), indirect=True)
def test_stable_audio_t2a_online(
_require_stable_audio_access: None,
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
) -> None:
@@ -0,0 +1,725 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Public duplex hook: resumable defaults True; ephemeral ids when False."""
from __future__ import annotations
import pytest
from vllm.sampling_params import SamplingParams
from vllm_omni.engine.duplex.config import DuplexCapabilities
from vllm_omni.engine.duplex.contracts import (
DuplexFence,
DuplexStageRequestContext,
DuplexStageSubmission,
duplex_ephemeral_stage_request_id,
)
from vllm_omni.engine.duplex.session.manager import DuplexSessionManager
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_submission_resumable_default_preserves_minicpm() -> None:
ctx = DuplexStageRequestContext(
request_id="r",
session_id="s",
fence=DuplexFence("s"),
stage_id=0,
final_stage_id=2,
config_generation=0,
sampling_params=(SamplingParams(max_tokens=1),),
)
submission = DuplexStageSubmission(
context=ctx,
prompt={"prompt_token_ids": [1, 2]},
already_submitted=False,
)
assert submission.resumable is True
def test_stage_request_id_respects_resumable_flag() -> None:
fence = DuplexFence("sess", epoch=3, turn_id=9)
resumable_id = DuplexSessionManager.stage_request_id(fence, stage_id=0, resumable=True)
ephemeral_id = DuplexSessionManager.stage_request_id(fence, stage_id=0, resumable=False)
assert ephemeral_id == duplex_ephemeral_stage_request_id(fence, stage_id=0)
assert resumable_id != ephemeral_id
assert ephemeral_id.endswith("stage0-turn9")
from vllm_omni.engine.duplex.contracts import duplex_turn_id_from_request_id
assert duplex_turn_id_from_request_id(ephemeral_id) == 9
assert duplex_turn_id_from_request_id(resumable_id) is None
def test_capabilities_expose_concurrent_turn_requests_default_false() -> None:
caps = DuplexCapabilities()
assert caps.supports_concurrent_turn_requests is False
assert caps.allows_video_without_audio() is False
assert caps.required_input_modalities == frozenset({"audio"})
assert caps.optional_input_modalities == frozenset({"video"})
assert caps.supports_core_resumable_request is False
payload = caps.as_dict()
assert payload["supports_concurrent_turn_requests"] is False
assert payload["required_input_modalities"] == ["audio"]
assert payload["optional_input_modalities"] == ["video"]
def test_next_commit_allowed_soft_opens_on_r4_release() -> None:
from types import SimpleNamespace
from vllm_omni.engine.duplex.session import helpers
caps = DuplexCapabilities(supports_concurrent_turn_requests=True)
session = SimpleNamespace(active_response_id="resp", capabilities=caps)
tasks = SimpleNamespace(
active_response_task=None,
has_response_bound_append_tasks=lambda: False,
)
# Monkeypatch assistant_playback_active via response_in_progress path: active_response_id set.
assert helpers.response_in_progress(session, tasks) is True
assert helpers.next_commit_allowed(session, tasks, concurrent_turn_requests_released=False) is False
assert helpers.next_commit_allowed(session, tasks, concurrent_turn_requests_released=True) is True
caps_off = DuplexCapabilities(supports_concurrent_turn_requests=False)
session_off = SimpleNamespace(active_response_id="resp", capabilities=caps_off)
assert helpers.next_commit_allowed(session_off, tasks, concurrent_turn_requests_released=True) is False
def test_active_response_accepts_own_turn_when_concurrent_turn_requests() -> None:
"""Approach A: each response owns its turn; draining TTS uses request→response map."""
from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig
from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession
session = DuplexEngineSession(
session_id="s",
config=DuplexSessionConfig(model="m", modalities=["text"]),
capabilities=DuplexCapabilities(supports_concurrent_turn_requests=True),
)
session.begin_response(turn_id=3)
assert session.active_response_accepts_model_turn(3)
assert not session.active_response_accepts_model_turn(4)
assert not session.active_response_accepts_model_turn(2)
session.bind_draining_request("req-old", "resp-old")
assert session.response_id_for_request("req-old") == "resp-old"
assert session.is_draining_request("req-old")
# Ending the active response must not wipe older draining bindings.
session.bind_draining_request("req-newer-tts", session.active_response_id or "resp-active")
ended = session.active_response_id
session.end_response(commit_text=False)
assert session.is_draining_request("req-old")
assert session.response_id_for_request("req-old") == "resp-old"
assert not session.is_draining_request("req-newer-tts")
session.clear_draining_for_response("resp-old")
assert not session.is_draining_request("req-old")
del ended
strict = DuplexEngineSession(
session_id="s2",
config=DuplexSessionConfig(model="m", modalities=["text"]),
capabilities=DuplexCapabilities(supports_concurrent_turn_requests=False),
)
strict.begin_response(turn_id=3)
assert strict.active_response_accepts_model_turn(3)
assert not strict.active_response_accepts_model_turn(4)
def test_draining_request_exempt_from_completed_turn_filter() -> None:
"""Draining ownership must win over the model_turn_id < turn_id late-audio guard."""
from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig
from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession
session = DuplexEngineSession(
session_id="s-drain",
config=DuplexSessionConfig(model="m", modalities=["text", "audio"]),
capabilities=DuplexCapabilities(supports_concurrent_turn_requests=True),
)
session.begin_response(turn_id=1)
r1 = session.active_response_id
assert r1 is not None
session.bind_draining_request("req-r1-tts", r1)
# Overlapping R2 becomes active, then finishes — must not wipe R1 draining.
session.begin_response(turn_id=2)
session.end_response(commit_text=False)
session.turn_id = 2
assert session.active_response_id is None
assert session.is_draining_request("req-r1-tts")
assert session.response_id_for_request("req-r1-tts") == r1
draining_response_id = session.response_id_for_request("req-r1-tts")
model_turn_id = 1
drop = (
draining_response_id is None
and session.active_response_id is None
and model_turn_id is not None
and model_turn_id < session.turn_id
)
assert drop is False
def test_ephemeral_turn_id_parser_accepts_stage_turn_ids() -> None:
from vllm_omni.engine.duplex.contracts import duplex_turn_id_from_request_id
assert duplex_turn_id_from_request_id("duplex-s.x.e.0.r.stage0-turn12") == 12
assert duplex_turn_id_from_request_id("duplex-s.x.e.0.r.stage2-turn3") == 3
assert duplex_turn_id_from_request_id("duplex-s.x.e.0.r.stage0_t9") is None
assert duplex_turn_id_from_request_id("duplex-s.x.e.0.r.stage0") is None
def test_same_turn_request_ids_skip_other_turns_and_epochs() -> None:
from vllm_omni.engine.duplex.contracts import duplex_same_turn_request_ids
stage3 = "duplex-s.x.e.0.r.stage3-turn4"
found = duplex_same_turn_request_ids(
stage3,
[
"duplex-s.x.e.0.r.stage0-turn4",
"duplex-s.x.e.0.r.stage1-turn4",
"duplex-s.x.e.0.r.stage2-turn4",
stage3,
"duplex-s.x.e.0.r.stage2-turn5",
"duplex-s.x.e.1.r.stage0-turn4",
],
)
assert found == [
"duplex-s.x.e.0.r.stage0-turn4",
"duplex-s.x.e.0.r.stage1-turn4",
"duplex-s.x.e.0.r.stage2-turn4",
]
from vllm_omni.engine.duplex.contracts import duplex_session_id_from_request_id
fence_session = "sess id"
import base64
encoded = base64.urlsafe_b64encode(fence_session.encode()).decode("ascii").rstrip("=")
assert duplex_session_id_from_request_id(f"duplex-s.{encoded}.e.0.r.stage1-turn2") == fence_session
assert duplex_session_id_from_request_id("not-a-request") is None
def test_stale_keys_skip_already_draining_request_ids() -> None:
"""Rebinding must not move an older draining Stage2/3 id onto the newer response."""
from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig
from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession
session = DuplexEngineSession(
session_id="s-stale",
config=DuplexSessionConfig(model="m", modalities=["text", "audio"]),
capabilities=DuplexCapabilities(supports_concurrent_turn_requests=True),
)
session.begin_response(turn_id=1)
r1 = session.active_response_id
assert r1 is not None
session.bind_draining_request("req-r1-talker", r1)
session.begin_response(turn_id=2)
r2 = session.active_response_id
assert r2 is not None and r2 != r1
# Simulate the concurrent-turn rebind loop: already-draining ids keep R1.
for rid in ("req-r1-talker", "req-r2-talker"):
if session.is_draining_request(rid):
continue
session.bind_draining_request(rid, r2)
assert session.response_id_for_request("req-r1-talker") == r1
assert session.response_id_for_request("req-r2-talker") == r2
def test_end_response_clears_only_own_draining_entries() -> None:
from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig
from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession
session = DuplexEngineSession(
session_id="s-end",
config=DuplexSessionConfig(model="m", modalities=["text", "audio"]),
capabilities=DuplexCapabilities(supports_concurrent_turn_requests=True),
)
session.begin_response(turn_id=1)
r1 = session.active_response_id
session.bind_draining_request("req-r1", r1)
session.begin_response(turn_id=2)
r2 = session.active_response_id
session.bind_draining_request("req-r2", r2)
session.end_response(commit_text=False)
assert session.is_draining_request("req-r1")
assert session.response_id_for_request("req-r1") == r1
assert not session.is_draining_request("req-r2")
def test_on_stage_failure_resolves_draining_response_before_active() -> None:
"""Mirrors runner.on_stage_failure: draining request_id owns the failed done."""
from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig
from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession
session = DuplexEngineSession(
session_id="s-fail",
config=DuplexSessionConfig(model="m", modalities=["text", "audio"]),
capabilities=DuplexCapabilities(supports_concurrent_turn_requests=True),
)
session.begin_response(turn_id=1)
r1 = session.active_response_id
assert r1 is not None
session.bind_draining_request("req-r1-talker", r1)
session.begin_response(turn_id=2)
r2 = session.active_response_id
assert r2 is not None and r2 != r1
request_id = "req-r1-talker"
draining_response_id = (
session.response_id_for_request(request_id) if session.is_draining_request(request_id) else None
)
response_id = draining_response_id or session.active_response_id
assert response_id == r1
assert response_id != r2
session.clear_draining_for_response(r1)
assert not session.is_draining_request(request_id)
assert session.active_response_id == r2
def test_concurrent_turn_requests_released_resets_when_new_stage0_binds() -> None:
"""After R4 release, a new ephemeral Stage0 bind must clear the gate flag."""
from types import SimpleNamespace
run = SimpleNamespace(concurrent_turn_requests_released=True)
# Simulate the concurrent-turn rebind arm in model_channel._append_via_data_plane.
concurrent_turn = True
if concurrent_turn:
run.concurrent_turn_requests_released = False
assert run.concurrent_turn_requests_released is False
def test_draining_stage_ids_come_from_the_plugin() -> None:
from types import SimpleNamespace
from vllm_omni.engine.duplex.session.model_channel import ModelChannel
channel = ModelChannel.__new__(ModelChannel)
channel._ctx = SimpleNamespace(
plugin=SimpleNamespace(draining_stage_ids=lambda *, stage_count: {stage_count - 1}),
stage_port=SimpleNamespace(stage_count=5),
)
assert channel._draining_stage_ids() == frozenset({4})
channel._ctx.plugin = SimpleNamespace()
assert channel._draining_stage_ids() == frozenset()
def test_draining_empty_eos_completes_owning_response_before_shortcut() -> None:
"""R2 finishing first must not swallow R1's empty Stage3 EOS."""
import asyncio
from types import SimpleNamespace
from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig
from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession
from vllm_omni.engine.duplex.session.model_channel import ModelChannel
session = DuplexEngineSession(
session_id="s-empty-eos",
config=DuplexSessionConfig(model="m", modalities=["text", "audio"], extra_body={"auto_response": True}),
capabilities=DuplexCapabilities(supports_concurrent_turn_requests=True, supports_core_resumable_request=False),
)
session.begin_response(turn_id=1)
r1 = session.active_response_id
assert r1 is not None
r1_request = "duplex-s.x.e.0.r.stage3-turn1"
still_running = "duplex-s.x.e.0.r.stage1-turn2"
session.bind_draining_request(r1_request, r1)
session.begin_response(turn_id=2)
session.end_response(commit_text=False)
session.turn_id = 2
assert session.active_response_id is None
class _Plane:
def is_terminal(self, request_id: str | None) -> bool:
del request_id
return False
def close_stream(self, request_id: str) -> None:
del request_id
def mark_terminal(self, request_id: str) -> None:
del request_id
class _Port:
def __init__(self) -> None:
self.cleanups: list[list[str]] = []
async def cleanup(self, request_ids: list[str], *, abort: bool = False) -> None:
del abort
self.cleanups.append(list(request_ids))
class _Out:
def __init__(self) -> None:
self.events: list[dict[str, object]] = []
def auto_responds(self) -> bool:
return True
def emit(self, payload: dict[str, object]) -> None:
self.events.append(payload)
port = _Port()
out = _Out()
async def _noop(*_args: object, **_kwargs: object) -> None:
return None
async def _schedule(*_args: object, **_kwargs: object) -> bool:
return False
channel = ModelChannel(
SimpleNamespace(
session=session,
plugin=SimpleNamespace(data_plane=_Plane()),
stage_port=port,
model_state=SimpleNamespace(clear_continuation=lambda: None),
services=SimpleNamespace(spawn=lambda *_a, **_k: None),
),
out,
close_from_runtime=_noop,
schedule_silence_continuation=_schedule,
abort_request=_noop,
)
asyncio.run(
channel._send_one_model_output_event(
{
"data_plane_request_id": r1_request,
"end_of_turn": True,
"model_turn_id": 1,
"text": "",
"stage_role": "tts",
}
)
)
done = [event for event in out.events if event.get("type") == "response.done"]
assert done and done[-1].get("response_id") == r1
assert port.cleanups == [[r1_request]]
assert all(still_running not in ids for ids in port.cleanups)
assert not session.is_draining_request(r1_request)
def test_draining_completion_drops_finished_response_books_only() -> None:
"""R1 drain finish drops R1 books and leaves the live R2 snapshot."""
import asyncio
from types import SimpleNamespace
from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig
from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession
from vllm_omni.engine.duplex.session.model_channel import ModelChannel
session = DuplexEngineSession(
session_id="s-drain-books",
config=DuplexSessionConfig(model="m", modalities=["text", "audio"], extra_body={"auto_response": True}),
capabilities=DuplexCapabilities(supports_concurrent_turn_requests=True, supports_core_resumable_request=False),
)
session.begin_response(turn_id=1)
r1 = session.active_response_id
assert r1 is not None
session.append_assistant_text("from-r1")
session.snapshot_active_response_for_drain()
r1_request = "duplex-s.x.e.0.r.stage3-turn1"
session.bind_draining_request(r1_request, r1)
session.begin_response(turn_id=2)
r2 = session.active_response_id
assert r2 is not None and r2 != r1
session.append_assistant_text("from-r2")
session.snapshot_active_response_for_drain()
class _Plane:
def is_terminal(self, request_id: str | None) -> bool:
del request_id
return False
def close_stream(self, request_id: str) -> None:
del request_id
def mark_terminal(self, request_id: str) -> None:
del request_id
class _Port:
async def cleanup(self, request_ids: list[str], *, abort: bool = False) -> None:
del request_ids, abort
class _Out:
def __init__(self) -> None:
self.events: list[dict[str, object]] = []
def auto_responds(self) -> bool:
return True
def emit(self, payload: dict[str, object]) -> None:
self.events.append(payload)
out = _Out()
async def _noop(*_args: object, **_kwargs: object) -> None:
return None
async def _schedule(*_args: object, **_kwargs: object) -> bool:
return False
channel = ModelChannel(
SimpleNamespace(
session=session,
plugin=SimpleNamespace(data_plane=_Plane()),
stage_port=_Port(),
model_state=SimpleNamespace(clear_continuation=lambda: None),
services=SimpleNamespace(spawn=lambda *_a, **_k: None),
),
out,
close_from_runtime=_noop,
schedule_silence_continuation=_schedule,
abort_request=_noop,
)
asyncio.run(
channel._send_one_model_output_event(
{
"data_plane_request_id": r1_request,
"end_of_turn": True,
"model_turn_id": 1,
"text": "",
"stage_role": "tts",
}
)
)
assert r1 not in session._conversation.assistant_response_snapshots
assert r1 not in session._playback.by_response
assert f"item_{r1}" not in session._conversation.history_item_placeholders
assert r2 in session._conversation.assistant_response_snapshots
assert session.active_response_id == r2
done = [event for event in out.events if event.get("type") == "response.done"]
assert done and done[-1].get("response_id") == r1
def test_stale_continue_does_not_close_the_new_response() -> None:
import asyncio
from types import SimpleNamespace
from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig
from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession
from vllm_omni.engine.duplex.session.model_channel import ModelChannel
session = DuplexEngineSession(
session_id="s-stale-continue",
config=DuplexSessionConfig(model="m", modalities=["text", "audio"], extra_body={"auto_response": True}),
capabilities=DuplexCapabilities(supports_concurrent_turn_requests=True, supports_core_resumable_request=False),
)
session.begin_response(turn_id=2)
live = session.active_response_id
session.epoch = 1
class _Out:
def __init__(self) -> None:
self.events: list[dict[str, object]] = []
def auto_responds(self) -> bool:
return True
def emit(self, payload: dict[str, object]) -> None:
self.events.append(payload)
out = _Out()
cleared: list[bool] = []
async def _noop(*_args: object, **_kwargs: object) -> None:
return None
async def _schedule(*_args: object, **_kwargs: object) -> bool:
return False
channel = ModelChannel(
SimpleNamespace(
session=session,
plugin=SimpleNamespace(data_plane=SimpleNamespace()),
stage_port=SimpleNamespace(),
model_state=SimpleNamespace(clear_continuation=lambda: cleared.append(True)),
services=SimpleNamespace(spawn=lambda *_a, **_k: None),
run=SimpleNamespace(closing=False),
),
out,
close_from_runtime=_noop,
schedule_silence_continuation=_schedule,
abort_request=_noop,
)
asyncio.run(channel.maybe_continue_response(expected_epoch=0))
assert session.active_response_id == live
assert out.events == []
assert cleared == []
session.bind_draining_request("duplex-s.x.e.1.r.stage3-turn2", live or "")
asyncio.run(channel.maybe_continue_response(expected_epoch=1))
assert session.active_response_id == live
assert out.events == []
session.clear_draining_requests()
asyncio.run(channel.maybe_continue_response(expected_epoch=1))
assert session.active_response_id is None
assert [event.get("type") for event in out.events] == ["response.done"]
assert out.events[0].get("response_id") == live
def test_silent_listen_with_continuation_releases_ephemeral_request() -> None:
import asyncio
from collections.abc import Coroutine
from types import SimpleNamespace
from typing import Any
from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig
from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession
from vllm_omni.engine.duplex.session.model_channel import ModelChannel
session = DuplexEngineSession(
session_id="s-silent-continue",
config=DuplexSessionConfig(model="m", modalities=["text", "audio"]),
capabilities=DuplexCapabilities(supports_concurrent_turn_requests=True, supports_core_resumable_request=False),
)
session.begin_response(turn_id=1)
response_id = session.active_response_id
request_id = "duplex-s.x.e.0.r.stage1-turn1"
session.bind_request(request_id)
class _Plane:
def __init__(self) -> None:
self.terminal: list[str] = []
def is_terminal(self, request_id: str | None) -> bool:
return request_id in self.terminal
def mark_terminal(self, request_id: str) -> None:
self.terminal.append(request_id)
class _Port:
def __init__(self) -> None:
self.cleanups: list[list[str]] = []
async def cleanup(self, request_ids: list[str], *, abort: bool = False) -> None:
del abort
self.cleanups.append(list(request_ids))
class _Out:
def __init__(self) -> None:
self.events: list[dict[str, object]] = []
def auto_responds(self) -> bool:
return False
def emit(self, payload: dict[str, object]) -> None:
self.events.append(payload)
port = _Port()
out = _Out()
spawned: list[Coroutine[Any, Any, None]] = []
async def _noop(*_args: object, **_kwargs: object) -> None:
return None
async def _schedule(*_args: object, **_kwargs: object) -> bool:
return False
channel = ModelChannel(
SimpleNamespace(
session=session,
plugin=SimpleNamespace(data_plane=_Plane()),
stage_port=port,
model_state=SimpleNamespace(
continuation_owner_id=None, continuation_units=0, clear_continuation=lambda: None
),
services=SimpleNamespace(spawn=lambda coro, **_k: spawned.append(coro)),
run=SimpleNamespace(closing=False),
),
out,
close_from_runtime=_noop,
schedule_silence_continuation=_schedule,
abort_request=_noop,
)
asyncio.run(
channel._send_one_model_output_event(
{
"data_plane_request_id": request_id,
"is_listen": True,
"end_of_turn": True,
"model_turn_id": 1,
"reason": "model_listen",
}
)
)
assert port.cleanups == [[request_id]]
assert session.active_request_id is None
assert spawned, "silent listen with unused continuation must schedule maybe_continue"
asyncio.run(spawned[0])
assert port.cleanups == [[request_id]]
assert session.active_response_id is None
assert [event.get("type") for event in out.events] == ["response.listen", "response.done"]
assert out.events[0].get("response_id") == response_id
assert out.events[1].get("response_id") == response_id
def test_silent_listen_aborts_data_plane_request_with_full_id() -> None:
"""abort_data_plane_request must pass [request_id], not a bare str."""
import asyncio
from types import SimpleNamespace
from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig
from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession
from vllm_omni.engine.duplex.session.model_channel import ModelChannel
session = DuplexEngineSession(
session_id="s-silent-abort",
config=DuplexSessionConfig(model="m", modalities=["text", "audio"]),
capabilities=DuplexCapabilities(supports_concurrent_turn_requests=True, supports_core_resumable_request=False),
)
request_id = "duplex-s.x.e.0.r.stage0-turn7"
session.bind_request(request_id)
aborts: list[list[str]] = []
class _Plane:
def is_terminal(self, request_id: str | None) -> bool:
return False
def mark_terminal(self, request_id: str) -> None:
del request_id
class _Port:
async def cleanup(self, request_ids: list[str], *, abort: bool = False) -> None:
del request_ids, abort
class _Out:
def auto_responds(self) -> bool:
return True
def emit(self, payload: dict[str, object]) -> None:
del payload
async def _noop(*_args: object, **_kwargs: object) -> None:
return None
async def _abort(request_ids: list[str], *, notify: bool = False) -> None:
del notify
aborts.append(list(request_ids))
channel = ModelChannel(
SimpleNamespace(
session=session,
plugin=SimpleNamespace(data_plane=_Plane()),
stage_port=_Port(),
model_state=SimpleNamespace(
continuation_owner_id=None, continuation_units=0, clear_continuation=lambda: None
),
services=SimpleNamespace(spawn=lambda coro, **_k: None),
run=SimpleNamespace(closing=False),
),
_Out(),
close_from_runtime=_noop,
schedule_silence_continuation=_noop,
abort_request=_abort,
)
asyncio.run(
channel._send_one_model_output_event(
{
"data_plane_request_id": request_id,
"is_listen": True,
"end_of_turn": True,
"model_turn_id": 1,
"reason": "model_listen",
"abort_data_plane_request": True,
}
)
)
assert aborts == [[request_id]]
assert all(len(item) > 1 for batch in aborts for item in batch), "must not split request id into chars"
+105
View File
@@ -242,6 +242,19 @@ def test_history_commit_with_ack_only_policy_defers_unacknowledged_text():
assert f"item_{response_id}" in session.pending_history_item_ids
def test_history_commit_with_ack_only_policy_keeps_only_acknowledged_prefix():
session = _session(config=DuplexSessionConfig(playback_commit_policy="ack_only"))
session.begin_response()
session.append_assistant_text("hello world")
session.mark_audio_sent(duration_ms=1_000, text_chars=6)
session.mark_audio_sent(duration_ms=2_000, text_chars=11)
session.acknowledge_playback(played_ms=1_000, committed_ms=1_000)
committed = session.end_response(commit_text=True)
assert committed == {"role": "assistant", "content": "hello"}
@pytest.mark.parametrize("audio_complete", [False, True])
def test_unaligned_response_keeps_empty_turn_before_later_user_input(audio_complete):
session = _session(config=DuplexSessionConfig(playback_commit_policy="ack_only"))
@@ -469,6 +482,36 @@ def test_cancel_fence_releases_stage_requests_and_advances_identity():
session.cancel_fence(DuplexFence("sid-other", epoch=1, turn_id=0), DuplexFence("sid-cancel", epoch=2))
def test_request_resource_keys_are_stage_id_and_request_id():
session = _session("sid-keys")
session.bind_stage_request(0, "req-a", fence=session.fence)
session.bind_stage_request(1, "req-b", fence=session.fence)
session.bind_stage_request(2, "req-tts", fence=session.fence)
stale_keys = list(session.request_resources.keys())
stale_ids = list(dict.fromkeys(rid for _, rid in stale_keys))
assert stale_ids == ["req-a", "req-b", "req-tts"]
for sid, rid in stale_keys:
if sid < 2:
session.request_resources.pop((sid, rid), None)
assert session.resource_request_ids() == ["req-tts"]
with pytest.raises(TypeError, match="unhashable"):
dict.fromkeys(rid for _, rid in session.request_resources.items())
def test_release_resources_for_request_ids_keeps_other_ids_on_the_same_fence():
session = _session("sid-drain-release")
fence = session.fence
session.bind_stage_request(2, "drain", fence=fence)
session.bind_stage_request(3, "drain", fence=fence)
session.bind_stage_request(0, "live", fence=fence)
released = session.release_resources_for_request_ids(["drain"])
assert released == ["drain"]
assert session.resource_request_ids() == ["live"]
assert session.release_resources_for_request_ids([]) == []
# ---- public view ----
@@ -688,3 +731,65 @@ def test_minicpmo_native_capabilities_do_not_overclaim_single_session_deployment
assert caps["supports_multi_session"] is False
assert caps["supports_multi_session_same_replica"] is False
def test_draining_response_stays_ack_admissible_after_next_begin_response() -> None:
from vllm_omni.engine.duplex.events import ErrorEvent
from vllm_omni.engine.duplex.session.playback_ledger import apply_playback_ack
session = _session()
first = session.begin_response(turn_id=1)
session.append_assistant_text("hello")
session.mark_audio_sent(400, text_chars=5)
session.snapshot_active_response_for_drain()
second = session.begin_response(turn_id=2)
assert second != first
assert session.playback.sent_ms == 0
events = apply_playback_ack(
session,
{
"type": "playback.ack",
"response_id": first,
"item_id": f"item_{first}",
"played_ms": 100,
"committed_ms": 100,
},
)
assert not any(isinstance(event, ErrorEvent) and event.code == "playback_item_not_found" for event in events)
session.mark_audio_sent(900, text_chars=5, response_id=first)
assert session.playback_for_response(first).sent_ms == 900
assert session.playback.sent_ms == 0
def test_finished_drain_keeps_sent_audio_ackable() -> None:
from vllm_omni.engine.duplex.events import ErrorEvent
from vllm_omni.engine.duplex.session.playback_ledger import apply_playback_ack
session = _session()
first = session.begin_response(turn_id=1)
session.append_assistant_text("hello")
session.mark_audio_sent(400, text_chars=5)
session.snapshot_active_response_for_drain()
second = session.begin_response(turn_id=2)
session.release_finished_drain_response(first)
assert first in session._conversation.assistant_response_snapshots
assert f"item_{first}" in session._conversation.history_item_placeholders
events = apply_playback_ack(
session,
{
"type": "playback.ack",
"response_id": first,
"item_id": f"item_{first}",
"played_ms": 400,
"committed_ms": 400,
},
)
assert not any(isinstance(event, ErrorEvent) for event in events)
assert session.active_response_id == second
silent = session.begin_response(turn_id=3)
session.snapshot_active_response_for_drain()
session.begin_response(turn_id=4)
session.release_finished_drain_response(silent)
assert silent not in session._conversation.assistant_response_snapshots
assert f"item_{silent}" not in session._conversation.history_item_placeholders
@@ -0,0 +1,49 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import pytest
from vllm_omni.engine.duplex.realtime_events import (
RealtimeProjectionState,
project_internal_event,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_input_transcribed_is_a_user_transcript_event() -> None:
events = project_internal_event(
RealtimeProjectionState(session_id="s"),
{"type": "input.transcribed", "transcript": " 你好 "},
)
assert len(events) == 1
assert events[0].wire_type == "conversation.item.input_audio_transcription.completed"
assert events[0].transcript == "你好"
def test_input_transcribed_updates_the_committed_item() -> None:
state = RealtimeProjectionState(session_id="s")
project_internal_event(
state,
{
"type": "input.committed",
"realtime_item_id": "item_user_1",
"is_speech": True,
"message": {"role": "user"},
},
)
events = project_internal_event(
state,
{"type": "input.transcribed", "transcript": "你好", "realtime_item_id": "item_user_1"},
)
assert events[0].item_id == "item_user_1"
item = state.conversation_items["item_user_1"]
assert item["content"][0]["transcript"] == "你好"
def test_blank_input_transcribed_emits_nothing() -> None:
events = project_internal_event(
RealtimeProjectionState(session_id="s"),
{"type": "input.transcribed", "transcript": " "},
)
assert events == []
@@ -0,0 +1,117 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import base64
from types import SimpleNamespace
import pytest
from vllm_omni.engine.duplex.config import DuplexSessionConfig
from vllm_omni.engine.duplex.contracts import DuplexFence
from vllm_omni.model_executor.models.minicpmo_4_5.duplex import plugin as module
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _plugin():
return module.MiniCPMO45DuplexPlugin(lambda *args: None)
@pytest.mark.asyncio
async def test_window_configuration_is_prepared_by_plugin(monkeypatch):
tokenizer = SimpleNamespace(
all_special_ids=[90],
unk_token_id=-1,
eos_token_id=99,
encode=lambda text, add_special_tokens=False: [ord(char) for char in text],
convert_tokens_to_ids=lambda token: {"<|listen|>": 91}.get(token, -1),
)
monkeypatch.setattr(module, "_load_tokenizer", lambda config: tokenizer)
config = DuplexSessionConfig(
modalities=("text",),
extra_body={
"sliding_window_mode": "basic",
"basic_window_high_tokens": 120,
"basic_window_low_tokens": 80,
},
)
runtime = await _plugin().prepare_runtime_config(config, model_config=None)
assert runtime["duplex_window_config"]["sliding_window_mode"] == "basic"
assert "sliding_window_mode" not in config.extra_body
assert runtime["duplex_window_prefix_tokens"] > 0
assert runtime["duplex_window_suffix_token_ids"]
assert runtime["duplex_window_previous_marker_token_ids"]
@pytest.mark.asyncio
async def test_invalid_window_configuration_is_rejected():
config = DuplexSessionConfig(
modalities=("text",),
extra_body={
"basic_window_high_tokens": 80,
"basic_window_low_tokens": 80,
},
)
with pytest.raises(module.MiniCPMO45ClientRuntimeConfigError) as exc:
await _plugin().prepare_runtime_config(config, model_config=None)
assert exc.value.code == "invalid_sliding_window_config"
def test_window_update_cannot_change_created_configuration():
config = DuplexSessionConfig(extra_body={"sliding_window_mode": "basic"})
with pytest.raises(module.MiniCPMO45ClientRuntimeConfigError) as exc:
_plugin().runtime_config_for_update(config, {"duplex_window_config": {"sliding_window_mode": "off"}})
assert exc.value.code == "sliding_window_update_unsupported"
def test_window_internal_configuration_is_server_owned():
with pytest.raises(module.MiniCPMO45ClientRuntimeConfigError):
_plugin().validate_client_extra_body({"duplex_window_config": {}})
@pytest.mark.parametrize("final", [False, True])
@pytest.mark.parametrize("seq,samples,expected", [(1, 32000, 16), (1, 48000, 28), (2, 16000, 13)])
def test_first_and_final_append_reserve_exact_window_input(seq, samples, expected, final):
prompt = module.build_duplex_data_plane_prompt(
request_id="window-request",
fence=DuplexFence("sid", turn_id=1),
session_config={},
runtime_config={"duplex_first_append_context_tokens": 5},
seq=seq,
turn_seq=seq,
payload={
"audio": base64.b64encode(bytes(samples * 4)).decode(),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
},
final=final,
)
assert len(prompt["prompt_token_ids"]) == expected
@pytest.mark.parametrize("seq", [1, 2, 3])
def test_final_exact_chunk_append_reserves_one_unit_not_two(seq):
"""A final append whose audio is already an exact number of chunks must
reserve exactly what Stage0 feeds: one unit per chunk (``<unit>`` plus its
audio) and the closure pair for every unit after the first. Serving pads
the final residual itself, so the old extra 12-slot silent-unit
reservation only put 12 pad positions ahead of the audio in the KV."""
def _budget(*, seq, final):
prompt = module.build_duplex_data_plane_prompt(
request_id="final-request",
fence=DuplexFence("sid", turn_id=1),
session_config={},
runtime_config={"duplex_first_append_context_tokens": 0},
seq=seq,
turn_seq=seq,
payload={
"audio": base64.b64encode(bytes(16000 * 4)).decode(),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
},
final=final,
)
return len(prompt["prompt_token_ids"])
assert _budget(seq=seq, final=True) == _budget(seq=seq, final=False)
@@ -79,6 +79,39 @@ def test_an_explicit_client_overlap_action_requests_barge_in() -> None:
assert decision["reason"] == "client_overlap_action"
def test_turn_mode_keeps_silent_vision_follow_during_overlap() -> None:
"""Vision-follow is is_speech=False + a frame; must not drop while TTS plays."""
session = _session()
session.capabilities = type(session.capabilities)(
required_input_modalities=frozenset({"video"}),
optional_input_modalities=frozenset({"audio"}),
)
payload = {"format": "pcm16", "audio": _pcm16(0.0), "video_frames": ["frame"], "is_speech": False}
decision = overlap_policy.decide(session, {"is_speech": False}, payload, auto_responds=False)
assert decision["action"] == "listen"
assert decision["reason"] == "vision_follow"
assert decision["buffer_audio"] is True
assert decision["defer_runtime_append"] is False
assert decision["force_listen"] is False
def test_turn_mode_drops_silent_vision_without_capability() -> None:
session = _session()
assert session.capabilities.allows_video_without_audio() is False
payload = {"format": "pcm16", "audio": _pcm16(0.0), "video_frames": ["frame"], "is_speech": False}
decision = overlap_policy.decide(session, {"is_speech": False}, payload, auto_responds=False)
assert decision["action"] == "drop"
assert decision["reason"] == "silence_or_noise"
def test_turn_mode_still_drops_silent_overlap_without_vision() -> None:
session = _session()
payload = {"format": "pcm16", "audio": _pcm16(0.0), "is_speech": False}
decision = overlap_policy.decide(session, {"is_speech": False}, payload, auto_responds=False)
assert decision["action"] == "drop"
assert decision["reason"] == "silence_or_noise"
def test_barge_in_is_deferred_when_the_session_cannot_support_it() -> None:
session = _session()
session.capabilities = type(session.capabilities)(supports_barge_in=False)
+73 -2
View File
@@ -51,6 +51,7 @@ from vllm_omni.engine.duplex.plugin import (
PcmAppendReservation,
)
from vllm_omni.engine.duplex.session import manager as session_manager_module
from vllm_omni.engine.duplex.session.context import DuplexSessionTasks
from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession
from vllm_omni.engine.duplex.session.lease import DuplexLeaseActivity
from vllm_omni.engine.duplex.session.manager import DuplexSessionManager
@@ -196,7 +197,11 @@ class FakePlugin(DuplexModelPlugin):
def capabilities(self, *, max_sessions: int) -> DuplexCapabilities:
del max_sessions
return DuplexCapabilities(supports_input_append=True, supports_core_resumable_request=True)
# Resident Stage0 ids (MiniCPM-shaped); AURA opts out via supports_core_resumable_request=False.
return DuplexCapabilities(
supports_input_append=True,
supports_core_resumable_request=True,
)
def validate_client_extra_body(self, extra_body: object) -> None:
pass
@@ -409,7 +414,8 @@ async def test_open_answers_with_capabilities_and_emits_session_created() -> Non
assert result.session_id == "sid-open"
assert result.lease_generation == 0
assert result.capabilities == DuplexCapabilities(
supports_input_append=True, supports_core_resumable_request=True
supports_input_append=True,
supports_core_resumable_request=True,
)
assert result.public_session is not None
assert result.public_session["id"] == "sid-open"
@@ -1028,6 +1034,40 @@ async def test_append_bytes_are_reserved_at_admission_until_the_runner_dequeues(
gate.set()
async def test_append_admission_counts_audio_and_video_frame_bytes() -> None:
"""Manager reserves len(audio)+Σlen(frame); video bytes count toward the same limit."""
async with Harness.create(max_sessions=1, max_pending_input_bytes_per_session=20) as harness:
await harness.open("sid-av")
harness.events()
session = harness.session("sid-av")
runner = harness.manager.runners["sid-av"]
gate = asyncio.Event()
runner._mailbox.put_nowait(_Internal("wait", {"gate": gate}))
runner._on_internal = lambda item: gate.wait() # type: ignore[method-assign]
await asyncio.sleep(0)
# Default caps require audio; attach video to a non-empty audio unit.
frame_a = "aaaa"
frame_b = "bbbbbb"
audio = b"1234"
expected = len(audio) + len(frame_a) + len(frame_b)
harness.command(
"sid-av",
AppendAudio(audio=audio, video_frames=(frame_a, frame_b), event_id="evt-av"),
)
assert session.pending_input_bytes == expected
# Second append that would exceed the limit is backpressured.
harness.command(
"sid-av",
AppendAudio(audio=b"x" * 10, video_frames=("yyyyyyyyyy",), event_id="evt-over"),
)
errors = [event for event in harness.events("sid-av") if isinstance(event, ErrorEvent)]
assert [error.code for error in errors] == ["input_backpressure"]
assert errors[0].related_event_id == "evt-over"
assert session.pending_input_bytes == expected
gate.set()
async def test_expired_session_retains_the_admission_slot_until_cleanup_succeeds() -> None:
async with Harness.create(max_sessions=1, idle_ttl_s=1.0) as harness:
await harness.open("sid-expired")
@@ -1573,3 +1613,34 @@ async def test_reaper_loop_survives_one_cleanup_failure(first_cleanup_delay: flo
finally:
shutdown.set()
await asyncio.wait_for(task, timeout=5.0)
# --------------------------------------------------------------------------- #
# Cancellation waits #
# --------------------------------------------------------------------------- #
async def test_cancel_append_tasks_absorbs_a_task_that_outlives_the_wait() -> None:
"""The wait after cancelling is allowed to time out; that is not an error.
On Python 3.10 the timeout arrives as ``asyncio.TimeoutError``, which is a
different class from the builtin ``TimeoutError`` until 3.11.
"""
async def outlives_the_first_cancel() -> bool:
try:
await asyncio.sleep(5)
except asyncio.CancelledError:
await asyncio.sleep(1)
raise
return True
tasks = DuplexSessionTasks()
task = asyncio.create_task(outlives_the_first_cancel())
await asyncio.sleep(0)
tasks.track_append_task(task, epoch=0, final=False, response_bound=False)
tasks.append_tail = task
assert await tasks.cancel_append_tasks(timeout_s=0.05) is True
assert tasks.append_tail is None
await asyncio.wait_for(asyncio.gather(task, return_exceptions=True), timeout=5.0)
+292 -2
View File
@@ -180,7 +180,10 @@ class Harness:
segment_token_ids=tuple(segment_token_ids),
segment_output_metadata=dict(segment_output_metadata or {}),
)
return self.runner.on_stage_output(stage_id, output, metrics, request_id=output.request_id, context=context)
request_id = getattr(output, "request_id", None)
if not isinstance(request_id, str):
raise AssertionError("stage output is missing request_id")
return self.runner.on_stage_output(stage_id, output, metrics, request_id=request_id, context=context)
async def deliver_and_settle(self, output: object, **kwargs: Any) -> list[DuplexEvent]:
self.deliver(output, **kwargs)
@@ -520,6 +523,28 @@ async def test_commit_without_audio_is_rejected() -> None:
await close_harness(h)
@pytest.mark.asyncio
async def test_empty_silent_commit_acks_without_opening_response() -> None:
"""#3: empty is_speech=False commit keeps the silent-ack pair, even auto-respond."""
h = await open_harness(auto_response=True)
try:
# resolve_commit only admits empty silent commits after non-speech was seen.
projector = h.runner._require_projector()
projector.input_audio_buffer_had_non_speech = True
events = await h.run(commands.Commit())
assert "error" not in types(events), types(events)
assert "input_audio_buffer.committed" in types(events), types(events)
assert "response.listen" in types(events), types(events)
listen = next(event for event in events if event.type == "response.listen")
assert listen.details["reason"] == "silence_or_noise"
committed = next(event for event in events if event.type == "input_audio_buffer.committed")
assert committed.details.get("empty") is True
assert committed.details.get("no_response") is True
assert h.port.submissions == []
finally:
await close_harness(h)
@pytest.mark.asyncio
async def test_commit_projects_the_user_item_and_does_not_resubmit_consumed_audio() -> None:
h = await open_harness()
@@ -674,6 +699,45 @@ async def test_stale_epoch_output_is_dropped_after_barge_in() -> None:
await close_harness(h)
async def test_barge_in_aborts_draining_tts_as_well_as_the_active_request() -> None:
h = await open_harness()
try:
await h.run(append_audio())
request_id = h.stage0_request_id()
await h.deliver_and_settle(tts_output(request_id, samples=24000, text="he"))
h.session.bind_draining_request("duplex-drain-tts", "resp-old")
# Draining TTS sits on an older turn fence. cancel_fence only drops
# the fence being cancelled, so these bindings must be popped by id.
from vllm_omni.engine.duplex.contracts import DuplexFence
from vllm_omni.engine.duplex.session.engine_session import DuplexRequestResource
older = DuplexFence(h.session.session_id, epoch=h.session.epoch, turn_id=h.session.turn_id + 5)
h.session.request_resources[(2, "duplex-drain-tts")] = DuplexRequestResource(
stage_id=2, request_id="duplex-drain-tts", fence=older, submitted=True
)
h.session.request_resources[(3, "duplex-drain-tts")] = DuplexRequestResource(
stage_id=3, request_id="duplex-drain-tts", fence=older, submitted=True
)
h.session.request_resources[(1, "still-live")] = DuplexRequestResource(
stage_id=1, request_id="still-live", fence=older, submitted=True
)
events = await h.run(commands.BargeIn())
assert h.port.aborts == [[request_id, "duplex-drain-tts"]]
assert not h.session.is_draining_request("duplex-drain-tts")
done_ids = [
event.response["id"]
for event in events
if getattr(event, "wire_type", "") == "response.done" and isinstance(getattr(event, "response", None), dict)
]
assert "resp-old" in done_ids
assert (2, "duplex-drain-tts") not in h.session.request_resources
assert (3, "duplex-drain-tts") not in h.session.request_resources
assert (0, request_id) not in h.session.request_resources
assert (1, "still-live") in h.session.request_resources
finally:
await close_harness(h)
@pytest.mark.asyncio
async def test_cancel_response_without_active_response_is_rejected() -> None:
h = await open_harness()
@@ -707,6 +771,41 @@ async def test_cancel_response_aborts_the_stage_request_and_reports_playback() -
await close_harness(h)
@pytest.mark.asyncio
async def test_cancel_response_absorbs_a_response_task_that_outlives_the_wait() -> None:
"""The 0.25 s wait after cancelling the response task may time out.
On Python 3.10 that timeout is ``asyncio.TimeoutError``, not the builtin
``TimeoutError``, and it must not stop the cancel from completing.
"""
async def outlives_the_first_cancel() -> None:
try:
await asyncio.sleep(5)
except asyncio.CancelledError:
await asyncio.sleep(1)
raise
h = await open_harness()
task = asyncio.create_task(outlives_the_first_cancel())
try:
await h.run(append_audio())
request_id = h.stage0_request_id()
await h.deliver_and_settle(tts_output(request_id, samples=24000, text="hello"))
h.runner.tasks.active_response_task = task
events = await h.run(commands.CancelResponse())
# The runner is still inside that wait when the mailbox goes quiet.
events += await h.settle(idle_s=0.6, timeout_s=5.0)
assert find(events, "response.done").status == "cancelled"
assert "error" not in types(events)
assert h.session.epoch == 1
finally:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
await close_harness(h)
@pytest.mark.asyncio
async def test_listen_decision_is_consumed_and_never_forwarded_to_tts() -> None:
h = await open_harness()
@@ -778,6 +877,46 @@ async def test_listen_decision_on_a_resumable_request_closes_the_bounded_respons
await close_harness(h)
@pytest.mark.asyncio
async def test_direct_response_listen_still_emits_response_done_after_continuation_clear() -> None:
"""AURA-like silent DIRECT_RESPONSE: after continuation budget is spent, emit response.done.
Non-resumable turn-commit still closes the active response on a listen/direct
decision so the client is not left without a terminal event.
"""
from dataclasses import replace
h = await open_harness()
try:
h.session.capabilities = replace(h.session.capabilities, supports_core_resumable_request=False)
await h.run(append_audio())
assert h.port.submissions, "ephemeral Stage0 must submit"
request_id = h.port.submissions[-1].context.request_id
assert "-turn" in request_id
await h.deliver_and_settle(tts_output(request_id, samples=24000, text="ok"))
response_id = h.session.active_response_id
assert response_id is not None
model_state = h.runner.model_state
model_state.continuation_owner_id = f"response:{response_id}"
model_state.continuation_units = h.runner.model._AUTO_RESPONSE_MAX_CONTINUATION_UNITS
listen = listen_output(request_id)
listen.finished = True
events = await h.deliver_and_settle(
listen,
stage_id=0,
segment_finished=True,
segment_token_ids=[11, 12, LISTEN_TOKEN_ID],
segment_output_metadata={"meta.listen_token_id": LISTEN_TOKEN_ID},
)
assert "response.listen" in types(events)
done = find(events, "response.done")
assert done.response_id == response_id
assert h.session.active_response_id is None
finally:
await close_harness(h)
@pytest.mark.asyncio
async def test_listen_decision_on_a_resumable_request_keeps_the_turn_going() -> None:
"""Same unfinished listen, budget left: it must schedule the next unit.
@@ -860,6 +999,38 @@ async def test_stage_failure_fails_the_active_response() -> None:
await close_harness(h)
@pytest.mark.asyncio
async def test_stage_failure_on_draining_request_fails_that_response_only() -> None:
"""Overlapped: a draining older request failure must not wipe the active response."""
from dataclasses import replace
h = await open_harness()
try:
h.session.capabilities = replace(h.session.capabilities, supports_concurrent_turn_requests=True)
await h.run(append_audio())
request_id = h.stage0_request_id()
await h.deliver_and_settle(tts_output(request_id, samples=24000, text="hello"))
r1 = h.session.active_response_id
assert r1 is not None
draining_req = "req-r1-talker-drain"
h.session.bind_draining_request(draining_req, r1)
# Open a newer active response while R1 TTS is still draining.
r2 = h.session.begin_response(turn_id=(h.session.turn_id or 0) + 1)
assert h.session.active_response_id == r2
assert r2 != r1
h.runner.on_stage_failure(2, RuntimeError("drain-boom"), request_id=draining_req)
events = await h.settle()
assert types(events)[0] == "error"
done = find(events, "response.done")
assert done.response_id == r1
assert done.status == "failed"
assert h.session.active_response_id == r2
assert not h.session.is_draining_request(draining_req)
finally:
await close_harness(h)
# --------------------------------------------------------------------------- #
# Turn mode (no auto response) #
# --------------------------------------------------------------------------- #
@@ -877,6 +1048,91 @@ async def test_turn_mode_skips_silent_chunks() -> None:
await close_harness(h)
@pytest.mark.asyncio
async def test_turn_mode_keeps_silent_chunks_with_video_frames() -> None:
"""Engine vision-follow buffers silent+frames; MiniCPM commit stays speech-gated."""
from dataclasses import replace
h = await open_harness(auto_response=False)
try:
h.session.capabilities = replace(
h.session.capabilities,
required_input_modalities=frozenset({"video"}),
optional_input_modalities=frozenset({"audio"}),
)
# Minimal 1x1 JPEG (base64) — wire validation only checks non-empty str.
frame = (
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkS"
"Ew8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJ"
"CQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIy"
"MjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAA"
"AAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAA"
"AAAAAAD/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAGfAP/EABQQ"
"AQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAQUCf//EABQRAQAAAAAAAAAAAAAAAAAA"
"AAD/2gAIAQMBAT8Bf//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQIBAT8Bf//E"
"ABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEABj8Cf//EABQQAQAAAAAAAAAAAAAA"
"AAAAAAD/2gAIAQEAAT8hf//Z"
)
cmd = commands.AppendAudio(
audio=pcm_f32(160, value=0.0),
format="pcm_f32le",
sample_rate_hz=16000,
is_speech=False,
video_frames=(frame,),
)
events = await h.run(cmd)
assert "response.listen" not in types(events)
assert h.runner.model_state.audio_buffer.has_pending()
events = await h.run(commands.Commit(create_response=True))
# MiniCPM prepare_commit is speech-gated (no frames-only Stage0).
assert "input_audio_buffer.committed" in types(events), types(events)
assert h.port.submissions == []
finally:
await close_harness(h)
@pytest.mark.asyncio
async def test_turn_mode_keeps_silent_vision_while_response_in_progress() -> None:
"""TTS still playing: vision-follow must buffer, not drop as silence_or_noise."""
from dataclasses import replace
h = await open_harness(auto_response=False)
try:
h.session.capabilities = replace(
h.session.capabilities,
required_input_modalities=frozenset({"video"}),
optional_input_modalities=frozenset({"audio"}),
)
h.session._response.active_response_id = "resp-tts"
frame = (
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkS"
"Ew8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJ"
"CQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIy"
"MjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAA"
"AAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAA"
"AAAAAAD/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAGfAP/EABQQ"
"AQAAAAAAAAAAAAAAAAAAAAD/2gAIAQMBAT8Bf//EABQRAQAAAAAAAAAAAAAAAAAA"
"AAD/2gAIAQIBAT8Bf//EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEABj8Cf//E"
"ABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAT8hf//Z"
)
events = await h.run(
commands.AppendAudio(
audio=pcm_f32(160, value=0.0),
format="pcm_f32le",
sample_rate_hz=16000,
is_speech=False,
video_frames=(frame,),
)
)
listen_reasons = [
getattr(event, "details", {}) or {} for event in events if getattr(event, "type", None) == "response.listen"
]
assert all(details.get("reason") != "silence_or_noise" for details in listen_reasons)
assert h.runner.model_state.audio_buffer.has_pending()
finally:
await close_harness(h)
@pytest.mark.asyncio
async def test_turn_mode_commit_with_response_create_starts_one_response() -> None:
h = await open_harness(auto_response=False)
@@ -1033,7 +1289,7 @@ async def test_conversation_items_can_be_injected_and_deleted() -> None:
await close_harness(h)
def _stage_metrics_of(event: object) -> dict[str, dict[str, object]]:
def _stage_metrics_of(event: DuplexEvent) -> dict[str, dict[str, object]]:
"""Per-stage engine metrics as the client reads them off one wire event."""
payload = event.to_realtime()
metadata = payload.get("metadata")
@@ -1081,6 +1337,40 @@ async def test_stage0_metrics_reach_the_response_even_though_its_output_feeds_tt
await close_harness(h)
@pytest.mark.asyncio
async def test_projected_stage1_metrics_reach_the_spoken_audio_event() -> None:
"""Thinker text is projected, but its TTFT/TPOT still have to ride the audio event."""
h = await open_harness(stage_count=3)
try:
plugin = h.runner.model._ctx.plugin
def project_stage1(*, stage_id: int, output: object, context: object) -> bool:
del output, context
return stage_id == 1
plugin.project_intermediate_output = project_stage1
await h.run(append_audio())
request_id = h.stage0_request_id()
thinker = SimpleNamespace(
request_id=request_id,
finished=True,
outputs=[SimpleNamespace(text="hello", token_ids=[7, 8], multimodal_output={})],
multimodal_output={},
)
forwarded = h.deliver(
thinker,
stage_id=1,
segment_finished=False,
metrics=stage_stats(stage_id=1, request_id=request_id, num_tokens_out=2, itls_ms=[6.0]),
)
assert forwarded is False
events = await h.deliver_and_settle(tts_output(request_id, samples=24000, text="hello"), stage_id=2)
stage_metrics = _stage_metrics_of(find(events, "response.output_audio.delta"))
assert stage_metrics["1"]["num_tokens_out"] == 2
finally:
await close_harness(h)
@pytest.mark.asyncio
async def test_stage0_metrics_from_several_units_are_summed_into_one_response() -> None:
"""Two pass-through segments before the first audio are one response's tokens."""
@@ -0,0 +1,28 @@
# SPDX-License-Identifier: Apache-2.0
"""Ephemeral Stage0 bind uses r.stage0-turn{N}, not resumable r.stage0."""
from __future__ import annotations
from vllm_omni.engine.duplex.contracts import (
DuplexFence,
duplex_ephemeral_stage_request_id,
duplex_resource_request_id,
)
from vllm_omni.engine.duplex.session.manager import DuplexSessionManager
def test_stage0_request_id_ephemeral_when_not_resumable() -> None:
fence = DuplexFence("duplex-test", epoch=0, turn_id=3)
rid = DuplexSessionManager.stage_request_id(fence, stage_id=0, resumable=False)
expected = duplex_ephemeral_stage_request_id(fence, stage_id=0)
assert rid == expected
assert rid.endswith(".r.stage0-turn3")
assert not rid.endswith(".r.stage0")
def test_stage0_request_id_resumable_keeps_stable_role() -> None:
fence = DuplexFence("duplex-test", epoch=1, turn_id=3)
rid = DuplexSessionManager.stage_request_id(fence, stage_id=0, resumable=True)
expected = duplex_resource_request_id(fence, "stage0")
assert rid == expected
assert rid.endswith(".r.stage0")
+2 -1
View File
@@ -22,6 +22,7 @@ from pydantic import ValidationError
from transformers import PretrainedConfig, Qwen3OmniMoeConfig
from vllm.engine.arg_utils import EngineArgs
from tests.helpers.mock import patch_hf_snapshot_download
from vllm_omni.config.model import OmniModelConfig
from vllm_omni.engine.arg_utils import OmniEngineArgs
from vllm_omni.engine.stage_init_utils import build_engine_args_dict
@@ -296,7 +297,7 @@ def test_remote_tokenizer_subfolder_download_does_not_report_failure(tmp_path, m
baseline_config = Mock()
warning = mocker.patch("vllm_omni.engine.arg_utils.logger.warning")
monkeypatch.setattr("huggingface_hub.HfApi.snapshot_download", lambda *args, **kwargs: str(tmp_path))
patch_hf_snapshot_download(monkeypatch, lambda *args, **kwargs: str(tmp_path), hf_home=tmp_path)
monkeypatch.setattr(OmniEngineArgs, "_patch_empty_hf_config", lambda *args, **kwargs: None)
monkeypatch.setattr(EngineArgs, "create_model_config", lambda _self: baseline_config)
monkeypatch.setattr(
@@ -14,6 +14,7 @@ import pytest
from omegaconf import OmegaConf
from vllm.v1.engine.utils import EngineZmqAddresses
from tests.helpers.mock import patch_hf_snapshot_download
from vllm_omni.config.omni_config import OmniStageRuntimeConfig
from vllm_omni.diffusion.data import AttentionConfig
from vllm_omni.engine import omni_engine_base as async_omni_engine_module
@@ -1566,22 +1567,20 @@ def test_build_engine_args_pulls_stage_subdirs_missing_from_cached_snapshot(monk
never materialized. Stage init must fetch exactly those subfolders instead
of joining onto a path that exists nowhere.
"""
from huggingface_hub import HfApi
from vllm_omni.engine.stage_init_utils import build_engine_args_dict
snapshot = tmp_path / "snapshots" / "deadbeef"
_make_snapshot(snapshot, ["tokenizer"])
calls = []
def fake_snapshot_download(self, repo_id, **kwargs):
def fake_snapshot_download(repo_id, **kwargs):
calls.append(kwargs)
if kwargs.get("local_files_only"):
return str(snapshot)
_make_snapshot(snapshot, ["language_model"])
return str(snapshot)
monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download)
patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path)
stage_cfg = types.SimpleNamespace(
stage_id=0,
@@ -1601,18 +1600,16 @@ def test_build_engine_args_pulls_stage_subdirs_missing_from_cached_snapshot(monk
def test_build_engine_args_skips_hub_call_when_cached_snapshot_is_complete(monkeypatch, tmp_path):
"""A warm cache stays offline-friendly: no outgoing Hub request."""
from huggingface_hub import HfApi
from vllm_omni.engine.stage_init_utils import build_engine_args_dict
snapshot = tmp_path / "snapshots" / "deadbeef"
_make_snapshot(snapshot, ["language_model", "tokenizer"])
def fake_snapshot_download(self, repo_id, **kwargs):
def fake_snapshot_download(repo_id, **kwargs):
assert kwargs.get("local_files_only"), "warm cache must not reach the Hub"
return str(snapshot)
monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download)
patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path)
stage_cfg = types.SimpleNamespace(
stage_id=0,
@@ -1633,8 +1630,6 @@ def test_build_engine_args_redownloads_a_partial_subdir(monkeypatch, tmp_path):
that local path leaves vLLM's loader without any fallback for the missing
weights.
"""
from huggingface_hub import HfApi
from vllm_omni.engine.stage_init_utils import build_engine_args_dict
snapshot = tmp_path / "snapshots" / "deadbeef"
@@ -1644,14 +1639,14 @@ def test_build_engine_args_redownloads_a_partial_subdir(monkeypatch, tmp_path):
_make_snapshot(snapshot, ["tokenizer"])
calls = []
def fake_snapshot_download(self, repo_id, **kwargs):
def fake_snapshot_download(repo_id, **kwargs):
calls.append(kwargs)
if kwargs.get("local_files_only"):
return str(snapshot)
_make_snapshot(snapshot, ["language_model"])
return str(snapshot)
monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download)
patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path)
stage_cfg = types.SimpleNamespace(
stage_id=0,
@@ -1672,8 +1667,6 @@ def test_build_engine_args_redownloads_when_index_lists_missing_shards(monkeypat
The index downloads early; accepting it as proof of weights converts the
Hub ID into a local path vLLM cannot fetch the remaining shards for.
"""
from huggingface_hub import HfApi
from vllm_omni.engine.stage_init_utils import build_engine_args_dict
snapshot = tmp_path / "snapshots" / "deadbeef"
@@ -1693,13 +1686,13 @@ def test_build_engine_args_redownloads_when_index_lists_missing_shards(monkeypat
_make_snapshot(snapshot, ["tokenizer"])
calls = []
def fake_snapshot_download(self, repo_id, **kwargs):
def fake_snapshot_download(repo_id, **kwargs):
calls.append(kwargs)
if not kwargs.get("local_files_only"):
(partial / "model-00002-of-00002.safetensors").write_text("x")
return str(snapshot)
monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download)
patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path)
stage_cfg = types.SimpleNamespace(
stage_id=0,
@@ -1716,8 +1709,6 @@ def test_build_engine_args_redownloads_when_index_lists_missing_shards(monkeypat
def test_build_engine_args_redownloads_shards_without_their_index(monkeypatch, tmp_path):
"""Shard-named weights always ship an index; one without it is partial."""
from huggingface_hub import HfApi
from vllm_omni.engine.stage_init_utils import build_engine_args_dict
snapshot = tmp_path / "snapshots" / "deadbeef"
@@ -1727,7 +1718,7 @@ def test_build_engine_args_redownloads_shards_without_their_index(monkeypatch, t
_make_snapshot(snapshot, ["tokenizer"])
calls = []
def fake_snapshot_download(self, repo_id, **kwargs):
def fake_snapshot_download(repo_id, **kwargs):
calls.append(kwargs)
if not kwargs.get("local_files_only"):
shards = {f"w{i}.weight": f"model-0000{i}-of-00004.safetensors" for i in range(1, 5)}
@@ -1736,7 +1727,7 @@ def test_build_engine_args_redownloads_shards_without_their_index(monkeypatch, t
(partial / shard).write_text("x")
return str(snapshot)
monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download)
patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path)
stage_cfg = types.SimpleNamespace(
stage_id=0,
@@ -1752,8 +1743,6 @@ def test_build_engine_args_redownloads_shards_without_their_index(monkeypatch, t
def test_build_engine_args_redownloads_a_tokenizer_folder_without_vocabulary(monkeypatch, tmp_path):
"""Templates and configs download first; alone they are not a tokenizer."""
from huggingface_hub import HfApi
from vllm_omni.engine.stage_init_utils import build_engine_args_dict
snapshot = tmp_path / "snapshots" / "deadbeef"
@@ -1763,13 +1752,13 @@ def test_build_engine_args_redownloads_a_tokenizer_folder_without_vocabulary(mon
(partial / "chat_template.jinja").write_text("{{ messages }}")
calls = []
def fake_snapshot_download(self, repo_id, **kwargs):
def fake_snapshot_download(repo_id, **kwargs):
calls.append(kwargs)
if not kwargs.get("local_files_only"):
_make_snapshot(snapshot, ["tokenizer"])
return str(snapshot)
monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download)
patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path)
stage_cfg = types.SimpleNamespace(
stage_id=0,
@@ -1787,19 +1776,17 @@ def test_build_engine_args_redownloads_a_tokenizer_folder_without_vocabulary(mon
def test_build_engine_args_forwards_revision_and_download_dir(monkeypatch, tmp_path):
"""revision/download_dir must shape snapshot selection (they cannot be
corrected downstream once the repo ID is a local path)."""
from huggingface_hub import HfApi
from vllm_omni.engine.stage_init_utils import build_engine_args_dict
snapshot = tmp_path / "snapshots" / "pinned"
_make_snapshot(snapshot, ["language_model", "tokenizer"])
seen = []
def fake_snapshot_download(self, repo_id, **kwargs):
def fake_snapshot_download(repo_id, **kwargs):
seen.append(kwargs)
return str(snapshot)
monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download)
patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path)
stage_cfg = types.SimpleNamespace(
stage_id=0,
@@ -1824,8 +1811,6 @@ def test_build_engine_args_forwards_revision_and_download_dir(monkeypatch, tmp_p
def test_build_engine_args_resolves_tokenizer_revision_separately(monkeypatch, tmp_path):
"""A tokenizer pinned to a different revision resolves against its own snapshot."""
from huggingface_hub import HfApi
from vllm_omni.engine.stage_init_utils import build_engine_args_dict
model_snapshot = tmp_path / "snapshots" / "model-rev"
@@ -1833,12 +1818,12 @@ def test_build_engine_args_resolves_tokenizer_revision_separately(monkeypatch, t
tokenizer_snapshot = tmp_path / "snapshots" / "tok-rev"
_make_snapshot(tokenizer_snapshot, ["tokenizer"])
def fake_snapshot_download(self, repo_id, **kwargs):
def fake_snapshot_download(repo_id, **kwargs):
if kwargs.get("revision") == "tok-rev":
return str(tokenizer_snapshot)
return str(model_snapshot)
monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download)
patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path)
stage_cfg = types.SimpleNamespace(
stage_id=0,
@@ -1861,8 +1846,6 @@ def test_build_engine_args_resolves_tokenizer_revision_separately(monkeypatch, t
def test_build_engine_args_resolves_root_tokenizer_revision_separately(monkeypatch, tmp_path):
"""An empty tokenizer_subdir targets the snapshot root and still honors
its own revision."""
from huggingface_hub import HfApi
from vllm_omni.engine.stage_init_utils import build_engine_args_dict
model_snapshot = tmp_path / "snapshots" / "model-rev"
@@ -1875,12 +1858,12 @@ def test_build_engine_args_resolves_root_tokenizer_revision_separately(monkeypat
tokenizer_snapshot.mkdir(parents=True, exist_ok=True)
(tokenizer_snapshot / _SUBDIR_ARTIFACT["tokenizer"]).write_text("x")
def fake_snapshot_download(self, repo_id, **kwargs):
def fake_snapshot_download(repo_id, **kwargs):
if kwargs.get("revision") == "tok-rev":
return str(tokenizer_snapshot)
return str(model_snapshot)
monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download)
patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path)
stage_cfg = types.SimpleNamespace(
stage_id=0,
@@ -1902,19 +1885,17 @@ def test_build_engine_args_resolves_root_tokenizer_revision_separately(monkeypat
def test_build_engine_args_fails_closed_when_subdir_cannot_be_downloaded(monkeypatch, tmp_path):
"""An undownloadable subfolder raises here instead of reaching HuggingFace."""
from huggingface_hub import HfApi
from vllm_omni.engine.stage_init_utils import build_engine_args_dict
snapshot = tmp_path / "snapshots" / "deadbeef"
_make_snapshot(snapshot, ["tokenizer"])
def fake_snapshot_download(self, repo_id, **kwargs):
def fake_snapshot_download(repo_id, **kwargs):
if kwargs.get("local_files_only"):
return str(snapshot)
raise OSError("offline")
monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download)
patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path)
stage_cfg = types.SimpleNamespace(
stage_id=0,
@@ -1927,16 +1908,14 @@ def test_build_engine_args_fails_closed_when_subdir_cannot_be_downloaded(monkeyp
build_engine_args_dict(stage_cfg, "MiniMaxAI/MiniMax-Music3")
def test_build_engine_args_fails_closed_on_cold_cache_instead_of_joining_repo_id(monkeypatch):
def test_build_engine_args_fails_closed_on_cold_cache_instead_of_joining_repo_id(monkeypatch, tmp_path):
"""With nothing cached, the stage must not join a subdir onto the repo id."""
from huggingface_hub import HfApi
from vllm_omni.engine.stage_init_utils import build_engine_args_dict
def fake_snapshot_download(self, repo_id, **kwargs):
def fake_snapshot_download(repo_id, **kwargs):
raise OSError("offline")
monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download)
patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path)
stage_cfg = types.SimpleNamespace(
stage_id=0,
+92 -2
View File
@@ -55,6 +55,7 @@ def _build(
stages: int = 1,
running_counter: FakeRunningCounter | None = None,
runtime_config: DuplexSessionRuntimeConfig | None = None,
plugin=None,
) -> tuple[DuplexOrchestrator, list[FakeStageClient], asyncio.Queue, asyncio.Queue]:
clients = [FakeStageClient(stage_type="llm", final_output=index == stages - 1) for index in range(stages)]
pools = _build_stage_pools(
@@ -70,7 +71,7 @@ def _build(
rpc_async_queue=rpc_q,
stage_pools=pools,
running_counter=running_counter,
plugin=MiniCPMO45DuplexPlugin(_encode_audio),
plugin=plugin or MiniCPMO45DuplexPlugin(_encode_audio),
duplex_session_config=runtime_config or DuplexSessionRuntimeConfig(reaper_interval_s=0.01),
model_config=None,
)
@@ -197,7 +198,8 @@ async def test_open_preregisters_the_stage0_request_and_close_releases_it() -> N
assert request_state.session_owned is True
assert request_state.session_id == SESSION_ID
assert request_state.fence == DuplexFence(SESSION_ID)
assert request_state.streaming.enabled is True
# Set on submit from submission.resumable, not again at preregister.
assert request_state.streaming.enabled is False
# Preregistration reserves the id; nothing is running until an append submits.
assert counter.value == 0
assert clients[0].add_request_calls == []
@@ -211,6 +213,18 @@ async def test_open_preregisters_the_stage0_request_and_close_releases_it() -> N
assert orchestrator.session_manager.active_count() == 0
@pytest.mark.asyncio
async def test_aura_ephemeral_preregister_disables_streaming() -> None:
from vllm_omni.model_executor.models.aura_omni.duplex.plugin import AuraDuplexPlugin
orchestrator, _, rpc_q, _ = _build(plugin=AuraDuplexPlugin(_encode_audio))
result = await _open(orchestrator, rpc_q)
assert result.ok is True
request_state = next(iter(orchestrator.request_states.values()))
assert request_state.streaming.enabled is False
await orchestrator.session_manager.shutdown()
@pytest.mark.asyncio
async def test_open_failure_rolls_back_session_and_reserved_request() -> None:
orchestrator, _, rpc_q, _ = _build()
@@ -275,6 +289,7 @@ async def test_append_submits_the_resumable_stage0_request_and_counts_it_running
assert submitted.resumable is True
assert submitted.sampling_params.max_tokens == 20
request_state = orchestrator.request_states[request_id]
assert request_state.streaming.enabled is True
assert request_state.stage_fences[0] == DuplexFence(SESSION_ID)
assert 0 in request_state.stage_submit_ts
assert counter.value == 1
@@ -390,6 +405,31 @@ async def test_forward_failure_closes_the_owning_session() -> None:
assert types[-1] == "session.expired"
@pytest.mark.asyncio
async def test_aura_forward_failure_keeps_the_session() -> None:
from vllm_omni.model_executor.models.aura_omni.duplex.plugin import AuraDuplexPlugin
orchestrator, clients, rpc_q, output_q = _build(stages=2, plugin=AuraDuplexPlugin(_encode_audio))
await _open(orchestrator, rpc_q)
request_id = next(iter(orchestrator.request_states))
await _submit(orchestrator, _append_audio())
request_state = orchestrator.request_states[request_id]
session = orchestrator.session_manager.get(SESSION_ID)
assert session is not None
absorbed = await orchestrator._handle_forward_failure(request_id, 1, request_state, ValueError("stale talker"))
await _settle(orchestrator)
assert absorbed is True
assert request_id not in orchestrator.request_states
assert SESSION_ID in orchestrator.session_manager.runners
assert session.state != DuplexSessionState.CLOSED
types = [message.event.type for message in [output_q.get_nowait() for _ in range(output_q.qsize())]]
assert "error" in types
assert "session.expired" not in types
await orchestrator.session_manager.shutdown()
@pytest.mark.asyncio
async def test_request_cleanup_failure_is_deferred_and_retried_by_the_reaper(monkeypatch) -> None:
orchestrator, clients, rpc_q, _ = _build()
@@ -560,3 +600,53 @@ async def test_turn_plugin_processes_multimodal_prompt_before_stage_submission(m
assert not orchestrator.request_states[submitted.request_id].streaming.enabled
finally:
await _close(orchestrator, rpc_q)
@pytest.mark.asyncio
async def test_sentence_partial_does_not_legacy_forward_the_full_stage_output() -> None:
from vllm_omni.engine.duplex.plugin import PartialStageForward
orchestrator, *_ = _build(stages=4)
forwarded: list[object] = []
async def record_forward(req_id, stage_id, output, req_state, **kwargs):
del req_id, stage_id, req_state, kwargs
forwarded.append(output)
async def no_intercept(*args, **kwargs):
del args, kwargs
return False
orchestrator._forward_to_next_stage = record_forward # type: ignore[method-assign]
orchestrator._intercept_stage_output = no_intercept # type: ignore[method-assign]
orchestrator.async_chunk = True
orchestrator._stage_receives_async_chunks = lambda stage_id: False # type: ignore[method-assign]
full = SimpleNamespace(request_id="r", finished=True, text="FULL")
chunk = SimpleNamespace(request_id="r", finished=True, text="CHUNK")
req_state = DuplexOrchestratorRequestState(
request_id="r",
final_stage_id=3,
session_owned=True,
sampling_params_list=[SimpleNamespace() for _ in range(4)],
)
orchestrator.plugin.plan_partial_stage_output = lambda *args, **kwargs: PartialStageForward( # type: ignore[method-assign]
output=chunk, is_final_update=True
)
orchestrator.plugin.partial_stage_followup = lambda *args, **kwargs: None # type: ignore[method-assign]
await orchestrator._route_output(1, 0, full, req_state, None)
assert forwarded == [chunk]
assert req_state.skip_legacy_stage_forward is False
forwarded.clear()
orchestrator.plugin.plan_partial_stage_output = lambda *args, **kwargs: None # type: ignore[method-assign]
await orchestrator._route_output(1, 0, full, req_state, None)
assert forwarded == [full]
def test_default_plugin_declares_no_draining_stages() -> None:
plugin = MiniCPMO45DuplexPlugin(_encode_audio)
assert plugin.draining_stage_ids(stage_count=4) == frozenset()
@@ -13,6 +13,7 @@ import asyncio
import queue
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock
import janus
import pytest
@@ -22,6 +23,7 @@ from vllm.v1.serial_utils import MsgpackEncoder
from vllm_omni.engine.messages import (
AddCompanionRequestMessage,
CollectiveRPCRequestMessage,
EngineQueueMessage,
ErrorMessage,
ShutdownRequestMessage,
@@ -1143,3 +1145,64 @@ async def test_diffusion_client_error_output_propagates_non_400_status(
finally:
orchestrator_fixture.request_sync_q.put_nowait(ShutdownRequestMessage())
orchestrator_fixture.thread.join(timeout=5)
@pytest.mark.asyncio
async def test_control_rpc_failure_is_reported_to_the_caller_not_fatal() -> None:
"""A control RPC that fails on one replica must come back as that
replica's error result instead of raising out of _request_handler.
"""
class _FailingClient(FakeStageClient):
async def collective_rpc_async(self, *args, **kwargs):
raise TimeoutError("outputs did not drain")
ok = FakeStageClient(stage_type="diffusion")
ok.collective_rpc_async = AsyncMock(return_value=None)
orchestrator, queues = _build_bare_orchestrator(
_build_stage_pools([[ok], [_FailingClient(stage_type="diffusion")]])
)
try:
await orchestrator._handle_collective_rpc(
CollectiveRPCRequestMessage(
rpc_id="rpc-1",
method="pause_scheduler",
args=(),
kwargs={"mode": "keep"},
stage_ids=[0, 1],
)
)
result = queues[2].async_q.get_nowait()
assert result.rpc_id == "rpc-1"
assert result.stage_ids == [0, 1]
assert result.results[0] is None
assert result.results[1]["supported"] is False
assert "outputs did not drain" in result.results[1]["error"]
finally:
for q in queues:
q.close()
@pytest.mark.asyncio
async def test_rpc_failure_capture_is_limited_to_pause_and_resume() -> None:
"""Only pause/resume failures are reported as a result; every other RPC
keeps propagating the way it does today.
"""
orchestrator, queues = _build_bare_orchestrator(_build_stage_pools([[FakeStageClient(stage_type="diffusion")]]))
orchestrator.stage_pools[0].collective_rpc = AsyncMock(side_effect=TimeoutError("worker died"))
def _msg(rpc_id: str, method: str) -> CollectiveRPCRequestMessage:
return CollectiveRPCRequestMessage(rpc_id=rpc_id, method=method, args=(), kwargs={}, stage_ids=[0])
try:
await orchestrator._handle_collective_rpc(_msg("rpc-resume", "resume_scheduler"))
result = queues[2].async_q.get_nowait()
assert result.results[0]["supported"] is False
assert "worker died" in result.results[0]["error"]
with pytest.raises(TimeoutError):
await orchestrator._handle_collective_rpc(_msg("rpc-sleep", "sleep"))
finally:
for q in queues:
q.close()
+18 -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
"""Event-driven orchestration loop (``VLLM_OMNI_EVENT_DRIVEN_ORCH=1``) tests.
Parity suite: re-runs the legacy orchestration scenarios from
@@ -19,7 +19,10 @@ import pytest
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
from vllm_omni.engine.messages import ShutdownRequestMessage
from vllm_omni.engine.orchestrator import _event_driven_orch_enabled
from vllm_omni.engine.orchestrator import (
_event_driven_orch_default_for_pipeline,
_event_driven_orch_enabled,
)
from . import test_orchestrator as legacy
from . import test_orchestrator_error_handling as legacy_errors
@@ -125,6 +128,19 @@ def test_flag_parsing(monkeypatch) -> None:
assert _event_driven_orch_enabled() is False
def test_only_qwen3_tts_has_a_pipeline_default() -> None:
assert _event_driven_orch_default_for_pipeline("qwen3_tts") is True
for model_type in (None, "qwen3_omni_moe", "minicpmo_4_5", "moss_tts_delay"):
assert _event_driven_orch_default_for_pipeline(model_type) is False
def test_explicit_event_driven_value_overrides_pipeline_default(monkeypatch) -> None:
monkeypatch.delenv("VLLM_OMNI_EVENT_DRIVEN_ORCH", raising=False)
assert _event_driven_orch_enabled(default=True) is True
monkeypatch.setenv("VLLM_OMNI_EVENT_DRIVEN_ORCH", "0")
assert _event_driven_orch_enabled(default=True) is False
def test_default_is_legacy_loop(monkeypatch) -> None:
"""Without the env flag, the harness runs the legacy poll loop."""
monkeypatch.delenv("VLLM_OMNI_EVENT_DRIVEN_ORCH", raising=False)
+32
View File
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import json
import time
@@ -45,6 +48,8 @@ def test_note_loop_and_flush_json(tmp_path, monkeypatch):
monitor.register_replica(0, 0)
monitor.note_loop(idle=False)
monitor.set_dispatch_queue_size(9)
monitor.set_dispatch_queue_size(7)
monitor.note_loop(idle=True)
now[0] = 1.5
@@ -54,10 +59,37 @@ def test_note_loop_and_flush_json(tmp_path, monkeypatch):
assert payload["configured_window_s"] == monitor_mod._WINDOW_S
assert payload["windows"]["loop_idle"] == [1]
assert payload["windows"]["loop_active"] == [1]
assert payload["windows"]["dispatch_queue_size"] == [7]
assert payload["windows"]["dispatch_queue_high_water"] == [9]
assert payload["replicas"]["stage=0,replica=0"]["outputs_queue_size"] == [5]
assert payload["replicas"]["stage=0,replica=0"]["inflight"] == [10]
@pytest.mark.parametrize("drain", [False, True])
def test_dispatch_backlog_carries_across_windows(tmp_path, monkeypatch, drain):
out_path = tmp_path / "orch_monitor.json"
monkeypatch.setenv("VLLM_OMNI_ORCH_MONITOR_PATH", str(out_path))
now = [0.0]
monkeypatch.setattr(time, "monotonic", lambda: now[0])
monitor = OrchestratorMonitor(replica_sampler=lambda: {})
monitor.set_dispatch_queue_size(3)
monitor.set_dispatch_queue_size(1)
now[0] = monitor_mod._WINDOW_S
monitor.note_loop(idle=False)
if drain:
monitor.set_dispatch_queue_size(0)
now[0] += monitor_mod._WINDOW_S
monitor.note_loop(idle=False)
now[0] += monitor_mod._WINDOW_S
monitor.flush()
windows = json.loads(out_path.read_text())["windows"]
remaining = 0 if drain else 1
assert windows["dispatch_queue_size"] == [1, remaining, remaining]
assert windows["dispatch_queue_high_water"] == [3, 1, remaining]
def test_register_replica_backfills_prior_windows(tmp_path, monkeypatch):
out_path = tmp_path / "orch_monitor.json"
monkeypatch.setenv("VLLM_OMNI_ORCH_MONITOR_PATH", str(out_path))
@@ -14,6 +14,7 @@ import pytest
from vllm.outputs import CompletionOutput, RequestOutput
from vllm.sampling_params import SamplingParams
from vllm_omni.engine.duplex_orchestrator import DuplexOrchestrator
from vllm_omni.engine.orchestrator import (
Orchestrator,
OrchestratorRequestState,
@@ -249,6 +250,41 @@ async def test_async_prewarm_skips_outgoing_only_stage(payload_sender_info) -> N
)
@pytest.mark.asyncio
async def test_async_prewarm_skips_stage_with_custom_process_input_func() -> None:
"""AURA Stage1 has asr2aura: must not be zero-prewarmed under async_chunk.
The custom-process-input gate lives on DuplexOrchestrator (AURA path), not
the turn-based Orchestrator base.
"""
orchestrator = object.__new__(DuplexOrchestrator)
stage0 = FakePrewarmPool("sender")
stage1 = FakePrewarmPool("receiver") # would receive chunks if role alone decided
stage1.stage_client = SimpleNamespace(custom_process_input_func=lambda *a, **k: None)
stage2 = FakePrewarmPool("receiver")
orchestrator.stage_pools = [stage0, stage1, stage2]
orchestrator._emit_tx_edge = lambda **_kwargs: None
orchestrator._on_stage_submitted = MagicMock()
req_state = OrchestratorRequestState(
request_id="req-prewarm-custom",
prompt={"prompt_token_ids": [1, 2]},
sampling_params_list=[SamplingParams(max_tokens=1) for _ in range(3)],
final_stage_id=2,
)
prewarmed = await orchestrator._prewarm_async_chunk_stages(
"req-prewarm-custom",
SimpleNamespace(prompt_token_ids=[1, 2], resumable=True),
req_state,
)
assert prewarmed is True
assert stage1.submitted == []
assert len(stage2.submitted) == 1
assert 1 not in req_state.stage_submit_ts
assert 2 in req_state.stage_submit_ts
@pytest.mark.asyncio
async def test_async_route_forwards_to_outgoing_only_stage() -> None:
orchestrator = object.__new__(Orchestrator)
+30 -2
View File
@@ -17,7 +17,12 @@ from vllm.v1.engine.utils import EngineZmqAddresses
from vllm_omni.config.config_factory import StageConfigFactory
from vllm_omni.config.omni_config import VllmOmniARStageConfig, VllmOmniDiffusionStageConfig
from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig, StageExecutionType, StagePipelineConfig
from vllm_omni.config.stage_config import (
DuplexSessionRuntimeConfig,
PipelineConfig,
StageExecutionType,
StagePipelineConfig,
)
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
from vllm_omni.engine.stage_engine_core_client import StageEngineCoreClientBase
from vllm_omni.engine.stage_engine_startup import (
@@ -393,6 +398,7 @@ class TestSingleStageModeDetection:
stage_cfgs: list[Any] | None = None,
resolved_config_path: str = "/fake/path",
patch_deploy_config: bool = True,
resolved_pipeline: PipelineConfig | None = None,
**kwargs: Any,
) -> AsyncOmniEngine:
mock_stage_configs = stage_cfgs or [_make_stage_cfg(0)]
@@ -407,10 +413,19 @@ class TestSingleStageModeDetection:
"vllm_omni.engine.omni_engine_base.load_deploy_config",
return_value=SimpleNamespace(duplex_session=DuplexSessionRuntimeConfig()),
)
def resolve_stage_configs(engine, *args, **kwargs):
if resolved_pipeline is not None:
engine._config_resolution = SimpleNamespace(
pipeline_config=resolved_pipeline, config_path=resolved_config_path
)
return resolved_config_path, mock_stage_configs
mocker.patch.object(
AsyncOmniEngine,
"_resolve_stage_configs",
return_value=(resolved_config_path, mock_stage_configs),
autospec=True,
side_effect=resolve_stage_configs,
)
mocker.patch.object(AsyncOmniEngine, "_bootstrap_orchestrator")
mock_thread_cls = mocker.patch("threading.Thread")
@@ -435,6 +450,19 @@ class TestSingleStageModeDetection:
)
assert engine.single_stage_mode is True
@pytest.mark.parametrize("model_type, expected", [("qwen3_tts", True), ("qwen3_omni_moe", False)])
def test_event_driven_default_uses_resolved_pipeline(self, mocker, model_type, expected):
pipeline = PipelineConfig(
model_type=model_type,
stages=(StagePipelineConfig(stage_id=0, model_stage="a", final_output=True),),
)
engine = self._make_engine_no_thread(mocker, resolved_pipeline=pipeline)
StageConfigFactory.get_pipeline_config.assert_called_once_with(
model="fake-model", trust_remote_code=False, deploy_config_path=None
)
assert engine._event_driven_orch_default is expected
def test_stage_id_kwarg_promotes_to_single_stage_mode(self, mocker: MockerFixture):
engine = self._make_engine_no_thread(
mocker,
Loaded 100 of 230 files, more files were not shown because too many files have changed in this diff. Show more