Compare commits

...
Author SHA1 Message Date
Zhou Taichang 5a7cda40c3 Merge branch 'main' into fix/omni-weight-snapshot-prefetch 2026-06-12 14:54:29 +08:00
Ting FU 1be1a71156 [Model] Add more resolution support for HunyuanImage3.0 (#4004)
Signed-off-by: Semmer <semmer@live.cn>
2026-06-12 14:44:00 +08:00
Chendi.Xue 3fe3aa359b [XPU][COSMOS3] removing cuda hardcode and make VLLM_VIDEO_SYNC_TIMEOUT a tunable config (#4360)
Signed-off-by: Chendi Xue <chendi.xue@intel.com>
2026-06-12 04:55:07 +00:00
541f06c60d [Bugfix] Fix Qwen3-TTS gradio streaming TTFP by using audio/pcm (#4346)
Signed-off-by: YU Xinyuan <yuxinyuan02@corp.netease.com>
Co-authored-by: YU Xinyuan <yuxinyuan02@corp.netease.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
2026-06-12 03:19:48 +00:00
yangyonggit a3834e32cf feat(moss-tts): add CUDA Graph support for codec decoder (#4157)
Signed-off-by: leo.yang <leo.yang.engineer@gmail.com>
2026-06-11 22:17:31 +00:00
152343d5fb Feat: non_streaming_mode for Qwen3-TTS Base Models During Online Inference (#4198)
Signed-off-by: nagisa-kun <1434936049@qq.com>
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
Co-authored-by: Yueqian Lin <linyueqian@outlook.com>
Co-authored-by: Sy03 <1370724210@qq.com>
2026-06-11 22:15:05 +00:00
MaciejBalaNVandHongsheng Liu 03cc888485 [refactor] Refactor guardrail error handling - add 400 error code (#4297)
Signed-off-by: Maciej Bala <mbala@nvidia.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-06-11 21:09:24 +00:00
e27fd46ab9 [Perf] Optimize Higgs Audio v3 serving (#4204)
Signed-off-by: Sy03 <1370724210@qq.com>
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
Co-authored-by: Yueqian Lin <linyueqian@outlook.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-06-11 13:29:31 -07:00
dengyunyang eb964dcec5 [HunyuanImage][Perf] opt prepare_attention_mask for e2e latency 6% reduction (#4333)
Signed-off-by: dengyunyang <584797741@qq.com>
2026-06-11 19:06:22 +08:00
Chendi.XueandHongsheng Liu 5414f78e5f [WAN2.2-S2V] Add server API for image + audio (#3394)
Signed-off-by: Chendi Xue <chendi.xue@intel.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-06-11 18:50:03 +08:00
WeiQing ChenandHongsheng Liu f3d40b5402 [Bugfix] Add Cosmos3-Nano baselines and fix USP gather (#4301)
Signed-off-by: david6666666 <530634352@qq.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-06-11 18:36:26 +08:00
Hongsheng Liu 3529b8280d [Doc] Clean up PR template (#4336) 2026-06-11 15:49:22 +08:00
tanhaoan333 e26f5cb918 [NPU] Support VoxCPM2 model (#4310)
Signed-off-by: tanhaoan333 <tanhaoan@huawei.com>
2026-06-11 14:12:06 +08:00
wangyu 0d3c259a76 [CI] skip unrelated L2 ready tests via diff-aware upload, aligned L3 tweak and fix issue 4334 (#4313)
Signed-off-by: wangyu <410167048@qq.com>
Signed-off-by: wangyu <53896905+yenuo26@users.noreply.github.com>
2026-06-11 12:58:40 +08:00
Kushanam 74b5ae5ebe [Bugfix] Fix SP denoise indentation bug on BAGEL (#4328)
Signed-off-by: Kushan A. <kahmadian@nvidia.com>
2026-06-11 03:32:22 +00:00
ChenWenjingandSy03 1fe267d6c9 [Bugfix][VoxCPM2] Fix VoxCPM2 concurrent speech quality (#4319)
Signed-off-by: CHEN <116010019@link.cuhk.edu.cn>
Co-authored-by: Sy03 <1370724210@qq.com>
2026-06-11 11:12:07 +08:00
dengyunyangandHongsheng Liu 14879d6619 [Bugfix][HunyuanImage] fix accuracy in stream mode (#4265)
Signed-off-by: dengyunyang <584797741@qq.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-06-11 10:39:00 +08:00
zhumingjue138 18e083e512 [Docs] add doc for failure mode (#3926)
Signed-off-by: zhumingjue <zhumingjue@huawei.com>
2026-06-11 10:36:34 +08:00
a1a53e5f94 [Skills] Add quantization Claude skill (#4252)
Signed-off-by: david6666666 <530634352@qq.com>
Co-authored-by: baonudesifeizhai <85092850+baonudesifeizhai@users.noreply.github.com>
Co-authored-by: lishunyang12 <125541396+lishunyang12@users.noreply.github.com>
2026-06-11 06:41:31 +08:00
wangyu 1b2ab23586 [Test] Automatically clean up audio files generated from requests, and realtime invalid-param coverage (#4294)
Signed-off-by: wangyu <410167048@qq.com>
Signed-off-by: [Your Name] <your.email@example.com>
2026-06-11 05:57:57 +08:00
57ea047acf Step audio R1 reasoning parser (#2846)
Signed-off-by: Mike_Qiu <qiudayu.qdy@antgroup.com>
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
Signed-off-by: Mike Qiu <qdy220091330@gmail.com>
Co-authored-by: Mike_Qiu <qiudayu.qdy@antgroup.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Yueqian Lin <linyueqian@outlook.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-06-10 21:03:19 +00:00
Gao Han 49c102589b Merge branch 'main' into fix/omni-weight-snapshot-prefetch 2026-06-04 22:59:51 +08:00
tzhouamandCursor 930c317b2b fix: harden omni weight snapshot downloads
Serialize full-repo Hugging Face snapshot materialization with a repo-wide
node lock and retry incomplete snapshots so CI does not race on shared caches.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 07:54:12 +00:00
101 changed files with 7750 additions and 1080 deletions

No files matched your search

+1 -1
View File
@@ -31,7 +31,7 @@ steps:
key: upload-ready-pipeline
if: __UPLOAD_READY_IF__
commands:
- buildkite-agent pipeline upload .buildkite/test-ready.yml
- python3 .buildkite/scripts/upload_pipeline.py --upload .buildkite/test-ready.yml
agents:
queue: "cpu_queue_premerge"
+1 -1
View File
@@ -14,7 +14,7 @@ steps:
depends_on: image-build
key: upload-ready-pipeline
commands:
- buildkite-agent pipeline upload .buildkite/test-ready.yml
- python3 .buildkite/scripts/upload_pipeline.py --upload .buildkite/test-ready.yml
agents:
queue: "cpu_queue_premerge"
-41
View File
@@ -44,9 +44,6 @@ steps:
depends_on: upload-merge-pipeline
steps:
- label: "Diffusion · LoRA Test"
source_file_dependencies:
- tests/diffusion/lora/
- vllm_omni/diffusion/lora/
timeout_in_minutes: 30
commands:
- pytest -sv tests/diffusion/lora/ -m 'advanced_model and cuda' --run-level "advanced_model"
@@ -64,9 +61,6 @@ steps:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "Distributed Test"
source_file_dependencies:
- tests/distributed/
- vllm_omni/distributed/
timeout_in_minutes: 60
commands:
- |
@@ -111,9 +105,6 @@ steps:
- group: ":card_index_dividers: Entrypoints Test"
depends_on: upload-merge-pipeline
source_file_dependencies:
- tests/entrypoints/
- vllm_omni/entrypoints/
steps:
- label: "Entrypoint Test with H100"
timeout_in_minutes: 60
@@ -174,13 +165,6 @@ steps:
depends_on: upload-merge-pipeline
steps:
- label: "TTS · Qwen3-TTS CustomVoice Test"
source_file_dependencies:
- tests/e2e/online_serving/test_qwen3_tts_customvoice.py
- tests/e2e/offline_inference/test_qwen3_tts_customvoice.py
- vllm_omni/model_executor/models/qwen3_tts/
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
- vllm_omni/deploy/qwen3_tts.yaml
- vllm_omni/model_executor/stage_configs/qwen3_tts_uniproc.yaml
commands:
- |
timeout 20m bash -c "
@@ -202,12 +186,6 @@ steps:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "TTS · Qwen3-TTS Base Test"
source_file_dependencies:
- tests/e2e/online_serving/test_qwen3_tts_base.py
- tests/e2e/offline_inference/test_qwen3_tts_base.py
- vllm_omni/model_executor/models/qwen3_tts/
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
- vllm_omni/deploy/qwen3_tts.yaml
commands:
- |
timeout 20m bash -c "
@@ -433,11 +411,6 @@ steps:
type: DirectoryOrCreate
- label: "TTS · VoxCPM2 Test"
source_file_dependencies:
- tests/e2e/offline_inference/test_voxcpm2_tts.py
- tests/e2e/online_serving/test_voxcpm2_tts.py
- vllm_omni/model_executor/models/voxcpm2/
- vllm_omni/deploy/voxcpm2.yaml
timeout_in_minutes: 30
commands:
- |
@@ -461,12 +434,6 @@ steps:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "Omni · Higgs-Audio V2 Test"
source_file_dependencies:
- tests/e2e/online_serving/test_higgs_audio_v2.py
- tests/e2e/offline_inference/test_higgs_audio_v2.py
- vllm_omni/model_executor/models/higgs_audio_v2/
- vllm_omni/model_executor/stage_input_processors/higgs_audio_v2.py
- vllm_omni/deploy/higgs_audio_v2.yaml
timeout_in_minutes: 30
commands:
- |
@@ -838,10 +805,6 @@ steps:
type: DirectoryOrCreate
- label: "Diffusion · Z Image Test"
source_file_dependencies:
- tests/e2e/offline_inference/test_z_image.py
- tests/e2e/online_serving/test_z_image.py
- vllm_omni/diffusion/models/z_image/
timeout_in_minutes: 30
commands:
- pytest -s -v tests/e2e/offline_inference/test_z_image.py tests/e2e/online_serving/test_z_image.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
@@ -859,10 +822,6 @@ steps:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "Diffusion · FLUX.2 Klein Test"
source_file_dependencies:
- tests/e2e/offline_inference/test_flux2_klein.py
- tests/e2e/online_serving/test_flux2_klein.py
- vllm_omni/diffusion/models/flux2_klein/
timeout_in_minutes: 30
commands:
- pytest -s -v tests/e2e/offline_inference/test_flux2_klein.py tests/e2e/online_serving/test_flux2_klein.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
+51
View File
@@ -568,6 +568,55 @@ steps:
path: /mnt/hf-cache
type: DirectoryOrCreate
- label: ":full_moon: TTS · Higgs-Audio-V3 · Perf Test"
key: nightly-tts-performance-higgs-audio-v3
depends_on: upload-nightly-pipeline
if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test" || build.pull_request.labels includes "tts-test"
timeout_in_minutes: 180
commands:
- export BENCHMARK_DIR=tests/dfx/perf/results
- export VLLM_USE_DEEP_GEMM="0"
- export VLLM_MOE_USE_DEEP_GEMM="0"
- |
set +e
pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_higgs_audio_v3.json
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
exit $$EXIT
agents:
queue: "mithril-h100-pool"
plugins:
- kubernetes:
podSpec:
containers:
- image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
resources:
limits:
nvidia.com/gpu: 1
volumeMounts:
- name: devshm
mountPath: /dev/shm
- name: hf-cache
mountPath: /root/.cache/huggingface
env:
- name: HF_HOME
value: /root/.cache/huggingface
- name: HF_TOKEN
valueFrom:
secretKeyRef:
name: hf-token-secret
key: token
nodeSelector:
node.kubernetes.io/instance-type: gpu-h100-sxm
volumes:
- name: devshm
emptyDir:
medium: Memory
- name: hf-cache
hostPath:
path: /mnt/hf-cache
type: DirectoryOrCreate
# Diffusion X2I suite: x2i / x2a / x2t and related non-video paths; x2v is only in "Diffusion X2V Model Test" below.
- group: ":card_index_dividers: Diffusion X2I(&A&T) Model Test"
key: nightly-diffusion-x2iat-group
@@ -1463,6 +1512,7 @@ steps:
- nightly-omni-performance-vllm-text
- nightly-omni-performance-multi-replicas
- nightly-tts-performance
- nightly-tts-performance-higgs-audio-v3
- nightly-diffusion-x2iat-performance-qwen-image
- nightly-diffusion-x2iat-performance-qwen-image-edit
- nightly-diffusion-x2iat-performance-qwen-image-edit-2511
@@ -1476,6 +1526,7 @@ steps:
- export DEFAULT_INPUT_DIR=tests/dfx/perf/results
- export DEFAULT_OUTPUT_DIR=tests/dfx/perf/results
- buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-tts-performance
- buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-tts-performance-higgs-audio-v3
- buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-omni-performance-no-async-chunk
- buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-omni-performance-async-chunk
- buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-omni-performance-vllm-text
+60 -36
View File
@@ -215,6 +215,43 @@ steps:
path: /mnt/hf-cache
type: DirectoryOrCreate
- label: "Entrypoints Test"
depends_on: upload-ready-pipeline
commands:
- |
timeout 30m bash -c "
export VLLM_WORKER_MULTIPROC_METHOD=spawn
pytest -sv tests/entrypoints/ -m 'core_model and cuda' --run-level 'core_model'
"
agents:
queue: "mithril-h100-pool"
plugins:
- kubernetes:
podSpec:
containers:
- image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
resources:
limits:
nvidia.com/gpu: 2
volumeMounts:
- name: devshm
mountPath: /dev/shm
- name: hf-cache
mountPath: /root/.cache/huggingface
env:
- name: HF_HOME
value: /root/.cache/huggingface
nodeSelector:
node.kubernetes.io/instance-type: gpu-h100-sxm
volumes:
- name: devshm
emptyDir:
medium: Memory
- name: hf-cache
hostPath:
path: /mnt/hf-cache
type: DirectoryOrCreate
- group: ":card_index_dividers: E2E Test"
depends_on: upload-ready-pipeline
steps:
@@ -239,6 +276,11 @@ steps:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "Omni · Qwen3-Omni Test"
source_file_dependencies:
- tests/e2e/online_serving/test_qwen3_omni.py
- vllm_omni/model_executor/models/qwen3_omni/
- vllm_omni/model_executor/stage_input_processors/qwen3_omni.py
- vllm_omni/deploy/qwen3_omni_moe.yaml
commands:
- |
timeout 20m bash -c "
@@ -273,42 +315,6 @@ steps:
path: /mnt/hf-cache
type: DirectoryOrCreate
- label: "Omni · Qwen3-Omni Realtime WebSocket"
commands:
- |
timeout 30m bash -c "
export VLLM_WORKER_MULTIPROC_METHOD=spawn
pytest -s -v tests/entrypoints/openai_api/test_qwen3_omni_realtime_websocket.py::TestQwen3OmniRealtimeWebSocket::test_streaming_audio_input_pcm_output_async_chunk_smoke -m 'core_model' --run-level 'core_model'
"
agents:
queue: "mithril-h100-pool"
plugins:
- kubernetes:
podSpec:
containers:
- image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
resources:
limits:
nvidia.com/gpu: 2
volumeMounts:
- name: devshm
mountPath: /dev/shm
- name: hf-cache
mountPath: /root/.cache/huggingface
env:
- name: HF_HOME
value: /root/.cache/huggingface
nodeSelector:
node.kubernetes.io/instance-type: gpu-h100-sxm
volumes:
- name: devshm
emptyDir:
medium: Memory
- name: hf-cache
hostPath:
path: /mnt/hf-cache
type: DirectoryOrCreate
- label: "TTS · VoxCPM2 Test"
timeout_in_minutes: 30
commands:
@@ -400,6 +406,9 @@ steps:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "Diffusion · Qwen Image Edit Test"
source_file_dependencies:
- tests/e2e/online_serving/test_qwen_image_edit.py
- vllm_omni/diffusion/models/qwen_image/
commands:
- |
timeout 20m bash -c "
@@ -435,6 +444,12 @@ steps:
type: DirectoryOrCreate
- label: "Diffusion · Bagel Test"
source_file_dependencies:
- tests/e2e/online_serving/test_bagel.py
- vllm_omni/model_executor/models/bagel/
- vllm_omni/diffusion/models/bagel/
- vllm_omni/model_executor/stage_input_processors/bagel.py
- vllm_omni/deploy/bagel.yaml
commands:
- |
timeout 40m bash -c "
@@ -476,6 +491,9 @@ steps:
type: DirectoryOrCreate
- label: "Diffusion · Wan22 Test"
source_file_dependencies:
- tests/e2e/online_serving/test_wan22_t2v.py
- vllm_omni/diffusion/models/wan2_2/
commands:
- |
timeout 40m bash -c "
@@ -518,6 +536,9 @@ steps:
- label: "Diffusion · Qwen Image Test"
source_file_dependencies:
- tests/e2e/online_serving/test_qwen_image.py
- vllm_omni/diffusion/models/qwen_image/
commands:
- |
timeout 40m bash -c "
@@ -559,6 +580,9 @@ steps:
type: DirectoryOrCreate
- label: "Diffusion · Qwen Image Layered Test"
source_file_dependencies:
- tests/e2e/online_serving/test_qwen_image_layered.py
- vllm_omni/diffusion/models/qwen_image/
commands:
- |
timeout 40m bash -c "
+103
View File
@@ -0,0 +1,103 @@
---
name: quantization
description: Work on vLLM-Omni quantization for diffusion, autoregressive, omni, or multi-stage models. Use when choosing or adding methods such as fp8, int8, gguf, mxfp8, mxfp4, mxfp4_dualscale, ModelOpt, AutoRound, INC, msModelSlim, awq, or gptq; debugging quantized loading; or validating memory, speed, and output quality.
---
# vLLM-Omni Quantization
Use this skill for quantization work in `vllm-omni`. Start from the local
code and docs, then pick the smallest validated path for the model, method,
hardware, and quality target.
## First Checks
Before changing code or recommending a command, identify:
- Model and task: AR text, omni/TTS, text-to-image, text-to-video, or multi-stage diffusion.
- Hardware backend: CUDA generation, Ascend NPU, Intel XPU, or another platform.
- Quantization mode: online from BF16/FP16 weights, pre-quantized checkpoint, runtime KV/FA quantization, or per-component routing.
- Scope: transformer/DiT, thinker language model, text encoder, VAE, audio/vision encoder, talker, or a specific stage.
- Validation target: memory reduction, latency/throughput, output quality, or all three.
Do not assume a method is supported just because the CLI accepts the string.
Check `docs/user_guide/quantization/` and the closest implementation first.
## Quick Decision
| Task | Start With |
|------|------------|
| Choose a method or command | `references/methods.md` and `references/modality-compat.md` |
| Use `build_quant_config()` or per-component routing | `references/methods.md` |
| Work on diffusion quantization | `references/diffusion.md` |
| Add quantization to a new model | `references/adding-models.md` |
| Convert or load ModelOpt FP8 checkpoints | `references/modelopt-fp8.md` |
| Debug ModelOpt, GGUF, AutoRound, MXFP, or serialized Int8 loading | `references/diffusion.md` and method docs under `docs/user_guide/quantization/` |
## Current Runtime Shape
The unified entrypoint is:
```python
from vllm_omni.quantization import build_quant_config
```
It supports method strings, flat method dictionaries, per-component
dictionaries, existing `QuantizationConfig` objects, and `None`. The factory
delegates generic methods to upstream `vllm` and keeps vLLM-Omni overrides for
diffusion or omni-specific routing:
- `gguf`
- `int8`
- `mxfp8`
- `mxfp4`
- `mxfp4_dualscale`
- `inc`, `auto-round`, `auto_round`
- ModelOpt auto-detected configs: `modelopt`, `modelopt_fp4`, `modelopt_mixed`
Use `ComponentQuantizationConfig` when only one stage or component should be
quantized. Pre-quantized ModelOpt-style checkpoints should not spill into
vision/audio encoders that have no corresponding scale tensors.
## Ownership Boundary
- Upstream `vllm` owns generic quantization configs, kernels, loader semantics,
hardware capability rules, and generic AR quantization methods.
- `vllm-omni` owns unified config routing, diffusion-specific wrappers,
component scoping, GGUF or ModelOpt checkpoint adapters, model-specific
prefix mapping, docs, examples, and validation.
If a new method needs missing generic kernels or loader behavior, fix upstream
`vllm` first. In `vllm-omni`, add thin integration and model wiring.
## Working Loop
1. Read the local method doc under `docs/user_guide/quantization/`.
2. Inspect the active factory and config class in `vllm_omni/quantization/`.
3. Inspect the target model pipeline and transformer for stable prefixes and
whether every relevant vLLM linear layer receives `quant_config`.
4. For pre-quantized checkpoints, inspect `transformer/config.json` and the
checkpoint tensor names before running full generation.
5. Validate with the same prompt, seed, size, scheduler, steps, dtype, eager or
non-eager mode, and parallelism as the BF16 baseline.
6. Report quality, latency, and memory separately. Do not call a method
supported until quality is checked.
## Common Mistakes
| Symptom | Likely Cause | Fix |
|---------|--------------|-----|
| `--quantization` has no visible effect | Wrong scope or unsupported model path | Check component routing and method docs |
| Some layers stay BF16 unexpectedly | `quant_config` was not threaded into all vLLM linear layers | Audit transformer constructors and prefixes |
| Quality collapses but loading succeeds | Too many sensitive layers were quantized | Add model-specific `ignored_layers` and compare to BF16 |
| ModelOpt checkpoint loads but output is corrupted | Prefixes, packed-module mapping, scale routing, or BF16 fallback is wrong | Use `references/modelopt-fp8.md` |
| GGUF shape or tensor mismatch | Missing architecture-specific adapter | Add explicit adapter mapping; avoid generic fallback |
| Online and offline MXFP4 disagree | Wrong `mxfp4` vs `mxfp4_dualscale` mode | Check `docs/user_guide/quantization/mxfp4.md` |
| Quantized path is slower than BF16 | Kernel path, compile mode, dtype, or shape mismatch | Compare eager-to-eager and non-eager-to-non-eager |
## References
- General methods and config routing: [references/methods.md](references/methods.md)
- Modality and model scope: [references/modality-compat.md](references/modality-compat.md)
- Diffusion implementation patterns: [references/diffusion.md](references/diffusion.md)
- Adding model support: [references/adding-models.md](references/adding-models.md)
- ModelOpt FP8 conversion and loading: [references/modelopt-fp8.md](references/modelopt-fp8.md)
@@ -0,0 +1,70 @@
# Adding Quantization Support to a Model
Use this checklist when extending quantization to a new architecture or stage.
## Classify Ownership
| Case | Owner |
|------|-------|
| New generic AR method, kernel, or loader semantics | upstream `vllm` first |
| Existing upstream method needs diffusion wiring | `vllm-omni` model and config integration |
| GGUF or ModelOpt tensor mapping for a diffusion architecture | `vllm-omni` adapter |
| NPU MXFP or msModelSlim diffusion path | `vllm-omni` platform/method wrapper and docs |
| Multi-stage omni checkpoint quantizes only the thinker LM | `vllm-omni` component scoping |
Avoid building a private quantization stack in model code.
## Model Wiring Checklist
1. Find the closest supported model implementation.
2. Confirm the model uses vLLM linear layers that accept `quant_config`.
3. Add `quant_config` to the transformer constructor.
4. Pass stable `prefix` values through every linear layer.
5. Handle fused modules and packed mappings explicitly.
6. Ensure `load_weights()` and any `WeightsMapper` remap `ignored_layers` or
`modules_to_not_convert`.
7. Keep tokenizer, scheduler, VAE, text encoder, audio/vision encoders, and
talker BF16 unless method-specific docs validate them.
8. Add or update a method doc under `docs/user_guide/quantization/`.
## Checkpoint Adapter Checklist
For pre-quantized checkpoints:
- Inspect `transformer/config.json` first.
- Check `quant_method`, `method`, `producer.name`, `quant_algo`, serialized
flags, `ignored_layers`, and any method-specific fields.
- Verify tensor name mapping before generation.
- Verify packed modules such as `to_qkv`, `add_kv_proj`, and `w13`.
- Confirm scale tensor names and shapes.
- Allow dequantization back to BF16 for intentionally skipped target layers.
## Sensitive Layer Discovery
Start conservative. Quantize the backbone first and keep output-side or routing
layers BF16 until quality is proven.
Frequently sensitive areas:
- attention output projections such as `to_out`
- FFN down projections such as `w2`
- final projections such as `proj_out`, `final_layer`, and output heads
- modulation, timestep, position, image, or text embedders
- refiner blocks
- MoE routers, experts selected by routing, and layernorm-adjacent modules
- VAE, text/vision towers, audio encoders, and codec/talker stages
Do not reuse one model family's ignore list blindly.
## Tests and Evidence
Minimum evidence for a PR:
- loader/config test or smoke command
- fixed-seed BF16 vs quantized output comparison
- quality metrics and saved outputs for precision-changing work
- memory and latency comparison under identical execution mode
- failure behavior for unsupported models or missing checkpoint fields
When the PR adds a method to a support table, include the exact model,
hardware, command, quality threshold, and artifact path used for validation.
@@ -0,0 +1,113 @@
# Diffusion Quantization Reference
Use this file for DiT quantization, config routing, checkpoint adapters, and
quality or loader debugging.
## Supported Method Families
The current local diffusion quantization families are:
- CUDA/GPU online or checkpoint: `fp8`, `int8`, `gguf`, ModelOpt
- Ascend NPU: `int8`, `mxfp8`, `mxfp4`, `mxfp4_dualscale`, msModelSlim paths
- Intel XPU: AutoRound/INC and online MXFP8 paths where documented
Always cross-check the method doc before claiming model support. The CLI
accepting a method string is not enough.
## Implementation Starting Points
| Area | Files |
|------|-------|
| Unified factory | `vllm_omni/quantization/factory.py` |
| Per-component routing | `vllm_omni/quantization/component_config.py` |
| GGUF diffusion method | `vllm_omni/quantization/gguf_config.py` |
| Int8 diffusion method | `vllm_omni/quantization/int8_config.py` |
| MXFP8 diffusion method | `vllm_omni/quantization/mxfp8_config.py` |
| MXFP4 and dual-scale method | `vllm_omni/quantization/mxfp4_config.py` |
| AutoRound/INC omni extension | `vllm_omni/quantization/inc_config.py` |
| ModelOpt checkpoint adapters | `vllm_omni/diffusion/model_loader/checkpoint_adapters/modelopt.py` |
| FP8 scaled-mm runtime patches | `vllm_omni/patch.py` |
| Similarity tool | `vllm_omni/quantization/tools/compare_diffusion_trajectory_similarity.py` |
## Threading `quant_config`
For a diffusion transformer:
1. Accept `quant_config: QuantizationConfig | None` in the transformer.
2. Pass `quant_config` and stable `prefix` values to every vLLM linear layer.
3. Preserve packed-module mappings for fused QKV, FFN, or model-specific fused
projections.
4. Call `apply_vllm_mapper()` on configs that need HF-to-vLLM name remapping.
5. For component configs, keep prefixes specific enough to avoid quantizing VAE
or encoders by accident.
Common skipped areas are `proj_out`, output projections, modulation layers,
embedders, refiner blocks, MoE routers, and layernorm-adjacent paths.
## Online vs Offline
Online quantization:
- starts from BF16/FP16 checkpoint weights
- computes or stores quantized weights at load time
- typically uses `--quantization <method>` or `quantization="<method>"`
Offline or pre-quantized checkpoints:
- include quantized weights and scales on disk
- usually carry `quantization_config` in `transformer/config.json`
- should auto-detect when the config is complete
- may require model-specific adapter logic for tensor names and packed modules
Do not mix online flags with incompatible offline checkpoint configs. If the
active config and disk config differ, `resolve_quant_config_from_disk()` should
raise or rebuild rather than silently loading corrupted weights.
## GGUF Rules
- Require `quantization_config.gguf_model`.
- Use the normal base model for tokenizer, scheduler, text encoder, and VAE.
- Add explicit architecture adapters for tensor-name mapping.
- Use `named_parameters()` and `named_buffers()`, not `state_dict()`, to
discover loadable names.
- Guard fused QKV/KV rewrites so tensors are not rewritten twice.
- Flatten N-D activations around the GGUF matmul and restore shape.
Unsupported architectures should fail clearly instead of falling back to a fake
generic mapper.
## MXFP Rules
- `mxfp8` is W8A8 microscaling, currently centered on NPU and documented XPU
behavior.
- `mxfp4` is online single-scale W4A4 on NPU.
- `mxfp4_dualscale` is online/offline dual-scale W4A4 with BF16 fallback; the
offline path is the production path when calibrated `mul_scale` exists.
- Offline dual-scale checkpoints must inject per-transformer `ignored_layers`
into config because cascade transformers can have different BF16 fallback
sets.
Do not load an offline dual-scale checkpoint with the online single-scale path.
## Common Failures
| Symptom | Likely Cause | Fix |
|---------|--------------|-----|
| `Unknown quantization method` | Factory override or upstream registration is missing | Add the right override or upstream method |
| Missing scale tensors | Wrong scope, wrong adapter, or BF16 layer not allowed to dequantize | Check config scope and adapter scale routing |
| Output rank collapses from 3-D to 2-D | FP8 scaled-mm kernel ignored output shape | Check the patch in `vllm_omni/patch.py` |
| Quality degrades silently | Sensitive layers quantized | Add `ignored_layers`; compare to BF16 |
| Only one cascade transformer works | Disk config differs per transformer but active config is reused | Rebuild from each transformer's config |
| Quantized path slower than BF16 | Kernel, dtype, compile, or benchmark mode mismatch | Compare eager-to-eager and non-eager-to-non-eager |
## Verification
For a new or changed diffusion quantization path, run:
1. Config and loader smoke test.
2. One fixed-seed generation against BF16.
3. Numeric comparison with the similarity tool or equivalent metrics.
4. Serving test if the path is user-facing online.
5. Memory and latency check under the same execution mode as the baseline.
Keep output artifacts when precision changes are part of the PR.
@@ -0,0 +1,82 @@
# General Quantization Methods
Use this file for method selection and unified `vllm-omni` config syntax. For
diffusion-specific wiring, see `diffusion.md`. For ModelOpt checkpoint export
and loading details, see `modelopt-fp8.md`.
## Unified Entry Point
`vllm_omni.quantization.build_quant_config()` accepts:
- `None` or `"none"` to disable quantization
- a method string such as `"fp8"`, `"int8"`, `"mxfp8"`, `"awq"`, or `"auto-round"`
- a flat dict with `"method"` or `"quant_method"`
- a per-component dict such as `{"transformer": {"method": "fp8"}, "vae": None}`
- an already-built `QuantizationConfig`
Per-component dicts are routed by `ComponentQuantizationConfig` using
longest-prefix matching. Prefix stability matters: if a model remaps HF names
to vLLM names, apply the mapper to `ignored_layers` or component prefixes.
## Local Overrides
The local factory delegates most methods to upstream `vllm`, but has vLLM-Omni
overrides for methods that need diffusion, NPU, XPU, or component behavior:
| Method | Local Config | Main Use |
|--------|--------------|----------|
| `gguf` | `DiffusionGGUFConfig` | Pre-quantized diffusion transformer weights |
| `int8` | `DiffusionInt8Config` | Online or serialized W8A8 diffusion transformers |
| `mxfp8` | `DiffusionMXFP8Config` | W8A8 MXFP8 diffusion on NPU and online XPU path |
| `mxfp4` | `DiffusionMXFP4Config` | Online W4A4 MXFP4 diffusion on NPU |
| `mxfp4_dualscale` | `DiffusionMXFP4DualScaleMixedConfig` | Online/offline W4A4 dual-scale plus BF16 fallback on NPU |
| `inc`, `auto-round`, `auto_round` | `OmniINCConfig` | AutoRound/INC checkpoint loading, including MXFP8 checkpoint metadata |
| ModelOpt config dicts | upstream ModelOpt configs through auto-detect | FP8, NVFP4, or mixed ModelOpt checkpoints |
Check `SUPPORTED_QUANTIZATION_METHODS` in `vllm_omni/quantization/factory.py`
before adding a new method name.
## AR and Generic Quantization
For AR-backed models, upstream `vllm` methods usually apply:
- `awq` and `gptq`: 4-bit or 8-bit weight quantization with calibration,
commonly used for text and AR stages.
- `fp8`: FP8 weight path or KV-cache FP8 depending on flags and hardware.
- `inc` or `auto-round`: pre-quantized checkpoints with Intel Neural
Compressor / AutoRound metadata.
Use these for the AR language model stage only when the checkpoint and model
guide support it. Omni and TTS models often keep audio, vision, talker, and
codec stages in BF16.
## Diffusion and Offline Methods
For diffusion transformers, prefer the method-specific docs:
- `docs/user_guide/quantization/fp8.md`
- `docs/user_guide/quantization/int8.md`
- `docs/user_guide/quantization/gguf.md`
- `docs/user_guide/quantization/modelopt.md`
- `docs/user_guide/quantization/mxfp8.md`
- `docs/user_guide/quantization/mxfp4.md`
- `docs/user_guide/quantization/autoround.md`
- `docs/user_guide/quantization/msmodelslim.md`
Pre-quantized checkpoints should normally auto-detect from config. Runtime
flags such as `--force-cutlass-fp8`, `--linear-backend cutlass`, and
`--moe-backend cutlass` select kernels; they do not quantize a BF16 checkpoint.
## Validation
Use fixed-seed A/B comparisons and record:
- model path and quantized checkpoint path
- method config and `ignored_layers`
- prompt, size, frame count, steps, scheduler, dtype, and seed
- eager/non-eager mode, compile flags, and parallelism
- output metrics such as PSNR, MAE, cosine similarity, SSIM, LPIPS, plus visual artifacts
- latency and peak memory, reported separately from quality
The helper `vllm_omni.quantization.tools.compare_diffusion_trajectory_similarity`
is the default lightweight image/video comparison tool for diffusion paths.
@@ -0,0 +1,47 @@
# Quantization Compatibility by Modality
Use this as a routing guide. The source of truth is the local method docs under
`docs/user_guide/quantization/` and the current model implementation.
## Summary
- AR and omni language-model quantization mostly follows upstream `vllm`, with
vLLM-Omni scoping for multi-stage models.
- Diffusion quantization is method-specific and model-specific. It usually
targets the diffusion transformer only.
- Pre-quantized checkpoints should quantize only the component that has
quantized weights and scale tensors.
## Model Type Matrix
| Model Type | Typical Quantization Scope | Notes |
|------------|----------------------------|-------|
| AR text models | Language model | Upstream `vllm` methods usually apply |
| Omni/TTS models | Thinker or AR language-model stage | Audio, vision, talker, and codec stages usually stay BF16 |
| DiT image models | Diffusion transformer | Check FP8, Int8, GGUF, ModelOpt, AutoRound docs |
| DiT video models | Diffusion transformer | HunyuanVideo FP8 and Wan2.2 MXFP paths are documented; validate other video paths |
| Multi-stage diffusion | Selected stage only | Use per-component routing and per-stage validation |
| Audio diffusion | Usually unsupported | Prefer dtype, offload, cache, or parallelism unless docs say otherwise |
## Method Hints
| Goal | Candidate |
|------|-----------|
| AR memory reduction | AWQ, GPTQ, AutoRound, ModelOpt where checkpoint supports it |
| CUDA diffusion W8A8 | FP8 or Int8 |
| Pre-quantized CUDA diffusion checkpoint | ModelOpt or GGUF |
| Ascend NPU diffusion | Int8, MXFP8, MXFP4, MXFP4 DualScale, msModelSlim |
| Intel XPU checkpoint | AutoRound/INC paths |
| Conservative image quality | selective FP8/Int8 with `ignored_layers` |
| Unsupported diffusion model | cache acceleration, CPU/layerwise offload, dtype, TP/SP/CFG parallelism |
## Validation Rule
Compatibility means more than successful loading. A method is compatible only
after it has:
- correct component scope
- complete checkpoint/config metadata if offline
- fixed-seed quality comparison against BF16
- acceptable latency and memory behavior
- docs or examples that state model, method, hardware, and caveats
@@ -0,0 +1,122 @@
# ModelOpt FP8 Conversion and Loading
Use this when exporting or loading ModelOpt FP8 checkpoints for diffusion
transformers. This reference captures the practical conversion lessons from the
2026-04-21 ModelOpt FP8 paste and aligns them with the current
`vllm-omni` ModelOpt adapter.
## Core Rule
Treat ModelOpt FP8 as selective checkpoint quantization, not one-click
whole-model FP8. Quantize the main transformer or DiT backbone, keep sensitive
or peripheral modules BF16, serialize a complete ModelOpt checkpoint, and let
vLLM-Omni auto-detect it from config.
Do not pass `--quantization fp8` to force a ModelOpt checkpoint path. The
checkpoint's `quantization_config` should select `modelopt`.
## Conversion Strategy
Prefer:
- backbone attention Q/K/V and FFN expansion projections
- conservative per-model ignore lists
- MHA quantizers disabled until basic linear quantization is stable
- full module-name prefixes in ignore/routing logic
- BF16 fallback for output-side and routing-sensitive layers
Avoid quantizing by default:
- VAE
- text, vision, or audio encoders
- embedders and modulation layers
- final heads and output projections
- refiner modules
- MoE routers and layernorm-sensitive blocks
## Model-Specific Starting Points
| Model Family | Quantize First | Keep BF16 First |
|--------------|----------------|-----------------|
| Z-Image | attention q/k/v, FFN w1/w3 | `to_out`, `w2`, `noise_refiner`, `context_refiner`, embedders, modulation, final/output |
| Qwen-Image | transformer backbone linear layers | `proj_out`, `img_in`, `txt_in`, norms, modulation, position/time/embed layers |
| FLUX2-dev / FLUX2-klein | backbone linear layers | `proj_out`, context/x embedders, `norm_out`, stream modulation, time embeddings |
| HunyuanImage-3.0 | `model.model` backbone | VAE, vision tower, token/time/image embedders, final image head, LM head, routers, layernorm-sensitive blocks |
These are starting points, not support claims. Validate with BF16 outputs.
## Required Checkpoint Metadata
`transformer/config.json` must include a complete `quantization_config`.
At minimum it should identify ModelOpt and the algorithm:
```json
{
"quantization_config": {
"producer": {"name": "modelopt"},
"quant_method": "modelopt",
"quant_algo": "FP8"
}
}
```
Current vLLM-Omni auto-detects:
- `FP8` and `FP8_PER_CHANNEL_PER_TOKEN` -> `modelopt`
- `NVFP4` -> `modelopt_fp4`
- `MIXED_PRECISION` -> `modelopt_mixed`
Packed modules and scales must map correctly:
- `to_qkv` from `to_q`, `to_k`, `to_v`
- `add_kv_proj` from `add_q_proj`, `add_k_proj`, `add_v_proj`
- `w13` from `w1`, `w3`
- `.input_scale`, `.weight_scale`, `.weight_scale_2`, `.weight_scale_inv`
For selective FP8, target layers that remain BF16 must be allowed to
dequantize from FP8 weights using the matching `weight_scale`, or the adapter
must skip the quantized scale tensors for that BF16 target.
## Runtime Signals
Healthy loading should show ModelOpt auto-detection and a ModelOpt linear
kernel path. Useful signals include:
- auto-detected quantization from config
- selected ModelOpt FP8 linear method
- CUTLASS or FlashInfer FP8 scaled-mm backend when enabled and supported
- non-eager path compiling the transformer when expected
If online serving fails while offline inference works, inspect warmup and
serving initialization before changing checkpoint conversion.
## Validation Order
1. Check config completeness.
2. Run an export checker such as `examples/quantization/check_modelopt_fp8_export.py`.
3. Confirm actual FP8 tensors and expected disk-size reduction.
4. Run one offline generation with fixed prompt, seed, size, and steps.
5. Run serving with `vllm serve --omni` and verify `/v1/images/generations`.
6. Compare against BF16 using visual review plus metrics such as LPIPS, PSNR,
MAE, cosine similarity, and SSIM.
7. Compare performance only under matched modes:
- BF16 eager vs FP8 eager
- BF16 non-eager vs FP8 non-eager
FP8 is not automatically faster. Check whether execution reaches FP8 kernels,
whether compile mode is comparable, and whether the workload shape fits the
kernel path.
## Triage
| Symptom | First Check |
|---------|-------------|
| Startup failure | `config.json`, `model_index.json`, `quantization_config`, missing scales |
| Loads but output is corrupted | Over-quantized sensitive layers or wrong full-name prefixes |
| Offline works but online fails | Dummy warmup, serving init, compile path |
| FP8 is not faster | Kernel selection, dtype, compile mode, workload shape |
| BF16 fallback fails | Missing `weight_scale` for dequantization or wrong packed-module mapping |
The success criterion is stable, correctly scoped checkpoint loading with
quality preserved enough for the target use case, not maximizing the number of
FP8 layers.
+2
View File
@@ -21,6 +21,8 @@ include:
- `diffusion-perf-opt`: guides diffusion model performance optimization,
including profiling traces, parallel strategies, stage timing analysis, and
benchmark-driven tuning
- `quantization`: guides quantization method selection, model integration,
checkpoint loading, and quality/performance validation for vLLM-Omni
- `add-omni-model`: covers addition of new omni-modality model support
- `add-tts-model`: covers integration of new TTS models and related serving
workflows
+6 -15
View File
@@ -1,24 +1,15 @@
<!-- markdownlint-disable -->
**Before submitting:** run the precheck-pr skill with code agent for a self-check against project conventions — catches dead code, missing benchmarks, and title format issues.
PLEASE FILL IN THE PR DESCRIPTION HERE ENSURING ALL CHECKLIST ITEMS (AT THE BOTTOM) HAVE BEEN CONSIDERED.
PLEASE FILL IN THE PR DESCRIPTION HERE.
## Purpose
## Test Plan
**vLLM Version:**
**vLLM-Omni Commit:**
## Test Result
---
<details>
<summary> Essential Elements of an Effective PR Description Checklist </summary>
- [ ] The purpose of the PR, such as "Fix some issue (link existing issues this PR will resolve)".
- [ ] The test plan. Please provide the test scripts & test commands. Please state the reasons if your codes don't require additional test scripts. For test file guidelines, please check the [test style doc](https://docs.vllm.ai/projects/vllm-omni/en/latest/contributing/ci/tests_style/)
- [ ] The test results. Please paste the results comparison before and after, or the e2e results.
- [ ] (Optional) The necessary documentation update, such as updating `supported_models.md` and `examples` for a new model. **Please run `mkdocs serve` to sync the documentation editions to `./docs`.**
- [ ] (Optional) Release notes update. If your change is user-facing, please update the release notes draft.
</details>
**BEFORE SUBMITTING,** please read <https://github.com/vllm-project/vllm-omni/blob/main/CONTRIBUTING.md>
**BEFORE SUBMITTING:** read [CONTRIBUTING.md](https://github.com/vllm-project/vllm-omni/blob/main/CONTRIBUTING.md) and run the [precheck-pr skill](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/precheck-pr/SKILL.md) with the code agent for a self-check against project conventions.
(anything written below this line will be removed by GitHub Actions)
+2
View File
@@ -1,3 +1,5 @@
# Contributing to vLLM-Omni
You may find information about contributing to vLLM-Omni on [Contributing](https://vllm-omni.readthedocs.io/en/latest/contributing/)
Before submitting a PR, run the [precheck-pr skill](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/precheck-pr/SKILL.md) with the code agent for a self-check against project conventions.
+9
View File
@@ -33,6 +33,15 @@ models:
voice_clone:
task_type: Base
bosonai/higgs-audio-v3-tts-4b:
supported_tasks: [default_voice, voice_clone]
backend: openai-audio-speech
endpoint: /v1/audio/speech
trust_remote_code: true
task_extra_body:
default_voice: {}
voice_clone: {}
openbmb/VoxCPM2:
supported_tasks: [voice_clone, default_voice]
trust_remote_code: true
+2
View File
@@ -87,6 +87,8 @@ nav:
- AutoRound: user_guide/quantization/autoround.md
- msModelSlim: user_guide/quantization/msmodelslim.md
- ComfyUI: features/comfyui.md
- Fault Tolerance:
- Failure Modes: user_guide/fault_injection_reliability_matrix.md
- Developer Guide:
- General:
- contributing/README.md
+2 -6
View File
@@ -118,17 +118,13 @@ Only specific types of PRs will be reviewed. The PR title is prefixed appropriat
### Pre-Check Before Submitting
Before submitting a PR, run the precheck-pr skill with code agent for a self-review against project conventions:
```
/precheck-pr
```
Before submitting a PR, run the [precheck-pr skill](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/precheck-pr/SKILL.md) with the code agent for a self-review against project conventions:
The skill offers two modes:
- **Quick (~3 min):** catches showstoppers — PR title format, missing benchmark claims, rebase status
- **Full (~10 min):** thorough maintainer-grade review — dead code scan, copy-paste detection, import hygiene
The precheck covers five PR types: Bug Fix, Performance, New Model, Diffusion Model, and General. Each type has a tailored checklist that validates evidence quality (repro steps, A/B benchmarks, registry entries, etc.). See the skill in `.claude/skills/precheck-pr/` for the full checklist.
The precheck covers five PR types: Bug Fix, Performance, New Model, Diffusion Model, and General. Each type has a tailored checklist that validates evidence quality (repro steps, A/B benchmarks, registry entries, etc.). See the [precheck-pr skill](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/precheck-pr/SKILL.md) for the full checklist.
### Local Test
Please run the L1 and L2 test cases locally first and attach the results before contacting us to add the "ready" label. Please refer to the [test instructions](./ci/test_guide.md) for running the test cases.
+100 -1
View File
@@ -268,6 +268,101 @@ Before entering specific testing levels, the project establishes two common spec
1. ***PR Checklist ([Tests Style](../ci/tests_style.md))***: This template defines the self-check items that must be completed before submitting a code review (Pull Request). It ensures that each code change meets basic requirements such as code style, dependency updates, and documentation synchronization before entering the automated testing pipeline, serving as the first manual line of defense for quality assurance.
2. ***CI Failure Explanation ([CI Failures](../ci/failures.md))***: This document archives and explains common failure patterns in the Continuous Integration (CI) pipeline, error log interpretation, and preliminary troubleshooting steps. It helps developers and testers quickly diagnose the causes of automated test failures, improving problem-solving efficiency.
<<<<<<< ready
## Notes
### Diff-aware Buildkite uploads (`source_file_dependencies`)
L2 (`.buildkite/test-ready.yml`) and L3 (`.buildkite/test-merge.yml`) pipelines can **skip unrelated GPU jobs at upload time** based on the PR diff. This is implemented by `.buildkite/scripts/upload_pipeline.py`, which filters steps before calling `buildkite-agent pipeline upload`.
#### What `source_file_dependencies` is
- A **uploader-only** YAML key on a Buildkite step or group. **Buildkite does not understand it**; `upload_pipeline.py` always **removes** it from the YAML that is uploaded.
- A list of path **prefixes** (directories or individual files). If **any** changed file in the diff equals a prefix or starts with `prefix/`, the step is kept; otherwise the step (or entire group) is **omitted** from the uploaded pipeline.
#### When filtering runs
| Build context | Changed files used for matching |
| --- | --- |
| Pull request | `git diff --name-only origin/<base>...<BUILDKITE_COMMIT>` |
| `main` branch push | `git diff --name-only <commit>^..<commit>` |
| Other (e.g. local dry-run, non-PR branch) | Diff cannot be resolved → **no filtering**; all steps are uploaded and `source_file_dependencies` is still stripped |
Docs-only PRs are handled earlier in bootstrap (`pipeline.yml`) via skip-ci logic; `source_file_dependencies` applies only to the **uploaded** L2/L3 test pipelines.
#### Where it is configured
| Level | Pipeline file | Upload entry |
| --- | --- | --- |
| L2 | `.buildkite/test-ready.yml` | `upload_pipeline.py --upload .buildkite/test-ready.yml` (from `pipeline.yml`) |
| L3 | `.buildkite/test-merge.yml` | `upload_pipeline.py --upload .buildkite/test-merge.yml` (from `pipeline.yml`) |
Steps **without** `source_file_dependencies` are always uploaded (subject to the usual label conditions: `ready` for L2, `merge-test` for L3).
#### Current skip policy (L2 / L3)
To balance CI cost and coverage:
- **Always run** (no `source_file_dependencies`): baseline groups such as Simple Test, Diffusion unit tests, Engine/Model Executor, Distributed, Custom Pipeline, Entrypoints (L2), LoRA / Entrypoints (L3), and **all E2E jobs on L4 docker queues** (`gpu_1_queue`, `gpu_4_queue`).
- **Diff-gated** (`source_file_dependencies` set): **H100 E2E jobs only** (`queue: mithril-h100-pool` under the E2E Test group)—typically the model’s pytest file(s) plus related `vllm_omni/` model, deploy, and stage paths.
Adding a new expensive H100 E2E step: list the test file(s) and the smallest set of source prefixes that should trigger it. Prefer **per-step** deps on leaf jobs rather than a broad group-level list unless every child shares the same paths.
#### YAML example
```yaml
- label: "Diffusion · Qwen Image Test"
source_file_dependencies:
- tests/e2e/online_serving/test_qwen_image.py
- vllm_omni/diffusion/models/qwen_image/
commands:
- pytest -s -v tests/e2e/online_serving/test_qwen_image.py -m 'core_model' ...
agents:
queue: "mithril-h100-pool"
```
A **group** may also define `source_file_dependencies`; nested steps inherit filtering as a unit—the whole group is dropped if no prefix matches.
#### Local dry-run
```bash
# Render filtered YAML to stdout (no upload)
python3 .buildkite/scripts/upload_pipeline.py .buildkite/test-ready.yml
# Confirm uploader-only keys are stripped
python3 .buildkite/scripts/upload_pipeline.py .buildkite/test-merge.yml | grep source_file_dependencies
# (no output expected)
```
On a PR build, Buildkite logs from `upload_pipeline.py` include lines such as `skip '…' (no changes under …)` for omitted steps.
#### Related
- Implementation: `.buildkite/scripts/upload_pipeline.py`
- L2/L3 diff skip does **not** replace label-based triggers (`ready`, `merge-test`); it only reduces which steps appear **after** the pipeline is already scheduled.
=======
### Test helper environment variables
Some shared helpers under `tests/helpers/` honor optional environment variables for local debugging. These are **not** set in CI by default.
| Variable | Accepted values | Description |
| -------- | --------------- | ----------- |
| `VLLM_OMNI_KEEP_REQUEST_MEDIA` | `1`, `true`, `yes` (case-insensitive) | When enabled, temporary WAV files created by `tests.helpers.media.convert_audio_bytes_to_text` are **not** deleted when the pytest process exits. By default, each call writes a unique file under the system temp directory via `tempfile.mkstemp` and registers `atexit` cleanup. Use this when debugging audio output validation (Whisper transcription, keyword checks, text–audio similarity). The saved path is logged as `audio data is saved: <path>`. |
Example (Linux / macOS):
```bash
export VLLM_OMNI_KEEP_REQUEST_MEDIA=1
pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py -k test_mix_to_text_audio
```
Example (Windows PowerShell):
```powershell
$env:VLLM_OMNI_KEEP_REQUEST_MEDIA = "1"
pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py -k test_mix_to_text_audio
```
>>>>>>> main
## Chapter 1: L1 & L2 Level Testing - Unit Testing and Basic End-to-End Verification
### 1.1 Testing Purpose
@@ -302,6 +397,7 @@ A clear directory structure is key to managing test cases efficiently.
### 1.4 Execution Method and Example
- ***Trigger Timing***: **`PR with ready label`**. That is, when a developer adds a "ready for review" or similar label to a PR on platforms like GitHub, L1 and L2 tests are automatically triggered.
- ***Diff-aware step skipping***: On L2, expensive **H100 E2E** jobs may be omitted at pipeline upload when the PR diff does not touch their [`source_file_dependencies`](#diff-aware-buildkite-uploads-source_file_dependencies) paths; L4 docker jobs and non-E2E steps still run. See [Diff-aware Buildkite uploads](#diff-aware-buildkite-uploads-source_file_dependencies).
- ***Execution Environment***: L1 uses ***CPU*** environment; L2 requires ***GPU*** environment.
- ***Script Example***:
@@ -404,7 +500,8 @@ L3 level testing executes after code is merged into the main branch. Its core pu
### 2.4 Execution Method and Example
- ***Trigger Timing***: **`PR Merged`**. Automatically triggered after code review is approved and merged into the main branch.
- ***Trigger Timing***: **`PR Merged`**. Automatically triggered after code review is approved and merged into the main branch (typically via `merge-test` label on the PR before merge).
- ***Diff-aware step skipping***: Same [`source_file_dependencies`](#diff-aware-buildkite-uploads-source_file_dependencies) mechanism as L2—only **H100 E2E** steps are diff-gated; L4 docker E2E and other L3 groups always upload. See [Diff-aware Buildkite uploads](#diff-aware-buildkite-uploads-source_file_dependencies).
- ***Execution Environment***: ***GPU*** servers.
- ***Script Example***:
@@ -520,6 +617,8 @@ L3 level testing executes after code is merged into the main branch. Its core pu
**Implicit Validation**: The `send_omni_request` and `send_diffusion_request` methods internally includes validation logic dynamically selected based on the --run-level parameter: core_model performs basic validation, while advanced_model and full_model perform deep validation.
**Audio output debugging**: Deep validation may transcribe returned audio via `convert_audio_bytes_to_text` (Whisper). If an audio keyword or text–audio similarity assertion fails, set `VLLM_OMNI_KEEP_REQUEST_MEDIA=1` before running pytest to keep the intermediate WAV files for inspection (see [Test helper environment variables](#test-helper-environment-variables)).
- ***Run Command (L3 merge)***: `pytest -s -v /tests/e2e/online_serving/test_{model_name}.py -m advanced_model --run-level=advanced_model`
- ***Run Command (L4 nightly expansion)***: `pytest -s -v /tests/e2e/online_serving/test_{model_name}_expansion.py -m full_model --run-level=full_model`
+2
View File
@@ -69,6 +69,8 @@ If the failure is already tracked, leave a comment on your PR noting that the fa
If the failure appears to be new, investigate whether your changes caused it.
For resilience behavior under injected process signals/OOM pressure (validation scenarios, not root-cause equivalence), see [Failure Mode Injection Scenarios and Expected Behavior Matrix](../../user_guide/fault_injection_reliability_matrix.md).
### Reading Buildkite Logs
1. Click **Details** next to the Buildkite check on your PR.
+1 -1
View File
@@ -33,7 +33,7 @@ th {
| `ZImagePipeline` | Z-Image | `Tongyi-MAI/Z-Image-Turbo` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `WanPipeline` | Wan2.1-T2V, Wan2.2-T2V, Wan2.2-TI2V | `Wan-AI/Wan2.1-T2V-1.3B-Diffusers`, `Wan-AI/Wan2.1-T2V-14B-Diffusers`, `Wan-AI/Wan2.2-T2V-A14B-Diffusers`, `Wan-AI/Wan2.2-TI2V-5B-Diffusers` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `WanImageToVideoPipeline` | Wan2.2-I2V | `Wan-AI/Wan2.2-I2V-A14B-Diffusers` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `Cosmos3OmniDiffusersPipeline` | Cosmos3 T2I, T2V, I2V, V2V, T2V with sound, action policy | `nvidia/Cosmos3-Nano` | ✅︎ | | | |
| `Cosmos3OmniDiffusersPipeline` | Cosmos3 T2I, T2V, I2V, V2V, T2V with sound, action policy | `nvidia/Cosmos3-Nano` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `WanSpeechToVideoPipeline` | Wan2.2-S2V | `Wan-AI/Wan2.2-S2V-14B` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `Wan22VACEPipeline` | Wan2.1-VACE | `Wan-AI/Wan2.1-VACE-1.3B-diffusers`, `Wan-AI/Wan2.1-VACE-14B-diffusers` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `LTX2Pipeline` | LTX-2-T2V | `Lightricks/LTX-2` | ✅︎ | ✅︎ | | |
+5 -1
View File
@@ -114,6 +114,7 @@ Content-Type: application/json
| `instructions` | string | "" | Voice style/emotion instructions |
| `max_new_tokens` | integer | 2048 | Maximum tokens to generate |
| `initial_codec_chunk_frames` | integer | null | Per-request initial chunk size override for TTFA tuning. When null, IC is computed dynamically based on server load. |
| `non_streaming_mode` | bool | null | Qwen3-TTS prompt construction mode override. Does not affect HTTP response streaming or async-chunk pipelining. When null, Qwen3-TTS uses model defaults: Base=false, CustomVoice/VoiceDesign=true. |
| `stream` | bool | false | Stream raw PCM chunks as they are decoded (requires `response_format="pcm"`) |
**Supported languages:** Only applicable to Qwen3-TTS. Derived from the model configuration (`talker_config.codec_language_id` in the checkpoint's `config.json`), plus `Auto`, which is always accepted. Official Qwen3-TTS checkpoints support: Auto, Chinese, English, Japanese, Korean, German, French, Russian, Portuguese, Spanish, Italian.
@@ -332,7 +333,8 @@ curl -X POST http://localhost:8091/v1/audio/speech \
"input": "Hello, this is a cloned voice",
"task_type": "Base",
"ref_audio": "https://example.com/reference.wav",
"ref_text": "Original transcript of the reference audio"
"ref_text": "Original transcript of the reference audio",
"non_streaming_mode": true
}' --output cloned.wav
```
@@ -454,6 +456,7 @@ Content-Type: application/json
| `ref_audio` | string | null | Default reference audio (Base task) |
| `ref_text` | string | null | Default reference transcript (Base task) |
| `max_new_tokens` | integer | null | Default max tokens |
| `non_streaming_mode` | bool | null | Default Qwen3-TTS prompt construction mode override. Does not affect HTTP response streaming or async-chunk pipelining. When null, Qwen3-TTS uses model defaults: Base=false, CustomVoice/VoiceDesign=true. |
Each item in the `items` array requires only `input` (the text). All other fields are optional and override the batch-level defaults when set:
@@ -469,6 +472,7 @@ Each item in the `items` array requires only `input` (the text). All other field
| `ref_audio` | string | Override reference audio |
| `ref_text` | string | Override reference transcript |
| `max_new_tokens` | integer | Override max tokens |
| `non_streaming_mode` | bool | Override Qwen3-TTS prompt construction mode. Does not affect HTTP response streaming or async-chunk pipelining. When null, inherits the batch-level value (then the model default). |
### Response Format
+22
View File
@@ -70,6 +70,7 @@ curl -L "http://localhost:8091/v1/videos/${video_id}/content" -o output.mp4
| `input_reference` | file | null | Uploaded reference image or video for image-to-video/video-to-video requests |
| `image_reference` | string | null | JSON-encoded reference image payload; do not combine with `input_reference` or `video_reference` |
| `video_reference` | string | null | JSON-encoded reference video payload; do not combine with `input_reference` or `image_reference` |
| `audio_reference` | string | null | JSON-encoded audio reference for speech-to-video: `{"audio_url": "..."}` — supports HTTP(s) URLs or base64 data URLs |
| `width` | integer | model default | Output video width |
| `height` | integer | model default | Output video height |
| `num_frames` | integer | model default | Number of generated frames |
@@ -161,6 +162,25 @@ are not implemented yet. Models may expose additional V2V controls through
`condition_frame_indexes_vision` and `condition_video_keep` to select which
decoded reference frames are used as clean conditioning.
### Speech-to-Video
For models that support audio-driven generation (e.g., Wan2.2-S2V), pass both
an image reference and an audio reference. The `audio_reference` field accepts a
JSON string with `audio_url` pointing to an HTTP(s) URL or base64 data URL.
```bash
curl -s http://localhost:8091/v1/videos \
-F "prompt=A person singing" \
-F 'image_reference={"image_url": "https://example.com/face.png"}' \
-F 'audio_reference={"audio_url": "https://example.com/speech.mp3"}' \
-F "width=832" \
-F "height=480" \
-F "num_inference_steps=40" \
-F "guidance_scale=4.5" \
-F "fps=16"
```
### Synchronous Generation
```bash
@@ -189,5 +209,7 @@ walkthroughs, see:
- [Text-to-Video](../user_guide/examples/online_serving/text_to_video.md)
- [Image-to-Video](../user_guide/examples/online_serving/image_to_video.md)
- [Speech-to-Video](../user_guide/examples/online_serving/speech_to_video.md)
for Wan2.2-S2V audio-driven lip-sync generation
- [Cosmos3 recipes](https://github.com/vllm-project/vllm-omni/blob/main/recipes/cosmos3/Cosmos3-Nano.md)
for model-specific video-to-video examples and conditioning controls
@@ -0,0 +1,128 @@
# Speech-To-Video
Source <https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/speech_to_video>.
This example demonstrates how to deploy the Wan2.2 speech-to-video (S2V) model for online video generation using vLLM-Omni.
## Supported Models
| Model | Model ID |
|-------|----------|
| Wan2.2 S2V (14B) | `Wan-AI/Wan2.2-S2V-14B` |
## Start Server
### Basic Start
```bash
VLLM_WORKER_MULTIPROC_METHOD=spawn \
vllm serve Wan-AI/Wan2.2-S2V-14B --omni \
--model-class-name WanS2VPipeline \
--tensor-parallel-size 2 \
--flow-shift 3.0 \
--vae-use-slicing --vae-use-tiling \
--cache-backend cache_dit \
--port 8091
```
### Start with Script
```bash
bash run_server.sh
```
The script allows overriding:
- `MODEL` (default: `Wan-AI/Wan2.2-S2V-14B`)
- `PORT` (default: `8091`)
- `FLOW_SHIFT` (default: `3.0`)
- `TP` (default: `2`)
- `CACHE_BACKEND` (default: `cache_dit`)
### Start with 4 GPUs
```bash
TP=4 bash run_server.sh
```
## API Endpoints
The server provides OpenAI-compatible video generation endpoints:
- `POST /v1/videos`: create a video generation job (async)
- `POST /v1/videos/sync`: generate a video and return raw bytes (sync, for benchmarks)
- `GET /v1/videos/{video_id}`: retrieve the current job status and metadata
- `GET /v1/videos`: list stored video jobs
- `GET /v1/videos/{video_id}/content`: download the generated video file
- `DELETE /v1/videos/{video_id}`: delete the job and any stored output
## Sync API (Recommended for Testing)
`POST /v1/videos/sync` blocks until generation completes and returns the raw
video bytes (`video/mp4`) directly in the response body.
```bash
bash run_curl_speech_to_video.sh
```
## Async Job API
`POST /v1/videos` creates an asynchronous job and returns immediately.
```bash
no_proxy=127.0.0.1 \
create_response=$(curl -sS -X POST http://127.0.0.1:8091/v1/videos \
-H "Accept: application/json" \
-F "prompt=A person singing" \
-F 'image_reference={"image_url": "https://raw.githubusercontent.com/Wan-Video/Wan2.2/main/examples/Five%20Hundred%20Miles.png"}' \
-F 'audio_reference={"audio_url": "https://raw.githubusercontent.com/Wan-Video/Wan2.2/main/examples/Five%20Hundred%20Miles.MP3"}' \
-F "width=832" -F "height=480" \
-F "num_inference_steps=40" \
-F "guidance_scale=4.5" \
-F "fps=16")
video_id=$(echo "$create_response" | jq -r '.id')
# Poll until complete
while true; do
status=$(curl -s "http://127.0.0.1:8091/v1/videos/${video_id}" | jq -r '.status')
if [ "$status" = "completed" ]; then
break
fi
if [ "$status" = "failed" ]; then
echo "Video generation failed"
exit 1
fi
sleep 2
done
# Download
curl -L "http://127.0.0.1:8091/v1/videos/${video_id}/content" -o s2v_output.mp4
```
## Request Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `prompt` | str | - | Text description of the desired video |
| `image_reference` | JSON str | - | Image reference: `{"image_url": "..."}` — supports HTTP(s) URLs or base64 data URLs |
| `audio_reference` | JSON str | - | Audio reference: `{"audio_url": "..."}` — supports HTTP(s) URLs or base64 data URLs |
| `width` | int | None | Video width in pixels |
| `height` | int | None | Video height in pixels |
| `fps` | int | None | Output video frame rate |
| `num_inference_steps` | int | None | Number of denoising steps (40 recommended) |
| `guidance_scale` | float | None | CFG guidance scale |
| `seed` | int | None | Random seed for reproducibility |
## Notes
- S2V requires both a reference image (`image_reference`) and an audio reference
(`audio_reference`). The generated video will show a person matching the reference
image with lip movements synchronized to the audio.
- `audio_reference` accepts a JSON string: `{"audio_url": "..."}` where the URL can be:
- An HTTP/HTTPS URL (e.g., `https://example.com/audio.mp3`)
- A base64 data URL (e.g., `data:audio/mp3;base64,...`)
- `--model-class-name WanS2VPipeline` is required on the server to select the
S2V pipeline (distinct from the T2V/I2V pipelines).
- `--cache-backend cache_dit` enables DiT caching for ~2x speedup on cached steps.
- Audio is muxed into the output MP4 automatically.
- Always pass `fps=16` to ensure correct video/audio alignment.
@@ -0,0 +1,117 @@
# vLLM-Omni Failure Mode Injection Scenarios and Expected Behavior Matrix
## Background and Goals
The online serving pipeline of vLLM-Omni can be abstracted as: `Serve` (service entry process) -> `Engine` (model orchestration unit) -> `Worker` (execution process) -> `GPU` (compute resource).
- `Serve` provides external APIs, accepts requests, and manages lifecycle.
- `Engine` handles request scheduling and inference orchestration; the number and organization of engines can vary by model type.
- `Worker` performs concrete model computation, usually bound to underlying GPU resources.
- `GPU` is the resource layer that ultimately carries inference compute and memory usage.
<div markdown style="text-align: center">
```mermaid
%%{init: {'flowchart': {'useMaxWidth': false}}}%%
flowchart TD
R(request)--> A(serve)
A --> B(engine)
A --> C(engine)
A --> D(engine)
B --> E(worker)
C --> F(worker)
D --> I(worker)
E --> G(GPU)
F --> H(GPU)
I --> L(GPU)
```
</div>
The diagram above shows an abstract relationship. Omni models and Diffusion models share the same hierarchy; differences mainly come from engine decomposition, worker count, and scheduling strategy.
In real customer environments, the system will inevitably encounter accidental operations or environmental disturbances, such as unintended process termination, container/node jitter, or short-term resource contention. From the system perspective, many of these events map to two fault categories: "process receives abnormal signals" or "GPU memory is squeezed." For example, pressing `Ctrl+C` essentially sends a `SIGINT` signal to the `serve` process; running `kill -15 <pid>` maps to graceful termination via `SIGTERM`; running `kill -9 <pid>` maps to forced termination via `SIGKILL`; when `docker kill` is executed or a Pod is deleted, the container runtime usually sends `SIGKILL`.
This document currently covers two core failure modes, and the system is expected to show predictable and observable behavior under each mode:
- **Failure Mode 1: Process receives abnormal signals (`SIGINT` / `SIGTERM` / `SIGKILL`)**
Focus on three aspects: whether processes exit as expected and complete cleanup, whether requests fail fast or connections are interrupted, and whether GPU resources are eventually fully released with no residue.
- **Failure Mode 2: OOM (occupy all free GPU memory via an extra process)**
Focus on two aspects: whether service health degrades to the expected state (for example, `503`), and whether requests fail within an acceptable time without hanging.
More failure modes (for example, network jitter and network interruption) will be added in future iterations to improve end-to-end reliability validation coverage.
> Note: independent fault injection on the `engine` component is not covered in the current version and will be added gradually in future versions.
## Fault Injection Scenario Matrix
| Scenario | Fault Type | System Behavior | Current Status |
|------|----------------------------|----------|----------|
| No load | Send `SIGKILL` to Worker | Worker process is killed immediately; main service detects child-process loss and turns unavailable; API enters stable 5xx | |
| No load | Send `SIGTERM` to Worker | Worker exits after receiving termination signal; main service is marked unavailable; API enters stable 5xx | |
| No load | Send `SIGKILL` to serve main process | serve main process exits instantly; request connections are interrupted; related child processes are cleaned up with no residue; GPU memory is released quickly | [#3725](https://github.com/vllm-project/vllm-omni/issues/3725) <br>[#43060](https://github.com/vllm-project/vllm/issues/43060) |
| No load | Send `SIGTERM` to serve main process | serve enters graceful shutdown and stops serving; then exits and completes cleanup; GPU memory is released | |
| No load | Send `SIGINT` to serve main process (equivalent to `Ctrl+C`) | Triggers serve shutdown path; service stops responding and becomes unavailable; related child processes exit and resources are released | |
| No load | Send `SIGKILL` to all related processes | All related processes terminate immediately; service becomes unavailable at once; no residual processes remain; GPU memory is released quickly | |
| No load | Send `SIGTERM` to all related processes | All processes enter exit flow and complete shutdown; service becomes unavailable; resource release is completed | |
| Under load | Send `SIGKILL` to Worker | In-flight requests are hard interrupted (5xx/connection drop); main service becomes unavailable | [#3683](https://github.com/vllm-project/vllm-omni/issues/3683) |
| Under load | Send `SIGTERM` to Worker | In-flight requests are canceled or fail fast; main service becomes unavailable | |
| Under load | Send `SIGKILL` to serve main process | serve is hard-killed and current connections are interrupted; in-flight requests fail; after cleanup there are no residual processes and GPU memory is released | [#3683](https://github.com/vllm-project/vllm-omni/issues/3683) |
| Under load | Send `SIGTERM` to serve main process | serve stops accepting new requests and executes shutdown flow; in-flight requests fail; no residue remains and GPU memory is released | [#3683](https://github.com/vllm-project/vllm-omni/issues/3683) |
| Under load | Send `SIGINT` to serve main process (equivalent to `Ctrl+C`) | `Ctrl+C`-style serve shutdown; in-flight requests fail (5xx/connection interruption); service unavailable; after exit there is no residue and GPU memory is released | [#3683](https://github.com/vllm-project/vllm-omni/issues/3683) |
| Under load | Send `SIGKILL` to all related processes | All processes terminate instantly; all in-flight requests fail; service becomes unavailable immediately; GPU memory is released quickly | |
| Under load | Send `SIGTERM` to all related processes | All processes exit gracefully; in-flight requests fail; service unavailable; GPU memory is released | [#3683](https://github.com/vllm-project/vllm-omni/issues/3683) |
| OOM | Occupy all free GPU memory via an extra process | After OOM injection process starts, GPU memory is continuously saturated; service enters unavailable/degraded state and health check drops to 503; different request types (chat/speech, etc.) fail fast within a fixed time and return 500 (no hanging) | [#4285](https://github.com/vllm-project/vllm-omni/issues/4285) |
## Source of Conclusions
The behaviors and conclusions above are summarized from current fault injection validation results on `Qwen3-Omni` and `Wan2.2`.
### Example: `SIGTERM` fault injection log (Qwen3-Omni)
The following full log is from a real run where a `SIGTERM` signal was sent to the serve root process.
```text
Launching OmniServer with: /workspace/.venv/bin/python3 -m vllm_omni.entrypoints.cli.main serve Qwen/Qwen3-Omni-30B-A3B-Instruct --host 127.0.0.1 --port 60675 --omni --async-chunk --stage-init-timeout 600 --init-timeout 900 --log-stats --stage-configs-path vllm-omni/vllm_omni/deploy/qwen3_omni_moe.yaml
Server ready on 127.0.0.1:60675 (OmniServer startup took 206.023s)
OmniServer started successfully
[reliability][process-kill] current_server_proc pid=556855 name=python3 cmdline=/workspace/.venv/bin/python3 -m vllm_omni.entrypoints.cli.main serve Qwen/Qwen3-Omni-30B-A3B-Instruct --host 127.0.0.1 --port 60675 --omni --async-chunk --stage-init-timeout 600 --init-timeout 900 --log-stats --stage-configs-path vllm-omni/vllm_omni/deploy/qwen3_omni_moe.yaml
[reliability][process-kill] current_server_proc pid=557118 name=python3 cmdline=/workspace/.venv/bin/python3 -c from multiprocessing.resource_tracker import main;main(57)
[reliability][process-kill] current_server_proc pid=557119 name=VLLM::StageEngineCoreProc_noid_replica0_DP0 cmdline=VLLM::StageEngineCoreProc_noid_replica0_DP0
[reliability][process-kill] current_server_proc pid=557122 name=VLLM::StageEngineCoreProc_noid_replica0_DP0 cmdline=VLLM::StageEngineCoreProc_noid_replica0_DP0
[reliability][process-kill] current_server_proc pid=558201 name=VLLM::StageEngineCoreProc_noid_replica0_DP0 cmdline=VLLM::StageEngineCoreProc_noid_replica0_DP0
[reliability][process-kill] root-kill pid=556855 name=python3 signal=SIGTERM cmdline=/workspace/.venv/bin/python3 -m vllm_omni.entrypoints.cli.main serve Qwen/Qwen3-Omni-30B-A3B-Instruct --host 127.0.0.1 --port 60675 --omni --async-chunk --stage-init-timeout 600 --init-timeout 900 --log-stats --stage-configs-path vllm-omni/vllm_omni/deploy/qwen3_omni_moe.yaml
[0;36m(APIServer pid=556855)[0;0m INFO 05-19 12:24:51 [omni_base.py:463] [AsyncOmni] Shutting down
[0;36m(APIServer pid=556855)[0;0m INFO 05-19 12:24:51 [async_omni_engine.py:2397] [AsyncOmniEngine] Shutting down Orchestrator
[0;36m(APIServer pid=556855)[0;0m INFO 05-19 12:24:51 [orchestrator.py:351] [Orchestrator] Received shutdown signal
[0;36m(APIServer pid=556855)[0;0m INFO 05-19 12:24:51 [orchestrator.py:1359] [Orchestrator] Shutting down all 3 client(s)
[0;36m(StageEngineCoreProc pid=557119)[0;0m [32mINFO[0m [90m05-19 12:24:51[0m [90m[core.py:1242][0m Shutdown initiated (timeout=0)
[0;36m(StageEngineCoreProc pid=557119)[0;0m [32mINFO[0m [90m05-19 12:24:51[0m [90m[core.py:1265][0m Shutdown complete
[0;36m(APIServer pid=556855)[0;0m INFO: Shutting down
[0;36m(APIServer pid=556855)[0;0m INFO: Waiting for application shutdown.
[0;36m(APIServer pid=556855)[0;0m INFO: Application shutdown complete.
[0;36m(APIServer pid=556855)[0;0m INFO: Finished server process [556855]
[0;36m(APIServer pid=556855)[0;0m INFO 05-19 12:24:51 [omni_base.py:463] [AsyncOmni] Shutting down
[rank0]:[W519 12:24:53.427894833 ProcessGroupNCCL.cpp:1575] Warning: WARNING: destroy_process_group() was not called before program exit, which can leak resources. For more info, please see https://pytorch.org/docs/stable/distributed.html#shutdown (function operator())
[0;36m(APIServer pid=556855)[0;0m INFO 05-19 12:24:56 [stage_pool.py:731] [StagePool] Stage 0 replica 0 shut down
[0;36m(StageEngineCoreProc pid=557122)[0;0m [32mINFO[0m [90m05-19 12:24:56[0m [90m[core.py:1242][0m Shutdown initiated (timeout=0)
[0;36m(StageEngineCoreProc pid=557122)[0;0m [32mINFO[0m [90m05-19 12:24:56[0m [90m[core.py:1265][0m Shutdown complete
[rank0]:[W519 12:24:57.569425065 ProcessGroupNCCL.cpp:1575] Warning: WARNING: destroy_process_group() was not called before program exit, which can leak resources. For more info, please see https://pytorch.org/docs/stable/distributed.html#shutdown (function operator())
[0;36m(APIServer pid=556855)[0;0m INFO 05-19 12:25:00 [stage_pool.py:731] [StagePool] Stage 1 replica 0 shut down
[0;36m(StageEngineCoreProc pid=558201)[0;0m [32mINFO[0m [90m05-19 12:25:00[0m [90m[core.py:1242][0m Shutdown initiated (timeout=0)
[0;36m(StageEngineCoreProc pid=558201)[0;0m [32mINFO[0m [90m05-19 12:25:00[0m [90m[core.py:1265][0m Shutdown complete
[0;36m(APIServer pid=556855)[0;0m WARNING 05-19 12:25:01 [async_omni_engine.py:2406] [AsyncOmniEngine] Orchestrator thread did not exit in time
[rank0]:[W519 12:25:01.867242016 ProcessGroupNCCL.cpp:1575] Warning: WARNING: destroy_process_group() was not called before program exit, which can leak resources. For more info, please see https://pytorch.org/docs/stable/distributed.html#shutdown (function operator())
```
### How this maps to the matrix
- This log corresponds to the `Send SIGTERM to serve main process` scenario in the matrix.
- The `root-kill ... signal=SIGTERM` line confirms the fault injection action is applied as expected.
- The sequence from `Shutting down Orchestrator` to stage-level `Shutdown complete` and `Stage ... shut down` matches the expected graceful shutdown path and child-process cleanup behavior described in the matrix.
## Related Documentation
- For practical CI debugging procedures and triage guidance, see [CI Failures](../contributing/ci/failures.md).
@@ -0,0 +1,115 @@
# Speech-To-Video
This example demonstrates how to deploy the Wan2.2 speech-to-video (S2V) model for online video generation using vLLM-Omni.
## Supported Models
| Model | Model ID |
|-------|----------|
| Wan2.2 S2V (14B) | `Wan-AI/Wan2.2-S2V-14B` |
## Start Server
### Basic Start
```bash
VLLM_WORKER_MULTIPROC_METHOD=spawn \
vllm serve Wan-AI/Wan2.2-S2V-14B --omni \
--model-class-name WanS2VPipeline \
--tensor-parallel-size 2 \
--flow-shift 3.0 \
--vae-use-slicing --vae-use-tiling \
--cache-backend cache_dit \
--port 8091
```
### Start with Script
```bash
bash run_server.sh
```
The script allows overriding:
- `MODEL` (default: `Wan-AI/Wan2.2-S2V-14B`)
- `PORT` (default: `8091`)
- `FLOW_SHIFT` (default: `3.0`)
- `TP` (default: `2`)
- `CACHE_BACKEND` (default: `cache_dit`)
### Start with 4 GPUs
```bash
TP=4 bash run_server.sh
```
## Sync API (Recommended for Testing)
`POST /v1/videos/sync` blocks until generation completes and returns the raw
video bytes (`video/mp4`) directly in the response body.
```bash
bash run_curl_speech_to_video.sh
```
## Async Job API
`POST /v1/videos` creates an asynchronous job and returns immediately.
```bash
no_proxy=127.0.0.1 \
create_response=$(curl -sS -X POST http://127.0.0.1:8091/v1/videos \
-H "Accept: application/json" \
-F "prompt=A person singing" \
-F 'image_reference={"image_url": "https://raw.githubusercontent.com/Wan-Video/Wan2.2/main/examples/Five%20Hundred%20Miles.png"}' \
-F 'audio_reference={"audio_url": "https://raw.githubusercontent.com/Wan-Video/Wan2.2/main/examples/Five%20Hundred%20Miles.MP3"}' \
-F "width=832" -F "height=480" \
-F "num_inference_steps=40" \
-F "guidance_scale=4.5" \
-F "fps=16")
video_id=$(echo "$create_response" | jq -r '.id')
# Poll until complete
while true; do
status=$(curl -s "http://127.0.0.1:8091/v1/videos/${video_id}" | jq -r '.status')
if [ "$status" = "completed" ]; then
break
fi
if [ "$status" = "failed" ]; then
echo "Video generation failed"
exit 1
fi
sleep 2
done
# Download
curl -L "http://127.0.0.1:8091/v1/videos/${video_id}/content" -o s2v_output.mp4
```
## Request Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `prompt` | str | - | Text description of the desired video |
| `image_reference` | JSON str | - | Image reference: `{"image_url": "..."}` — supports HTTP(s) URLs or base64 data URLs |
| `audio_reference` | JSON str | - | Audio reference: `{"audio_url": "..."}` — supports HTTP(s) URLs or base64 data URLs |
| `width` | int | None | Video width in pixels |
| `height` | int | None | Video height in pixels |
| `fps` | int | None | Output video frame rate |
| `num_inference_steps` | int | None | Number of denoising steps (40 recommended) |
| `guidance_scale` | float | None | CFG guidance scale |
| `seed` | int | None | Random seed for reproducibility |
## Notes
- S2V requires both a reference image (`image_reference`) and an audio reference
(`audio_reference`). The generated video will show a person matching the reference
image with lip movements synchronized to the audio.
- `audio_reference` accepts a JSON string: `{"audio_url": "..."}` where the URL can be:
- An HTTP/HTTPS URL (e.g., `https://example.com/audio.mp3`)
- A base64 data URL (e.g., `data:audio/mp3;base64,...`)
- `--model-class-name WanS2VPipeline` is required on the server to select the
S2V pipeline (distinct from the T2V/I2V pipelines).
- `--cache-backend cache_dit` enables DiT caching for ~2x speedup on cached steps.
- Audio is muxed into the output MP4 automatically.
- Always pass `fps=16` to ensure correct video/audio alignment.
@@ -0,0 +1,44 @@
#!/bin/bash
# Wan2.2 S2V (speech-to-video) curl example using the sync video API.
set -euo pipefail
BASE_URL="${BASE_URL:-http://localhost:8091}"
OUTPUT_PATH="${OUTPUT_PATH:-s2v_480p_serve.mp4}"
IMAGE_URL="${IMAGE_URL:-https://raw.githubusercontent.com/Wan-Video/Wan2.2/main/examples/Five%20Hundred%20Miles.png}"
AUDIO_URL="${AUDIO_URL:-https://raw.githubusercontent.com/Wan-Video/Wan2.2/main/examples/Five%20Hundred%20Miles.MP3}"
PROMPT="${PROMPT:-A person singing}"
WIDTH="${WIDTH:-832}"
HEIGHT="${HEIGHT:-480}"
NUM_INFERENCE_STEPS="${NUM_INFERENCE_STEPS:-40}"
GUIDANCE_SCALE="${GUIDANCE_SCALE:-4.5}"
FPS="${FPS:-16}"
echo "Sending S2V request..."
echo " Image URL: $IMAGE_URL"
echo " Audio URL: $AUDIO_URL"
echo " Prompt: $PROMPT"
echo " Resolution: ${WIDTH}x${HEIGHT}"
echo " Steps: $NUM_INFERENCE_STEPS"
echo " FPS: $FPS"
IMAGE_REF_JSON="{\"image_url\": \"${IMAGE_URL}\"}"
AUDIO_REF_JSON="{\"audio_url\": \"${AUDIO_URL}\"}"
no_proxy=127.0.0.1 \
curl -X POST "${BASE_URL}/v1/videos/sync" \
-F "prompt=${PROMPT}" \
-F "image_reference=${IMAGE_REF_JSON}" \
-F "audio_reference=${AUDIO_REF_JSON}" \
-F "width=${WIDTH}" -F "height=${HEIGHT}" \
-F "num_inference_steps=${NUM_INFERENCE_STEPS}" \
-F "guidance_scale=${GUIDANCE_SCALE}" \
-F "fps=${FPS}" \
--output "${OUTPUT_PATH}"
if [ -f "$OUTPUT_PATH" ] && [ -s "$OUTPUT_PATH" ]; then
echo "Saved video to ${OUTPUT_PATH} ($(du -h "$OUTPUT_PATH" | cut -f1))"
else
echo "ERROR: Output file is empty or missing"
exit 1
fi
+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
# Wan2.2 S2V (speech-to-video) online serving startup script
MODEL="${MODEL:-Wan-AI/Wan2.2-S2V-14B}"
PORT="${PORT:-8091}"
FLOW_SHIFT="${FLOW_SHIFT:-3.0}"
TP="${TP:-2}"
CACHE_BACKEND="${CACHE_BACKEND:-cache_dit}"
echo "Starting Wan2.2 S2V server..."
echo "Model: $MODEL"
echo "Port: $PORT"
echo "Flow shift: $FLOW_SHIFT"
echo "Tensor parallel size: $TP"
echo "Cache backend: $CACHE_BACKEND"
CACHE_BACKEND_FLAG=""
if [ "$CACHE_BACKEND" != "none" ]; then
CACHE_BACKEND_FLAG="--cache-backend $CACHE_BACKEND"
fi
VLLM_WORKER_MULTIPROC_METHOD=spawn \
vllm serve "$MODEL" --omni \
--model-class-name WanS2VPipeline \
--tensor-parallel-size "$TP" \
--flow-shift "$FLOW_SHIFT" \
--vae-use-slicing --vae-use-tiling \
--port "$PORT" \
$CACHE_BACKEND_FLAG
@@ -408,7 +408,10 @@ def create_app(api_base: str):
await resp.aclose()
await client.aclose()
return StreamingResponse(relay(), media_type="application/octet-stream")
# Use audio/pcm to bypass browsers (e.g. Chromium) MIME type sniffing, which
# buffers application/octet-stream responses before delivering chunks to
# fetch().body.getReader(), inflating TTFP to total_ms.
return StreamingResponse(relay(), media_type="audio/pcm")
# ── Gradio UI ────────────────────────────────────────────────
voices = fetch_voices(api_base)
+6 -1
View File
@@ -66,7 +66,8 @@ dev = [
"voxcpm>=2.0.2", # for VoxCPM2 path-locating in tests
"torchdiffeq>=0.2.5", # for covo_audio Test
"FlagEmbedding", # for hunyuan_image3 tests
"kernels<0.15.0" # for accuracy tests comparison with diffusers output (0.15.0 incompatible with diffusers 0.38.0)
"kernels<0.15.0", # for accuracy tests comparison with diffusers output (0.15.0 incompatible with diffusers 0.38.0)
"s3tokenizer>=0.3.0" # for tts test
]
demo = [
@@ -292,5 +293,9 @@ fullset = "fullset"
Vai = "Vai"
tockens = "tockens"
CANN = "CANN"
# Step-Audio reasoning parser streams `</think>` tokens split across chunks
# (e.g. "</" + "thi" + "nk>"). The fragment "thi" appears literally in its
# parser logic and tests and is not a typo of "the"/"this".
thi = "thi"
ND = "ND"
byt5 = "byt5"
+24 -3
View File
@@ -52,6 +52,7 @@ vllm-omni serve bosonai/higgs-audio-v3-tts-4b \
The default deploy config `vllm_omni/deploy/higgs_multimodal_qwen3.yaml` is
loaded automatically by model registry (HF `model_type=higgs_multimodal_qwen3`).
It matches the high-throughput profile.
**Offline batch inference:**
@@ -103,8 +104,28 @@ python examples/online_serving/text_to_speech/higgs_audio_v3/batch_speech_client
- Voice cloning: `ref_audio` accepts WAV/FLAC/MP3; `ref_text` is optional but improves fidelity.
- Deploy config: `vllm_omni/deploy/higgs_multimodal_qwen3.yaml` (auto-discovered from `model_type`).
- `max_num_seqs=16` for both stages.
- `enforce_eager=true` for both stages (CUDA graph available for stage 0 but no throughput gain at batch>1).
- Stage 0 and Stage 1 default to the same device (`0`) for single-GPU serving.
- Stage 0 intentionally keeps `enforce_eager=true`. This preserves the Higgs-specific local MLP CUDA graph path, which is the current high-throughput default.
- Stage 1 remains `enforce_eager=true` for the codec decoder.
- Deploy profiles:
- High throughput: `vllm_omni/deploy/higgs_multimodal_qwen3_high_throughput.yaml`.
Use this for medium/high concurrency. The auto-discovered
`higgs_multimodal_qwen3.yaml` is kept as a compatibility/default alias for
this profile.
- Low latency: `vllm_omni/deploy/higgs_multimodal_qwen3_low_latency.yaml`.
Use this for low-concurrency serving (for example c1-c4). It sets Stage 0
`enforce_eager=false` and enables vLLM `FULL_DECODE_ONLY` CUDA graph through
YAML `compilation_config`; no environment variable is required.
- Profile details: `vllm_omni/deploy/README_higgs_audio_v3.md`.
- Performance note:
- Do not switch the auto-discovered default Stage 0 profile to vLLM
`FULL_DECODE_ONLY` CUDA graph without an end-to-end throughput and
audio-quality revalidation. On the H20 SeedTTS c16/full-dataset benchmark,
the eager Stage 0 path with Higgs local MLP CUDA graph reproduced
~35 audio_s/s (`1088/1088` OK per run, three runs around 134-136s). A
separate H20 low-concurrency smoke after the FULL_DECODE audio-feedback
capture fix showed lower c1/c4 latency, so the FULL_DECODE profile is kept
as an explicit low-latency option rather than the throughput default.
- Known limitations:
- CUDA graph for stage 0 helps batch=1 (-13% RTF) but not batch>1 due to model-owned sampler running outside graph.
- Stage 1 (code2wav) must use `enforce_eager=true` (`@torch.inference_mode` incompatible with graph capture).
- Async streaming (chunk-based) not yet implemented; sync-only pipeline.
- Stage 0 full-decode CUDA graph is experimental; sampler, delay-state updates, staging, and request postprocess remain outside the graph.
+28
View File
@@ -171,6 +171,34 @@ python examples/offline_inference/speech_to_video/speech_to_video.py \
```
### Online Serving
#### Server
```bash
vllm serve Wan-AI/Wan2.2-S2V-14B --omni \
--model-class-name WanS2VPipeline \
--tensor-parallel-size 2 \
--flow-shift 3.0 \
--vae-use-slicing --vae-use-tiling \
--port 8091
```
#### Client
```bash
no_proxy=127.0.0.1 \
curl -X POST http://127.0.0.1:8091/v1/videos/sync \
-F "prompt=A person singing" \
-F 'image_reference={"image_url": "https://raw.githubusercontent.com/Wan-Video/Wan2.2/main/examples/Five%20Hundred%20Miles.png"}' \
-F 'audio_reference={"audio_url": "https://raw.githubusercontent.com/Wan-Video/Wan2.2/main/examples/Five%20Hundred%20Miles.MP3"}' \
-F "width=832" -F "height=480" \
-F "num_inference_steps=40" \
-F "guidance_scale=4.5" \
-F "fps=16" \
--output s2v_480p_serve.mp4
```
#### Verification
The output video should show a talking/singing person matching the reference
@@ -0,0 +1,74 @@
[
{
"test_name": "test_higgs_audio_v3",
"server_params": {
"model": "bosonai/higgs-audio-v3-tts-4b",
"stage_overrides": {
"0": {
"gpu_memory_utilization": 0.3
},
"1": {
"gpu_memory_utilization": 0.2
}
},
"extra_cli_args": [
"--deploy-config",
"vllm_omni/deploy/higgs_multimodal_qwen3.yaml",
"--trust-remote-code"
]
},
"benchmark_params": [
{
"task": "default_voice",
"eval_phase": "latency",
"dataset_name": "seed-tts-text",
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "benchmarks/build_dataset/seed_tts_smoke",
"num_prompts": [
20
],
"max_concurrency": [
1
],
"seed_tts_locale": "en",
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
3000
],
"median_audio_rtf": [
0.5
]
}
},
{
"task": "default_voice",
"eval_phase": "throughput",
"dataset_name": "seed-tts-text",
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "benchmarks/build_dataset/seed_tts_smoke",
"num_prompts": [
500
],
"max_concurrency": [
16
],
"seed_tts_locale": "en",
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
5000
],
"median_audio_rtf": [
1.2
],
"audio_throughput": [
30
]
}
}
]
}
]
@@ -198,19 +198,78 @@ def test_video_chat_stream_invalid_requests(
# WS /v1/realtime
# ─────────────────────────────────────────────────────────────────────────────
# Resolved in ``test_realtime_invalid_requests`` (skip ``session.created`` handshake).
_REALTIME_WS_MODEL_MISMATCH = object()
_REALTIME_WS_INVALID_AUDIO_APPEND = object()
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize(
"send_frames_spec, ws_error_code, err_message",
[
pytest.param("{not-json", "invalid_json", "Invalid JSON", id="invalid_json"),
pytest.param(
json.dumps({"type": "session.update"}),
"invalid_event",
("model", "Missing required field"),
id="session_update_missing_model",
),
pytest.param(
_REALTIME_WS_MODEL_MISMATCH,
"model_not_found",
("model", "does not exist"),
id="session_update_model_mismatch",
),
pytest.param(
json.dumps({"type": "input_audio_buffer.commit", "final": False}),
"model_not_validated",
("session.update", "validate the model"),
id="commit_without_model_validation",
),
pytest.param(
json.dumps({"type": "unknown.event"}),
"unknown_event",
"Unknown event type",
id="unknown_event_type",
),
pytest.param(
_REALTIME_WS_INVALID_AUDIO_APPEND,
"invalid_audio",
"Invalid audio data",
id="invalid_audio_append",
),
],
)
@pytest.mark.parametrize("omni_server", _QWEN3_OMNI_SERVER, indirect=True)
def test_realtime_rejected_when_async_chunk_enabled(
def test_realtime_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
send_frames_spec: Any,
ws_error_code: str,
err_message: str | tuple[str, ...],
) -> None:
if send_frames_spec is _REALTIME_WS_MODEL_MISMATCH:
send_frames = json.dumps(
{
"type": "session.update",
"model": "this-model-is-not-served-on-this-instance",
}
)
elif send_frames_spec is _REALTIME_WS_INVALID_AUDIO_APPEND:
send_frames = [
json.dumps({"type": "session.update", "model": omni_server.model}),
json.dumps({"type": "input_audio_buffer.append", "audio": "not-valid-base64!!!"}),
]
else:
send_frames = send_frames_spec
openai_client.send_realtime_ws_request(
{
"send_frames": send_frames,
"timeout": 120,
"ws_max_size": None,
"ws_skip_types": ["session.created"],
"ws_json_type": "error",
"ws_error_code": "unsupported",
"err_message": "async_chunk",
"ws_error_code": ws_error_code,
"err_message": err_message,
}
)
@@ -131,6 +131,38 @@ def test_forward_returns_video_prediction(monkeypatch: pytest.MonkeyPatch) -> No
assert tuple(output.shape) == (1, 2, 1, 2, 2)
def test_forward_gathers_gen_tokens_before_unpatchify(monkeypatch: pytest.MonkeyPatch) -> None:
from vllm_omni.diffusion.models.cosmos3 import transformer_cosmos3
class RecordingGather(nn.Module):
def __init__(self) -> None:
super().__init__()
self.calls = 0
def forward(self, hidden_gen: torch.Tensor) -> torch.Tensor:
self.calls += 1
return hidden_gen
monkeypatch.setattr(transformer_cosmos3, "_get_ulysses_state", lambda: (1, 0, None))
model = transformer_cosmos3.Cosmos3VFMTransformer(
SimpleNamespace(tf_model_config=_tiny_cosmos3_config(), dtype=torch.float32)
)
gather = RecordingGather()
model.gen_sp_gather = gather
output = model(
hidden_states=torch.zeros(1, 2, 1, 2, 2),
timestep=torch.tensor([1.0]),
text_ids=torch.tensor([[1, 2]], dtype=torch.long),
text_mask=torch.ones(1, 2, dtype=torch.long),
video_shape=(1, 2, 2),
fps=24.0,
)
assert gather.calls == 1
assert tuple(output.shape) == (1, 2, 1, 2, 2)
def test_sound_and_action_modules_follow_config() -> None:
from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import Cosmos3VFMTransformer
@@ -0,0 +1,728 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for multi-resolution support in HunyuanImage3.
Covers three areas:
1. ``ResolutionGroup._calc_by_step`` — training-bucket generation,
verifying that each resolution has correct width×height and that
indices match expected ratio ordering.
2. ``HUNYUAN_IMAGE3_EXTRA_RESOLUTIONS`` — extra resolution matching
via ``Resolution.append`` and new-entry insertion in
``ResolutionGroup.__init__``.
3. ``get_cached_resolution_group`` and ``ar2diffusion`` — caching
behaviour and correct ``ResolutionGroup`` construction for the
AR→DiT stage transition.
"""
from __future__ import annotations
import math
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_image3_transformer import (
HUNYUAN_IMAGE3_EXTRA_RESOLUTIONS,
Resolution,
ResolutionGroup,
get_cached_resolution_group,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _make_reso(desc: str | tuple[int, int]) -> Resolution:
"""Build a ``Resolution`` from a ``"HxW"`` string or ``(h, w)`` tuple."""
if isinstance(desc, str):
h, w = desc.split("x")
return Resolution(int(h), int(w))
return Resolution(desc[0], desc[1])
def _reso_set(rg: ResolutionGroup) -> set[tuple[int, int]]:
"""Return all ``(h, w)`` tuples reachable through a ``ResolutionGroup``.
For entries that carry ``extra_res`` this includes every alternative
resolution in that set.
"""
out: set[tuple[int, int]] = set()
for r in rg.data:
out.add((r.h, r.w))
out |= r.extra_res
return out
# ---------------------------------------------------------------------------
# 1. _calc_by_step — training-bucket resolution parsing
# ---------------------------------------------------------------------------
_BASE = 1024
_STEP = _BASE // 16 # 64
_ALIGN = 16
_MIN_SIDE = _BASE // 2 # 512
_MAX_SIDE = _BASE * 2 # 2048
def _build_plain_group(
base_size: int = _BASE,
step: int | None = None,
align: int = _ALIGN,
) -> ResolutionGroup:
"""Return a ``ResolutionGroup`` with *no* extra resolutions."""
return ResolutionGroup(base_size=base_size, step=step, align=align, extra_resolutions=None)
class TestCalcByStep:
"""Verify ``_calc_by_step`` generates the expected training buckets."""
# -- count & range -------------------------------------------------------
def test_count_for_base_1024(self):
"""33 buckets: 1 centre + 2×16 from the stepping loops."""
rg = _build_plain_group()
# 1 centre + 16 portrait + 16 landscape
assert len(rg.data) == 33, f"expected 33 training buckets, got {len(rg.data)}"
def test_all_sizes_within_bounds(self):
rg = _build_plain_group()
for r in rg.data:
assert _MIN_SIDE <= r.h <= _MAX_SIDE, f"height {r.h} outside [{_MIN_SIDE}, {_MAX_SIDE}]"
assert _MIN_SIDE <= r.w <= _MAX_SIDE, f"width {r.w} outside [{_MIN_SIDE}, {_MAX_SIDE}]"
def test_all_sizes_aligned(self):
rg = _build_plain_group()
for r in rg.data:
assert r.h % _ALIGN == 0, f"height {r.h} not {_ALIGN}-aligned"
assert r.w % _ALIGN == 0, f"width {r.w} not {_ALIGN}-aligned"
# -- ratio ordering ------------------------------------------------------
def test_sorted_by_ratio_ascending(self):
rg = _build_plain_group()
ratios = [r.ratio for r in rg.data]
for i in range(1, len(ratios)):
assert ratios[i - 1] <= ratios[i], f"ratio[{i - 1}]={ratios[i - 1]:.4f} > ratio[{i}]={ratios[i]:.4f}"
def test_ratio_index_matches_numpy_array(self):
rg = _build_plain_group()
for i, r in enumerate(rg.data):
assert math.isclose(rg.ratio[i], r.ratio, rel_tol=1e-9), (
f"rg.ratio[{i}]={rg.ratio[i]:.6f} != data[{i}].ratio={r.ratio:.6f}"
)
# -- key index positions -------------------------------------------------
@pytest.mark.parametrize(
"idx,expected_hw",
[
# First (most portrait) → 1:4 ratio bucket
(0, (512, 2048)),
# Centre square bucket
(16, (1024, 1024)),
# Most landscape bucket → 4:1 ratio
(32, (2048, 512)),
],
)
def test_landmark_index(self, idx, expected_hw):
rg = _build_plain_group()
r = rg.data[idx]
assert (r.h, r.w) == expected_hw, f"idx {idx}: expected {expected_hw}, got ({r.h}, {r.w})"
def test_center_is_square(self):
rg = _build_plain_group()
r = rg.data[16]
assert r.h == r.w == _BASE
def test_first_and_last_are_extremes(self):
rg = _build_plain_group()
# first → 512×2048 (ratio 0.25)
assert rg.data[0].ratio < 0.3
# last → 2048×512 (ratio 4.0)
assert rg.data[-1].ratio > 3.9
# -- width × height matching ---------------------------------------------
def test_every_resolution_has_same_area_product(self):
"""Symmetry: stepping loop produces the same product set in both
directions, so (h × w) tuples should appear symmetrically."""
rg = _build_plain_group()
products = {(r.h, r.w) for r in rg.data}
# For every (h, w) there should exist (w, h) or a nearby equivalent
for h, w in products:
if h == w:
continue # square — trivially symmetric
assert (w, h) in products or any(abs(rr.h - w) <= _STEP and abs(rr.w - h) <= _STEP for rr in rg.data), (
f"no symmetric counterpart for {h}×{w}"
)
def test_product_of_wh_divisible_by_256(self):
"""Each bucket's area should be a multiple of 256 (16×16),
which is the VAE down-sample grid."""
rg = _build_plain_group()
for r in rg.data:
assert (r.h * r.w) % 256 == 0, f"{r.h}×{r.w} = {r.h * r.w} not divisible by 256"
# -- custom base_size / step / align -------------------------------------
@pytest.mark.parametrize("base_size", [512, 768, 1280])
def test_count_scales_with_base_size(self, base_size):
step = base_size // 16
rg = ResolutionGroup(base_size=base_size, step=step, align=16, extra_resolutions=None)
expected = 1 + 2 * (base_size // step) # centre + 2×phases
assert len(rg.data) == expected, f"base_size={base_size}: expected {expected}, got {len(rg.data)}"
def test_custom_step(self):
"""A smaller step produces more buckets."""
rg_small = ResolutionGroup(base_size=1024, step=32, align=16, extra_resolutions=None)
rg_default = _build_plain_group()
assert len(rg_small.data) > len(rg_default.data), "smaller step should produce more buckets"
# ---------------------------------------------------------------------------
# 2. HUNYUAN_IMAGE3_EXTRA_RESOLUTIONS matching
# ---------------------------------------------------------------------------
_EXTRA_SQUARES = {"512x512", "640x640", "768x768", "896x896"}
_EXTRA_NON_SQUARES = {"1024x768", "1280x720", "768x1024", "720x1280"}
def _build_extra_group() -> ResolutionGroup:
return ResolutionGroup(
base_size=_BASE,
extra_resolutions=[_make_reso(s) for s in HUNYUAN_IMAGE3_EXTRA_RESOLUTIONS],
)
class TestExtraResolutionMatching:
"""Verify extra resolutions are correctly integrated into
the ``ResolutionGroup``."""
# -- square extras (ratio = 1.0) -----------------------------------------
def test_square_extras_appended_to_base_square(self):
"""512², 640², 768², 896² all have ratio 1.0 and should be
appended to the (1024, 1024) Resolution via ``append()``."""
rg = _build_extra_group()
# Find the 1024×1024 entry
square_reso = next(r for r in rg.data if r.h == 1024 and r.w == 1024)
expected_extra = {(512, 512), (640, 640), (768, 768), (896, 896)}
assert square_reso.extra_res == expected_extra, f"square extra_res: {square_reso.extra_res}"
def test_base_1024_unchanged(self):
"""The 1024×1024 entry itself still reports h=1024, w=1024."""
rg = _build_extra_group()
r = next(r for r in rg.data if r.h == 1024 and r.w == 1024)
assert r.h == 1024 and r.w == 1024
def test_squares_not_standalone_entries(self):
"""Square extras must NOT appear as top-level ``Resolution`` entries;
they live inside ``extra_res`` only."""
rg = _build_extra_group()
standalone = {(r.h, r.w) for r in rg.data}
for square in [(512, 512), (640, 640), (768, 768), (896, 896)]:
assert square not in standalone, f"{square} should be in extra_res, not a standalone entry"
# -- non-square extras (ratio ≠ 1.0) ------------------------------------
@pytest.mark.parametrize("reso_str", sorted(_EXTRA_NON_SQUARES))
def test_non_square_extra_is_standalone_entry(self, reso_str):
"""1024×768, 1280×720, 768×1024, 720×1280 are appended as
new ``Resolution`` entries because no training bucket shares
their exact ratio."""
r = _make_reso(reso_str)
rg = _build_extra_group()
matching = [d for d in rg.data if d.h == r.h and d.w == r.w]
assert len(matching) == 1, f"{reso_str} should be in rg.data exactly once"
assert matching[0].extra_res == set(), f"{reso_str} should have empty extra_res, got {matching[0].extra_res}"
def test_non_square_extras_empty_extra_res(self):
"""Non-square extras have no own extra_res."""
rg = _build_extra_group()
for r in rg.data:
if (r.h, r.w) in {(1024, 768), (1280, 720), (768, 1024), (720, 1280)}:
assert r.extra_res == set()
# -- total count ---------------------------------------------------------
def test_total_count(self):
"""33 training + 4 non-square extras = 37."""
rg = _build_extra_group()
assert len(rg.data) == 37, f"expected 37, got {len(rg.data)}"
def test_extra_count(self):
"""Exactly 8 extra resolutions (4 square + 4 non-square)."""
rg = _build_extra_group()
reso_set = _reso_set(rg)
extra_in_set = reso_set - {(r.h, r.w) for r in _build_plain_group().data}
assert len(extra_in_set) == 8, f"expected 8 extra, got {len(extra_in_set)}: {extra_in_set}"
# -- index stability -----------------------------------------------------
def test_indices_0_32_unchanged(self):
"""Indices 0–32 remain the training buckets (sorted by ratio)."""
plain = _build_plain_group()
rg = _build_extra_group()
for i in range(33):
assert (rg.data[i].h, rg.data[i].w) == (plain.data[i].h, plain.data[i].w), (
f"idx {i}: extra group has ({rg.data[i].h},{rg.data[i].w}), "
f"expected ({plain.data[i].h},{plain.data[i].w})"
)
@pytest.mark.parametrize(
"reso_str,expected_idx",
[
("1024x768", 33),
("1280x720", 34),
("768x1024", 35),
("720x1280", 36),
],
)
def test_non_square_extra_indices(self, reso_str, expected_idx):
"""Non-square extras are appended in order at indices 33-36."""
r = _make_reso(reso_str)
rg = _build_extra_group()
idx = next(i for i, d in enumerate(rg.data) if d.h == r.h and d.w == r.w)
assert idx == expected_idx, f"{reso_str}: expected idx {expected_idx}, got {idx}"
# -- ratio lookup via get_base_size_and_ratio_index ----------------------
@pytest.mark.parametrize(
"h,w,expected_idx",
[
(1024, 768, 33),
(1280, 720, 34),
(768, 1024, 35),
(720, 1280, 36),
(1024, 1024, 16),
(512, 512, 16),
(640, 640, 16),
(768, 768, 16),
(896, 896, 16),
],
)
def test_ratio_index_for_resolutions(self, h, w, expected_idx):
"""``get_base_size_and_ratio_index`` must return the correct
ratio index for both training-bucket and extra resolutions."""
rg = _build_extra_group()
_, idx = rg.get_base_size_and_ratio_index(w, h)
assert idx == expected_idx, f"({w},{h}): expected idx {expected_idx}, got {idx}"
# ---------------------------------------------------------------------------
# Resolution.match — area-based matching within a ratio group
# ---------------------------------------------------------------------------
class TestResolutionMatch:
"""Verify ``Resolution.match`` picks the best-fit alternative from
``extra_res`` based on area proximity."""
def test_no_extra_returns_self(self):
r = _make_reso("1024x768")
assert r.match(800, 600) == (768, 1024)
@pytest.mark.parametrize(
"target_w,target_h,expected",
[
# Exact area match in extra_res
(512, 512, (512, 512)),
(640, 640, (640, 640)),
(768, 768, (768, 768)),
(896, 896, (896, 896)),
# Closer to 1024² than to any extra square
(1024, 1024, (1024, 1024)),
(1000, 1000, (1024, 1024)),
# Small square picks 512²
(256, 256, (512, 512)),
(400, 400, (512, 512)),
# 700² → closest to 640² (diff 129600) vs 768² (diff 99856)
# 768*768 - 700*700 = 589824 - 490000 = 99824
# 640*640 - 700*700 = 409600 - 490000 = -80400 → abs=80400
# So 640² is closer in area
(700, 700, (640, 640)),
# 800² → closest to 768² (diff 49984) vs 896² (diff 162944)
(800, 800, (768, 768)),
],
)
def test_square_extra_match(self, target_w, target_h, expected):
"""Build a Resolution like the 1024² entry with square extras,
then verify area-based matching."""
r = Resolution(1024, 1024)
for s in [(512, 512), (640, 640), (768, 768), (896, 896)]:
r.extra_res.add(s)
result = r.match(target_w, target_h)
assert result == expected, f"match({target_w},{target_h}) = {result}, expected {expected}"
def test_match_returns_tuple(self):
r = Resolution(1024, 1024)
r.extra_res.add((512, 512))
result = r.match(512, 512)
assert isinstance(result, tuple)
assert len(result) == 2
assert all(isinstance(v, int) for v in result)
def test_match_does_not_mutate_resolution(self):
r = Resolution(1024, 1024)
r.extra_res.add((512, 512))
extra_before = set(r.extra_res)
r.match(300, 300)
assert r.extra_res == extra_before
# ---------------------------------------------------------------------------
# ResolutionGroup.get_target_size — end-to-end resolution selection
# ---------------------------------------------------------------------------
class TestGetTargetSize:
"""Verify ``get_target_size`` uses ratio-then-area logic correctly."""
@pytest.fixture(scope="class")
def rg(self) -> ResolutionGroup:
return _build_extra_group()
@pytest.mark.parametrize(
"w,h,expected_min_area,expected_max_area",
[
# Square targets should land on square variants
(1024, 1024, 1024 * 1024, 1024 * 1024),
(512, 512, 512 * 512, 512 * 512),
(640, 640, 640 * 640, 640 * 640),
(768, 768, 768 * 768, 768 * 768),
(896, 896, 896 * 896, 896 * 896),
],
)
def test_square_target_returns_square(self, rg, w, h, expected_min_area, expected_max_area):
rw, rh = rg.get_target_size(w, h)
area = rw * rh
assert expected_min_area <= area <= expected_max_area, f"({w},{h}) → ({rw},{rh}) area={area}"
def test_training_bucket_self_maps(self, rg):
"""A training bucket should map to itself (or an extra_res variant
with the same ratio)."""
for r in rg.data:
rw, rh = rg.get_target_size(r.w, r.h)
# Either exact match or the ratio must be preserved
assert abs(rh / rw - r.ratio) < 0.01, f"({r.w},{r.h}) → ({rw},{rh}) ratio drifted from {r.ratio:.4f}"
def test_wild_ratio_still_finds_bucket(self, rg):
"""Even an unusual aspect ratio should map to some valid bucket."""
for w, h in [(800, 600), (1200, 900), (1600, 400), (400, 1600)]:
rw, rh = rg.get_target_size(w, h)
assert rw > 0 and rh > 0
assert rw % 16 == 0 and rh % 16 == 0
# ---------------------------------------------------------------------------
# 3. get_cached_resolution_group & ar2diffusion integration
# ---------------------------------------------------------------------------
class TestGetCachedResolutionGroup:
"""Verify ``get_cached_resolution_group`` returns a correctly
configured ``ResolutionGroup`` and respects the LRU cache."""
def test_returns_resolution_group(self):
rg = get_cached_resolution_group(base_size=1024)
assert isinstance(rg, ResolutionGroup)
def test_base_size_propagated(self):
rg = get_cached_resolution_group(base_size=1024)
assert rg.base_size == 1024
def test_has_extra_resolutions(self):
rg = get_cached_resolution_group(base_size=1024)
reso_set = _reso_set(rg)
for s in HUNYUAN_IMAGE3_EXTRA_RESOLUTIONS:
r = _make_reso(s)
assert (r.h, r.w) in reso_set, f"{s} missing from cached group"
def test_cache_same_base_size_same_object(self):
"""LRU cache: same base_size → same object identity."""
# Clear any prior cache entries
get_cached_resolution_group.cache_clear()
rg1 = get_cached_resolution_group(base_size=1024)
rg2 = get_cached_resolution_group(base_size=1024)
assert rg1 is rg2, "same base_size must return cached object"
def test_cache_different_base_size_different_object(self):
get_cached_resolution_group.cache_clear()
rg1 = get_cached_resolution_group(base_size=1024)
rg2 = get_cached_resolution_group(base_size=768)
assert rg1 is not rg2, "different base_size must return different objects"
def test_cache_size_limit(self):
"""Cache is bounded at 4 entries (``maxsize=4``)."""
get_cached_resolution_group.cache_clear()
for bs in [512, 768, 1024, 1280, 1536]:
get_cached_resolution_group(base_size=bs)
info = get_cached_resolution_group.cache_info()
assert info.currsize <= 4, f"cache grew to {info.currsize}, max is 4"
# All 5 unique base_size values caused cache misses
assert info.misses >= 5, f"expected >=5 misses for 5 unique base_size values, got {info.misses}"
def test_resolution_indexing_works(self):
"""Indices 0..len(rg)-1 must be accessible."""
rg = get_cached_resolution_group(base_size=1024)
for i in range(len(rg)):
r = rg[i]
assert isinstance(r, Resolution)
assert r.h > 0 and r.w > 0
@pytest.mark.parametrize("base_size", [512, 1024])
def test_ratio_index_roundtrip_via_group_length(self, base_size):
"""Every ratio index in [0, len(rg)) must resolve to a valid
Resolution with height/width within bounds."""
rg = get_cached_resolution_group(base_size=base_size)
for i in range(len(rg)):
r = rg[i]
assert isinstance(r.height, int) and r.height > 0
assert isinstance(r.width, int) and r.width > 0
# ---------------------------------------------------------------------------
# ar2diffusion — integration tests
# ---------------------------------------------------------------------------
# Token IDs matching the real prompt_utils values
_RATIO_0 = 128044
_RATIO_32 = 128076
_RATIO_33 = 130103
_RATIO_36 = 130106
def _fake_ar_output(
*,
cumulative_token_ids: list[int] | None = None,
text: str = "",
) -> MagicMock:
"""Minimal stub for a vLLM ``RequestOutput`` with one completion."""
choice = MagicMock()
choice.cumulative_token_ids = cumulative_token_ids or []
choice.text = text
output = MagicMock()
output.outputs = [choice]
return output
class TestAr2DiffusionIntegration:
"""Verify that ``ar2diffusion`` calls ``get_cached_resolution_group``
and obtains a correctly configured ``ResolutionGroup`` whose indices map
to the expected training-bucket or extra-resolution sizes."""
def _call_ar2diffusion(
self,
token_ids: list[int],
prompt_kwargs: dict[str, Any] | None = None,
) -> tuple[Any, Any]:
"""Invoke ``ar2diffusion`` with a single fake AR output.
Returns ``(diffusion_inputs, ar_predicted_sizes)`` so callers can
assert on the resolved height/width.
"""
from vllm_omni.model_executor.stage_input_processors.hunyuan_image3 import (
ar2diffusion,
)
pk = dict(prompt_kwargs or {})
pk.setdefault("prompt", "a beautiful sunset")
pk.setdefault("height", 1024)
pk.setdefault("width", 1024)
pk.setdefault("image_base_size", 1024)
# Build a prompt-like object that behaves like the real OmniTokensPrompt
class _FakePrompt:
def __init__(self, d):
self._d = d
def __getitem__(self, key):
return self._d[key]
def get(self, key, default=None):
return self._d.get(key, default)
def _asdict(self):
return dict(self._d)
prompt = _FakePrompt(pk)
ar_output = _fake_ar_output(cumulative_token_ids=token_ids)
result = ar2diffusion(
source_outputs=[ar_output],
prompt=[prompt],
requires_multimodal_data=False,
)
return result
# -- ratio token ↔ resolution resolution ---------------------------------
@pytest.mark.parametrize(
"ratio_idx,expected_hw",
[
# Training buckets
(0, (512, 2048)),
(16, (1024, 1024)),
(32, (2048, 512)),
# Non-square extras
(33, (1024, 768)),
(34, (1280, 720)),
(35, (768, 1024)),
(36, (720, 1280)),
],
)
def test_ratio_index_resolves_to_correct_size(self, ratio_idx, expected_hw):
"""When the AR emits ``<img_ratio_N>``, the DiT input must carry
the training-bucket width/height for that index."""
rg = get_cached_resolution_group(base_size=1024)
reso = rg[ratio_idx]
assert (reso.height, reso.width) == expected_hw, (
f"ratio_idx {ratio_idx}: expected {expected_hw}, got ({reso.height}, {reso.width})"
)
@pytest.mark.parametrize("ratio_idx", [0, 16, 32, 33, 34, 35, 36])
def test_ar2diffusion_extracts_height_width(self, ratio_idx):
"""End-to-end: simulate AR output containing ``<img_ratio_{idx}>``
and assert the DiT inputs carry the expected size."""
rg = get_cached_resolution_group(base_size=1024)
expected_h = rg[ratio_idx].height
expected_w = rg[ratio_idx].width
token_id = _RATIO_0 + ratio_idx if ratio_idx <= 32 else _RATIO_33 + (ratio_idx - 33)
result = self._call_ar2diffusion(
token_ids=[150000, 150001, token_id],
prompt_kwargs={"height": 1024, "width": 1024, "image_base_size": 1024},
)
assert len(result) == 1
diffusion_input = result[0]
assert diffusion_input["height"] == expected_h, (
f"ratio_idx={ratio_idx}: expected height {expected_h}, got {diffusion_input['height']}"
)
assert diffusion_input["width"] == expected_w, (
f"ratio_idx={ratio_idx}: expected width {expected_w}, got {diffusion_input['width']}"
)
def test_no_ratio_token_falls_back_to_prompt_size(self):
"""If no ratio token is present, height/width from the prompt
are kept unchanged."""
result = self._call_ar2diffusion(
token_ids=[150000, 150001], # no ratio tokens
prompt_kwargs={"height": 768, "width": 1024, "image_base_size": 1024},
)
assert len(result) == 1
diffusion_input = result[0]
assert diffusion_input["height"] == 768
assert diffusion_input["width"] == 1024
def test_out_of_range_ratio_index_falls_back(self):
"""If ratio_idx ≥ len(reso_group), ar2diffusion must keep the
prompt size (fallback path)."""
with patch(
"vllm_omni.model_executor.stage_input_processors.hunyuan_image3._extract_ratio_index",
return_value=999, # out of range
):
result = self._call_ar2diffusion(
token_ids=[150000],
prompt_kwargs={"height": 512, "width": 512, "image_base_size": 1024},
)
assert len(result) == 1
assert result[0]["height"] == 512
assert result[0]["width"] == 512
# -- cache behaviour -----------------------------------------------------
def test_ar2diffusion_uses_cached_group(self):
"""Each invocation of ``ar2diffusion`` calls
``get_cached_resolution_group``, which returns a shared cached object."""
get_cached_resolution_group.cache_clear()
rg_before = get_cached_resolution_group(base_size=1024)
token_id = _RATIO_0 + 16 # <img_ratio_16> = 1024×1024
self._call_ar2diffusion(
token_ids=[token_id],
prompt_kwargs={"height": 1024, "width": 1024, "image_base_size": 1024},
)
rg_after = get_cached_resolution_group(base_size=1024)
assert rg_before is rg_after, "get_cached_resolution_group should return the same cached object"
def test_correct_reso_group_used_for_base_size(self):
"""Verify ``ar2diffusion`` constructs or retrieves the
ResolutionGroup for the correct ``image_base_size``."""
get_cached_resolution_group.cache_clear()
# First request with a non-default base_size
token_id = _RATIO_0 # <img_ratio_0>
self._call_ar2diffusion(
token_ids=[token_id],
prompt_kwargs={"height": 512, "width": 512, "image_base_size": 512},
)
# The cached group for base_size=512 should exist
rg512 = get_cached_resolution_group(base_size=512)
assert rg512.base_size == 512
# And base_size=1024 should also be separately accessible
rg1024 = get_cached_resolution_group(base_size=1024)
assert rg1024.base_size == 1024
assert rg512 is not rg1024
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
class TestEdgeCases:
def test_resolution_string_parsing(self):
"""Resolution accepts both "HxW" strings and (h, w) tuples."""
r1 = Resolution("512x2048")
assert r1.h == 512 and r1.w == 2048
r2 = Resolution(768, 1024)
assert r2.h == 768 and r2.w == 1024
r3 = Resolution(1024) # single int → square
assert r3.h == 1024 and r3.w == 1024
def test_resolution_extra_res_initially_empty(self):
r = Resolution(1024, 1024)
assert r.extra_res == set()
assert r.match(512, 512) == (1024, 1024)
def test_resolution_append_deduplicates(self):
"""Appending the same resolution twice is a no-op on the set."""
r = Resolution(1024, 1024)
r.append(Resolution(512, 512))
r.append(Resolution(512, 512))
assert len(r.extra_res) == 1
def test_all_final_resolutions_are_16_aligned(self):
"""Every reachable resolution (including extras) must be
divisible by the VAE down-sample factor of 16."""
rg = _build_extra_group()
for h, w in _reso_set(rg):
assert h % 16 == 0, f"height {h} not 16-aligned in {h}×{w}"
assert w % 16 == 0, f"width {w} not 16-aligned in {h}×{w}"
def test_ratio_array_length_matches_data(self):
rg = _build_extra_group()
assert len(rg.ratio) == len(rg.data)
def test_resolution_repr(self):
r = Resolution(1024, 1024)
rep = repr(r)
assert "1024x1024" in rep
r2 = Resolution(1024, 1024)
r2.extra_res.add((512, 512))
rep2 = repr(r2)
assert "512x512" in rep2
@@ -145,7 +145,7 @@ def test_shm_connector_flow(mocker: MockerFixture):
Using real SharedMemoryConnector (inline mode for simplicity).
"""
# 1. Setup Connector
config = {"shm_threshold_bytes": 1024} # Large threshold to use inline
config = {"shm_threshold_bytes": 1024, "inline_small_payloads": True}
connector = SharedMemoryConnector(config)
connectors_map = {("0", "1"): connector}
@@ -75,18 +75,21 @@ def test_create_unknown_connector():
@pytest.fixture
def shm_connector():
config = {"shm_threshold_bytes": 100} # Small threshold for testing
config = {"shm_threshold_bytes": 100, "inline_small_payloads": True}
return SharedMemoryConnector(config)
def test_put_get_inline(shm_connector):
"""Test transfer for small data (currently always uses SHM)."""
"""Test inline transfer for small data."""
data = {"small": "data"}
success, size, metadata = shm_connector.put("stage_0", "stage_1", "req_1", data)
assert success is True
assert "shm" in metadata
assert "inline_bytes" in metadata
assert "shm" not in metadata
assert "size" in metadata
assert shm_connector._metrics["inline_writes"] == 1
assert shm_connector._metrics["shm_writes"] == 0
# Retrieve
retrieved_data, ret_size = shm_connector.get("stage_0", "stage_1", "req_1", metadata)
@@ -13,7 +13,7 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@pytest.fixture()
def connector():
c = SharedMemoryConnector({"shm_threshold_bytes": 64})
c = SharedMemoryConnector({"shm_threshold_bytes": 0})
yield c
c.close()
@@ -161,3 +161,113 @@ async def test_diffusion_error_output_routed_as_finished(orchestrator_factory) -
finally:
orchestrator_fixture.request_sync_q.put_nowait(ShutdownRequestMessage())
orchestrator_fixture.thread.join(timeout=5)
@pytest.mark.asyncio
async def test_diffusion_client_error_output_propagates_status_code(orchestrator_factory) -> None:
"""A client-error OmniRequestOutput (e.g. a guardrail 4xx) from a diffusion
stage must surface as an ErrorMessage that carries status_code/error_type,
so the frontend can map it to the correct HTTP response instead of a 500.
"""
stage0 = FakeStageClient(stage_type="diffusion", final_output=True, final_output_type="image")
orchestrator_fixture = orchestrator_factory([stage0])
params = OmniDiffusionSamplingParams()
try:
await _enqueue_add_request(
orchestrator_fixture,
request_id="req-blocked",
prompt={"prompt": "blocked"},
original_prompt={"prompt": "blocked"},
sampling_params_list=[params],
final_stage_id=0,
)
await _wait_for(lambda: len(stage0.add_request_calls) == 1)
# Push a client-error output from the diffusion stage.
stage0.push_diffusion_output(
OmniRequestOutput.from_error(
"req-blocked",
"Input was blocked by Cosmos3 guardrails.",
status_code=400,
error_type="BadRequestError",
)
)
msg = await _get_any_output_message(orchestrator_fixture)
assert isinstance(msg, ErrorMessage)
assert msg.request_id == "req-blocked"
assert msg.stage_id == 0
assert msg.fatal is False
assert msg.status_code == 400
assert msg.error_type == "BadRequestError"
assert msg.error == "Input was blocked by Cosmos3 guardrails."
# Request state should be cleaned up.
await _wait_for(lambda: "req-blocked" not in orchestrator_fixture.orchestrator.request_states)
finally:
orchestrator_fixture.request_sync_q.put_nowait(ShutdownRequestMessage())
orchestrator_fixture.thread.join(timeout=5)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("status_code", "error_type"),
[
pytest.param(429, "RateLimitError", id="429-too-many-requests"),
pytest.param(413, "PayloadTooLargeError", id="413-payload-too-large"),
pytest.param(422, "UnprocessableEntityError", id="422-unprocessable-entity"),
pytest.param(403, "PermissionDeniedError", id="403-forbidden"),
],
)
async def test_diffusion_client_error_output_propagates_non_400_status(
orchestrator_factory,
status_code: int,
error_type: str,
) -> None:
"""A diffusion-stage client error with a non-400 4xx status must be serialized
into an ErrorMessage that preserves that exact status_code/error_type.
"""
stage0 = FakeStageClient(stage_type="diffusion", final_output=True, final_output_type="image")
orchestrator_fixture = orchestrator_factory([stage0])
params = OmniDiffusionSamplingParams()
try:
await _enqueue_add_request(
orchestrator_fixture,
request_id="req-blocked",
prompt={"prompt": "blocked"},
original_prompt={"prompt": "blocked"},
sampling_params_list=[params],
final_stage_id=0,
)
await _wait_for(lambda: len(stage0.add_request_calls) == 1)
# Push a non-400 client-error output from the diffusion stage.
stage0.push_diffusion_output(
OmniRequestOutput.from_error(
"req-blocked",
"client error from diffusion stage",
status_code=status_code,
error_type=error_type,
)
)
msg = await _get_any_output_message(orchestrator_fixture)
assert isinstance(msg, ErrorMessage)
assert msg.request_id == "req-blocked"
assert msg.stage_id == 0
assert msg.fatal is False
assert msg.status_code == status_code
assert msg.error_type == error_type
assert msg.error == "client error from diffusion stage"
# Request state should be cleaned up.
await _wait_for(lambda: "req-blocked" not in orchestrator_fixture.orchestrator.request_states)
finally:
orchestrator_fixture.request_sync_q.put_nowait(ShutdownRequestMessage())
orchestrator_fixture.thread.join(timeout=5)
@@ -0,0 +1,187 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for decode_audio_url helper."""
import asyncio
import base64
import os
import httpx
import pytest
from vllm_omni.entrypoints.openai.errors import InvalidInputReferenceError
from vllm_omni.entrypoints.openai.video_api_utils import decode_audio_url
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _run(coro):
return asyncio.run(coro)
def _cleanup(path):
if path and os.path.exists(path):
os.unlink(path)
class TestDecodeAudioUrlDataUrl:
"""Tests for base64 data URL decoding."""
def test_valid_mp3_data_url(self):
audio_bytes = b"\xff\xfb\x90\x00" * 100
b64 = base64.b64encode(audio_bytes).decode()
url = f"data:audio/mp3;base64,{b64}"
path = _run(decode_audio_url(url))
try:
assert os.path.isfile(path)
assert path.endswith(".mp3")
with open(path, "rb") as f:
assert f.read() == audio_bytes
finally:
_cleanup(path)
def test_valid_wav_data_url(self):
audio_bytes = b"RIFF" + b"\x00" * 100
b64 = base64.b64encode(audio_bytes).decode()
url = f"data:audio/wav;base64,{b64}"
path = _run(decode_audio_url(url))
try:
assert path.endswith(".wav")
with open(path, "rb") as f:
assert f.read() == audio_bytes
finally:
_cleanup(path)
def test_valid_mpeg_data_url_uses_mp3_suffix(self):
audio_bytes = b"\xff\xfb\x90\x00" * 50
b64 = base64.b64encode(audio_bytes).decode()
url = f"data:audio/mpeg;base64,{b64}"
path = _run(decode_audio_url(url))
try:
assert path.endswith(".mp3")
finally:
_cleanup(path)
def test_valid_ogg_data_url(self):
audio_bytes = b"OggS" + b"\x00" * 100
b64 = base64.b64encode(audio_bytes).decode()
url = f"data:audio/ogg;base64,{b64}"
path = _run(decode_audio_url(url))
try:
assert path.endswith(".ogg")
finally:
_cleanup(path)
def test_invalid_base64_raises(self):
url = "data:audio/mp3;base64,!!!not-valid-base64!!!"
with pytest.raises(InvalidInputReferenceError, match="not valid base64"):
_run(decode_audio_url(url))
def test_empty_audio_data_raises(self):
url = "data:audio/mp3;base64,"
with pytest.raises(InvalidInputReferenceError, match="audio data is empty"):
_run(decode_audio_url(url))
class TestDecodeAudioUrlSuffixSanitization:
"""Tests for MIME extension sanitization (path traversal prevention)."""
def test_path_traversal_in_mime_is_neutralized(self):
audio_bytes = b"\x00" * 100
b64 = base64.b64encode(audio_bytes).decode()
url = f"data:audio/../..;base64,{b64}"
path = _run(decode_audio_url(url))
try:
# ".." contains non-alnum dots, so falls back to .wav
assert path.endswith(".wav")
assert os.path.dirname(path) == "/tmp"
finally:
_cleanup(path)
def test_non_alnum_extension_falls_back_to_wav(self):
audio_bytes = b"\x00" * 100
b64 = base64.b64encode(audio_bytes).decode()
url = f"data:audio/foo-bar;base64,{b64}"
path = _run(decode_audio_url(url))
try:
assert path.endswith(".wav")
finally:
_cleanup(path)
def test_too_long_extension_falls_back_to_wav(self):
audio_bytes = b"\x00" * 100
b64 = base64.b64encode(audio_bytes).decode()
url = f"data:audio/verylongextension;base64,{b64}"
path = _run(decode_audio_url(url))
try:
assert path.endswith(".wav")
finally:
_cleanup(path)
def test_valid_short_alnum_extension_is_used(self):
audio_bytes = b"\x00" * 100
b64 = base64.b64encode(audio_bytes).decode()
url = f"data:audio/flac;base64,{b64}"
path = _run(decode_audio_url(url))
try:
assert path.endswith(".flac")
finally:
_cleanup(path)
class TestDecodeAudioUrlHttp:
"""Tests for HTTP URL decoding."""
def test_valid_http_url(self, monkeypatch):
audio_bytes = b"\xff\xfb\x90\x00" * 50
fake_resp = httpx.Response(200, content=audio_bytes)
async def _mock_get(self, url):
return fake_resp
monkeypatch.setattr(httpx.AsyncClient, "get", _mock_get)
monkeypatch.setattr(httpx.Response, "raise_for_status", lambda self: None)
path = _run(decode_audio_url("https://example.com/audio.mp3"))
try:
assert os.path.isfile(path)
assert path.endswith(".wav")
with open(path, "rb") as f:
assert f.read() == audio_bytes
finally:
_cleanup(path)
def test_http_error_raises(self, monkeypatch):
async def _mock_get(self, url):
raise httpx.HTTPStatusError("Not Found", request=httpx.Request("GET", url), response=httpx.Response(404))
monkeypatch.setattr(httpx.AsyncClient, "get", _mock_get)
with pytest.raises(InvalidInputReferenceError, match="failed to download audio"):
_run(decode_audio_url("https://example.com/missing.mp3"))
class TestDecodeAudioUrlInvalid:
"""Tests for invalid URL schemes."""
def test_local_file_path_raises(self):
with pytest.raises(InvalidInputReferenceError, match="must be an http.*URL or data URL"):
_run(decode_audio_url("/path/to/local/file.mp3"))
def test_ftp_url_raises(self):
with pytest.raises(InvalidInputReferenceError, match="must be an http.*URL or data URL"):
_run(decode_audio_url("ftp://example.com/file.mp3"))
def test_empty_string_raises(self):
with pytest.raises(InvalidInputReferenceError, match="must be an http.*URL or data URL"):
_run(decode_audio_url(""))
@@ -29,6 +29,7 @@ from vllm_omni.entrypoints.openai.image_api_utils import (
parse_size,
)
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
from vllm_omni.errors import GuardrailViolationError
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -587,6 +588,24 @@ def test_generate_single_image(test_client):
assert test_client.app.state.engine_client.captured_prompt["modalities"] == ["image"]
def test_generate_images_guardrail_error_returns_400(test_client, mock_async_diffusion):
async def blocked_generate(**kwargs):
raise GuardrailViolationError("Input was blocked by Cosmos3 guardrails.")
yield MockGenerationResult([]) # pragma: no cover
mock_async_diffusion.generate = blocked_generate
response = test_client.post(
"/v1/images/generations",
json={
"prompt": "blocked prompt",
"n": 1,
"size": "1024x1024",
},
)
assert response.status_code == 400
assert response.json()["detail"] == "Input was blocked by Cosmos3 guardrails."
def test_generate_images_async_omni_sampling_params(async_omni_test_client):
"""Test AsyncOmni path uses per-stage sampling params."""
response = async_omni_test_client.post(
@@ -891,6 +910,31 @@ def test_image_edits_streaming_errors_on_empty_final_image(streaming_image_edit_
assert payloads[2] == "[DONE]"
def test_image_edits_streaming_guardrail_error_uses_400(streaming_image_edit_client):
async def generate_guardrail_error(prompt, request_id, sampling_params=None, sampling_params_list=None, **kwargs):
raise GuardrailViolationError("Input was blocked by Cosmos3 guardrails.")
yield MockStageResult(stage_id=1, final_output_type="image", images=[]) # pragma: no cover
streaming_image_edit_client.app.state.engine_client.generate = generate_guardrail_error
img_bytes = make_test_image_bytes((16, 16))
response = streaming_image_edit_client.post(
"/v1/images/edits",
files=[("image", img_bytes)],
data={
"prompt": "blocked prompt",
"stream": "true",
},
)
assert response.status_code == 200
payloads = _parse_sse_payloads(response.text)
assert payloads[0]["object"] == "error"
assert payloads[0]["error"]["message"] == "Input was blocked by Cosmos3 guardrails."
assert payloads[0]["error"]["type"] == "BadRequestError"
assert payloads[0]["error"]["code"] == 400
assert payloads[1] == "[DONE]"
def test_image_edits_streaming_rejects_single_stage(test_client):
img_bytes = make_test_image_bytes((16, 16))
response = test_client.post(
@@ -31,6 +31,7 @@ from vllm_omni.entrypoints.openai.protocol.audio import (
CreateAudio,
OpenAICreateSpeechRequest,
SpeechBatchItem,
StreamingSpeechSessionConfig,
)
from vllm_omni.entrypoints.openai.serving_speech import (
_TTS_LANGUAGES,
@@ -1235,6 +1236,49 @@ class TestTTSMethods:
assert params["language"] == ["English"]
assert params["task_type"] == ["CustomVoice"]
def test_build_tts_params_base_non_streaming_mode_true(self, speech_server):
"""Base task should pass through an explicit non_streaming_mode override."""
req = OpenAICreateSpeechRequest(
input="Hello",
task_type="Base",
ref_audio="data:audio/wav;base64,abc",
ref_text="reference",
non_streaming_mode=True,
)
params = speech_server._build_tts_params(req)
assert params["task_type"] == ["Base"]
assert params["non_streaming_mode"] == [True]
def test_build_tts_params_base_omits_non_streaming_mode_by_default(self, speech_server):
"""Base task should keep using the model default when no override is sent."""
req = OpenAICreateSpeechRequest(
input="Hello",
task_type="Base",
ref_audio="data:audio/wav;base64,abc",
ref_text="reference",
)
params = speech_server._build_tts_params(req)
assert params["task_type"] == ["Base"]
assert "non_streaming_mode" not in params
def test_build_tts_params_explicit_non_streaming_mode_overrides_voicedesign_default(self, speech_server):
"""Explicit false should not be replaced by the VoiceDesign fallback."""
req = OpenAICreateSpeechRequest(
input="Hello",
task_type="VoiceDesign",
instructions="warm and calm",
non_streaming_mode=False,
)
params = speech_server._build_tts_params(req)
assert params["task_type"] == ["VoiceDesign"]
assert params["non_streaming_mode"] == [False]
def test_load_supported_speakers(self, mocker: MockerFixture):
"""Test _load_supported_speakers."""
mock_engine_client = mocker.MagicMock()
@@ -2023,6 +2067,34 @@ class TestMergeBatchItem:
assert merged.task_type == "CustomVoice"
assert merged.max_new_tokens == 512
def test_non_streaming_mode_batch_default_used(self):
"""Batch-level non_streaming_mode should be used when item doesn't specify one."""
batch = BatchSpeechRequest(
items=[SpeechBatchItem(input="hi")],
non_streaming_mode=True,
)
merged = OmniOpenAIServingSpeech._merge_batch_item(batch, batch.items[0])
assert merged.non_streaming_mode is True
def test_non_streaming_mode_item_override_wins(self):
"""Per-item false should override a true batch-level default."""
batch = BatchSpeechRequest(
items=[SpeechBatchItem(input="hi", non_streaming_mode=False)],
non_streaming_mode=True,
)
merged = OmniOpenAIServingSpeech._merge_batch_item(batch, batch.items[0])
assert merged.non_streaming_mode is False
def test_streaming_speech_session_config_accepts_non_streaming_mode():
config = StreamingSpeechSessionConfig(non_streaming_mode=True)
assert config.non_streaming_mode is True
class TestAsyncOmniSupportedTasks:
"""Test that AsyncOmni reports correct supported tasks based on output modalities."""
@@ -2811,6 +2883,52 @@ class TestTTSAsyncOffloading:
qwen3_tts_server._build_tts_params.assert_called_once()
qwen3_tts_server._estimate_prompt_len_async.assert_awaited_once()
def test_prepare_speech_generation_qwen3_voicedesign_non_streaming_mode_false(
self, qwen3_tts_server, mocker: MockerFixture
):
"""VoiceDesign explicit false should reach the model prompt additional_information."""
qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None)
qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
request = OpenAICreateSpeechRequest(
input="hello",
task_type="VoiceDesign",
instructions="warm and calm",
non_streaming_mode=False,
)
_request_id, _generator, tts_params = asyncio.run(qwen3_tts_server._prepare_speech_generation(request))
assert tts_params["task_type"] == ["VoiceDesign"]
assert tts_params["non_streaming_mode"] == [False]
prompt = qwen3_tts_server.engine_client.generate.call_args.kwargs["prompt"]
assert prompt["additional_information"] is tts_params
assert prompt["additional_information"]["non_streaming_mode"] == [False]
def test_prepare_speech_generation_qwen3_base_non_streaming_mode_true(
self, qwen3_tts_server, mocker: MockerFixture
):
"""Base explicit true should reach the model prompt additional_information."""
qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None)
qwen3_tts_server._resolve_ref_audio = mocker.AsyncMock(return_value=([0.0] * 48000, 24000))
qwen3_tts_server._get_resolved_ref_audio_artifact_key = mocker.MagicMock(return_value=None)
qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
request = OpenAICreateSpeechRequest(
input="hello",
task_type="Base",
ref_audio="data:audio/wav;base64,abc",
ref_text="reference transcript",
non_streaming_mode=True,
)
_request_id, _generator, tts_params = asyncio.run(qwen3_tts_server._prepare_speech_generation(request))
assert tts_params["task_type"] == ["Base"]
assert tts_params["ref_text"] == ["reference transcript"]
assert tts_params["non_streaming_mode"] == [True]
prompt = qwen3_tts_server.engine_client.generate.call_args.kwargs["prompt"]
assert prompt["additional_information"] is tts_params
assert prompt["additional_information"]["non_streaming_mode"] == [True]
def test_qwen3_repeated_ref_audio_hot_path_sends_cache_key_without_waveform(self, qwen3_tts_server):
"""After a ref artifact is marked ready, repeated requests avoid ref_audio payload IPC."""
wav_list = [0.0] * 48000
@@ -31,6 +31,7 @@ from vllm_omni.entrypoints.openai.protocol.videos import (
from vllm_omni.entrypoints.openai.serving_video import OmniOpenAIServingVideo
from vllm_omni.entrypoints.openai.storage import LocalStorageManager
from vllm_omni.entrypoints.openai.stores import AsyncDictStore, TaskRegistry
from vllm_omni.errors import GuardrailViolationError
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -86,8 +87,10 @@ class BlockingVideoHandler:
if self.stage_configs is None:
self.stage_configs = stage_configs
async def generate_video_bytes(self, request, reference_id, *, reference_image=None, reference_video=None):
del request, reference_id, reference_image, reference_video
async def generate_video_bytes(
self, request, reference_id, *, reference_image=None, reference_video=None, reference_audio=None
):
del request, reference_id, reference_image, reference_video, reference_audio
self.started.set()
try:
await asyncio.Future()
@@ -457,6 +460,41 @@ def test_cosmos3_reference_video_limit_caps_condition_frames_to_output_frames():
assert api_server._reference_video_decode_spec(request, _cosmos3_stage_configs()).max_frames == 5
def test_s2v_video_generation_with_audio_reference_form(test_client, mocker: MockerFixture):
"""Speech-to-video: image + audio_reference (base64 data URL) passes audio path to multi_modal_data."""
audio_bytes = b"\xff\xfb\x90\x00" * 50
audio_b64 = base64.b64encode(audio_bytes).decode()
audio_ref = json.dumps({"audio_url": f"data:audio/mp3;base64,{audio_b64}"})
mocker.patch(
"vllm_omni.entrypoints.openai.serving_video._encode_video_bytes",
return_value=b"fake-video",
)
response = test_client.post(
"/v1/videos",
data={
"prompt": "A person singing",
"audio_reference": audio_ref,
"width": "832",
"height": "480",
},
files={"input_reference": ("face.png", _make_test_image_bytes((64, 64)), "image/png")},
)
assert response.status_code == 200
video_id = response.json()["id"]
_wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
engine = test_client.app.state.openai_serving_video._engine_client
prompt = engine.captured_prompt
assert "multi_modal_data" in prompt
assert "image" in prompt["multi_modal_data"]
assert "audio" in prompt["multi_modal_data"]
audio_path = prompt["multi_modal_data"]["audio"]
assert isinstance(audio_path, str)
assert audio_path.endswith(".mp3")
def test_seconds_defaults_fps_and_frames(test_client, mocker: MockerFixture):
fps_values = []
@@ -982,6 +1020,25 @@ def test_invalid_lora_returns_400(test_client):
assert "lora object" in failed["error"]["message"].lower()
def test_async_guardrail_error_returns_400_on_retrieve(test_client, mocker: MockerFixture):
mocker.patch.object(
OmniOpenAIServingVideo,
"generate_video_bytes",
side_effect=GuardrailViolationError("Input was blocked by Cosmos3 guardrails."),
)
response = test_client.post("/v1/videos", data={"prompt": "blocked prompt"})
assert response.status_code == 200
video_id = response.json()["id"]
failed = _wait_for_status(test_client, video_id, VideoGenerationStatus.FAILED.value)
assert failed["error"]["code"] == 400
assert failed["error"]["message"] == "Input was blocked by Cosmos3 guardrails."
retrieve = test_client.get(f"/v1/videos/{video_id}")
assert retrieve.status_code == 400
assert retrieve.json()["error"]["code"] == 400
def test_unsupported_image_reference_file_id_returns_400(test_client):
response = test_client.post(
"/v1/videos",
@@ -1484,6 +1541,20 @@ def test_sync_generation_error_returns_500(test_client, mocker: MockerFixture):
assert "GPU exploded" in response.json()["detail"]
def test_sync_guardrail_error_returns_400(test_client, mocker: MockerFixture):
mocker.patch.object(
OmniOpenAIServingVideo,
"generate_video_bytes",
side_effect=GuardrailViolationError("Input was blocked by Cosmos3 guardrails."),
)
response = test_client.post(
"/v1/videos/sync",
data={"prompt": "blocked prompt"},
)
assert response.status_code == 400
assert response.json()["detail"] == "Input was blocked by Cosmos3 guardrails."
def test_sync_does_not_create_store_entry(test_client, mocker: MockerFixture):
"""The sync endpoint should NOT leave any record in VIDEO_STORE — it is
stateless by design."""
+205
View File
@@ -18,6 +18,12 @@ from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.entrypoints.client_request_state import ClientRequestState
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.entrypoints.omni_base import OmniEngineDeadError
from vllm_omni.errors import (
OmniClientError,
client_error_from_metadata,
client_error_metadata,
is_client_error_status,
)
from vllm_omni.outputs import OmniRequestOutput
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -604,6 +610,71 @@ async def test_async_omni_propagates_fatal_error_context(monkeypatch: pytest.Mon
assert getattr(exc_info.value, "error_stage_id") == 2
def _enqueue_client_error_message(engine: FakeAsyncOmniEngine, msg: dict[str, Any]) -> None:
engine.output_q.put_nowait(
ErrorMessage(
request_id=msg["request_id"],
stage_id=2,
error="Input was blocked by Cosmos3 guardrails.",
status_code=400,
error_type="BadRequestError",
)
)
@pytest.mark.asyncio
async def test_async_omni_propagates_client_error_status(monkeypatch: pytest.MonkeyPatch):
"""A non-fatal client error from the orchestrator must be routed to the
requesting generate() call (not raised in the shared dispatcher) and
surface as an OmniClientError carrying status_code/error_type."""
engine = FakeAsyncOmniEngine(stage_metadata=THREE_STAGE_META, on_add_request=_enqueue_client_error_message)
_patch_engine(monkeypatch, engine)
app = AsyncOmni("dummy-model")
try:
with pytest.raises(OmniClientError) as exc_info:
async for _ in app.generate(prompt="blocked", request_id="req-1"):
pass
finally:
app.shutdown()
assert exc_info.value.status_code == 400
assert exc_info.value.error_type == "BadRequestError"
assert str(exc_info.value) == "Input was blocked by Cosmos3 guardrails."
def _enqueue_server_error_message(engine: FakeAsyncOmniEngine, msg: dict[str, Any]) -> None:
engine.output_q.put_nowait(
ErrorMessage(
request_id=msg["request_id"],
stage_id=2,
error="GPU exploded",
)
)
@pytest.mark.asyncio
async def test_async_omni_propagates_server_error_as_runtime(monkeypatch: pytest.MonkeyPatch):
"""A non-fatal error WITHOUT a 4xx status_code is a server fault: it must
surface as a RuntimeError (-> HTTP 500), not an OmniClientError (-> 400).
Guards against the fix over-broadly mapping every error to a client error."""
engine = FakeAsyncOmniEngine(stage_metadata=THREE_STAGE_META, on_add_request=_enqueue_server_error_message)
_patch_engine(monkeypatch, engine)
app = AsyncOmni("dummy-model")
try:
# OmniClientError subclasses ValueError, not RuntimeError, so matching
# RuntimeError here also proves it was NOT raised as a client error.
with pytest.raises(RuntimeError) as exc_info:
async for _ in app.generate(prompt="boom", request_id="req-1"):
pass
finally:
app.shutdown()
assert not isinstance(exc_info.value, OmniClientError)
assert str(exc_info.value) == "GPU exploded"
def test_omni_generate_py_generator_yields_final_outputs_for_each_request(monkeypatch: pytest.MonkeyPatch):
sampling_params = [SamplingParams(max_tokens=8) for _ in range(3)]
engine = FakeAsyncOmniEngine(
@@ -761,6 +832,140 @@ def test_non_fatal_error_raises_runtime():
base._handle_output_message(msg)
def test_non_fatal_client_error_raises_omni_client_error():
"""A non-fatal ErrorMessage carrying a 4xx status_code (e.g. a guardrail
block) must surface as an OmniClientError with the metadata intact, not a
bare RuntimeError. This covers the offline/sync Omni consumer path."""
base = _make_base()
msg = ErrorMessage(
error="Input was blocked by Cosmos3 guardrails.",
status_code=400,
error_type="BadRequestError",
)
with pytest.raises(OmniClientError) as exc_info:
base._handle_output_message(msg)
assert exc_info.value.status_code == 400
assert exc_info.value.error_type == "BadRequestError"
assert str(exc_info.value) == "Input was blocked by Cosmos3 guardrails."
_NON_400_CLIENT_ERRORS = [
pytest.param(429, "RateLimitError", id="429-too-many-requests"),
pytest.param(413, "PayloadTooLargeError", id="413-payload-too-large"),
pytest.param(422, "UnprocessableEntityError", id="422-unprocessable-entity"),
pytest.param(403, "PermissionDeniedError", id="403-forbidden"),
]
@pytest.mark.parametrize(
("status_code", "error_type"), [pytest.param(400, "BadRequestError", id="400")] + _NON_400_CLIENT_ERRORS
)
def test_client_error_metadata_round_trip_preserves_4xx(status_code: int, error_type: str):
original = OmniClientError("blocked", status_code=status_code, error_type=error_type)
# Outbound: what the broad `except Exception` handlers serialize.
carried_status, carried_type = client_error_metadata(original)
assert carried_status == status_code
assert carried_type == error_type
assert is_client_error_status(carried_status)
# Inbound: reconstruction at the consuming end.
rebuilt = client_error_from_metadata("blocked", status_code=carried_status, error_type=carried_type)
assert isinstance(rebuilt, OmniClientError)
assert rebuilt.status_code == status_code
assert rebuilt.error_type == error_type
assert str(rebuilt) == "blocked"
@pytest.mark.parametrize(("status_code", "error_type"), _NON_400_CLIENT_ERRORS)
def test_non_fatal_client_error_preserves_non_400_status(status_code: int, error_type: str):
base = _make_base()
msg = ErrorMessage(
error="client side failure",
status_code=status_code,
error_type=error_type,
)
with pytest.raises(OmniClientError) as exc_info:
base._handle_output_message(msg)
assert exc_info.value.status_code == status_code
assert exc_info.value.error_type == error_type
assert str(exc_info.value) == "client side failure"
@pytest.mark.parametrize(
"status_code",
[
pytest.param(None, id="no-status"),
pytest.param(399, id="399-below-4xx"),
pytest.param(500, id="500-server-error"),
pytest.param(503, id="503-service-unavailable"),
],
)
def test_non_fatal_non_4xx_status_raises_runtime(status_code: int | None):
base = _make_base()
msg = ErrorMessage(
error="server side failure",
status_code=status_code,
error_type="ShouldBeIgnoredForNon4xx",
)
with pytest.raises(RuntimeError) as exc_info:
base._handle_output_message(msg)
assert not isinstance(exc_info.value, OmniClientError)
assert str(exc_info.value) == "server side failure"
def _make_enqueue_client_error(
status_code: int,
error_type: str,
error_text: str,
) -> Callable[[FakeAsyncOmniEngine, dict[str, Any]], None]:
def _enqueue(engine: FakeAsyncOmniEngine, msg: dict[str, Any]) -> None:
engine.output_q.put_nowait(
ErrorMessage(
request_id=msg["request_id"],
stage_id=2,
error=error_text,
status_code=status_code,
error_type=error_type,
)
)
return _enqueue
@pytest.mark.asyncio
@pytest.mark.parametrize(("status_code", "error_type"), _NON_400_CLIENT_ERRORS)
async def test_async_omni_propagates_non_400_client_error_status(
monkeypatch: pytest.MonkeyPatch,
status_code: int,
error_type: str,
):
error_text = f"blocked with {status_code}"
engine = FakeAsyncOmniEngine(
stage_metadata=THREE_STAGE_META,
on_add_request=_make_enqueue_client_error(status_code, error_type, error_text),
)
_patch_engine(monkeypatch, engine)
app = AsyncOmni("dummy-model")
try:
with pytest.raises(OmniClientError) as exc_info:
async for _ in app.generate(prompt="blocked", request_id="req-1"):
pass
finally:
app.shutdown()
assert exc_info.value.status_code == status_code
assert exc_info.value.error_type == error_type
assert str(exc_info.value) == error_text
def test_async_omni_errored_property_alive():
omni = object.__new__(AsyncOmni)
omni.engine = SimpleNamespace(
+5 -2
View File
@@ -1,5 +1,6 @@
"""Synthetic media generation and media/text utilities for tests."""
import atexit
import base64
import concurrent.futures
import gc
@@ -14,7 +15,6 @@ import re
import subprocess
import tempfile
import time
import uuid
from contextlib import contextmanager
from pathlib import Path
from typing import Any
@@ -683,7 +683,10 @@ def convert_audio_file_to_text(output_path: str) -> str:
def convert_audio_bytes_to_text(raw_bytes: bytes) -> str:
output_path = f"./test_{uuid.uuid4().hex}.wav"
output_fd, output_path = tempfile.mkstemp(prefix="test_", suffix=".wav")
os.close(output_fd)
if os.environ.get("VLLM_OMNI_KEEP_REQUEST_MEDIA", "").lower() not in ("1", "true", "yes"):
atexit.register(Path(output_path).unlink, missing_ok=True)
data, samplerate = sf.read(io.BytesIO(raw_bytes))
sf.write(output_path, data, samplerate, format="WAV", subtype="PCM_16")
print(f"audio data is saved: {output_path}")
+16 -10
View File
@@ -1473,6 +1473,8 @@ class OpenAIClientHandler:
- ``send_frames``: optional ``str`` or sequence of ``str`` raw WebSocket text frames (omit when the server
speaks first, e.g. ``/v1/realtime`` rejection path).
- ``ws_skip_types``: optional event ``type`` strings to ignore while waiting for the first matching frame
(e.g. ``["session.created"]`` on ``/v1/realtime``).
- ``timeout``: seconds to wait for the first inbound text frame (default ``120``).
- ``ws_max_size``: passed through as ``max_size`` to :func:`websockets.connect` when the key is present.
"""
@@ -1486,6 +1488,7 @@ class OpenAIClientHandler:
timeout = float(cfg.get("timeout", 120.0))
uri = self._build_ws_url(path)
skip_types = set(cfg.get("ws_skip_types") or [])
connect_kw: dict[str, Any] = {}
if "ws_max_size" in cfg:
@@ -1497,16 +1500,19 @@ class OpenAIClientHandler:
async with websockets.connect(uri, **connect_kw) as ws:
for frame in frames:
await ws.send(frame)
raw = await asyncio.wait_for(ws.recv(), timeout=timeout)
if not isinstance(raw, str):
raise AssertionError(f"Expected JSON text frame from {uri}, got {type(raw).__name__}")
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
raise AssertionError(f"Expected JSON text frame from {uri}, body={raw[:500]!r}") from exc
if not isinstance(data, dict):
raise AssertionError(f"Expected JSON object from {uri}, got {type(data).__name__}")
return WebSocketJsonResponse(json_body=data)
while True:
raw = await asyncio.wait_for(ws.recv(), timeout=timeout)
if not isinstance(raw, str):
raise AssertionError(f"Expected JSON text frame from {uri}, got {type(raw).__name__}")
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
raise AssertionError(f"Expected JSON text frame from {uri}, body={raw[:500]!r}") from exc
if not isinstance(data, dict):
raise AssertionError(f"Expected JSON object from {uri}, got {type(data).__name__}")
if skip_types and data.get("type") in skip_types:
continue
return WebSocketJsonResponse(json_body=data)
resp = asyncio.run(_recv_first_json_object())
_run_ws_expectations_from_request_config(cfg, resp)
Whitespace-only changes.
@@ -0,0 +1,361 @@
"""Tests for MossTTSCUDAGraphCodecWrapper numerical equivalence.
Verifies that CUDA Graph-accelerated decoding produces results equivalent
to eager mode, with special attention to the two-argument _decode interface
(codes [NQ, 1, T] + lengths [1]) and the NQ-first input convention.
"""
import pytest
import torch
import torch.nn as nn
from vllm_omni.model_executor.models.moss_tts.audio_tokenizer import (
MossAudioTokenizerDecoderOutput,
)
from vllm_omni.model_executor.models.moss_tts.moss_codec_cudagraph import (
MossTTSCUDAGraphCodecWrapper,
)
pytestmark = [pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")]
DEVICE = torch.device("cuda:0")
NUM_QUANTIZERS = 8
DOWNSAMPLE_RATE = 4 # synthetic; real checkpoint uses 1920
# ---------------------------------------------------------------------------
# Synthetic codec model
# ---------------------------------------------------------------------------
class SyntheticCodecModel(nn.Module):
"""Minimal stand-in for MossAudioTokenizerModel.
Exposes the same two-argument _decode(codes, lengths) interface and
returns a MossAudioTokenizerDecoderOutput, mirroring the real model.
Input:
codes: [NQ, B, T] long — RVQ codes
lengths: [B] long — valid frame counts
Output:
MossAudioTokenizerDecoderOutput(audio=[B, 1, T*upsample], audio_lengths=[B])
"""
def __init__(
self,
num_quantizers: int = NUM_QUANTIZERS,
downsample_rate: int = DOWNSAMPLE_RATE,
):
super().__init__()
self.downsample_rate = downsample_rate
hidden = 32
# embed: treat NQ quantizers as input channels for Conv1d
self.embed = nn.Conv1d(num_quantizers, hidden, kernel_size=3, padding=1)
self.conv = nn.Conv1d(hidden, hidden, kernel_size=3, padding=1)
self.upsample = nn.ConvTranspose1d(hidden, 1, kernel_size=downsample_rate, stride=downsample_rate)
def _decode(self, codes: torch.Tensor, lengths: torch.Tensor) -> MossAudioTokenizerDecoderOutput:
"""codes: [NQ, B, T], lengths: [B] → audio [B, 1, T*upsample]."""
nq, b, t = codes.shape
# treat NQ as channel dim → [B, NQ, T] for Conv1d (expects [B, C, T])
x = codes.permute(1, 0, 2).to(dtype=self.embed.weight.dtype) # [B, NQ, T]
x = torch.relu(self.embed(x)) # [B, hidden, T]
x = torch.relu(self.conv(x)) # [B, hidden, T]
audio = self.upsample(x) # [B, 1, T * upsample]
audio_lengths = lengths * self.downsample_rate
return MossAudioTokenizerDecoderOutput(audio=audio, audio_lengths=audio_lengths)
def batch_decode(
self,
codes_list: list[torch.Tensor],
num_quantizers: int | None = None,
) -> MossAudioTokenizerDecoderOutput:
"""Mirrors MossAudioTokenizerModel.batch_decode (fallback path)."""
device = codes_list[0].device
nq = num_quantizers or codes_list[0].shape[0]
max_t = max(c.shape[-1] for c in codes_list)
codes = torch.zeros(nq, len(codes_list), max_t, device=device, dtype=torch.long)
lengths = torch.zeros(len(codes_list), device=device, dtype=torch.long)
for i, c in enumerate(codes_list):
codes[:nq, i, : c.shape[-1]] = c[:nq]
lengths[i] = c.shape[-1]
return self._decode(codes, lengths)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def model():
torch.manual_seed(0)
return SyntheticCodecModel().to(DEVICE).eval()
@pytest.fixture(scope="module")
def wrapper(model):
w = MossTTSCUDAGraphCodecWrapper(
model=model,
capture_sizes=[25, 50, 100],
num_quantizers=NUM_QUANTIZERS,
enabled=True,
)
w.warmup(DEVICE)
return w
def _random_codes(t: int, device: torch.device = DEVICE) -> torch.Tensor:
"""Return [NQ, T] long tensor (single-request convention)."""
return torch.randint(0, 64, (NUM_QUANTIZERS, t), dtype=torch.long, device=device)
def _eager_decode(model: SyntheticCodecModel, codes_nq_t: torch.Tensor) -> MossAudioTokenizerDecoderOutput:
"""Run the model directly in eager mode (reference output)."""
t = codes_nq_t.shape[-1]
codes_nq_1_t = codes_nq_t.unsqueeze(1) # [NQ, 1, T]
lengths = torch.tensor([t], dtype=torch.long, device=codes_nq_t.device)
with torch.no_grad():
return model._decode(codes_nq_1_t, lengths)
# ---------------------------------------------------------------------------
# 1. Exact-size inputs — must be bit-identical
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("t", [25, 50, 100])
def test_exact_size_bit_identical(model, wrapper, t):
codes = _random_codes(t)
ref = _eager_decode(model, codes)
with torch.no_grad():
out = wrapper.decode(codes)
torch.testing.assert_close(out.audio, ref.audio, atol=0, rtol=0)
# ---------------------------------------------------------------------------
# 2. Padded inputs — output trimmed to actual length
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("t", [10, 30, 47, 73, 99])
def test_padded_output_shape(model, wrapper, t):
codes = _random_codes(t)
ref = _eager_decode(model, codes)
with torch.no_grad():
out = wrapper.decode(codes)
expected_len = t * DOWNSAMPLE_RATE
assert out.audio.shape[-1] == expected_len, f"got {out.audio.shape[-1]}, want {expected_len}"
assert out.audio.shape == ref.audio.shape
@pytest.mark.parametrize("t", [10, 30, 47, 73, 99])
def test_padded_interior_positions_close(model, wrapper, t):
"""Positions well away from the zero-padding boundary must be numerically close."""
codes = _random_codes(t)
ref = _eager_decode(model, codes)
with torch.no_grad():
out = wrapper.decode(codes)
boundary = 2 * DOWNSAMPLE_RATE
if ref.audio.shape[-1] > boundary:
torch.testing.assert_close(
out.audio[..., :-boundary],
ref.audio[..., :-boundary],
atol=1e-5,
rtol=1e-5,
)
# ---------------------------------------------------------------------------
# 3. Fallback to eager (T > all capture sizes)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("t", [101, 150, 200])
def test_fallback_exact_match(model, wrapper, t):
codes = _random_codes(t)
ref = _eager_decode(model, codes)
with torch.no_grad():
out = wrapper.decode(codes)
torch.testing.assert_close(out.audio, ref.audio, atol=0, rtol=0)
# ---------------------------------------------------------------------------
# 4. Static buffer not aliased across calls
# ---------------------------------------------------------------------------
def test_output_not_aliased_after_later_replay(model, wrapper):
"""clone() in decode() must prevent later replays from overwriting the result."""
codes_a = _random_codes(50)
codes_b = _random_codes(50)
with torch.no_grad():
out_a = wrapper.decode(codes_a)
saved = out_a.audio.clone()
_ = wrapper.decode(codes_b) # replay overwrites static buffer
torch.testing.assert_close(out_a.audio, saved, atol=0, rtol=0)
def test_deterministic_across_calls(model, wrapper):
codes = _random_codes(30)
with torch.no_grad():
out1 = wrapper.decode(codes)
out2 = wrapper.decode(codes)
torch.testing.assert_close(out1.audio, out2.audio, atol=0, rtol=0)
# ---------------------------------------------------------------------------
# 5. Disabled wrapper falls back to eager
# ---------------------------------------------------------------------------
def test_disabled_falls_back_to_eager(model, wrapper):
codes = _random_codes(30)
ref = _eager_decode(model, codes)
wrapper.enabled = False
with torch.no_grad():
out = wrapper.decode(codes)
wrapper.enabled = True
torch.testing.assert_close(out.audio, ref.audio, atol=0, rtol=0)
# ---------------------------------------------------------------------------
# 6. audio_lengths is consistent with audio shape
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("t", [25, 30, 50, 99, 100])
def test_audio_lengths_consistent(model, wrapper, t):
codes = _random_codes(t)
with torch.no_grad():
out = wrapper.decode(codes)
assert out.audio_lengths is not None
assert int(out.audio_lengths[0].item()) == out.audio.shape[-1]
# ---------------------------------------------------------------------------
# 7. NQ-first input layout: codes_nq_t is [NQ, T], not [T, NQ]
# ---------------------------------------------------------------------------
def test_nq_first_layout_matches_eager(model, wrapper):
"""Verify that wrapper correctly interprets [NQ, T] (NQ-first) layout."""
t = 25
codes = _random_codes(t) # [NQ, T]
ref = _eager_decode(model, codes)
with torch.no_grad():
out = wrapper.decode(codes)
torch.testing.assert_close(out.audio, ref.audio, atol=0, rtol=0)
# ---------------------------------------------------------------------------
# 8. Non-contiguous input — copy_() must handle strided tensors correctly
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("t", [20, 47])
def test_noncontiguous_input_matches_contiguous(model, wrapper, t):
"""Padded path uses copy_() on a slice; must produce the same result
whether codes_nq_t is contiguous or a non-contiguous view (e.g. a column
slice of a larger buffer, which is the realistic production layout when the
caller slices out one request from a batched tensor)."""
codes_contiguous = _random_codes(t)
# Build a non-contiguous view: embed in a larger [NQ, 2, T] buffer and
# take column 0. The resulting tensor has stride (2*T, 2, 1) → non-contiguous.
buf = torch.zeros(NUM_QUANTIZERS, 2, t, dtype=torch.long, device=DEVICE)
buf[:, 0, :] = codes_contiguous
codes_noncontig = buf[:, 0, :] # [NQ, T], non-contiguous
assert not codes_noncontig.is_contiguous()
with torch.no_grad():
out_c = wrapper.decode(codes_contiguous)
out_nc = wrapper.decode(codes_noncontig)
torch.testing.assert_close(out_c.audio, out_nc.audio, atol=0, rtol=0)
# ---------------------------------------------------------------------------
# 9. Zero-padding uses token 0 — must be a valid codebook index (no OOB)
# ---------------------------------------------------------------------------
def test_zero_padding_no_cuda_error(model, wrapper):
"""Padded inputs fill unused frames with code 0. Verify that the zero-
padded region does not corrupt the output shape.
Note: SyntheticCodecModel uses Conv1d (not nn.Embedding), so it cannot
trigger a real CUDA index-out-of-bounds on large token ids. This test
validates shape correctness only; OOB safety on the real codec requires
an integration test with MossAudioTokenizerModel."""
t = 7 # well below the 25-frame bucket → large zero-padded region
codes = _random_codes(t)
with torch.no_grad():
out = wrapper.decode(codes)
torch.accelerator.synchronize() # surface any deferred CUDA errors
assert out.audio.shape[-1] == t * DOWNSAMPLE_RATE
# ---------------------------------------------------------------------------
# 10. Shape alignment: graph path and eager fallback return identical shapes
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("t", [25, 30, 101])
def test_graph_and_eager_shape_identical(model, wrapper, t):
"""Graph replay (t<=100) and eager fallback (t>100) must return tensors
with the same number of dimensions and the same batch/channel layout so
downstream consumers can treat both paths uniformly."""
codes = _random_codes(t)
ref = _eager_decode(model, codes)
with torch.no_grad():
out = wrapper.decode(codes)
assert out.audio.ndim == ref.audio.ndim, f"ndim mismatch: graph={out.audio.ndim} eager={ref.audio.ndim}"
assert out.audio.shape[:-1] == ref.audio.shape[:-1], (
f"batch/channel shape mismatch: graph={out.audio.shape} eager={ref.audio.shape}"
)
# ---------------------------------------------------------------------------
# 11. Half-precision weights: graph and eager must agree under fp16/bf16
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
def test_half_precision_graph_eager_agree(dtype):
"""CUDA Graph must produce outputs consistent with eager when model weights
are in half precision. The wrapper itself stores codes as long and lengths
as long; only the model's conv weights change dtype."""
torch.manual_seed(0)
model_hp = SyntheticCodecModel().to(device=DEVICE, dtype=dtype).eval()
wrapper_hp = MossTTSCUDAGraphCodecWrapper(
model=model_hp,
capture_sizes=[25, 50],
num_quantizers=NUM_QUANTIZERS,
enabled=True,
)
wrapper_hp.warmup(DEVICE)
codes = _random_codes(25)
ref = _eager_decode(model_hp, codes)
with torch.no_grad():
out = wrapper_hp.decode(codes)
# Half-precision accumulation introduces small numerical error; use
# tolerances that match typical fp16/bf16 rounding.
atol = 1e-2 if dtype == torch.float16 else 5e-2
torch.testing.assert_close(out.audio.float(), ref.audio.float(), atol=atol, rtol=1e-3)
# ---------------------------------------------------------------------------
# 12. Empty input (T=0): wrapper must not crash and return zero-length audio
# ---------------------------------------------------------------------------
def test_empty_input_t0(model, wrapper):
"""T=0 input must be handled gracefully: no exception, audio length == 0.
The outer MossTTSCodecDecoder guards against empty segments, but the
wrapper should also handle T=0 without crashing so it is safe to call
directly."""
codes = _random_codes(0) # [NQ, 0]
with torch.no_grad():
out = wrapper.decode(codes)
assert out.audio.shape[-1] == 0, f"expected empty audio, got shape {out.audio.shape}"
View File
Whitespace-only changes.
@@ -0,0 +1,918 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Tests for StepAudioReasoningParser.
Step-Audio supports two representations of thinking markers:
1. **Special tokens**: ``<|THINK_START|>`` and ``<|THINK_END|>``
(single-token IDs in vocab).
2. **Text markers**: ``<think>`` and ``</think>``
(multi-token sequences for </think>, e.g. [522, 26865]).
These tests use standalone mock objects so they can run without
installing the full vLLM/torch stack.
"""
import sys
from types import ModuleType
from unittest.mock import MagicMock
import pytest
# ---------------------------------------------------------------------------
# Bootstrap minimal vLLM stubs so the parser can be imported
# ---------------------------------------------------------------------------
def _install_vllm_stubs() -> dict[str, ModuleType | None]:
"""Temporarily install minimal stub modules for the vllm imports the
parser needs, so it can be imported without the full vLLM/torch stack.
Returns a snapshot of the original ``sys.modules`` entries that were
replaced. The caller MUST pass it to ``_restore_vllm_stubs`` once the
parser is imported. These stubs are process-global: if they leak, they
shadow the real vllm modules for every other test in the same worker
(and any forked server subprocess), e.g. replacing
``vllm.entrypoints.openai.engine.protocol`` with a stub that lacks
``ErrorResponse`` and breaking unrelated server-startup tests.
"""
stubs: dict[str, ModuleType] = {}
# DeltaMessage stub
delta_mod = ModuleType("vllm.entrypoints.openai.engine.protocol")
class DeltaMessage:
__slots__ = ("reasoning", "content")
def __init__(self, reasoning=None, content=None):
self.reasoning = reasoning
self.content = content
delta_mod.DeltaMessage = DeltaMessage
stubs["vllm.entrypoints.openai.engine.protocol"] = delta_mod
# ReasoningParser stub
reasoning_mod = ModuleType("vllm.reasoning")
class ReasoningParser:
def __init__(self, tokenizer, *args, **kwargs):
self.model_tokenizer = tokenizer
@property
def vocab(self):
if not self.model_tokenizer:
return {}
return self.model_tokenizer.get_vocab()
class _ReasoningParserManager:
reasoning_parsers = {}
lazy_parsers = {}
@classmethod
def register_lazy_module(cls, name, module_path, class_name):
cls.lazy_parsers[name] = (module_path, class_name)
reasoning_mod.ReasoningParser = ReasoningParser
reasoning_mod.ReasoningParserManager = _ReasoningParserManager
stubs["vllm.reasoning"] = reasoning_mod
stubs["vllm.reasoning.abs_reasoning_parsers"] = reasoning_mod
# TokenizerLike stub
tokenizers_mod = ModuleType("vllm.tokenizers")
tokenizers_mod.TokenizerLike = object
stubs["vllm.tokenizers"] = tokenizers_mod
# ChatCompletionRequest / ResponsesRequest stubs
chat_mod = ModuleType("vllm.entrypoints.openai.chat_completion.protocol")
chat_mod.ChatCompletionRequest = MagicMock
stubs["vllm.entrypoints.openai.chat_completion.protocol"] = chat_mod
responses_mod = ModuleType("vllm.entrypoints.openai.responses.protocol")
responses_mod.ResponsesRequest = MagicMock
stubs["vllm.entrypoints.openai.responses.protocol"] = responses_mod
# Parent packages: only stub the ones not already importable, so we never
# clobber a real (installed) vllm package tree.
for mod_path in [
"vllm",
"vllm.entrypoints",
"vllm.entrypoints.openai",
"vllm.entrypoints.openai.engine",
"vllm.entrypoints.openai.chat_completion",
"vllm.entrypoints.openai.responses",
]:
if mod_path not in sys.modules and mod_path not in stubs:
stubs[mod_path] = ModuleType(mod_path)
saved = {name: sys.modules.get(name) for name in stubs}
sys.modules.update(stubs)
return saved
def _restore_vllm_stubs(saved: dict[str, ModuleType | None]) -> None:
"""Undo ``_install_vllm_stubs`` so ``sys.modules`` is left untouched."""
for name, original in saved.items():
if original is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = original
# Install stubs only for the duration of the import. The parser binds these
# stub classes into its own module namespace at import time, so it keeps using
# them at runtime even after sys.modules is restored below.
_saved_modules = _install_vllm_stubs()
try:
from vllm_omni.reasoning.step_audio_reasoning_parser import ( # noqa: E402
StepAudioReasoningParser,
)
finally:
_restore_vllm_stubs(_saved_modules)
# Short aliases for marker constants
TS = StepAudioReasoningParser.THINK_START_TEXT # <think>
TE = StepAudioReasoningParser.THINK_END_TEXT # </think>
TSS = StepAudioReasoningParser.THINK_START_SPECIAL # <|THINK_START|>
TES = StepAudioReasoningParser.THINK_END_SPECIAL # <|THINK_END|>
# ---------------------------------------------------------------------------
# Mock tokenizers
# ---------------------------------------------------------------------------
class SingleTokenTokenizer:
"""Tokenizer where both ``<think>`` and ``</think>`` are single tokens.
This exercises the fast (token-ID) path.
Also includes special-token forms ``<|THINK_START|>`` and ``<|THINK_END|>``.
"""
def __init__(self):
self._vocab = {
TS: 100, # <think>
TE: 101, # </think>
TSS: 151669, # <|THINK_START|>
TES: 151670, # <|THINK_END|>
"Hello": 1,
" world": 2,
"reasoning": 3,
" content": 4,
"\n": 5,
"</": 6,
"think>": 7,
"some text": 8,
}
self._reverse = {v: k for k, v in self._vocab.items()}
def get_vocab(self):
return dict(self._vocab)
def decode(self, ids, skip_special_tokens=False):
return "".join(self._reverse.get(i, "") for i in ids)
def tokenize(self, text):
if not text:
return []
return [text]
class MultiTokenTokenizer:
"""Tokenizer where ``</think>`` is NOT a single token.
``<think>`` is in the vocab (ID 100), but ``</think>`` is NOT.
Instead, ``</think>`` encodes as ["</", "think>"] → [522, 26865].
Both special tokens ``<|THINK_START|>`` and ``<|THINK_END|>`` ARE
present as single tokens.
This exercises the text-based path (with special-token fallback).
"""
def __init__(self):
self._vocab = {
TS: 100, # <think> - single token
# </think> is intentionally NOT in the vocab
"</": 522,
"think>": 26865,
TSS: 151669, # <|THINK_START|>
TES: 151670, # <|THINK_END|>
"Hello": 1,
" world": 2,
"reasoning": 3,
" content": 4,
"\n": 5,
"some text": 8,
}
self._reverse = {v: k for k, v in self._vocab.items()}
def get_vocab(self):
return dict(self._vocab)
def decode(self, ids, skip_special_tokens=False):
return "".join(self._reverse.get(i, "") for i in ids)
def tokenize(self, text):
if not text:
return []
return [text]
class TextOnlyTokenizer:
"""Tokenizer with only text-form markers (no special tokens at all).
Both ``<think>`` and ``</think>`` are single tokens.
``<|THINK_START|>`` and ``<|THINK_END|>`` are NOT in the vocab.
This tests the pure text-based fallback paths.
"""
def __init__(self):
self._vocab = {
TS: 100, # <think>
TE: 101, # </think>
"Hello": 1,
" world": 2,
"reasoning": 3,
" content": 4,
"\n": 5,
"</": 6,
"think>": 7,
}
self._reverse = {v: k for k, v in self._vocab.items()}
def get_vocab(self):
return dict(self._vocab)
def decode(self, ids, skip_special_tokens=False):
return "".join(self._reverse.get(i, "") for i in ids)
def tokenize(self, text):
if not text:
return []
return [text]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _stream_extract(parser, deltas_and_ids):
"""Run streaming extraction over a list of (delta_text, delta_ids) tuples.
Returns (reasoning_text, content_text)."""
prev_text = ""
prev_ids = []
all_r = []
all_c = []
for delta_text, delta_ids in deltas_and_ids:
cur_text = prev_text + delta_text
cur_ids = prev_ids + delta_ids
result = parser.extract_reasoning_streaming(prev_text, cur_text, delta_text, prev_ids, cur_ids, delta_ids)
if result is not None:
if result.reasoning:
all_r.append(result.reasoning)
if result.content:
all_c.append(result.content)
prev_text = cur_text
prev_ids = cur_ids
return "".join(all_r), "".join(all_c)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def single_token_parser():
return StepAudioReasoningParser(SingleTokenTokenizer())
@pytest.fixture
def multi_token_parser():
return StepAudioReasoningParser(MultiTokenTokenizer())
@pytest.fixture
def text_only_parser():
return StepAudioReasoningParser(TextOnlyTokenizer())
@pytest.fixture
def request_obj():
return MagicMock(messages=[], model="test-model")
# ---------------------------------------------------------------------------
# Initialization tests
# ---------------------------------------------------------------------------
class TestInit:
def test_single_token_ids(self, single_token_parser):
"""Text-form markers are in vocab as single tokens."""
assert single_token_parser.think_start_text_id == 100
assert single_token_parser.think_end_text_id == 101
def test_single_token_special_ids(self, single_token_parser):
"""Special-token markers are also in vocab."""
assert single_token_parser.think_start_special_id == 151669
assert single_token_parser.think_end_special_id == 151670
def test_single_token_has_ids(self, single_token_parser):
assert single_token_parser._has_start_token_id is True
assert single_token_parser._has_end_token_id is True
def test_multi_token_text_ids(self, multi_token_parser):
"""<think> is a single token; </think> is NOT."""
assert multi_token_parser.think_start_text_id == 100
assert multi_token_parser.think_end_text_id == -1
def test_multi_token_special_ids(self, multi_token_parser):
"""Special tokens are still in vocab."""
assert multi_token_parser.think_start_special_id == 151669
assert multi_token_parser.think_end_special_id == 151670
def test_multi_token_has_ids(self, multi_token_parser):
"""Even without </think> as single token, special tokens provide IDs."""
assert multi_token_parser._has_start_token_id is True
assert multi_token_parser._has_end_token_id is True
def test_text_only_ids(self, text_only_parser):
"""Only text-form markers, no special tokens."""
assert text_only_parser.think_start_text_id == 100
assert text_only_parser.think_end_text_id == 101
assert text_only_parser.think_start_special_id == -1
assert text_only_parser.think_end_special_id == -1
assert text_only_parser._has_start_token_id is True
assert text_only_parser._has_end_token_id is True
def test_missing_tokenizer_raises(self):
with pytest.raises(ValueError, match="model tokenizer must be passed"):
StepAudioReasoningParser(None)
# ---------------------------------------------------------------------------
# extract_reasoning (non-streaming)
# ---------------------------------------------------------------------------
class TestExtractReasoning:
@pytest.mark.parametrize(
"parser_name",
[
"single_token_parser",
"multi_token_parser",
"text_only_parser",
],
)
def test_basic_extraction(self, request, parser_name, request_obj):
parser = request.getfixturevalue(parser_name)
output = f"some reasoning{TE}Hello world"
reasoning, content = parser.extract_reasoning(output, request_obj)
assert reasoning == "some reasoning"
assert content == "Hello world"
@pytest.mark.parametrize(
"parser_name",
[
"single_token_parser",
"multi_token_parser",
"text_only_parser",
],
)
def test_no_end_token(self, request, parser_name, request_obj):
parser = request.getfixturevalue(parser_name)
output = "still thinking..."
reasoning, content = parser.extract_reasoning(output, request_obj)
assert reasoning == "still thinking..."
assert content is None
@pytest.mark.parametrize(
"parser_name",
[
"single_token_parser",
"multi_token_parser",
"text_only_parser",
],
)
def test_with_start_token_prefix(self, request, parser_name, request_obj):
parser = request.getfixturevalue(parser_name)
output = f"{TS}my reasoning{TE}answer"
reasoning, content = parser.extract_reasoning(output, request_obj)
assert reasoning == "my reasoning"
assert content == "answer"
@pytest.mark.parametrize(
"parser_name",
[
"single_token_parser",
"multi_token_parser",
"text_only_parser",
],
)
def test_strips_newline_after_end(self, request, parser_name, request_obj):
parser = request.getfixturevalue(parser_name)
output = f"reasoning{TE}\nHello"
reasoning, content = parser.extract_reasoning(output, request_obj)
assert reasoning == "reasoning"
assert content == "Hello"
@pytest.mark.parametrize(
"parser_name",
[
"single_token_parser",
"multi_token_parser",
"text_only_parser",
],
)
def test_empty_reasoning(self, request, parser_name, request_obj):
parser = request.getfixturevalue(parser_name)
output = f"{TE}Hello"
reasoning, content = parser.extract_reasoning(output, request_obj)
assert reasoning is None
assert content == "Hello"
@pytest.mark.parametrize(
"parser_name",
[
"single_token_parser",
"multi_token_parser",
],
)
def test_special_end_token(self, request, parser_name, request_obj):
"""Test with <|THINK_END|> special token form."""
parser = request.getfixturevalue(parser_name)
output = f"reasoning{TES}answer"
reasoning, content = parser.extract_reasoning(output, request_obj)
assert reasoning == "reasoning"
assert content == "answer"
@pytest.mark.parametrize(
"parser_name",
[
"single_token_parser",
"multi_token_parser",
],
)
def test_special_start_token(self, request, parser_name, request_obj):
"""Test with <|THINK_START|> special token form."""
parser = request.getfixturevalue(parser_name)
output = f"{TSS}reasoning{TE}answer"
reasoning, content = parser.extract_reasoning(output, request_obj)
assert reasoning == "reasoning"
assert content == "answer"
# ---------------------------------------------------------------------------
# is_reasoning_end
# ---------------------------------------------------------------------------
class TestIsReasoningEnd:
def test_single_token_text_end(self, single_token_parser):
assert single_token_parser.is_reasoning_end([1, 2, 101]) is True
def test_single_token_special_end(self, single_token_parser):
assert single_token_parser.is_reasoning_end([1, 2, 151670]) is True
def test_single_token_no_end(self, single_token_parser):
assert single_token_parser.is_reasoning_end([1, 2, 3]) is False
def test_multi_token_text_end_decoded(self, multi_token_parser):
"""</think> = [522, 26865] detected via decode."""
assert multi_token_parser.is_reasoning_end([1, 522, 26865]) is True
def test_multi_token_special_end(self, multi_token_parser):
"""<|THINK_END|> detected as single token ID."""
assert multi_token_parser.is_reasoning_end([1, 151670]) is True
def test_multi_token_no_end(self, multi_token_parser):
assert multi_token_parser.is_reasoning_end([1, 2, 3]) is False
def test_multi_token_partial_marker(self, multi_token_parser):
"""Only </ without think> should NOT count."""
assert multi_token_parser.is_reasoning_end([1, 522, 3]) is False
def test_multi_token_empty_input(self, multi_token_parser):
assert multi_token_parser.is_reasoning_end([]) is False
def test_text_only_end(self, text_only_parser):
assert text_only_parser.is_reasoning_end([1, 2, 101]) is True
def test_text_only_no_end(self, text_only_parser):
assert text_only_parser.is_reasoning_end([1, 2, 3]) is False
def test_start_after_end_returns_false(self, single_token_parser):
"""Multi-turn prompt: start marker AFTER end marker → reasoning
is still active (the generation prompt's start marker)."""
# IDs: [start=100, end=101, start=100] → last marker is start
assert single_token_parser.is_reasoning_end([100, 101, 100]) is False
def test_end_is_last_marker_returns_true(self, single_token_parser):
"""End marker is the last marker → reasoning has ended."""
# IDs: [start=100, end=101] → last marker is end
assert single_token_parser.is_reasoning_end([100, 101]) is True
def test_start_only_no_end(self, single_token_parser):
"""Only a start marker, no end marker → reasoning is active."""
assert single_token_parser.is_reasoning_end([100, 1, 2]) is False
def test_multi_turn_prompt_start_after_end(self, multi_token_parser):
"""Simulated multi-turn prompt with previous assistant response.
Prompt contains: ... </think ... <think ...
The last marker is <think (start), so is_reasoning_end
should return False.
"""
# Decode of these IDs: "reasoning" + "</ " + "think>" + " " + "<think"
# The last marker is the start marker.
# Using: [3=reasoning, 522="</", 26865="think>", 100=TS]
ids = [3, 522, 26865, 100]
assert multi_token_parser.is_reasoning_end(ids) is False
def test_multi_turn_prompt_end_is_last(self, multi_token_parser):
"""Previous turn ends with </think, no new start → ended."""
# [3=reasoning, 522="</", 26865="think>"]
ids = [3, 522, 26865]
assert multi_token_parser.is_reasoning_end(ids) is True
def test_special_start_after_end(self, multi_token_parser):
"""Special token start after end marker → reasoning still active."""
# [151670=<|THINK_END|>, 151669=<|THINK_START|>]
ids = [151670, 151669]
assert multi_token_parser.is_reasoning_end(ids) is False
# ---------------------------------------------------------------------------
# is_reasoning_end_streaming
# ---------------------------------------------------------------------------
class TestIsReasoningEndStreaming:
def test_single_token_delta_has_end(self, single_token_parser):
assert single_token_parser.is_reasoning_end_streaming([1, 2], [101]) is True
def test_single_token_delta_no_end(self, single_token_parser):
assert single_token_parser.is_reasoning_end_streaming([1, 2], [3]) is False
def test_multi_token_delta_has_both_parts(self, multi_token_parser):
"""Full </think> in delta → [522, 26865]."""
assert multi_token_parser.is_reasoning_end_streaming([1], [522, 26865]) is True
def test_multi_token_marker_spans_boundary(self, multi_token_parser):
"""</ in previous, think> in delta."""
assert multi_token_parser.is_reasoning_end_streaming([1, 522], [26865]) is True
def test_multi_token_no_end(self, multi_token_parser):
assert multi_token_parser.is_reasoning_end_streaming([1, 2], [3]) is False
# ---------------------------------------------------------------------------
# extract_reasoning_streaming
# ---------------------------------------------------------------------------
class TestExtractReasoningStreaming:
"""Test streaming extraction for both single-token and multi-token modes."""
@pytest.mark.parametrize(
"parser_name",
[
"single_token_parser",
"multi_token_parser",
"text_only_parser",
],
)
def test_simple_streaming(self, request_, parser_name):
parser = request_.getfixturevalue(parser_name)
# Simulate: <think> reasoning </think> final
if parser_name == "multi_token_parser":
deltas = [TS, "reasoning", TE, " content"]
token_ids_per_delta = [
[100], # <think>
[3], # reasoning
[522, 26865], # </think>
[4], # content
]
else:
deltas = [TS, "reasoning", TE, " content"]
token_ids_per_delta = [
[100], # <think>
[3], # reasoning
[101], # </think>
[4], # content
]
r, c = _stream_extract(parser, list(zip(deltas, token_ids_per_delta)))
assert r == "reasoning"
assert c == " content"
@pytest.mark.parametrize(
"parser_name",
[
"single_token_parser",
"multi_token_parser",
"text_only_parser",
],
)
def test_no_end_token_streaming(self, request_, parser_name):
parser = request_.getfixturevalue(parser_name)
deltas_and_ids = [
(TS, [100]),
("thinking", [3]),
(" more", [2]),
]
r, c = _stream_extract(parser, deltas_and_ids)
assert r == "thinking more"
assert c == ""
def test_multi_token_end_split_across_chunks(self, multi_token_parser):
"""The key test: </think> is split across streaming chunks.
Previous chunk ends with "reasoning</" and delta starts with
"think>\\nfinal answer". This is the real-world scenario when
``</think>`` is tokenized as two separate tokens.
"""
deltas_and_ids = [
("reasoning", [3]),
("</", [522]),
("think>\nHello", [26865, 1]),
]
r, c = _stream_extract(multi_token_parser, deltas_and_ids)
assert r == "reasoning"
assert c == "Hello"
def test_multi_token_start_token_in_delta(self, multi_token_parser):
"""Test that <think> start token is skipped in streaming output."""
deltas_and_ids = [
(TS, [100]),
("reasoning", [3]),
(TE, [522, 26865]),
("Hello", [1]),
]
r, c = _stream_extract(multi_token_parser, deltas_and_ids)
assert r == "reasoning"
assert c == "Hello"
def test_multi_token_three_way_split(self, multi_token_parser):
"""</think> split across 3 chunks: </ + thi + nk>."""
deltas_and_ids = [
("some reasoning", [3]),
("</", [522]),
("thi", [8]),
("nk>", [8]),
("\nHello", [1]),
]
r, c = _stream_extract(multi_token_parser, deltas_and_ids)
assert r == "some reasoning"
assert c == "Hello"
def test_special_end_token_in_stream(self, multi_token_parser):
"""<|THINK_END|> special token in streaming (decoded as text)."""
deltas_and_ids = [
("reasoning", [3]),
(TES, [151670]),
("answer", [1]),
]
r, c = _stream_extract(multi_token_parser, deltas_and_ids)
assert r == "reasoning"
assert c == "answer"
def test_no_start_token_in_stream(self, multi_token_parser):
"""Model starts directly with reasoning (no <think> prefix)."""
deltas_and_ids = [
("reasoning", [3]),
(TE, [522, 26865]),
("answer", [1]),
]
r, c = _stream_extract(multi_token_parser, deltas_and_ids)
assert r == "reasoning"
assert c == "answer"
def test_empty_reasoning(self, single_token_parser):
"""Start immediately followed by end → empty reasoning."""
deltas_and_ids = [
(TS, [100]),
(TE, [101]),
("Hello", [1]),
]
r, c = _stream_extract(single_token_parser, deltas_and_ids)
assert r in ("", None)
assert c == "Hello"
def test_end_marker_no_content_after(self, multi_token_parser):
"""End marker with no content following it."""
deltas_and_ids = [
("reasoning", [3]),
(TE, [522, 26865]),
]
r, c = _stream_extract(multi_token_parser, deltas_and_ids)
assert r == "reasoning"
assert c == ""
def test_delta_is_just_start_token(self, multi_token_parser):
"""Delta consisting only of the start token should be skipped."""
deltas_and_ids = [
(TS, [100]),
]
r, c = _stream_extract(multi_token_parser, deltas_and_ids)
assert r == ""
assert c == ""
@pytest.mark.parametrize(
"parser_name",
[
"single_token_parser",
"multi_token_parser",
],
)
def test_only_first_newline_stripped_after_end(self, request_, parser_name):
"""Only the first newline after </think> should be stripped."""
parser = request_.getfixturevalue(parser_name)
if parser_name == "multi_token_parser":
deltas_and_ids = [
("reasoning", [3]),
(TE, [522, 26865]),
("\n\nHello", [5, 1]),
]
else:
deltas_and_ids = [
("reasoning", [3]),
(TE, [101]),
("\n\nHello", [5, 1]),
]
r, c = _stream_extract(parser, deltas_and_ids)
assert r == "reasoning"
assert c == "\nHello"
@pytest.mark.parametrize(
"parser_name",
[
"single_token_parser",
"multi_token_parser",
],
)
def test_content_after_end_multiple_deltas(self, request_, parser_name):
"""Multiple content deltas after end marker."""
parser = request_.getfixturevalue(parser_name)
if parser_name == "multi_token_parser":
deltas_and_ids = [
(TS, [100]),
("reasoning", [3]),
(TE, [522, 26865]),
("Hello", [1]),
(" world", [2]),
]
else:
deltas_and_ids = [
(TS, [100]),
("reasoning", [3]),
(TE, [101]),
("Hello", [1]),
(" world", [2]),
]
r, c = _stream_extract(parser, deltas_and_ids)
assert r == "reasoning"
assert c == "Hello world"
def test_pending_buffer_resolved_on_non_marker(self, multi_token_parser):
"""When buffered text turns out not to be a marker prefix, it is
emitted as reasoning on the next call."""
# "reason<" is buffered because "<" could start a marker.
# Next delta ">" doesn't form a marker, so the buffer is
# flushed as reasoning.
parser = multi_token_parser
deltas_and_ids = [
(TS, [100]),
("reason", [3]),
("<", [8]), # "<" is buffered (prefix of markers)
("> more", [8]), # ">" doesn't complete any marker
]
r, c = _stream_extract(parser, deltas_and_ids)
assert r == "reason> more"
assert c == ""
def test_multi_token_end_with_reasoning_before_buffer(self, multi_token_parser):
"""Reasoning text before a buffered prefix should be emitted."""
# "reasoning<" → "reasoning" emitted, "<" buffered
# "</think>\nanswer" → buffer resolves as end marker
parser = multi_token_parser
deltas_and_ids = [
("reasoning", [3]),
("<", [8]),
(f"/{TE[2:]}\nanswer", [522, 26865, 1]),
]
r, c = _stream_extract(parser, deltas_and_ids)
assert r == "reasoning"
assert c == "answer"
def test_multi_token_five_way_split(self, multi_token_parser):
"""</think> split across 5 tiny chunks."""
# Split: each chunk is one character of </think>
chunks = list(TE)
deltas_and_ids = [
("reasoning", [3]),
]
for ch in chunks:
deltas_and_ids.append((ch, [8]))
deltas_and_ids.append(("\nHello", [1]))
r, c = _stream_extract(multi_token_parser, deltas_and_ids)
assert r == "reasoning"
assert c == "Hello"
def test_single_token_parser_with_pending(self, single_token_parser):
"""Single-token parser: ambiguous '<' prefix buffered then resolved."""
# Even with single-token markers, the text-based path buffers
# '<' because it could be a prefix of a marker.
parser = single_token_parser
deltas_and_ids = [
(TS, [100]),
("reason", [3]),
("<", [8]),
("> not a marker", [8]),
]
r, c = _stream_extract(parser, deltas_and_ids)
assert r == "reason> not a marker"
assert c == ""
# ---------------------------------------------------------------------------
class TestCountReasoningTokens:
def test_single_token_basic(self, single_token_parser):
# IDs: [100=<think>, 3=reasoning, 101=</think>, 4=content]
assert single_token_parser.count_reasoning_tokens([100, 3, 101, 4]) == 1
def test_single_token_no_end(self, single_token_parser):
# All tokens after start are reasoning
assert single_token_parser.count_reasoning_tokens([100, 3, 2]) == 2
def test_multi_token_basic(self, multi_token_parser):
# </think> encodes as [522, 26865]
# Full sequence: <think> reasoning </think> content
# IDs: [100, 3, 522, 26865, 4]
result = multi_token_parser.count_reasoning_tokens([100, 3, 522, 26865, 4])
assert result > 0
def test_multi_token_no_end(self, multi_token_parser):
# No end token → all tokens inside <think> are counted.
result = multi_token_parser.count_reasoning_tokens([100, 3, 2])
assert result > 0
def test_text_only_basic(self, text_only_parser):
assert text_only_parser.count_reasoning_tokens([100, 3, 101, 4]) == 1
def test_text_only_no_end(self, text_only_parser):
assert text_only_parser.count_reasoning_tokens([100, 3, 2]) == 2
# ---------------------------------------------------------------------------
# extract_content_ids
# ---------------------------------------------------------------------------
class TestExtractContentIds:
def test_single_token_basic(self, single_token_parser):
# [1, 101=</think>, 2, 3] → content starts after ID 101
result = single_token_parser.extract_content_ids([1, 101, 2, 3])
assert result == [2, 3]
def test_single_token_no_end(self, single_token_parser):
result = single_token_parser.extract_content_ids([1, 2, 3])
assert result == []
def test_multi_token_basic(self, multi_token_parser):
# </think> = [522, 26865]
# [3, 522, 26865, 4] → content after </think>
result = multi_token_parser.extract_content_ids([3, 522, 26865, 4])
assert 4 in result
def test_multi_token_no_end(self, multi_token_parser):
result = multi_token_parser.extract_content_ids([1, 2, 3])
assert result == []
def test_text_only_basic(self, text_only_parser):
result = text_only_parser.extract_content_ids([1, 101, 2, 3])
assert result == [2, 3]
def test_text_only_no_end(self, text_only_parser):
result = text_only_parser.extract_content_ids([1, 2, 3])
assert result == []
# ---------------------------------------------------------------------------
# Fixture request workaround (pytest "request" fixture shadowed by parametrize)
# ---------------------------------------------------------------------------
@pytest.fixture
def request_(request):
"""Proxy for the pytest ``request`` fixture to avoid name collisions
with the ``request_obj`` fixture used in extract_reasoning tests."""
return request
+457 -304
View File
@@ -6,6 +6,10 @@ AC-5 (delay pattern), AC-7 (stage processor), and AC-10 (registry)
without requiring the actual checkpoint or GPU.
"""
import asyncio
import time
from collections import OrderedDict
import pytest
import torch
@@ -232,7 +236,7 @@ class TestDelayPatternBehavior:
class TestSamplerMethods:
"""Test the actual _sample_audio_codes and _apply_delay_pattern_masking methods."""
"""Test the actual sampler and batched delay masking methods."""
def _make_minimal_talker(self):
"""Create a minimal talker-like object with sampler/masking methods."""
@@ -241,18 +245,65 @@ class TestSamplerMethods:
class FakeTalker:
num_codebooks = 8
codebook_size = 1026
_audio_state = {}
t = FakeTalker()
t._sample_audio_codes = mod.HiggsAudioV3TalkerForConditionalGeneration._sample_audio_codes.__get__(t)
t._apply_delay_pattern_masking = (
mod.HiggsAudioV3TalkerForConditionalGeneration._apply_delay_pattern_masking.__get__(t)
return t
def _make_batched_sampler_talker(self, num_rows=4):
"""Create a fake talker with GPU-resident sampler state helpers."""
from vllm_omni.model_executor.models.higgs_audio_v3 import higgs_audio_v3_talker as mod
class FakeTalker:
num_codebooks = 8
codebook_size = 1026
t = FakeTalker()
t._decode_last_codes = torch.arange(num_rows * t.num_codebooks, dtype=torch.long).view(
num_rows, t.num_codebooks
)
t._decode_has_codes = torch.tensor([True, False, True, True])[:num_rows].clone()
t._decode_delay_count = torch.tensor([0, 2, 7, 4], dtype=torch.int32)[:num_rows].clone()
t._decode_eoc_countdown = torch.tensor([-1, -1, 3, 1], dtype=torch.int32)[:num_rows].clone()
t._decode_generation_done = torch.tensor([False, False, False, True])[:num_rows].clone()
t._decode_active_audio_count = 0
t._codebook_index_cache = {}
t._row_index_cache = {}
t._boc_frame_cache = {}
t._last_audio_codes_buffer = None
t._last_audio_host_staging = None
t._last_audio_gpu_staging = None
t._last_audio_staging_event = None
t._audio_staging_events = []
t._audio_staging_event_cursor = 0
t._last_audio_codes = None
t._last_audio_code_valid = []
t._postprocess_cursor = 0
t._postprocess_audio_rows = 0
t._postprocess_audio_active_rows = 0
cls = mod.HiggsAudioV3TalkerForConditionalGeneration
for name in (
"_sample_audio_codes",
"_ensure_decode_state_capacity",
"_get_row_indices",
"_get_codebook_indices",
"_get_boc_frames",
"_get_audio_codes_buffer",
"_get_audio_gpu_staging_buffer",
"_get_audio_host_staging_buffer",
"_apply_delay_pattern_masking_batched",
"_update_delay_state_batched",
"_prefill_row_mask",
"_audio_seed_mask_from_step_input",
):
setattr(t, name, getattr(cls, name).__get__(t))
t._device_cache_key = cls._device_cache_key
return t
def test_sample_respects_mask(self):
"""Tokens masked to -inf must never be sampled."""
t = self._make_minimal_talker()
t = self._make_batched_sampler_talker(num_rows=1)
logits = torch.full((10, 1026), float("-inf"))
# Only token 500 is allowed for each row
logits[:, 500] = 0.0
@@ -262,7 +313,7 @@ class TestSamplerMethods:
def test_sample_all_masked_falls_back_to_argmax(self):
"""All-masked row should fall back to argmax (least-negative logit)."""
t = self._make_minimal_talker()
t = self._make_batched_sampler_talker(num_rows=1)
logits = torch.full((2, 1026), float("-inf"))
# Row 0: all masked
# Row 1: only token 42 allowed
@@ -275,11 +326,13 @@ class TestSamplerMethods:
"""During delay phase, codebooks beyond delay_count must have only BOC allowed."""
from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import BOC_ID
t = self._make_minimal_talker()
t = self._make_batched_sampler_talker(num_rows=1)
# Simulate delay_count=2 for batch row 0
t._audio_state[0] = {"num_delay": 2, "num_remaining_delays": None}
t._ensure_decode_state_capacity(1, torch.device("cpu"))
t._decode_delay_count[0] = 2
t._decode_eoc_countdown[0] = -1
cb_logits = torch.zeros(1, 8, 1026) # [1 audio row, 8 codebooks, 1026 vocab]
t._apply_delay_pattern_masking(cb_logits, [0])
t._apply_delay_pattern_masking_batched(cb_logits, torch.tensor([0], dtype=torch.long))
# CBs 3-7 should have only BOC allowed (everything else -inf)
for q in range(3, 8):
row = cb_logits[0, q]
@@ -292,10 +345,12 @@ class TestSamplerMethods:
"""Active codebooks during delay should have BOC disallowed."""
from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import BOC_ID
t = self._make_minimal_talker()
t._audio_state[0] = {"num_delay": 3, "num_remaining_delays": None}
t = self._make_batched_sampler_talker(num_rows=1)
t._ensure_decode_state_capacity(1, torch.device("cpu"))
t._decode_delay_count[0] = 3
t._decode_eoc_countdown[0] = -1
cb_logits = torch.zeros(1, 8, 1026)
t._apply_delay_pattern_masking(cb_logits, [0])
t._apply_delay_pattern_masking_batched(cb_logits, torch.tensor([0], dtype=torch.long))
# CBs 0-3 are active: BOC should be -inf
for q in range(4):
assert cb_logits[0, q, BOC_ID].item() == float("-inf")
@@ -304,10 +359,12 @@ class TestSamplerMethods:
"""Only codebook 0 should allow EOC during normal generation."""
from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import EOC_ID
t = self._make_minimal_talker()
t._audio_state[0] = {"num_delay": 8, "num_remaining_delays": None}
t = self._make_batched_sampler_talker(num_rows=1)
t._ensure_decode_state_capacity(1, torch.device("cpu"))
t._decode_delay_count[0] = 8
t._decode_eoc_countdown[0] = -1
cb_logits = torch.zeros(1, 8, 1026)
t._apply_delay_pattern_masking(cb_logits, [0])
t._apply_delay_pattern_masking_batched(cb_logits, torch.tensor([0], dtype=torch.long))
# CB0 should keep EOC
assert cb_logits[0, 0, EOC_ID].item() != float("-inf")
# CB1-7 should have EOC masked
@@ -321,11 +378,13 @@ class TestSamplerMethods:
EOC_ID,
)
t = self._make_minimal_talker()
t = self._make_batched_sampler_talker(num_rows=1)
# Ramp-down with 4 remaining delays: lock CBs 0-3 to EOC
t._audio_state[0] = {"num_delay": 8, "num_remaining_delays": 4}
t._ensure_decode_state_capacity(1, torch.device("cpu"))
t._decode_delay_count[0] = 8
t._decode_eoc_countdown[0] = 4
cb_logits = torch.zeros(1, 8, 1026)
t._apply_delay_pattern_masking(cb_logits, [0])
t._apply_delay_pattern_masking_batched(cb_logits, torch.tensor([0], dtype=torch.long))
# CBs 0-3 locked: only EOC allowed
for q in range(4):
row = cb_logits[0, q]
@@ -337,6 +396,195 @@ class TestSamplerMethods:
assert cb_logits[0, q, BOC_ID].item() == float("-inf")
assert cb_logits[0, q, EOC_ID].item() == float("-inf")
def test_batched_delay_masking_all_rows_matches_sparse_path(self):
"""The all-row fast path must preserve sparse path masking semantics."""
torch.manual_seed(0)
sparse = self._make_batched_sampler_talker()
all_rows = self._make_batched_sampler_talker()
rows = torch.arange(4, dtype=torch.long)
logits_sparse = torch.randn(4, 8, 1026)
logits_all_rows = logits_sparse.clone()
sparse._apply_delay_pattern_masking_batched(logits_sparse, rows, all_rows=False)
all_rows._apply_delay_pattern_masking_batched(logits_all_rows, rows, all_rows=True)
assert torch.equal(logits_all_rows, logits_sparse)
def test_delay_state_update_all_rows_matches_sparse_path(self):
"""The all-row fast path must update state and staging like index_copy_."""
from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import (
EOC_ID,
)
sparse = self._make_batched_sampler_talker()
all_rows = self._make_batched_sampler_talker()
rows = torch.arange(4, dtype=torch.long)
codes = torch.tensor(
[
[10, 11, 12, 13, 14, 15, 16, 17],
[20, 21, 22, 23, 24, 25, 26, 27],
[30, 31, EOC_ID, 33, 34, 35, 36, 37],
[40, 41, 42, 43, 44, 45, 46, 47],
],
dtype=torch.long,
)
code_row_mask = torch.tensor([True, False, True, True])
sparse._update_delay_state_batched(
codes.clone(), rows, 4, torch.device("cpu"), code_row_mask=code_row_mask, all_rows=False
)
all_rows._update_delay_state_batched(
codes.clone(), rows, 4, torch.device("cpu"), code_row_mask=code_row_mask, all_rows=True
)
assert torch.equal(all_rows._decode_delay_count, sparse._decode_delay_count)
assert torch.equal(all_rows._decode_eoc_countdown, sparse._decode_eoc_countdown)
assert torch.equal(all_rows._decode_generation_done, sparse._decode_generation_done)
assert torch.equal(all_rows._decode_last_codes, sparse._decode_last_codes)
assert torch.equal(all_rows._decode_has_codes, sparse._decode_has_codes)
assert all_rows._last_audio_codes is None
assert sparse._last_audio_codes is None
assert torch.equal(all_rows._last_audio_host_staging, sparse._last_audio_host_staging)
def test_direct_audio_sampler_does_not_override_done_tokens(self):
"""Done rows are represented directly in GPU sampled tokens."""
from vllm_omni.model_executor.models.higgs_audio_v3 import higgs_audio_v3_talker as mod
t = self._make_batched_sampler_talker(num_rows=2)
t._resolve_token_ids = lambda: None
t._audio_continuation_id = 99999
t._eos_token_id = 151671
t._last_logits_hidden = torch.zeros(2, 16)
t._last_step_input_ids = torch.tensor([99999, 99999])
t._last_step_query_start_loc = None
t._decode_has_codes = torch.tensor([True, True])
t._decode_generation_done = torch.tensor([False, True])
t._decode_delay_count = torch.zeros(2, dtype=torch.long)
t._decode_eoc_countdown = torch.full((2,), -1, dtype=torch.long)
t._fast_audio_direct_rows = 2
t._last_audio_done_flags = None
t._fast_audio_sampler_gpu_fallback_reason = lambda **kwargs: None
t._audio_codebook_logits_from_rows = lambda hidden, rows, all_rows=False: torch.zeros(2, 8, 1026)
t._apply_delay_pattern_masking_batched = lambda cb_logits, audio_rows, all_rows=False: None
t._sample_audio_codes = lambda logits_2d: torch.zeros(int(logits_2d.shape[0]), dtype=torch.long)
t._update_delay_state_batched = lambda *args, **kwargs: None
t.sample = mod.HiggsAudioV3TalkerForConditionalGeneration.sample.__get__(t)
sampler_output = t.sample(torch.zeros(2, 200000), sampling_metadata=object())
assert not getattr(
mod.HiggsAudioV3TalkerForConditionalGeneration, "supports_sampled_token_ids_cpu_override", False
)
assert sampler_output.sampled_token_ids.tolist() == [[99999], [151671]]
def test_direct_audio_sampler_uses_gpu_tokens_for_active_rows(self):
"""Direct audio sampling leaves scheduler-visible tokens to the GPU tensor."""
from vllm_omni.model_executor.models.higgs_audio_v3 import higgs_audio_v3_talker as mod
t = self._make_batched_sampler_talker(num_rows=2)
t._resolve_token_ids = lambda: None
t._audio_continuation_id = 99999
t._eos_token_id = 151671
t._last_logits_hidden = torch.zeros(2, 16)
t._last_step_input_ids = torch.tensor([99999, 99999])
t._last_step_query_start_loc = None
t._decode_has_codes = torch.tensor([True, True])
t._decode_generation_done = torch.tensor([False, False])
t._decode_delay_count = torch.zeros(2, dtype=torch.long)
t._decode_eoc_countdown = torch.full((2,), -1, dtype=torch.long)
t._fast_audio_direct_rows = 2
t._last_audio_done_flags = [0, 0]
t._fast_audio_sampler_gpu_fallback_reason = lambda **kwargs: None
t._audio_codebook_logits_from_rows = lambda hidden, rows, all_rows=False: torch.zeros(2, 8, 1026)
t._apply_delay_pattern_masking_batched = lambda cb_logits, audio_rows, all_rows=False: None
t._sample_audio_codes = lambda logits_2d: torch.zeros(int(logits_2d.shape[0]), dtype=torch.long)
t._update_delay_state_batched = lambda *args, **kwargs: None
t.sample = mod.HiggsAudioV3TalkerForConditionalGeneration.sample.__get__(t)
sampler_output = t.sample(torch.zeros(2, 200000), sampling_metadata=object())
assert sampler_output.sampled_token_ids.tolist() == [[99999], [99999]]
def test_terminal_rampdown_frame_is_emitted_before_done(self):
"""The final ramp-down frame must be emitted, not replaced by a done marker."""
from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import EOC_ID
t = self._make_batched_sampler_talker(num_rows=1)
t._decode_delay_count[0] = 8
t._decode_eoc_countdown[0] = 1
t._decode_generation_done[0] = False
t._decode_has_codes[0] = True
codes = torch.tensor([[10, 11, 12, 13, 14, 15, 16, 17]], dtype=torch.long)
t._update_delay_state_batched(
codes,
torch.tensor([0], dtype=torch.long),
1,
torch.device("cpu"),
code_row_mask=torch.tensor([True]),
all_rows=True,
)
staged = t._last_audio_host_staging[0]
assert staged[t.num_codebooks].item() == 1 # valid
assert staged[t.num_codebooks + 1].item() == 1 # done
assert not (staged[: t.num_codebooks] == -1).any()
assert staged[: t.num_codebooks - 1].eq(EOC_ID).all()
assert t._decode_generation_done[0].item() is True
assert t._decode_has_codes[0].item() is False
def test_decode_state_growth_preserves_inflight_rows(self):
"""Growing decode buffers must copy old rows and initialize new rows inactive."""
t = self._make_batched_sampler_talker(num_rows=2)
old_codes = t._decode_last_codes.clone()
old_has = t._decode_has_codes.clone()
old_delay = t._decode_delay_count.clone()
old_rem = t._decode_eoc_countdown.clone()
old_done = t._decode_generation_done.clone()
t._ensure_decode_state_capacity(5, torch.device("cpu"))
assert t._decode_last_codes.shape[0] >= 5
assert torch.equal(t._decode_last_codes[:2], old_codes)
assert torch.equal(t._decode_has_codes[:2], old_has)
assert torch.equal(t._decode_delay_count[:2], old_delay)
assert torch.equal(t._decode_eoc_countdown[:2], old_rem)
assert torch.equal(t._decode_generation_done[:2], old_done)
assert not t._decode_has_codes[2:5].any()
assert not t._decode_generation_done[2:5].any()
assert t._decode_eoc_countdown[2:5].eq(-1).all()
def test_mixed_batch_prefill_mask_targets_request_rows(self):
"""Mixed prefill/decode must reset only prefill request rows."""
t = self._make_batched_sampler_talker(num_rows=3)
t._last_step_query_start_loc = torch.tensor([0, 1, 4, 5])
mask = t._prefill_row_mask(3, torch.device("cpu"))
assert torch.equal(mask, torch.tensor([False, True, False]))
def test_mixed_batch_seed_mask_maps_token_tails_to_request_rows(self):
"""Seed detection should return one flag per request, not per token."""
t = self._make_batched_sampler_talker(num_rows=3)
t._audio_continuation_id = 99999
t._last_step_query_start_loc = torch.tensor([0, 1, 4, 5])
t._last_step_input_ids = torch.tensor([99999, 11, 12, 13, 99999])
mask = t._audio_seed_mask_from_step_input(3, torch.device("cpu"))
assert torch.equal(mask, torch.tensor([True, False, True]))
def test_mixed_batch_seed_mask_allows_prefill_tail_audio_token(self):
"""A prefill span ending in <|audio|> starts audio-code generation."""
t = self._make_batched_sampler_talker(num_rows=3)
t._audio_continuation_id = 99999
t._last_step_query_start_loc = torch.tensor([0, 1, 4, 5])
t._last_step_input_ids = torch.tensor([99999, 11, 12, 99999, 99999])
mask = t._audio_seed_mask_from_step_input(3, torch.device("cpu"))
assert torch.equal(mask, torch.tensor([True, True, True]))
# ---- AC-6: Feedback Method Tests ----
@@ -348,10 +596,14 @@ class TestFeedbackMethods:
class FakeTalker:
_last_audio_codes = torch.tensor([[100, 200, 300, 400, 500, 600, 700, 800]])
_last_audio_code_valid = [True]
_last_audio_host_staging = None
_last_audio_staging_event = None
_postprocess_cursor = 0
t = FakeTalker()
t.postprocess = mod.HiggsAudioV3TalkerForConditionalGeneration.postprocess.__get__(t)
t._postprocess_impl = mod.HiggsAudioV3TalkerForConditionalGeneration._postprocess_impl.__get__(t)
result = t.postprocess(torch.zeros(1, 64))
assert "codes" in result
assert "audio" in result["codes"]
@@ -363,10 +615,14 @@ class TestFeedbackMethods:
class FakeTalker:
_last_audio_codes = torch.tensor([[-1, -1, -1, -1, -1, -1, -1, -1]])
_last_audio_code_valid = [False]
_last_audio_host_staging = None
_last_audio_staging_event = None
_postprocess_cursor = 0
t = FakeTalker()
t.postprocess = mod.HiggsAudioV3TalkerForConditionalGeneration.postprocess.__get__(t)
t._postprocess_impl = mod.HiggsAudioV3TalkerForConditionalGeneration._postprocess_impl.__get__(t)
result = t.postprocess(torch.zeros(1, 64))
assert result == {}
@@ -378,10 +634,14 @@ class TestFeedbackMethods:
_last_audio_codes = torch.tensor(
[[100, 200, 300, 400, 500, 600, 700, 800], [-1, -1, -1, -1, -1, -1, -1, -1]]
)
_last_audio_code_valid = [True, False]
_last_audio_host_staging = None
_last_audio_staging_event = None
_postprocess_cursor = 0
t = FakeTalker()
t.postprocess = mod.HiggsAudioV3TalkerForConditionalGeneration.postprocess.__get__(t)
t._postprocess_impl = mod.HiggsAudioV3TalkerForConditionalGeneration._postprocess_impl.__get__(t)
r1 = t.postprocess(torch.zeros(1, 64))
assert "codes" in r1
r2 = t.postprocess(torch.zeros(1, 64))
@@ -405,14 +665,23 @@ class TestAudioFeedback:
codebook_size = 1026
_audio_continuation_id = 99999 # fake audio token
_last_step_query_start_loc = None
_audio_state = {}
_decode_active_audio_count = 0
_decode_last_codes = torch.zeros(4, 8, dtype=torch.long)
_decode_has_codes = torch.zeros(4, dtype=torch.bool)
_use_external_decode_cudagraph = False
multimodal_embedding = embed
model = type("M", (), {"embed_tokens": lambda self, ids: torch.zeros(ids.shape[0], 16)})()
t = FakeTalker()
t._decode_last_codes = torch.zeros(256, 8, dtype=torch.long)
t._decode_has_codes = torch.zeros(256, dtype=torch.bool)
t._apply_audio_feedback = mod.HiggsAudioV3TalkerForConditionalGeneration._apply_audio_feedback.__get__(t)
t._is_single_token_decode_step = (
mod.HiggsAudioV3TalkerForConditionalGeneration._is_single_token_decode_step.__get__(t)
)
t._step_request_count = mod.HiggsAudioV3TalkerForConditionalGeneration._step_request_count.__get__(t)
t._decode_request_token_positions = (
mod.HiggsAudioV3TalkerForConditionalGeneration._decode_request_token_positions.__get__(t)
)
t._ensure_decode_state_capacity = lambda min_bs, device: None
return t
def test_text_positions_unchanged(self):
@@ -428,9 +697,11 @@ class TestAudioFeedback:
"""Audio position with state should have its embedding replaced."""
t = self._make_feedback_talker()
audio_id = t._audio_continuation_id
t._audio_state[1] = {"last_codes": torch.zeros(8, dtype=torch.long)}
t._decode_last_codes[1] = torch.zeros(8, dtype=torch.long)
t._decode_has_codes[1] = True
t._decode_active_audio_count = 1
t._ensure_decode_state_capacity = lambda min_bs, device: None
t._last_step_query_start_loc = torch.tensor([0, 1, 2, 3])
input_ids = torch.tensor([1, audio_id, 3])
hidden = torch.zeros(3, 16)
result = t._apply_audio_feedback(hidden, input_ids)
@@ -444,12 +715,80 @@ class TestAudioFeedback:
"""Audio position without state should not be replaced."""
t = self._make_feedback_talker()
audio_id = t._audio_continuation_id
# No state for row 0 — _decode_has_codes[0] defaults to False
# No state for row 0
input_ids = torch.tensor([audio_id])
hidden = torch.zeros(1, 16)
result = t._apply_audio_feedback(hidden, input_ids)
assert torch.equal(result, hidden)
def test_prefill_span_does_not_receive_audio_feedback(self):
"""A prefill span can have numel == hidden rows but must not be treated as decode."""
t = self._make_feedback_talker()
t._last_step_input_ids = torch.tensor([1, 2, 3, 4])
t._last_step_query_start_loc = torch.tensor([0, 4])
t._decode_last_codes[0] = torch.zeros(8, dtype=torch.long)
t._decode_has_codes[0] = True
t._decode_active_audio_count = 1
hidden = torch.zeros(4, 16)
result = t._apply_audio_feedback(hidden, t._last_step_input_ids)
assert torch.equal(result, hidden)
def test_multi_request_decode_receives_audio_feedback(self):
"""Concurrent decode has numel > 1 but each request span is exactly one token."""
t = self._make_feedback_talker()
t._last_step_input_ids = torch.tensor([10, 11, 12, 13])
t._last_step_query_start_loc = torch.tensor([0, 1, 2, 3, 4])
t._decode_last_codes[2] = torch.zeros(8, dtype=torch.long)
t._decode_has_codes[2] = True
t._decode_active_audio_count = 1
hidden = torch.zeros(4, 16)
result = t._apply_audio_feedback(hidden, t._last_step_input_ids)
assert result[2].abs().sum() > 0
assert result[0].abs().sum() == 0
assert result[1].abs().sum() == 0
assert result[3].abs().sum() == 0
def test_external_decode_graph_capture_keeps_feedback_ops_without_query_start_loc(self):
"""FULL_DECODE capture has no request qsl but still needs dense decode feedback ops."""
t = self._make_feedback_talker()
t._use_external_decode_cudagraph = True
t._last_step_input_ids = torch.tensor([10, 11, 12, 13])
t._last_step_query_start_loc = None
t._decode_last_codes[2] = torch.zeros(8, dtype=torch.long)
t._decode_has_codes[2] = True
hidden = torch.zeros(4, 16)
result = t._apply_audio_feedback(hidden, t._last_step_input_ids)
assert result[2].abs().sum() > 0
assert result[0].abs().sum() == 0
assert result[1].abs().sum() == 0
assert result[3].abs().sum() == 0
def test_mixed_prefill_decode_receives_feedback_only_on_decode_rows(self):
"""Mixed batches should skip prefill spans but keep audio feedback for decode spans."""
t = self._make_feedback_talker()
t._last_step_input_ids = torch.tensor([99999, 21, 22, 23, 99999])
t._last_step_query_start_loc = torch.tensor([0, 1, 4, 5])
t._decode_last_codes[0] = torch.zeros(8, dtype=torch.long)
t._decode_last_codes[2] = torch.zeros(8, dtype=torch.long)
t._decode_has_codes[0] = True
t._decode_has_codes[2] = True
t._decode_active_audio_count = 2
hidden = torch.zeros(5, 16)
result = t._apply_audio_feedback(hidden, t._last_step_input_ids)
assert result[0].abs().sum() > 0
assert result[4].abs().sum() > 0
assert result[1].abs().sum() == 0
assert result[2].abs().sum() == 0
assert result[3].abs().sum() == 0
# ---- AC-8: Codec Strictness ----
@@ -775,6 +1114,23 @@ class TestStageInputProcessor:
with pytest.raises(ValueError, match="Not enough frames"):
_revert_delay_pattern(codes)
def test_talker2code2wav_skips_too_few_frames_without_crashing(self):
from vllm_omni.model_executor.stage_input_processors.higgs_audio_v3 import (
talker2code2wav,
)
class Output:
multimodal_output = {"codes": {"audio": torch.zeros(5, 8, dtype=torch.long)}}
class TalkerOutput:
finished = True
outputs = [Output()]
result = talker2code2wav([TalkerOutput()])
assert len(result) == 1
assert result[0]["prompt_token_ids"] == []
def test_filter_real_code_frames(self):
from vllm_omni.model_executor.stage_input_processors.higgs_audio_v3 import (
_filter_real_code_frames,
@@ -826,8 +1182,14 @@ class TestRegistry:
# __file__ = tests/unit/higgs_audio_v3/test_*.py → 4 dirnames to repo root
repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
yaml_path = os.path.join(repo_root, "vllm_omni", "deploy", "higgs_multimodal_qwen3.yaml")
assert os.path.isfile(yaml_path), f"Deploy YAML not found at {yaml_path}"
deploy_dir = os.path.join(repo_root, "vllm_omni", "deploy")
for name in (
"higgs_multimodal_qwen3.yaml",
"higgs_multimodal_qwen3_high_throughput.yaml",
"higgs_multimodal_qwen3_low_latency.yaml",
):
yaml_path = os.path.join(deploy_dir, name)
assert os.path.isfile(yaml_path), f"Deploy YAML not found at {yaml_path}"
# ---- AC-3: Prompt Builder ----
@@ -914,298 +1276,89 @@ class TestPromptBuilder:
assert 151704 not in ids # <|ref_text|>
# ---- Regression: ramp-down done-before-emit off-by-one ----
class TestVoiceCloneReferenceCache:
def test_audio_tokenizer_dir_env_accepts_parent_or_subdir(self, tmp_path, monkeypatch):
from vllm_omni.model_executor.models.higgs_audio_v2 import higgs_audio_v2_tokenizer as tok
parent = tmp_path / "OmniVoice"
subdir = parent / "audio_tokenizer"
subdir.mkdir(parents=True)
(subdir / "config.json").write_text("{}", encoding="utf-8")
def _simulate_delay_state_machine(
sampled_codes_per_step: list[torch.Tensor],
) -> list[torch.Tensor]:
"""Reproduce the delay-pattern state machine from sample().
monkeypatch.setenv("HIGGS_AUDIO_TOKENIZER_PATH", str(parent))
assert tok._resolve_audio_tokenizer_dir() == str(subdir)
Feeds ``sampled_codes_per_step`` (each [Q]) through the exact same
logic as HiggsAudioV3TalkerForConditionalGeneration.sample() and
returns the list of emitted frames (non -1 codes).
"""
from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import (
BOC_ID,
EOC_ID,
)
monkeypatch.setenv("HIGGS_AUDIO_TOKENIZER_PATH", str(subdir))
assert tok._resolve_audio_tokenizer_dir() == str(subdir)
num_codebooks = 8
bos = BOC_ID
eos_stream = EOC_ID
def test_higgs_v3_ref_code_cache_returns_clone(self):
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
state: dict = {
"num_delay": 0,
"num_remaining_delays": None,
"audio_out_ids": None,
"last_codes": torch.full((num_codebooks,), bos, dtype=torch.long),
"should_terminate": False,
}
serving = object.__new__(OmniOpenAIServingSpeech)
serving._higgs_audio_v3_ref_code_cache = OrderedDict()
serving._higgs_audio_v3_ref_code_cache_bytes = 0
serving._higgs_audio_v3_ref_code_inflight = {}
emitted: list[torch.Tensor] = []
for step_codes in sampled_codes_per_step:
if state["should_terminate"]:
break
codes = torch.arange(16, dtype=torch.long).reshape(2, 8)
serving._put_higgs_audio_v3_ref_codes("ref-a", codes)
num_delay = state["num_delay"]
num_remaining_delays = state["num_remaining_delays"]
this_codes = step_codes.clone()
cached = serving._get_higgs_audio_v3_ref_codes("ref-a")
assert cached is not None
cached.fill_(0)
if num_delay + 1 < num_codebooks:
this_codes[num_delay + 1 :] = bos
num_delay += 1
cached_again = serving._get_higgs_audio_v3_ref_codes("ref-a")
assert cached_again is not None
assert torch.equal(cached_again, codes)
if num_remaining_delays is not None:
this_codes[: num_codebooks - num_remaining_delays] = eos_stream
num_remaining_delays -= 1
else:
eos_positions = (this_codes == eos_stream).nonzero(as_tuple=False).reshape(-1)
if eos_positions.numel() > 0:
last_eos_idx = int(eos_positions[-1].item())
this_codes[: last_eos_idx + 1] = eos_stream
num_remaining_delays = num_codebooks - last_eos_idx - 1
def test_higgs_v3_ref_code_inflight_deduplicates_concurrent_encode(self):
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
if num_remaining_delays is not None and num_remaining_delays <= 0:
state["num_delay"] = 0
state["num_remaining_delays"] = None
state["should_terminate"] = True
continue
serving = object.__new__(OmniOpenAIServingSpeech)
serving._higgs_audio_v3_ref_code_cache = OrderedDict()
serving._higgs_audio_v3_ref_code_cache_bytes = 0
serving._higgs_audio_v3_ref_code_inflight = {}
calls = 0
state["num_delay"] = num_delay
state["num_remaining_delays"] = num_remaining_delays
state["last_codes"] = this_codes.clone()
emitted.append(this_codes)
def encode_reference_audio(_wav, _sr):
nonlocal calls
calls += 1
time.sleep(0.05)
return torch.arange(16, dtype=torch.long).reshape(2, 8)
return emitted
def apply_delay_pattern(codes):
return codes + 1
async def run():
return await asyncio.gather(
*[
serving._resolve_higgs_audio_v3_ref_codes(
"ref-a",
object(),
24000,
encode_reference_audio,
apply_delay_pattern,
)
for _ in range(3)
]
)
class TestRampDownOffByOne:
"""Regression tests for the done-before-emit off-by-one bug.
results = asyncio.run(run())
assert calls == 1
assert sum(int(inflight_wait) for _, _, inflight_wait in results) == 2
for codes, cache_hit, _ in results:
assert cache_hit is False
assert torch.equal(codes, torch.arange(16, dtype=torch.long).reshape(2, 8) + 1)
When EOC fires early, the ramp-down must emit enough frames so that
Stage1's _revert_delay_pattern receives T >= Q = 8.
"""
def test_eoc_at_step0_terminates(self):
"""EOC on cb0 at step 0 terminates; T < Q is handled by Stage1 defensive try/except."""
from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import EOC_ID
Q = 8
steps = []
for s in range(20):
codes = torch.randint(0, 1024, (Q,))
if s == 0:
codes[0] = EOC_ID
steps.append(codes)
emitted = _simulate_delay_state_machine(steps)
assert len(emitted) == Q - 1, f"EOC at step 0: expected {Q - 1} emitted frames, got {len(emitted)}"
def test_eoc_at_step1_emits_enough_frames(self):
"""EOC on cb0 at step 1 must produce >= Q emitted frames."""
from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import EOC_ID
Q = 8
steps = []
for s in range(20):
codes = torch.randint(0, 1024, (Q,))
if s == 1:
codes[0] = EOC_ID
steps.append(codes)
emitted = _simulate_delay_state_machine(steps)
assert len(emitted) >= Q, f"EOC at step 1: expected >= {Q} emitted frames, got {len(emitted)}"
def test_eoc_after_full_rampup_emits_enough(self):
"""EOC after all codebooks are active produces T >> Q."""
from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import EOC_ID
Q = 8
eoc_step = 10
steps = []
for s in range(30):
codes = torch.randint(0, 1024, (Q,))
if s == eoc_step:
codes[0] = EOC_ID
steps.append(codes)
emitted = _simulate_delay_state_machine(steps)
assert len(emitted) >= Q, f"EOC at step {eoc_step}: expected >= {Q} emitted frames, got {len(emitted)}"
def test_last_rampdown_frame_not_all_negative(self):
"""The last emitted ramp-down frame must contain real codes, not -1."""
from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import EOC_ID
Q = 8
steps = []
for s in range(20):
codes = torch.randint(0, 1024, (Q,))
if s == 1:
codes[0] = EOC_ID
steps.append(codes)
emitted = _simulate_delay_state_machine(steps)
last = emitted[-1]
assert (last >= 0).all(), f"Last ramp-down frame contains negative values: {last.tolist()}"
def test_emitted_frames_revertible_by_stage1(self):
"""Emitted frames from EOC-at-step-1 must be revertible by _revert_delay_pattern."""
from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import EOC_ID
from vllm_omni.model_executor.stage_input_processors.higgs_audio_v3 import (
_revert_delay_pattern,
cached, cache_hit, inflight_wait = asyncio.run(
serving._resolve_higgs_audio_v3_ref_codes(
"ref-a",
object(),
24000,
encode_reference_audio,
apply_delay_pattern,
)
)
Q = 8
steps = []
for s in range(20):
codes = torch.randint(0, 1024, (Q,))
if s == 1:
codes[0] = EOC_ID
steps.append(codes)
emitted = _simulate_delay_state_machine(steps)
# Stack to [T, Q], transpose to [Q, T]
codes_qt = torch.stack(emitted, dim=0).t().contiguous()
assert codes_qt.shape[0] == Q
assert codes_qt.shape[1] >= Q
# Must not raise
reverted = _revert_delay_pattern(codes_qt)
assert reverted.shape[0] == Q
assert reverted.shape[1] == codes_qt.shape[1] - Q + 1
class TestStage1DefensiveHandling:
"""Stage1 must not crash the server when receiving T < Q frames."""
def test_revert_delay_pattern_raises_on_insufficient_frames(self):
"""_revert_delay_pattern correctly raises ValueError for T < Q."""
from vllm_omni.model_executor.stage_input_processors.higgs_audio_v3 import (
_revert_delay_pattern,
)
codes = torch.zeros(8, 7) # T=7 < Q=8
with pytest.raises(ValueError, match="Not enough frames"):
_revert_delay_pattern(codes)
def test_talker2code2wav_survives_insufficient_frames(self):
"""talker2code2wav must not crash when audio_codes has T < Q frames."""
from vllm_omni.model_executor.stage_input_processors.higgs_audio_v3 import (
talker2code2wav,
)
# Build a fake talker output with only 7 frames (T=7 < Q=8)
class FakeOutput:
def __init__(self):
self.multimodal_output = {
"codes": {"audio": torch.zeros(7, 8, dtype=torch.long)},
}
class FakeTalkerOutput:
finished = True
outputs = [FakeOutput()]
# Must not raise — should return gracefully
result = talker2code2wav([FakeTalkerOutput()])
assert len(result) == 1
# Result is an OmniTokensPrompt with empty prompt_token_ids
out = result[0]
token_ids = out.prompt_token_ids if hasattr(out, "prompt_token_ids") else out.get("prompt_token_ids", [])
assert token_ids == []
# ---- Regression: buffer growth + prefill guard ----
class TestBufferGrowthPreservesState:
"""Bug #1: buffer growth in _apply_audio_feedback must not wipe existing state."""
def _make_feedback_talker_with_buffers(self):
from vllm_omni.model_executor.models.higgs_audio_v3 import higgs_audio_v3_talker as mod
embed = mod.HiggsFusedMultiTextEmbedding(num_codebooks=8, vocab_size=1026, hidden_size=16)
torch.nn.init.ones_(embed.weight)
class FakeTalker:
num_codebooks = 8
codebook_size = 1026
multimodal_embedding = embed
t = FakeTalker()
t._decode_last_codes = torch.zeros(4, 8, dtype=torch.long)
t._decode_has_codes = torch.zeros(4, dtype=torch.bool)
t._apply_audio_feedback = mod.HiggsAudioV3TalkerForConditionalGeneration._apply_audio_feedback.__get__(t)
return t
def test_buffer_growth_preserves_existing_codes(self):
"""Growing the buffer must copy old _decode_last_codes data."""
t = self._make_feedback_talker_with_buffers()
# Set state for slot 2
t._decode_last_codes[2] = torch.tensor([100, 200, 300, 400, 500, 600, 700, 800])
t._decode_has_codes[2] = True
# Trigger growth by calling with bs=8 > current capacity 4
hidden = torch.randn(8, 16)
t._apply_audio_feedback(hidden, torch.zeros(8, dtype=torch.long))
# After growth, slot 2 data must still be present
assert t._decode_has_codes[2].item() is True
assert t._decode_last_codes[2, 0].item() == 100
def test_buffer_growth_new_slots_are_inactive(self):
"""New slots from buffer growth must have has_codes=False."""
t = self._make_feedback_talker_with_buffers()
t._decode_has_codes[1] = True
hidden = torch.randn(8, 16)
t._apply_audio_feedback(hidden, torch.zeros(8, dtype=torch.long))
# Slots 4-7 (new) must be inactive
assert not t._decode_has_codes[4].item()
assert not t._decode_has_codes[7].item()
# Slot 1 (old) must still be active
assert t._decode_has_codes[1].item()
class TestPrefillGuard:
"""Bug #2: _apply_audio_feedback must NOT run during prefill.
The prefill detection uses attn_metadata.max_query_len (== 1 for decode,
> 1 for prefill). This correctly handles concurrent decode (N requests,
each contributing 1 token → numel=N but max_query_len=1).
"""
@staticmethod
def _is_prefill_via_max_query_len(max_query_len: int | None, input_ids: torch.Tensor) -> bool:
"""Mirror the actual detection logic in forward()."""
if max_query_len is not None:
return int(max_query_len) > 1
return input_ids is not None and int(input_ids.numel()) > 1
def test_prefill_detected_via_max_query_len(self):
"""Prefill: max_query_len > 1 → is_prefill=True."""
input_ids = torch.tensor([1, 2, 3, 4, 5])
assert self._is_prefill_via_max_query_len(5, input_ids) is True
def test_single_decode_detected_via_max_query_len(self):
"""Single-request decode: max_query_len=1 → is_prefill=False."""
input_ids = torch.tensor([42])
assert self._is_prefill_via_max_query_len(1, input_ids) is False
def test_concurrent_decode_not_misclassified(self):
"""Multi-request decode: numel=4 but max_query_len=1 → is_prefill=False.
This is the regression caught by reviewer: with c=4 concurrent decode
requests, input_ids.numel()==4 but each request contributes exactly 1
token, so max_query_len==1 and audio feedback must still apply.
"""
input_ids = torch.tensor([42, 43, 44, 45]) # 4 concurrent decode
assert self._is_prefill_via_max_query_len(1, input_ids) is False
def test_fallback_when_max_query_len_unavailable(self):
"""When attn_metadata is unavailable, fall back to numel heuristic."""
prefill_ids = torch.tensor([1, 2, 3])
assert self._is_prefill_via_max_query_len(None, prefill_ids) is True
decode_ids = torch.tensor([42])
assert self._is_prefill_via_max_query_len(None, decode_ids) is False
assert calls == 1
assert cache_hit is True
assert inflight_wait is False
assert torch.equal(cached, torch.arange(16, dtype=torch.long).reshape(2, 8) + 1)
+53
View File
@@ -0,0 +1,53 @@
# Higgs-Audio V3 Deploy Profiles
Higgs-Audio V3 has two Stage 0 graph profiles. They are intentionally separate
because the graph paths are mutually exclusive.
## High Throughput
Use `higgs_multimodal_qwen3_high_throughput.yaml` for medium/high concurrency
serving. This profile keeps Stage 0 `enforce_eager: true`, which preserves the
Higgs-specific local MLP CUDA graph path. It is the default production profile
for throughput-oriented serving.
`higgs_multimodal_qwen3.yaml` is kept as the auto-discovered default deploy
config for `model_type=higgs_multimodal_qwen3`, and matches the high-throughput
profile.
```bash
vllm-omni serve bosonai/higgs-audio-v3-tts-4b \
--omni --trust-remote-code \
--deploy-config vllm_omni/deploy/higgs_multimodal_qwen3_high_throughput.yaml
```
## Low Latency
Use `higgs_multimodal_qwen3_low_latency.yaml` for low-concurrency serving
(for example c1-c4) where Stage 0 decode launch overhead dominates. This profile
sets Stage 0 `enforce_eager: false` and explicitly enables vLLM
`FULL_DECODE_ONLY` CUDA graph:
```yaml
compilation_config:
cudagraph_capture_sizes: [1, 2, 4, 8, 16]
cudagraph_mode: FULL_DECODE_ONLY
cudagraph_num_of_warmups: 1
```
FULL_DECODE is controlled by deploy configuration, not by an environment
variable. When this external decode graph is active, the Higgs talker disables
the local MLP CUDA graph automatically.
```bash
vllm-omni serve bosonai/higgs-audio-v3-tts-4b \
--omni --trust-remote-code \
--deploy-config vllm_omni/deploy/higgs_multimodal_qwen3_low_latency.yaml
```
## Notes
- Stage 1 remains `enforce_eager: true` in both profiles.
- Keep `VLLM_USE_DEEP_GEMM=0` and `VLLM_MOE_USE_DEEP_GEMM=0` for this model
unless DeepGEMM support is revalidated.
- Revalidate end-to-end throughput and audio quality before changing the default
auto-discovered config.
@@ -1,5 +1,11 @@
# Higgs-audio v3 deploy: Stage 0 (talker) -> Stage 1 (code2wav) via shared memory.
#
# Auto-discovered default for model_type=higgs_multimodal_qwen3. This file is
# the high-throughput profile and intentionally keeps Stage 0 enforce_eager=true
# so the Higgs-specific local MLP CUDA graph path remains active. For low
# concurrency latency-sensitive serving, use
# higgs_multimodal_qwen3_low_latency.yaml explicitly.
#
# Plain TTS only (no voice cloning). async_chunk streams codec chunks from
# Stage 0 to Stage 1 as they are produced (TTFA approaches one AR loop's
# worth of work). Stage 1 honors meta.left_context_size + meta.right_holdback_size
@@ -32,6 +38,7 @@ stages:
max_num_seqs: 16
gpu_memory_utilization: 0.6
enforce_eager: true
attention_backend: FLASHINFER
trust_remote_code: true
# Stage-0 prefix caching is on by default. The talker's HS opt-out and
# deferred ``codes.audio`` mm-output write (see higgs_audio_v3_talker.py)
@@ -0,0 +1,80 @@
# Higgs-audio v3 deploy, high-throughput profile.
#
# This is the default production profile for medium/high concurrency serving.
# Stage 0 keeps enforce_eager=true so Higgs' local MLP CUDA graph remains active.
# Do not enable vLLM FULL_DECODE graph here: it replaces the local MLP graph
# path instead of stacking on top of it.
#
# Plain TTS only (no voice cloning). async_chunk streams codec chunks from
# Stage 0 to Stage 1 as they are produced (TTFA approaches one AR loop's
# worth of work). Stage 1 honors meta.left_context_size + meta.right_holdback_size
# so the per-chunk codec windows stitch into a coherent PCM stream.
async_chunk: true
connectors:
connector_of_shared_memory:
name: SharedMemoryConnector
extra:
shm_threshold_bytes: 65536
# Stream PCM bytes back to the client as Stage 1 emits each chunk.
codec_streaming: true
# Streaming chunk shape for talker2code2wav_async_chunk:
# codec_chunk_frames steady-state new frames per emit
# codec_left_context_frames sliding past context to avoid leading-edge artifacts
# codec_right_holdback_frames trailing frames held back so the codec
# has future context for emit-region tail samples
# initial_codec_chunk_frames smaller first chunk to minimize TTFA
codec_chunk_frames: 25
codec_left_context_frames: 25
codec_right_holdback_frames: 4
initial_codec_chunk_frames: 1
connector_get_sleep_s: 0.01
connector_get_max_wait_first_chunk: 5000
connector_get_max_wait: 500
stages:
- stage_id: 0
max_num_seqs: 64
gpu_memory_utilization: 0.6
enforce_eager: true
attention_backend: FLASHINFER
trust_remote_code: true
# Stage-0 prefix caching is on by default. The talker's HS opt-out and
# deferred ``codes.audio`` mm-output write (see higgs_audio_v3_talker.py)
# keep per-step bookkeeping negligible; any shared text prefix across
# concurrent requests is a net throughput win. Stage 1 (codec decoder)
# has no KV cache and stays disabled.
enable_prefix_caching: true
async_scheduling: false
max_num_batched_tokens: 4096
max_model_len: 8192
devices: "0"
output_connectors:
to_stage_1: connector_of_shared_memory
default_sampling_params:
temperature: 1.0
top_p: 0.95
top_k: 50
max_tokens: 2048
seed: 42
repetition_penalty: 1.0
- stage_id: 1
max_num_seqs: 16
gpu_memory_utilization: 0.25
enforce_eager: true
trust_remote_code: true
enable_prefix_caching: false
async_scheduling: false
max_num_batched_tokens: 65536
max_model_len: 65536
devices: "0"
input_connectors:
from_stage_0: connector_of_shared_memory
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
max_tokens: 65536
seed: 42
repetition_penalty: 1.0
@@ -0,0 +1,85 @@
# Higgs-audio v3 deploy, low-latency profile.
#
# Use this profile for low-concurrency serving (for example c1-c4) where Stage 0
# decode launch overhead dominates. Stage 0 enables vLLM FULL_DECODE_ONLY CUDA
# graph through YAML configuration:
#
# enforce_eager: false
# compilation_config.cudagraph_mode: FULL_DECODE_ONLY
#
# This is not controlled by an environment variable. The Higgs talker disables
# its local MLP CUDA graph automatically when the external decode graph is active,
# because the two graph paths are mutually exclusive.
#
# Plain TTS only (no voice cloning). async_chunk streams codec chunks from
# Stage 0 to Stage 1 as they are produced (TTFA approaches one AR loop's
# worth of work). Stage 1 honors meta.left_context_size + meta.right_holdback_size
# so the per-chunk codec windows stitch into a coherent PCM stream.
async_chunk: true
connectors:
connector_of_shared_memory:
name: SharedMemoryConnector
extra:
shm_threshold_bytes: 65536
# Stream PCM bytes back to the client as Stage 1 emits each chunk.
codec_streaming: true
# Streaming chunk shape for talker2code2wav_async_chunk:
# codec_chunk_frames steady-state new frames per emit
# codec_left_context_frames sliding past context to avoid leading-edge artifacts
# codec_right_holdback_frames trailing frames held back so the codec
# has future context for emit-region tail samples
# initial_codec_chunk_frames smaller first chunk to minimize TTFA
codec_chunk_frames: 25
codec_left_context_frames: 25
codec_right_holdback_frames: 4
initial_codec_chunk_frames: 1
connector_get_sleep_s: 0.01
connector_get_max_wait_first_chunk: 5000
connector_get_max_wait: 500
stages:
- stage_id: 0
max_num_seqs: 16
gpu_memory_utilization: 0.6
enforce_eager: false
attention_backend: FLASHINFER
trust_remote_code: true
enable_prefix_caching: true
async_scheduling: false
compilation_config:
cudagraph_capture_sizes: [1, 2, 4, 8, 16]
cudagraph_mode: FULL_DECODE_ONLY
cudagraph_num_of_warmups: 1
max_num_batched_tokens: 4096
max_model_len: 8192
devices: "0"
output_connectors:
to_stage_1: connector_of_shared_memory
default_sampling_params:
temperature: 1.0
top_p: 0.95
top_k: 50
max_tokens: 2048
seed: 42
repetition_penalty: 1.0
- stage_id: 1
max_num_seqs: 16
gpu_memory_utilization: 0.25
enforce_eager: true
trust_remote_code: true
enable_prefix_caching: false
async_scheduling: false
max_num_batched_tokens: 65536
max_model_len: 65536
devices: "0"
input_connectors:
from_stage_0: connector_of_shared_memory
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
max_tokens: 65536
seed: 42
repetition_penalty: 1.0
+2 -1
View File
@@ -14,6 +14,7 @@ connectors:
connector_get_max_wait: 300
codec_chunk_frames: 25
codec_left_context_frames: 0
decode_cudagraph_capture_sizes: [4, 8, 16, 25, 32, 50, 64, 100, 128, 200, 256]
stages:
- stage_id: 0
@@ -38,7 +39,7 @@ stages:
- stage_id: 1
max_num_seqs: 1
gpu_memory_utilization: 0.12
enforce_eager: true
enforce_eager: false
enable_prefix_caching: false
async_scheduling: true
# Talker can emit up to max_tokens=4096 frames × n_vq=32 = 131072 codec
+12
View File
@@ -21,6 +21,7 @@ from vllm.model_executor.layers.quantization.base_config import (
from vllm_omni.diffusion.model_metadata import get_diffusion_model_metadata
from vllm_omni.diffusion.utils.network_utils import is_port_available
from vllm_omni.errors import client_error_metadata
from vllm_omni.quantization import build_quant_config
if TYPE_CHECKING:
@@ -1062,6 +1063,8 @@ class DiffusionOutput:
trajectory_log_probs: torch.Tensor | dict[str, Any] | None = None
trajectory_decoded: list[Image.Image] | None = None
error: str | None = None
error_status_code: int | None = None
error_type: str | None = None
aborted: bool = False
abort_message: str | None = None
@@ -1102,6 +1105,15 @@ class DiffusionOutput:
if self.custom_output:
self.custom_output = {k: _maybe_to_cpu(v) for k, v in self.custom_output.items()}
@classmethod
def from_exception(cls, exc: BaseException) -> "DiffusionOutput":
status_code, error_type = client_error_metadata(exc)
return cls(
error=str(exc),
error_status_code=status_code,
error_type=error_type,
)
class DiffusionRequestAbortedError(RuntimeError):
"""Raised when a diffusion request ends via user-visible abort."""
+9 -2
View File
@@ -34,6 +34,7 @@ from vllm_omni.diffusion.request import DUMMY_DIFFUSION_REQUEST_ID, OmniDiffusio
from vllm_omni.diffusion.sched import RequestScheduler, SchedulerInterface, StepScheduler
from vllm_omni.diffusion.sched.interface import DiffusionRequestStatus
from vllm_omni.diffusion.worker.utils import BaseRunnerOutput, BatchRunnerOutput, RunnerOutput
from vllm_omni.errors import client_error_from_metadata, is_client_error_status
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniTextPrompt
from vllm_omni.outputs import OmniRequestOutput
@@ -231,6 +232,12 @@ class DiffusionEngine:
if output.aborted:
raise DiffusionRequestAbortedError(output.abort_message or "Diffusion request aborted.")
if output.error:
if is_client_error_status(output.error_status_code):
raise client_error_from_metadata(
output.error,
status_code=output.error_status_code,
error_type=output.error_type,
)
raise RuntimeError(output.error)
logger.debug("Generation completed successfully.")
@@ -507,7 +514,7 @@ class DiffusionEngine:
request_id=request_id,
step_index=None,
finished=True,
result=DiffusionOutput(error=str(exc)),
result=DiffusionOutput.from_exception(exc),
)
for request_id in sched_output.scheduled_request_ids
]
@@ -664,7 +671,7 @@ class DiffusionEngine:
request_id=request_id,
step_index=None,
finished=True,
result=DiffusionOutput(error=str(exc)),
result=DiffusionOutput.from_exception(exc),
)
self._process_aborts_queue()
@@ -22,6 +22,7 @@ from vllm_omni.diffusion.diffusion_engine import DiffusionEngine
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.engine.stage_client import StageClientBase
from vllm_omni.engine.stage_init_utils import StageMetadata
from vllm_omni.errors import client_error_metadata
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
@@ -143,11 +144,13 @@ class InlineStageDiffusionClient(StageClientBase):
logger.info("request_id: %s aborted: %s", request_id, str(e))
except Exception as e:
logger.exception("Diffusion request %s failed: %s", request_id, e)
error_output = OmniRequestOutput.from_diffusion(
status_code, error_type = client_error_metadata(e)
error_output = OmniRequestOutput.from_error(
request_id=request_id,
images=[],
error_message=str(e),
status_code=status_code,
error_type=error_type,
)
error_output.error = str(e)
self._output_queue.put_nowait(error_output)
finally:
self._tasks.pop(request_id, None)
@@ -248,11 +251,13 @@ class InlineStageDiffusionClient(StageClientBase):
logger.info("request_id: %s aborted: %s", request_id, str(e))
except Exception as e:
logger.exception("Batch diffusion request %s failed: %s", request_id, e)
error_output = OmniRequestOutput.from_diffusion(
status_code, error_type = client_error_metadata(e)
error_output = OmniRequestOutput.from_error(
request_id=request_id,
images=[],
error_message=str(e),
status_code=status_code,
error_type=error_type,
)
error_output.error = str(e)
self._output_queue.put_nowait(error_output)
finally:
self._tasks.pop(request_id, None)
@@ -1749,12 +1749,12 @@ class Bagel(nn.Module):
trajectory_latents.append(x_t.clone())
for i, t in enumerate(timesteps.tolist()): # host floats; a 0-d tensor t would sync each step
timestep = torch.tensor([t] * x_t.shape[0], device=x_t.device)
if frame_condition_token_indexes is not None:
# Cond positions stay at t=0 (clean signal). Matches upstream
# PR #33 lance.py line 1605:
# timestep[current_vae_mse_indexes_local_in_vae] = t
# (cond positions remain at the ``torch.zeros`` init value).
timestep[frame_condition_token_indexes] = 0.0
if frame_condition_token_indexes is not None:
# Cond positions stay at t=0 (clean signal). Matches upstream
# PR #33 lance.py line 1605:
# timestep[current_vae_mse_indexes_local_in_vae] = t
# (cond positions remain at the ``torch.zeros`` init value).
timestep[frame_condition_token_indexes] = 0.0
in_cfg_window = t > cfg_interval[0] and t <= cfg_interval[1]
cfg_text_scale_ = cfg_text_scale if in_cfg_window else 1.0
cfg_img_scale_ = cfg_img_scale if in_cfg_window else 1.0
@@ -1818,12 +1818,12 @@ class Bagel(nn.Module):
trajectory_latents.append(x_t.clone())
for i, t in enumerate(timesteps.tolist()): # host floats; a 0-d tensor t would sync each step
timestep = torch.tensor([t] * x_t.shape[0], device=x_t.device)
if frame_condition_token_indexes is not None:
# Cond positions stay at t=0 (clean signal). Matches upstream
# PR #33 lance.py line 1605:
# timestep[current_vae_mse_indexes_local_in_vae] = t
# (cond positions remain at the ``torch.zeros`` init value).
timestep[frame_condition_token_indexes] = 0.0
if frame_condition_token_indexes is not None:
# Cond positions stay at t=0 (clean signal). Matches upstream
# PR #33 lance.py line 1605:
# timestep[current_vae_mse_indexes_local_in_vae] = t
# (cond positions remain at the ``torch.zeros`` init value).
timestep[frame_condition_token_indexes] = 0.0
v_t = self.forward_single_branch(
x_t=x_t,
timestep=timestep,
@@ -20,6 +20,7 @@ import torch
from vllm.logger import init_logger
from vllm_omni.diffusion.models.progress_bar import _is_rank_zero
from vllm_omni.errors import GuardrailViolationError
if TYPE_CHECKING:
from vllm_omni.diffusion.data import OmniDiffusionConfig
@@ -67,7 +68,7 @@ def _build_text_guardrail(checker: Any) -> TextGuardrailFn:
def text_guardrail(prompt: str) -> None:
if not checker.check_text_safety(prompt):
# CosmosSafetyChecker logs the specific reason at CRITICAL.
raise ValueError("Guardrail blocked prompt")
raise GuardrailViolationError("Input was blocked by Cosmos3 guardrails.")
return text_guardrail
@@ -34,6 +34,7 @@ from vllm_omni.diffusion.data import OmniDiffusionConfig
from vllm_omni.diffusion.distributed.sp_plan import SequenceParallelInput, SequenceParallelOutput
from vllm_omni.diffusion.forward_context import get_forward_context, is_forward_context_available
from vllm_omni.diffusion.layers.norm import RMSNorm
from vllm_omni.platforms import current_omni_platform
logger = init_logger(__name__)
@@ -1419,8 +1420,8 @@ class Cosmos3VFMTransformer(nn.Module):
# For I2V: only add to noisy tokens, not conditioned ones.
# Conditioned frames are clean context and should not receive
# the diffusion timestep signal.
with torch.autocast("cuda", enabled=True, dtype=torch.float32):
time_embed = self.time_embedder(timestep * self.timestep_scale)
with torch.autocast(current_omni_platform.device_type, enabled=False):
time_embed = self.time_embedder((timestep * self.timestep_scale).float())
time_embed = time_embed.to(hidden_states.dtype)
if noisy_frame_mask is not None:
@@ -1519,7 +1520,7 @@ class Cosmos3VFMTransformer(nn.Module):
if isinstance(hidden_gen, tuple):
hidden_gen = hidden_gen[0]
hidden_gen = self.gen_sp_gather(hidden_gen)
hidden_gen = self.gen_sp_gather(hidden_gen)
# Final norm and project back to latent space
hidden_gen = self.norm_moe_gen(hidden_gen)
@@ -5,6 +5,7 @@ import inspect
import logging
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, cast
import numpy as np
@@ -460,6 +461,22 @@ class Resolution:
self.w = self.width = size[1]
self.r = self.ratio = self.height / self.width
self.extra_res = set()
def match(self, width, height) -> tuple[int, int]:
if not self.extra_res:
return self.w, self.h
else:
ret_w, ret_h = self.w, self.h
target_area = width * height
min_area_diff = abs((self.w * self.h) - target_area)
for res in self.extra_res:
area_diff = abs((res[0] * res[1]) - target_area)
if area_diff < min_area_diff:
min_area_diff = area_diff
ret_w, ret_h = res[0], res[1]
return (ret_w, ret_h)
def __getitem__(self, idx):
if idx == 0:
return self.h
@@ -471,6 +488,19 @@ class Resolution:
def __str__(self):
return f"{self.h}x{self.w}"
def __repr__(self) -> str:
if not self.extra_res:
return "{" + f"{self.h}x{self.w}" + "}"
else:
ret_str = "{" + f"[{self.h}x{self.w}]"
for er in self.extra_res:
ret_str = ret_str + f"[{er[0]}x{er[1]}]"
ret_str = ret_str + "}"
return ret_str
def append(self, res: "Resolution"):
self.extra_res.add((res.w, res.h))
# Baked-in extras matching the official model's
# `HunyuanImage3ImageProcessor.vae_reso_group` (image_processor.py:147-152).
@@ -482,6 +512,10 @@ HUNYUAN_IMAGE3_EXTRA_RESOLUTIONS: tuple[str, ...] = (
"1280x720",
"768x1024",
"720x1280",
"512x512",
"640x640",
"768x768",
"896x896",
)
@@ -502,12 +536,17 @@ class ResolutionGroup:
if extra_resolutions is not None:
for er in extra_resolutions:
if not any(r.ratio == er.ratio for r in self.data):
for r in self.data:
if r.ratio == er.ratio:
r.append(er)
break
else:
self.data.append(er)
self.ratio = np.array([x.ratio for x in self.data])
self.attr = ["" for _ in range(len(self.data))]
self.prefix_space = 0
logger.debug(f"ResolutionGroup: {self}")
def __len__(self):
return len(self.data)
@@ -523,13 +562,27 @@ class ResolutionGroup:
res_str += (
f"\n{prefix}ID: height width ratio {' ' * max(0, attr_maxlen - 4)}count h/16 w/16 tokens\n{prefix}"
)
res_str += ("\n" + prefix).join(
[
rows = []
for i, x in enumerate(self.data):
main_row = (
f"{i:2d}: ({x.h:4d}, {x.w:4d}) {self.ratio[i]:.4f} {self.attr[i]:>{attr_maxlen}s} "
f"({x.h // 16:3d}, {x.w // 16:3d}) {x.h // 16 * x.w // 16:6d}"
for i, x in enumerate(self.data)
]
)
)
rows.append(main_row)
extra_val = getattr(x, "extra_res", None)
if extra_val:
for sub_h, sub_w in sorted(list(extra_val)):
sub_ratio = sub_h / sub_w
sub_h16, sub_w16 = sub_h // 16, sub_w // 16
sub_tokens = sub_h16 * sub_w16
sub_row = (
f" ({sub_h:4d}, {sub_w:4d}) {sub_ratio:.4f} {' ' * attr_maxlen} "
f"({sub_h16:3d}, {sub_w16:3d}) {sub_tokens:6d}"
)
rows.append(sub_row)
res_str += ("\n" + prefix).join(rows)
res_str += f"\n{prefix_close})"
return res_str
@@ -568,8 +621,8 @@ class ResolutionGroup:
def get_target_size(self, width, height):
ratio = height / width
idx = np.argmin(np.abs(self.ratio - ratio))
reso = self.data[idx]
return reso.w, reso.h
w, h = self.data[idx].match(width, height)
return w, h
def get_base_size_and_ratio_index(self, width, height):
ratio = height / width
@@ -577,6 +630,13 @@ class ResolutionGroup:
return self.base_size, idx
@lru_cache(maxsize=4)
def get_cached_resolution_group(base_size: int) -> ResolutionGroup:
extra_res_tuple = tuple(Resolution(s) for s in HUNYUAN_IMAGE3_EXTRA_RESOLUTIONS)
extra_resolutions = list(extra_res_tuple) if extra_res_tuple else None
return ResolutionGroup(base_size=base_size, extra_resolutions=extra_resolutions)
class ImageInfo:
"""Class to store image information for processing and generation."""
@@ -1406,10 +1466,7 @@ class HunyuanImage3ImageProcessor:
def __init__(self, config):
self.config = config
self.reso_group = ResolutionGroup(
base_size=config.image_base_size,
extra_resolutions=[Resolution(s) for s in HUNYUAN_IMAGE3_EXTRA_RESOLUTIONS],
)
self.reso_group = get_cached_resolution_group(base_size=config.image_base_size)
self.vae_processor = transforms.Compose(
[
transforms.ToTensor(),
@@ -1446,6 +1503,7 @@ class HunyuanImage3ImageProcessor:
token_height = image_height // (self.config.vae_downsample_factor[0] * self.config.patch_size)
token_width = image_width // (self.config.vae_downsample_factor[1] * self.config.patch_size)
base_size, ratio_idx = self.reso_group.get_base_size_and_ratio_index(image_size[1], image_size[0])
image_info = ImageInfo(
image_type="gen_image",
image_width=image_width,
@@ -2148,7 +2206,6 @@ class HunyuanImage3Model(nn.Module):
return tensor[offset * units : offset * units + units]
for name, loaded_weight in weights:
# print(f"Loading weight name: {name}, tp_rank: {tp_rank}", flush=True)
if contains_unexpected_keyword(name, unexpected_keywords):
logger.warning("Skipping unexpected weight name: %s", name)
continue
@@ -970,11 +970,14 @@ class HunyuanImage3Pipeline(
# This implementation can handle sequences with text and image modalities, where text tokens use causal
# attention and image tokens use full attention.
bsz, seq_len = inputs_tensor.shape
device = inputs_tensor.device
tokenizer_output = model_kwargs["tokenizer_output"]
batch_image_slices = [
tokenizer_output.joint_image_slices[i] + tokenizer_output.gen_image_slices[i] for i in range(bsz)
]
attention_mask = torch.ones(seq_len, seq_len, dtype=torch.bool).tril(diagonal=0).repeat(bsz, 1, 1)
attention_mask = (
torch.ones(seq_len, seq_len, dtype=torch.bool, device=device).tril(diagonal=0).repeat(bsz, 1, 1)
)
full_attn_spans: list[list[tuple[int, int]]] = [[] for _ in range(bsz)]
for i in range(bsz):
for j, image_slice in enumerate(batch_image_slices[i]):
@@ -69,8 +69,7 @@ class DistributedRMSNorm(nn.Module):
local_count = x.shape[-1]
if tp_size > 1:
global_sum_sq = local_sum_sq.clone()
tensor_model_parallel_all_reduce(global_sum_sq)
global_sum_sq = tensor_model_parallel_all_reduce(local_sum_sq)
global_count = local_count * tp_size
else:
global_sum_sq = local_sum_sq
+11 -2
View File
@@ -228,7 +228,9 @@ class StageDiffusionClient(StageClientBase):
elif msg_type == "error":
req_id = msg.get("request_id")
rpc_id = msg.get("rpc_id")
error_msg = msg.get("error")
error_msg = msg.get("error") or "Unknown diffusion subprocess error."
status_code = msg.get("status_code")
error_type = msg.get("error_type")
logger.error(
"[StageDiffusionClient] stage-%s [rep-%s] subprocess error for %s: %s",
self.stage_id,
@@ -245,7 +247,14 @@ class StageDiffusionClient(StageClientBase):
# Route request errors as error outputs so the Orchestrator
# sees the request complete (instead of hanging forever).
if req_id is not None:
self._output_queue.put_nowait(OmniRequestOutput.from_error(req_id, error_msg))
self._output_queue.put_nowait(
OmniRequestOutput.from_error(
req_id,
error_msg,
status_code=status_code,
error_type=error_type,
)
)
# Fields that are subprocess-local and cannot be serialized across
# process boundaries. They are recreated in the subprocess with
@@ -33,6 +33,7 @@ from vllm_omni.distributed.omni_connectors.utils.serialization import (
)
from vllm_omni.distributed.omni_coordinator import OmniCoordClientForStage
from vllm_omni.engine.stage_init_utils import set_death_signal
from vllm_omni.errors import client_error_metadata
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
@@ -400,12 +401,15 @@ class StageDiffusionProc:
)
except Exception as e:
logger.exception("Diffusion request %s failed: %s", request_id, e)
status_code, error_type = client_error_metadata(e)
await response_socket.send(
encoder.encode(
{
"type": "error",
"request_id": request_id,
"error": str(e),
"status_code": status_code,
"error_type": error_type,
}
)
)
@@ -483,12 +487,15 @@ class StageDiffusionProc:
)
except Exception as e:
logger.exception("Batch diffusion request %s failed: %s", rid, e)
status_code, error_type = client_error_metadata(e)
await response_socket.send(
encoder.encode(
{
"type": "error",
"request_id": rid,
"error": str(e),
"status_code": status_code,
"error_type": error_type,
}
)
)
@@ -801,7 +801,7 @@ class WorkerProc:
except Exception as e:
logger.error(f"Error processing RPC: {e}", exc_info=True)
if self.result_mq is not None:
self.return_result(DiffusionOutput(error=str(e)))
self.return_result(DiffusionOutput.from_exception(e))
elif isinstance(msg, dict) and msg.get("type") == "shutdown":
logger.info("Worker %s: Received shutdown message", self.gpu_id)
@@ -817,7 +817,7 @@ class WorkerProc:
f"Error executing forward in event loop: {e}",
exc_info=True,
)
output = DiffusionOutput(error=str(e))
output = DiffusionOutput.from_exception(e)
try:
self.return_result(output)
@@ -29,6 +29,7 @@ class SharedMemoryConnector(OmniConnectorBase):
self.stage_id = config.get("stage_id", -1)
self.device = config.get("device", "cuda:0")
self.threshold = int(config.get("shm_threshold_bytes", 65536))
self.inline_small_payloads = bool(config.get("inline_small_payloads", False))
self._pending_keys: set[str] = set()
self._metrics = {
"puts": 0,
@@ -53,9 +54,11 @@ class SharedMemoryConnector(OmniConnectorBase):
payload = self.serialize_obj(data)
size = len(payload)
# Currently, we always use SHM.
if True:
# Use Shared Memory
# The legacy async-chunk adapter transfers only the connector key
# across stages; it cannot deliver inline metadata to the receiver.
# Keep key-addressed SHM as the default and only inline payloads
# when the caller explicitly enables the metadata-aware path.
if size >= self.threshold or not self.inline_small_payloads:
lock_file = f"/dev/shm/shm_{put_key}_lockfile.lock"
with open(lock_file, "wb+") as lockf:
fcntl.flock(lockf, fcntl.LOCK_EX)
@@ -67,9 +70,8 @@ class SharedMemoryConnector(OmniConnectorBase):
self._pending_keys.add(put_key)
self._metrics["shm_writes"] += 1
else:
# Inline - pass bytes directly to avoid double serialization of the object
# We already serialized it to check size, so we pass the bytes.
# The Queue will pickle these bytes (fast), avoiding re-serializing the complex object.
# Inline small payloads to avoid the mmap + file-lock overhead
# that dominates codec chunk transfers.
metadata = {"inline_bytes": payload, "size": size}
self._metrics["inline_writes"] += 1
+3
View File
@@ -84,6 +84,9 @@ def register_omni_models_to_vllm():
if arch not in supported_archs:
ModelRegistry.register_model(arch, f"vllm_omni.model_executor.models.{mod_folder}.{mod_relname}:{cls_name}")
# Register omni-specific reasoning parsers (e.g., step_audio).
import vllm_omni.reasoning # noqa: F401
@dataclass
class OmniEngineArgs(EngineArgs):
+2
View File
@@ -73,6 +73,8 @@ class UnregisterRemoteReplicaMessage(EngineQueueMessage, kw_only=True):
class ErrorMessage(EngineQueueMessage, kw_only=True):
type: Literal["error"] = "error"
error: str
status_code: int | None = None
error_type: str | None = None
fatal: bool = False
request_id: str | None = None
stage_id: int | None = None
+2
View File
@@ -809,6 +809,8 @@ class Orchestrator:
request_id=parent_id,
stage_id=stage_id,
error=output.error,
status_code=getattr(output, "error_status_code", None),
error_type=getattr(output, "error_type", None),
)
)
await self._cleanup_request_ids(
+4
View File
@@ -269,6 +269,10 @@ class OmniRequestState(RequestState):
# tokens, so we always store a cumulative copy here.
base_output.cumulative_token_ids = list(self.detokenizer.output_token_ids)
# Attach cumulative_text only at the final step for inter-stage use.
if finish_reason is not None and hasattr(self.detokenizer, "output_text"):
base_output.cumulative_text = self.detokenizer.output_text
if not hasattr(base_output, "multimodal_output"):
setattr(base_output, "multimodal_output", {})
if self.mm_accumulated:
@@ -89,6 +89,20 @@ class StageEngineCoreProc(EngineCoreProc):
signal_callback: SignalCallback | None = None
maybe_register_config_serialize_by_value()
# Register vllm-omni reasoning parsers (e.g. step_audio) in this
# subprocess so they are available when the engine core resolves
# ``--reasoning-parser``. The main process already registered them
# at import time, but the forked subprocess starts with a fresh
# ReasoningParserManager.
try:
import vllm_omni.reasoning # noqa: F401
except ImportError:
logger.warning(
"Failed to import vllm_omni.reasoning in subprocess; "
"custom reasoning parsers (e.g. step_audio) will not be "
"available."
)
engine_core: StageEngineCoreProc | None = None
coord_client: OmniCoordClientForStage | None = None
try:
+17 -1
View File
@@ -33,6 +33,7 @@ from vllm_omni.entrypoints.omni_base import (
OmniBase,
OmniEngineDeadError,
)
from vllm_omni.errors import client_error_metadata
from vllm_omni.inputs.data import OmniSamplingParams
from vllm_omni.metrics.stats import OrchestratorAggregator as OrchestratorMetrics
from vllm_omni.outputs import OmniRequestOutput
@@ -483,10 +484,13 @@ class AsyncOmni(EngineClient, OmniBase):
except (asyncio.CancelledError, GeneratorExit):
cancelled = True
except Exception as error:
status_code, error_type = client_error_metadata(error)
await req_state.queue.put(
ErrorMessage(
request_id=request_id,
error=str(error),
status_code=status_code,
error_type=error_type,
)
)
finally:
@@ -589,7 +593,7 @@ class AsyncOmni(EngineClient, OmniBase):
result.error,
error_stage_id=result.stage_id,
)
raise RuntimeError(result.error)
self._raise_nonfatal_error_message(result)
if not isinstance(result, OutputMessage):
logger.warning("[AsyncOmni] Dropping unexpected per-request message %r", result)
@@ -662,6 +666,18 @@ class AsyncOmni(EngineClient, OmniBase):
await self.event_resolver.resolve(msg)
continue
if isinstance(msg, ErrorMessage) and not msg.fatal:
req_state = self.request_states.get(msg.request_id)
if req_state is not None:
await req_state.queue.put(msg)
else:
logger.warning(
"[%s] dropping non-fatal error for unknown req %s",
self._name,
msg.request_id,
)
continue
should_continue, _, stage_id, req_state = self._handle_output_message(msg)
if should_continue:
continue
+19 -2
View File
@@ -20,6 +20,7 @@ from vllm_omni.engine.messages import (
from vllm_omni.entrypoints.client_request_state import ClientRequestState
from vllm_omni.entrypoints.pd_utils import PDDisaggregationMixin
from vllm_omni.entrypoints.utils import coerce_param_message_types, get_final_stage_id_for_e2e
from vllm_omni.errors import raise_client_error_or
from vllm_omni.metrics.modality import OmniModalityMetrics, observe_modality_at_finalize
from vllm_omni.metrics.prometheus import OmniPrometheusMetrics
from vllm_omni.metrics.stats import OrchestratorAggregator
@@ -372,7 +373,7 @@ class OmniBase(PDDisaggregationMixin):
msg.error,
error_stage_id=msg.stage_id,
)
raise RuntimeError(msg.error)
self._raise_nonfatal_error_message(msg)
if not isinstance(msg, OutputMessage):
logger.warning("[%s] got unexpected msg type: %s", self.__class__.__name__, msg.type)
@@ -408,6 +409,15 @@ class OmniBase(PDDisaggregationMixin):
return False, req_id, stage_id, req_state
def _raise_nonfatal_error_message(self, msg: ErrorMessage) -> None:
"""Raise the exception for a non-fatal, request-scoped error message."""
raise_client_error_or(
msg.error,
status_code=msg.status_code,
error_type=msg.error_type,
fallback=RuntimeError,
)
def _check_engine_output_error(
self,
result: OutputMessage,
@@ -424,6 +434,8 @@ class OmniBase(PDDisaggregationMixin):
error_text = getattr(engine_outputs, "error", None)
if error_text is None:
return
status_code = engine_outputs.error_status_code
error_type = engine_outputs.error_type
logger.error(
"[%s] Stage error for req=%s stage-%s: %s",
self.__class__.__name__,
@@ -437,7 +449,12 @@ class OmniBase(PDDisaggregationMixin):
error_text,
error_stage_id=stage_id,
)
raise EngineGenerateError(error_text)
raise_client_error_or(
error_text,
status_code=status_code,
error_type=error_type,
fallback=EngineGenerateError,
)
def _process_single_result(
self,
+65 -7
View File
@@ -126,7 +126,12 @@ from vllm_omni.entrypoints.openai.serving_audio_generate import OmniOpenAIServin
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
from vllm_omni.entrypoints.openai.serving_speech_stream import OmniStreamingSpeechHandler
from vllm_omni.entrypoints.openai.serving_video import OmniOpenAIServingVideo, ReferenceImage, ReferenceVideo
from vllm_omni.entrypoints.openai.serving_video import (
OmniOpenAIServingVideo,
ReferenceAudio,
ReferenceImage,
ReferenceVideo,
)
from vllm_omni.entrypoints.openai.serving_video_stream import OmniStreamingVideoHandler
from vllm_omni.entrypoints.openai.stage_params import (
build_stage_sampling_params_list,
@@ -135,8 +140,9 @@ from vllm_omni.entrypoints.openai.stage_params import (
from vllm_omni.entrypoints.openai.storage import STORAGE_MANAGER
from vllm_omni.entrypoints.openai.stores import VIDEO_STORE, VIDEO_TASKS
from vllm_omni.entrypoints.openai.utils import get_stage_type, parse_lora_request
from vllm_omni.entrypoints.openai.video_api_utils import decode_input_reference
from vllm_omni.entrypoints.openai.video_api_utils import decode_audio_url, decode_input_reference
from vllm_omni.entrypoints.openpi.serving import ServingRealtimeRobotOpenPI
from vllm_omni.errors import OmniClientError
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniTextPrompt
from vllm_omni.utils.tracking_parser import TrackingArgumentParser, TrackingNamespace
@@ -425,6 +431,15 @@ async def omni_run_server_worker(listen_address, sock, args, client_config=None,
ReasoningParserManager.import_reasoning_parser(args.reasoning_parser_plugin)
# In vllm-omni's multi-process architecture, create_engine_config() runs
# in the subprocess (StageEngineCoreProc), not in the API server process.
# Propagate reasoning_parser from CLI args into structured_outputs_config
# here so that OmniOpenAIServingChat receives the correct value.
# (Upstream vLLM does this in EngineArgs.create_engine_config().)
if hasattr(args, "reasoning_parser") and args.reasoning_parser:
if not args.structured_outputs_config.reasoning_parser:
args.structured_outputs_config.reasoning_parser = args.reasoning_parser
# Load logging config for uvicorn if specified
log_config = get_uvicorn_log_config(args)
if log_config is not None:
@@ -1798,6 +1813,9 @@ async def generate_images(request: ImageGenerationRequest, raw_request: Request)
return _create_engine_error_json_response(raw_request, exc)
except HTTPException:
raise
except OmniClientError as e:
logger.info("Client error during image generation: %s", e)
raise HTTPException(status_code=e.status_code, detail=e.message) from e
except ValueError as e:
logger.error(f"Validation error: {e}")
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST.value, detail=str(e))
@@ -2153,6 +2171,9 @@ async def edit_images(
return _create_engine_error_json_response(raw_request, exc)
except HTTPException:
raise
except OmniClientError as e:
logger.info("Client error during image edit: %s", e)
raise HTTPException(status_code=e.status_code, detail=e.message) from e
except ValueError as e:
logger.error(f"Validation error: {e}")
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST.value, detail=str(e))
@@ -2653,6 +2674,9 @@ def _video_error_from_exception(exc: Exception) -> VideoError:
message = str(exc.detail) if exc.detail else str(exc)
return VideoError(code=exc.status_code, message=message)
if isinstance(exc, OmniClientError):
return VideoError(code=exc.status_code, message=exc.message)
if isinstance(exc, (EngineGenerateError, EngineDeadError)):
err = create_error_response(exc)
return VideoError(code=err.error.code, message=err.error.message)
@@ -2677,6 +2701,7 @@ async def _run_video_generation_job(
video_id: str,
reference_image: ReferenceImage | None = None,
reference_video: ReferenceVideo | None = None,
reference_audio: ReferenceAudio | None = None,
app_state: Any | None = None,
) -> None:
job = await VIDEO_STORE.get(video_id)
@@ -2693,6 +2718,7 @@ async def _run_video_generation_job(
video_id,
reference_image=reference_image,
reference_video=reference_video,
reference_audio=reference_audio,
)
file_name = f"{video_id}.{job.file_extension}"
@@ -2749,9 +2775,12 @@ async def _run_video_generation_job(
_cleanup_video(video_id, output_path)
await VIDEO_STORE.pop(video_id)
raise
finally:
if reference_audio is not None and os.path.exists(reference_audio.path):
os.unlink(reference_audio.path)
VIDEO_SYNC_TIMEOUT_S = 600.0
VIDEO_SYNC_TIMEOUT_S = float(os.environ.get("VLLM_OMNI_VIDEO_SYNC_TIMEOUT", 600.0))
async def _parse_video_form(
@@ -2760,6 +2789,7 @@ async def _parse_video_form(
input_reference: UploadFile | None = File(default=None),
image_reference: str | None = Form(default=None),
video_reference: str | None = Form(default=None),
audio_reference: str | None = Form(default=None),
model: str | None = Form(default=None),
seconds: SecondStr | None = Form(default=None),
size: SizeStr | None = Form(default=None),
@@ -2784,7 +2814,14 @@ async def _parse_video_form(
frame_interpolation_model_path: str | None = Form(default=None),
lora: str | None = Form(default=None),
extra_params: str | None = Form(default=None),
) -> tuple[VideoGenerationRequest, "OmniOpenAIServingVideo", str, ReferenceImage | None, ReferenceVideo | None]:
) -> tuple[
VideoGenerationRequest,
"OmniOpenAIServingVideo",
str,
ReferenceImage | None,
ReferenceVideo | None,
ReferenceAudio | None,
]:
"""FastAPI dependency that parses video form data, validates inputs,
resolves the handler, and decodes any reference image.
@@ -2793,6 +2830,7 @@ async def _parse_video_form(
input_reference_bytes = await input_reference.read() if input_reference is not None else None
parsed_image_reference = _parse_form_json(image_reference)
parsed_video_reference = _parse_form_json(video_reference)
parsed_audio_reference = _parse_form_json(audio_reference)
provided_references = sum(
item is not None for item in (parsed_image_reference, parsed_video_reference, input_reference_bytes)
@@ -2810,6 +2848,7 @@ async def _parse_video_form(
"size": size,
"image_reference": parsed_image_reference,
"video_reference": parsed_video_reference,
"audio_reference": parsed_audio_reference,
"user": user,
"width": width,
"height": height,
@@ -2884,7 +2923,16 @@ async def _parse_video_form(
reference_image = ReferenceImage(data=media_data) if isinstance(media_data, Image.Image) else None
reference_video = ReferenceVideo(data=media_data) if isinstance(media_data, list) else None
return request, handler, effective_model_name, reference_image, reference_video
reference_audio: ReferenceAudio | None = None
if request.audio_reference is not None:
try:
audio_path = await decode_audio_url(request.audio_reference.audio_url)
except InvalidInputReferenceError as exc:
raise HTTPException(400, detail=str(exc)) from exc
reference_audio = ReferenceAudio(path=audio_path)
return request, handler, effective_model_name, reference_image, reference_video, reference_audio
@router.post(
@@ -2904,6 +2952,7 @@ async def create_video(
str,
ReferenceImage | None,
ReferenceVideo | None,
ReferenceAudio | None,
] = Depends(_parse_video_form),
) -> VideoResponse:
"""Create an asynchronous video generation job.
@@ -2911,7 +2960,7 @@ async def create_video(
Accepts multipart form-data (see ``_parse_video_form`` for parameters),
persists a queued job record, and starts generation in the background.
"""
request, handler, effective_model_name, reference_image, reference_video = ctx
request, handler, effective_model_name, reference_image, reference_video, reference_audio = ctx
ref = video_response_from_request(effective_model_name, request)
await VIDEO_STORE.upsert(ref.id, ref)
task = asyncio.create_task(
@@ -2921,6 +2970,7 @@ async def create_video(
ref.id,
reference_image,
reference_video,
reference_audio,
app_state=raw_request.app.state,
)
)
@@ -2945,6 +2995,7 @@ async def create_video_sync(
str,
ReferenceImage | None,
ReferenceVideo | None,
ReferenceAudio | None,
] = Depends(_parse_video_form),
) -> Response:
"""Synchronous video generation endpoint.
@@ -2956,7 +3007,7 @@ async def create_video_sync(
Metadata is returned via response headers ``X-Request-Id``,
``X-Model``, and ``X-Inference-Time-S``.
"""
request, handler, effective_model_name, reference_image, reference_video = ctx
request, handler, effective_model_name, reference_image, reference_video, reference_audio = ctx
request_id = f"video_sync-{random_uuid()}"
raw_request.state.request_metadata = RequestResponseMetadata(request_id=request_id)
started_at = time.perf_counter()
@@ -2967,6 +3018,7 @@ async def create_video_sync(
request_id,
reference_image=reference_image,
reference_video=reference_video,
reference_audio=reference_audio,
),
timeout=VIDEO_SYNC_TIMEOUT_S,
)
@@ -2979,12 +3031,18 @@ async def create_video_sync(
return _create_engine_error_json_response(raw_request, exc)
except HTTPException:
raise
except OmniClientError as exc:
logger.info("Client error during sync video generation: %s", exc)
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
except Exception as exc:
logger.exception("Sync video generation failed for request_id=%s", request_id)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
detail=f"Video generation failed: {str(exc)}",
) from exc
finally:
if reference_audio is not None and os.path.exists(reference_audio.path):
os.unlink(reference_audio.path)
inference_time_s = time.perf_counter() - started_at
return Response(
@@ -133,6 +133,14 @@ class OpenAICreateSpeechRequest(BaseModel):
ge=0,
description="Per-request initial chunk size override. If null, computed dynamically based on server load.",
)
non_streaming_mode: bool | None = Field(
default=None,
description=(
"Qwen3-TTS prompt construction mode override. This does not "
"control HTTP response streaming or async-chunk pipelining. "
"When null, use model defaults: Base=False, CustomVoice/VoiceDesign=True."
),
)
extra_params: dict[str, Any] | None = Field(
default=None,
description=("Optional model-specific parameters passed directly to the model's extra_args."),
@@ -370,6 +378,7 @@ class SpeechBatchItem(BaseModel):
x_vector_only_mode: bool | None = None
max_new_tokens: int | None = None
initial_codec_chunk_frames: int | None = Field(default=None, ge=0)
non_streaming_mode: bool | None = None
class BatchSpeechRequest(BaseModel):
@@ -389,6 +398,7 @@ class BatchSpeechRequest(BaseModel):
x_vector_only_mode: bool | None = None
max_new_tokens: int | None = None
initial_codec_chunk_frames: int | None = Field(default=None, ge=0)
non_streaming_mode: bool | None = None
class SpeechBatchItemResult(BaseModel):
@@ -423,6 +433,14 @@ class StreamingSpeechSessionConfig(BaseModel):
ge=0,
description="Initial chunk size for reduced TTFA. Overrides stage config for this session.",
)
non_streaming_mode: bool | None = Field(
default=None,
description=(
"Qwen3-TTS prompt construction mode override. This does not "
"control WebSocket audio streaming or async-chunk pipelining. "
"When null, use model defaults: Base=False, CustomVoice/VoiceDesign=True."
),
)
ref_audio: str | None = None
ref_text: str | None = None
x_vector_only_mode: bool | None = None
@@ -86,6 +86,14 @@ class UrlVideoReference(BaseModel):
VideoReference = UrlVideoReference | FileVideoReference
class UrlAudioReference(BaseModel):
model_config = ConfigDict(extra="forbid")
audio_url: str
AudioReference = UrlAudioReference
class VideoGenerationRequest(BaseModel):
"""
OpenAI-style video generation request.
@@ -116,6 +124,10 @@ class VideoGenerationRequest(BaseModel):
default=None,
description="Optional JSON-safe video reference that guides generation. Provide either video_url or file_id.",
)
audio_reference: AudioReference | None = Field(
default=None,
description="Optional audio reference for speech-to-video. Provide audio_url (http(s) or data URL).",
)
# Video params block for extensibility
video_params: VideoParams | None = Field(default=None, description="Optional video-specific parameters")
@@ -113,6 +113,7 @@ from vllm_omni.entrypoints.openai.utils import (
resolve_diffusion_od_config,
validate_requested_speaker,
)
from vllm_omni.errors import OmniClientError
from vllm_omni.lora.request import LoRARequest
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.utils.audio import audio_chunk_pcm_bytes, audio_chunk_sample_rate
@@ -2869,6 +2870,18 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
"[OmniOpenAIServingChat] Engine dead during streaming image edit, "
"but cannot terminate server (no raw_request context)."
)
except OmniClientError as exc:
logger.info("Client error during streaming image edit: %s", exc)
chunk = ImageEditStreamError(
created=created,
model=model,
error={
"message": exc.message,
"type": exc.error_type,
"code": exc.status_code,
},
)
yield f"data: {chunk.model_dump_json()}\n\n"
except Exception as exc:
logger.exception("Streaming image edit failed: %s", exc)
chunk = ImageEditStreamError(
@@ -3265,6 +3278,13 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
return response
except OmniClientError as e:
logger.info("Client error during diffusion chat completion: %s", e)
return self._create_error_response(
e.message,
err_type=e.error_type,
status_code=e.status_code,
)
except Exception as e:
logger.exception("Diffusion chat completion failed: %s", e)
return self._create_error_response(
+86 -14
View File
@@ -78,7 +78,7 @@ _MING_TTS_MODEL_STAGES = {"ming_tts"}
_MOSS_TTS_MODEL_STAGES = {"moss_tts_nano"}
_MOSS_TTS_FULL_MODEL_STAGES = {"moss_tts", "moss_tts_codec"}
_HIGGS_AUDIO_V2_TTS_MODEL_STAGES = {"higgs_audio_v2"}
_HIGGS_AUDIO_V3_TTS_MODEL_STAGES = {"higgs_audio_v3"}
_HIGGS_V3_TTS_MODEL_STAGES = {"higgs_audio_v3"}
_GLM_TTS_MODEL_STAGES = {"glm_tts"}
_TTS_MODEL_STAGES: set[str] = (
_VOXTRAL_TTS_MODEL_STAGES
@@ -87,7 +87,7 @@ _TTS_MODEL_STAGES: set[str] = (
| _COSYVOICE3_TTS_MODEL_STAGES
| _OMNIVOICE_TTS_MODEL_STAGES
| _HIGGS_AUDIO_V2_TTS_MODEL_STAGES
| _HIGGS_AUDIO_V3_TTS_MODEL_STAGES
| _HIGGS_V3_TTS_MODEL_STAGES
| _COVO_AUDIO_MODEL_STAGES
| _VOXCPM2_TTS_MODEL_STAGES
| _MING_TTS_MODEL_STAGES
@@ -123,6 +123,8 @@ _REF_AUDIO_MIN_DURATION = 1.0 # seconds
_REF_AUDIO_MAX_DURATION = 30.0 # seconds
_REF_AUDIO_RESOLVE_CACHE_MAX_ENTRIES = 256
_REF_AUDIO_RESOLVE_CACHE_MAX_BYTES = 256 * 1024 * 1024
_HIGGS_V3_REF_CODE_CACHE_MAX_ENTRIES = 256
_HIGGS_V3_REF_CODE_CACHE_MAX_BYTES = 64 * 1024 * 1024
_QWEN3_TTS_REF_AUDIO_CACHE_KEY = "_qwen3_tts_ref_audio_cache_key"
_TTS_MAX_INSTRUCTIONS_LENGTH = 500
_TTS_MAX_NEW_TOKENS_MIN = 1
@@ -288,6 +290,9 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
self._ref_audio_resolve_cache_max_bytes = _REF_AUDIO_RESOLVE_CACHE_MAX_BYTES
self._ref_audio_model_artifact_ready: set[str] = set()
self._request_ref_audio_artifact_keys: dict[str, str] = {}
self._higgs_audio_v3_ref_code_cache: OrderedDict[str, tuple[torch.Tensor, int]] = OrderedDict()
self._higgs_audio_v3_ref_code_cache_bytes = 0
self._higgs_audio_v3_ref_code_inflight: dict[str, asyncio.Task[torch.Tensor]] = {}
self._speaker_cache = get_speaker_cache()
self._last_upload_ts = 0
self._upload_lock = asyncio.Lock()
@@ -646,7 +651,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
return "moss_tts"
if model_stage in _HIGGS_AUDIO_V2_TTS_MODEL_STAGES:
return "higgs_audio_v2"
if model_stage in _HIGGS_AUDIO_V3_TTS_MODEL_STAGES:
if model_stage in _HIGGS_V3_TTS_MODEL_STAGES:
return "higgs_audio_v3"
if model_stage in _GLM_TTS_MODEL_STAGES:
return "glm_tts"
@@ -2018,17 +2023,18 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
apply_delay_pattern,
encode_reference_audio,
)
from vllm_omni.model_executor.models.higgs_audio_v3.ref_audio_cache import (
cached_encode_reference_audio,
)
wav_list, sr = await self._resolve_ref_audio(request.ref_audio)
artifact_key = self._get_resolved_ref_audio_artifact_key(request.ref_audio)
wav = np.asarray(wav_list, dtype=np.float32)
# Cache the codec encode by content hash so identical ref_audio
# payloads skip the encode and reuse the same [num_frames,
# num_codebooks] codes tensor across requests.
ref_codes_raw = await asyncio.to_thread(cached_encode_reference_audio, wav, int(sr), encode_reference_audio)
ref_codes_delayed = apply_delay_pattern(ref_codes_raw)
ref_codes_delayed, cache_hit, inflight_wait = await self._resolve_higgs_audio_v3_ref_codes(
artifact_key,
wav,
int(sr),
encode_reference_audio,
apply_delay_pattern,
)
del cache_hit, inflight_wait
prompt_ids = adapter.build_prompt(
request.input,
@@ -2044,6 +2050,68 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
}
return prompt
async def _resolve_higgs_audio_v3_ref_codes(
self,
artifact_key: str | None,
wav: np.ndarray,
sr: int,
encode_reference_audio,
apply_delay_pattern,
) -> tuple[torch.Tensor, bool, bool]:
ref_codes_delayed = self._get_higgs_audio_v3_ref_codes(artifact_key)
if ref_codes_delayed is not None:
return ref_codes_delayed, True, False
if not artifact_key:
ref_codes_raw = await asyncio.to_thread(encode_reference_audio, wav, sr)
return apply_delay_pattern(ref_codes_raw), False, False
task = self._higgs_audio_v3_ref_code_inflight.get(artifact_key)
if task is not None:
return (await task).clone(), False, True
async def _encode_and_cache() -> torch.Tensor:
ref_codes_raw = await asyncio.to_thread(encode_reference_audio, wav, sr)
delayed = apply_delay_pattern(ref_codes_raw)
self._put_higgs_audio_v3_ref_codes(artifact_key, delayed)
cached = self._get_higgs_audio_v3_ref_codes(artifact_key)
return cached if cached is not None else delayed.detach().to("cpu", dtype=torch.long).contiguous()
task = asyncio.create_task(_encode_and_cache())
self._higgs_audio_v3_ref_code_inflight[artifact_key] = task
try:
return (await task).clone(), False, False
finally:
if self._higgs_audio_v3_ref_code_inflight.get(artifact_key) is task:
self._higgs_audio_v3_ref_code_inflight.pop(artifact_key, None)
def _get_higgs_audio_v3_ref_codes(self, artifact_key: str | None) -> torch.Tensor | None:
if not artifact_key:
return None
cached = self._higgs_audio_v3_ref_code_cache.get(artifact_key)
if cached is None:
return None
self._higgs_audio_v3_ref_code_cache.move_to_end(artifact_key)
return cached[0].clone()
def _put_higgs_audio_v3_ref_codes(self, artifact_key: str, codes: torch.Tensor) -> None:
if _HIGGS_V3_REF_CODE_CACHE_MAX_ENTRIES <= 0 or _HIGGS_V3_REF_CODE_CACHE_MAX_BYTES <= 0 or not artifact_key:
return
cached_codes = codes.detach().to("cpu", dtype=torch.long).contiguous()
size = int(cached_codes.numel() * cached_codes.element_size())
if size > _HIGGS_V3_REF_CODE_CACHE_MAX_BYTES:
return
previous = self._higgs_audio_v3_ref_code_cache.pop(artifact_key, None)
if previous is not None:
self._higgs_audio_v3_ref_code_cache_bytes -= previous[1]
self._higgs_audio_v3_ref_code_cache[artifact_key] = (cached_codes, size)
self._higgs_audio_v3_ref_code_cache_bytes += size
while len(self._higgs_audio_v3_ref_code_cache) > _HIGGS_V3_REF_CODE_CACHE_MAX_ENTRIES:
_, (_, old_size) = self._higgs_audio_v3_ref_code_cache.popitem(last=False)
self._higgs_audio_v3_ref_code_cache_bytes -= old_size
while self._higgs_audio_v3_ref_code_cache_bytes > _HIGGS_V3_REF_CODE_CACHE_MAX_BYTES:
_, (_, old_size) = self._higgs_audio_v3_ref_code_cache.popitem(last=False)
self._higgs_audio_v3_ref_code_cache_bytes -= old_size
async def _resolve_higgs_audio_v3_adapter(self):
"""Lazy-load the tokenizer adapter for higgs_audio_v3."""
cached = getattr(self, "_higgs_audio_v3_adapter", None)
@@ -2747,9 +2815,12 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
if request.initial_codec_chunk_frames is not None:
params["initial_codec_chunk_frames"] = [request.initial_codec_chunk_frames]
# VoiceDesign requires non_streaming_mode (match offline script behaviour).
# CustomVoice and Base rely on the model default (True and False respectively).
if params["task_type"][0] == "VoiceDesign":
if request.non_streaming_mode is not None:
params["non_streaming_mode"] = [request.non_streaming_mode]
# Preserve the legacy VoiceDesign fallback when the request omits an
# explicit override. CustomVoice and Base rely on model defaults
# (True and False respectively).
elif params["task_type"][0] == "VoiceDesign":
params["non_streaming_mode"] = [True]
return params
@@ -3906,6 +3977,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
x_vector_only_mode=_pick("x_vector_only_mode"),
max_new_tokens=_pick("max_new_tokens"),
initial_codec_chunk_frames=_pick("initial_codec_chunk_frames"),
non_streaming_mode=_pick("non_streaming_mode"),
)
async def create_speech_batch(
@@ -221,6 +221,7 @@ class OmniStreamingSpeechHandler:
speed=config.speed,
max_new_tokens=config.max_new_tokens,
initial_codec_chunk_frames=config.initial_codec_chunk_frames,
non_streaming_mode=config.non_streaming_mode,
ref_audio=config.ref_audio,
ref_text=config.ref_text,
x_vector_only_mode=config.x_vector_only_mode,
+19 -2
View File
@@ -46,6 +46,13 @@ class ReferenceVideo:
data: list[Image.Image]
@dataclass
class ReferenceAudio:
"""Reference audio file path for speech-to-video generation."""
path: str
@dataclass
class VideoGenerationArtifacts:
"""Normalized outputs and profiler metadata extracted from one request."""
@@ -104,6 +111,7 @@ class OmniOpenAIServingVideo:
*,
reference_image: ReferenceImage | None = None,
reference_video: ReferenceVideo | None = None,
reference_audio: ReferenceAudio | None = None,
) -> VideoGenerationArtifacts:
"""Run the generation pipeline and extract video/audio/profiler outputs."""
prompt: OmniTextPrompt = OmniTextPrompt(prompt=request.prompt, modalities=["video"])
@@ -124,10 +132,15 @@ class OmniOpenAIServingVideo:
target_size = (vp.width, vp.height)
if input_image.size != target_size:
input_image = input_image.resize(target_size, Image.Resampling.LANCZOS)
multi_modal_data: dict[str, Any] = {}
if input_image is not None:
prompt["multi_modal_data"] = {"image": input_image}
multi_modal_data["image"] = input_image
elif input_video is not None:
prompt["multi_modal_data"] = {"video": input_video}
multi_modal_data["video"] = input_video
if reference_audio is not None:
multi_modal_data["audio"] = reference_audio.path
if multi_modal_data:
prompt["multi_modal_data"] = multi_modal_data
if vp.width is not None and vp.height is not None:
gen_params.width = vp.width
gen_params.height = vp.height
@@ -228,12 +241,14 @@ class OmniOpenAIServingVideo:
*,
reference_image: ReferenceImage | None = None,
reference_video: ReferenceVideo | None = None,
reference_audio: ReferenceAudio | None = None,
) -> VideoGenerationResponse:
artifacts = await self._run_and_extract(
request,
reference_id,
reference_image=reference_image,
reference_video=reference_video,
reference_audio=reference_audio,
)
video_codec_options = {"preset": "ultrafast", "threads": "0"}
@@ -275,6 +290,7 @@ class OmniOpenAIServingVideo:
*,
reference_image: ReferenceImage | None = None,
reference_video: ReferenceVideo | None = None,
reference_audio: ReferenceAudio | None = None,
) -> tuple[bytes, dict[str, float], float, VideoAction | None]:
"""Generate a video and return raw MP4 bytes, bypassing base64 encoding."""
artifacts = await self._run_and_extract(
@@ -282,6 +298,7 @@ class OmniOpenAIServingVideo:
reference_id,
reference_image=reference_image,
reference_video=reference_video,
reference_audio=reference_audio,
)
if len(artifacts.videos) > 1:
logger.warning(
@@ -186,6 +186,53 @@ async def decode_video_url(
raise InvalidInputReferenceError("Invalid video_reference.video_url: must be an http(s) URL or data URL.")
async def decode_audio_url(audio_url: str) -> str:
"""Decode an audio URL or data-URL to a temporary file path."""
import tempfile
audio_bytes: bytes | None = None
if audio_url.startswith("data:audio"):
_, b64_data = audio_url.split(",", 1)
try:
audio_bytes = base64.b64decode(b64_data)
except (binascii.Error, ValueError) as exc:
raise InvalidInputReferenceError(
"Invalid audio_reference.audio_url: audio data is not valid base64."
) from exc
elif audio_url.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=60) as client:
try:
response = await client.get(audio_url)
response.raise_for_status()
except httpx.HTTPError as exc:
raise InvalidInputReferenceError(
"Invalid audio_reference.audio_url: failed to download audio."
) from exc
audio_bytes = response.content
else:
raise InvalidInputReferenceError("Invalid audio_reference.audio_url: must be an http(s) URL or data URL.")
if not audio_bytes:
raise InvalidInputReferenceError("Invalid audio_reference: audio data is empty.")
suffix = ".wav"
if audio_url.startswith("data:audio/"):
mime = audio_url.split(";")[0].removeprefix("data:")
ext = mime.split("/")[-1]
if ext in ("mpeg", "mp3"):
suffix = ".mp3"
elif ext == "wav":
suffix = ".wav"
elif ext.isalnum() and len(ext) <= 8:
suffix = f".{ext}"
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
tmp.write(audio_bytes)
tmp.close()
return tmp.name
async def decode_input_reference(
image_reference: ImageReference | None,
video_reference: VideoReference | None,
+81
View File
@@ -0,0 +1,81 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Request-scoped client error types shared across vLLM-Omni entrypoints."""
from __future__ import annotations
from collections.abc import Callable
from http import HTTPStatus
from typing import NoReturn
DEFAULT_CLIENT_ERROR_TYPE = "BadRequestError"
class OmniClientError(ValueError):
"""
Request-scoped error that should be surfaced as a 4xx response.
One example of using OmniClientError is GuardrailViolationError, which is captured and resurfaced
as HTTP 400 error code, instead of a generic 500. OmniClientError should be used for any exceptions
which need to be resurfaced as 4xx, as opposed to EngineDeadError/EngineGenerateError which are resurfaced as 500.
"""
def __init__(
self,
message: str,
*,
status_code: int = HTTPStatus.BAD_REQUEST.value,
error_type: str = DEFAULT_CLIENT_ERROR_TYPE,
) -> None:
super().__init__(message)
self.message = message
self.status_code = int(status_code)
self.error_type = error_type
class GuardrailViolationError(OmniClientError):
"""Raised when a model guardrail rejects request content."""
def client_error_metadata(exc: BaseException) -> tuple[int | None, str | None]:
if isinstance(exc, OmniClientError):
return exc.status_code, exc.error_type
return None, None
def client_error_from_metadata(
message: str,
*,
status_code: int | None,
error_type: str | None,
) -> OmniClientError:
return OmniClientError(
message,
status_code=status_code or HTTPStatus.BAD_REQUEST.value,
error_type=error_type or DEFAULT_CLIENT_ERROR_TYPE,
)
def is_client_error_status(status_code: int | None) -> bool:
return status_code is not None and 400 <= int(status_code) < 500
def raise_client_error_or(
message: str,
*,
status_code: int | None,
error_type: str | None,
fallback: Callable[[str], BaseException],
) -> NoReturn:
"""Raise a client error for 4xx statuses, otherwise raise ``fallback(message)``.
Centralizes the "client-error-or-fallback" decision shared by the engine
error paths so the status mapping lives in one place.
"""
if is_client_error_status(status_code):
raise client_error_from_metadata(
message,
status_code=status_code,
error_type=error_type,
)
raise fallback(message)
@@ -1,4 +1,8 @@
import contextlib
import errno
import os
import time
from collections.abc import Iterator
from pathlib import Path
import huggingface_hub
@@ -13,6 +17,159 @@ else:
logger = init_logger(__name__)
_DOWNLOAD_MAX_ATTEMPTS = 3
_DOWNLOAD_BACKOFF_BASE_S = 1.0
_FULL_SNAPSHOT_METADATA_MARKERS = (
"config.json",
"preprocessor_config.json",
"processor_config.json",
"tokenizer_config.json",
"model_index.json",
)
def _node_lock_dir() -> str:
candidates: list[str] = []
hf_home = os.environ.get("HF_HOME")
if hf_home:
candidates.append(os.path.join(hf_home, "locks", "vllm-omni-weight-prefetch"))
xdg_cache = os.environ.get("XDG_CACHE_HOME")
if xdg_cache:
candidates.append(os.path.join(xdg_cache, "huggingface", "locks", "vllm-omni-weight-prefetch"))
candidates.append(
os.path.join(os.path.expanduser("~"), ".cache", "huggingface", "locks", "vllm-omni-weight-prefetch")
)
candidates.append(os.path.join("/tmp", "vllm-omni-weight-prefetch-locks"))
for cand in candidates:
try:
os.makedirs(cand, exist_ok=True)
probe = os.path.join(cand, ".write_check")
with open(probe, "a"):
pass
with contextlib.suppress(OSError):
os.remove(probe)
return cand
except OSError:
continue
fallback = os.path.join("/tmp", "vllm-omni-weight-prefetch-locks")
os.makedirs(fallback, exist_ok=True)
return fallback
def _safe_repo_filename(model: str) -> str:
return model.replace("/", "__").replace(os.sep, "__") + ".lock"
def _dotfile_lock_acquire(lock_dir: str, model: str, timeout: float = 300.0, poll_interval: float = 0.5) -> str | None:
lock_path = os.path.join(lock_dir, _safe_repo_filename(model) + ".dir")
deadline = time.monotonic() + timeout
while True:
try:
os.makedirs(lock_path, exist_ok=False)
logger.info("Acquired dotfile weight prefetch lock for %s at %s", model, lock_path)
return lock_path
except FileExistsError:
if time.monotonic() >= deadline:
logger.warning(
"Timed out waiting for dotfile weight prefetch lock %s after %.0fs; proceeding unlocked",
lock_path,
timeout,
)
return None
time.sleep(poll_interval)
@contextlib.contextmanager
def _repo_download_lock(model: str) -> Iterator[None]:
"""Serialize full-repo snapshot materialization across Omni processes."""
lock_dir = None
dotfile_held = None
fd = None
flock_held = False
try:
import fcntl # type: ignore[import-not-found]
except ImportError: # pragma: no cover - non-POSIX
fcntl = None
if fcntl is not None:
try:
lock_dir = _node_lock_dir()
lock_path = os.path.join(lock_dir, _safe_repo_filename(model))
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644)
fcntl.flock(fd, fcntl.LOCK_EX)
flock_held = True
logger.info("Acquired flock weight prefetch lock for %s at %s", model, lock_path)
except OSError as exc:
if fd is not None:
with contextlib.suppress(OSError):
os.close(fd)
fd = None
if exc.errno not in (errno.ENOLCK, errno.EOPNOTSUPP, errno.EACCES, errno.EINVAL):
raise
logger.warning("fcntl.flock unavailable for weight prefetch of %s (%s); using dotfile lock", model, exc)
if not flock_held:
try:
lock_dir = lock_dir or _node_lock_dir()
dotfile_held = _dotfile_lock_acquire(lock_dir, model)
except OSError as exc:
logger.warning("Could not allocate weight prefetch lock dir for %s (%s); proceeding unlocked", model, exc)
try:
yield
finally:
if flock_held and fd is not None:
with contextlib.suppress(OSError):
fcntl.flock(fd, fcntl.LOCK_UN)
if fd is not None:
with contextlib.suppress(OSError):
os.close(fd)
if dotfile_held is not None:
with contextlib.suppress(OSError):
os.rmdir(dotfile_held)
def _looks_like_auth_error(exc: BaseException) -> bool:
try:
from huggingface_hub.errors import GatedRepoError, RepositoryNotFoundError # type: ignore[import-not-found]
if isinstance(exc, GatedRepoError | RepositoryNotFoundError):
return True
except ImportError: # pragma: no cover - very old huggingface_hub
pass
status = getattr(getattr(exc, "response", None), "status_code", None)
if status in (401, 403):
return True
msg = str(exc).lower()
return "401 client error" in msg or "403 client error" in msg or "gatedrepo" in msg
def _is_full_snapshot_request(allow_patterns: list[str]) -> bool:
return any(pattern.strip() in {"*", "**", "./*", "**/*"} for pattern in allow_patterns)
def _verify_snapshot_materialized(hf_folder: str, allow_patterns: list[str]) -> None:
root = Path(hf_folder)
if not root.exists():
raise OSError(f"Downloaded snapshot folder does not exist: {hf_folder}")
if not any(root.glob(pattern) for pattern in allow_patterns):
raise OSError(f"Downloaded snapshot {hf_folder} has no files matching {allow_patterns}")
# Full-repo downloads are used before transformers loads tokenizers,
# processors, and feature extractors. A half-materialized shared cache can
# contain weights while missing these small metadata files, which later
# surfaces as "Can't load feature extractor ... preprocessor_config.json".
if _is_full_snapshot_request(allow_patterns) and not any(
(root / marker).exists() for marker in _FULL_SNAPSHOT_METADATA_MARKERS
):
raise OSError(
f"Downloaded full snapshot {hf_folder} is missing expected metadata files {_FULL_SNAPSHOT_METADATA_MARKERS}"
)
def download_weights_from_hf_specific(
model_name_or_path: str,
@@ -44,44 +201,77 @@ def download_weights_from_hf_specific(
str: The path to the downloaded model weights.
"""
assert len(allow_patterns) > 0
allow_patterns = list(allow_patterns)
local_only = huggingface_hub.constants.HF_HUB_OFFLINE
download_kwargs = {"tqdm_class": DisabledTqdm} if not envs.VLLM_USE_MODELSCOPE else {}
logger.info("Using model weights format %s", allow_patterns)
# Use file lock to prevent multiple processes from
# downloading the same model weights at the same time.
with get_lock(model_name_or_path, cache_dir):
start_time = time.perf_counter()
if require_all:
hf_folder = snapshot_download(
model_name_or_path,
allow_patterns=allow_patterns,
ignore_patterns=ignore_patterns,
cache_dir=cache_dir,
revision=revision,
local_files_only=local_only,
**download_kwargs,
)
else:
for allow_pattern in allow_patterns:
hf_folder = snapshot_download(
model_name_or_path,
allow_patterns=allow_pattern,
ignore_patterns=ignore_patterns,
cache_dir=cache_dir,
revision=revision,
local_files_only=local_only,
**download_kwargs,
)
# If we have downloaded weights for this allow_pattern,
# we don't need to check the rest, unless require_all is set.
if any(Path(hf_folder).glob(allow_pattern)):
start_time = time.perf_counter()
last_exc: BaseException | None = None
for attempt in range(1, _DOWNLOAD_MAX_ATTEMPTS + 1):
try:
# Use both vLLM's cache lock and a repo-wide node lock. The latter
# protects the transformers v5 eager metadata resolver from seeing
# a peer process' half-written shared HF cache.
with get_lock(model_name_or_path, cache_dir), _repo_download_lock(model_name_or_path):
if require_all:
hf_folder = snapshot_download(
model_name_or_path,
allow_patterns=allow_patterns,
ignore_patterns=ignore_patterns,
cache_dir=cache_dir,
revision=revision,
local_files_only=local_only,
**download_kwargs,
)
_verify_snapshot_materialized(hf_folder, allow_patterns)
break
time_taken = time.perf_counter() - start_time
if time_taken > 0.5:
logger.info(
"Time spent downloading weights for %s: %.6f seconds",
hf_folder = None
for allow_pattern in allow_patterns:
hf_folder = snapshot_download(
model_name_or_path,
allow_patterns=allow_pattern,
ignore_patterns=ignore_patterns,
cache_dir=cache_dir,
revision=revision,
local_files_only=local_only,
**download_kwargs,
)
_verify_snapshot_materialized(hf_folder, [allow_pattern])
# If we have downloaded weights for this allow_pattern,
# we don't need to check the rest, unless require_all is set.
if any(Path(hf_folder).glob(allow_pattern)):
break
if hf_folder is None:
raise OSError(f"No snapshot downloaded for patterns {allow_patterns}")
break
except Exception as exc:
last_exc = exc
if _looks_like_auth_error(exc) or attempt == _DOWNLOAD_MAX_ATTEMPTS or local_only:
raise
backoff = _DOWNLOAD_BACKOFF_BASE_S * attempt
logger.warning(
"Downloading weights for %s with patterns %s failed on attempt %d/%d (%s: %s); retrying in %.1fs",
model_name_or_path,
time_taken,
allow_patterns,
attempt,
_DOWNLOAD_MAX_ATTEMPTS,
type(exc).__name__,
exc,
backoff,
)
time.sleep(backoff)
if last_exc is not None and "hf_folder" not in locals():
raise last_exc
time_taken = time.perf_counter() - start_time
if time_taken > 0.5:
logger.info(
"Time spent downloading weights for %s: %.6f seconds",
model_name_or_path,
time_taken,
)
return hf_folder
@@ -162,6 +162,54 @@ def _build_delay_pattern(codes: torch.Tensor) -> torch.Tensor:
_ENCODER_CACHE: Any | None = None
_K2_OMNIVOICE_REPO = "k2-fsa/OmniVoice"
_K2_OMNIVOICE_SUBDIR = "audio_tokenizer"
_AUDIO_TOKENIZER_PATH_ENVS = (
"HIGGS_AUDIO_TOKENIZER_PATH",
"HIGGS_AUDIO_V2_TOKENIZER_PATH",
)
def _normalize_audio_tokenizer_dir(path: str) -> str | None:
if not path:
return None
expanded = os.path.abspath(os.path.expanduser(path))
if os.path.isfile(os.path.join(expanded, "config.json")):
return expanded
nested = os.path.join(expanded, _K2_OMNIVOICE_SUBDIR)
if os.path.isfile(os.path.join(nested, "config.json")):
return nested
return None
def _resolve_audio_tokenizer_dir() -> str | None:
"""Resolve the Higgs codec encoder dir without requiring online HF access.
Voice cloning runs in the API server process. Production H20 jobs are often
offline, so prefer explicit local directories and already-populated HF cache
before falling back to ``snapshot_download``.
"""
for env_name in _AUDIO_TOKENIZER_PATH_ENVS:
candidate = _normalize_audio_tokenizer_dir(os.getenv(env_name, ""))
if candidate is not None:
return candidate
from huggingface_hub import try_to_load_from_cache
cached_config = try_to_load_from_cache(
repo_id=_K2_OMNIVOICE_REPO,
filename=f"{_K2_OMNIVOICE_SUBDIR}/config.json",
)
if isinstance(cached_config, str) and os.path.isfile(cached_config):
return os.path.dirname(cached_config)
from huggingface_hub.constants import HF_HUB_CACHE
safe = _K2_OMNIVOICE_REPO.replace("/", "--")
snapshots_dir = os.path.join(HF_HUB_CACHE, f"models--{safe}", "snapshots")
if os.path.isdir(snapshots_dir):
for rev in os.listdir(snapshots_dir):
candidate = _normalize_audio_tokenizer_dir(os.path.join(snapshots_dir, rev))
if candidate is not None:
return candidate
def _load_audio_tokenizer():
@@ -180,14 +228,17 @@ def _load_audio_tokenizer():
Only the ``audio_tokenizer/`` subdirectory (~806 MB) is downloaded.
"""
from huggingface_hub import snapshot_download
from transformers import HiggsAudioV2TokenizerModel
repo_path = snapshot_download(
_K2_OMNIVOICE_REPO,
allow_patterns=[f"{_K2_OMNIVOICE_SUBDIR}/*"],
)
audio_tokenizer_dir = os.path.join(repo_path, _K2_OMNIVOICE_SUBDIR)
audio_tokenizer_dir = _resolve_audio_tokenizer_dir()
if audio_tokenizer_dir is None:
from huggingface_hub import snapshot_download
repo_path = snapshot_download(
_K2_OMNIVOICE_REPO,
allow_patterns=[f"{_K2_OMNIVOICE_SUBDIR}/*"],
)
audio_tokenizer_dir = os.path.join(repo_path, _K2_OMNIVOICE_SUBDIR)
device = "cuda" if torch.cuda.is_available() else "cpu"
model = HiggsAudioV2TokenizerModel.from_pretrained(audio_tokenizer_dir).to(device)
return model.eval()
@@ -93,8 +93,13 @@ class HiggsAudioV3Config(PretrainedConfig):
tts_token_id: int | None = None,
text_token_id: int | None = None,
audio_continuation_id: int | None = None,
enable_flashinfer_api_unwrap: bool = True,
enable_mlp_cudagraph: bool = True,
**kwargs: Any,
) -> None:
# Legacy perf knob removed: Higgs v3 scheduler tokens now come from
# SamplerOutput.sampled_token_ids only.
kwargs.pop("enable_cpu_token_override", None)
self.audio_token_id = audio_token_id
self.mel_per_sample = mel_per_sample
@@ -124,6 +129,8 @@ class HiggsAudioV3Config(PretrainedConfig):
self.tts_token_id = tts_token_id
self.text_token_id = text_token_id
self.audio_continuation_id = audio_continuation_id
self.enable_flashinfer_api_unwrap = bool(enable_flashinfer_api_unwrap)
self.enable_mlp_cudagraph = bool(enable_mlp_cudagraph)
super().__init__(**kwargs)
@@ -35,6 +35,7 @@ __all__ = [
logger = init_logger(__name__)
# Prefix under which codec weights are stored in the v3 checkpoint.
_CODEC_PREFIX = "tied.embedding.modality_embeddings.0.model."
File diff suppressed because it is too large. Load diff
@@ -20,6 +20,9 @@ from vllm_omni.model_executor.models.moss_tts.audio_tokenizer import (
MossAudioTokenizerConfig,
MossAudioTokenizerModel,
)
from vllm_omni.model_executor.models.moss_tts.moss_codec_cudagraph import (
MossTTSCUDAGraphCodecWrapper,
)
from vllm_omni.model_executor.models.output_templates import OmniOutput
logger = init_logger(__name__)
@@ -28,15 +31,16 @@ logger = init_logger(__name__)
class MossTTSCodecDecoder(nn.Module):
"""Stage-1 decoder for all MOSS-TTS variants.
Consumes ``(T, NQ)`` audio VQ codes emitted by Stage 0 and decodes them
Consumes ``(NQ, T)`` audio VQ codes emitted by Stage 0 and decodes them
to a 24 kHz mono waveform using the vendored
``MossAudioTokenizerModel``.
All five variants share the same codec checkpoint
``OpenMOSS-Team/MOSS-Audio-Tokenizer``. The number of quantizers
(``n_vq``) is passed as a ``num_quantizers`` argument to ``batch_decode``
so one codec instance handles both ``n_vq=32`` (MOSS-TTS) and ``n_vq=16``
(all other variants) without swapping weights.
(``n_vq``) is read from ``hf_config`` at construction time and fixed for
the lifetime of the instance; the same checkpoint can be configured as
``n_vq=32`` (MOSS-TTS) or ``n_vq=16`` (all other variants) without
swapping weights.
The codec checkpoint path defaults to the value stored in
``vllm_config.model_config.hf_config.codec_model_name_or_path`` but can
@@ -64,6 +68,7 @@ class MossTTSCodecDecoder(nn.Module):
# Resolved at load_weights() time
self._codec: MossAudioTokenizerModel | None = None
self._cuda_graph_wrapper: MossTTSCUDAGraphCodecWrapper | None = None
self._sr_tensor = torch.tensor(self._OUTPUT_SAMPLE_RATE, dtype=torch.int32)
# ------------------------------------------------------------------
@@ -100,10 +105,11 @@ class MossTTSCodecDecoder(nn.Module):
Stage 0 emits flat codebook-major ``[NQ * T_chunk]`` audio codes. The
chunk transfer adapter assigns those to ``request.prompt_token_ids``,
which arrives here as ``input_ids``. Per-request offsets are derived
from ``runtime_additional_information``-attached request metadata; meta
like ``left_context_size`` (overlap context for the causal decoder)
also lives there.
which arrives here as ``input_ids`` concatenated across all requests.
Per-request slice boundaries are computed from
``kwargs["num_scheduled_tokens"]`` (a list of token counts, one per
request). ``runtime_additional_information`` carries per-request
metadata such as ``left_context_size``.
Returns
-------
@@ -163,11 +169,10 @@ class MossTTSCodecDecoder(nn.Module):
continue
t_chunk = int(seg.numel() // self._n_vq)
codes_nq_t = seg.reshape(self._n_vq, t_chunk).to(device=device)
# Clamp out-of-range codes (the talker uses ``audio_pad_code``
# =``codebook_size`` for delay-pattern padding; passing it to the
# codec embedding lookup would be an OOB index. The downstream
# de-delay step is intended to drop those rows, but until that's
# wired we clamp so the kernel does not assert.)
# Clamp out-of-range codes: the talker uses ``audio_pad_code``
# (= ``codebook_size``) for delay-pattern padding. The stage input
# processor de-delays and drops pad rows before forwarding here, but
# clamp as a defensive guard against any edge-case leakage.
codebook_size = self._codec.config.codebook_size
codes_nq_t = codes_nq_t.clamp_(0, int(codebook_size) - 1)
@@ -183,7 +188,10 @@ class MossTTSCodecDecoder(nn.Module):
left_ctx = int(left_ctx.reshape(-1)[0].item()) if left_ctx.numel() else 0
left_ctx = int(left_ctx)
out = self._codec.batch_decode(codes_list=[codes_nq_t], num_quantizers=self._n_vq)
if self._cuda_graph_wrapper is not None:
out = self._cuda_graph_wrapper.decode(codes_nq_t)
else:
out = self._codec.batch_decode(codes_list=[codes_nq_t], num_quantizers=self._n_vq)
if out.audio is None:
continue
@@ -194,7 +202,13 @@ class MossTTSCodecDecoder(nn.Module):
# Trim left-context samples.
if left_ctx > 0:
trim = left_ctx * self._codec.downsample_rate
trim = min(left_ctx * self._codec.downsample_rate, wav.shape[0])
if trim < left_ctx * self._codec.downsample_rate:
logger.warning(
"left_ctx trim (%d samples) exceeds wav length (%d); returning empty audio.",
left_ctx * self._codec.downsample_rate,
wav.shape[0],
)
wav = wav[trim:]
audios[i] = wav
@@ -288,8 +302,7 @@ class MossTTSCodecDecoder(nn.Module):
codec_cfg.num_quantizers,
)
# Move sr_tensor to the same device
self._sr_tensor = self._sr_tensor.to(device=device)
self._maybe_enable_decoder_cudagraph(device)
# vLLM's track_weights_loading() compares the returned set against
# ``self.named_parameters()``. After ``self._codec = codec`` above,
@@ -297,5 +310,42 @@ class MossTTSCodecDecoder(nn.Module):
# mirror that here.
return {f"_codec.{name}" for name, _ in codec.named_parameters()}
def _maybe_enable_decoder_cudagraph(self, device: torch.device) -> None:
"""Capture CUDA Graphs for the codec decoder if enforce_eager is False."""
if getattr(self.vllm_config.model_config, "enforce_eager", True):
return
if self._codec is None:
return
# Read capture sizes from the connector's extra config (same convention
# as Qwen3-TTS), falling back to a sensible default covering common
# codec_chunk_frames values used in moss_tts.yaml.
capture_sizes: list[int] = [4, 8, 16, 25, 32, 50, 64, 100, 128, 200, 256]
model_cfg = getattr(self.vllm_config, "model_config", None)
connector_cfg = getattr(model_cfg, "stage_connector_config", None)
if isinstance(connector_cfg, dict):
extra_cfg: dict | None = connector_cfg.get("extra", connector_cfg)
else:
extra_cfg = getattr(connector_cfg, "extra", None)
if isinstance(extra_cfg, dict):
raw = extra_cfg.get("decode_cudagraph_capture_sizes")
if raw is not None:
if isinstance(raw, (list, tuple)):
parsed = sorted({int(v) for v in raw if int(v) > 0})
elif isinstance(raw, str):
parsed = sorted({int(v.strip()) for v in raw.split(",") if v.strip()})
else:
parsed = [int(raw)]
if parsed:
capture_sizes = parsed
self._cuda_graph_wrapper = MossTTSCUDAGraphCodecWrapper(
model=self._codec,
capture_sizes=capture_sizes,
num_quantizers=self._n_vq,
enabled=True,
)
self._cuda_graph_wrapper.warmup(device)
__all__ = ["MossTTSCodecDecoder"]
@@ -0,0 +1,207 @@
"""CUDA Graph acceleration for the MOSS Audio Tokenizer codec decoder.
Captures MossAudioTokenizerModel._decode for a set of fixed frame-count
bucket sizes, then replays the captured graph at inference time to eliminate
kernel-launch overhead. Inputs that exceed all captured sizes fall back to
eager execution transparently.
"""
from __future__ import annotations
import time
import torch
from torch.cuda import CUDAGraph
from vllm.logger import init_logger
from vllm.platforms import current_platform
from vllm_omni.model_executor.models.moss_tts.audio_tokenizer import (
MossAudioTokenizerDecoderOutput,
MossAudioTokenizerModel,
)
logger = init_logger(__name__)
class MossTTSCUDAGraphCodecWrapper:
"""CUDA Graph wrapper for MossAudioTokenizerModel._decode.
Graphs are keyed by padded_T (int). On each call the actual T is
bucket-matched to the smallest pre-captured size >= T. The static code
buffer [NQ, 1, padded_T] is filled left-aligned (right-zero-padded) and
the graph is replayed. The output audio is sliced to the correct length
by scaling from the captured audio shape (actual_T / padded_T * captured_len),
avoiding any assumption about downsample_rate vs effective decoder upsample.
The slice is cloned before returning so the static buffer can be reused.
Usage::
wrapper = MossTTSCUDAGraphCodecWrapper(codec_model, capture_sizes, nq)
wrapper.warmup(device)
# per-request decode:
out = wrapper.decode(codes_nq_t) # codes_nq_t: [NQ, T]
"""
def __init__(
self,
model: MossAudioTokenizerModel,
capture_sizes: list[int],
num_quantizers: int,
enabled: bool = True,
) -> None:
self.model = model
self.capture_sizes: list[int] = sorted(capture_sizes)
self.num_quantizers = num_quantizers
self.enabled = enabled
# All dicts keyed by padded_T.
self.graphs: dict[int, CUDAGraph] = {}
self.static_codes: dict[int, torch.Tensor] = {} # [NQ, 1, padded_T]
# static_lengths is kept alive here — the captured graph holds a
# reference to the underlying storage and must not be GC'd.
self.static_lengths: dict[int, torch.Tensor] = {} # [1]
self.static_audio: dict[int, torch.Tensor] = {} # [1, 1, padded_T * effective_upsample]
self._warmed_up = False
# ------------------------------------------------------------------
# Size helpers
# ------------------------------------------------------------------
def _get_padded_size(self, actual_t: int) -> int | None:
"""Return the smallest capture size >= actual_t, or None if too large."""
for s in self.capture_sizes:
if actual_t <= s:
return s
return None
# ------------------------------------------------------------------
# Warmup / capture
# ------------------------------------------------------------------
def warmup(self, device: torch.device) -> None:
"""Allocate static buffers and capture CUDA Graphs for all sizes."""
if device.type != "cuda" or not self.enabled or self._warmed_up:
return
nq = self.num_quantizers
logger.info(
"MOSS-TTS codec CUDA Graph warmup: nq=%d capture_sizes=%s",
nq,
self.capture_sizes,
)
t0 = time.perf_counter()
# One eager run per size to let cuDNN / CUDA allocate memory before
# the capture window (graph capture forbids new CUDA allocs during it).
for size in self.capture_sizes:
dummy_codes = torch.zeros(nq, 1, size, dtype=torch.long, device=device)
dummy_lengths = torch.tensor([size], dtype=torch.long, device=device)
with torch.no_grad():
_ = self.model._decode(dummy_codes, dummy_lengths)
torch.accelerator.synchronize(device)
for size in self.capture_sizes:
try:
self._capture(size, device)
logger.info(" Captured CUDA Graph for size=%d", size)
except Exception:
logger.warning(
" Failed to capture CUDA Graph for size=%d; this size will fall back to eager decode",
size,
exc_info=True,
)
self._warmed_up = True
elapsed_ms = (time.perf_counter() - t0) * 1000.0
logger.info(
"MOSS-TTS codec CUDA Graph warmup complete: %d/%d captured in %.1f ms",
len(self.graphs),
len(self.capture_sizes),
elapsed_ms,
)
def _capture(self, size: int, device: torch.device) -> None:
nq = self.num_quantizers
static_codes = torch.zeros(nq, 1, size, dtype=torch.long, device=device)
# lengths holds the number of valid code frames; set to full size at
# capture time so the decoder emits a full-size audio buffer.
static_lengths = torch.tensor([size], dtype=torch.long, device=device)
# Extra eager warmup inside capture to ensure all kernels are compiled.
with torch.no_grad():
_ = self.model._decode(static_codes, static_lengths)
torch.accelerator.synchronize(device)
graph = CUDAGraph()
with torch.no_grad():
with torch.cuda.graph(graph, pool=current_platform.get_global_graph_pool()):
static_out = self.model._decode(static_codes, static_lengths)
self.graphs[size] = graph
self.static_codes[size] = static_codes
self.static_lengths[size] = static_lengths
# static_out.audio is a static buffer reused every replay; hold a
# reference so it is not garbage-collected.
self.static_audio[size] = static_out.audio # [1, 1, size * effective_upsample]
# ------------------------------------------------------------------
# Inference
# ------------------------------------------------------------------
@torch.no_grad()
def decode(self, codes_nq_t: torch.Tensor) -> MossAudioTokenizerDecoderOutput:
"""Decode [NQ, T] codes to waveform using a CUDA Graph when possible.
Falls back to eager batch_decode when:
- CUDA Graph is disabled or not yet warmed up
- an outer CUDA stream capture is active (e.g. vLLM FULL graph mode)
- actual T exceeds all pre-captured sizes
"""
if not self.enabled or not self._warmed_up:
return self.model.batch_decode(codes_list=[codes_nq_t], num_quantizers=self.num_quantizers)
# Replaying a graph inside an active stream capture would corrupt the
# outer graph. Fall back to eager so the caller can complete its capture.
if torch.cuda.is_current_stream_capturing():
return self.model.batch_decode(codes_list=[codes_nq_t], num_quantizers=self.num_quantizers)
actual_t = int(codes_nq_t.shape[-1])
padded_size = self._get_padded_size(actual_t)
if padded_size is None or padded_size not in self.graphs:
return self.model.batch_decode(codes_list=[codes_nq_t], num_quantizers=self.num_quantizers)
# --- Fill static buffers then replay ---
static_codes = self.static_codes[padded_size] # [NQ, 1, padded_size]
if actual_t == padded_size:
# Exact fit: copy the whole buffer at once.
# codes_nq_t is [NQ, T]; unsqueeze(1) → [NQ, 1, T] matching static_codes.
static_codes.copy_(codes_nq_t.unsqueeze(1))
else:
# Smaller input: zero the buffer first (right-zero-pad), then fill
# left-aligned. static_codes[:, 0, :actual_t] is [NQ, actual_t]
# and codes_nq_t is [NQ, actual_t].
static_codes.zero_()
static_codes[:, 0, :actual_t].copy_(codes_nq_t)
self.graphs[padded_size].replay()
# static_audio[padded_size] is the live output buffer of the graph.
# Slice to the real audio length and clone before returning; without
# the clone the next replay would overwrite the caller's tensor.
# Derive the slice length from the captured audio shape rather than
# downsample_rate: the decoder's effective upsample (product of all
# PatchedPretransform patch sizes) can differ from the config attribute.
captured_len = self.static_audio[padded_size].shape[-1]
actual_wav_len = captured_len * actual_t // padded_size
audio = self.static_audio[padded_size][..., :actual_wav_len].clone()
audio_lengths = torch.tensor([actual_wav_len], dtype=torch.long, device=audio.device)
return MossAudioTokenizerDecoderOutput(audio=audio, audio_lengths=audio_lengths)
__all__ = ["MossTTSCUDAGraphCodecWrapper"]
@@ -36,6 +36,7 @@ from vllm.platforms import current_platform
from vllm.sequence import IntermediateTensors
from vllm_omni.model_executor.models.output_templates import OmniOutput
from vllm_omni.platforms import current_omni_platform
from vllm_omni.utils.speaker_cache import (
get_speaker_cache,
iter_custom_voice_profiles,
@@ -824,7 +825,7 @@ def _optimized_solve_euler_with_noise(
t = t - dt
if step < len(t_span) - 1:
dt = t - t_span[step + 1]
return x
return x.clone()
# ===================================================================
@@ -863,11 +864,11 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
# the checkpoint download/read cost at construction time; DummyModelLoader
# will then randomize the just-loaded _tts params — this is intended.
model_path = vllm_config.model_config.model
self._device = current_omni_platform.get_torch_device()
VoxCPM = import_voxcpm2_core()
native = VoxCPM.from_pretrained(model_path, load_denoiser=False, optimize=False)
self._tts: nn.Module = native.tts_model.to("cuda")
self._tts: nn.Module = native.tts_model.to(self._device)
self._side_dtype = self._tts.fusion_concat_proj.weight.dtype
self._device = "cuda"
self._patch_size = self._tts.patch_size
self._feat_dim = self._tts.feat_dim
self._sample_rate = getattr(self.config, "sample_rate", 48000)
@@ -888,8 +889,9 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
# for sliding-window streaming decode. 12 matches the nanovllm reference
# implementation and covers the longest VAE decoder receptive field.
self._n_decode_pad_frames = 12
self._enable_torch_compile = True
self._compile_vae = True
use_cuda_graph = current_omni_platform.is_cuda()
self._enable_torch_compile = current_omni_platform.supports_torch_inductor()
self._compile_vae = self._enable_torch_compile
self._max_decode_steps = 2000
self._max_batch_size = getattr(vllm_config.scheduler_config, "max_num_seqs", 4)
@@ -900,15 +902,15 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
self._enable_profiling = self._runtime_config.enable_profiling
self._perf = _PerfTimer(enabled=self._enable_profiling)
self._cfm_buffers: _CFMBufferManager | None = None
self._enable_cuda_graph = True
self._enable_cuda_graph = use_cuda_graph
self._scaffold_graphs: dict[int, _CapturedGraph] = {}
self._residual_graphs: dict[int, _CapturedGraph] = {}
self._decode_graph_capture_policy = self._runtime_config.decode_graph_capture_policy
self._vae_graphs: dict[tuple[int, int], _CapturedVAEGraph] = {}
self._enable_vae_cuda_graph = self._runtime_config.enable_vae_cuda_graph
self._enable_vae_cuda_graph = use_cuda_graph and self._runtime_config.enable_vae_cuda_graph
self._cfm_graphs: dict[int, _CapturedCFMGraph] = {}
self._enable_cfm_cuda_graph = self._runtime_config.enable_cfm_cuda_graph
self._enable_cfm_prealloc_output = self._runtime_config.enable_cfm_prealloc_output
self._enable_cfm_cuda_graph = use_cuda_graph and self._runtime_config.enable_cfm_cuda_graph
self._enable_cfm_prealloc_output = use_cuda_graph and self._runtime_config.enable_cfm_prealloc_output
self._enable_batched_cfm = self._runtime_config.enable_batched_cfm
self._deterministic_cfm_noise = self._runtime_config.deterministic_cfm_noise
self._deterministic_cfm_seed = self._runtime_config.deterministic_cfm_seed
@@ -916,7 +918,7 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
self._audio_emit_every = self._runtime_config.audio_emit_every
self._vae_decode_every = self._runtime_config.vae_decode_every
self._enable_delayed_audio_copy = self._runtime_config.enable_delayed_audio_copy
self._delayed_audio_copy_use_events = self._runtime_config.delayed_audio_copy_use_events
self._delayed_audio_copy_use_events = use_cuda_graph and self._runtime_config.delayed_audio_copy_use_events
self._coalesce_audio_d2h = self._runtime_config.coalesce_audio_d2h
self._enable_batched_vae_decode = self._runtime_config.enable_batched_vae_decode
self._enable_batched_fsq_fusion = self._runtime_config.enable_batched_fsq_fusion
@@ -1382,7 +1384,7 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
return sr_cond
def _run_vae_decode(self, feat: torch.Tensor) -> torch.Tensor:
if not feat.is_cuda:
if feat.device.type != current_omni_platform.device_type:
return self.tts.audio_vae.decode(feat)
sr_cond = self._get_vae_decode_sr_cond(feat.device)
@@ -1817,7 +1819,7 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
def _run_cfm(self, dit_h: torch.Tensor, cond: torch.Tensor) -> torch.Tensor:
with _NvtxRange("voxcpm2.cfm"):
if self._cfm_buffers is not None:
if self._enable_cfm_cuda_graph and dit_h.is_cuda:
if self._enable_cfm_cuda_graph and dit_h.device.type == current_omni_platform.device_type:
return self._run_cfm_cuda_graph(dit_h, cond).transpose(1, 2)
return _optimized_solve_euler(
self.tts.feat_decoder,
@@ -1841,7 +1843,7 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
def _run_cfm_for_state(self, state: _RequestState, dit_h: torch.Tensor, cond: torch.Tensor) -> torch.Tensor:
with _NvtxRange("voxcpm2.cfm"):
if self._cfm_buffers is not None:
if self._enable_cfm_cuda_graph and dit_h.is_cuda:
if self._enable_cfm_cuda_graph and dit_h.device.type == current_omni_platform.device_type:
if self._deterministic_cfm_noise and not self._enable_cfm_prealloc_output:
graph = self._get_cfm_cuda_graph(dit_h, cond)
with _NvtxRange("voxcpm2.cfm.graph_copy_mu"):
@@ -2057,7 +2059,7 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
def _enqueue_delayed_audio_copy(self, state: _RequestState, audio: torch.Tensor) -> None:
src = audio.detach().contiguous()
if not src.is_cuda:
if src.device.type != current_omni_platform.device_type:
state.pending_audio_copies.append(_PendingAudioCopy(host=src.cpu().contiguous()))
return
@@ -2145,7 +2147,7 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
if state.is_stopping or state.precomputed_is_stopping is not None:
continue
stop_logits = state.precomputed_stop_logits
if stop_logits is None or not stop_logits.is_cuda:
if stop_logits is None or stop_logits.device.type != current_omni_platform.device_type:
continue
pending.append((state, stop_logits))
if not pending:
@@ -2426,7 +2428,9 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
if self._uses_sparse_audio_outputs():
ready_req_ids = list(audio_by_req)
chunks = [audio_by_req[req_id].reshape(-1) for req_id in ready_req_ids]
if self._coalesce_audio_d2h and any(chunk.is_cuda for chunk in chunks):
if self._coalesce_audio_d2h and any(
chunk.device.type == current_omni_platform.device_type for chunk in chunks
):
sizes = [int(chunk.numel()) for chunk in chunks]
merged = torch.cat(chunks, dim=0) if len(chunks) > 1 else chunks[0]
merged_cpu = merged.detach().cpu().contiguous()
@@ -2435,7 +2439,9 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
mm["model_outputs"] = chunks
mm["sr"] = [sr for _ in ready_req_ids]
mm["meta"] = {"req_id": ready_req_ids, "sparse_audio": ["1"]}
elif self._coalesce_audio_d2h and any(audio.is_cuda for audio in audio_by_req.values()):
elif self._coalesce_audio_d2h and any(
audio.device.type == current_omni_platform.device_type for audio in audio_by_req.values()
):
ready_req_ids = list(audio_by_req)
chunks = [audio_by_req[req_id].reshape(-1) for req_id in ready_req_ids]
sizes = [int(chunk.numel()) for chunk in chunks]
@@ -32,6 +32,7 @@ from vllm_omni.data_entry_keys import (
OmniPayload,
OmniPayloadStruct,
)
from vllm_omni.inputs.data import OmniTokensPrompt
__all__ = ["talker2code2wav", "talker2code2wav_async_chunk"]
@@ -48,6 +49,15 @@ _DEFAULT_CODEC_LEFT_CONTEXT_FRAMES = 25
_DEFAULT_CODEC_RIGHT_HOLDBACK_FRAMES = 4
def _empty_code2wav_prompt() -> Any:
return OmniTokensPrompt(
prompt_token_ids=[],
multi_modal_data=None,
mm_processor_kwargs=None,
additional_information=None,
)
def _revert_delay_pattern(audio_codes_qt: torch.Tensor) -> torch.Tensor:
"""Reverse the MusicGen-style delay pattern.
@@ -91,9 +101,8 @@ def talker2code2wav(
_requires_multimodal_data: bool = False,
) -> list[Any]:
"""Sync: collect all talker codes, revert delay pattern, filter, pass to code2wav."""
from vllm_omni.inputs.data import OmniTokensPrompt
code2wav_inputs: list[OmniTokensPrompt] = []
code2wav_inputs: list[Any] = []
for talker_output in source_outputs:
if not talker_output.finished:
continue
@@ -103,14 +112,7 @@ def talker2code2wav(
audio_codes = mm_codes.get("audio")
if audio_codes is None or not isinstance(audio_codes, torch.Tensor) or audio_codes.numel() == 0:
code2wav_inputs.append(
OmniTokensPrompt(
prompt_token_ids=[],
multi_modal_data=None,
mm_processor_kwargs=None,
additional_information=None,
)
)
code2wav_inputs.append(_empty_code2wav_prompt())
continue
audio_codes = audio_codes.to(torch.long)
@@ -137,22 +139,9 @@ def talker2code2wav(
# Step 1: Revert delay pattern
try:
codes_qt = _revert_delay_pattern(codes_qt)
except ValueError:
logger.warning(
"Skipping request: insufficient frames for delay pattern "
"reversal (T=%d < Q=%d). This is a request-level error, "
"not a server-fatal condition.",
codes_qt.shape[1],
codes_qt.shape[0],
)
code2wav_inputs.append(
OmniTokensPrompt(
prompt_token_ids=[],
multi_modal_data=None,
mm_processor_kwargs=None,
additional_information=None,
)
)
except ValueError as exc:
logger.warning("Skipping invalid Higgs Audio v3 code sequence for Stage 1: %s", exc)
code2wav_inputs.append(_empty_code2wav_prompt())
continue
# Step 2: Replace out-of-range codes (BOC=1024, EOC=1025, -1) with 0.
@@ -172,14 +161,7 @@ def talker2code2wav(
codes_qt = codes_qt[:, :-1]
if codes_qt.numel() == 0:
code2wav_inputs.append(
OmniTokensPrompt(
prompt_token_ids=[],
multi_modal_data=None,
mm_processor_kwargs=None,
additional_information=None,
)
)
code2wav_inputs.append(_empty_code2wav_prompt())
continue
# Code2Wav expects codebook-major flat: [Q * num_frames]
@@ -18,6 +18,11 @@ from typing import Any
from vllm.inputs import TextPrompt
from vllm.logger import init_logger
from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_image3_transformer import (
Resolution,
ResolutionGroup,
get_cached_resolution_group,
)
from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import (
HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS,
)
@@ -25,82 +30,6 @@ from vllm_omni.inputs.data import OmniTokensPrompt
logger = init_logger(__name__)
# AR emits `<img_size_BASE><img_ratio_Y>` after `</recaption>` in IT2I/T2I
# (see `HunyuanImage3ForCausalMM.sample` and `_stage_transitions`). The
# ratio_index resolves to a (height, width) bucket via ResolutionGroup, which
# is the official upstream's mechanism for AR-driven output aspect; without
# this lookup the DiT pipeline falls back to the user-provided width/height
# (in the `/v1/images/edits` path that defaults to `pil_images[0].size`,
# i.e. the first reference image's bucket, usually square, see
# api_server.py:1808-1811).
_HUNYUAN_IMAGE3_EXTRA_RESOLUTIONS: tuple[str, ...] = (
"1024x768",
"1280x720",
"768x1024",
"720x1280",
)
class _Resolution:
def __init__(self, size: str | int | tuple[int, int], *args: int):
if isinstance(size, str):
if "x" in size:
h, w = size.split("x")
size = (int(h), int(w))
else:
size = int(size)
if args:
size = (int(size), args[0])
if isinstance(size, int):
size = (size, size)
self.height = int(size[0])
self.width = int(size[1])
self.ratio = self.height / self.width
def _build_resolutions_by_step(base_size: int, align: int = 1) -> list[_Resolution]:
step = base_size // 16
min_height = base_size // 2
min_width = base_size // 2
max_height = base_size * 2
max_width = base_size * 2
resolutions = [_Resolution(base_size, base_size)]
cur_height, cur_width = base_size, base_size
while True:
if cur_height >= max_height and cur_width <= min_width:
break
cur_height = min(cur_height + step, max_height)
cur_width = max(cur_width - step, min_width)
resolutions.append(_Resolution(cur_height // align * align, cur_width // align * align))
cur_height, cur_width = base_size, base_size
while True:
if cur_height <= min_height and cur_width >= max_width:
break
cur_height = max(cur_height - step, min_height)
cur_width = min(cur_width + step, max_width)
resolutions.append(_Resolution(cur_height // align * align, cur_width // align * align))
return sorted(resolutions, key=lambda x: x.ratio)
@lru_cache(maxsize=4)
def _build_ratio_size_table(base_size: int) -> list[tuple[int, int]]:
"""Return `[(height, width)]` indexed by ratio_index for HunyuanImage-3.
Mirrors `HunyuanImage3ImageProcessor.build_image_info`'s
`reso_group[ratio_index]` reverse lookup. Cached because the table
is constant per `base_size`.
"""
resolutions = _build_resolutions_by_step(base_size)
for extra_resolution in (_Resolution(s) for s in _HUNYUAN_IMAGE3_EXTRA_RESOLUTIONS):
if not any(r.ratio == extra_resolution.ratio for r in resolutions):
resolutions.append(extra_resolution)
return [(r.height, r.width) for r in resolutions]
def _truncate_at_cot_end(generated_text: str) -> str:
"""Truncate AR output at first `</recaption>` (or `</think>` fallback).
@@ -185,7 +114,8 @@ def ar2diffusion(
for i, ar_output in enumerate(ar_outputs):
output = ar_output.outputs[0]
generated_token_ids = output.cumulative_token_ids
generated_text = getattr(output, "text", "") or ""
# Prefer cumulative_text, fallback to text if aggregation dropped it
generated_text = getattr(output, "cumulative_text", None) or getattr(output, "text", "") or ""
# Get original prompt info
original_prompt = prompt[i] if i < len(prompt) else {}
@@ -216,16 +146,18 @@ def ar2diffusion(
ar_predicted = False
if ratio_idx is not None:
base_size = int(original_prompt.get("image_base_size", 1024))
size_table = _build_ratio_size_table(base_size)
if 0 <= ratio_idx < len(size_table):
height, width = size_table[ratio_idx]
reso_group: ResolutionGroup = get_cached_resolution_group(base_size=base_size)
try:
reso: Resolution = reso_group[ratio_idx]
height = reso.height
width = reso.width
ar_predicted = True
else:
except IndexError:
logger.warning(
"[ar2diffusion] Request %d: ratio_index=%d out of range [0,%d), keeping prompt size %dx%d",
i,
ratio_idx,
len(size_table),
len(reso_group),
height,
width,
)
+7
View File
@@ -102,12 +102,17 @@ class OmniRequestOutput:
# error handling
error: str | None = None
error_status_code: int | None = None
error_type: str | None = None
@classmethod
def from_error(
cls,
request_id: str,
error_message: str,
*,
status_code: int | None = None,
error_type: str | None = None,
) -> "OmniRequestOutput":
"""Create a terminal error output.
@@ -122,6 +127,8 @@ class OmniRequestOutput:
request_id=request_id,
finished=True,
error=error_message,
error_status_code=status_code,
error_type=error_type,
)
@classmethod
@@ -240,9 +240,38 @@ class NPUARModelRunner(OmniNPUModelRunner):
def _resolve_pooler_payload_req_ids(self, req_ids_output_copy: list[str]) -> tuple[str, list[str]]:
downstream_req_ids = [rid for rid in req_ids_output_copy if self._request_needs_downstream_stage_payload(rid)]
engine_output_type = (self.vllm_config.model_config.engine_output_type or "").lower()
# Single-stage AR TTS models (e.g. VoxCPM2) finish on this stage but still
# need multimodal payloads for final audio postprocess/output.
if engine_output_type == "audio" and not downstream_req_ids:
downstream_req_ids = req_ids_output_copy
return engine_output_type, downstream_req_ids
@staticmethod
def _sparse_mm_req_ids(multimodal_outputs: Any) -> list[str] | None:
if not isinstance(multimodal_outputs, dict):
return None
meta = multimodal_outputs.get("meta")
req_ids = None
sparse_audio = False
if isinstance(meta, dict):
req_ids = meta.get("req_id")
sparse_audio = NPUARModelRunner._is_sparse_audio_marker(meta.get("sparse_audio"))
if req_ids is None:
req_ids = multimodal_outputs.get("meta.req_id")
sparse_audio = NPUARModelRunner._is_sparse_audio_marker(multimodal_outputs.get("meta.sparse_audio"))
if not sparse_audio:
return None
if not isinstance(req_ids, list):
return None
return [rid for rid in req_ids if isinstance(rid, str)]
@staticmethod
def _is_sparse_audio_marker(value: Any) -> bool:
if isinstance(value, list):
return any(str(item).lower() in ("1", "true", "yes", "on") for item in value)
if isinstance(value, str):
return value.lower() in ("1", "true", "yes", "on")
return bool(value)
# -------------------------------------- Omni-new -------------------------------------------------
@torch.inference_mode()
@@ -910,16 +939,27 @@ class NPUARModelRunner(OmniNPUModelRunner):
# -------------------------------------- Omni-new -------------------------------------------------
engine_output_type, downstream_req_ids = self._resolve_pooler_payload_req_ids(req_ids_output_copy)
sparse_mm_req_ids = self._sparse_mm_req_ids(multimodal_outputs)
sparse_mm_index = {rid: i for i, rid in enumerate(sparse_mm_req_ids or [])}
if engine_output_type == "audio" and sparse_mm_req_ids is not None:
sparse_req_id_set = set(sparse_mm_req_ids)
downstream_req_ids = [rid for rid in req_ids_output_copy if rid in sparse_req_id_set]
needs_pooler_payload = len(downstream_req_ids) > 0
downstream_req_id_set = set(downstream_req_ids)
hidden_states_cpu = None
req_hidden_states_cpu: dict[str, torch.Tensor] | None = None
if needs_pooler_payload:
audio_sparse_output = engine_output_type == "audio" and sparse_mm_req_ids is not None
needs_scheduled_hidden_payload = needs_pooler_payload and (
self.omni_prefix_cache is None or not self._model_needs_full_prefix_hidden_states()
)
if needs_scheduled_hidden_payload:
num_valid_tokens = min(
int(scheduler_output.total_num_scheduled_tokens),
int(hidden_states.shape[0]),
)
if len(downstream_req_ids) == len(req_ids_output_copy):
if audio_sparse_output:
pass
elif len(downstream_req_ids) == len(req_ids_output_copy):
hidden_states_cpu = hidden_states[:num_valid_tokens].detach().to("cpu").contiguous()
else:
req_hidden_states_cpu = {}
@@ -931,9 +971,14 @@ class NPUARModelRunner(OmniNPUModelRunner):
dtype=np.int32,
)
query_start_loc_cpu = self.query_start_loc.cpu
if callable(query_start_loc_cpu):
query_start_loc_cpu = query_start_loc_cpu()
pooler_output: list[dict[str, object]] | None = None
if needs_pooler_payload:
combined_hidden_states = None
combined_multimodal_outputs = None
mm_cpu = None
if self.omni_prefix_cache is not None:
(
combined_hidden_states,
@@ -943,8 +988,10 @@ class NPUARModelRunner(OmniNPUModelRunner):
multimodal_outputs,
scheduler_output.num_scheduled_tokens,
)
else:
mm_cpu = build_mm_cpu(flatten_payload(multimodal_outputs))
if self.omni_prefix_cache is None or combined_multimodal_outputs is None:
mm_cpu = build_mm_cpu(
flatten_payload(multimodal_outputs) if multimodal_outputs else multimodal_outputs
)
self._process_additional_information_updates(
hidden_states,
@@ -973,17 +1020,20 @@ class NPUARModelRunner(OmniNPUModelRunner):
start = int(query_start_loc_cpu[idx])
sched = int(num_scheduled_tokens_np[idx])
end = start + sched
if req_hidden_states_cpu is not None and combined_hidden_states is None:
req_hidden_states = req_hidden_states_cpu[rid]
else:
req_hidden_states = self._resolve_req_hidden_states(
hidden_states_cpu,
combined_hidden_states,
rid,
start,
end,
)
payload: dict[str, object] = {"hidden": req_hidden_states}
payload: dict[str, object] = {}
if not audio_sparse_output:
if req_hidden_states_cpu is not None and combined_hidden_states is None:
req_hidden_states = req_hidden_states_cpu[rid]
else:
req_hidden_states = self._resolve_req_hidden_states(
hidden_states_cpu,
combined_hidden_states,
rid,
start,
end,
)
payload["hidden"] = req_hidden_states
mm_payload: dict[str, object] = {}
if combined_multimodal_outputs or mm_cpu:
if combined_multimodal_outputs:
@@ -1006,6 +1056,25 @@ class NPUARModelRunner(OmniNPUModelRunner):
mm_payload[mm_key] = _unwrap_lists(combined_multimodal_outputs[mm_key][rid])
else:
for mm_key, mm_val in mm_cpu.items():
if mm_key in {"meta.req_id", "meta.sparse_audio"}:
continue
if audio_sparse_output and isinstance(mm_val, list):
sparse_idx = sparse_mm_index.get(rid)
if sparse_idx is None:
continue
if sparse_idx >= len(mm_val):
logger.warning(
"Sparse multimodal payload mismatch for request %s: index %d >= %d.",
rid,
sparse_idx,
len(mm_val),
)
continue
sparse_val = mm_val[sparse_idx]
mm_payload[mm_key] = (
sparse_val.clone() if isinstance(sparse_val, torch.Tensor) else sparse_val
)
continue
mm_payload[mm_key] = to_payload_element(
element=mm_val,
idx=idx,
+24
View File
@@ -0,0 +1,24 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from vllm.reasoning import ReasoningParserManager
# Step-Audio specific reasoning parser.
# Registered at import time so it is available before the engine
# resolves ``--reasoning-parser step_audio``.
_OMNI_REASONING_PARSERS = {
"step_audio": (
"vllm_omni.reasoning.step_audio_reasoning_parser",
"StepAudioReasoningParser",
),
}
def register_omni_reasoning_parsers():
"""Register vllm-omni reasoning parsers with vLLM's ReasoningParserManager."""
for name, (module_path, class_name) in _OMNI_REASONING_PARSERS.items():
ReasoningParserManager.register_lazy_module(name, module_path, class_name)
register_omni_reasoning_parsers()
@@ -0,0 +1,574 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from collections.abc import Iterable, Sequence
from itertools import islice
from typing import TYPE_CHECKING
from vllm.entrypoints.openai.engine.protocol import DeltaMessage
from vllm.logger import init_logger
from vllm.reasoning import ReasoningParser
from vllm.tokenizers import TokenizerLike
logger = init_logger(__name__)
if TYPE_CHECKING:
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
class StepAudioReasoningParser(ReasoningParser):
"""Reasoning parser for Step-Audio models.
Step-Audio supports two representations of thinking markers:
1. **Special tokens**: ``<|THINK_START|>`` and ``<|THINK_END|>``
(single-token IDs, e.g. 151669 and 151670).
2. **Text markers**: ```` and ``````
(multi-token sequences, e.g. ``</think`` → [522, 26865],
``>`` → [29]).
The chat template and typical model output use the **text form**
(multi-token). The parser therefore always uses **text-based**
matching for streaming extraction, but also checks single-token
IDs when available for methods that only receive token IDs
(``is_reasoning_end``, ``extract_content_ids``, etc.).
"""
# Text-form think markers (used in chat template & typical model output).
THINK_START_TEXT = "<think>"
THINK_END_TEXT = "</think>"
# Special-token-form think markers (single-token IDs in vocab).
THINK_START_SPECIAL = "<|THINK_START|>"
THINK_END_SPECIAL = "<|THINK_END|>"
def __init__(self, tokenizer: TokenizerLike, *args, **kwargs):
super().__init__(tokenizer, *args, **kwargs)
if not self.model_tokenizer:
raise ValueError(
"The model tokenizer must be passed to the ReasoningParser constructor during construction."
)
# --- Resolve think markers ----------------------------------------
# Special-token form (single tokens in vocab for fast ID checks).
self.think_start_special_id: int = self.vocab.get(self.THINK_START_SPECIAL, -1)
self.think_end_special_id: int = self.vocab.get(self.THINK_END_SPECIAL, -1)
# Text-form markers in vocab (usually NOT single tokens for
# Step-Audio, but checked for completeness).
self.think_start_text_id: int = self.vocab.get(self.THINK_START_TEXT, -1)
self.think_end_text_id: int = self.vocab.get(self.THINK_END_TEXT, -1)
# Set canonical token IDs for the ReasoningParser base contract.
# Prefer special-token IDs; fall back to text-form IDs.
self.think_start_token_id: int = (
self.think_start_special_id if self.think_start_special_id != -1 else self.think_start_text_id
)
self.think_end_token_id: int = (
self.think_end_special_id if self.think_end_special_id != -1 else self.think_end_text_id
)
# Whether single-token ID matching is possible for start/end.
self._has_start_token_id = self.think_start_token_id != -1
self._has_end_token_id = self.think_end_token_id != -1
# For text-based matching we must recognise BOTH forms.
# self.think_start_token / self.think_end_token are used by
# some helper methods and for the text-based path.
self.think_start_token = self.THINK_START_TEXT
self.think_end_token = self.THINK_END_TEXT
# Tracks whether we have already emitted the end of reasoning
# in streaming mode.
self._reasoning_ended = False
# Tracks whether reasoning JUST ended (within the last call),
# so we can strip a leading newline from the next content delta.
self._just_ended = False
# Buffer for text that might be the start of a multi-token
# think marker (e.g. "</" could be the beginning of "</think>").
# We hold this text until the next delta disambiguates it.
self._pending = ""
# Complete think marker strings used for ambiguous-prefix detection.
# When streaming, the tail of delta_text might be the start of one
# of these markers (e.g. "</" could be the beginning of ATHINK_END_AT).
# We buffer such ambiguous text to avoid leaking partial markers.
self._all_markers = (
self.THINK_START_TEXT,
self.THINK_END_TEXT,
self.THINK_START_SPECIAL,
self.THINK_END_SPECIAL,
)
# ------------------------------------------------------------------
# Token-ID helpers
# ------------------------------------------------------------------
def _has_end_token_in_ids(self, token_ids: Sequence[int]) -> bool:
"""Check for end marker in token IDs (single-token forms only)."""
if self.think_end_special_id != -1 and self.think_end_special_id in token_ids:
return True
if self.think_end_text_id != -1 and self.think_end_text_id in token_ids:
return True
return False
def _has_start_token_in_ids(self, token_ids: Sequence[int]) -> bool:
"""Check for start marker in token IDs (single-token forms only)."""
if self.think_start_special_id != -1 and self.think_start_special_id in token_ids:
return True
if self.think_start_text_id != -1 and self.think_start_text_id in token_ids:
return True
return False
# ------------------------------------------------------------------
# Text-based helpers (handles both text-form and special-form markers)
# ------------------------------------------------------------------
def _has_end_token_in_text(self, text: str) -> bool:
return self.THINK_END_TEXT in text or self.THINK_END_SPECIAL in text
def _has_start_token_in_text(self, text: str) -> bool:
return self.THINK_START_TEXT in text or self.THINK_START_SPECIAL in text
def _find_end_token_in_text(self, text: str) -> tuple[int, int]:
"""Find the first end token in text. Returns (start, end) indices
such that text[start:end] is the end token. Returns (-1, -1) if
not found."""
idx = text.find(self.THINK_END_TEXT)
if idx != -1:
return idx, idx + len(self.THINK_END_TEXT)
idx = text.find(self.THINK_END_SPECIAL)
if idx != -1:
return idx, idx + len(self.THINK_END_SPECIAL)
return -1, -1
def _find_start_token_in_text(self, text: str) -> tuple[int, int]:
"""Find the first start token in text. Returns (start, end) indices
such that text[start:end] is the start token. Returns (-1, -1) if
not found."""
idx = text.find(self.THINK_START_TEXT)
if idx != -1:
return idx, idx + len(self.THINK_START_TEXT)
idx = text.find(self.THINK_START_SPECIAL)
if idx != -1:
return idx, idx + len(self.THINK_START_SPECIAL)
return -1, -1
def _decode_ids(self, token_ids: Sequence[int]) -> str:
"""Decode a sequence of token IDs to text using the model tokenizer."""
return self.model_tokenizer.decode(token_ids, skip_special_tokens=False)
# ------------------------------------------------------------------
# ReasoningParser interface
# ------------------------------------------------------------------
def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
"""Check if reasoning has ended in the given token sequence.
When called with **prompt** token IDs (by the serving layer), the
prompt may contain think markers from previous assistant turns.
In multi-turn conversations the prompt can include both start
and end markers, with the *last* marker being a start marker
(from the generation prompt). In that case reasoning is NOT
ended — the model is about to generate inside a new think block.
We therefore find the **last** think marker (start or end) in
the decoded text and only return True if it is an end marker.
"""
try:
text = self._decode_ids(input_ids)
except Exception:
# Fall back to token-ID-only check.
result = self._has_end_token_in_ids(input_ids)
logger.debug(
"StepAudio is_reasoning_end: decode failed, fallback result=%s, num_ids=%d",
result,
len(input_ids),
)
return result
# Find the position of the LAST think marker (start or end).
last_start = max(
text.rfind(self.THINK_START_TEXT),
text.rfind(self.THINK_START_SPECIAL),
)
last_end = max(
text.rfind(self.THINK_END_TEXT),
text.rfind(self.THINK_END_SPECIAL),
)
if last_end == -1:
# No end marker at all → reasoning not ended.
logger.debug(
"StepAudio is_reasoning_end: no end marker found, text_tail=%r (len=%d), returning False",
text[-100:] if len(text) > 100 else text,
len(text),
)
return False
if last_start > last_end:
# Last marker is a START marker (e.g. the generation prompt's
# ``). Reasoning is still active.
logger.debug(
"StepAudio is_reasoning_end: last_start=%d > last_end=%d, reasoning still active, returning False",
last_start,
last_end,
)
return False
# Last marker is an END marker → reasoning has ended.
logger.debug(
"StepAudio is_reasoning_end: last_end=%d >= last_start=%d, reasoning ended, returning True",
last_end,
last_start,
)
return True
def is_reasoning_end_streaming(self, input_ids: Sequence[int], delta_ids: Iterable[int]) -> bool:
delta_list = tuple(delta_ids)
# Fast check: single-token markers in delta.
if self._has_end_token_in_ids(delta_list):
return True
# Fallback: decode delta + tail of input_ids and check text.
# The end marker may span the boundary between previous tokens
# and new delta tokens when it is encoded as multiple tokens
# (e.g. "</think" = [522, 26865]).
try:
delta_text = self._decode_ids(delta_list)
if self._has_end_token_in_text(delta_text):
return True
overlap_ids = list(input_ids[-8:]) + list(delta_list)
overlap_text = self._decode_ids(overlap_ids)
return self._has_end_token_in_text(overlap_text)
except Exception:
return False
def extract_content_ids(self, input_ids: list[int]) -> list[int]:
# Fast check: single end-token in IDs.
if self._has_end_token_id:
# Check for special-token or text-form single token.
for end_id in (self.think_end_special_id, self.think_end_text_id):
if end_id != -1 and end_id in islice(input_ids, 0, max(0, len(input_ids) - 1)):
idx = input_ids.index(end_id)
return input_ids[idx + 1 :]
# Fallback: decode to text and find multi-token end marker.
try:
text = self._decode_ids(input_ids)
end_pos, end_len = self._find_end_token_in_text(text)
if end_pos == -1:
return []
content_start = end_len
if content_start < len(text) and text[content_start] == "\n":
content_start += 1
if content_start >= len(text):
return []
for i in range(len(input_ids)):
prefix_text = self._decode_ids(input_ids[: i + 1])
if len(prefix_text) > content_start:
return input_ids[i:]
return []
except Exception:
return []
def extract_reasoning(
self,
model_output: str,
request: "ChatCompletionRequest | ResponsesRequest",
) -> tuple[str | None, str | None]:
logger.debug(
"StepAudio extract_reasoning: model_output=%r (len=%d)",
model_output[:200],
len(model_output),
)
# Strip leading start token if present (text or special form).
for start_tok in (self.THINK_START_TEXT, self.THINK_START_SPECIAL):
if model_output.startswith(start_tok):
model_output = model_output[len(start_tok) :]
logger.debug("StepAudio extract_reasoning: stripped start_tok=%r", start_tok)
break
# Find the end token (text or special form).
end_pos, end_len = self._find_end_token_in_text(model_output)
if end_pos == -1:
# No end token found — everything is reasoning.
logger.debug(
"StepAudio extract_reasoning: no end token, reasoning=%r (len=%d), content=None",
model_output[:200],
len(model_output),
)
return model_output or None, None
reasoning = model_output[:end_pos]
content = model_output[end_len:]
# Strip leading newline that models often emit right after end token.
content = content.lstrip("\n")
logger.debug(
"StepAudio extract_reasoning: end_pos=%d, reasoning=%r (len=%d), content=%r (len=%d)",
end_pos,
reasoning[:200],
len(reasoning),
content[:200],
len(content),
)
return reasoning or None, content or None
def extract_reasoning_streaming(
self,
previous_text: str,
current_text: str,
delta_text: str,
previous_token_ids: Sequence[int],
current_token_ids: Sequence[int],
delta_token_ids: Sequence[int],
) -> DeltaMessage | None:
# Step-Audio typically uses multi-token text-form markers
# (e.g. "</think" → [522, 26865] + ">" → [29]).
# We always use the text-based path because it handles both
# single-token special markers and multi-token text markers.
# The text-based path is also efficient since previous_text
# and delta_text are already available.
return self._extract_streaming_text(
previous_text,
delta_text,
)
# ------------------------------------------------------------------
# Streaming: text-based (handles both text and special token forms)
# ------------------------------------------------------------------
def _ends_with_marker_prefix(self, text: str) -> str:
"""If text ends with a prefix of any think marker, return the
ambiguous suffix that should be buffered.
For example:
- text="reasoning</" → marker ATHINK_END_AT starts with "</" → return "</"
- text="reasoning<" → markers start with "<" → return "<"
- text="reasoning<th" → ATHINK_START starts with "<th" → return "<th"
(but "</think" also starts with "<", so we buffer from the longest)
- text="reasoning</thi" → ATHINK_END_AT starts with "</thi" → return "</thi"
"""
if not text:
return ""
# Find the longest suffix of text that is a prefix of any marker.
best = ""
for marker in self._all_markers:
for i in range(1, len(marker) + 1):
prefix = marker[:i]
if text.endswith(prefix) and len(prefix) > len(best):
best = prefix
return best
# Counter for debug logging (to avoid flooding logs)
_stream_call_count: int = 0
def _extract_streaming_text(
self,
previous_text: str,
delta_text: str,
) -> DeltaMessage | None:
# Reset _just_ended flag at the start of each call.
# It will be set to True if this call ends reasoning.
just_ended_before = self._just_ended
self._just_ended = False
# Debug logging for first few calls
StepAudioReasoningParser._stream_call_count += 1
call_num = StepAudioReasoningParser._stream_call_count
if call_num <= 20:
logger.debug(
"StepAudio stream #%d: prev_text=%r (len=%d), delta_text=%r (len=%d), _reasoning_ended=%s, _pending=%r",
call_num,
previous_text[:100],
len(previous_text),
delta_text[:100],
len(delta_text),
self._reasoning_ended,
self._pending[:50] if self._pending else "",
)
# Prepend any pending text from a previous call.
# The pending text is already part of previous_text (the framework
# accumulates all generated text), so we must also trim it from
# previous_text to avoid double-counting when we form `combined`.
if self._pending:
n = len(self._pending)
if previous_text.endswith(self._pending):
previous_text = previous_text[:-n]
delta_text = self._pending + delta_text
self._pending = ""
combined = previous_text + delta_text
has_end = self._has_end_token_in_text(combined)
has_end_in_previous = self._has_end_token_in_text(previous_text)
has_end_in_delta = self._has_end_token_in_text(delta_text)
if call_num <= 20:
logger.debug(
"StepAudio stream #%d: has_end=%s, has_end_in_previous=%s, has_end_in_delta=%s, combined=%r (len=%d)",
call_num,
has_end,
has_end_in_previous,
has_end_in_delta,
combined[:150],
len(combined),
)
if has_end_in_previous or self._reasoning_ended:
# Already past reasoning — everything is content.
self._reasoning_ended = True
# Strip at most one leading newline right after the end marker.
if delta_text.startswith("\n") and just_ended_before:
return DeltaMessage(content=delta_text[1:] or None)
return DeltaMessage(content=delta_text)
if has_end_in_delta:
# End token fully contained in the delta — split reasoning
# and content.
end_index, end_len = self._find_end_token_in_text(delta_text)
reasoning = delta_text[:end_index]
content = delta_text[end_len:]
# Strip leading newline after end token.
if content.startswith("\n"):
content = content[1:]
self._reasoning_ended = True
self._just_ended = True
return DeltaMessage(
reasoning=reasoning or None,
content=content or None,
)
if has_end and not has_end_in_previous and not has_end_in_delta:
# End token spans the boundary between previous_text and
# delta_text (e.g. previous ends with "</" and delta
# starts with "think>..."). Find the split point in the
# combined text.
end_index, end_len = self._find_end_token_in_text(combined)
marker_start_in_prev = end_index - len(previous_text)
if marker_start_in_prev > 0:
# Some of previous_text's trailing chars are part of
# the marker — they were already emitted as reasoning
# in a previous call. The delta begins the content
# after the marker.
content_after = combined[end_len:]
if content_after.startswith("\n"):
content_after = content_after[1:]
self._reasoning_ended = True
self._just_ended = True
return DeltaMessage(content=content_after or None)
else:
# Edge case: the entire marker is in previous_text
# but was not detected because of how text was split
# across streaming chunks.
self._reasoning_ended = True
self._just_ended = True
full_content = combined[end_len:]
if full_content.startswith("\n"):
full_content = full_content[1:]
return DeltaMessage(content=full_content or None)
# No end token seen yet — everything is reasoning.
# Skip the start token prefix if present in delta.
start_idx, start_len = self._find_start_token_in_text(delta_text)
if start_idx != -1 and delta_text.strip() == delta_text[start_idx:start_len]:
# Delta is only the start token — skip it entirely.
return None
if start_idx == 0:
# Delta starts with the start token — skip past it.
after_start = delta_text[start_len:]
return DeltaMessage(reasoning=after_start or None)
# Check if the delta ends with a partial marker prefix (e.g. "</"
# which could be the beginning of the end marker). Buffer the
# ambiguous text to avoid leaking partial markers into reasoning.
ambiguous_suffix = self._ends_with_marker_prefix(delta_text)
if ambiguous_suffix:
if ambiguous_suffix == delta_text:
# Entire delta is a possible marker prefix — buffer it all.
self._pending = delta_text
return None
else:
# Emit the non-ambiguous part as reasoning, buffer the rest.
to_emit = delta_text[: -len(ambiguous_suffix)]
self._pending = ambiguous_suffix
return DeltaMessage(reasoning=to_emit) if to_emit else None
return DeltaMessage(reasoning=delta_text)
def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
"""Count tokens within thinking spans."""
# Fast path: if single-token markers are present, count using IDs.
if self._has_start_token_id and self._has_end_token_id:
count = 0
depth = 0
for token_id in token_ids:
if token_id == self.think_start_token_id:
depth += 1
continue
if token_id == self.think_end_token_id:
if depth > 0:
depth -= 1
continue
if depth > 0:
count += 1
# Only trust the fast-path result if we actually found markers.
if depth == 0 and count > 0:
return count
# Text-based fallback: handles both text-form multi-token markers
# and special-token single-token markers in decoded text.
try:
text = self._decode_ids(token_ids)
if not text:
return 0
n_tokens = len(token_ids)
if n_tokens == 0:
return 0
total_chars = len(text)
if total_chars == 0:
return 0
chars_per_token = total_chars / n_tokens
count = 0
depth = 0
i = 0
while i < len(text):
found_start = False
for start_tok in (self.THINK_START_TEXT, self.THINK_START_SPECIAL):
if text[i:].startswith(start_tok):
depth += 1
i += len(start_tok)
found_start = True
break
if found_start:
continue
found_end = False
for end_tok in (self.THINK_END_TEXT, self.THINK_END_SPECIAL):
if text[i:].startswith(end_tok):
if depth > 0:
depth -= 1
i += len(end_tok)
found_end = True
break
if found_end:
continue
if depth > 0:
count += 1
i += 1
# Scale per-character count to approximate token count.
if chars_per_token > 1:
count = max(1, round(count / chars_per_token))
return count
except Exception:
return 0
+2 -1
View File
@@ -163,6 +163,8 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
return output_token_ids
def _sampling_metadata_for_model_sampler(self, sampling_metadata):
if getattr(self.model, "skips_model_sampler_output_token_history", False):
return sampling_metadata
output_token_ids = self._build_model_sampler_output_token_ids()
if output_token_ids == sampling_metadata.output_token_ids:
return sampling_metadata
@@ -568,7 +570,6 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
use_cascade_attn=cascade_attn_prefix_lens is not None,
num_encoder_reqs=len(scheduler_output.scheduled_encoder_inputs),
)
num_tokens_padded = batch_desc.num_tokens
num_reqs_padded = batch_desc.num_reqs if batch_desc.num_reqs is not None else num_reqs
ubatch_slices, ubatch_slices_padded = maybe_create_ubatch_slices(
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more