Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81de3f3308 | ||
|
|
f9c9de8d16 | ||
|
|
ff2e33a329 | ||
|
|
0b5b0979d8 | ||
|
|
f04ff76c54 | ||
|
|
b220df8397 | ||
|
|
520e058b95 | ||
|
|
0288c3f56e | ||
|
|
477083a103 | ||
|
|
e1b2f589c1 | ||
|
|
44c8d1c449 | ||
|
|
2298359f67 | ||
|
|
d1c8beeb18 | ||
|
|
e2b83db683 | ||
|
|
49c6fc1540 | ||
|
|
a65e89cbd4 | ||
|
|
7992c2213e | ||
|
|
8e26dce1f4 | ||
|
|
eb11446b7f | ||
|
|
705e781b0e | ||
|
|
b81aeb7b86 | ||
|
|
3d1ad0c0f4 | ||
|
|
fe4a2a7477 | ||
|
|
6f16f9546d | ||
|
|
58cb8de68b | ||
|
|
b864374089 | ||
|
|
2052deef07 | ||
|
|
86b85c078b | ||
|
|
5e06d2e73c | ||
|
|
f321cdbe16 | ||
|
|
4cca28e9bd | ||
|
|
ded8934626 | ||
|
|
759aa4ffeb | ||
|
|
1a9d598d2b | ||
|
|
8fe913e461 | ||
|
|
71748388f8 | ||
|
|
f7f48929db | ||
|
|
18f01befa7 | ||
|
|
1d75e63f7f |
No files matched your search
@@ -72,7 +72,22 @@ steps:
|
||||
- export VLLM_LOGGING_LEVEL=DEBUG
|
||||
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
- pytest -s -v tests/diffusion/offloader/test_diffusion_cpu_offload.py
|
||||
- pytest -s -v tests/diffusion/offloader/test_diffusion_layerwise_offload.py
|
||||
- >-
|
||||
pytest -s -v tests/diffusion/offloader/test_diffusion_layerwise_offload.py
|
||||
--deselect='tests/diffusion/offloader/test_diffusion_layerwise_offload.py::test_layerwise_offload_diffusion_model[stabilityai/stable-audio-open-1.0]'
|
||||
|
||||
# TODO: Diagnose unstable Stable Audio layerwise-offload memory savings on ROCm.
|
||||
- label: "Diffusion Stable Audio Layerwise Offload Test (quarantined)"
|
||||
agent_pool: mi300_1
|
||||
depends_on: amd-build
|
||||
mirror_hardwares: [amdproduction]
|
||||
grade: NonBlocking
|
||||
commands:
|
||||
- export VLLM_LOGGING_LEVEL=DEBUG
|
||||
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
- >-
|
||||
pytest -s -v
|
||||
'tests/diffusion/offloader/test_diffusion_layerwise_offload.py::test_layerwise_offload_diffusion_model[stabilityai/stable-audio-open-1.0]'
|
||||
|
||||
- label: "Diffusion Sequence Parallelism Test (Need 4 GPUs) · Shard 1/2"
|
||||
agent_pool: mi300_4
|
||||
@@ -223,13 +238,10 @@ steps:
|
||||
grade: Blocking
|
||||
timeout_in_minutes: 50
|
||||
commands:
|
||||
- |
|
||||
timeout 40m bash -c '
|
||||
export VLLM_LOGGING_LEVEL=DEBUG
|
||||
export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
|
||||
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py tests/e2e/offline_inference/test_qwen3_tts_base.py -m 'advanced_model and cuda' --run-level 'advanced_model'
|
||||
'
|
||||
- export VLLM_LOGGING_LEVEL=DEBUG
|
||||
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
- export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
|
||||
- timeout 40m pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py tests/e2e/offline_inference/test_qwen3_tts_base.py -m "advanced_model and cuda" --run-level "advanced_model"
|
||||
|
||||
# TODO: Bagel test on ROCm is very unstable. @tjtanaa
|
||||
# Need to debug before reneable numerical changes across large PRs
|
||||
|
||||
@@ -85,6 +85,9 @@ steps:
|
||||
- vllm_omni/model_executor/models/common/alias_free_activation.py
|
||||
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
|
||||
- vllm_omni/deploy/qwen3_tts.yaml
|
||||
- vllm_omni/entrypoints/openai/serving_speech.py
|
||||
- vllm_omni/entrypoints/openai/tts_adapters/
|
||||
- vllm_omni/engine/stage_pool.py
|
||||
commands:
|
||||
- |
|
||||
timeout 40m bash -c "
|
||||
@@ -104,6 +107,9 @@ steps:
|
||||
- vllm_omni/model_executor/models/common/alias_free_activation.py
|
||||
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
|
||||
- vllm_omni/deploy/qwen3_tts.yaml
|
||||
- vllm_omni/entrypoints/openai/serving_speech.py
|
||||
- vllm_omni/entrypoints/openai/tts_adapters/
|
||||
- vllm_omni/engine/stage_pool.py
|
||||
- .buildkite/common/scripts/run_cov_split.sh
|
||||
- pyproject.toml
|
||||
# 40 -> 60: the coverage split runs the model twice.
|
||||
@@ -231,3 +237,38 @@ steps:
|
||||
pytest -s -v tests/e2e/online_serving/test_hunyuan_video_15_expansion.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
|
||||
"
|
||||
mirror_hardwares: h100_1
|
||||
|
||||
- label: "Diffusion · MiniMax-H3 L3 DLO DP2 and FastH3"
|
||||
source_file_dependencies:
|
||||
- tests/e2e/online_serving/minimax_h3/
|
||||
- tests/helpers/assertions.py
|
||||
- tests/helpers/mark.py
|
||||
- tests/helpers/media.py
|
||||
- tests/helpers/runtime.py
|
||||
- vllm_omni/diffusion/models/minimax_h3/
|
||||
- vllm_omni/diffusion/lora/
|
||||
- vllm_omni/diffusion/offloader/
|
||||
- vllm_omni/diffusion/distributed/
|
||||
- vllm_omni/entrypoints/openai/
|
||||
# Keep four server lifecycles in one Buildkite job, but never in one
|
||||
# pytest process. DLO uses multiprocessing shared-memory queues; the
|
||||
# parent's resource_tracker can retain a stale /psm_* entry during
|
||||
# teardown and strand the next Ref2VA collective.
|
||||
# A per-file process plus a hard timeout also makes a regression fail
|
||||
# instead of leaving the merge queue stuck indefinitely.
|
||||
timeout_in_minutes: 150
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
export VLLM_IMAGE_FETCH_TIMEOUT=60
|
||||
if [ -f vllm_omni/diffusion/models/minimax_h3/lora.py ]; then
|
||||
export VLLM_TEST_MINIMAX_H3_TURBO_LORA=$(python3 -c 'from huggingface_hub import hf_hub_download; print(hf_hub_download(repo_id="lightx2v/Minimax-h3-Turbo", filename="minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors"))')
|
||||
fi
|
||||
if [ -f vllm_omni/diffusion/models/minimax_h3/fasth3.py ]; then
|
||||
export VLLM_TEST_MINIMAX_H3_FASTH3_LORA=$(python3 -c 'from huggingface_hub import hf_hub_download; print(hf_hub_download(repo_id="FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA", filename="dense-datafree/adapter_model.safetensors"))')
|
||||
fi
|
||||
timeout 35m pytest -s -v tests/e2e/online_serving/minimax_h3/test_minimax_h3_dlo_dp2_fl2va.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
|
||||
timeout 35m pytest -s -v tests/e2e/online_serving/minimax_h3/test_minimax_h3_dlo_dp2_ref2va.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
|
||||
timeout 35m pytest -s -v tests/e2e/online_serving/minimax_h3/test_minimax_h3_turbo_lora.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
|
||||
timeout 35m pytest -s -v tests/e2e/online_serving/minimax_h3/test_minimax_h3_fasth3_lora.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
|
||||
mirror_hardwares: h100_2
|
||||
@@ -252,6 +252,9 @@ steps:
|
||||
- vllm_omni/model_executor/models/common/alias_free_activation.py
|
||||
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
|
||||
- vllm_omni/deploy/qwen3_tts.yaml
|
||||
- vllm_omni/entrypoints/openai/serving_speech.py
|
||||
- vllm_omni/entrypoints/openai/tts_adapters/
|
||||
- vllm_omni/engine/stage_pool.py
|
||||
commands:
|
||||
- |
|
||||
timeout 20m bash -c "
|
||||
@@ -260,25 +263,6 @@ steps:
|
||||
"
|
||||
mirror_hardwares: l4_1
|
||||
|
||||
- label: "TTS · Qwen3-TTS Base Test"
|
||||
source_file_dependencies:
|
||||
- tests/e2e/online_serving/test_qwen3_tts_base.py
|
||||
- vllm_omni/model_executor/models/qwen3_tts/
|
||||
- vllm_omni/model_executor/models/common/qwen3_code_predictor.py
|
||||
- vllm_omni/model_executor/models/common/snake_activation.py
|
||||
- vllm_omni/model_executor/models/common/alias_free_activation.py
|
||||
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
|
||||
- vllm_omni/deploy/qwen3_tts.yaml
|
||||
commands:
|
||||
- |
|
||||
timeout 20m bash -c "
|
||||
export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1
|
||||
export VLLM_OMNI_USE_V2_RUNNER=1
|
||||
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py -m 'core_model' --run-level 'core_model'
|
||||
"
|
||||
mirror_hardwares: l4_1
|
||||
|
||||
- label: "Diffusion · Wan22 Test"
|
||||
source_file_dependencies:
|
||||
- tests/e2e/online_serving/test_wan22_t2v.py
|
||||
|
||||
@@ -16,6 +16,7 @@ Easy, fast, and cheap omni-modality model serving for everyone
|
||||
|
||||
*Latest News* 🔥
|
||||
|
||||
- [2026/08] We released [0.28.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.28.0), featuring production-ready [MiniMax H3](recipes/MiniMaxAI/MiniMax-H3.md) serving on GPU and NPU, a unified AR/DiT paged KV cache runtime, and enhanced realtime full-duplex serving for the [MiniCPM-o series](recipes/OpenBMB/MiniCPM-o-4_5.md).
|
||||
- [2026/08] [VeRL-Omni](https://github.com/verl-project/verl-omni) `v0.2.0` is released: faster diffusion RL powered by vLLM-Omni (request-level/step-wise batching with FA3), rebuilt Qwen3-Omni multimodal training (DPO & GSPO), plus LTX-2.3, Qwen-Image-Edit support and more. See the [release notes](https://github.com/verl-project/verl-omni/releases/tag/v0.2.0).
|
||||
- [2026/08] We released [0.26.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.26.0) - aligned with the vLLM 0.26 release line, featuring [MiniMax H3](recipes/MiniMaxAI/MiniMax-H3.md) joint video/audio generation, an experimental full-duplex realtime runtime for [MiniCPM-o 4.5](recipes/OpenBMB/MiniCPM-o-4_5.md), distributed layerwise diffusion offload, and broader model, hardware, streaming, TTS, and quantization support.
|
||||
- [2026/07] We released [0.24.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.24.0) - aligned with the vLLM 0.24 release line, expanding production-ready coverage across TTS, speech, diffusion, image/video generation, and robot-policy serving, with major Omni stage runtime refactoring, diffusion request-level batching, async output materialization, quantization/cache/memory improvements, and broad CUDA/ROCm/XPU/NPU support.
|
||||
@@ -57,9 +58,9 @@ vLLM-Omni is flexible and easy to use with:
|
||||
vLLM-Omni seamlessly supports most popular open-source models on HuggingFace, including:
|
||||
|
||||
- **Omni-modality models** (e.g. Qwen3-Omni, MiniCPM-o 4.5, Cosmos3, HunyuanImage, BAGEL)
|
||||
- **TTS models** (e.g. Qwen3-TTS, VoxCPM2, Ming-Omni-TTS, CosyVoice3)
|
||||
- **Diffusion models** — image, video, and audio generation (e.g. MiniMax H3, Qwen-Image, Wan2.2, FLUX)
|
||||
- **Robot-policy and action models** (e.g. GR00T-N1.7, DreamZero-DROID, InternVLA-A1, Cosmos3 action policy)
|
||||
- **TTS models** (e.g. Qwen3-TTS, IndexTTS 2.5, dots.tts, CosyVoice3)
|
||||
- **Diffusion models** — image, video, and audio generation (e.g. MiniMax H3, LTX-2.5, SANA-Video, Wan2.2)
|
||||
- **Robot-policy and action models** (e.g. π0, GR00T-N1.7, DreamZero-DROID, InternVLA-A1)
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -67,6 +68,7 @@ Visit our [documentation](https://vllm-omni.readthedocs.io/en/latest/) to learn
|
||||
|
||||
- [Installation](https://vllm-omni.readthedocs.io/en/latest/getting_started/installation/)
|
||||
- [Quickstart](https://vllm-omni.readthedocs.io/en/latest/getting_started/quickstart/)
|
||||
- [Nightly CUDA Docker images](https://hub.docker.com/r/vllm/vllm-omni/tags?name=nightly) built from the latest `main`
|
||||
- [List of Supported Models](https://vllm-omni.readthedocs.io/en/latest/models/supported_models/)
|
||||
- [Deployment Recipes](https://recipes.vllm.ai) for vLLM-Omni model serving
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ RUN python3 -m pip install -v --no-deps \
|
||||
"stepaudio2-minicpmo>=0.1.1" \
|
||||
"step-audio2>=1.0.0"
|
||||
|
||||
ENV VLLM_TARGET_DEVICE=npu
|
||||
ENV VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
|
||||
ENTRYPOINT []
|
||||
@@ -30,6 +30,7 @@ RUN python3 -m pip install -v --no-deps \
|
||||
"stepaudio2-minicpmo>=0.1.1" \
|
||||
"step-audio2>=1.0.0"
|
||||
|
||||
ENV VLLM_TARGET_DEVICE=npu
|
||||
ENV VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
|
||||
ENTRYPOINT []
|
||||
@@ -65,6 +65,7 @@ RUN python3 -m pip install -v --no-deps \
|
||||
--trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \
|
||||
--default-timeout=60
|
||||
|
||||
ENV VLLM_TARGET_DEVICE=npu
|
||||
ENV VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
|
||||
ENTRYPOINT []
|
||||
@@ -66,6 +66,7 @@ RUN python3 -m pip install -v --no-deps \
|
||||
--trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \
|
||||
--default-timeout=60
|
||||
|
||||
ENV VLLM_TARGET_DEVICE=npu
|
||||
ENV VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
|
||||
ENTRYPOINT []
|
||||
+19
-9
@@ -5,14 +5,22 @@ nav:
|
||||
- getting_started/quickstart.md
|
||||
- getting_started/installation/*
|
||||
- Serving:
|
||||
- OpenAI-Compatible API:
|
||||
- Chat Completions API: serving/chat_completions_api.md
|
||||
- Image Generation: serving/image_generation_api.md
|
||||
- Image Edit: serving/image_edit_api.md
|
||||
- Text to Speech: serving/speech_api.md
|
||||
- Videos: serving/videos_api.md
|
||||
- Audio Generation: serving/audio_generate_api.md
|
||||
- Streaming Video Input: serving/video_stream_api.md
|
||||
- API Server:
|
||||
- Overview: serving/README.md
|
||||
- Core APIs:
|
||||
- Chat Completions: serving/chat_completions_api.md
|
||||
- Text to Speech: serving/speech_api.md
|
||||
- Audio Generation: serving/audio_generate_api.md
|
||||
- Image Generation: serving/image_generation_api.md
|
||||
- Image Edit: serving/image_edit_api.md
|
||||
- Videos: serving/videos_api.md
|
||||
- Streaming and Realtime:
|
||||
- Realtime Audio: serving/realtime_api.md
|
||||
- Full Duplex: serving/full_duplex_api.md
|
||||
- Streaming Video Input: serving/video_stream_api.md
|
||||
- Streaming Video Output: serving/streaming_video_output_api.md
|
||||
- OpenPI Robot Policy: serving/openpi_api.md
|
||||
- Standalone Experimental Servers: serving/standalone_servers.md
|
||||
- Examples:
|
||||
- examples/README.md
|
||||
- Offline Inference:
|
||||
@@ -29,7 +37,7 @@ nav:
|
||||
- Image-To-Video: user_guide/examples/online_serving/image_to_video.md
|
||||
- Speech-To-Video: user_guide/examples/online_serving/speech_to_video.md
|
||||
- Text-To-Image: user_guide/examples/online_serving/text_to_image.md
|
||||
- Text-To-Speech: user_guide/examples/online_serving/text_to_speech.md
|
||||
- Text-To-Speech (Online Serving): user_guide/examples/online_serving/text_to_speech.md
|
||||
- Text-To-Video: user_guide/examples/online_serving/text_to_video.md
|
||||
- General:
|
||||
- usage/*
|
||||
@@ -119,6 +127,8 @@ nav:
|
||||
- Async Omni Output Materialization: design/feature/omni_async_output_materialization.md
|
||||
- Automatic Prefix Caching: design/feature/prefix_caching.md
|
||||
- Realtime AR-Diffusion: design/feature/realtime_ar_diffusion.md
|
||||
- Model-Specific Capabilities:
|
||||
- MiniMax H3 Temporal VAE Chunks: design/feature/minimax_h3_temporal_vae_chunks.md
|
||||
- Communication:
|
||||
- OmniConnector Implementations:
|
||||
- Mooncake Store Connector: design/feature/omni_connectors/mooncake_store_connector.md
|
||||
|
||||
+3
-3
@@ -57,9 +57,9 @@ vLLM-Omni is flexible and easy to use with:
|
||||
vLLM-Omni seamlessly supports most popular open-source models on HuggingFace, including:
|
||||
|
||||
- Omni-modality models (e.g. Qwen3-Omni, MiniCPM-o 4.5, Cosmos3, HunyuanImage, BAGEL)
|
||||
- TTS models (e.g. Qwen3-TTS, VoxCPM2, Ming-Omni-TTS, CosyVoice3)
|
||||
- Diffusion models — image, video, and audio generation (e.g. MiniMax H3, Qwen-Image, Wan2.2, FLUX)
|
||||
- Robot-policy and action models (e.g. GR00T-N1.7, DreamZero-DROID, InternVLA-A1, Cosmos3 action policy)
|
||||
- TTS models (e.g. Qwen3-TTS, IndexTTS 2.5, dots.tts, CosyVoice3)
|
||||
- Diffusion models — image, video, and audio generation (e.g. MiniMax H3, LTX-2.5, SANA-Video, Wan2.2)
|
||||
- Robot-policy and action models (e.g. π0, GR00T-N1.7, DreamZero-DROID, InternVLA-A1)
|
||||
|
||||
For more information, checkout the following:
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 274 KiB |
@@ -2,7 +2,7 @@
|
||||
|
||||
This section lists the most common options for running vLLM-Omni.
|
||||
|
||||
For options within a vLLM Engine, please refer to the [vLLM 0.26 configuration guide](https://docs.vllm.ai/en/v0.26.0/configuration/index.html).
|
||||
For options within a vLLM Engine, please refer to the [vLLM 0.28 configuration guide](https://docs.vllm.ai/en/v0.28.0/configuration/index.html).
|
||||
|
||||
Each model defines fixed topology in a registered `PipelineConfig` and runtime
|
||||
overrides in a deploy YAML.
|
||||
|
||||
@@ -151,7 +151,7 @@ their keys only.
|
||||
## Inherited vLLM variables
|
||||
|
||||
vLLM-Omni also reads variables through its aligned vLLM dependency. Refer to
|
||||
the [vLLM 0.26 environment-variable reference](https://docs.vllm.ai/en/v0.26.0/configuration/env_vars.html)
|
||||
the [vLLM 0.28 environment-variable reference](https://docs.vllm.ai/en/v0.28.0/configuration/env_vars.html)
|
||||
for their definitions. This includes vLLM launch, cache, logging, plugin, ROCm,
|
||||
XPU, ModelScope, and FlashInfer workspace settings.
|
||||
|
||||
|
||||
@@ -170,6 +170,10 @@ because the user-visible output is diffusion media rather than a token stream.
|
||||
The
|
||||
[MiniMax-H3 recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/MiniMaxAI/MiniMax-H3.md)
|
||||
contains the hardware-specific profiles and warmup requirements.
|
||||
The internal, opt-in
|
||||
[temporal VAE chunk callback](feature/minimax_h3_temporal_vae_chunks.md)
|
||||
documents the narrower released-source contract for publishing committed video
|
||||
frames during decode. It does not change the complete-output serving topology.
|
||||
|
||||
### Cosmos3: unified MoT reasoner and generator
|
||||
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
# MiniMax H3 temporal VAE chunk callback
|
||||
|
||||
!!! warning "Experimental internal capability"
|
||||
|
||||
This feature is an opt-in model integration seam. The current MiniMax H3
|
||||
pipeline still uses complete-output VAE decode, and no CLI option, serving
|
||||
path, transport, or MP4 sink enables temporal chunks by default.
|
||||
|
||||
## Status and scope
|
||||
|
||||
MiniMax H3 decodes video latents in temporal windows, but the released VAE
|
||||
normally exposes only the complete reconstructed video. The temporal callback
|
||||
publishes frames after the released decoder has completed overlap blending and
|
||||
padding removal. This gives a future runtime consumer a stable point at which
|
||||
it can schedule representation conversion, D2H transfer, and encoding while
|
||||
the VAE continues decoding later windows.
|
||||
|
||||
The implementation remains model-local:
|
||||
|
||||
- `vae.py` owns VAE topology and the thin
|
||||
`decode_latent_with_chunks()` facade;
|
||||
- `chunked_decode.py` owns the normalized callback contract, metadata
|
||||
validation, and fixed VAE-group rendezvous points; and
|
||||
- `temporal_chunks.py` owns the source-gated compatibility planner and the
|
||||
released temporal assembly control flow.
|
||||
|
||||
Crop policy, typed media, transport, backpressure, encoding, and public output
|
||||
remain runtime responsibilities. The model code does not schedule requests or
|
||||
select a presentation format.
|
||||
|
||||
## Execution flow
|
||||
|
||||
The default and opt-in paths are intentionally separate:
|
||||
|
||||
```text
|
||||
Default:
|
||||
latent -> MiniMaxH3VideoVAE.decode_latent() -> complete RGB video
|
||||
|
||||
Opt-in producer seam:
|
||||
latent
|
||||
-> MiniMaxH3VideoVAE.decode_latent_with_chunks()
|
||||
-> group-local preflight and callback-owner validation
|
||||
-> released temporal decode / overlap blending / padding removal
|
||||
-> borrowed decoder-domain chunk
|
||||
-> normalize + clone into owned RGB float32 BCTHW [0, 1]
|
||||
-> synchronous callback
|
||||
-> complete RGB video is still returned
|
||||
```
|
||||
|
||||
Calling `decode_latent_with_chunks()` is the opt-in. Supplying no callback on a
|
||||
VAE peer does not select the default decoder; every rank in a VAE patch group
|
||||
must enter the chunked method, and only the output rank receives the callback.
|
||||
|
||||
## Producer contract
|
||||
|
||||
The normalized callback has this structural signature:
|
||||
|
||||
```python
|
||||
def callback(
|
||||
frames: torch.Tensor,
|
||||
/,
|
||||
*,
|
||||
chunk_index: int,
|
||||
total_chunks: int,
|
||||
frame_start: int,
|
||||
is_final: bool,
|
||||
) -> None:
|
||||
...
|
||||
```
|
||||
|
||||
For a successful decode:
|
||||
|
||||
- `frames` is owned, contiguous RGB float32 in `BCTHW` layout and `[0, 1]`;
|
||||
- callback storage does not alias the returned complete video;
|
||||
- the consumer may retain or mutate a chunk;
|
||||
- `chunk_index` is zero-based, contiguous, and strictly increasing;
|
||||
- `total_chunks` is positive and stable for the decode;
|
||||
- `frame_start` equals the end of the previous published range;
|
||||
- chunks are nonempty and contain only temporally committed frames;
|
||||
- exactly one chunk has `is_final=True`; and
|
||||
- concatenating chunks along time exactly reconstructs the returned video.
|
||||
|
||||
The callback runs synchronously on the producer's current device stream. A
|
||||
consumer that reads the tensor on another stream must establish ordering, for
|
||||
example with an event, and retain the allocation for that stream's lifetime.
|
||||
A slow callback also blocks VAE production. An overlap-oriented runtime
|
||||
consumer should therefore acquire bounded capacity, enqueue side-stream work,
|
||||
and return promptly instead of encoding frames inside the callback.
|
||||
|
||||
Chunks retain the decoded VAE canvas dimensions. A future runtime integration
|
||||
must apply the same requested-height and requested-width crop as the complete
|
||||
MiniMax H3 pipeline before declaring the media transport-ready.
|
||||
|
||||
The raw compatibility callback is deliberately narrower. It receives borrowed
|
||||
decoder-domain tensors and must not retain or mutate them. The coordinator is
|
||||
the ownership boundary that normalizes and clones raw chunks before invoking
|
||||
the public callback.
|
||||
|
||||
## Temporal planning and terminal publication
|
||||
|
||||
The released configuration advances five latent tokens per temporal window and
|
||||
includes a two-token overlap, so each `_adaptive_decode` input normally contains
|
||||
seven tokens. After temporal scaling, pre-padding removal, and five-frame
|
||||
overlap blending, the normal committed unit is 17 output frames. The final
|
||||
remainder depends on the requested frame plan.
|
||||
|
||||
Representative released frame plans include:
|
||||
|
||||
| Output frames | Published chunks |
|
||||
| ---: | --- |
|
||||
| 124 | `7 x 17 + 5` |
|
||||
| 209 | `12 x 17 + 5` |
|
||||
| 243 | `14 x 17 + 5` |
|
||||
| 362 | `21 x 17 + 5` |
|
||||
|
||||
The terminal callback is held until the complete frame plan passes its logical
|
||||
frame, padding, written-frame, chunk-count, and finality checks. Earlier chunks
|
||||
are semantically stable, but they do not imply that the overall request has
|
||||
succeeded; a consumer must discard or abort partial output if a later decoder
|
||||
failure terminates the request.
|
||||
|
||||
## Source and configuration gate
|
||||
|
||||
The released VAE has no public temporal-callback API. vLLM-Omni therefore owns
|
||||
a compatibility implementation for one exact released code contract. The
|
||||
reference revision identifies its provenance, but runtime acceptance is based
|
||||
on source content, configuration, and structure rather than repository or
|
||||
weight metadata:
|
||||
|
||||
- reference Hugging Face revision, not independently checked at runtime:
|
||||
`42ed227ee7df40d41602854ae760620d6eb651fe`;
|
||||
- sorted 15-file top-level `video_vae/*.py` manifest (excluding
|
||||
`__init__.py`) SHA-256:
|
||||
`30e64afbaa940696bf8bfe2c86003a4b1666c84b22fa3aa12403a3e6a03f705d`;
|
||||
- clip length `17`;
|
||||
- token chunk, overlap, and drop `5`, `2`, and `3`;
|
||||
- temporal ratio `4`;
|
||||
- frame pre-padding and overlap `3` and `5`; and
|
||||
- no isolated first or last frame.
|
||||
|
||||
The adapter does not verify Hugging Face revision metadata, repository identity,
|
||||
or model weights. The manifest pin detects Python compatibility drift; it is
|
||||
not a security boundary for remote-code execution. The source manifest, listed
|
||||
temporal attributes, structural contract, plan, and dtype are validated before
|
||||
the first `_adaptive_decode` call. A mismatch disables only the opt-in chunk path.
|
||||
`decode_latent()` remains available and performs no source fingerprint or chunk
|
||||
rendezvous.
|
||||
|
||||
## Supported models
|
||||
|
||||
Support in this table means the internal producer contract above, not public
|
||||
serving or end-to-end transport support.
|
||||
|
||||
| Model or component | Source contract | Status | Validation |
|
||||
| --- | --- | --- | --- |
|
||||
| MiniMax H3 FL2VA video VAE | Released manifest/listed temporal attributes above (reference `42ed227e`) | Supported, opt-in | Same-environment full-output parity and L20X/B300 feasibility measured on the implementation through PR commit `60e9bbad` |
|
||||
| MiniMax H3 Ref2VA video VAE | Same released manifest/listed temporal attributes | Source-compatible; not separately benchmarked | Ref2VA and FL2VA use byte-identical video-VAE source and configuration in the reference snapshot |
|
||||
| MiniMax H3 with a different Python manifest or listed temporal attribute | Different enforced contract | Unsupported for chunk mode | Fails closed before decoder collectives; complete decode remains available |
|
||||
| Same manifest/temporal attributes with different weights or other component metadata | Not checked by this gate | Not independently qualified | The adapter does not inspect weights, repository identity, revision metadata, or arbitrary component configuration |
|
||||
| Other video models | Any | Unsupported | No generic temporal VAE producer ABI is declared by this feature |
|
||||
|
||||
## Feature compatibility
|
||||
|
||||
| Dimension or combination | Status | Constraint |
|
||||
| --- | --- | --- |
|
||||
| Existing complete-output decode | Supported and default | No source check, callback object, or callback rendezvous |
|
||||
| Explicit chunk decode | Supported internally | Caller invokes `decode_latent_with_chunks()` directly |
|
||||
| VAE patch parallel size 1 | Supported by the model seam | Caller supplies the callback only on the request output owner; no WORLD rendezvous is added |
|
||||
| VAE patch parallel size equal to the full DiT group | Supported when data parallel size is 1 | Every VAE-group rank calls; exactly group rank 0 owns the callback |
|
||||
| Partial VAE patch subgroup | Unsupported | MiniMax H3 currently accepts size 1 or the full DiT group only |
|
||||
| Data parallel size greater than 1 with VAE patch parallel size 1 | Model seam only | Per-replica callback ownership is possible, but no serving/runtime consumer is wired |
|
||||
| Data parallel size greater than 1 with VAE patch parallel size greater than 1 | Unsupported | Fails before decoder collectives because no replica-local VAE patch group is defined |
|
||||
| Too few spatial tiles for the VAE group | Correctness fallback | Decodes rank-locally and restores native tiling state; slower than patch parallel decode |
|
||||
| Device memory and full-output assembly | Not reduced or bounded | The compatibility path still assembles and returns the full decode while allocating owned callback chunks; no peak-HBM reduction is claimed |
|
||||
| DiT cache, attention, TP, SP, HSDP, LoRA, or quantization features | No direct callback dependency | This seam runs after denoising and does not independently qualify combinations beyond the MiniMax H3 model's own matrix |
|
||||
| Layerwise or distributed component offload | Not separately validated | Component residency must place the video VAE before decode |
|
||||
| Step execution or request batching | Not integrated | No pipeline caller binds a runtime chunk consumer |
|
||||
| Requested-size crop and device uint8 preparation | Not implemented here | Runtime-owned follow-up |
|
||||
| Pinned D2H, SHM/ZMQ, or cross-process transport | Not implemented here | Runtime-owned follow-up |
|
||||
| Complete MP4 or fragmented MP4 sink | Not implemented here | Existing serving behavior still waits for complete output |
|
||||
| Audio VAE chunks and incremental A/V composition | Not supported | Video chunks do not imply audio or request finality |
|
||||
| Cancellation and multi-output lease ownership | Not implemented here | Requires a bounded runtime lifecycle |
|
||||
| NVIDIA CUDA | Feasibility evidence | L20X and B300 measurements apply to the implementation through `60e9bbad`; the current coordination refactor was not reprofiled |
|
||||
| AMD ROCm | Not validated for this feature | Base MiniMax H3 support is documented separately and does not imply temporal-chunk validation |
|
||||
| Ascend, Intel, or other accelerators | No feature support claim | Consult the model support table independently; this design adds no platform qualification |
|
||||
|
||||
## Distributed and failure semantics
|
||||
|
||||
When VAE patch parallelism is active, all VAE-group ranks run the same
|
||||
preflight before entering checkpoint collectives. Source/planner errors and the
|
||||
resolved temporal concat dtype are reconciled at fixed group rendezvous points.
|
||||
|
||||
Callback ownership is also a group decision. Rank `r` contributes `r + 1` when
|
||||
it owns a callback and zero otherwise. A group SUM equals one only when rank 0
|
||||
is the sole owner; every invalid owner configuration therefore fails
|
||||
identically on all ranks before decode.
|
||||
|
||||
Callback exceptions are retained on the output rank. Further publication stops,
|
||||
but every rank completes the same remaining native decoder collective schedule.
|
||||
The original exception is then raised on the owner and
|
||||
`MiniMaxH3ChunkCallbackPeerError` on peers.
|
||||
|
||||
Exceptions raised inside the checkpoint's native distributed decode cannot in
|
||||
general be reconciled by the adapter: a peer may already be blocked inside a
|
||||
checkpoint-owned collective. Resolving such failures requires native collective
|
||||
handling or supervisor-level timeout and worker-group teardown. The process
|
||||
group must not be assumed reusable after such a failure.
|
||||
|
||||
## Validation evidence
|
||||
|
||||
The contract suite covers ordinary and padded frame plans, blend boundaries,
|
||||
owned storage and mutation isolation, source-manifest/listed-temporal-attribute
|
||||
drift, callback failure, and healthy subsequent requests. Real Gloo processes cover
|
||||
rank-asymmetric preflight failures, dtype disagreement, two-rank callback
|
||||
failure propagation, three-rank wrong-owner rejection, insufficient-tile
|
||||
fallback, and group recovery for failures that occur at adapter-owned
|
||||
rendezvous points.
|
||||
|
||||
Four-device L20X and independent four- and eight-device B300 experiments on the
|
||||
implementation through PR commit `60e9bbad` showed exact serial-versus-chunked
|
||||
frame/MP4 parity within each environment and lower VAE-to-complete-MP4 latency
|
||||
when an experimental consumer was attached. Hashes are not claimed to match
|
||||
across different hardware/software environments. The later coordination and
|
||||
module-boundary refactors were revalidated with CPU and real Gloo tests but were
|
||||
not reprofiled on GPUs. These are feasibility measurements, not a promise for
|
||||
default serving, because this change does not contain the transport or encoder
|
||||
consumer. See [RFC #6872](https://github.com/vllm-project/vllm-omni/issues/6872)
|
||||
and [PR #6885](https://github.com/vllm-project/vllm-omni/pull/6885) for the
|
||||
measurement provenance and limitations.
|
||||
|
||||
## Generalization boundary and follow-ups
|
||||
|
||||
This feature does not define a repository-wide `SupportsVideoChunkDecode`
|
||||
interface. H3 temporal blending, Wan causal caches, LTX temporal/spatial halos,
|
||||
and pipeline-level generation chunks have different commit and failure
|
||||
boundaries. A generic producer contract should be extracted only after a second
|
||||
model proves the same semantic event without `Any` arguments or model-name
|
||||
dispatch.
|
||||
|
||||
The reusable future boundary is the emitted, committed video-media chunk, not a
|
||||
universal latent-to-VAE method. It should extend the typed media work in
|
||||
[RFC #6541](https://github.com/vllm-project/vllm-omni/issues/6541) and
|
||||
[PR #6615](https://github.com/vllm-project/vllm-omni/pull/6615), while keeping
|
||||
model-specific halo, cache, padding, blending, distributed, and source-gating
|
||||
logic inside each model adapter.
|
||||
|
||||
Runtime follow-ups own:
|
||||
|
||||
- typed video-plus-audio media and request/output identity;
|
||||
- requested-size crop and device preparation;
|
||||
- bounded leases, backpressure, cancellation, and cleanup;
|
||||
- D2H/IPC transport and observability; and
|
||||
- complete-MP4 and fragmented-MP4 consumers.
|
||||
|
||||
## Related documentation
|
||||
|
||||
- [Diffusion model integration](../module/diffusion/diffusion_model_integration.md)
|
||||
- [VAE patch parallelism](vae_parallel.md)
|
||||
- [Async diffusion output](async_diffusion_output.md)
|
||||
- [MiniMax H3 recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/MiniMaxAI/MiniMax-H3.md)
|
||||
@@ -25,6 +25,10 @@ implementation contract; it is not, by itself, a general support claim.
|
||||
- [Automatic Prefix Caching in Omni Models](feature/prefix_caching.md)
|
||||
- [Realtime AR-Diffusion Sessions](feature/realtime_ar_diffusion.md)
|
||||
|
||||
### Model-specific capabilities
|
||||
|
||||
- [MiniMax H3 Temporal VAE Chunks](feature/minimax_h3_temporal_vae_chunks.md)
|
||||
|
||||
### Communication
|
||||
|
||||
#### OmniConnector implementations
|
||||
|
||||
@@ -57,3 +57,10 @@ cross-stage routing.
|
||||
|
||||
Test registry selection, checkpoint loading, minimal inference, input and output
|
||||
contracts, and every declared optional capability.
|
||||
|
||||
## Model-specific capability documents
|
||||
|
||||
- [MiniMax H3 temporal VAE chunk callback](../../feature/minimax_h3_temporal_vae_chunks.md)
|
||||
defines the source-gated producer seam and its boundary with future runtime
|
||||
media transport. It is an internal opt-in capability, not a public serving
|
||||
feature.
|
||||
@@ -3,4 +3,6 @@
|
||||
vLLM-Omni's examples are split into two categories:
|
||||
|
||||
- If you are using vLLM-Omni from within Python code, see the *Offline Inference* section.
|
||||
- If you are using vLLM-Omni from an HTTP application or client, see the *Online Serving* section.
|
||||
- If you are using vLLM-Omni from an HTTP application or client, start with
|
||||
the [API Server guide](../serving/README.md) to choose an endpoint, then use
|
||||
the *Online Serving* section for model-specific examples.
|
||||
@@ -34,7 +34,7 @@ vLLM-Omni is a Python library that supports the following GPU variants. The libr
|
||||
|
||||
### Pre-built wheels
|
||||
|
||||
Note: Pre-built wheels are currently available for vLLM-Omni 0.11.0rc1, 0.12.0rc1, 0.14.0rc1, 0.14.0, 0.16.0, 0.18.0, 0.20.0, 0.21.0, 0.22.0, 0.23.0, 0.24.0, 0.25.0, and 0.26.0; the 0.28.0 wheels are published together with the v0.28.0 release this branch produces. If you need a newer unreleased revision, please [build from source](https://docs.vllm.ai/projects/vllm-omni/en/latest/getting_started/installation/gpu/#build-wheel-from-source).
|
||||
Note: Pre-built wheels are currently available for vLLM-Omni 0.11.0rc1, 0.12.0rc1, 0.14.0rc1, 0.14.0, 0.16.0, 0.18.0, 0.20.0, 0.21.0, 0.22.0, 0.23.0, 0.24.0, 0.25.0, 0.26.0, and 0.28.0. If you need a newer unreleased revision, please [build from source](https://docs.vllm.ai/projects/vllm-omni/en/latest/getting_started/installation/gpu/#build-wheel-from-source).
|
||||
|
||||
=== "NVIDIA CUDA"
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# --8<-- [end:requirements]
|
||||
# --8<-- [start:set-up-using-python]
|
||||
|
||||
vLLM-Omni depends on the matching major/minor release of vLLM. The vLLM-Omni 0.26.x release line uses vLLM 0.26.x.
|
||||
vLLM-Omni depends on the matching major/minor release of vLLM. The vLLM-Omni 0.28.x release line uses vLLM 0.28.x.
|
||||
|
||||
!!! note
|
||||
PyTorch installed via `conda` will statically link `NCCL` library, which can cause issues when vLLM tries to use `NCCL`. See <gh-issue:8420> for more details.
|
||||
@@ -95,6 +95,13 @@ uv pip install --no-build-isolation --editable .
|
||||
|
||||
vLLM-Omni offers an official docker image for deployment. These images are built on top of vLLM docker images and available on Docker Hub as [vllm/vllm-omni](https://hub.docker.com/r/vllm/vllm-omni/tags). The version of vLLM-Omni indicates which release of vLLM it is based on.
|
||||
|
||||
Nightly CUDA images built from the latest `main` are also available:
|
||||
|
||||
- [`vllm/vllm-omni:nightly`](https://hub.docker.com/r/vllm/vllm-omni/tags?name=nightly) is the rolling multi-architecture tag.
|
||||
- [`vllm/vllm-omni:nightly-<commit>`](https://hub.docker.com/r/vllm/vllm-omni/tags?name=nightly-) provides commit-pinned multi-architecture tags. The newest 14 are retained.
|
||||
|
||||
Use a commit-pinned tag when you need a reproducible nightly environment.
|
||||
|
||||
Here's an example deployment command that has been verified on 2 x H100's:
|
||||
```bash
|
||||
docker run --runtime nvidia --gpus 2 \
|
||||
|
||||
@@ -9,8 +9,8 @@ The recommended way to use vLLM-Omni on NPU is through the vLLM-Ascend pre-built
|
||||
|
||||
```bash
|
||||
# vLLM-Ascend image
|
||||
# Atlas A2: quay.io/atlas-ci/vllm-ascend:v0.26.0
|
||||
# Atlas A3: quay.io/atlas-ci/vllm-ascend:v0.26.0-a3
|
||||
# Atlas A2: quay.io/atlas-ci/vllm-ascend:v0.28.0
|
||||
# Atlas A3: quay.io/atlas-ci/vllm-ascend:v0.28.0-a3
|
||||
docker run --rm \
|
||||
--name vllm-omni-npu \
|
||||
--shm-size=1g \
|
||||
@@ -28,11 +28,11 @@ docker run --rm \
|
||||
-v /etc/ascend_install.info:/etc/ascend_install.info \
|
||||
-v /root/.cache:/root/.cache \
|
||||
-p 8000:8000 \
|
||||
-it quay.io/atlas-ci/vllm-ascend:v0.26.0-a3 bash
|
||||
-it quay.io/atlas-ci/vllm-ascend:v0.28.0-a3 bash
|
||||
|
||||
# Inside the container, install vLLM-Omni from source
|
||||
cd /vllm-workspace
|
||||
git clone -b v0.26.0 https://github.com/vllm-project/vllm-omni.git
|
||||
git clone -b v0.28.0 https://github.com/vllm-project/vllm-omni.git
|
||||
cd vllm-omni
|
||||
pip install -v -e . --no-build-isolation
|
||||
# or VLLM_OMNI_TARGET_DEVICE=npu pip install -v -e .
|
||||
@@ -50,21 +50,11 @@ We are keeping [issue #886](https://github.com/vllm-project/vllm-omni/issues/886
|
||||
|
||||
# --8<-- [start:installation-main]
|
||||
|
||||
You can also build vLLM-Omni from the latest main branch if you want to use the latest features or bug fixes. (But sometimes it will break for a while. You can check [issue #886](https://github.com/vllm-project/vllm-omni/issues/886) for the status of the latest commit of vLLM-Omni main branch on NPU.)
|
||||
You can also install vLLM-Omni from the latest main branch if you want to use the latest features or bug fixes. Use the aligned vLLM-Ascend v0.28.0 image described above, and check [issue #886](https://github.com/vllm-project/vllm-omni/issues/886) for the status of the latest vLLM-Omni commit on NPU.
|
||||
|
||||
```bash
|
||||
# Pin vLLM and vLLM-Ascend to the v0.26 release line
|
||||
git clone -b v0.26.0 https://github.com/vllm-project/vllm.git
|
||||
cd vllm
|
||||
VLLM_TARGET_DEVICE=empty pip install -v -e .
|
||||
cd ..
|
||||
|
||||
git clone -b releases/v0.26.0rc https://github.com/vllm-project/vllm-ascend.git
|
||||
cd vllm-ascend
|
||||
pip install -v -e .
|
||||
cd ..
|
||||
|
||||
# Install vLLM-Omni from the latest main branch
|
||||
# Inside the vLLM-Ascend v0.28.0 container, install vLLM-Omni from main
|
||||
cd /vllm-workspace
|
||||
git clone https://github.com/vllm-project/vllm-omni.git
|
||||
cd vllm-omni
|
||||
pip install -v -e . --no-build-isolation
|
||||
@@ -88,8 +78,8 @@ Supported images as following.
|
||||
Here's an example deployment command that has been verified on 4 x NPUs:
|
||||
|
||||
```bash
|
||||
# Atlas A2: quay.io/ascend/vllm-omni:v0.26.0
|
||||
# Atlas A3: quay.io/ascend/vllm-omni:v0.26.0-a3
|
||||
# Atlas A2: quay.io/ascend/vllm-omni:v0.28.0
|
||||
# Atlas A3: quay.io/ascend/vllm-omni:v0.28.0-a3
|
||||
docker run --rm \
|
||||
--name vllm-omni-a3 \
|
||||
--shm-size=4g \
|
||||
@@ -114,7 +104,7 @@ docker run --rm \
|
||||
-v /etc/ascend_install.info:/etc/ascend_install.info \
|
||||
-v ~/.cache:/root/.cache \
|
||||
-p 8091:8091 \
|
||||
-it quay.io/ascend/vllm-omni:v0.26.0-a3 bash
|
||||
-it quay.io/ascend/vllm-omni:v0.28.0-a3 bash
|
||||
```
|
||||
|
||||
!!! tip
|
||||
|
||||
@@ -91,7 +91,7 @@ if __name__ == "__main__":
|
||||
|
||||
For more usages, please refer to [offline inference](../user_guide/examples/offline_inference/qwen2_5_omni.md)
|
||||
|
||||
## Online Serving with OpenAI-Completions API
|
||||
## Online Serving with the API Server
|
||||
|
||||
Text-to-image generation quickstart with vLLM-Omni:
|
||||
|
||||
@@ -100,20 +100,16 @@ vllm serve Tongyi-MAI/Z-Image-Turbo --omni --port 8091
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8091/v1/chat/completions \
|
||||
curl -s http://localhost:8091/v1/images/generations \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"messages": [
|
||||
{"role": "user", "content": "a cup of coffee on the table"}
|
||||
],
|
||||
"extra_body": {
|
||||
"height": 1024,
|
||||
"width": 1024,
|
||||
"num_inference_steps": 50,
|
||||
"guidance_scale": 4.0,
|
||||
"seed": 42
|
||||
}
|
||||
}' | jq -r '.choices[0].message.content[0].image_url.url' | cut -d',' -f2 | base64 -d > coffee.png
|
||||
"prompt": "a cup of coffee on the table",
|
||||
"size": "1024x1024",
|
||||
"response_format": "b64_json",
|
||||
"seed": 42
|
||||
}' | jq -r '.data[0].b64_json' | base64 -d > coffee.png
|
||||
```
|
||||
|
||||
For more details, please refer to [online serving](../user_guide/examples/online_serving/text_to_image.md).
|
||||
See the [API Server guide](../serving/README.md) to choose an endpoint. For
|
||||
model-specific details, refer to the [text-to-image online serving
|
||||
example](../user_guide/examples/online_serving/text_to_image.md).
|
||||
@@ -21,7 +21,7 @@ th {
|
||||
|--------------|--------|-------------------|------------|---------|-----|-----------|--------|
|
||||
| `Qwen3OmniMoeForConditionalGeneration` | Qwen3-Omni | `Qwen/Qwen3-Omni-30B-A3B-Instruct` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/Qwen/Qwen3-Omni.md) |
|
||||
| `Qwen2_5OmniForConditionalGeneration` | Qwen2.5-Omni | `Qwen/Qwen2.5-Omni-7B`, `Qwen/Qwen2.5-Omni-3B` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
|
||||
| `MingFlashOmniForConditionalGeneration` + `MingImagePipeline` | Ming-flash-omni-2.0 (omni-speech + imagegen<sup>1</sup>) | `Jonathan1909/Ming-flash-omni-2.0` | ✅︎ | | | | — |
|
||||
| `MingFlashOmniForConditionalGeneration` + `MingImagePipeline` | Ming-flash-omni-2.0 (omni-speech + imagegen<sup>1</sup>) | `Jonathan1909/Ming-flash-omni-2.0` | ✅︎ | | | | — |
|
||||
| `BagelForConditionalGeneration` | BAGEL (DiT-only) | `ByteDance-Seed/BAGEL-7B-MoT` | ✅︎ | ✅︎ | | ✅︎ | — |
|
||||
| `InternVLAA1Pipeline` | InternVLA-A1 | `InternRobotics/InternVLA-A1-3B` | ✅︎ | ✅︎ | | | — |
|
||||
| `Gr00tN1d7Pipeline` | GR00T N1.7 | `nvidia/GR00T-N1.7-3B` | ✅︎ | | | | — |
|
||||
@@ -62,7 +62,7 @@ th {
|
||||
| `LongcatImagePipeline` | LongCat-Image | `meituan-longcat/LongCat-Image` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
|
||||
| `LongCatImageEditPipeline` | LongCat-Image-Edit | `meituan-longcat/LongCat-Image-Edit` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
|
||||
| `LongCatVideoAvatarPipeline` | LongCat-Video-Avatar-1.5 A2V/AI2V (native single-speaker and multi-speaker AI2V/AVC) | `meituan-longcat/LongCat-Video-Avatar-1.5` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/meituan-longcat/LongCat-Video-Avatar-1.5.md) |
|
||||
| `BooguImagePipeline` | Boogu-Image | `Boogu/Boogu-Image-0.1-Base`, `Boogu/Boogu-Image-0.1-Edit` | ✅︎ | | | ✅︎ | — |
|
||||
| `BooguImagePipeline` | Boogu-Image | `Boogu/Boogu-Image-0.1-Base`, `Boogu/Boogu-Image-0.1-Edit`, `Boogu/Boogu-Image-0.1-Edit-Turbo` | ✅︎ | | | ✅︎ | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/Boogu/Boogu-Image.md) |
|
||||
| `StableDiffusionXLPipeline` | Stable-Diffusion-XL | `stabilityai/stable-diffusion-xl-base-1.0` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
|
||||
| `StableDiffusion3Pipeline` | Stable-Diffusion-3 | `stabilityai/stable-diffusion-3.5-medium` | ✅︎ | ✅︎ | | ✅︎ | — |
|
||||
| `CosyVoice3Model` | CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | ✅︎ | ✅︎ | | ✅︎ | — |
|
||||
@@ -95,7 +95,7 @@ th {
|
||||
| `MiMoV2ASRForCausalLM` | MiMo-V2.5-ASR | `XiaomiMiMo/MiMo-V2.5-ASR` | ✅︎ | ✅︎ | | | — |
|
||||
| `Flux2Pipeline` | FLUX.2-dev | `black-forest-labs/FLUX.2-dev` | ✅︎ | ✅︎ | | | — |
|
||||
| `FishSpeechSlowARForConditionalGeneration` | Fish Speech S2 Pro | `fishaudio/s2-pro` | ✅︎ | ✅︎ | | ✅︎ | — |
|
||||
| `SenseNovaU1Pipeline` | SenseNova-U1 (DiT-only) | `SenseNova/SenseNova-U1-8B-MoT` | ✅︎ | | | | — |
|
||||
| `SenseNovaU1Pipeline` | SenseNova-U1 (DiT-only), SenseNova-U1.5 | `SenseNova/SenseNova-U1-8B-MoT`, `sensenova/SenseNova-U1.5-8B-MoT` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/SenseNova/SenseNova-U1.5.md) |
|
||||
| `LancePipeline` | Lance | `bytedance-research/Lance` | ✅︎ | | | | — |
|
||||
| `HunyuanVideo15Pipeline` | HunyuanVideo-1.5-T2V | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v`, `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_t2v` | ✅︎ | ✅︎ | | | — |
|
||||
| `HunyuanVideo15ImageToVideoPipeline` | HunyuanVideo-1.5-I2V | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_i2v`, `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_i2v` | ✅︎ | ✅︎ | | | — |
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
# API Server
|
||||
|
||||
vLLM-Omni exposes OpenAI-compatible HTTP APIs plus vLLM-Omni extensions for
|
||||
image, audio, video, realtime, and robot-policy workloads. Use this page to
|
||||
choose an endpoint; follow the linked reference page for model-specific fields,
|
||||
wire protocols, and response details.
|
||||
|
||||
Each server instance hosts one model. An endpoint is usable only when the
|
||||
loaded model supports that task.
|
||||
|
||||
This guide focuses on vLLM-Omni's primary task endpoints. Compatible models
|
||||
may also expose standard routes inherited from vLLM, such as
|
||||
`POST /v1/completions`; those routes remain model- and task-dependent.
|
||||
|
||||
## Start and Verify the Server
|
||||
|
||||
Start a model with the shared serving command:
|
||||
|
||||
```bash
|
||||
vllm serve <model> --omni --port 8091
|
||||
```
|
||||
|
||||
Some models require additional flags or a deployment configuration. Use the
|
||||
model-specific guide when one is provided.
|
||||
|
||||
After the server starts, check its health and served model name:
|
||||
|
||||
```bash
|
||||
export VLLM_OMNI_BASE_URL=http://localhost:8091
|
||||
|
||||
curl "$VLLM_OMNI_BASE_URL/health"
|
||||
curl "$VLLM_OMNI_BASE_URL/v1/models" | jq .
|
||||
```
|
||||
|
||||
Use `http://localhost:8091/v1` as the `base_url` for an OpenAI SDK client. If
|
||||
the server was started with `--api-key`, include an `Authorization` header with
|
||||
`Bearer <api-key>` in requests.
|
||||
|
||||
## Choose a Core HTTP Endpoint
|
||||
|
||||
Prefer a task-specific endpoint when one matches your workload. Use Chat
|
||||
Completions for conversational or heterogeneous omni pipelines, rather than as
|
||||
the default wrapper for every generation task.
|
||||
|
||||
| Task | Endpoint | Request | Response | Details |
|
||||
|------|----------|---------|----------|---------|
|
||||
| Conversation or multimodal understanding/generation | `POST /v1/chat/completions` | JSON | OpenAI-style JSON or SSE | [Chat Completions](chat_completions_api.md) |
|
||||
| Text-to-speech | `POST /v1/audio/speech` | JSON | Audio bytes or SSE | [Speech](speech_api.md) |
|
||||
| Sound, music, or ambient audio generation | `POST /v1/audio/generate` | JSON | Audio bytes | [Audio Generation](audio_generate_api.md) |
|
||||
| Text-to-image | `POST /v1/images/generations` | JSON | Base64 JSON or an image file | [Image Generation](image_generation_api.md) |
|
||||
| Image editing | `POST /v1/images/edits` | Multipart form | Base64 JSON or SSE | [Image Edit](image_edit_api.md) |
|
||||
| Video generation (recommended) | `POST /v1/videos` | Multipart form | Asynchronous job JSON | [Videos](videos_api.md) |
|
||||
| Video generation (blocking) | `POST /v1/videos/sync` | Multipart form | Video bytes | [Videos](videos_api.md#synchronous-response) |
|
||||
|
||||
`POST /v1/videos/sync` is intended for tests and simple scripts. Use the
|
||||
asynchronous job API for production requests so clients can poll, download,
|
||||
and delete generated videos independently.
|
||||
|
||||
## Minimal Requests
|
||||
|
||||
The examples below assume a compatible model is already running. TTS voices,
|
||||
media inputs, generation controls, and output capabilities vary by model.
|
||||
|
||||
=== "Chat"
|
||||
|
||||
```bash
|
||||
curl "$VLLM_OMNI_BASE_URL/v1/chat/completions" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"messages": [{"role": "user", "content": "Describe vLLM-Omni briefly."}],
|
||||
"modalities": ["text"]
|
||||
}'
|
||||
```
|
||||
|
||||
=== "Speech"
|
||||
|
||||
```bash
|
||||
curl "$VLLM_OMNI_BASE_URL/v1/audio/speech" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"input": "Hello from vLLM-Omni.",
|
||||
"voice": "vivian"
|
||||
}' \
|
||||
--output speech.wav
|
||||
```
|
||||
|
||||
=== "Audio"
|
||||
|
||||
```bash
|
||||
curl "$VLLM_OMNI_BASE_URL/v1/audio/generate" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"input": "Ocean waves on a quiet beach",
|
||||
"audio_length": 5.0
|
||||
}' \
|
||||
--output audio.wav
|
||||
```
|
||||
|
||||
=== "Image"
|
||||
|
||||
```bash
|
||||
curl "$VLLM_OMNI_BASE_URL/v1/images/generations" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": "A small robot reading beside a window",
|
||||
"size": "1024x1024",
|
||||
"response_format": "file"
|
||||
}' \
|
||||
--output image.png
|
||||
```
|
||||
|
||||
=== "Image edit"
|
||||
|
||||
```bash
|
||||
curl "$VLLM_OMNI_BASE_URL/v1/images/edits" \
|
||||
-F "image=@input.png" \
|
||||
-F "prompt=Turn the background into a snowy mountain" \
|
||||
| jq -r '.data[0].b64_json' \
|
||||
| base64 --decode > edited.png
|
||||
```
|
||||
|
||||
=== "Video"
|
||||
|
||||
```bash
|
||||
curl "$VLLM_OMNI_BASE_URL/v1/videos" \
|
||||
-F "prompt=A cinematic tracking shot of a mountain lake at sunrise" \
|
||||
-F "size=1280x720" \
|
||||
-F "seconds=5"
|
||||
```
|
||||
|
||||
The response contains a video job `id`. Poll and download it with:
|
||||
|
||||
```bash
|
||||
curl "$VLLM_OMNI_BASE_URL/v1/videos/<video_id>"
|
||||
curl -L "$VLLM_OMNI_BASE_URL/v1/videos/<video_id>/content" --output video.mp4
|
||||
```
|
||||
|
||||
## Choose a Streaming or Realtime Endpoint
|
||||
|
||||
These WebSocket APIs have different event schemas and cannot be used
|
||||
interchangeably.
|
||||
|
||||
| Workload | Endpoint | Interaction model | Details |
|
||||
|----------|----------|-------------------|---------|
|
||||
| Incremental text input for speech synthesis | `WS /v1/audio/speech/stream` | Send text events and receive audio | [Streaming Text to Speech](speech_api.md#streaming-text-input-websocket) |
|
||||
| Live video understanding | `WS /v1/video/chat/stream` | Send video frames and receive text/audio | [Streaming Video Input](video_stream_api.md) |
|
||||
| Turn-based realtime audio | `WS /v1/realtime` | Stream one audio input and receive transcript/audio events | [Realtime Audio](realtime_api.md) |
|
||||
| Continuous speech-to-speech interaction | `WS /v1/realtime?duplex=1` or `WS /v1/duplex` | Listen and speak concurrently with session control | [Full Duplex](full_duplex_api.md) |
|
||||
| Generated video chunks | `WS /v1/realtime/video` | Start a diffusion request and receive fragmented MP4 | [Streaming Video Output](streaming_video_output_api.md) |
|
||||
| Robot policy inference | `WS /v1/realtime/robot/openpi` | Send MessagePack observations and receive action arrays | [OpenPI Robot Policy](openpi_api.md) |
|
||||
|
||||
All six routes are model- or configuration-dependent. In particular,
|
||||
`/v1/realtime` is not full duplex unless the client sets `duplex=1` and the
|
||||
deployment explicitly enables duplex sessions. Clients should also verify the
|
||||
duplex capability payload because the query-parameter form falls back to the
|
||||
ordinary realtime handler when duplex is unavailable.
|
||||
|
||||
## Related Endpoints
|
||||
|
||||
| Purpose | Endpoints | Reference |
|
||||
|---------|-----------|-----------|
|
||||
| Discovery and readiness | `GET /health`, `GET /v1/models` | This page |
|
||||
| Batched conversations | `POST /v1/chat/completions/batch` | [Batch requests](chat_completions_api.md#batch-requests) |
|
||||
| Batched speech | `POST /v1/audio/speech/batch` | [Batch speech generation](speech_api.md#batch-speech-generation) |
|
||||
| TTS voice management | `GET/POST /v1/audio/voices`, `DELETE /v1/audio/voices/{name}` | [Voices](speech_api.md#voices-endpoint) |
|
||||
| Video job lifecycle | `GET /v1/videos`, `GET/DELETE /v1/videos/{video_id}`, `GET /v1/videos/{video_id}/content` | [Video endpoints](videos_api.md#endpoints) |
|
||||
| Release and restore stage memory | `POST /v1/omni/sleep`, `POST /v1/omni/wakeup` | [Sleep Mode](../features/sleep_mode.md) |
|
||||
|
||||
## Standalone Experimental Servers
|
||||
|
||||
PersonaPlex also provides a Moshi-compatible WebSocket server, while JoyVL
|
||||
provides a stateful interaction orchestrator in front of another model server.
|
||||
These are separate processes with their own routes; they are not additional
|
||||
paths on every `vllm serve --omni` instance. See [Standalone Experimental
|
||||
Servers](standalone_servers.md) before deploying either one.
|
||||
|
||||
## Compatibility Notes
|
||||
|
||||
- A registered path does not mean every model supports it. Unsupported tasks
|
||||
return an OpenAI-style error or a WebSocket `error` event.
|
||||
- Use JSON for Chat, Speech, Audio Generation, and Image Generation. Use
|
||||
`multipart/form-data` when uploading images, videos, or other files.
|
||||
- Generation can take minutes for large diffusion models. Configure client
|
||||
timeouts accordingly; prefer asynchronous video jobs over long blocking
|
||||
requests.
|
||||
- Model-specific parameters are vLLM-Omni extensions. Check the endpoint
|
||||
reference before assuming an OpenAI SDK exposes them directly.
|
||||
@@ -4,14 +4,14 @@ vLLM-Omni provides an API for text-to-audio generation using diffusion-based mod
|
||||
|
||||
Unlike the [Speech API](speech_api.md) which targets text-to-speech synthesis, the Audio Generate API is designed for general-purpose audio generation from text descriptions (sound effects, music, ambient soundscapes, etc.).
|
||||
|
||||
Each server instance runs a single model (specified at startup via `vllm-omni serve <model> --omni`).
|
||||
Each server instance runs a single model (specified at startup via `vllm serve <model> --omni`).
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Start the Server
|
||||
|
||||
```bash
|
||||
vllm-omni serve stabilityai/stable-audio-open-1.0 \
|
||||
vllm serve stabilityai/stable-audio-open-1.0 \
|
||||
--host 0.0.0.0 \
|
||||
--port 8091 \
|
||||
--gpu-memory-utilization 0.9 \
|
||||
@@ -326,7 +326,7 @@ curl http://localhost:8091/health
|
||||
Enable debug logging:
|
||||
|
||||
```bash
|
||||
vllm-omni serve stabilityai/stable-audio-open-1.0 \
|
||||
vllm serve stabilityai/stable-audio-open-1.0 \
|
||||
--host 0.0.0.0 \
|
||||
--port 8091 \
|
||||
--gpu-memory-utilization 0.9 \
|
||||
|
||||
@@ -1,78 +1,110 @@
|
||||
# Chat Completions API
|
||||
|
||||
vLLM-Omni supports generating and editing images via the `/v1/chat/completions`
|
||||
endpoint using diffusion models. This page explains how to pass generation
|
||||
parameters (such as `num_inference_steps`, `height`, `width`) to diffusion
|
||||
models through this endpoint.
|
||||
Use `/v1/chat/completions` for conversational and multimodal pipelines that
|
||||
follow the OpenAI Chat API schema. Depending on the loaded model, a request can
|
||||
contain text, image, audio, or video input and can return text, audio, images,
|
||||
or other model-specific output.
|
||||
|
||||
!!! tip
|
||||
For dedicated endpoints that accept generation parameters as top-level
|
||||
fields, see [Image Generation API](image_generation_api.md) and
|
||||
[Image Edit API](image_edit_api.md).
|
||||
Some diffusion pipelines also support image generation and editing through
|
||||
Chat Completions. Prefer the dedicated [Image Generation
|
||||
API](image_generation_api.md) or [Image Edit API](image_edit_api.md) for
|
||||
task-specific clients; their request fields and response contracts are more
|
||||
direct.
|
||||
|
||||
## Passing Generation Parameters
|
||||
|
||||
The `/v1/chat/completions` endpoint follows the OpenAI Chat API schema, which
|
||||
does not natively include diffusion-specific fields like `num_inference_steps`
|
||||
or `height`. How you pass these extra fields depends on your client.
|
||||
|
||||
### curl / Python `requests`
|
||||
|
||||
Wrap generation parameters inside an `"extra_body"` key in the JSON body:
|
||||
## Basic Request
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8091/v1/chat/completions \
|
||||
curl http://localhost:8091/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"messages": [
|
||||
{"role": "user", "content": "A beautiful landscape painting"}
|
||||
{"role": "user", "content": "Describe vLLM-Omni briefly."}
|
||||
],
|
||||
"extra_body": {
|
||||
"num_inference_steps": 50,
|
||||
"seed": 42
|
||||
}
|
||||
"modalities": ["text"]
|
||||
}'
|
||||
```
|
||||
|
||||
### OpenAI Python SDK
|
||||
Use `stream: true` for Server-Sent Events. Input media syntax, supported output
|
||||
`modalities`, and response choices depend on the model; see the
|
||||
[model-specific online serving examples](../user_guide/examples/online_serving/qwen3_omni.md).
|
||||
|
||||
Use the `extra_body` **keyword argument**. The SDK automatically merges these
|
||||
fields into the top-level request body:
|
||||
!!! tip
|
||||
Each server hosts one model. Query `GET /v1/models` for its served model
|
||||
name when your client requires the `model` field.
|
||||
|
||||
```python
|
||||
response = client.chat.completions.create(
|
||||
model="Qwen/Qwen-Image",
|
||||
messages=[{"role": "user", "content": "A beautiful landscape painting"}],
|
||||
extra_body={
|
||||
"num_inference_steps": 50,
|
||||
"seed": 42,
|
||||
},
|
||||
)
|
||||
## vLLM Extension Parameters
|
||||
|
||||
Send standard and vLLM-specific Chat parameters as top-level fields in a
|
||||
direct HTTP request. When using the OpenAI Python SDK, pass vLLM-specific
|
||||
fields through the SDK's `extra_body` keyword; the SDK merges them into the
|
||||
top-level JSON sent to the server.
|
||||
|
||||
=== "curl"
|
||||
|
||||
```bash
|
||||
curl http://localhost:8091/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"messages": [{"role": "user", "content": "Write a short haiku."}],
|
||||
"top_k": 40
|
||||
}'
|
||||
```
|
||||
|
||||
=== "OpenAI Python SDK"
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(base_url="http://localhost:8091/v1", api_key="none")
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="your-served-model",
|
||||
messages=[{"role": "user", "content": "Write a short haiku."}],
|
||||
extra_body={"top_k": 40},
|
||||
)
|
||||
```
|
||||
|
||||
??? note "Diffusion compatibility"
|
||||
|
||||
Diffusion models exposed through Chat Completions can accept supported
|
||||
fields such as `num_inference_steps`, `seed`, `height`, and `width`. Send
|
||||
them at the top level with direct HTTP, or in the SDK's `extra_body`
|
||||
keyword argument.
|
||||
|
||||
A literal nested `"extra_body"` JSON object is accepted for compatibility,
|
||||
but it is not recommended for new direct HTTP clients. Do not provide the
|
||||
same parameter in multiple locations; the server returns a `400` error for
|
||||
duplicate diffusion parameters. Prefer the dedicated [Image Generation
|
||||
API](image_generation_api.md) or [Image Edit API](image_edit_api.md) when
|
||||
either endpoint matches the task.
|
||||
|
||||
## Batch Requests
|
||||
|
||||
`POST /v1/chat/completions/batch` accepts the same shared generation fields,
|
||||
but `messages` is a list of conversations. The response contains one choice
|
||||
per conversation in input order.
|
||||
|
||||
```bash
|
||||
curl http://localhost:8091/v1/chat/completions/batch \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"messages": [
|
||||
[{"role": "user", "content": "Summarize vLLM in one sentence."}],
|
||||
[{"role": "user", "content": "Summarize vLLM-Omni in one sentence."}]
|
||||
],
|
||||
"max_tokens": 64
|
||||
}'
|
||||
```
|
||||
|
||||
!!! note "SDK `extra_body` vs. JSON `extra_body`"
|
||||
These two `extra_body` usages look similar but work differently under the
|
||||
hood. The SDK flattens the dict into the top-level request JSON, while the
|
||||
curl/requests approach sends it as a nested `"extra_body"` key. Both are
|
||||
handled correctly by the server.
|
||||
|
||||
!!! note "About the `ignored fields` warning"
|
||||
You may see a log message like:
|
||||
|
||||
```
|
||||
WARNING: The following fields were present in the request but ignored: {'height', 'width', ...}
|
||||
```
|
||||
|
||||
This is **harmless**. It is emitted by vLLM's request validation layer
|
||||
because these fields are not part of the standard OpenAI
|
||||
`ChatCompletionRequest` schema. The fields are still stored internally
|
||||
and correctly forwarded to the diffusion pipeline.
|
||||
Batch Chat Completions does not support streaming, tools, beam search, or
|
||||
`n > 1`.
|
||||
|
||||
## Model-Specific Examples
|
||||
|
||||
For complete examples with full request/response details, see the model-specific
|
||||
guides:
|
||||
For complete examples with model-specific inputs and outputs, see:
|
||||
|
||||
- [Qwen3-Omni](../user_guide/examples/online_serving/qwen3_omni.md)
|
||||
- [Qwen2.5-Omni](../user_guide/examples/online_serving/qwen2_5_omni.md)
|
||||
- [Text-to-Image (Qwen-Image)](../user_guide/examples/online_serving/text_to_image.md)
|
||||
- [Image-to-Image (Qwen-Image-Edit, Qwen-Image-Layered)](../user_guide/examples/online_serving/image_to_image.md)
|
||||
- [GLM-Image](../user_guide/examples/online_serving/glm_image.md)
|
||||
@@ -0,0 +1,116 @@
|
||||
# Full-Duplex WebSocket API
|
||||
|
||||
vLLM-Omni provides an experimental full-duplex runtime for models that can
|
||||
continue receiving speech while producing speech. It adds persistent session
|
||||
state, model-specific turn policy, overlap handling, playback acknowledgement,
|
||||
and optional session resume.
|
||||
|
||||
Full duplex is distinct from the turn-based [Realtime Audio API](realtime_api.md).
|
||||
|
||||
## Choose an Endpoint
|
||||
|
||||
| Endpoint | Protocol | Recommended use |
|
||||
|----------|----------|-----------------|
|
||||
| `WS /v1/realtime?duplex=1` | OpenAI Realtime-style event projection | Applications and browser clients |
|
||||
| `WS /v1/duplex` | Native vLLM-Omni duplex events | Runtime integration and low-level testing |
|
||||
|
||||
Both endpoints use the same duplex engine and require the same model-side
|
||||
adapter. Prefer the Realtime projection unless the native lifecycle events are
|
||||
specifically required.
|
||||
|
||||
## Enable Full Duplex
|
||||
|
||||
The route becomes usable only when the deployment configuration explicitly
|
||||
sets:
|
||||
|
||||
```yaml
|
||||
session_mode: duplex
|
||||
```
|
||||
|
||||
The selected model pipeline must also provide a duplex serving adapter.
|
||||
Model-native deployments configure the corresponding engine runtime extension
|
||||
and control plane as part of their registered pipeline.
|
||||
|
||||
!!! warning
|
||||
|
||||
`WS /v1/duplex` fails with `Duplex API is not available` when duplex is not
|
||||
enabled. By contrast, `/v1/realtime?duplex=1` falls back to the ordinary
|
||||
turn-based realtime handler when no duplex handler exists. Confirm that
|
||||
`session.created.session.capabilities` is present before treating the
|
||||
connection as full duplex.
|
||||
|
||||
The current unified-runtime integrations are:
|
||||
|
||||
- MiniCPM-o 4.5, using `vllm_omni/deploy/minicpmo_4_5_duplex.yaml`;
|
||||
- PersonaPlex, whose default `vllm_omni/deploy/personaplex.yaml` enables duplex.
|
||||
|
||||
JoyVL is a separate HTTP interaction orchestrator and does not use these
|
||||
WebSocket endpoints. See [Standalone Experimental Servers](standalone_servers.md).
|
||||
|
||||
## MiniCPM-o Quick Start
|
||||
|
||||
Start the duplex deployment:
|
||||
|
||||
```bash
|
||||
vllm serve openbmb/MiniCPM-o-4_5 --omni \
|
||||
--deploy-config vllm_omni/deploy/minicpmo_4_5_duplex.yaml \
|
||||
--trust-remote-code \
|
||||
--port 8091
|
||||
```
|
||||
|
||||
Stream a mono, PCM16, 16 kHz WAV file with the provided client:
|
||||
|
||||
```bash
|
||||
python examples/online_serving/minicpmo/realtime_duplex_demo.py \
|
||||
--url 'ws://localhost:8091/v1/realtime?duplex=1' \
|
||||
--model openbmb/MiniCPM-o-4_5 \
|
||||
--input-wav input_16k_mono.wav \
|
||||
--ref-audio reference_voice.wav \
|
||||
--output-dir /tmp/minicpmo-duplex
|
||||
```
|
||||
|
||||
## Realtime Event Lifecycle
|
||||
|
||||
A typical `/v1/realtime?duplex=1` session follows this lifecycle:
|
||||
|
||||
1. Send `session.update` with the model, modalities, audio formats, and session
|
||||
options.
|
||||
2. Wait for `session.created`; inspect `session.capabilities` instead of
|
||||
assuming every model supports the same controls.
|
||||
3. Send `input_audio_buffer.append` events while microphone audio arrives.
|
||||
4. Send `input_audio_buffer.commit` at a user-turn boundary when required by
|
||||
the model policy.
|
||||
5. Consume `response.created`, transcript deltas, `response.audio.delta`, and
|
||||
`response.done` or `response.listen` events.
|
||||
6. Send `playback.ack` after audio has been played when the session advertises
|
||||
playback acknowledgement support.
|
||||
7. Send `session.close` and wait for `session.closed`.
|
||||
|
||||
Unlike the turn-based realtime endpoint, input may continue while a response
|
||||
is active. The server can emit `overlap.decision` to describe whether input was
|
||||
deferred, treated as a short acknowledgement, or used to interrupt output.
|
||||
|
||||
## Capabilities and Model Differences
|
||||
|
||||
The `session.created` payload includes capability fields such as
|
||||
`supports_barge_in`, `supports_playback_ack`, `supports_multi_session`,
|
||||
`supports_session_resume`, and `chunk_period_ms`. Treat this payload as the
|
||||
runtime contract.
|
||||
|
||||
For example, PersonaPlex supports native overlapping speech but currently
|
||||
advertises `supports_barge_in=false`; destructive output interruption and
|
||||
model-state rewind have not been validated for that integration. Capacity and
|
||||
session-resume behavior also depend on the selected deployment configuration.
|
||||
|
||||
## Native Protocol
|
||||
|
||||
`WS /v1/duplex` exposes lower-level lifecycle names including
|
||||
`session.create`, `input_audio_buffer.append`, `turn.signal`, `playback.ack`,
|
||||
and `session.close`. It returns native session, input, response, overlap, and
|
||||
error events. This protocol is experimental and may evolve with the runtime;
|
||||
applications should use the provided Realtime client where possible.
|
||||
|
||||
See the [MiniCPM-o example](https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/minicpmo),
|
||||
[PersonaPlex example](https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/personaplex),
|
||||
and [full-duplex runtime design](https://github.com/vllm-project/vllm-omni/blob/main/vllm_omni/experimental/fullduplex/DESIGN.md)
|
||||
for model-specific validation and architecture details.
|
||||
@@ -0,0 +1,109 @@
|
||||
# OpenPI Robot Policy WebSocket API
|
||||
|
||||
Use `WS /v1/realtime/robot/openpi` for low-latency robot-policy inference with
|
||||
OpenPI-compatible clients. A persistent WebSocket carries observations to the
|
||||
loaded policy model and returns action arrays.
|
||||
|
||||
Despite the `/realtime` prefix, this endpoint does not use the OpenAI Realtime
|
||||
event schema. It uses binary MessagePack frames with NumPy extensions.
|
||||
|
||||
## Availability
|
||||
|
||||
The endpoint is available only when the loaded diffusion policy configuration
|
||||
contains `policy_server_config`. The server sends that model-specific
|
||||
configuration to the client as the first binary frame.
|
||||
|
||||
For example, DreamZero declares:
|
||||
|
||||
```yaml
|
||||
policy_server_config:
|
||||
image_resolution: [180, 320]
|
||||
n_external_cameras: 2
|
||||
needs_wrist_camera: true
|
||||
needs_stereo_camera: false
|
||||
needs_session_id: true
|
||||
action_space: joint_position
|
||||
```
|
||||
|
||||
Without this configuration, the WebSocket returns `Robot policy not available`
|
||||
and closes.
|
||||
|
||||
## DreamZero Quick Start
|
||||
|
||||
Start the bundled DreamZero deployment:
|
||||
|
||||
```bash
|
||||
vllm serve GEAR-Dreams/DreamZero-DROID --omni --port 8091 \
|
||||
--served-model-name dreamzero-droid \
|
||||
--deploy-config vllm_omni/deploy/dreamzero_tp1_cfg2.yaml \
|
||||
--enforce-eager --disable-log-stats
|
||||
```
|
||||
|
||||
Install the optional client dependencies, download the sample camera inputs,
|
||||
and run the client:
|
||||
|
||||
```bash
|
||||
pip install openpi-client websockets opencv-python
|
||||
|
||||
hf download YangshenDeng/vllm-omni-dreamzero-assets \
|
||||
--repo-type dataset \
|
||||
--local-dir outputs/dreamzero/assets
|
||||
|
||||
python examples/online_serving/dreamzero/openpi_client.py \
|
||||
--host 127.0.0.1 \
|
||||
--port 8091 \
|
||||
--video-dir outputs/dreamzero/assets
|
||||
```
|
||||
|
||||
## Protocol
|
||||
|
||||
The connection is request-response after an initial server handshake:
|
||||
|
||||
```text
|
||||
connect
|
||||
<- msgpack(policy_server_config)
|
||||
|
||||
infer
|
||||
-> msgpack({"endpoint": "infer", "session_id": "...", ...observation})
|
||||
<- msgpack(ndarray | dict[str, ndarray])
|
||||
|
||||
reset
|
||||
-> msgpack({"endpoint": "reset"})
|
||||
<- msgpack({"status": "reset successful"})
|
||||
```
|
||||
|
||||
If `endpoint` is omitted, the server treats the message as `infer`. Each
|
||||
inference message produces one action response after the engine request
|
||||
completes; action tokens or intermediate tensors are not streamed.
|
||||
|
||||
NumPy arrays use the marker format implemented by `openpi-client`. The server
|
||||
also accepts the legacy vLLM NumPy marker representation on input. JSON text
|
||||
frames are not observation messages.
|
||||
|
||||
## Sessions and Reset
|
||||
|
||||
- The API layer's current-session and first-call counters are scoped to each
|
||||
WebSocket connection.
|
||||
- `session_id` identifies model-side state across observations on that
|
||||
connection. If omitted, it defaults to `default`.
|
||||
- The first inference for a session is sent to the model with `reset=true`.
|
||||
- Changing `session_id`, or sending the `reset` command, causes the next
|
||||
inference to start with `reset=true`.
|
||||
- The policy pipeline owns observation transforms and persistent model state,
|
||||
normally keyed by `session_id`; the API layer forwards the raw observation
|
||||
dictionary.
|
||||
|
||||
## Limits and Errors
|
||||
|
||||
- Maximum inbound payload size is 64 MiB.
|
||||
- The server closes an idle connection after 30 seconds.
|
||||
- Invalid binary input returns a MessagePack
|
||||
`{"type":"error","message":"Invalid request payload"}` response.
|
||||
- Inference failures return a generic `Internal inference error` without
|
||||
exposing an internal traceback.
|
||||
- Unsupported NumPy object, structured, and complex dtypes are rejected.
|
||||
|
||||
Observation keys, camera layout, state tensors, and action shapes are defined
|
||||
by the loaded policy rather than by this transport. Read the handshake before
|
||||
constructing observations. See the [DreamZero example](https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/dreamzero)
|
||||
for a complete OpenPI client and DROID simulation loop.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Realtime Audio WebSocket API
|
||||
|
||||
Use `WS /v1/realtime` to stream PCM audio into a compatible omni model and
|
||||
receive incremental transcription and synthesized response audio. This is a
|
||||
turn-based streaming API: it starts one generation over an audio stream and
|
||||
finishes after the client closes that input stream.
|
||||
|
||||
For a model that listens while it is already speaking, use the experimental
|
||||
[Full-Duplex API](full_duplex_api.md) instead.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start a compatible model. The currently documented example uses Qwen3-Omni:
|
||||
|
||||
```bash
|
||||
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
|
||||
```
|
||||
|
||||
Run the provided client with a mono, 16-bit PCM, 16 kHz WAV file:
|
||||
|
||||
```bash
|
||||
python examples/online_serving/qwen3_omni/openai_realtime_client.py \
|
||||
--url ws://localhost:8091/v1/realtime \
|
||||
--model Qwen/Qwen3-Omni-30B-A3B-Instruct \
|
||||
--input-wav input_16k_mono.wav \
|
||||
--output-wav response.wav \
|
||||
--output-text response.txt
|
||||
```
|
||||
|
||||
The client dependency is `websockets`.
|
||||
|
||||
## Protocol
|
||||
|
||||
Messages in both directions are JSON text frames. Audio carried inside an
|
||||
event is base64-encoded raw PCM16.
|
||||
|
||||
| Direction | Event | Purpose |
|
||||
|-----------|-------|---------|
|
||||
| Server to client | `session.created` | Confirms that the WebSocket connection is ready |
|
||||
| Client to server | `session.update` | Selects and validates the served model |
|
||||
| Client to server | `input_audio_buffer.commit` with `final: false` | Starts generation over the incoming stream |
|
||||
| Client to server | `input_audio_buffer.append` | Appends base64 PCM16 audio |
|
||||
| Client to server | `input_audio_buffer.commit` with `final: true` | Marks the end of input |
|
||||
| Server to client | `transcription.delta` | Carries incremental response text |
|
||||
| Server to client | `transcription.done` | Carries final text and token usage |
|
||||
| Server to client | `response.audio.delta` | Carries incremental PCM16 response audio |
|
||||
| Server to client | `response.audio.done` | Marks the end of response audio |
|
||||
| Server to client | `error` | Reports an invalid event, model, or audio payload |
|
||||
|
||||
A minimal client sends events in this order:
|
||||
|
||||
```json
|
||||
{"type":"session.update","model":"Qwen/Qwen3-Omni-30B-A3B-Instruct"}
|
||||
{"type":"input_audio_buffer.commit","final":false}
|
||||
{"type":"input_audio_buffer.append","audio":"<base64-pcm16>"}
|
||||
{"type":"input_audio_buffer.commit","final":true}
|
||||
```
|
||||
|
||||
The initial non-final commit intentionally starts generation before all audio
|
||||
has arrived. Continue sending `input_audio_buffer.append` events while the
|
||||
engine is consuming the stream.
|
||||
|
||||
## Audio Handling
|
||||
|
||||
- Input is mono PCM16 at 16 kHz for the Qwen3-Omni example.
|
||||
- `response.audio.delta.audio` contains base64-encoded PCM16 bytes.
|
||||
- Read `sample_rate_hz` from each audio event instead of assuming an output
|
||||
rate. Qwen3-Omni output is typically 24 kHz.
|
||||
- Concatenate audio deltas in receive order to construct the output waveform.
|
||||
- Set `input_audio_buffer.commit.final` to `true` after the last input chunk so
|
||||
the server can terminate the streaming request.
|
||||
|
||||
## Availability and Limitations
|
||||
|
||||
The path is registered on the unified API server, but it is usable only when
|
||||
the loaded pipeline implements realtime audio input and produces compatible
|
||||
audio output. Unsupported deployments return an `error` event. The endpoint
|
||||
does not provide duplex session resume, playback acknowledgement, overlap
|
||||
policy, or barge-in controls.
|
||||
|
||||
See the [Qwen3-Omni online serving example](https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/qwen3_omni)
|
||||
for concurrency options, chunk pacing, and per-delta audio debugging.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
vLLM-Omni provides an OpenAI-compatible API for text-to-speech (TTS) generation. Supported TTS models include:
|
||||
|
||||
- **Qwen3-TTS** (`Qwen/Qwen3-TTS-12Hz-*`) -- Qwen3-based TTS with CustomVoice, VoiceDesign, and Base (voice cloning) task types. Output: 24 kHz.
|
||||
- **Qwen3-TTS** (`Qwen/Qwen3-TTS-12Hz-*`) -- Qwen3-based TTS with CustomVoice, VoiceDesign, and Base (voice cloning) task types. Native output: 24 kHz; API output can be resampled to 8 kHz.
|
||||
- **Fish Speech S2 Pro** (`fishaudio/s2-pro`) -- Dual-AR TTS with DAC codec. Supports text-to-speech and voice cloning via reference audio. Output: 44.1 kHz.
|
||||
- **Voxtral TTS** (`mistralai/Voxtral-4B-TTS-2603`) -- AR + FlowMatching TTS with preset voices. Output: 24 kHz.
|
||||
- **CosyVoice3** (`FunAudioLLM/Fun-CosyVoice3-0.5B-2512`) -- 2-stage talker + flow-matching code2wav. Voice cloning via `ref_audio` + `ref_text` (no presets). Output: 24 kHz.
|
||||
@@ -116,6 +116,7 @@ Content-Type: application/json
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `sample_rate` | integer | model native | Target output sample rate. Qwen3-TTS supports 8000 or 24000 Hz; the model remains native 24 kHz internally and is resampled before encoding. |
|
||||
| `task_type` | string | "CustomVoice" | TTS task type: CustomVoice, VoiceDesign, or Base |
|
||||
| `language` | string | "Auto" | Language (see supported languages below) |
|
||||
| `instructions` | string | "" | Voice style/emotion instructions |
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Standalone Experimental Servers
|
||||
|
||||
Most vLLM-Omni endpoints run on the unified server started with
|
||||
`vllm serve <model> --omni`. The experimental full-duplex package also contains
|
||||
two separate user-facing server processes. Their routes are not added to the
|
||||
unified API server.
|
||||
|
||||
| Server | Transport | Role |
|
||||
|--------|-----------|------|
|
||||
| PersonaPlex compatibility server | WebSocket | Hosts the official PersonaPlex browser protocol and a raw-PCM alternative |
|
||||
| JoyVL interaction server | HTTP | Adds state, memory, proactive decisions, and delegation in front of a separate OpenAI-compatible model backend |
|
||||
|
||||
## PersonaPlex Compatibility Server
|
||||
|
||||
Use this server when a client requires the official Moshi/PersonaPlex wire
|
||||
protocol or the bundled browser UI. For the engine-owned unified duplex path,
|
||||
use [`WS /v1/realtime?duplex=1`](full_duplex_api.md) instead.
|
||||
|
||||
Start it with:
|
||||
|
||||
```bash
|
||||
HF_TOKEN=... CUDA_VISIBLE_DEVICES=0 python -m \
|
||||
vllm_omni.experimental.fullduplex.personaplex.serving.server \
|
||||
--port 8091
|
||||
```
|
||||
|
||||
| Route | Purpose |
|
||||
|-------|---------|
|
||||
| `GET /` | Official PersonaPlex browser client, when its assets are available |
|
||||
| `GET /health` | Readiness check |
|
||||
| `WS /api/chat` | Moshi-compatible tagged binary protocol with Opus audio |
|
||||
| `WS /v1/audio/duplex` | Simpler float32 raw-PCM protocol |
|
||||
|
||||
### `/api/chat` Protocol
|
||||
|
||||
The first byte of every binary frame is a message tag:
|
||||
|
||||
| Direction | Tag | Payload |
|
||||
|-----------|-----|---------|
|
||||
| Server to client | `0x00` | Ready handshake |
|
||||
| Client to server | `0x01` | Opus microphone audio at 24 kHz |
|
||||
| Server to client | `0x01` | Opus response audio |
|
||||
| Server to client | `0x02` | UTF-8 inner-monologue text |
|
||||
|
||||
The optional `text_prompt` and `voice_prompt` query parameters select a persona
|
||||
and voice. The default server admits one conversation; use `--batch-size N` to
|
||||
configure multiple lockstep slots. Connections beyond capacity close with
|
||||
WebSocket code 1013.
|
||||
|
||||
### `/v1/audio/duplex` Protocol
|
||||
|
||||
Send `{"type":"open","persona":"...","voice":"..."}` as a JSON text frame,
|
||||
then exchange binary float32 PCM frames. The server returns generated float32
|
||||
audio as binary frames and text as `{"type":"text","text":"..."}`. Send
|
||||
`{"type":"close"}` and wait for `{"type":"done"}` to flush and finish.
|
||||
|
||||
See the [PersonaPlex example](https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/personaplex)
|
||||
for the browser and headless clients.
|
||||
|
||||
## JoyVL Interaction Server
|
||||
|
||||
JoyVL is an orchestration layer, not a model-serving engine. It calls a
|
||||
separate OpenAI-compatible backend and adds per-session frame history, memory,
|
||||
persona policy, and optional delegation.
|
||||
|
||||
Start the model backend and orchestrator separately:
|
||||
|
||||
```bash
|
||||
vllm serve jdopensource/JoyAI-VL-Interaction-Preview \
|
||||
--served-model-name JoyAI-VL-Interaction-Preview \
|
||||
--port 8092 \
|
||||
--max-model-len 131072 \
|
||||
--enable-prefix-caching \
|
||||
--limit-mm-per-prompt '{"image":256,"video":1}'
|
||||
|
||||
python -m vllm_omni.experimental.fullduplex.joyvl.serving.server \
|
||||
--port 8091 \
|
||||
--main-backend-url http://127.0.0.1:8092/v1 \
|
||||
--main-model JoyAI-VL-Interaction-Preview
|
||||
```
|
||||
|
||||
| Method and route | Purpose |
|
||||
|------------------|---------|
|
||||
| `GET /health` | Readiness check |
|
||||
| `GET /v1/models` | Reports the configured interaction model |
|
||||
| `POST /v1/chat/completions` | Processes one frame or interaction tick |
|
||||
| `POST /reset` | Resets a session |
|
||||
| `POST /v1/streaming/reset` | Alias for `/reset` |
|
||||
| `POST /v1/streaming/persona` | Changes the session persona |
|
||||
|
||||
Send one multimodal Chat Completions request per video frame, normally around
|
||||
one frame per second, and identify the session with `x-session-id`. Responses
|
||||
include an `interaction` block whose action is `silence`, `response`, or
|
||||
`delegate`.
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8091/v1/chat/completions \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'x-session-id: demo' \
|
||||
-d '{
|
||||
"messages": [{"role": "user", "content": [
|
||||
{"type": "text", "text": "Alert me if a fire appears"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}}
|
||||
]}]
|
||||
}'
|
||||
```
|
||||
|
||||
See the [JoyVL recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/JD/JoyAI-VL-Interaction.md)
|
||||
for memory, personas, delegation backends, and the browser UI.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Streaming Video Output WebSocket API
|
||||
|
||||
Use `WS /v1/realtime/video` to receive fragmented MP4 chunks while a compatible
|
||||
diffusion video model is generating. This endpoint streams generated video
|
||||
output; it is unrelated to [`WS /v1/video/chat/stream`](video_stream_api.md),
|
||||
which streams live video input into a conversational model.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start a model that supports step execution and enable diffusion streaming
|
||||
output:
|
||||
|
||||
```bash
|
||||
vllm serve BestWishYsh/Helios-Distilled --omni \
|
||||
--diffusion-streaming-output \
|
||||
--port 8091
|
||||
```
|
||||
|
||||
Install the client dependency and run the provided client:
|
||||
|
||||
```bash
|
||||
pip install av websockets
|
||||
|
||||
python examples/online_serving/streaming_video_generation/streaming_video_client.py \
|
||||
--host 127.0.0.1 \
|
||||
--port 8091 \
|
||||
--model BestWishYsh/Helios-Distilled \
|
||||
--prompt "A serene lakeside sunrise with mist over the water" \
|
||||
--output output.mp4
|
||||
```
|
||||
|
||||
## Protocol
|
||||
|
||||
Control events are JSON text frames. Each `video.chunk_metadata` event is
|
||||
immediately followed by one binary WebSocket frame containing fragmented MP4
|
||||
bytes.
|
||||
|
||||
| Direction | Event or frame | Purpose |
|
||||
|-----------|----------------|---------|
|
||||
| Client to server | `session.start` | Starts generation with a prompt and video parameters |
|
||||
| Server to client | `video.start` | Confirms the request ID, format, and accepted configuration |
|
||||
| Server to client | `video.chunk_metadata` | Describes the next binary media frame |
|
||||
| Server to client | Binary frame | Carries one fragmented MP4 chunk |
|
||||
| Client to server | `session.interaction` | Queues an optional prompt update during generation |
|
||||
| Server to client | `session.interaction.queued` | Confirms that an interaction was queued |
|
||||
| Client to server | `session.ping` | Refreshes the stall clock |
|
||||
| Server to client | `session.pong` | Acknowledges a ping |
|
||||
| Client to server | `session.stop` | Cancels the active generation |
|
||||
| Server to client | `session.done` | Reports completion or cancellation and the chunk count |
|
||||
| Server to client | `error` | Reports validation, generation, or timeout failures |
|
||||
|
||||
Start a minimal session with:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "session.start",
|
||||
"model": "BestWishYsh/Helios-Distilled",
|
||||
"prompt": "A serene lakeside sunrise",
|
||||
"format": "m4s",
|
||||
"width": 640,
|
||||
"height": 384,
|
||||
"fps": 16,
|
||||
"num_frames": 99
|
||||
}
|
||||
```
|
||||
|
||||
Only `m4s` output is currently accepted. Concatenate the binary frames in
|
||||
receive order, then remux the fragmented stream if the target player requires
|
||||
a progressive MP4 file.
|
||||
|
||||
## Mid-Generation Interaction
|
||||
|
||||
Models that implement interaction updates can change the active prompt:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "session.interaction",
|
||||
"interaction": {
|
||||
"event_id": "sunlight-update",
|
||||
"event": {"prompt": "Sunlight breaks through the mist"},
|
||||
"transition_chunks": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This feature is pipeline-dependent. An enabled endpoint does not imply that
|
||||
the loaded model supports midway prompt updates.
|
||||
|
||||
The default start timeout is 10 seconds and the server reports a stall after
|
||||
about 60 seconds without generation progress or a `session.ping`. See the
|
||||
[streaming video generation example](https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/streaming_video_generation)
|
||||
for prompt-update scheduling and browser playback.
|
||||
@@ -138,7 +138,7 @@ The following tables show which models support each feature:
|
||||
| **Qwen-Image-Edit** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
|
||||
| **Qwen-Image-Edit-2509** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ (decode) | ✅ | ❌ | ❌ |
|
||||
| **Qwen-Image-Layered** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
|
||||
| **SenseNova-U1** | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
| **SenseNova-U1 / U1.5** | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
| **Stable-Diffusion-XL** | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
|
||||
| **Stable-Diffusion3.5** | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ (decode) | ❌ | ❌ |
|
||||
| **Z-Image** | ✅ | ✅ | ✅ | ❓ | ✅ (TP=2 only) | ❌ | ✅ | ❌ | ✅ (decode) | ✅ | ❌ |
|
||||
|
||||
@@ -49,7 +49,7 @@ scenario below for live barge-in validation on the target GPU.
|
||||
Default:
|
||||
|
||||
```bash
|
||||
vllm-omni serve openbmb/MiniCPM-o-4_5 \
|
||||
vllm serve openbmb/MiniCPM-o-4_5 \
|
||||
--omni \
|
||||
--deploy-config vllm_omni/deploy/minicpmo_4_5.yaml \
|
||||
--trust-remote-code \
|
||||
|
||||
@@ -155,6 +155,23 @@ def _response_in_progress(events: list[dict[str, object]]) -> bool:
|
||||
)
|
||||
|
||||
|
||||
async def _commit_input_after_playback_checkpoint(client: RealtimeDuplexClient) -> float:
|
||||
"""Commit input only after preserving already-played assistant history.
|
||||
|
||||
Native duplex can finish several responses while the user is still
|
||||
streaming. Under the ``ack_only`` playback policy, acknowledging those
|
||||
responses after the final input commit is intentionally rejected: it could
|
||||
append an old assistant message after the newly committed user message.
|
||||
Checkpoint playback first so the server commits each assistant item at its
|
||||
original history position. A second acknowledgement after the commit can
|
||||
then safely advance response-local playback that drained in the meantime.
|
||||
"""
|
||||
await client.acknowledge_playback()
|
||||
commit_sent_at_s = time.monotonic()
|
||||
await client.commit()
|
||||
return commit_sent_at_s
|
||||
|
||||
|
||||
def _event_count_after(
|
||||
events: list[dict[str, object]],
|
||||
event_type: str,
|
||||
@@ -448,8 +465,7 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
|
||||
chunk_period_ms=_chunk_period_ms(client.events.events),
|
||||
)
|
||||
wait_for_post_commit_decision = False
|
||||
commit_sent_at_s = time.monotonic()
|
||||
await client.commit()
|
||||
commit_sent_at_s = await _commit_input_after_playback_checkpoint(client)
|
||||
wait_error: str | None = None
|
||||
committed_index: int | None = None
|
||||
post_commit_decision: str | None = None
|
||||
|
||||
@@ -37,7 +37,7 @@ During generation the client normally sends only `session.start` and then receiv
|
||||
## Install Client Dependency
|
||||
|
||||
```bash
|
||||
pip install websockets
|
||||
pip install av websockets
|
||||
# For the Gradio demo:
|
||||
pip install 'vllm-omni[demo]' websockets
|
||||
```
|
||||
|
||||
@@ -8,7 +8,7 @@ the received bytes to disk when the session finishes or is interrupted.
|
||||
Image/reference input is intentionally not supported in this example yet.
|
||||
|
||||
Requirements:
|
||||
pip install websockets
|
||||
pip install av websockets
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""OpenAI-compatible client for Qwen3-TTS via /v1/audio/speech endpoint.
|
||||
|
||||
This script demonstrates how to use the OpenAI-compatible speech API
|
||||
@@ -74,6 +77,8 @@ def run_tts_generation(args) -> None:
|
||||
"voice": args.speaker,
|
||||
"response_format": args.response_format,
|
||||
}
|
||||
if args.sample_rate is not None:
|
||||
payload["sample_rate"] = args.sample_rate
|
||||
|
||||
# Add optional parameters
|
||||
if args.instructions:
|
||||
@@ -246,6 +251,13 @@ def parse_args():
|
||||
choices=["wav", "mp3", "flac", "pcm", "aac", "opus"],
|
||||
help="Audio output format (default: wav)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sample-rate",
|
||||
type=int,
|
||||
default=None,
|
||||
choices=[8000, 24000],
|
||||
help="Output sample rate in Hz (default: model native)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
# Boogu-Image
|
||||
|
||||
> Text-to-image and image-editing online serving (Boogu-Image-0.1-Base / -Edit)
|
||||
> Text-to-image and image-editing online serving
|
||||
(Boogu-Image-0.1-Base / -Edit / -Edit-Turbo)
|
||||
|
||||
## Summary
|
||||
|
||||
- Vendor: Boogu
|
||||
- Model: `Boogu/Boogu-Image-0.1-Base` (text-to-image), `Boogu/Boogu-Image-0.1-Edit` (image editing)
|
||||
- Model: `Boogu/Boogu-Image-0.1-Base` (text-to-image),
|
||||
`Boogu/Boogu-Image-0.1-Edit` (image editing), and
|
||||
`Boogu/Boogu-Image-0.1-Edit-Turbo` (four-step image editing)
|
||||
- Task: Text-to-image generation and text-guided image editing (TI2I)
|
||||
- Mode: Online serving with the OpenAI-compatible API
|
||||
- Maintainer: Community
|
||||
@@ -13,19 +16,23 @@
|
||||
## When to use this recipe
|
||||
|
||||
Use this recipe when you want a known-good starting point for serving
|
||||
`Boogu/Boogu-Image-0.1-Base` with vLLM-Omni's native pipeline (no
|
||||
`Boogu/Boogu-Image-0.1-Base`, `Boogu/Boogu-Image-0.1-Edit`, or
|
||||
`Boogu/Boogu-Image-0.1-Edit-Turbo` with vLLM-Omni's native pipeline (no
|
||||
`--diffusion-load-format diffusers`, and the upstream `boogu` package is not
|
||||
required).
|
||||
|
||||
Boogu-Image-0.1 is an Apache-2.0 unified image generation and editing model
|
||||
family. This recipe covers the Base text-to-image checkpoint, which pairs a
|
||||
Qwen3-VL multimodal encoder with a Diffusion Transformer (DiT) and a flow-match
|
||||
Euler scheduler with time-shift. It handles photorealistic generation and
|
||||
Chinese/English text rendering.
|
||||
family. The Base text-to-image checkpoint pairs a Qwen3-VL multimodal encoder
|
||||
with a Diffusion Transformer (DiT) and a flow-match Euler scheduler with
|
||||
time-shift. It handles photorealistic generation and Chinese/English text
|
||||
rendering. The Edit and Edit-Turbo checkpoints use the same native
|
||||
`BooguImagePipeline` for image editing.
|
||||
|
||||
## References
|
||||
|
||||
- Upstream model card: <https://huggingface.co/Boogu/Boogu-Image-0.1-Base>
|
||||
- Edit model card: <https://huggingface.co/Boogu/Boogu-Image-0.1-Edit>
|
||||
- Edit-Turbo 1K hotfix: <https://huggingface.co/Boogu/Boogu-Image-0.1-Edit-Turbo/tree/hotfix-1k-20260708>
|
||||
- Project page: <https://boogu.org>
|
||||
- GitHub: <https://github.com/boogu-project/Boogu-Image>
|
||||
- Related example: [`examples/online_serving/text_to_image/`](../../examples/online_serving/text_to_image/README.md)
|
||||
@@ -93,7 +100,7 @@ curl -s http://localhost:8091/v1/chat/completions \
|
||||
- **Memory usage:** ~34.6 GiB on GPU. Use `--vae-use-slicing --vae-use-tiling`
|
||||
to trim peak VRAM if needed.
|
||||
- **Key flags:**
|
||||
- `--omni` — enables vLLM-Omni diffusion serving.
|
||||
- `--omni` — enables vLLM-Omni diffusion serving.
|
||||
- **Guidance:** Boogu-Image uses `guidance_scale` (mapped to the upstream
|
||||
`text_guidance_scale`); the default is `4.0`. Classifier-free guidance is
|
||||
active whenever `guidance_scale > 1.0`.
|
||||
@@ -111,13 +118,13 @@ the image-editing (TI2I) path activates automatically when a request carries a
|
||||
reference image. The Base text-to-image path is unaffected (no reference image
|
||||
is sent).
|
||||
|
||||
#### Command
|
||||
### Command
|
||||
|
||||
```bash
|
||||
vllm serve Boogu/Boogu-Image-0.1-Edit --omni --port 8091
|
||||
```
|
||||
|
||||
#### Verification
|
||||
### Verification
|
||||
|
||||
Edit an image with `/v1/images/edits` (the model-card example — change a photo
|
||||
to a colored-pencil drawing). Diffusion parameters are plain multipart form
|
||||
@@ -157,14 +164,14 @@ curl -s http://localhost:8091/v1/chat/completions \
|
||||
}' | jq -r '.choices[0].message.content[0].image_url.url' | cut -d',' -f2- | base64 -d > edited.png
|
||||
```
|
||||
|
||||
#### Notes
|
||||
### Notes
|
||||
|
||||
- **Single reference image:** only one input image is supported for now (the
|
||||
upstream "Only support 1 reference image for now" limit).
|
||||
- **Guidance semantics:**
|
||||
- `guidance_scale` = text guidance (upstream `text_guidance_scale`, default
|
||||
- `guidance_scale` = text guidance (upstream `text_guidance_scale`, default
|
||||
`4.0`); `> 1.0` enables text CFG. Editing typically uses `5.0`.
|
||||
- `guidance_scale_2` = image guidance (upstream `image_guidance_scale`,
|
||||
- `guidance_scale_2` = image guidance (upstream `image_guidance_scale`,
|
||||
default `1.0` = off). Setting it `> 1.0` enables the double-guidance path
|
||||
(3 model predictions per step), steering more strongly toward the reference
|
||||
image.
|
||||
@@ -178,3 +185,69 @@ curl -s http://localhost:8091/v1/chat/completions \
|
||||
supports Boogu-Image-Edit directly
|
||||
(`--model Boogu/Boogu-Image-0.1-Edit --guidance-scale 5.0`, optional
|
||||
`--guidance-scale-2`).
|
||||
|
||||
## Fast image editing (Boogu-Image-0.1-Edit-Turbo)
|
||||
|
||||
Edit-Turbo is the distilled image-editing checkpoint. Its `model_index.json`
|
||||
declares `BooguImagePipeline`, so it uses the same native TI2I path as Edit.
|
||||
The upstream 1K hotfix is recommended for stable results and is pinned below
|
||||
to avoid silently loading the older checkpoint from the repository's default
|
||||
revision.
|
||||
|
||||
### Command
|
||||
|
||||
```bash
|
||||
vllm serve Boogu/Boogu-Image-0.1-Edit-Turbo \
|
||||
--omni \
|
||||
--revision hotfix-1k-20260708 \
|
||||
--port 8091
|
||||
```
|
||||
|
||||
### Verification
|
||||
|
||||
Edit an image with the distilled four-step settings:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8091/v1/images/edits \
|
||||
-F model="Boogu/Boogu-Image-0.1-Edit-Turbo" \
|
||||
-F image="@input.png" \
|
||||
-F prompt="Change the style to a colored pencil drawing." \
|
||||
-F num_inference_steps=4 \
|
||||
-F guidance_scale=1.0 \
|
||||
-F seed=42 \
|
||||
| jq -r '.data[0].b64_json' | base64 -d > edited-turbo.png
|
||||
```
|
||||
|
||||
Or via chat completions:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8091/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,<BASE64>"}},
|
||||
{"type": "text", "text": "Change the style to a colored pencil drawing."}
|
||||
]}
|
||||
],
|
||||
"extra_body": {
|
||||
"num_inference_steps": 4,
|
||||
"guidance_scale": 1.0,
|
||||
"seed": 42
|
||||
}
|
||||
}' | jq -r '.choices[0].message.content[0].image_url.url' | cut -d',' -f2- | base64 -d > edited-turbo.png
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- **Pinned revision:** use `hotfix-1k-20260708`; upstream recommends the 1K
|
||||
hotfix over the 1.5K variant for more stable results.
|
||||
- **Recommended settings:** `num_inference_steps=4` and
|
||||
`guidance_scale=1.0`. Edit-Turbo is guidance-distilled, meaning the guidance
|
||||
behavior is already baked into the distilled checkpoint. A scale of `1.0`
|
||||
avoids applying additional classifier-free guidance; unlike the regular Edit
|
||||
checkpoint, it should not be increased to `5.0`.
|
||||
- **Reference images:** the same single-reference and `align_res` behavior as
|
||||
the regular Edit checkpoint applies.
|
||||
- **Known limitations:** the same single-GPU limitations as Base and Edit apply;
|
||||
CPU offload, Cache-DiT, and multi-GPU parallelism are not yet validated.
|
||||
@@ -69,11 +69,12 @@ reference-video preparation and MP4 output.
|
||||
|
||||
For CUDA and ROCm deployments, non-streaming MP4 responses are encoded on the
|
||||
host CPU through PyAV/libx264 after generation. The response encoder selects the
|
||||
path automatically at runtime: inputs with a supported frame shape, common dtype,
|
||||
and RGB per-channel contiguous layout use direct planar frames; other inputs
|
||||
fall back to the legacy path before the PyAV container is opened. No CLI flag,
|
||||
model declaration, or user configuration is required. Streaming fMP4 output is
|
||||
unchanged.
|
||||
path automatically at runtime. The server-owned parallel converter accepts
|
||||
supported frame shapes and dtypes with either per-channel-contiguous or strided
|
||||
RGB planes, including interleaved arrays materialized by output transport.
|
||||
Standalone callers without a parallel converter retain the legacy fallback for
|
||||
strided planes. No CLI flag, model declaration, or user configuration is
|
||||
required. Streaming fMP4 output is unchanged.
|
||||
|
||||
A community benchmark on 2x Xeon 8480C reported the following comparison
|
||||
between the legacy and direct planar paths
|
||||
@@ -917,6 +918,72 @@ adapter and twice without it, then compare the four output digests. The adapter
|
||||
is bound and deterministic when each pair matches internally and the two pairs
|
||||
differ from each other.
|
||||
|
||||
### FastH3 adapter
|
||||
|
||||
[FastH3](https://haoailab.com/blogs/fasth3-preview/) is FastVideo's four-step
|
||||
DMD2 student of H3-Base. It reuses H3's text encoder, VAEs, tokenizers, and
|
||||
schedulers unchanged, replacing 49 denoiser evaluations with four. It is fused
|
||||
into the checkpoint at load time rather than switched per request, because it
|
||||
carries full-rank deltas that no LoRA layer can express.
|
||||
|
||||
The bundle publishes four variants, so download one and point `--lora-path` at
|
||||
it; the repository root is refused rather than guessed at:
|
||||
|
||||
```bash
|
||||
export FASTH3_DIR=/path/to/fasth3
|
||||
hf download FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA \
|
||||
dense-datafree/adapter_model.safetensors --local-dir "${FASTH3_DIR}"
|
||||
export FASTH3_LORA="${FASTH3_DIR}/dense-datafree/adapter_model.safetensors"
|
||||
```
|
||||
|
||||
Add `--task-type fl2va --lora-path "${FASTH3_LORA}"` to a non-offloaded server
|
||||
command. T2VA is served by the FL2VA partition, so `--task-type fl2va` is
|
||||
correct even though FastH3 preview v1 distills T2VA only. Because the adapter is
|
||||
fused, `--lora-backend` does not apply and a request carrying a `lora=` field is
|
||||
rejected rather than served without the adapter it asked for.
|
||||
|
||||
```bash
|
||||
-F 'num_inference_steps=4' \
|
||||
-F 'extra_params={"task":"t2va","duration":4.4}'
|
||||
```
|
||||
|
||||
Requests must ask for `num_inference_steps=4` and `task=t2va`: the release's five
|
||||
sigma points bound four denoiser evaluations, and that count is what the step
|
||||
scheduler admits a request on. The server denoises on the release's own ladder,
|
||||
keeping H3's per-modality shifts at the checkpoint values, so a request that
|
||||
overrides `flow_shift` or `audio_flow_shift` is rejected - it would sample the
|
||||
student at noise levels it was never distilled at.
|
||||
|
||||
Only a release that identifies itself as FastH3 is fused; any other
|
||||
`fastvideo-lora-v2` adapter stays on the dynamic LoRA route. A claimed artifact
|
||||
is then held to its own metadata: one that misdeclares its tensor counts or
|
||||
leaves a transformer block unedited is refused at startup instead of serving
|
||||
mostly base H3 weights on a four-step schedule. Offload is refused for the same
|
||||
reason - `--enable-cpu-offload`, `--enable-layerwise-offload` and
|
||||
`--enable-distributed-layerwise-offload` all bypass the fusion, so they fail fast.
|
||||
|
||||
> [!NOTE]
|
||||
> Only the **Dense / Data-Free** variant is supported today. The three VSA
|
||||
> variants carry `.set_weight` compression gates for modules that do not exist
|
||||
> in vLLM-Omni's dense H3 attention, and the release states dense attention is
|
||||
> not a supported substitute for them, so they are refused at startup rather
|
||||
> than silently run wrong. Video Sparse Attention for H3's packed
|
||||
> `[text | cond | audio | video]` sequence is follow-up work.
|
||||
|
||||
Measured on 8x NVIDIA B300 with USP8, VAE patch-parallel 8, `TRTLLM_ATTN`, at
|
||||
1344x768, 4.4 s, seed 1101, one warmup excluded and two runs recorded:
|
||||
|
||||
| Adapter | Steps | End-to-end | Diffusion engine |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| none (base H3) | 50 | 25.8 / 26.4 s | 16.22 / 16.28 s |
|
||||
| FastH3 Dense | 4 (5 sigma points) | 11.7 / 11.8 s | 2.37 / 2.36 s |
|
||||
|
||||
The denoising speedup is 6.9x. End-to-end is 2.2x because text encoding, VAE
|
||||
decoding and muxing are a fixed cost that dominates a clip this short; longer
|
||||
generations move the end-to-end figure toward the denoising one. Fusing the
|
||||
adapter does not measurably change startup: weight loading took 77.3 s with it
|
||||
against 85.8 s without.
|
||||
|
||||
## Key parameters
|
||||
|
||||
| Parameter | Recommended value | Notes |
|
||||
@@ -1075,3 +1142,4 @@ vllm serve "${MODEL_ROOT}/FL2VA" \
|
||||
- [Supported models](../../docs/models/supported_models.md)
|
||||
- [Video API](../../docs/serving/videos_api.md)
|
||||
- [Diffusion parallelism](../../docs/user_guide/diffusion/parallelism/overview.md)
|
||||
- [Temporal VAE chunk callback design](../../docs/design/feature/minimax_h3_temporal_vae_chunks.md)
|
||||
@@ -78,6 +78,7 @@ recipes/
|
||||
| [`Qwen/Qwen3-Omni.md`](./Qwen/Qwen3-Omni.md) | Online serving for multimodal chat | 1x A100 80GB |
|
||||
| [`Qwen/Qwen3-TTS.md`](./Qwen/Qwen3-TTS.md) | Text-to-speech serving (CustomVoice / VoiceDesign / Base) | 1x H100/A100 80GB / 1x AMD MI300X |
|
||||
| [`SenseNova/SenseNova-U1.md`](./SenseNova/SenseNova-U1.md) | Unified image generation and understanding | 1x or 2x H200 / 1x AMD MI300X |
|
||||
| [`SenseNova/SenseNova-U1.5.md`](./SenseNova/SenseNova-U1.5.md) | Unified image generation and understanding, with an 8-step distilled LoRA | 1x A800 80GB |
|
||||
| [`StabilityAI/Stable-Audio-Open.md`](./StabilityAI/Stable-Audio-Open.md) | Offline + online text-to-audio generation (Stable Audio Open) | 1x RTX 4090 24GB / 1x AMD MI300X |
|
||||
| [`Tencent/Covo-Audio-Chat.md`](./Tencent/Covo-Audio-Chat.md) | Online serving for audio chat | 1x A100 80GB |
|
||||
| [`Tencent/HunyuanImage-3.0-Instruct.md`](./Tencent/HunyuanImage-3.0-Instruct.md) | DiT-only text-to-image serving and benchmark, including ModelOpt mixed FP8/NVFP4 | 4x H100/H800 80GB / 2x B200 |
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# SenseNova-U1.5
|
||||
|
||||
> Unified image generation and understanding, with an official 8-step distilled LoRA
|
||||
|
||||
## Summary
|
||||
|
||||
- Vendor: SenseNova
|
||||
- Model: `sensenova/SenseNova-U1.5-8B-MoT`
|
||||
- LoRA: `sensenova/SenseNova-U1.5-8B-MoT-LoRAs` (`SenseNova-U1.5-8B-MoT-LoRA-8step.safetensors`)
|
||||
- Task: text2img, img2img, img2text (visual understanding), text2text (chat)
|
||||
- Mode: Offline inference, Online serving (OpenAI-compatible API)
|
||||
- Maintainer: Community
|
||||
|
||||
## When to use this recipe
|
||||
|
||||
U1.5 runs on the same `SenseNovaU1Pipeline` as U1 — the checkpoint keeps
|
||||
`model_type: neo_chat`, so it resolves without `--model-class-name`. Relative to U1 the
|
||||
config flips two fields, `use_pixel_head` to `true` (the flow-matching head becomes a
|
||||
`ConvDecoder`) and `noise_scale_max_value` from 8.0 to 16.0; both are already read by
|
||||
`SenseNovaU1Config`. The checkpoint is 13 shards / 50.2 GB on disk, 30.3 GB of that fp32, and
|
||||
loads to roughly 34 GB in bf16.
|
||||
|
||||
Use this recipe for U1.5 specifically, including its distilled few-step LoRA. For U1 see
|
||||
[SenseNova-U1](SenseNova-U1.md).
|
||||
|
||||
## Hardware Support
|
||||
|
||||
### GPU
|
||||
|
||||
#### 1x A800 80GB
|
||||
|
||||
- 1x NVIDIA A800 80GB PCIe with an Intel Xeon Gold 6336Y, driver 595.84, Linux
|
||||
- Python 3.12.13, vLLM 0.28.0, torch 2.13.0+cu130, CUDA 13.0, diffusers 0.40.0,
|
||||
transformers 5.14.1, flashinfer 0.6.16.post3, BF16, TP=1
|
||||
- Peak GPU memory: 34.3 GB at 1024x1024, 36.4 GB at 1536x2720
|
||||
|
||||
##### Text-to-Image
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/text_to_image/text_to_image.py \
|
||||
--model sensenova/SenseNova-U1.5-8B-MoT \
|
||||
--prompt "Close portrait of an elderly woman by a farmhouse window, warm natural light." \
|
||||
--width 1024 --height 1024 \
|
||||
--seed 42 --num-inference-steps 50 --cfg-scale 4.0 \
|
||||
--extra-body '{"think": false, "cfg_norm": "none", "timestep_shift": 3.0, "t_eps": 0.02}' \
|
||||
--output sensenova_u15_t2i.png
|
||||
```
|
||||
|
||||
Think mode (`"think": true`) is recommended for higher image quality.
|
||||
|
||||
##### Image-to-Image Editing
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/image_to_image/image_edit.py \
|
||||
--model sensenova/SenseNova-U1.5-8B-MoT \
|
||||
--prompt "Turn this into an oil painting" \
|
||||
--image input.png --resolution 1024 \
|
||||
--seed 42 --num-inference-steps 25 --cfg-scale 4.0 \
|
||||
--extra-args '{"think": true, "img_cfg_scale": 1.0, "cfg_norm": "none", "timestep_shift": 3.0}' \
|
||||
--output sensenova_u15_edit.png
|
||||
```
|
||||
|
||||
##### 8-step distilled LoRA
|
||||
|
||||
The distilled LoRA is fused into the generation tower at load time:
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/text_to_image/text_to_image.py \
|
||||
--model sensenova/SenseNova-U1.5-8B-MoT \
|
||||
--lora-path SenseNova-U1.5-8B-MoT-LoRA-8step.safetensors --lora-backend distill \
|
||||
--prompt "Close portrait of an elderly woman by a farmhouse window, warm natural light." \
|
||||
--width 1024 --height 1024 \
|
||||
--seed 42 --num-inference-steps 8 --cfg-scale 1.0 \
|
||||
--extra-body '{"think": false, "cfg_norm": "none", "timestep_shift": 3.0, "t_eps": 0.02}' \
|
||||
--output sensenova_u15_lora8.png
|
||||
```
|
||||
|
||||
**Use `--cfg-scale 1.0` with this LoRA.** It is distilled with DMD and runs without
|
||||
classifier-free guidance; the default `4.0` applies guidance twice and produces a
|
||||
blown-out, posterised image.
|
||||
|
||||
##### Online serving
|
||||
|
||||
```bash
|
||||
vllm serve sensenova/SenseNova-U1.5-8B-MoT --omni --port 8091
|
||||
|
||||
python examples/online_serving/sensenova_u1/openai_chat_client.py \
|
||||
-s http://127.0.0.1:8091 -m img2text -i input.png -p "Describe this image."
|
||||
```
|
||||
|
||||
`-s` takes the base URL; the client appends `/v1` itself.
|
||||
|
||||
#### Measured latency (1x A800 80GB, 25 steps, median of 3 after a warmup)
|
||||
|
||||
| Resolution | Step latency | Total |
|
||||
| --- | --- | --- |
|
||||
| 1024x1024 | 209.5 ms | 5.24 s |
|
||||
| 1536x1536 | 458.5 ms | 11.46 s |
|
||||
|
||||
At 1536x2720 with 50 steps, end-to-end is 43.0 s.
|
||||
|
||||
#### Measured latency (1x NVIDIA H200 139GB, single run, no warmup)
|
||||
|
||||
Reported by @hsliuustc0106 against PR head `29c090d`, on one reserved CUDA device.
|
||||
|
||||
- Python 3.12.13, vLLM 0.28.0, torch 2.13.0+cu130, CUDA 13.0, diffusers 0.40.0,
|
||||
transformers 5.14.1, flashinfer 0.6.16.post3, BF16, TP=1
|
||||
- `sensenova/SenseNova-U1.5-8B-MoT` with `SenseNova-U1.5-8B-MoT-LoRA-8step.safetensors`,
|
||||
seed 42, 1024x1024
|
||||
|
||||
| Case | Stage latency | Peak GPU memory |
|
||||
| --- | --- | --- |
|
||||
| 50 steps, cfg 4.0, think off | 9017 ms | 34384 MiB |
|
||||
| 8 steps, cfg 1.0, no LoRA control | 624.00 ms, 77.85 ms/step | 34376 MiB |
|
||||
| 8 steps, cfg 1.0, distilled LoRA | 623.85 ms, 77.98 ms/step | 34364 MiB |
|
||||
| 50 steps, cfg 4.0, think on, `VLLM_OMNI_SENSENOVA_PAGED_DECODE=1` | 81622 ms | 34594 MiB |
|
||||
|
||||
Each row runs the Text-to-Image command above with `--num-inference-steps` and
|
||||
`--cfg-scale` as listed and `think` set through `--extra-body`; the LoRA row adds
|
||||
`--lora-path` and `--lora-backend distill`.
|
||||
|
||||
Single runs without a preceding warmup, so the think-on row carries the first-request
|
||||
compile rather than a steady state. The distilled LoRA fused into 168 parameters, and its
|
||||
image differs from the same-seed no-LoRA control in 1,048,574 of 1,048,576 pixels, MAE 58.62.
|
||||
|
||||
```bash
|
||||
pytest -m "cpu and not cuda" tests/diffusion/models/sensenova_u1/ tests/diffusion/lora/test_loader.py -q
|
||||
pytest -m "cpu and not cuda" tests/config/test_environment_variables.py tests/diffusion/test_diffusion_worker.py -q
|
||||
pytest -m cuda tests/diffusion/models/sensenova_u1/test_sensenova_u1_attention_gqa.py \
|
||||
tests/diffusion/models/sensenova_u1/test_sensenova_u1_paged_decode.py -q --tb=short
|
||||
```
|
||||
|
||||
71, 13 and 9 passed. Serving reached `/health`; `img2text` returned a 1921-character
|
||||
description and `text2text` the expected answer.
|
||||
|
||||
#### Verification
|
||||
|
||||
```bash
|
||||
pytest -q tests/diffusion/models/sensenova_u1/
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Both `use_pixel_head` and `noise_scale_max_value` come from `config.json`; no flag is needed.
|
||||
- The checkpoint carries no `configuration_neo_chat.py`, so the loader logs a few
|
||||
"does not appear to have a file named configuration_neo_chat.py" errors during startup and
|
||||
then proceeds on the in-tree config. Generation is unaffected.
|
||||
- The LoRA targets the generation tower only (`*_mot_gen`); understanding-tower weights are
|
||||
untouched.
|
||||
- Autoregressive decode (think, text-to-text and image-to-text) runs on a paged K/V cache under
|
||||
a captured CUDA graph. It falls back to the ordinary cache when the device or the bundled
|
||||
`flash_attn_varlen_func` cannot support it; set `VLLM_OMNI_SENSENOVA_PAGED_DECODE=0` to force
|
||||
that fallback.
|
||||
- The first request after startup costs about 0.7 s more than the steady state whether the paged
|
||||
path is on or off. Measured on one A800 with the inductor, triton and vLLM compile caches all
|
||||
cleared, median of three runs: 718 ms above steady with the path on, 679 ms with it off.
|
||||
- Each request captures its own graphs, and a think request captures twice because the sequence
|
||||
grows past the 512 bucket, so the capture cost is paid per request rather than once at
|
||||
startup.
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Benchmark legacy and automatic MP4 response encoding on synthetic input."""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -11,13 +11,16 @@ import statistics
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import numpy as np
|
||||
|
||||
from vllm_omni.entrypoints.openai.video_api_utils import (
|
||||
_encode_video_bytes,
|
||||
_encode_video_bytes_legacy,
|
||||
_PlanarFrameConverter,
|
||||
)
|
||||
|
||||
|
||||
@@ -42,12 +45,19 @@ def _build_inputs(
|
||||
fps: int,
|
||||
audio_sample_rate: int,
|
||||
seed: int,
|
||||
input_layout: str = "planar-backed",
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
rng = np.random.default_rng(seed)
|
||||
planar = rng.random((3, frames, height, width), dtype=np.float32)
|
||||
video = planar.transpose(1, 2, 3, 0)
|
||||
if not all(video[..., channel].flags.c_contiguous for channel in range(3)):
|
||||
if input_layout == "interleaved":
|
||||
video = np.ascontiguousarray(video)
|
||||
if not video.flags.c_contiguous:
|
||||
raise RuntimeError("synthetic video is not C-contiguous")
|
||||
elif input_layout == "planar-backed" and not all(video[..., channel].flags.c_contiguous for channel in range(3)):
|
||||
raise RuntimeError("synthetic video does not expose contiguous channel planes")
|
||||
elif input_layout != "planar-backed":
|
||||
raise ValueError(f"unsupported input layout: {input_layout}")
|
||||
|
||||
sample_count = max(1, round(frames / fps * audio_sample_rate))
|
||||
timeline = np.arange(sample_count, dtype=np.float32) / audio_sample_rate
|
||||
@@ -86,8 +96,8 @@ def _measure(
|
||||
|
||||
def _summarize(records: list[dict[str, object]], label: str) -> dict[str, object]:
|
||||
selected = [record for record in records if record["label"] == label]
|
||||
wall_values = [float(record["wall_ms"]) for record in selected]
|
||||
cpu_values = [float(record["process_cpu_ms"]) for record in selected]
|
||||
wall_values = [cast(float, record["wall_ms"]) for record in selected]
|
||||
cpu_values = [cast(float, record["process_cpu_ms"]) for record in selected]
|
||||
return {
|
||||
"runs": len(selected),
|
||||
"wall_ms": {
|
||||
@@ -113,6 +123,16 @@ def _parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--warmup", type=int, default=1)
|
||||
parser.add_argument("--rounds", type=_positive_int, default=3)
|
||||
parser.add_argument("--seed", type=int, default=20260817)
|
||||
parser.add_argument(
|
||||
"--input-layout",
|
||||
choices=("planar-backed", "interleaved"),
|
||||
default="planar-backed",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--frame-conversion-workers",
|
||||
type=_positive_int,
|
||||
default=8,
|
||||
)
|
||||
parser.add_argument("--output-json", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.warmup < 0:
|
||||
@@ -129,33 +149,41 @@ def main() -> None:
|
||||
fps=args.fps,
|
||||
audio_sample_rate=args.audio_sample_rate,
|
||||
seed=args.seed,
|
||||
input_layout=args.input_layout,
|
||||
)
|
||||
baseline = BenchmarkVariant("legacy", _encode_video_bytes_legacy)
|
||||
candidate = BenchmarkVariant("automatic", _encode_video_bytes)
|
||||
converter = _PlanarFrameConverter(max_workers=args.frame_conversion_workers)
|
||||
candidate = BenchmarkVariant(
|
||||
"automatic",
|
||||
partial(_encode_video_bytes, frame_converter=converter),
|
||||
)
|
||||
|
||||
for _ in range(args.warmup):
|
||||
for variant in (baseline, candidate):
|
||||
_measure(
|
||||
variant,
|
||||
video=video,
|
||||
audio=audio,
|
||||
fps=args.fps,
|
||||
audio_sample_rate=args.audio_sample_rate,
|
||||
)
|
||||
try:
|
||||
for _ in range(args.warmup):
|
||||
for variant in (baseline, candidate):
|
||||
_measure(
|
||||
variant,
|
||||
video=video,
|
||||
audio=audio,
|
||||
fps=args.fps,
|
||||
audio_sample_rate=args.audio_sample_rate,
|
||||
)
|
||||
|
||||
records: list[dict[str, object]] = []
|
||||
for round_index in range(args.rounds):
|
||||
order = (baseline, candidate) if round_index % 2 == 0 else (candidate, baseline)
|
||||
for variant in order:
|
||||
_, record = _measure(
|
||||
variant,
|
||||
video=video,
|
||||
audio=audio,
|
||||
fps=args.fps,
|
||||
audio_sample_rate=args.audio_sample_rate,
|
||||
)
|
||||
record["round"] = round_index + 1
|
||||
records.append(record)
|
||||
records: list[dict[str, object]] = []
|
||||
for round_index in range(args.rounds):
|
||||
order = (baseline, candidate) if round_index % 2 == 0 else (candidate, baseline)
|
||||
for variant in order:
|
||||
_, record = _measure(
|
||||
variant,
|
||||
video=video,
|
||||
audio=audio,
|
||||
fps=args.fps,
|
||||
audio_sample_rate=args.audio_sample_rate,
|
||||
)
|
||||
record["round"] = round_index + 1
|
||||
records.append(record)
|
||||
finally:
|
||||
converter.shutdown()
|
||||
|
||||
output_hashes = {str(record["output_sha256"]) for record in records}
|
||||
if len(output_hashes) != 1:
|
||||
@@ -165,8 +193,8 @@ def main() -> None:
|
||||
paired_savings_percent = []
|
||||
for round_index in range(args.rounds):
|
||||
pair = [record for record in records if record["round"] == round_index + 1]
|
||||
baseline_ms = float(next(record["wall_ms"] for record in pair if record["label"] == baseline.label))
|
||||
candidate_ms = float(next(record["wall_ms"] for record in pair if record["label"] == candidate.label))
|
||||
baseline_ms = cast(float, next(record["wall_ms"] for record in pair if record["label"] == baseline.label))
|
||||
candidate_ms = cast(float, next(record["wall_ms"] for record in pair if record["label"] == candidate.label))
|
||||
paired_savings_ms.append(baseline_ms - candidate_ms)
|
||||
paired_savings_percent.append((baseline_ms - candidate_ms) / baseline_ms * 100)
|
||||
|
||||
@@ -180,6 +208,8 @@ def main() -> None:
|
||||
"warmup": args.warmup,
|
||||
"rounds": args.rounds,
|
||||
"seed": args.seed,
|
||||
"input_layout": args.input_layout,
|
||||
"frame_conversion_workers": args.frame_conversion_workers,
|
||||
"video_shape": list(video.shape),
|
||||
"video_strides": list(video.strides),
|
||||
"codec_options": {"preset": "ultrafast", "threads": "0"},
|
||||
|
||||
@@ -9,23 +9,98 @@ import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from argparse import Namespace
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from vllm.benchmarks.lib.endpoint_request_func import RequestFuncInput
|
||||
|
||||
from vllm_omni.benchmarks.data_modules.seed_tts_dataset import (
|
||||
SeedTTSSampleRequest,
|
||||
SeedTTSTextSampleRequest,
|
||||
)
|
||||
from vllm_omni.benchmarks.patch.patch import (
|
||||
MixRequestFuncOutput,
|
||||
_apply_stage0_token_timings,
|
||||
_attach_seed_tts_to_request_func_input,
|
||||
async_request_openai_chat_omni_completions,
|
||||
async_request_openai_realtime_duplex,
|
||||
should_request_stage_metrics,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.client import RealtimeEventCollector
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.benchmark, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _seed_tts_request_func_input() -> RequestFuncInput:
|
||||
return RequestFuncInput(
|
||||
model="test-model",
|
||||
model_name="test-model",
|
||||
prompt="target text",
|
||||
api_url="http://test.com/v1/chat/completions",
|
||||
prompt_len=2,
|
||||
output_len=20,
|
||||
extra_body={"modalities": ["text", "audio"]},
|
||||
)
|
||||
|
||||
|
||||
def test_seed_tts_chat_request_carries_reference_audio_once() -> None:
|
||||
reference_audio_url = "data:audio/wav;base64,AAAA"
|
||||
sample = SeedTTSSampleRequest(
|
||||
prompt="target text",
|
||||
prompt_len=2,
|
||||
expected_output_len=20,
|
||||
multi_modal_data=None,
|
||||
seed_tts_system_prompt="Clone the supplied voice.",
|
||||
seed_tts_speech_extra={
|
||||
"ref_audio": reference_audio_url,
|
||||
"ref_text": "reference text",
|
||||
"task_type": "Base",
|
||||
},
|
||||
)
|
||||
request_func_input = _seed_tts_request_func_input()
|
||||
|
||||
_attach_seed_tts_to_request_func_input(sample, request_func_input)
|
||||
|
||||
assert request_func_input.omni_chat_messages == [
|
||||
{
|
||||
"role": "system",
|
||||
"content": [{"type": "text", "text": "Clone the supplied voice."}],
|
||||
},
|
||||
{"role": "user", "content": [{"type": "text", "text": "target text"}]},
|
||||
]
|
||||
assert request_func_input.extra_body["ref_audio"] == reference_audio_url
|
||||
serialized_request = json.dumps(
|
||||
{"messages": request_func_input.omni_chat_messages, **request_func_input.extra_body},
|
||||
)
|
||||
assert serialized_request.count(reference_audio_url) == 1
|
||||
|
||||
|
||||
def test_seed_tts_text_chat_messages_do_not_add_reference_audio() -> None:
|
||||
sample = SeedTTSTextSampleRequest(
|
||||
prompt="target text",
|
||||
prompt_len=2,
|
||||
expected_output_len=20,
|
||||
multi_modal_data=None,
|
||||
seed_tts_system_prompt="Use the configured voice.",
|
||||
seed_tts_speech_extra=None,
|
||||
)
|
||||
request_func_input = _seed_tts_request_func_input()
|
||||
|
||||
_attach_seed_tts_to_request_func_input(sample, request_func_input)
|
||||
|
||||
assert getattr(request_func_input, "seed_tts_row", False) is True
|
||||
assert request_func_input.omni_chat_messages == [
|
||||
{
|
||||
"role": "system",
|
||||
"content": [{"type": "text", "text": "Use the configured voice."}],
|
||||
},
|
||||
{"role": "user", "content": [{"type": "text", "text": "target text"}]},
|
||||
]
|
||||
assert "ref_audio" not in request_func_input.extra_body
|
||||
|
||||
|
||||
class MockResponse:
|
||||
"""Mock aiohttp response for testing"""
|
||||
|
||||
@@ -274,6 +349,107 @@ def create_sse_chunk(data_dict):
|
||||
return f"data: {json.dumps(data_dict)}\n\n".encode()
|
||||
|
||||
|
||||
def test_chat_text_timing_metrics_request_stage_metrics():
|
||||
args = Namespace(
|
||||
backend="openai-chat-omni",
|
||||
percentile_metrics="ttft,tpot,itl,e2el",
|
||||
print_stage=False,
|
||||
extra_body={},
|
||||
)
|
||||
|
||||
assert should_request_stage_metrics(args) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bundled_first_text_chunk_uses_stage0_token_timings(mocker: MockerFixture):
|
||||
"""Engine timings recover TPOT when every text token arrives together."""
|
||||
request_input = RequestFuncInput(
|
||||
model="test-model",
|
||||
model_name="test-model",
|
||||
prompt="test prompt",
|
||||
api_url="http://test.com/v1/chat/completions",
|
||||
prompt_len=10,
|
||||
output_len=20,
|
||||
)
|
||||
chunks = [
|
||||
create_sse_chunk(
|
||||
{
|
||||
"choices": [{"delta": {"content": "ABCD"}}],
|
||||
"modality": "text",
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 4, "total_tokens": 14},
|
||||
"metrics": {
|
||||
"stage_metrics": {
|
||||
"0": {
|
||||
"num_tokens_out": 4,
|
||||
"vllm_itls_ms": [10.0, 11.0, 12.0],
|
||||
"vllm_tpot_ms": 11.0,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
mock_response = MockResponse(200, chunks)
|
||||
mock_session = mocker.AsyncMock()
|
||||
mock_session.post = mocker.MagicMock(return_value=mock_response)
|
||||
|
||||
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
|
||||
|
||||
assert output.success is True
|
||||
assert output.output_tokens == 4
|
||||
assert output.itl == pytest.approx([0.010, 0.011, 0.012])
|
||||
assert output.text_latency - output.ttft == pytest.approx(0.033)
|
||||
assert output.tpot_measured is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_positive_client_text_timings_take_precedence_over_stage0(mocker: MockerFixture):
|
||||
request_input = RequestFuncInput(
|
||||
model="test-model",
|
||||
model_name="test-model",
|
||||
prompt="test prompt",
|
||||
api_url="http://test.com/v1/chat/completions",
|
||||
prompt_len=10,
|
||||
output_len=20,
|
||||
)
|
||||
chunks = [
|
||||
create_sse_chunk(
|
||||
{
|
||||
"choices": [{"delta": {"content": "A"}}],
|
||||
"modality": "text",
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 1, "total_tokens": 11},
|
||||
}
|
||||
),
|
||||
create_sse_chunk(
|
||||
{
|
||||
"choices": [{"delta": {"content": "B"}}],
|
||||
"modality": "text",
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12},
|
||||
"metrics": {
|
||||
"stage_metrics": {
|
||||
"0": {
|
||||
"num_tokens_out": 2,
|
||||
"vllm_itls_ms": [1000.0],
|
||||
"vllm_tpot_ms": 1000.0,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
mock_response = MockResponse(200, chunks, delay_between_chunks=0.01)
|
||||
mock_session = mocker.AsyncMock()
|
||||
mock_session.post = mocker.MagicMock(return_value=mock_response)
|
||||
|
||||
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
|
||||
|
||||
assert output.success is True
|
||||
assert len(output.itl) == 1
|
||||
assert 0.0 < output.itl[0] < 0.1
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# output_tokens Tests
|
||||
# ============================================================================
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Tests for SeedTTSTextDataset, SeedTTSTextSampleRequest, SeedTTSDesignDataset,
|
||||
and SeedTTSDesignSampleRequest.
|
||||
|
||||
@@ -234,34 +237,6 @@ def test_seed_tts_design_dataset_rejects_missing_description(seed_tts_design_roo
|
||||
assert req.seed_tts_utterance_id == "ok"
|
||||
|
||||
|
||||
def test_attach_sets_seed_tts_row_even_without_extra_body():
|
||||
"""seed_tts_row=True must be set for SeedTTSTextSampleRequest (no extra body)."""
|
||||
from vllm_omni.benchmarks.data_modules.seed_tts_dataset import SeedTTSTextSampleRequest
|
||||
|
||||
req = SeedTTSTextSampleRequest(
|
||||
prompt="hello world",
|
||||
prompt_len=2,
|
||||
expected_output_len=100,
|
||||
multi_modal_data=None,
|
||||
request_id="test-0",
|
||||
seed_tts_speech_extra=None,
|
||||
seed_tts_ref_wav_path="",
|
||||
)
|
||||
assert req.seed_tts_speech_extra is None
|
||||
assert req.seed_tts_ref_wav_path == ""
|
||||
# The fix ensures that even with speech_extra=None, the function
|
||||
# sets seed_tts_row=True. We verify the source code has the fix.
|
||||
import inspect
|
||||
|
||||
import vllm_omni.benchmarks.patch.patch as patch_mod
|
||||
|
||||
src = inspect.getsource(patch_mod._attach_seed_tts_to_request_func_input)
|
||||
# seed_tts_row must be set BEFORE the 'if not ex: return' check
|
||||
row_pos = src.index("seed_tts_row")
|
||||
not_ex_pos = src.index("if not ex:")
|
||||
assert row_pos < not_ex_pos, "seed_tts_row must be set before 'if not ex: return'"
|
||||
|
||||
|
||||
def test_seed_tts_whisper_transcribe_passes_attention_mask(monkeypatch):
|
||||
from vllm_omni.benchmarks.data_modules import seed_tts_eval
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from pathlib import Path
|
||||
from shlex import split
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
AMD_MERGE_PIPELINE = Path(".buildkite/amd/test-amd-merge.yml")
|
||||
|
||||
|
||||
def _find_step(label: str) -> dict:
|
||||
pipeline = yaml.safe_load(AMD_MERGE_PIPELINE.read_text(encoding="utf-8"))
|
||||
|
||||
def walk(steps: list[dict]) -> dict | None:
|
||||
for step in steps:
|
||||
if step.get("label") == label:
|
||||
return step
|
||||
if nested := walk(step.get("steps", [])):
|
||||
return nested
|
||||
return None
|
||||
|
||||
step = walk(pipeline.get("steps", []))
|
||||
assert step is not None, f"missing AMD pipeline step: {label}"
|
||||
return step
|
||||
|
||||
|
||||
def test_qwen3_tts_base_preserves_advanced_model_arguments() -> None:
|
||||
step = _find_step("Qwen3-TTS Base E2E Test")
|
||||
commands = step["commands"]
|
||||
|
||||
assert all("bash -c" not in command for command in commands)
|
||||
pytest_command = next(command for command in commands if "pytest" in command)
|
||||
argv = split(pytest_command)
|
||||
|
||||
marker_index = argv.index("-m")
|
||||
run_level_index = argv.index("--run-level")
|
||||
assert argv[marker_index + 1] == "advanced_model and cuda"
|
||||
assert argv[run_level_index + 1] == "advanced_model"
|
||||
@@ -41,6 +41,11 @@ from vllm_omni.engine.arg_utils import SHARED_FIELDS, internal_blacklist_keys
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
SINGLE_STAGE_PIPE_CFG = (StagePipelineConfig(stage_id=0, model_stage="a", final_output=True),)
|
||||
# Validation strings to match against in post init
|
||||
NO_STAGES_MATCH_STR = "no stages"
|
||||
NO_TERMINAL_STAGE_MATCH_STR = "No terminal stage"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stable_test_platform(monkeypatch):
|
||||
@@ -618,6 +623,7 @@ class TestPipelineDiscovery:
|
||||
p = PipelineConfig(
|
||||
model_type="custom_collide",
|
||||
hf_architectures=("SomeCollidingArch",),
|
||||
stages=SINGLE_STAGE_PIPE_CFG,
|
||||
)
|
||||
assert p.hf_architectures == ("SomeCollidingArch",)
|
||||
|
||||
@@ -639,33 +645,63 @@ class TestStagePipelineConfig:
|
||||
|
||||
|
||||
class TestPipelineConfigNew:
|
||||
def test_frozen(self):
|
||||
p = PipelineConfig(model_type="t", model_arch="A")
|
||||
with pytest.raises(AttributeError):
|
||||
p.model_type = "changed"
|
||||
def test_simple_init(self):
|
||||
# Ensure pipeline config validates in post init.
|
||||
PipelineConfig(
|
||||
model_type="t",
|
||||
model_arch="A",
|
||||
stages=SINGLE_STAGE_PIPE_CFG,
|
||||
)
|
||||
|
||||
def test_validate_valid(self):
|
||||
def test_frozen(self):
|
||||
p = PipelineConfig(
|
||||
model_type="t",
|
||||
model_arch="A",
|
||||
stages=SINGLE_STAGE_PIPE_CFG,
|
||||
)
|
||||
with pytest.raises(AttributeError):
|
||||
p.model_type = "changed"
|
||||
|
||||
def test_validate_no_stages(self):
|
||||
with pytest.raises(ValueError, match=NO_STAGES_MATCH_STR):
|
||||
PipelineConfig(model_type="t", model_arch="A")
|
||||
|
||||
def test_validate_no_terminal_stage(self):
|
||||
"""A pipeline with no ``final_output`` stage can never emit a result."""
|
||||
with pytest.raises(ValueError, match=NO_TERMINAL_STAGE_MATCH_STR):
|
||||
PipelineConfig(
|
||||
model_type="t",
|
||||
model_arch="A",
|
||||
stages=(
|
||||
StagePipelineConfig(stage_id=0, model_stage="a"),
|
||||
StagePipelineConfig(stage_id=1, model_stage="b", input_sources=(0,)),
|
||||
),
|
||||
)
|
||||
|
||||
def test_pipeline_can_have_final_output_in_any_stage(self):
|
||||
"""Ensure any stage may carry ``final_output``."""
|
||||
PipelineConfig(
|
||||
model_type="t",
|
||||
model_arch="A",
|
||||
stages=(
|
||||
StagePipelineConfig(stage_id=0, model_stage="a"),
|
||||
StagePipelineConfig(stage_id=0, model_stage="a", final_output=True),
|
||||
StagePipelineConfig(stage_id=1, model_stage="b", input_sources=(0,)),
|
||||
),
|
||||
)
|
||||
assert p.validate() == []
|
||||
|
||||
def test_validate_no_stages(self):
|
||||
p = PipelineConfig(model_type="t", model_arch="A")
|
||||
assert any("no stages" in e.lower() for e in p.validate())
|
||||
|
||||
|
||||
class TestPipelineRegistration:
|
||||
def test_resolve_pipeline_prefers_deploy_pipeline_key(self, clean_pipeline_registry, tmp_path):
|
||||
deploy_key = "deploy_selected_pipeline"
|
||||
model_type_key = "hf_model_type_pipeline"
|
||||
deploy_pipe = PipelineConfig(model_type=deploy_key)
|
||||
model_type_pipe = PipelineConfig(model_type=model_type_key)
|
||||
deploy_pipe = PipelineConfig(
|
||||
model_type=deploy_key,
|
||||
stages=SINGLE_STAGE_PIPE_CFG,
|
||||
)
|
||||
model_type_pipe = PipelineConfig(
|
||||
model_type=model_type_key,
|
||||
stages=SINGLE_STAGE_PIPE_CFG,
|
||||
)
|
||||
register_pipeline(deploy_pipe)
|
||||
register_pipeline(model_type_pipe)
|
||||
|
||||
@@ -691,7 +727,11 @@ class TestPipelineRegistration:
|
||||
tmp_path,
|
||||
):
|
||||
model_type_key = "registered_model_type_pipeline"
|
||||
register_pipeline(PipelineConfig(model_type=model_type_key))
|
||||
pipeline_cfg = PipelineConfig(
|
||||
model_type=model_type_key,
|
||||
stages=SINGLE_STAGE_PIPE_CFG,
|
||||
)
|
||||
register_pipeline(pipeline_cfg)
|
||||
|
||||
deploy_path = tmp_path / "deploy.yaml"
|
||||
deploy_path.write_text("pipeline: missing_pipeline\n", encoding="utf-8")
|
||||
@@ -726,6 +766,7 @@ class TestPipelineRegistration:
|
||||
pipe_cfg = PipelineConfig(
|
||||
model_type=pipeline_key,
|
||||
hf_architectures=("ArchitectureFallbackForTest",),
|
||||
stages=SINGLE_STAGE_PIPE_CFG,
|
||||
)
|
||||
register_pipeline(pipe_cfg)
|
||||
|
||||
@@ -756,15 +797,18 @@ class TestPipelineRegistration:
|
||||
model_type="rejected_by_predicate",
|
||||
hf_architectures=(shared_arch,),
|
||||
hf_config_predicate=rejecting_predicate,
|
||||
stages=SINGLE_STAGE_PIPE_CFG,
|
||||
)
|
||||
raises_cfg = PipelineConfig(
|
||||
model_type="raises_in_predicate",
|
||||
hf_architectures=(shared_arch,),
|
||||
hf_config_predicate=raising_predicate,
|
||||
stages=SINGLE_STAGE_PIPE_CFG,
|
||||
)
|
||||
accept_cfg = PipelineConfig(
|
||||
model_type="accepted_by_predicate",
|
||||
hf_architectures=(shared_arch,),
|
||||
stages=SINGLE_STAGE_PIPE_CFG,
|
||||
)
|
||||
register_pipeline(reject_cfg)
|
||||
register_pipeline(raises_cfg)
|
||||
@@ -798,6 +842,7 @@ class TestPipelineRegistration:
|
||||
model_type="predicate_without_arch_match",
|
||||
hf_architectures=("DifferentArchitectureForTest",),
|
||||
hf_config_predicate=predicate,
|
||||
stages=SINGLE_STAGE_PIPE_CFG,
|
||||
)
|
||||
register_pipeline(pipe_cfg)
|
||||
|
||||
@@ -821,6 +866,7 @@ class TestPipelineRegistration:
|
||||
resolved_cfg = PipelineConfig(
|
||||
model_type="callable_resolved_pipeline",
|
||||
hf_architectures=("CallableArchitectureForTest",),
|
||||
stages=SINGLE_STAGE_PIPE_CFG,
|
||||
)
|
||||
seen_hf_configs = []
|
||||
|
||||
@@ -894,7 +940,10 @@ class TestPipelineRegistration:
|
||||
def test_pipeline_registration(self, clean_pipeline_registry):
|
||||
"""Ensure that we can register and create a custom pipeline config."""
|
||||
new_model_type = "new_model_type"
|
||||
pipe_cfg = PipelineConfig(model_type=new_model_type)
|
||||
pipe_cfg = PipelineConfig(
|
||||
model_type=new_model_type,
|
||||
stages=SINGLE_STAGE_PIPE_CFG,
|
||||
)
|
||||
|
||||
# Register the new PipelineConfig
|
||||
assert new_model_type not in OMNI_PIPELINES
|
||||
@@ -937,7 +986,10 @@ class TestPipelineRegistration:
|
||||
def custom_resolver(
|
||||
hf_config: FakeConfig,
|
||||
) -> PipelineConfig:
|
||||
return PipelineConfig(model_type=resolved_type)
|
||||
return PipelineConfig(
|
||||
model_type=resolved_type,
|
||||
stages=SINGLE_STAGE_PIPE_CFG,
|
||||
)
|
||||
|
||||
# Register the new PipelineConfig
|
||||
assert new_model_type not in OMNI_PIPELINES
|
||||
@@ -994,7 +1046,7 @@ class TestPipelineRegistration:
|
||||
pipe_cfg = PipelineConfig(
|
||||
model_type=pipeline_key,
|
||||
model_arch="DeployOnlyArch",
|
||||
stages=(StagePipelineConfig(stage_id=0, model_stage="diffusion"),),
|
||||
stages=SINGLE_STAGE_PIPE_CFG,
|
||||
)
|
||||
register_pipeline(pipe_cfg)
|
||||
|
||||
@@ -1027,7 +1079,11 @@ class TestPipelineRegistration:
|
||||
|
||||
def test_structured_path_loads_explicit_deploy_config_once(self, clean_pipeline_registry, tmp_path):
|
||||
pipeline_key = "single_load_pipeline"
|
||||
register_pipeline(PipelineConfig(model_type=pipeline_key))
|
||||
pipeline_cfg = PipelineConfig(
|
||||
model_type=pipeline_key,
|
||||
stages=SINGLE_STAGE_PIPE_CFG,
|
||||
)
|
||||
register_pipeline(pipeline_cfg)
|
||||
deploy_path = tmp_path / "single_load.yaml"
|
||||
deploy_path.write_text(f"pipeline: {pipeline_key}\n", encoding="utf-8")
|
||||
|
||||
@@ -1057,6 +1113,7 @@ class TestPipelineRegistration:
|
||||
pipeline = PipelineConfig(
|
||||
model_type="pipeline_with_default",
|
||||
default_deploy_config_name=default_name,
|
||||
stages=SINGLE_STAGE_PIPE_CFG,
|
||||
)
|
||||
|
||||
with patch(
|
||||
@@ -1076,8 +1133,16 @@ class TestPipelineRegistration:
|
||||
OmniServingCapability.COMPLETIONS,
|
||||
"pipeline_a blocks completions",
|
||||
)
|
||||
pipe_a = PipelineConfig(model_type="detect_type", endpoint_restrictions=(restriction,))
|
||||
pipe_b = PipelineConfig(model_type="override_type", endpoint_restrictions=())
|
||||
pipe_a = PipelineConfig(
|
||||
model_type="detect_type",
|
||||
endpoint_restrictions=(restriction,),
|
||||
stages=SINGLE_STAGE_PIPE_CFG,
|
||||
)
|
||||
pipe_b = PipelineConfig(
|
||||
model_type="override_type",
|
||||
endpoint_restrictions=(),
|
||||
stages=SINGLE_STAGE_PIPE_CFG,
|
||||
)
|
||||
register_pipeline(pipe_a)
|
||||
register_pipeline(pipe_b)
|
||||
|
||||
@@ -1617,6 +1682,7 @@ stages:
|
||||
model_stage="ar",
|
||||
execution_type=StageExecutionType.LLM_AR,
|
||||
requires_multimodal_data=True,
|
||||
final_output=True,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -1637,6 +1703,7 @@ stages:
|
||||
model_stage="ar",
|
||||
execution_type=StageExecutionType.LLM_AR,
|
||||
scheduler_cls=scheduler_cls,
|
||||
final_output=True,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -1882,7 +1949,6 @@ class TestQwen3OmniPipeline:
|
||||
assert isinstance(p, PipelineConfig)
|
||||
assert p.model_arch == "Qwen3OmniMoeForConditionalGeneration"
|
||||
assert len(p.stages) == 3
|
||||
assert p.validate() == []
|
||||
|
||||
def test_thinker(self):
|
||||
p = resolve_pipeline_config(
|
||||
@@ -1936,7 +2002,6 @@ class TestQwen2_5OmniPipeline:
|
||||
assert isinstance(p, PipelineConfig)
|
||||
assert p.model_arch == "Qwen2_5OmniForConditionalGeneration"
|
||||
assert len(p.stages) == 3
|
||||
assert p.validate() == []
|
||||
|
||||
def test_thinker(self):
|
||||
p = resolve_pipeline_config("qwen2_5_omni")
|
||||
@@ -1985,7 +2050,6 @@ class TestQwen3TTSPipeline:
|
||||
assert p is not None
|
||||
assert p.model_arch == "Qwen3TTSTalkerForConditionalGeneration"
|
||||
assert len(p.stages) == 2
|
||||
assert p.validate() == []
|
||||
|
||||
def test_talker_stage(self):
|
||||
p = resolve_pipeline_config("qwen3_tts")
|
||||
@@ -2065,7 +2129,6 @@ class TestMingFlashOmniPipeline:
|
||||
assert isinstance(p, PipelineConfig)
|
||||
assert p.model_arch == "MingFlashOmniForConditionalGeneration"
|
||||
assert len(p.stages) == 2
|
||||
assert p.validate() == []
|
||||
|
||||
def test_thinker_stage(self):
|
||||
p = resolve_pipeline_config("ming_flash_omni")
|
||||
@@ -2122,7 +2185,6 @@ class TestMingFlashOmniPipeline:
|
||||
assert isinstance(p, PipelineConfig)
|
||||
assert p.model_arch == "MingFlashOmniTalkerForConditionalGeneration"
|
||||
assert len(p.stages) == 1
|
||||
assert p.validate() == []
|
||||
|
||||
def test_tts_stage(self):
|
||||
p = resolve_pipeline_config("ming_flash_omni_tts")
|
||||
@@ -2179,7 +2241,6 @@ class TestMingFlashOmniPipeline:
|
||||
assert isinstance(p, PipelineConfig)
|
||||
assert p.model_arch == "MingFlashOmniForConditionalGeneration"
|
||||
assert len(p.stages) == 1
|
||||
assert p.validate() == []
|
||||
|
||||
def test_thinker_only_stage(self):
|
||||
p = resolve_pipeline_config("ming_flash_omni_thinker_only")
|
||||
@@ -2218,7 +2279,6 @@ class TestMingFlashOmniPipeline:
|
||||
assert p is not None
|
||||
assert p.model_arch == "MingFlashOmniForConditionalGeneration"
|
||||
assert len(p.stages) == 2
|
||||
assert p.validate() == []
|
||||
|
||||
def test_image_thinker_stage(self):
|
||||
s = resolve_pipeline_config("ming_flash_omni_image").get_stage(0)
|
||||
@@ -2339,6 +2399,21 @@ class TestBaseConfigInheritance:
|
||||
# CI overrides max_tokens
|
||||
assert s0["max_tokens"] == 150
|
||||
|
||||
def test_qwen3_omni_colocate_async_bounds_only_rocm_kv_cache(self):
|
||||
ci_path = Path(get_deploy_config_path("ci/qwen3_omni_moe_colocate_async.yaml"))
|
||||
pipeline = resolve_pipeline_config("qwen3_omni_moe_thinker_only")
|
||||
assert isinstance(pipeline, PipelineConfig)
|
||||
|
||||
cuda = _apply_platform_overrides(load_deploy_config(ci_path), platform="cuda")
|
||||
cuda_stage = merge_pipeline_deploy(pipeline, cuda)[0]
|
||||
assert cuda_stage.yaml_engine_args["gpu_memory_utilization"] == 0.9
|
||||
assert "kv_cache_memory_bytes" not in cuda_stage.yaml_engine_args
|
||||
|
||||
rocm = _apply_platform_overrides(load_deploy_config(ci_path), platform="rocm")
|
||||
rocm_stage = merge_pipeline_deploy(pipeline, rocm)[0]
|
||||
assert "gpu_memory_utilization" not in rocm_stage.yaml_engine_args
|
||||
assert rocm_stage.yaml_engine_args["kv_cache_memory_bytes"] == 2 * 1024**3
|
||||
|
||||
def test_pure_inheritance_overlay(self, tmp_path):
|
||||
"""An overlay with only ``base_config`` inherits everything."""
|
||||
base = Path(get_deploy_config_path("qwen3_omni_moe.yaml"))
|
||||
@@ -2800,6 +2875,54 @@ stages:
|
||||
assert omega_config.engine_args.max_model_len == 8192
|
||||
assert omega_config.engine_args.enforce_eager is True
|
||||
|
||||
def test_cli_attention_shorthand_replaces_yaml_structured_default(self):
|
||||
stage = StageConfig(
|
||||
stage_id=0,
|
||||
model_stage="dit",
|
||||
stage_type=StageType.DIFFUSION,
|
||||
yaml_engine_args={
|
||||
"diffusion_attention_config": {
|
||||
"default": {"backend": "FLASH_ATTN"},
|
||||
"per_role": {"cross": {"backend": "TORCH_SDPA"}},
|
||||
},
|
||||
},
|
||||
runtime_overrides={"diffusion_attention_backend": "SAGE_ATTN"},
|
||||
)
|
||||
|
||||
engine_args = stage.to_omegaconf().engine_args
|
||||
|
||||
assert engine_args.diffusion_attention_backend == "SAGE_ATTN"
|
||||
assert "default" not in engine_args.diffusion_attention_config
|
||||
assert engine_args.diffusion_attention_config.per_role.cross.backend == "TORCH_SDPA"
|
||||
|
||||
def test_cli_attention_shorthand_keeps_yaml_per_role_only_config(self):
|
||||
stage = StageConfig(
|
||||
stage_id=0,
|
||||
model_stage="dit",
|
||||
stage_type=StageType.DIFFUSION,
|
||||
yaml_engine_args={"diffusion_attention_config": {"per_role": {"cross": {"backend": "TORCH_SDPA"}}}},
|
||||
runtime_overrides={"diffusion_attention_backend": "SAGE_ATTN"},
|
||||
)
|
||||
|
||||
engine_args = stage.to_omegaconf().engine_args
|
||||
|
||||
assert engine_args.diffusion_attention_backend == "SAGE_ATTN"
|
||||
assert engine_args.diffusion_attention_config == {"per_role": {"cross": {"backend": "TORCH_SDPA"}}}
|
||||
|
||||
def test_cli_attention_config_replaces_yaml_shorthand(self):
|
||||
stage = StageConfig(
|
||||
stage_id=0,
|
||||
model_stage="dit",
|
||||
stage_type=StageType.DIFFUSION,
|
||||
yaml_engine_args={"diffusion_attention_backend": "TORCH_SDPA"},
|
||||
runtime_overrides={"diffusion_attention_config": {"default": {"backend": "SAGE_ATTN"}}},
|
||||
)
|
||||
|
||||
engine_args = stage.to_omegaconf().engine_args
|
||||
|
||||
assert "diffusion_attention_backend" not in engine_args
|
||||
assert engine_args.diffusion_attention_config.default.backend == "SAGE_ATTN"
|
||||
|
||||
|
||||
class TestSentinelDefaultPrecedence:
|
||||
"""Caller-typed (non-None) values win over YAML; None values fall through
|
||||
|
||||
@@ -167,7 +167,7 @@ def test_inventory_matches_reviewed_snapshot_counts():
|
||||
EnvironmentVariableCategory.PUBLIC_OMNI: 24,
|
||||
EnvironmentVariableCategory.INHERITED_VLLM: 20,
|
||||
EnvironmentVariableCategory.PLATFORM_EXTERNAL: 27,
|
||||
EnvironmentVariableCategory.MODEL_SPECIFIC: 56,
|
||||
EnvironmentVariableCategory.MODEL_SPECIFIC: 57,
|
||||
EnvironmentVariableCategory.BENCHMARK_TRANSITIONAL: 20,
|
||||
EnvironmentVariableCategory.INTERNAL: 2,
|
||||
}
|
||||
@@ -178,7 +178,7 @@ def test_inventory_matches_reviewed_snapshot_counts():
|
||||
if item.category is EnvironmentVariableCategory.MODEL_SPECIFIC
|
||||
)
|
||||
assert {disposition: disposition_counts[disposition] for disposition in ModelEnvironmentVariableDisposition} == {
|
||||
ModelEnvironmentVariableDisposition.PROMOTE: 33,
|
||||
ModelEnvironmentVariableDisposition.PROMOTE: 34,
|
||||
ModelEnvironmentVariableDisposition.REQUEST_SCOPE: 6,
|
||||
ModelEnvironmentVariableDisposition.EXTERNAL: 0,
|
||||
ModelEnvironmentVariableDisposition.INTERNALIZE: 11,
|
||||
|
||||
@@ -45,6 +45,7 @@ from vllm_omni.config.stage_config import (
|
||||
PipelineConfig,
|
||||
StageDeployConfig,
|
||||
StageExecutionType,
|
||||
StagePipelineConfig,
|
||||
load_deploy_config,
|
||||
merge_pipeline_deploy,
|
||||
)
|
||||
@@ -1039,7 +1040,10 @@ def test_from_pipeline_config_uses_hf_config_for_callable_resolver():
|
||||
|
||||
|
||||
def test_from_pipeline_config_accepts_pre_resolved_pipeline():
|
||||
resolved_pipeline = PipelineConfig(model_type="callable_resolved_variant")
|
||||
resolved_pipeline = PipelineConfig(
|
||||
model_type="callable_resolved_variant",
|
||||
stages=(StagePipelineConfig(stage_id=0, model_stage="a", final_output=True),),
|
||||
)
|
||||
|
||||
omni_config = VllmOmniConfig.from_pipeline_config(resolved_pipeline)
|
||||
|
||||
@@ -1072,6 +1076,7 @@ def test_from_pipeline_config_default_deploy_name_ignores_cwd(monkeypatch, tmp_p
|
||||
pipeline = PipelineConfig(
|
||||
model_type="pipeline_with_default",
|
||||
default_deploy_config_name=default_name,
|
||||
stages=(StagePipelineConfig(stage_id=0, model_stage="a", final_output=True),),
|
||||
)
|
||||
loaded_paths = []
|
||||
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Tests for out of tree registration to OMNI_PIPELINES."""
|
||||
|
||||
import pytest
|
||||
from transformers import PretrainedConfig
|
||||
|
||||
from vllm_omni.config.pipeline_registry import OMNI_PIPELINES, register_pipeline
|
||||
from vllm_omni.config.stage_config import PipelineConfig, pipeline_cfg_resolver
|
||||
from vllm_omni.config.stage_config import PipelineConfig, StagePipelineConfig, pipeline_cfg_resolver
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def build_fake_pipeline_config(model_type: str) -> PipelineConfig:
|
||||
return PipelineConfig(
|
||||
model_type=model_type, stages=(StagePipelineConfig(stage_id=0, model_stage="a", final_output=True),)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def custom_resolver():
|
||||
"""Build a reusable custom resolver for PipelineConfigs."""
|
||||
@@ -22,7 +28,7 @@ def custom_resolver():
|
||||
def custom_resolver(
|
||||
hf_config: CustomConfigType,
|
||||
) -> PipelineConfig:
|
||||
return PipelineConfig(model_type="resolved_type")
|
||||
return build_fake_pipeline_config("resolved_type")
|
||||
|
||||
return custom_resolver
|
||||
|
||||
@@ -30,7 +36,7 @@ def custom_resolver():
|
||||
def test_register_pipeline_config(clean_pipeline_registry):
|
||||
"""Ensure that we can register a custom pipeline config to OMNI_PIPELINES."""
|
||||
new_model_type = "new_model_type"
|
||||
pipe_cfg = PipelineConfig(model_type=new_model_type)
|
||||
pipe_cfg = build_fake_pipeline_config(new_model_type)
|
||||
assert new_model_type not in OMNI_PIPELINES
|
||||
register_pipeline(pipe_cfg)
|
||||
assert new_model_type in OMNI_PIPELINES
|
||||
@@ -41,7 +47,7 @@ def test_register_pipeline_config_with_model_type(clean_pipeline_registry):
|
||||
"""Ensure that we can register a custom pipeline config with an explicit model_type to OMNI_PIPELINES."""
|
||||
new_model_type = "new_model_type"
|
||||
unused_model_type = "foo"
|
||||
pipe_cfg = PipelineConfig(model_type=unused_model_type)
|
||||
pipe_cfg = build_fake_pipeline_config(unused_model_type)
|
||||
assert new_model_type not in OMNI_PIPELINES
|
||||
assert unused_model_type not in OMNI_PIPELINES
|
||||
|
||||
|
||||
@@ -70,10 +70,14 @@ _PARAMS = [
|
||||
pytest.param({"guidance_scale": -1.0}, "guidance_scale", id="guidance_scale_negative", marks=_SKIP_ISSUE_3649),
|
||||
pytest.param({"guidance_scale": 0}, "guidance_scale", id="guidance_scale_zero", marks=_SKIP_ISSUE_3649),
|
||||
pytest.param(
|
||||
{"num_inference_steps": 0}, "num_inference_steps", id="num_inference_steps_zero", marks=_SKIP_ISSUE_3649
|
||||
{"num_inference_steps": 0},
|
||||
("num_inference_steps", "greater than or equal to 1"),
|
||||
id="num_inference_steps_zero",
|
||||
),
|
||||
pytest.param(
|
||||
{"num_inference_steps": -1}, ("number of steps", "non-negative"), id="num_inference_steps_negative"
|
||||
{"num_inference_steps": -1},
|
||||
("num_inference_steps", "greater than or equal to 1"),
|
||||
id="num_inference_steps_negative",
|
||||
),
|
||||
pytest.param(
|
||||
{"num_inference_steps": 6000},
|
||||
|
||||
@@ -157,7 +157,13 @@ def test_speech_missing_required_fields(omni_server: OmniServer, online_client:
|
||||
"ref_audio": None,
|
||||
"ref_text": None,
|
||||
},
|
||||
("Invalid voice", "vivian"),
|
||||
# NOTE: This is delegating down the TTS adapter, so the message is both specific
|
||||
# to the Qwen3TTS adapter and dependent on whether or not the model has any speakers
|
||||
# (as opposed to having valida speakers, but getting an invalid speaker). it would
|
||||
# be more ideal to validate that this is landing on `.validate()` on the adapter
|
||||
# and ensuring that the returned message matches what we would get from calling the
|
||||
# adapter validate directly.
|
||||
("no speakers configured",),
|
||||
id="customvoice_invalid_voice",
|
||||
),
|
||||
pytest.param(
|
||||
|
||||
@@ -11,6 +11,7 @@ Tests cover:
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
@@ -466,6 +467,120 @@ class TestCurrentDiffusionConfig:
|
||||
|
||||
|
||||
class TestAttentionInitUsesCurrentDiffusionConfig:
|
||||
@pytest.mark.parametrize("mindiesd_available", [False, True])
|
||||
def test_paged_npu_memory_profile_uses_sdpa_only_without_mindiesd(self, monkeypatch, mindiesd_available):
|
||||
attention = Attention.__new__(Attention)
|
||||
attention._scheduler_paged_kv = True
|
||||
attention.paged_kv_cache_role = "primary"
|
||||
attention.backend_pref = "FLASH_ATTN"
|
||||
attention.attn_backend = SimpleNamespace(supports_piecewise_spans=True, get_name=lambda: "FLASH_ATTN")
|
||||
flash_output = torch.ones(1)
|
||||
sdpa_output = torch.zeros(1)
|
||||
dense_flash = Mock(return_value=flash_output)
|
||||
sdpa = Mock(return_value=sdpa_output)
|
||||
attention.attention = SimpleNamespace(forward=dense_flash)
|
||||
attention.sdpa_fallback = SimpleNamespace(forward=sdpa)
|
||||
monkeypatch.setattr(layer_mod, "is_forward_context_available", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
layer_mod,
|
||||
"get_forward_context",
|
||||
lambda: SimpleNamespace(in_diffusion_kv_memory_profile=True),
|
||||
)
|
||||
monkeypatch.setattr(layer_mod.current_omni_platform, "is_npu", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
layer_mod.current_omni_platform,
|
||||
"supports_diffusion_dense_flash_attention",
|
||||
lambda: mindiesd_available,
|
||||
)
|
||||
|
||||
result = attention._run_local_attention(
|
||||
torch.zeros(1, dtype=torch.bfloat16),
|
||||
torch.zeros(1, dtype=torch.bfloat16),
|
||||
torch.zeros(1, dtype=torch.bfloat16),
|
||||
AttentionMetadata(),
|
||||
)
|
||||
|
||||
if mindiesd_available:
|
||||
assert result is flash_output
|
||||
dense_flash.assert_called_once()
|
||||
sdpa.assert_not_called()
|
||||
else:
|
||||
assert result is sdpa_output
|
||||
dense_flash.assert_not_called()
|
||||
sdpa.assert_called_once()
|
||||
|
||||
def test_dense_flash_import_error_is_not_reclassified(self, monkeypatch):
|
||||
attention = Attention.__new__(Attention)
|
||||
attention._scheduler_paged_kv = False
|
||||
attention.paged_kv_cache_role = "primary"
|
||||
attention.backend_pref = "FLASH_ATTN"
|
||||
attention.attn_backend = SimpleNamespace(supports_piecewise_spans=True, get_name=lambda: "FLASH_ATTN")
|
||||
attention.attention = SimpleNamespace(
|
||||
forward=Mock(side_effect=ModuleNotFoundError("No module named 'mindiesd'", name="mindiesd"))
|
||||
)
|
||||
attention.sdpa_fallback = SimpleNamespace(forward=Mock(side_effect=AssertionError("must not fall back")))
|
||||
monkeypatch.setattr(layer_mod, "is_forward_context_available", lambda: False)
|
||||
|
||||
with pytest.raises(ModuleNotFoundError, match="mindiesd"):
|
||||
attention._run_local_attention(
|
||||
torch.zeros(1, dtype=torch.bfloat16),
|
||||
torch.zeros(1, dtype=torch.bfloat16),
|
||||
torch.zeros(1, dtype=torch.bfloat16),
|
||||
AttentionMetadata(),
|
||||
)
|
||||
|
||||
def test_paged_marker_does_not_change_dense_backend(self, monkeypatch):
|
||||
class _FakeAttentionImpl:
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
|
||||
def forward(self, query, key, value, attn_metadata=None):
|
||||
return query
|
||||
|
||||
class _DenseBackend:
|
||||
supports_paged_kv = False
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "TORCH_SDPA"
|
||||
|
||||
@staticmethod
|
||||
def get_impl_cls():
|
||||
return _FakeAttentionImpl
|
||||
|
||||
calls = []
|
||||
|
||||
def _fake_get_attn_backend_for_role(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return _DenseBackend, None
|
||||
|
||||
monkeypatch.setattr(layer_mod, "get_attn_backend_for_role", _fake_get_attn_backend_for_role)
|
||||
monkeypatch.setattr(layer_mod.SDPABackend, "get_impl_cls", staticmethod(lambda: _FakeAttentionImpl))
|
||||
monkeypatch.setattr(layer_mod, "build_parallel_attention_strategy", lambda **kwargs: object())
|
||||
monkeypatch.setattr(layer_mod, "is_forward_context_available", lambda: False)
|
||||
|
||||
od_config = SimpleNamespace(
|
||||
diffusion_attention_config=AttentionConfig(),
|
||||
diffusion_kv_mode=layer_mod.DiffusionKVCacheMode.DENSE_LEGACY,
|
||||
parallel_config=SimpleNamespace(ring_degree=1),
|
||||
diffusion_kv_cache_dtype=None,
|
||||
diffusion_kv_cache_skip_step_indices=None,
|
||||
diffusion_kv_cache_skip_layer_indices=None,
|
||||
)
|
||||
|
||||
with set_current_diffusion_config(od_config):
|
||||
attention = Attention(
|
||||
num_heads=4,
|
||||
head_size=64,
|
||||
causal=False,
|
||||
softmax_scale=1.0,
|
||||
paged_kv_cache_role="primary",
|
||||
)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert attention.attn_backend is _DenseBackend
|
||||
assert attention.backend_pref is None
|
||||
|
||||
def test_attention_init_uses_current_diffusion_config_without_forward_context(self, monkeypatch):
|
||||
class _FakeAttentionImpl:
|
||||
def __init__(self, **kwargs):
|
||||
|
||||
@@ -353,8 +353,15 @@ def test_cross_attn_key_padding_vs_sdpa(k_len):
|
||||
print(f"Max absolute difference: {max_diff:.6f}")
|
||||
print(f"Mean absolute difference: {mean_diff:.6f}")
|
||||
|
||||
assert max_diff < 0.01, f"Max difference {max_diff} exceeds threshold 0.01"
|
||||
assert mean_diff < 0.001, f"Mean difference {mean_diff} exceeds threshold 0.001"
|
||||
# Match the platform-specific comparison used by test_fa_vs_sdpa above.
|
||||
if current_omni_platform.is_rocm():
|
||||
# AITER FlashAttention and PyTorch SDPA use different BF16 reduction
|
||||
# implementations on ROCm, so individual values can differ by one BF16
|
||||
# step even though the outputs agree within the expected relative error.
|
||||
torch.testing.assert_close(output_fa, output_sdpa, rtol=1e-2, atol=1.5e-2)
|
||||
else:
|
||||
assert max_diff < 0.01, f"Max difference {max_diff} exceeds threshold 0.01"
|
||||
assert mean_diff < 0.001, f"Mean difference {mean_diff} exceeds threshold 0.001"
|
||||
|
||||
print("✓ Case 3 PASSED: FA and SDPA cross-attention outputs are very close!")
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""End-to-end test for ``piecewise_attn`` (CPU).
|
||||
|
||||
Verify that running attention in segments (causal outside full-attn spans,
|
||||
@@ -98,9 +98,12 @@ def test_paged_piecewise_runner_uses_packed_indices_and_kv_endpoints():
|
||||
query = torch.arange(12 * 2 * 4, dtype=torch.float32).reshape(12, 2, 4)
|
||||
key = query + 100
|
||||
value = query + 200
|
||||
calls = []
|
||||
calls: list[object] = []
|
||||
|
||||
def run_segment(segment_query, segment_key, segment_value, segment_batch):
|
||||
output_buffer = torch.empty_like(query)
|
||||
|
||||
def run_segment(segment_query, segment_key, segment_value, segment_batch, segment_output_buffer):
|
||||
assert segment_output_buffer is None
|
||||
calls.append(
|
||||
(
|
||||
[segment.mode for segment in segment_batch.row_segments],
|
||||
@@ -119,9 +122,13 @@ def test_paged_piecewise_runner_uses_packed_indices_and_kv_endpoints():
|
||||
plan,
|
||||
plan.segments,
|
||||
run_segment,
|
||||
output_buffer=output_buffer,
|
||||
)
|
||||
|
||||
assert output is output_buffer
|
||||
torch.testing.assert_close(output, query)
|
||||
assert all(segment.query_range is None for segment in plan.segments)
|
||||
assert not plan.segments_cover_query_contiguously
|
||||
assert calls == [
|
||||
(["causal", "causal"], [2, 4], [0, 1, 6, 7]),
|
||||
(["full", "full"], [5, 7], [2, 3, 4, 8, 9, 10]),
|
||||
@@ -129,6 +136,161 @@ def test_paged_piecewise_runner_uses_packed_indices_and_kv_endpoints():
|
||||
]
|
||||
|
||||
|
||||
def test_paged_piecewise_runner_preserves_homogeneous_batch_without_scatter(monkeypatch: pytest.MonkeyPatch):
|
||||
plan = build_paged_piecewise_plan(
|
||||
[[(2, 5)], [(2, 5)]],
|
||||
query_offsets=[0, 0],
|
||||
query_lens=[6, 6],
|
||||
seq_lens=[6, 6],
|
||||
device=DEVICE,
|
||||
)
|
||||
assert plan.homogeneous_batch_shape == (2, 6)
|
||||
query = torch.arange(12 * 2 * 4, dtype=torch.float32).reshape(12, 2, 4)
|
||||
calls: list[torch.Tensor] = []
|
||||
|
||||
def fail_index_copy(*_args, **_kwargs):
|
||||
raise AssertionError("homogeneous piecewise path must not use index_copy_")
|
||||
|
||||
monkeypatch.setattr(torch.Tensor, "index_copy_", fail_index_copy)
|
||||
|
||||
def run_segment(segment_query, segment_key, segment_value, _metadata, segment_output_buffer):
|
||||
assert segment_key is None
|
||||
assert segment_value is None
|
||||
assert segment_output_buffer is None
|
||||
calls.append(segment_query.clone())
|
||||
return segment_query + 1
|
||||
|
||||
output_buffer = torch.empty_like(query)
|
||||
output = run_paged_piecewise_plan(
|
||||
query,
|
||||
None,
|
||||
None,
|
||||
plan,
|
||||
plan.segments,
|
||||
run_segment,
|
||||
output_buffer=output_buffer,
|
||||
use_homogeneous_batch=True,
|
||||
)
|
||||
|
||||
assert output is output_buffer
|
||||
torch.testing.assert_close(output, query + 1)
|
||||
assert [call.shape[0] for call in calls] == [4, 6, 2]
|
||||
torch.testing.assert_close(calls[0], query[[0, 1, 6, 7]])
|
||||
torch.testing.assert_close(calls[1], query[[2, 3, 4, 8, 9, 10]])
|
||||
torch.testing.assert_close(calls[2], query[[5, 11]])
|
||||
|
||||
|
||||
def test_paged_piecewise_runner_keeps_indexed_fallback_for_heterogeneous_layout():
|
||||
plan = build_paged_piecewise_plan(
|
||||
[[(2, 5)], [(1, 4)]],
|
||||
query_offsets=[0, 0],
|
||||
query_lens=[6, 6],
|
||||
seq_lens=[6, 6],
|
||||
device=DEVICE,
|
||||
)
|
||||
assert plan.homogeneous_batch_shape is None
|
||||
query = torch.arange(12 * 2 * 4, dtype=torch.float32).reshape(12, 2, 4)
|
||||
output = run_paged_piecewise_plan(
|
||||
query,
|
||||
None,
|
||||
None,
|
||||
plan,
|
||||
plan.segments,
|
||||
lambda segment_query, *_args: segment_query,
|
||||
use_homogeneous_batch=True,
|
||||
)
|
||||
torch.testing.assert_close(output, query)
|
||||
|
||||
|
||||
def test_paged_piecewise_runner_slices_contiguous_segments():
|
||||
plan = build_paged_piecewise_plan(
|
||||
[[(2, 5)]],
|
||||
query_offsets=[0],
|
||||
query_lens=[6],
|
||||
seq_lens=[6],
|
||||
device=DEVICE,
|
||||
)
|
||||
query = torch.arange(6 * 2 * 4, dtype=torch.float32).reshape(6, 2, 4)
|
||||
key = query + 100
|
||||
value = query + 200
|
||||
expected_ranges = [(0, 2), (2, 5), (5, 6)]
|
||||
calls: list[tuple[int, int]] = []
|
||||
|
||||
def run_segment(segment_query, segment_key, segment_value, _segment_batch, segment_output_buffer):
|
||||
assert segment_output_buffer is None
|
||||
expected_range = expected_ranges[len(calls)]
|
||||
start, end = expected_range
|
||||
calls.append(expected_range)
|
||||
assert segment_query.untyped_storage().data_ptr() == query.untyped_storage().data_ptr()
|
||||
assert segment_key.untyped_storage().data_ptr() == key.untyped_storage().data_ptr()
|
||||
assert segment_value.untyped_storage().data_ptr() == value.untyped_storage().data_ptr()
|
||||
torch.testing.assert_close(segment_query, query[start:end])
|
||||
return segment_query
|
||||
|
||||
output = run_paged_piecewise_plan(query, key, value, plan, plan.segments, run_segment)
|
||||
|
||||
assert calls == expected_ranges
|
||||
assert [segment.query_range for segment in plan.segments] == expected_ranges
|
||||
assert plan.segments_cover_query_contiguously
|
||||
torch.testing.assert_close(output, query)
|
||||
|
||||
|
||||
def test_paged_piecewise_runner_writes_contiguous_segments_into_output_buffer():
|
||||
plan = build_paged_piecewise_plan(
|
||||
[[(2, 5)]],
|
||||
query_offsets=[0],
|
||||
query_lens=[6],
|
||||
seq_lens=[6],
|
||||
device=DEVICE,
|
||||
)
|
||||
query = torch.arange(6 * 2 * 4, dtype=torch.float32).reshape(6, 2, 4)
|
||||
output_buffer = torch.empty(6, 2, 3)
|
||||
targets = []
|
||||
|
||||
def run_segment(segment_query, _key, _value, _metadata, segment_output_buffer):
|
||||
assert segment_output_buffer is not None
|
||||
assert segment_output_buffer.untyped_storage().data_ptr() == output_buffer.untyped_storage().data_ptr()
|
||||
targets.append(segment_output_buffer)
|
||||
return segment_output_buffer.copy_(segment_query[..., :3])
|
||||
|
||||
output = run_paged_piecewise_plan(
|
||||
query,
|
||||
query,
|
||||
query,
|
||||
plan,
|
||||
plan.segments,
|
||||
run_segment,
|
||||
output_buffer=output_buffer,
|
||||
)
|
||||
|
||||
assert output is output_buffer
|
||||
assert [target.shape[0] for target in targets] == [2, 3, 1]
|
||||
torch.testing.assert_close(output, query[..., :3])
|
||||
|
||||
|
||||
def test_paged_piecewise_runner_returns_single_full_segment_directly():
|
||||
plan = build_paged_piecewise_plan(
|
||||
[[]],
|
||||
query_offsets=[0],
|
||||
query_lens=[6],
|
||||
seq_lens=[6],
|
||||
device=DEVICE,
|
||||
)
|
||||
query = torch.randn(6, 2, 4)
|
||||
runner_output = query + 1
|
||||
|
||||
output = run_paged_piecewise_plan(
|
||||
query,
|
||||
query,
|
||||
query,
|
||||
plan,
|
||||
plan.segments,
|
||||
lambda *_args: runner_output,
|
||||
)
|
||||
|
||||
assert output is runner_output
|
||||
|
||||
|
||||
def test_paged_piecewise_runner_uses_runner_output_shape():
|
||||
plan = build_paged_piecewise_plan(
|
||||
[[(2, 5)]],
|
||||
|
||||
@@ -7,7 +7,7 @@ End-to-end test for diffusion batching via AsyncOmni.
|
||||
This test fires multiple concurrent ``AsyncOmni.generate()`` calls for a
|
||||
diffusion model and validates that every caller receives its correct
|
||||
individual result. When the underlying diffusion stage is configured with
|
||||
``batch_size > 1`` (via stage config or ``StageDiffusionClient``), the
|
||||
``max_num_seqs > 1``, the
|
||||
requests will be batched internally.
|
||||
|
||||
Even without explicit batching config this test is useful for verifying
|
||||
@@ -260,7 +260,7 @@ async def compare_single_vs_parallel(
|
||||
) -> None:
|
||||
"""Run the same prompts sequentially then in parallel and print a comparison."""
|
||||
|
||||
omni = AsyncOmni(model=model, max_num_seqs=batch_size, diffusion_batch_size=batch_size)
|
||||
omni = AsyncOmni(model=model, max_num_seqs=batch_size)
|
||||
try:
|
||||
await warmup(omni, WARMUP_PROMPTS)
|
||||
single_time = await run_single(omni, prompts)
|
||||
@@ -288,7 +288,7 @@ async def main(model: str, num_prompts: int, mode: str, batch_size: int = 1) ->
|
||||
await compare_single_vs_parallel(model, prompts, batch_size=batch_size)
|
||||
return
|
||||
|
||||
omni = AsyncOmni(model=model, max_num_seqs=batch_size, diffusion_batch_size=batch_size)
|
||||
omni = AsyncOmni(model=model, max_num_seqs=batch_size)
|
||||
try:
|
||||
await warmup(omni, WARMUP_PROMPTS)
|
||||
|
||||
@@ -389,7 +389,7 @@ def test_diffusion_batching_async_concurrent(model_name: str):
|
||||
fired via asyncio.gather. Each request_id must appear in the results."""
|
||||
|
||||
async def _inner():
|
||||
omni = AsyncOmni(model=model_name, diffusion_batch_size=1)
|
||||
omni = AsyncOmni(model=model_name, max_num_seqs=1)
|
||||
try:
|
||||
prompts = TEST_PROMPTS[:4]
|
||||
sp = _default_sampling_params()
|
||||
@@ -430,7 +430,7 @@ def test_diffusion_batching_list_prompt_rejected(model_name: str):
|
||||
"""
|
||||
|
||||
async def _inner():
|
||||
omni = AsyncOmni(model=model_name, diffusion_batch_size=4)
|
||||
omni = AsyncOmni(model=model_name, max_num_seqs=4)
|
||||
try:
|
||||
prompts = TEST_PROMPTS[:4]
|
||||
sp = _default_sampling_params()
|
||||
@@ -528,7 +528,7 @@ if __name__ == "__main__":
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Scheduler in-flight width (--max-num-seqs) and client diffusion_batch_size",
|
||||
help="Scheduler in-flight width (--max-num-seqs)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def restore_torch_default_dtype():
|
||||
"""Keep process-global dtype changes from leaking between diffusion tests."""
|
||||
default_dtype = torch.get_default_dtype()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
torch.set_default_dtype(default_dtype)
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -485,8 +485,16 @@ def test_valid_sequence_and_context_install_into_native_rows(monkeypatch: pytest
|
||||
assert runner.get_diffusion_kv_row("req-0", 0, "text") == 1
|
||||
sequence_binding = runner._resolve_paged_attention_row("req-0", 0, None)
|
||||
context_binding = runner._resolve_paged_attention_row("req-0", None, "text")
|
||||
assert (sequence_binding.row_index, sequence_binding.max_seq_len) == (0, 9)
|
||||
assert (context_binding.row_index, context_binding.max_seq_len) == (1, 5)
|
||||
assert (sequence_binding.row_index, sequence_binding.max_seq_len, sequence_binding.block_ids) == (
|
||||
0,
|
||||
9,
|
||||
((1, 2, 3), (4, 5)),
|
||||
)
|
||||
assert (context_binding.row_index, context_binding.max_seq_len, context_binding.block_ids) == (
|
||||
1,
|
||||
5,
|
||||
((6, 7), (8,)),
|
||||
)
|
||||
|
||||
|
||||
def test_block_table_mutations_invalidate_prepared_attention_batches(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -36,7 +36,7 @@ def test_non_mapping_omni_kv_config_is_rejected() -> None:
|
||||
OmniDiffusionConfig.from_kwargs(omni_kv_config="paged_scheduler")
|
||||
|
||||
|
||||
def test_paged_scheduler_rejects_dense_legacy_kv_receive() -> None:
|
||||
def test_paged_scheduler_rejects_dense_connector_kv_receive() -> None:
|
||||
with pytest.raises(ValueError, match="does not support imported AR KV"):
|
||||
OmniDiffusionConfig.from_kwargs(
|
||||
diffusion_kv_mode="paged_scheduler",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
@@ -290,6 +290,7 @@ def test_minus_one_max_model_len_is_auto_fitted_by_native_cache_sizing() -> None
|
||||
dtype=torch.bfloat16,
|
||||
non_causal=True,
|
||||
)
|
||||
model_limit = vllm_config.model_config.max_model_len
|
||||
|
||||
initialization.build_native_kv_cache_configs(
|
||||
vllm_config,
|
||||
@@ -298,4 +299,8 @@ def test_minus_one_max_model_len_is_auto_fitted_by_native_cache_sizing() -> None
|
||||
)
|
||||
|
||||
assert vllm_config.model_config.original_max_model_len == -1
|
||||
assert vllm_config.model_config.max_model_len == 256
|
||||
# Sixteen physical pages provide ``16 * block_size`` tokens. The native
|
||||
# backend chooses the block geometry, so keep this assertion backend
|
||||
# independent instead of assuming the generic 16-token block size.
|
||||
expected_max_model_len = min(model_limit, spec.block_size * 16)
|
||||
assert vllm_config.model_config.max_model_len == expected_max_model_len
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -20,12 +20,16 @@ from vllm_omni.diffusion.attention.layer import Attention
|
||||
from vllm_omni.diffusion.diffusion_kv import paged_attention_adapter as adapter_module
|
||||
from vllm_omni.diffusion.diffusion_kv.paged_attention_adapter import (
|
||||
DiffusionPagedAttentionAdapter,
|
||||
DiffusionPagedAttentionMetadata,
|
||||
DiffusionPagedAttentionRow,
|
||||
DiffusionPagedAttentionRowBinding,
|
||||
DiffusionPagedAttentionRuntime,
|
||||
)
|
||||
from vllm_omni.diffusion.forward_context import (
|
||||
get_forward_context,
|
||||
override_paged_kv_adapter,
|
||||
set_forward_context,
|
||||
set_forward_context_denoise_step_idx,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
@@ -122,8 +126,8 @@ class _FakeNativeImpl:
|
||||
def do_kv_cache_update(
|
||||
self,
|
||||
_layer,
|
||||
key: torch.Tensor,
|
||||
value: torch.Tensor,
|
||||
key: torch.Tensor | None,
|
||||
value: torch.Tensor | None,
|
||||
_kv_cache,
|
||||
slot_mapping: torch.Tensor,
|
||||
) -> None:
|
||||
@@ -155,6 +159,11 @@ def _make_adapter(
|
||||
capacity: int = 16,
|
||||
) -> tuple[DiffusionPagedAttentionAdapter, _FakeBlockTables, _FakeLayer, list[tuple]]:
|
||||
events: list[tuple] = []
|
||||
monkeypatch.setattr(
|
||||
adapter_module.current_omni_platform,
|
||||
"requires_diffusion_paged_kv_prewrite",
|
||||
lambda: False,
|
||||
)
|
||||
block_tables = _FakeBlockTables()
|
||||
block_tables.max_num_batched_tokens = capacity
|
||||
block_tables.num_blocks.np.fill(math.ceil(capacity / block_tables.block_sizes[0]))
|
||||
@@ -301,6 +310,47 @@ def test_paged_adapter_accepts_native_gqa_shapes(monkeypatch: pytest.MonkeyPatch
|
||||
assert context.value_write.shape == (3, 8, 4)
|
||||
|
||||
|
||||
def test_paged_adapter_preserves_strided_kv_for_platform_writer(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter, _, _, _ = _make_adapter(monkeypatch)
|
||||
batch = adapter.prepare_batch(
|
||||
[DiffusionPagedAttentionRow(request_id="req-0", sequence_id=0, query_len=5, seq_len=5)]
|
||||
)
|
||||
packed_qkv = torch.randn(1, 5, 3 * 2 * 4)
|
||||
query, key, value = packed_qkv.split(2 * 4, dim=-1)
|
||||
query = query.reshape(1, 5, 2, 4)
|
||||
key = key.reshape(1, 5, 2, 4)
|
||||
value = value.reshape(1, 5, 2, 4)
|
||||
assert not key.is_contiguous()
|
||||
assert not value.is_contiguous()
|
||||
|
||||
with adapter.activate(batch):
|
||||
context = adapter.prepare_layer_context("layer-0", query, key, value)
|
||||
|
||||
assert context.query.is_contiguous()
|
||||
assert not context.key_write.is_contiguous()
|
||||
assert not context.value_write.is_contiguous()
|
||||
assert context.key_write.data_ptr() == key.data_ptr()
|
||||
assert context.value_write.data_ptr() == value.data_ptr()
|
||||
|
||||
|
||||
def test_paged_adapter_packs_noncontiguous_kv_head_layout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter, _, _, _ = _make_adapter(monkeypatch)
|
||||
batch = adapter.prepare_batch(
|
||||
[DiffusionPagedAttentionRow(request_id="req-0", sequence_id=0, query_len=5, seq_len=5)]
|
||||
)
|
||||
query = torch.randn(1, 5, 2, 4)
|
||||
key = torch.randn(1, 5, 4, 2).transpose(-1, -2)
|
||||
value = torch.randn(1, 5, 4, 2).transpose(-1, -2)
|
||||
assert key.shape == (1, 5, 2, 4)
|
||||
assert key.stride(-1) != 1
|
||||
|
||||
with adapter.activate(batch):
|
||||
context = adapter.prepare_layer_context("layer-0", query, key, value)
|
||||
|
||||
assert context.key_write.is_contiguous()
|
||||
assert context.value_write.is_contiguous()
|
||||
|
||||
|
||||
def test_omni_paged_backend_defers_backend_owned_cache_update(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter, _, layer, _ = _make_adapter(monkeypatch)
|
||||
layer.attn_backend.forward_includes_kv_cache_update = True
|
||||
@@ -350,10 +400,48 @@ def test_omni_piecewise_backend_defers_backend_owned_cache_update(monkeypatch: p
|
||||
assert [metadata.query_start_loc_cpu.tolist() for metadata in segment_metadata] == [[0, 1], [0, 2], [0, 1]]
|
||||
assert [metadata.positions.tolist() for metadata in segment_metadata] == [[0], [1, 2], [3]]
|
||||
assert [metadata.slot_mappings.tolist() for metadata in segment_metadata] == [[[0]], [[1, 2]], [[3]]]
|
||||
for event in events[1:]:
|
||||
build_kwargs = event[1]
|
||||
assert build_kwargs["positions"].untyped_storage().data_ptr() == batch.positions.untyped_storage().data_ptr()
|
||||
assert (
|
||||
build_kwargs["slot_mappings"].untyped_storage().data_ptr()
|
||||
== batch.slot_mappings.untyped_storage().data_ptr()
|
||||
)
|
||||
|
||||
|
||||
def test_platform_prewrite_updates_once_then_piecewise_reads_cache(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter, _, layer, _ = _make_adapter(monkeypatch)
|
||||
layer.attn_backend.forward_includes_kv_cache_update = True
|
||||
monkeypatch.setattr(
|
||||
adapter_module.current_omni_platform,
|
||||
"requires_diffusion_paged_kv_prewrite",
|
||||
lambda: True,
|
||||
)
|
||||
adapter.resolve_row = lambda *_args: DiffusionPagedAttentionRowBinding(
|
||||
row_index=2,
|
||||
max_seq_len=16,
|
||||
block_ids=((3, 7, 9, 11),),
|
||||
)
|
||||
row = DiffusionPagedAttentionRow(request_id="req-0", sequence_id=0, query_len=4, seq_len=4)
|
||||
batch = adapter.prepare_batch([row])
|
||||
qkv = torch.randn(1, 4, 2, 4)
|
||||
metadata = SimpleNamespace(full_attn_spans=[[(1, 3)]], extra={})
|
||||
|
||||
with adapter.activate(batch):
|
||||
context = adapter.prepare_layer_context("layer-0", qkv, qkv, qkv, omni_attn_metadata=metadata)
|
||||
output = _run_omni_paged_backend(context)
|
||||
|
||||
assert torch.equal(output, qkv)
|
||||
assert layer.native_events == ["update", "forward", "forward", "forward"]
|
||||
assert len(layer.updates) == 1
|
||||
assert all(call[1] is None and call[2] is None for call in layer.calls)
|
||||
|
||||
|
||||
def test_omni_paged_backend_runs_hunyuan_piecewise_segments(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter, _, layer, events = _make_adapter(monkeypatch)
|
||||
# Exercise the Ascend-only homogeneous batch path. CUDA keeps its native
|
||||
# output-buffer contract and is covered by the GPU adapter tests.
|
||||
monkeypatch.setattr(adapter_module.current_omni_platform, "is_npu", lambda: True)
|
||||
batch = adapter.prepare_batch(
|
||||
[
|
||||
DiffusionPagedAttentionRow(
|
||||
@@ -450,6 +538,48 @@ def test_omni_paged_backend_runs_hunyuan_piecewise_segments(monkeypatch: pytest.
|
||||
]
|
||||
|
||||
|
||||
def test_piecewise_metadata_snapshots_reused_native_scheduler_buffer(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter, _, _, _ = _make_adapter(monkeypatch)
|
||||
shared_scheduler_metadata = torch.zeros(1, dtype=torch.int32)
|
||||
build_id = 0
|
||||
|
||||
def build_metadata(**kwargs):
|
||||
nonlocal build_id
|
||||
shared_scheduler_metadata.fill_(build_id)
|
||||
metadata = SimpleNamespace(
|
||||
build_id=build_id,
|
||||
causal=kwargs["causal"],
|
||||
seq_lens=kwargs["seq_lens"].clone(),
|
||||
query_start_loc_cpu=kwargs["query_start_loc_cpu"].clone(),
|
||||
positions=kwargs["positions"].clone(),
|
||||
slot_mappings=kwargs["slot_mappings"].clone(),
|
||||
scheduler_metadata=shared_scheduler_metadata,
|
||||
prefix_scheduler_metadata=None,
|
||||
)
|
||||
build_id += 1
|
||||
return {"layer-0": metadata}
|
||||
|
||||
monkeypatch.setattr(adapter_module.current_omni_platform, "build_diffusion_kv_attn_metadata", build_metadata)
|
||||
batch = adapter.prepare_batch(
|
||||
[DiffusionPagedAttentionRow(request_id="req-0", sequence_id=0, query_len=4, seq_len=4)]
|
||||
)
|
||||
qkv = torch.randn(1, 4, 2, 4)
|
||||
metadata = SimpleNamespace(full_attn_spans=[[(1, 3)]], extra={})
|
||||
|
||||
with adapter.activate(batch):
|
||||
context = adapter.prepare_layer_context(
|
||||
"layer-0",
|
||||
qkv,
|
||||
qkv,
|
||||
qkv,
|
||||
omni_attn_metadata=metadata,
|
||||
)
|
||||
|
||||
segment_schedules = [item.scheduler_metadata for item in context.piecewise_native_metadata]
|
||||
assert [item.item() for item in segment_schedules] == [1, 2, 3]
|
||||
assert len({item.data_ptr() for item in segment_schedules}) == 3
|
||||
|
||||
|
||||
def test_omni_paged_backend_treats_empty_full_spans_as_causal(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter, _, layer, events = _make_adapter(monkeypatch)
|
||||
batch = adapter.prepare_batch(
|
||||
@@ -851,6 +981,28 @@ def test_forward_rejects_batched_layout_that_does_not_match_rows(monkeypatch: py
|
||||
adapter.prepare_layer_context("layer-0", qkv, qkv, qkv)
|
||||
|
||||
|
||||
def test_forward_packs_and_restores_right_padded_heterogeneous_rows(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter, _, _, _ = _make_adapter(monkeypatch)
|
||||
batch = adapter.prepare_batch(
|
||||
[
|
||||
DiffusionPagedAttentionRow(request_id="req-0", sequence_id=0, query_len=1, seq_len=1),
|
||||
DiffusionPagedAttentionRow(request_id="req-1", sequence_id=1, query_len=3, seq_len=3),
|
||||
]
|
||||
)
|
||||
qkv = torch.arange(2 * 3 * 2 * 4, dtype=torch.float32).reshape(2, 3, 2, 4)
|
||||
|
||||
with adapter.activate(batch):
|
||||
context = adapter.prepare_layer_context("layer-0", qkv, qkv, qkv)
|
||||
|
||||
assert context.output_scatter_indices.tolist() == [0, 3, 4, 5]
|
||||
torch.testing.assert_close(context.query, qkv.reshape(-1, 2, 4)[[0, 3, 4, 5]])
|
||||
restored = context.restore_output(context.query)
|
||||
assert restored.shape == qkv.shape
|
||||
torch.testing.assert_close(restored[0, 0], qkv[0, 0])
|
||||
assert torch.count_nonzero(restored[0, 1:]) == 0
|
||||
torch.testing.assert_close(restored[1], qkv[1])
|
||||
|
||||
|
||||
def test_forward_rejects_padded_full_kv(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter, _, _, _ = _make_adapter(monkeypatch)
|
||||
batch = adapter.prepare_batch(
|
||||
@@ -878,6 +1030,41 @@ def test_forward_rejects_padded_full_kv(monkeypatch: pytest.MonkeyPatch) -> None
|
||||
adapter.prepare_layer_context("layer-0", query, full_kv, full_kv)
|
||||
|
||||
|
||||
def test_runner_runtime_switches_from_prefill_to_reusable_denoise_metadata(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter, _, _, _ = _make_adapter(monkeypatch)
|
||||
metadata = DiffusionPagedAttentionMetadata(
|
||||
prefill_rows=(DiffusionPagedAttentionRow(request_id="req-0", sequence_id=0, query_len=6, seq_len=6),),
|
||||
denoise_rows=(
|
||||
DiffusionPagedAttentionRow(
|
||||
request_id="req-0",
|
||||
sequence_id=0,
|
||||
query_len=2,
|
||||
seq_len=5,
|
||||
kv_start_pos=3,
|
||||
),
|
||||
),
|
||||
)
|
||||
runtime = DiffusionPagedAttentionRuntime(adapter, metadata)
|
||||
|
||||
with set_forward_context(paged_kv_runtime=runtime), runtime.activate():
|
||||
set_forward_context_denoise_step_idx(0)
|
||||
assert Attention._active_paged_kv_adapter() is adapter
|
||||
assert adapter._active_batch.rows == metadata.prefill_rows
|
||||
|
||||
set_forward_context_denoise_step_idx(1)
|
||||
assert Attention._active_paged_kv_adapter() is adapter
|
||||
denoise_batch = adapter._active_batch
|
||||
assert denoise_batch.rows == metadata.denoise_rows
|
||||
|
||||
set_forward_context_denoise_step_idx(2)
|
||||
assert Attention._active_paged_kv_adapter() is adapter
|
||||
assert adapter._active_batch is denoise_batch
|
||||
|
||||
assert adapter._active_batch is None
|
||||
|
||||
|
||||
def test_causal_batch_rejects_non_suffix_write(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter, _, _, _ = _make_adapter(monkeypatch, non_causal=False)
|
||||
row = DiffusionPagedAttentionRow(
|
||||
@@ -992,7 +1179,8 @@ def test_layer_adapter_accepts_platform_native_backend_and_uses_rank_local_heads
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
selected_backends = []
|
||||
impl_cls = Mock(return_value=SimpleNamespace(forward=Mock()))
|
||||
specialized_backends = []
|
||||
impl_cls = Mock(return_value=SimpleNamespace(forward=Mock(), do_kv_cache_update=Mock()))
|
||||
native_backend = SimpleNamespace(
|
||||
get_name=lambda: "ASCEND",
|
||||
indexes_kv_by_block_stride=lambda: True,
|
||||
@@ -1003,16 +1191,28 @@ def test_layer_adapter_accepts_platform_native_backend_and_uses_rank_local_heads
|
||||
original_backend_per_kind = {"full": object()}
|
||||
config = SimpleNamespace(
|
||||
attention_config=SimpleNamespace(backend=None, backend_per_kind=original_backend_per_kind),
|
||||
parallel_config=SimpleNamespace(prefill_context_parallel_size=2),
|
||||
model_config=SimpleNamespace(dtype=torch.float16),
|
||||
cache_config=SimpleNamespace(cache_dtype="auto"),
|
||||
)
|
||||
|
||||
def select_backend(**_kwargs):
|
||||
selected_backends.append((config.attention_config.backend, config.attention_config.backend_per_kind))
|
||||
selected_backends.append(
|
||||
(
|
||||
config.attention_config.backend,
|
||||
config.attention_config.backend_per_kind,
|
||||
config.parallel_config.prefill_context_parallel_size,
|
||||
)
|
||||
)
|
||||
return native_backend
|
||||
|
||||
monkeypatch.setattr(adapter_module, "get_attn_backend", select_backend)
|
||||
monkeypatch.setattr(adapter_module, "set_current_vllm_config", lambda _config: nullcontext())
|
||||
monkeypatch.setattr(
|
||||
adapter_module.current_omni_platform,
|
||||
"get_diffusion_paged_kv_attn_backend",
|
||||
lambda backend, *, ulysses_degree: specialized_backends.append((backend, ulysses_degree)) or backend,
|
||||
)
|
||||
layer = SimpleNamespace(num_heads=8, softmax_scale=0.125)
|
||||
spec = FullAttentionSpec(
|
||||
block_size=16,
|
||||
@@ -1031,9 +1231,11 @@ def test_layer_adapter_accepts_platform_native_backend_and_uses_rank_local_heads
|
||||
ulysses_degree=2,
|
||||
)
|
||||
|
||||
assert selected_backends == [(adapter_module.AttentionBackendEnum.FLASH_ATTN, {})]
|
||||
assert selected_backends == [(adapter_module.AttentionBackendEnum.FLASH_ATTN, {}, 2)]
|
||||
assert specialized_backends == [(native_backend, 2)]
|
||||
assert config.attention_config.backend is None
|
||||
assert config.attention_config.backend_per_kind is original_backend_per_kind
|
||||
assert config.parallel_config.prefill_context_parallel_size == 2
|
||||
assert native_layer.num_heads == 4
|
||||
assert native_layer.num_kv_heads == 2
|
||||
assert native_layer.spec.num_kv_heads == 2
|
||||
@@ -1094,6 +1296,51 @@ def test_omni_attention_wraps_paged_kernel_with_sp_hooks() -> None:
|
||||
assert torch.equal(output, torch.full_like(original, 6))
|
||||
|
||||
|
||||
def test_omni_attention_strips_paged_ulysses_padding_around_kernel() -> None:
|
||||
events: list[tuple[str, tuple[int, ...]]] = []
|
||||
|
||||
class Strategy:
|
||||
name = "ulysses"
|
||||
|
||||
def pre_attention(self, query, key, value, metadata):
|
||||
return query, key, value, metadata, SimpleNamespace(joint_len=0, joint_strategy="front")
|
||||
|
||||
def post_attention(self, output, _ctx):
|
||||
events.append(("post", tuple(output.shape)))
|
||||
return output
|
||||
|
||||
class Adapter:
|
||||
def prepare_layer_context(self, _layer_name, query, key, value, *, omni_attn_metadata):
|
||||
assert omni_attn_metadata is None
|
||||
events.append(("kernel_input", tuple(query.shape)))
|
||||
assert query[:, -1].tolist() == [[[3.0]]]
|
||||
assert torch.equal(query, key)
|
||||
assert torch.equal(query, value)
|
||||
return SimpleNamespace(query=query)
|
||||
|
||||
class Backend:
|
||||
def forward_paged(self, context):
|
||||
return context.query
|
||||
|
||||
layer = Attention.__new__(Attention)
|
||||
nn.Module.__init__(layer)
|
||||
layer.prefix = "layer-0"
|
||||
layer.paged_kv_cache_role = "primary"
|
||||
layer.attn_backend = SimpleNamespace(supports_paged_kv=True, get_name=lambda: "FLASH_ATTN")
|
||||
layer.attention = Backend()
|
||||
layer.use_ring = False
|
||||
layer._no_parallel_strategy = object()
|
||||
layer._get_active_parallel_strategy = lambda: Strategy()
|
||||
tensor = torch.arange(5, dtype=torch.float32).reshape(1, 5, 1, 1)
|
||||
|
||||
with set_forward_context(), override_paged_kv_adapter(Adapter()):
|
||||
get_forward_context().sp_padding_size = 1
|
||||
output = layer._forward_impl(tensor, tensor, tensor)
|
||||
|
||||
assert events == [("kernel_input", (1, 4, 1, 1)), ("post", (1, 5, 1, 1))]
|
||||
assert output.flatten().tolist() == [0.0, 1.0, 2.0, 3.0, 0.0]
|
||||
|
||||
|
||||
def test_omni_attention_rejects_paged_request_for_backend_without_paged_support() -> None:
|
||||
class Adapter:
|
||||
def prepare_layer_context(self, *args, **kwargs):
|
||||
@@ -1145,3 +1392,15 @@ def test_omni_attention_keeps_dense_kernel_without_active_adapter() -> None:
|
||||
|
||||
assert events == ["pre", "dense", "post"]
|
||||
assert output is qkv
|
||||
|
||||
|
||||
def test_omni_attention_rejects_scheduler_paged_forward_without_active_adapter() -> None:
|
||||
layer = Attention.__new__(Attention)
|
||||
nn.Module.__init__(layer)
|
||||
layer._scheduler_paged_kv = True
|
||||
layer.paged_kv_cache_role = "primary"
|
||||
layer._get_active_parallel_strategy = lambda: object()
|
||||
qkv = torch.zeros(1, 2, 2, 4)
|
||||
|
||||
with set_forward_context(), pytest.raises(RuntimeError, match="without an active Worker adapter"):
|
||||
layer._forward_impl(qkv, qkv, qkv)
|
||||
@@ -154,8 +154,17 @@ def test_adapter_executes_native_paged_attention_on_non_contiguous_blocks() -> N
|
||||
|
||||
torch.manual_seed(1)
|
||||
query = torch.randn(19, _NUM_HEADS, _HEAD_SIZE, dtype=torch.float16, device=device)
|
||||
key = torch.randn_like(query)
|
||||
value = torch.randn_like(query)
|
||||
packed_qkv = torch.randn(
|
||||
19,
|
||||
3 * _NUM_HEADS * _HEAD_SIZE,
|
||||
dtype=torch.float16,
|
||||
device=device,
|
||||
)
|
||||
_, key_flat, value_flat = packed_qkv.split(_NUM_HEADS * _HEAD_SIZE, dim=-1)
|
||||
key = key_flat.reshape(19, _NUM_HEADS, _HEAD_SIZE)
|
||||
value = value_flat.reshape(19, _NUM_HEADS, _HEAD_SIZE)
|
||||
assert not key.is_contiguous()
|
||||
assert not value.is_contiguous()
|
||||
omni_backend = FlashAttentionImpl(
|
||||
num_heads=_NUM_HEADS,
|
||||
head_size=_HEAD_SIZE,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
@@ -42,9 +42,10 @@ class _RunnerKVBackend:
|
||||
self.kv_cache_config = config
|
||||
|
||||
|
||||
def _attention(*, enabled: bool) -> Attention:
|
||||
def _attention(*, enabled: bool, prefix: str = "image_attention") -> Attention:
|
||||
attention = Attention.__new__(Attention)
|
||||
nn.Module.__init__(attention)
|
||||
attention.prefix = prefix
|
||||
attention.paged_kv_cache_role = "primary" if enabled else None
|
||||
attention.paged_kv_cache_dtype = torch.bfloat16
|
||||
attention.num_kv_heads = 2
|
||||
@@ -82,6 +83,23 @@ def test_runner_discovers_native_spec_from_loaded_attention() -> None:
|
||||
assert spec.non_causal is True
|
||||
|
||||
|
||||
def test_runner_uses_attention_prefix_as_canonical_layer_name() -> None:
|
||||
runner = _runner(_attention(enabled=True, prefix="layers.0.self_attn.image_attn.attn"))
|
||||
|
||||
specs = runner.get_kv_cache_spec()
|
||||
|
||||
assert set(specs) == {"layers.0.self_attn.image_attn.attn"}
|
||||
|
||||
|
||||
def test_runner_rejects_duplicate_attention_prefixes() -> None:
|
||||
runner = _runner(_attention(enabled=True, prefix="shared"))
|
||||
second_attention = _attention(enabled=True, prefix="shared")
|
||||
runner.pipeline.second_image_attention = second_attention
|
||||
|
||||
with pytest.raises(RuntimeError, match="Duplicate canonical paged Diffusion Attention prefix 'shared'"):
|
||||
runner.get_kv_cache_spec()
|
||||
|
||||
|
||||
def test_runner_rejects_paged_mode_without_cache_enabled_attention() -> None:
|
||||
runner = _runner(_attention(enabled=False))
|
||||
|
||||
|
||||
@@ -42,7 +42,10 @@ def make_metadata(request_id: str = "req-0") -> DiffusionKVMetadata:
|
||||
|
||||
def make_runner(mode: DiffusionKVCacheMode) -> DiffusionModelRunner:
|
||||
runner = object.__new__(DiffusionModelRunner)
|
||||
runner.od_config = SimpleNamespace(diffusion_kv_mode=mode)
|
||||
runner.od_config = SimpleNamespace(
|
||||
diffusion_kv_mode=mode,
|
||||
parallel_config=SimpleNamespace(cfg_parallel_size=1),
|
||||
)
|
||||
return runner
|
||||
|
||||
|
||||
@@ -85,6 +88,51 @@ def test_new_request_data_carries_scheduler_allocation_atomically() -> None:
|
||||
assert new_req.diffusion_kv_metadata is metadata
|
||||
|
||||
|
||||
def test_runner_builds_prefill_and_denoise_rows_from_scheduler_metadata() -> None:
|
||||
runner = make_runner(DiffusionKVCacheMode.PAGED_SCHEDULER)
|
||||
metadata = DiffusionKVMetadata(
|
||||
request_id="req-0",
|
||||
allocation_generation=1,
|
||||
sequences=(
|
||||
DiffusionKVSequenceMetadata(0, 4, 2, 8, ([1, 2],)),
|
||||
DiffusionKVSequenceMetadata(1, 5, 2, 9, ([3, 4],)),
|
||||
),
|
||||
)
|
||||
|
||||
attn_metadata = runner._build_paged_attention_metadata([metadata])
|
||||
|
||||
assert [
|
||||
(row.request_id, row.sequence_id, row.kv_start_pos, row.query_len, row.seq_len)
|
||||
for row in attn_metadata.prefill_rows
|
||||
] == [("req-0", 0, 0, 8, 8), ("req-0", 1, 0, 9, 9)]
|
||||
assert [
|
||||
(row.request_id, row.sequence_id, row.kv_start_pos, row.query_len, row.seq_len)
|
||||
for row in attn_metadata.denoise_rows
|
||||
] == [("req-0", 0, 4, 2, 6), ("req-0", 1, 5, 2, 7)]
|
||||
|
||||
|
||||
def test_runner_selects_only_local_cfg_parallel_row(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
runner = make_runner(DiffusionKVCacheMode.PAGED_SCHEDULER)
|
||||
runner.od_config.parallel_config.cfg_parallel_size = 2
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.diffusion.worker.diffusion_model_runner.get_classifier_free_guidance_rank",
|
||||
lambda: 1,
|
||||
)
|
||||
metadata = DiffusionKVMetadata(
|
||||
request_id="req-0",
|
||||
allocation_generation=1,
|
||||
sequences=(
|
||||
DiffusionKVSequenceMetadata(0, 4, 2, 8, ([1, 2],)),
|
||||
DiffusionKVSequenceMetadata(1, 5, 2, 9, ([3, 4],)),
|
||||
),
|
||||
)
|
||||
|
||||
attn_metadata = runner._build_paged_attention_metadata([metadata])
|
||||
|
||||
assert [row.sequence_id for row in attn_metadata.prefill_rows] == [1]
|
||||
assert [row.sequence_id for row in attn_metadata.denoise_rows] == [1]
|
||||
|
||||
|
||||
def test_paged_request_without_metadata_fails_before_request_forward() -> None:
|
||||
runner = make_runner(DiffusionKVCacheMode.PAGED_SCHEDULER)
|
||||
runner._execute_request_list = Mock()
|
||||
|
||||
@@ -43,21 +43,16 @@ def test_jit_build_includes_cuda_headers_from_nvidia_wheels(tmp_path, monkeypatc
|
||||
|
||||
@dataclass
|
||||
class _FakeAllocation:
|
||||
"""Byte workspace stand-in that slices and reinterprets like ``symm_mem.empty()``."""
|
||||
|
||||
size: int
|
||||
device: torch.device
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.buffer = torch.empty(self.size, dtype=torch.uint8)
|
||||
|
||||
class _FakeHandle:
|
||||
rank = 0
|
||||
|
||||
def __init__(self, allocation: _FakeAllocation) -> None:
|
||||
self.allocation = allocation
|
||||
self.views: list[tuple[tuple[int, ...], torch.dtype]] = []
|
||||
|
||||
def get_buffer(self, rank, shape, dtype):
|
||||
assert rank == self.rank
|
||||
self.views.append((shape, dtype))
|
||||
return self.allocation, shape, dtype
|
||||
def __getitem__(self, item: slice) -> torch.Tensor:
|
||||
return self.buffer[item]
|
||||
|
||||
|
||||
def test_workspace_reuses_peak_capacity_across_shapes(monkeypatch) -> None:
|
||||
@@ -93,16 +88,20 @@ def test_workspace_reuses_peak_capacity_across_shapes(monkeypatch) -> None:
|
||||
return allocation
|
||||
|
||||
monkeypatch.setattr(a2a_permute.symm_mem, "empty", empty)
|
||||
monkeypatch.setattr(a2a_permute.symm_mem, "rendezvous", lambda allocation, _group: _FakeHandle(allocation))
|
||||
# The handle is only retained to keep the rendezvous registration alive.
|
||||
monkeypatch.setattr(a2a_permute.symm_mem, "rendezvous", lambda _allocation, _group: object())
|
||||
|
||||
device = torch.device("cuda:0")
|
||||
first = a2a_permute._get_symm_buffer((2, 3), torch.float16, device, "group")
|
||||
smaller = a2a_permute._get_symm_buffer((1, 4), torch.float16, device, "group")
|
||||
larger = a2a_permute._get_symm_buffer((4, 3), torch.float16, device, "group")
|
||||
|
||||
assert first[0].size == 12
|
||||
assert smaller[0] is first[0]
|
||||
assert larger[0].size == 24
|
||||
assert (first.shape, first.dtype) == ((2, 3), torch.float16)
|
||||
assert (smaller.shape, smaller.dtype) == ((1, 4), torch.float16)
|
||||
assert (larger.shape, larger.dtype) == ((4, 3), torch.float16)
|
||||
# A sub-capacity request reuses the workspace; only growth allocates again.
|
||||
assert smaller.untyped_storage().data_ptr() == first.untyped_storage().data_ptr()
|
||||
assert larger.untyped_storage().data_ptr() != first.untyped_storage().data_ptr()
|
||||
assert [allocation.size for allocation in allocations] == [12, 24]
|
||||
assert len(a2a_permute._SYMM_WORKSPACES) == 1
|
||||
assert len(all_reduce_calls) == 1
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
import pytest
|
||||
@@ -106,7 +109,7 @@ def make_dummy_lora_state_dict(
|
||||
"""
|
||||
key_a = f"{base_key}.{lora_a_suffix}"
|
||||
key_b = f"{base_key}.{lora_b_suffix}"
|
||||
key_bias = f"{base_key}.{lora_bias_suffix}" if bias else None
|
||||
key_bias = f"{base_key}.{lora_bias_suffix}"
|
||||
|
||||
if weight_a is None:
|
||||
weight_a = torch.randn(RANK, HEAD_DIM)
|
||||
@@ -436,3 +439,54 @@ def test_wan_load_lora_weights_targets_both_transformers(mocker: MockerFixture):
|
||||
assert_params_equal(pipeline.transformer, original_high)
|
||||
assert_params_equal(pipeline.transformer_2, original_low)
|
||||
assert "adapter0" not in pipeline.lora_loaded
|
||||
|
||||
|
||||
def test_prepare_lora_delta_disambiguates_prefix_overlapping_stacked_names():
|
||||
"""A fused name can be a prefix of another fused name.
|
||||
|
||||
SenseNova-U1 carries both `.qkv_proj` (understanding) and `.qkv_proj_mot_gen`
|
||||
(generation). Selecting stacked mappings by substring matched both rules for
|
||||
the generation weight and concatenated six shards into a delta twice as tall
|
||||
as the parameter.
|
||||
"""
|
||||
param_to_weight_names = {
|
||||
".qkv_proj_mot_gen": [".q_proj_mot_gen", ".k_proj_mot_gen", ".v_proj_mot_gen"],
|
||||
".qkv_proj": [".q_proj", ".k_proj", ".v_proj"],
|
||||
}
|
||||
state_dict = {}
|
||||
for part in ("q", "k", "v"):
|
||||
for tower in ("_mot_gen", ""):
|
||||
base = f"layers.0.self_attn.{part}_proj{tower}"
|
||||
state_dict[f"{base}.lora_A.weight"] = torch.ones(RANK, HEAD_DIM)
|
||||
state_dict[f"{base}.lora_B.weight"] = torch.ones(HEAD_DIM, RANK)
|
||||
|
||||
for base_key in ("layers.0.self_attn.qkv_proj_mot_gen", "layers.0.self_attn.qkv_proj"):
|
||||
delta, _ = _prepare_lora_delta(state_dict, base_key, param_to_weight_names)
|
||||
assert delta.shape == (3 * HEAD_DIM, HEAD_DIM), base_key
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fused, shards",
|
||||
[
|
||||
# qwen_image_transformer.py stacked_params_mapping
|
||||
(".to_qkv", (".to_q", ".to_k", ".to_v")),
|
||||
(".add_kv_proj", (".add_q_proj", ".add_k_proj", ".add_v_proj")),
|
||||
# wan2_2_transformer.py stacked_params_mapping
|
||||
(".attn1.to_qkv", (".attn1.to_q", ".attn1.to_k", ".attn1.to_v")),
|
||||
# wan2_2_s2v_transformer.py stacked_params_mapping
|
||||
(".self_attn.to_qkv", (".self_attn.q", ".self_attn.k", ".self_attn.v")),
|
||||
],
|
||||
)
|
||||
def test_prepare_lora_delta_unchanged_for_existing_fused_layouts(fused, shards):
|
||||
"""None of the fused names shipped today overlap by prefix, so matching on the
|
||||
tail must select exactly the shards substring matching selected."""
|
||||
param_to_weight_names = {fused: list(shards)}
|
||||
base_key = f"blocks.0{fused}"
|
||||
state_dict = {}
|
||||
for shard in shards:
|
||||
state_dict[f"blocks.0{shard}.lora_A.weight"] = torch.ones(RANK, HEAD_DIM)
|
||||
state_dict[f"blocks.0{shard}.lora_B.weight"] = torch.ones(HEAD_DIM, RANK)
|
||||
|
||||
delta, used_keys = _prepare_lora_delta(state_dict, base_key, param_to_weight_names)
|
||||
assert delta.shape == (len(shards) * HEAD_DIM, HEAD_DIM)
|
||||
assert len(used_keys) == 2 * len(shards)
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""L1 unit tests for the native Boogu-Image pipeline.
|
||||
|
||||
@@ -133,6 +133,51 @@ def test_constructor_strips_mllm_lm_head(boogu_pipeline, mock_dependencies):
|
||||
assert boogu_pipeline.mllm is mock_dependencies["inner_encoder"]
|
||||
|
||||
|
||||
def test_constructor_forwards_revision_to_all_component_loaders(mock_dependencies, mocker):
|
||||
from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import (
|
||||
BooguImagePipeline,
|
||||
)
|
||||
|
||||
revision = "hotfix-1k-20260708"
|
||||
prefetch = mocker.patch(f"{_MODULE}.prefetch_subfolders")
|
||||
scheduler_loader = mocker.patch(
|
||||
f"{_MODULE}.FlowMatchEulerDiscreteScheduler.from_pretrained",
|
||||
return_value=mock_dependencies["scheduler"],
|
||||
)
|
||||
mllm_loader = mocker.patch(
|
||||
f"{_MODULE}.Qwen3VLForConditionalGeneration.from_pretrained",
|
||||
return_value=mock_dependencies["mllm_wrapper"],
|
||||
)
|
||||
processor_loader = mocker.patch(
|
||||
f"{_MODULE}.Qwen3VLProcessor.from_pretrained",
|
||||
return_value=mock_dependencies["processor"],
|
||||
)
|
||||
vae_loader = mocker.patch(
|
||||
f"{_MODULE}.AutoencoderKL.from_pretrained",
|
||||
return_value=mock_dependencies["vae"],
|
||||
)
|
||||
od_config = OmniDiffusionConfig(
|
||||
model="dummy-boogu",
|
||||
revision=revision,
|
||||
tf_model_config=TransformerConfig(params={}),
|
||||
dtype=torch.float32,
|
||||
num_gpus=1,
|
||||
)
|
||||
|
||||
pipeline = BooguImagePipeline(od_config=od_config)
|
||||
|
||||
(source,) = pipeline.weights_sources
|
||||
assert source.revision == revision
|
||||
prefetch.assert_called_once_with(
|
||||
"dummy-boogu",
|
||||
["scheduler", "vae", "mllm", "processor"],
|
||||
local_files_only=True,
|
||||
revision=revision,
|
||||
)
|
||||
for loader in (scheduler_loader, mllm_loader, processor_loader, vae_loader):
|
||||
assert loader.call_args.kwargs["revision"] == revision
|
||||
|
||||
|
||||
def test_constructor_weights_sources(boogu_pipeline):
|
||||
(source,) = boogu_pipeline.weights_sources
|
||||
assert source.model_or_path == "dummy-boogu"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -270,6 +270,17 @@ def test_grouped_denoise_allows_sdpa_attention_backend():
|
||||
pipeline._ensure_grouped_attention_backend_supported(2)
|
||||
|
||||
|
||||
def test_scheduler_paged_step_execution_is_rejected():
|
||||
pipeline = _pipeline()
|
||||
pipeline.od_config.diffusion_kv_mode = hy3_module.DiffusionKVCacheMode.PAGED_SCHEDULER
|
||||
state = _state("paged-step", 0)
|
||||
|
||||
with pytest.raises(ValueError, match="request-level execution only"):
|
||||
pipeline.prepare_encode(state)
|
||||
with pytest.raises(ValueError, match="request-level execution only"):
|
||||
pipeline.denoise_step(InputBatch.make_batch([state]))
|
||||
|
||||
|
||||
def test_step_scheduler_preserves_latent_dtype_for_mixed_progress_batches():
|
||||
pipeline = _pipeline()
|
||||
pipeline._pipeline = SimpleNamespace(prepare_extra_func_kwargs=lambda step, kwargs: {})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Unit tests for ImageKVCacheManager.
|
||||
|
||||
Covers: cache → reuse flow, AR KV injection, CFG (sequential & parallel), SP, cross-request isolation.
|
||||
@@ -37,12 +37,21 @@ class MockAttention(nn.Module):
|
||||
super().__init__()
|
||||
self.paged_kv_active = False
|
||||
self.calls = []
|
||||
self.paged_calls = []
|
||||
self.paged_metadata = []
|
||||
|
||||
def is_paged_kv_active(self):
|
||||
return self.paged_kv_active
|
||||
|
||||
def forward(self, query, key, value, attn_metadata=None, **kwargs):
|
||||
self.calls.append((query, key, value))
|
||||
from vllm_omni.diffusion.forward_context import get_forward_context, is_forward_context_available
|
||||
|
||||
if is_forward_context_available() and get_forward_context().paged_kv_adapter is not None:
|
||||
self.paged_calls.append((query, key, value))
|
||||
self.paged_metadata.append(attn_metadata)
|
||||
if attn_metadata is not None and attn_metadata.joint_query is not None:
|
||||
return torch.cat((attn_metadata.joint_query, query), dim=1)
|
||||
return query
|
||||
|
||||
|
||||
@@ -105,6 +114,7 @@ def _call_mgr(
|
||||
shard_image_size=None,
|
||||
gen_timestep_scatter_index=None,
|
||||
position_ids=None,
|
||||
full_attn_spans=None,
|
||||
):
|
||||
query = torch.randn(bs * q_len, NUM_HEADS, HEAD_DIM)
|
||||
attn_mask = torch.zeros(bs, 1, seq_len, seq_len)
|
||||
@@ -121,6 +131,7 @@ def _call_mgr(
|
||||
shard_image_size=shard_image_size,
|
||||
gen_timestep_scatter_index=gen_timestep_scatter_index,
|
||||
position_ids=position_ids,
|
||||
full_attn_spans=full_attn_spans,
|
||||
)
|
||||
|
||||
|
||||
@@ -132,6 +143,83 @@ def test_cache_manager_registers_attention_without_adding_dense_state() -> None:
|
||||
assert mgr.state_dict() == {}
|
||||
|
||||
|
||||
def test_scheduler_paged_kv_runs_piecewise_for_first_and_later_steps() -> None:
|
||||
from vllm_omni.diffusion.forward_context import override_paged_kv_adapter, set_forward_context
|
||||
|
||||
mgr = _make_cache_mgr()
|
||||
mgr.attn.paged_kv_active = True
|
||||
bs = 2
|
||||
prefix_lens = torch.tensor([[2], [4]], dtype=torch.long)
|
||||
first_q_len = 12
|
||||
first_k, first_v = _make_known_kv(bs * first_q_len, base=1.0)
|
||||
full_attn_spans = [[(2, 12)], [(2, 12)]]
|
||||
|
||||
with set_forward_context(), override_paged_kv_adapter(object()):
|
||||
_call_mgr(
|
||||
mgr,
|
||||
bs,
|
||||
q_len=first_q_len,
|
||||
seq_len=first_q_len,
|
||||
key_flat=first_k,
|
||||
value_flat=first_v,
|
||||
first_step=True,
|
||||
gen_timestep_scatter_index=prefix_lens,
|
||||
full_attn_spans=full_attn_spans,
|
||||
)
|
||||
|
||||
assert mgr.image_kv_cache_map is None
|
||||
assert mgr.image_kv_cache_lens is None
|
||||
assert len(mgr.attn.paged_calls) == 1
|
||||
assert mgr.attn.paged_calls[0][0].shape == (bs, first_q_len, NUM_HEADS, HEAD_DIM)
|
||||
assert mgr.attn.paged_calls[0][1].shape == (bs, first_q_len, NUM_KV_HEADS, HEAD_DIM)
|
||||
assert mgr.attn.paged_calls[0][2].shape == (bs, first_q_len, NUM_KV_HEADS, HEAD_DIM)
|
||||
assert mgr.attn.paged_metadata[0].full_attn_spans == full_attn_spans
|
||||
|
||||
current_q_len = IMAGE_TOKEN_LEN
|
||||
current_k, current_v = _make_known_kv(bs * current_q_len, base=50.0)
|
||||
_call_mgr(
|
||||
mgr,
|
||||
bs,
|
||||
q_len=current_q_len,
|
||||
seq_len=prefix_lens[-1].item() + current_q_len,
|
||||
key_flat=current_k,
|
||||
value_flat=current_v,
|
||||
position_ids=torch.arange(4, 4 + current_q_len).repeat(bs, 1),
|
||||
full_attn_spans=full_attn_spans,
|
||||
)
|
||||
|
||||
assert len(mgr.attn.paged_calls) == 2
|
||||
assert mgr.attn.paged_calls[1][0].shape == (bs, current_q_len, NUM_HEADS, HEAD_DIM)
|
||||
assert mgr.attn.paged_calls[1][1].shape == (bs, current_q_len, NUM_KV_HEADS, HEAD_DIM)
|
||||
assert mgr.attn.paged_calls[1][2].shape == (bs, current_q_len, NUM_KV_HEADS, HEAD_DIM)
|
||||
assert mgr.attn.paged_metadata[1].full_attn_spans == full_attn_spans
|
||||
|
||||
|
||||
def test_cache_manager_rejects_dense_imported_prefix_for_paged_attention() -> None:
|
||||
from vllm_omni.diffusion.forward_context import override_paged_kv_adapter, set_forward_context
|
||||
|
||||
mgr = _make_cache_mgr()
|
||||
mgr.attn.paged_kv_active = True
|
||||
prefix_len = 4
|
||||
prefix_key, prefix_value = _make_known_kv(prefix_len, base=100.0)
|
||||
mgr._injected_ar_kv = [(prefix_key, prefix_value)]
|
||||
current_key, current_value = _make_known_kv(3, base=1.0)
|
||||
|
||||
with set_forward_context(), override_paged_kv_adapter(object()):
|
||||
with pytest.raises(NotImplementedError, match="does not support imported AR KV"):
|
||||
_call_mgr(
|
||||
mgr,
|
||||
bs=1,
|
||||
q_len=3,
|
||||
seq_len=7,
|
||||
key_flat=current_key,
|
||||
value_flat=current_value,
|
||||
first_step=True,
|
||||
gen_timestep_scatter_index=torch.tensor([[0]]),
|
||||
full_attn_spans=[[(4, 7)]],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("paged_kv_active", "expected_kv_heads"),
|
||||
[(False, NUM_HEADS), (True, NUM_KV_HEADS)],
|
||||
@@ -155,6 +243,7 @@ def test_gqa_kv_stays_compressed_for_paged_attention(
|
||||
first_step=True,
|
||||
num_image_tokens=0,
|
||||
gen_timestep_scatter_index=_gen_timestep_index(1, 0),
|
||||
full_attn_spans=[[(0, q_len)]] if paged_kv_active else None,
|
||||
)
|
||||
|
||||
_, key_input, value_input = mgr.attn.calls[-1]
|
||||
|
||||
@@ -57,7 +57,8 @@ def test_h3_prepares_resolved_cache_state_immediately_before_denoise():
|
||||
return torch.zeros(1), torch.zeros(1)
|
||||
|
||||
pipeline.diffuse = diffuse
|
||||
pipeline.decode = Mock(return_value=(torch.zeros(1), torch.zeros(1)))
|
||||
decoded_audio = torch.zeros(1)
|
||||
pipeline.decode = Mock(return_value=(torch.zeros(1, 3, 1, 1, 1), decoded_audio))
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
quality="high",
|
||||
width=1344,
|
||||
@@ -86,7 +87,9 @@ def test_h3_prepares_resolved_cache_state_immediately_before_denoise():
|
||||
num_inference_steps=50,
|
||||
extra_args={"task": "t2va", "aspect_ratio": "16:9"},
|
||||
)
|
||||
assert output.output == pipeline.decode.return_value
|
||||
assert output.output[0].shape == (1, 1, 1, 1, 3)
|
||||
assert output.output[0].dtype == torch.uint8
|
||||
assert output.output[1] is decoded_audio
|
||||
|
||||
|
||||
def test_pipeline_import_registry_and_component_discovery():
|
||||
@@ -493,18 +496,41 @@ def test_joint_postprocess_is_multiprocessing_picklable():
|
||||
|
||||
postprocess = get_minimax_h3_post_process_func(SimpleNamespace())
|
||||
postprocess = ForkingPickler.loads(ForkingPickler.dumps(postprocess))
|
||||
video = torch.linspace(0, 1, 2 * 3 * 2 * 4 * 5).reshape(2, 3, 2, 4, 5)
|
||||
# decode() hands over quantised frames already laid out as (B, T, H, W, C).
|
||||
video = torch.arange(2 * 2 * 4 * 5 * 3, dtype=torch.uint8).reshape(2, 2, 4, 5, 3)
|
||||
audio = torch.arange(12, dtype=torch.float32).reshape(1, 2, 6)
|
||||
|
||||
result = postprocess((video, audio), output_type="np")
|
||||
|
||||
assert isinstance(result["video"], list)
|
||||
assert result["video"][0].dtype == np.uint8
|
||||
assert result["video"][0].shape == (2, 4, 5, 3)
|
||||
np.testing.assert_array_equal(result["video"][0], video[0].numpy())
|
||||
np.testing.assert_array_equal(result["audio"], audio.numpy())
|
||||
assert result["audio_sample_rate"] == 32000
|
||||
assert result["fps"] == 24
|
||||
|
||||
|
||||
def test_joint_video_output_is_quantized_before_transfer():
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
|
||||
_minimax_h3_post_process,
|
||||
_prepare_minimax_h3_video_output,
|
||||
)
|
||||
|
||||
video = torch.linspace(-0.1, 1.1, 2 * 3 * 2 * 4 * 5, dtype=torch.float64).reshape(2, 3, 2, 4, 5)
|
||||
expected = torch.round(video.float().clamp(0, 1) * 255).to(torch.uint8).permute(0, 2, 3, 4, 1)
|
||||
prepared = _prepare_minimax_h3_video_output(video)
|
||||
|
||||
assert prepared.dtype == torch.uint8
|
||||
assert prepared.is_contiguous()
|
||||
assert prepared.shape == (2, 2, 4, 5, 3)
|
||||
torch.testing.assert_close(prepared, expected, rtol=0, atol=0)
|
||||
|
||||
result = _minimax_h3_post_process((prepared, torch.zeros(1, 2, 6)))
|
||||
assert result["video"][0].dtype == np.uint8
|
||||
np.testing.assert_array_equal(result["video"][0], expected[0].numpy())
|
||||
|
||||
|
||||
def test_cfg_parallel_is_rejected_for_distilled_checkpoint():
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
@@ -572,7 +598,7 @@ def test_base_schedule_overrides_the_uniform_sigma_positions():
|
||||
@pytest.mark.parametrize(
|
||||
"base_schedule",
|
||||
[
|
||||
[0.9, 0.5, 0.0],
|
||||
[1.5, 0.5, 0.0],
|
||||
[1.0, 0.5, 0.1],
|
||||
[1.0, 0.5, 0.5, 0.0],
|
||||
[1.0],
|
||||
@@ -627,7 +653,7 @@ def _distilled_pipeline(diffuse_calls, base_schedule_by_partition):
|
||||
return torch.zeros(1), torch.zeros(1)
|
||||
|
||||
pipeline.diffuse = diffuse
|
||||
pipeline.decode = Mock(return_value=(torch.zeros(1), torch.zeros(1)))
|
||||
pipeline.decode = Mock(return_value=(torch.zeros(1, 3, 1, 1, 1), torch.zeros(1)))
|
||||
return pipeline
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from safetensors.torch import save_file
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3.fasth3 import (
|
||||
FASTH3_BASE_MODEL,
|
||||
FASTH3_BASE_SCHEDULE,
|
||||
FASTH3_DENOISE_STEPS,
|
||||
FASTH3_FORMAT,
|
||||
FastH3AdapterError,
|
||||
FastH3WeightFusion,
|
||||
resolve_fasth3_fusion,
|
||||
)
|
||||
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import _reorder_grouped_qkv_to_qkv
|
||||
from vllm_omni.diffusion.models.minimax_h3.time_request import minimax_h3_time_shift_sigmas
|
||||
from vllm_omni.errors import OmniClientError
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
_RANK = 2
|
||||
_HIDDEN = 4
|
||||
_HEAD_DIM = 2
|
||||
_HEADS = 3
|
||||
_INNER = _HEAD_DIM * _HEADS # attention inner size
|
||||
_FFN = 5
|
||||
|
||||
|
||||
def _factors(out_dim: int, in_dim: int) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""A rank-2 pair whose product is reproducible and non-symmetric."""
|
||||
a = torch.arange(_RANK * in_dim, dtype=torch.float32).reshape(_RANK, in_dim) / (in_dim * _RANK)
|
||||
b = torch.arange(out_dim * _RANK, dtype=torch.float32).reshape(out_dim, _RANK) / (out_dim * _RANK)
|
||||
return a, b
|
||||
|
||||
|
||||
def _write_adapter(
|
||||
path,
|
||||
*,
|
||||
tensors=None,
|
||||
drop: str | None = None,
|
||||
blocks: int = 1,
|
||||
metadata: dict[str, str | None] | None = None,
|
||||
) -> None:
|
||||
"""Write an artifact in the published ``fastvideo-lora-v2`` shape."""
|
||||
payload: dict[str, torch.Tensor] = {}
|
||||
for block in range(blocks):
|
||||
for suffix, (out_dim, in_dim) in {
|
||||
"attn.to_q": (_INNER, _HIDDEN),
|
||||
"attn.to_k": (_INNER, _HIDDEN),
|
||||
"attn.to_v": (_INNER, _HIDDEN),
|
||||
"attn.to_out.0": (_HIDDEN, _INNER),
|
||||
"ff.net.0.proj": (2 * _FFN, _HIDDEN),
|
||||
"ff.net.2": (_HIDDEN, _FFN),
|
||||
}.items():
|
||||
a, b = _factors(out_dim, in_dim)
|
||||
payload[f"transformer_blocks.{block}.{suffix}.lora_A.weight"] = a
|
||||
payload[f"transformer_blocks.{block}.{suffix}.lora_B.weight"] = b
|
||||
payload.update(tensors or {})
|
||||
if drop is not None:
|
||||
payload.pop(drop, None)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# The published writer records what it emitted; mirror it so the fixtures
|
||||
# are as self-describing as the real artifact.
|
||||
declared = {
|
||||
"format": FASTH3_FORMAT,
|
||||
"finetuned_model": "FastVideo/FastVideo-FastH3-Dense-4-step-v1",
|
||||
"base_model": FASTH3_BASE_MODEL,
|
||||
"rank": str(_RANK),
|
||||
"low_rank_tensors": str(sum(1 for key in payload if key.endswith((".lora_A.weight", ".lora_B.weight")))),
|
||||
"diff_tensors": str(sum(1 for key in payload if key.endswith((".diff", ".diff_b")))),
|
||||
"set_weight_tensors": str(sum(1 for key in payload if key.endswith(".set_weight"))),
|
||||
}
|
||||
# safetensors metadata is string-only, so a ``None`` override drops the key.
|
||||
declared.update(metadata or {})
|
||||
save_file(payload, str(path), metadata={key: value for key, value in declared.items() if value is not None})
|
||||
|
||||
|
||||
def _claim(path, **kwargs) -> FastH3WeightFusion | None:
|
||||
"""Whatever from_path decides, including declining the artifact."""
|
||||
kwargs.setdefault("head_dim", _HEAD_DIM)
|
||||
kwargs.setdefault("num_blocks", 1)
|
||||
kwargs.setdefault("num_refiner_blocks", 0)
|
||||
return FastH3WeightFusion.from_path(path, **kwargs)
|
||||
|
||||
|
||||
def _load(path, **kwargs) -> FastH3WeightFusion:
|
||||
"""The same, for the tests that only make sense on a claimed adapter."""
|
||||
fusion = _claim(path, **kwargs)
|
||||
assert fusion is not None, f"{path} was not claimed as a FastH3 adapter"
|
||||
return fusion
|
||||
|
||||
|
||||
def test_only_an_artifact_carrying_the_release_identity_is_claimed(tmp_path):
|
||||
plain = tmp_path / "peft" / "adapter_model.safetensors"
|
||||
plain.parent.mkdir(parents=True)
|
||||
save_file({"transformer_blocks.0.attn.to_q.lora_A.weight": torch.ones((_RANK, _HIDDEN))}, str(plain))
|
||||
# No fastvideo-lora-v2 metadata: this is somebody else's LoRA and has to
|
||||
# stay on the dynamic route rather than being fused.
|
||||
assert _claim(plain.parent) is None
|
||||
assert _claim(tmp_path / "missing") is None
|
||||
|
||||
claimed = tmp_path / "fasth3" / "adapter_model.safetensors"
|
||||
_write_adapter(claimed)
|
||||
assert _claim(claimed.parent) is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"identity",
|
||||
[
|
||||
# An ordinary H3 adapter out of FastVideo's own extraction tools.
|
||||
{"finetuned_model": "someone/minimax-h3-style-lora"},
|
||||
# Somebody else's adapter that merely names the student it imitates.
|
||||
{"finetuned_model": "someone/fasth3-style-lora-for-h3"},
|
||||
{"finetuned_model": ""},
|
||||
{"finetuned_model": None},
|
||||
# A FastH3 name over a different base model.
|
||||
{"base_model": "Wan-AI/Wan2.2-TI2V-5B"},
|
||||
],
|
||||
)
|
||||
def test_a_generic_fastvideo_h3_adapter_stays_on_the_dynamic_route(identity, tmp_path):
|
||||
"""The fixture edits every block, so only the identity separates it from FastH3."""
|
||||
path = tmp_path / "generic" / "adapter_model.safetensors"
|
||||
_write_adapter(path, metadata=identity)
|
||||
|
||||
assert _claim(path.parent) is None
|
||||
|
||||
|
||||
def test_a_claimed_artifact_must_declare_every_tensor_count(tmp_path):
|
||||
"""Declining is for other people's adapters; a FastH3 file is held to its word."""
|
||||
path = tmp_path / "fasth3" / "adapter_model.safetensors"
|
||||
_write_adapter(path, metadata={"diff_tensors": None})
|
||||
|
||||
with pytest.raises(FastH3AdapterError, match="omits diff_tensors"):
|
||||
_claim(path.parent)
|
||||
|
||||
|
||||
def test_the_published_bundle_root_is_refused_rather_than_guessed(tmp_path):
|
||||
root = tmp_path / "FastVideo-FastH3-4-step-Preview-v1-LoRA"
|
||||
for slug in ("dense-datafree", "vsa-datafree"):
|
||||
_write_adapter(root / slug / "adapter_model.safetensors")
|
||||
(root / "adapter_manifest.json").write_text(json.dumps({"schema_version": "fasth3-lora-bundle-v1"}))
|
||||
|
||||
with pytest.raises(FastH3AdapterError, match="point --lora-path at one variant"):
|
||||
_load(root)
|
||||
# One variant inside it is unambiguous.
|
||||
assert _claim(root / "dense-datafree") is not None
|
||||
|
||||
|
||||
def test_a_multi_shard_non_fasth3_adapter_stays_on_the_dynamic_route(tmp_path):
|
||||
# A plain PEFT LoRA saved as several shards must not hard-fail startup with a
|
||||
# FastH3-specific message; it has to fall through to the dynamic LoRA route.
|
||||
directory = tmp_path / "peft-sharded"
|
||||
directory.mkdir()
|
||||
for shard in ("adapter_model-00001-of-00002.safetensors", "adapter_model-00002-of-00002.safetensors"):
|
||||
save_file(
|
||||
{"transformer_blocks.0.attn.to_q.lora_A.weight": torch.ones((_RANK, _HIDDEN))}, str(directory / shard)
|
||||
)
|
||||
|
||||
assert _claim(directory) is None
|
||||
|
||||
|
||||
def test_low_rank_factors_reach_the_fused_projections(tmp_path):
|
||||
path = tmp_path / "fasth3" / "adapter_model.safetensors"
|
||||
_write_adapter(path)
|
||||
fusion = _load(path.parent)
|
||||
|
||||
qkv = torch.zeros((3 * _INNER, _HIDDEN), dtype=torch.float32)
|
||||
fused = fusion.fuse("blocks.0.attn.qkv_proj.weight", qkv).cpu()
|
||||
# The checkpoint stores one head group at a time as [q, k, v], so the delta
|
||||
# has to survive the loader's own unpacking as three separate projections.
|
||||
q, k, v = torch.split(
|
||||
_reorder_grouped_qkv_to_qkv(fused, num_query_groups=_HEADS, heads_per_group=1, head_dim=_HEAD_DIM),
|
||||
[_INNER, _INNER, _INNER],
|
||||
)
|
||||
a, b = _factors(_INNER, _HIDDEN)
|
||||
expected = b @ a
|
||||
for got in (q, k, v):
|
||||
assert torch.allclose(got, expected, atol=1e-5)
|
||||
|
||||
|
||||
def test_the_fused_mlp_delta_is_swapped_into_gate_first_order(tmp_path):
|
||||
path = tmp_path / "fasth3" / "adapter_model.safetensors"
|
||||
_write_adapter(path)
|
||||
fusion = _load(path.parent)
|
||||
|
||||
fused = fusion.fuse("blocks.0.mlp.fc1.weight", torch.zeros((2 * _FFN, _HIDDEN))).cpu()
|
||||
a, b = _factors(2 * _FFN, _HIDDEN)
|
||||
value, gate = (b @ a).chunk(2, dim=0)
|
||||
# The diffusers export is value-first; H3's fc1 is gate-first.
|
||||
assert torch.allclose(fused, torch.cat((gate, value), dim=0), atol=1e-5)
|
||||
|
||||
|
||||
def test_diff_and_diff_b_edit_weights_and_biases(tmp_path):
|
||||
path = tmp_path / "fasth3" / "adapter_model.safetensors"
|
||||
_write_adapter(
|
||||
path,
|
||||
tensors={
|
||||
"transformer_blocks.0.norm1.diff": torch.full((_HIDDEN,), 0.25),
|
||||
"transformer_blocks.0.adaln_proj.linear.diff_b": torch.full((_HIDDEN,), -0.5),
|
||||
"context_embedder.diff": torch.full((_HIDDEN, _HIDDEN), 0.125),
|
||||
"context_embedder.diff_b": torch.full((_HIDDEN,), 2.0),
|
||||
},
|
||||
)
|
||||
fusion = _load(path.parent)
|
||||
|
||||
# A full-rank delta on an RMSNorm vector is exactly what a LoRA layer
|
||||
# cannot express, and why this release is fused instead of switched.
|
||||
assert torch.allclose(fusion.fuse("blocks.0.norm1.weight", torch.ones(_HIDDEN)).cpu(), torch.full((_HIDDEN,), 1.25))
|
||||
assert torch.allclose(
|
||||
fusion.fuse("blocks.0.adaln_proj.linear.bias", torch.ones(_HIDDEN)).cpu(), torch.full((_HIDDEN,), 0.5)
|
||||
)
|
||||
assert torch.allclose(
|
||||
fusion.fuse("condition_proj.weight", torch.zeros((_HIDDEN, _HIDDEN))).cpu(),
|
||||
torch.full((_HIDDEN, _HIDDEN), 0.125),
|
||||
)
|
||||
assert torch.allclose(fusion.fuse("condition_proj.bias", torch.zeros(_HIDDEN)).cpu(), torch.full((_HIDDEN,), 2.0))
|
||||
|
||||
|
||||
def test_a_streamed_checkpoint_keeps_the_parameters_the_adapter_never_edits(tmp_path):
|
||||
path = tmp_path / "fasth3" / "adapter_model.safetensors"
|
||||
_write_adapter(path, tensors={"transformer_blocks.0.norm1.diff": torch.full((_HIDDEN,), 3.0)})
|
||||
fusion = _load(path.parent)
|
||||
|
||||
untouched = torch.arange(6, dtype=torch.bfloat16).reshape(2, 3)
|
||||
streamed = dict(
|
||||
fusion.apply([("blocks.0.norm1.weight", torch.zeros(_HIDDEN)), ("blocks.0.attn.q_norm.weight", untouched)])
|
||||
)
|
||||
assert torch.allclose(streamed["blocks.0.norm1.weight"].cpu(), torch.full((_HIDDEN,), 3.0))
|
||||
assert streamed["blocks.0.attn.q_norm.weight"] is untouched
|
||||
|
||||
# validate_fully_applied() releases the deltas, so a second stream would
|
||||
# fuse nothing at all; it has to fail rather than serve base H3 weights.
|
||||
with pytest.raises(FastH3AdapterError, match="already been fused"):
|
||||
next(fusion.apply([("blocks.0.norm1.weight", torch.zeros(_HIDDEN))]))
|
||||
|
||||
|
||||
def test_the_fused_result_keeps_the_parameter_dtype(tmp_path):
|
||||
path = tmp_path / "fasth3" / "adapter_model.safetensors"
|
||||
_write_adapter(path, tensors={"transformer_blocks.0.norm1.diff": torch.full((_HIDDEN,), 0.5)})
|
||||
fusion = _load(path.parent)
|
||||
|
||||
fused = fusion.fuse("blocks.0.norm1.weight", torch.ones(_HIDDEN, dtype=torch.bfloat16))
|
||||
assert fused.dtype == torch.bfloat16
|
||||
|
||||
|
||||
def test_a_vsa_variant_is_recognised_by_its_compression_gates(tmp_path):
|
||||
dense = tmp_path / "dense" / "adapter_model.safetensors"
|
||||
_write_adapter(dense)
|
||||
assert _load(dense.parent).requires_vsa is False
|
||||
|
||||
sparse = tmp_path / "vsa" / "adapter_model.safetensors"
|
||||
_write_adapter(sparse, tensors={"transformer_blocks.0.attn.to_gate_compress.set_weight": torch.ones((2, 2))})
|
||||
assert _load(sparse.parent).requires_vsa is True
|
||||
|
||||
|
||||
def test_a_tensor_naming_no_h3_parameter_is_an_error(tmp_path):
|
||||
path = tmp_path / "fasth3" / "adapter_model.safetensors"
|
||||
_write_adapter(path, tensors={"transformer_blocks.0.attn.norm_q.lora_A.weight": torch.ones((_RANK, _HIDDEN))})
|
||||
# Dropping it silently would load a model that is not the distilled student.
|
||||
with pytest.raises(FastH3AdapterError, match="name no known"):
|
||||
_load(path.parent)
|
||||
|
||||
|
||||
def test_an_unpaired_low_rank_factor_is_an_error(tmp_path):
|
||||
path = tmp_path / "fasth3" / "adapter_model.safetensors"
|
||||
_write_adapter(path, drop="transformer_blocks.0.attn.to_q.lora_B.weight")
|
||||
|
||||
with pytest.raises(FastH3AdapterError, match="unpaired factor"):
|
||||
_load(path.parent)
|
||||
|
||||
|
||||
def test_a_delta_the_checkpoint_never_offered_is_reported(tmp_path):
|
||||
path = tmp_path / "fasth3" / "adapter_model.safetensors"
|
||||
_write_adapter(path)
|
||||
fusion = _load(path.parent)
|
||||
|
||||
fusion.fuse("blocks.0.attn.qkv_proj.weight", torch.zeros((3 * _INNER, _HIDDEN)))
|
||||
# An unapplied delta is the failure that matters: the model would load and
|
||||
# generate, just not as the distilled student.
|
||||
with pytest.raises(FastH3AdapterError, match="never provided"):
|
||||
fusion.validate_fully_applied()
|
||||
|
||||
for name, shape in (
|
||||
("blocks.0.attn.out_proj.weight", (_HIDDEN, _INNER)),
|
||||
("blocks.0.mlp.fc1.weight", (2 * _FFN, _HIDDEN)),
|
||||
("blocks.0.mlp.fc2.weight", (_HIDDEN, _FFN)),
|
||||
):
|
||||
fusion.fuse(name, torch.zeros(shape))
|
||||
fusion.validate_fully_applied()
|
||||
|
||||
|
||||
def _pipeline_stub(fusion=None):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.partition = "combined"
|
||||
pipeline.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"})
|
||||
pipeline._turbo_lora_adapter_ids = set()
|
||||
pipeline._fasth3 = fusion
|
||||
pipeline.od_config = SimpleNamespace()
|
||||
pipeline.default_video_shift, pipeline.default_audio_shift = 12.0, 3.0
|
||||
return pipeline
|
||||
|
||||
|
||||
def _sampling(steps=FASTH3_DENOISE_STEPS, **kwargs) -> SimpleNamespace:
|
||||
fields = {"num_inference_steps": steps, "extra_args": {}, "lora_request": None}
|
||||
return SimpleNamespace(**{**fields, **kwargs})
|
||||
|
||||
|
||||
def _check_request(fusion, sampling) -> None:
|
||||
fusion.check_request(sampling, video_shift=12.0, audio_shift=3.0)
|
||||
|
||||
|
||||
def _fusion(tmp_path, **kwargs) -> FastH3WeightFusion:
|
||||
path = tmp_path / "fasth3" / "adapter_model.safetensors"
|
||||
_write_adapter(path, **kwargs)
|
||||
return _load(path.parent)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("task", ["fl2va", "ref2va"])
|
||||
def test_an_active_fasth3_fusion_restricts_requests_to_t2va(task, tmp_path):
|
||||
pipeline = _pipeline_stub(_fusion(tmp_path))
|
||||
|
||||
with pytest.raises(OmniClientError, match="distills \\['t2va'\\] only"):
|
||||
pipeline._resolve_task(task, {})
|
||||
assert pipeline._resolve_task("t2va", {}) == "t2va"
|
||||
assert _pipeline_stub()._resolve_task(task, {}) == task
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_inference_steps", [None, 5, 50])
|
||||
def test_fasth3_requires_one_forward_per_sigma_interval(num_inference_steps, tmp_path):
|
||||
fusion = _fusion(tmp_path)
|
||||
|
||||
# The five sigma points bound four forwards, and a request states forwards:
|
||||
# the interval contract the pinned schedules use, and the count the step
|
||||
# scheduler admits a request on before any pipeline hook runs.
|
||||
with pytest.raises(OmniClientError, match=f"num_inference_steps={FASTH3_DENOISE_STEPS}"):
|
||||
_check_request(fusion, _sampling(num_inference_steps))
|
||||
_check_request(fusion, _sampling())
|
||||
# The pinned branch coerces the string form; this one cannot disagree.
|
||||
_check_request(fusion, _sampling(str(FASTH3_DENOISE_STEPS)))
|
||||
|
||||
|
||||
def test_the_request_denoises_on_the_release_ladder(tmp_path):
|
||||
pipeline = _pipeline_stub(_fusion(tmp_path))
|
||||
|
||||
positions, num_steps = pipeline._resolve_sigma_positions("t2va", _sampling())
|
||||
|
||||
# dmd_denoising_steps [999, 749, 500, 250] out of 1000, closed with the
|
||||
# terminal 0.0: the pre-shift positions the student was distilled at, not
|
||||
# the uniform ladder num_inference_steps would otherwise derive.
|
||||
assert positions == (0.999, 0.749, 0.5, 0.25, 0.0)
|
||||
# Five sigma points bound four transformer forwards, and forwards is the
|
||||
# unit num_steps carries downstream (Cache-DiT, quality hints).
|
||||
assert num_steps == FASTH3_DENOISE_STEPS == len(positions) - 1
|
||||
# H3's own per-modality shifts still apply on top, and are what turn those
|
||||
# positions into the noise levels the four forwards run at.
|
||||
assert minimax_h3_time_shift_sigmas(shift_scale=12.0, base_schedule=positions) == pytest.approx(
|
||||
[0.999917, 0.972833, 0.923077, 0.8, 0.0], abs=1e-6
|
||||
)
|
||||
assert minimax_h3_time_shift_sigmas(shift_scale=3.0, base_schedule=positions) == pytest.approx(
|
||||
[0.999666, 0.899520, 0.75, 0.5, 0.0], abs=1e-6
|
||||
)
|
||||
# Without a fused adapter the same request keeps the undistilled ladder.
|
||||
assert _pipeline_stub()._resolve_sigma_positions("t2va", _sampling()) == (None, FASTH3_DENOISE_STEPS)
|
||||
|
||||
|
||||
def test_the_contract_leaves_the_checkpoint_shifts_alone(tmp_path):
|
||||
pipeline = _pipeline_stub(_fusion(tmp_path))
|
||||
pipeline.partition = "fl2va"
|
||||
|
||||
pipeline._fasth3.check_serving_contract(
|
||||
partition=pipeline.partition,
|
||||
od_config=pipeline.od_config,
|
||||
video_shift=pipeline.default_video_shift,
|
||||
audio_shift=pipeline.default_audio_shift,
|
||||
)
|
||||
|
||||
# The ladder is stated pre-shift, so H3's 12/3 shifts are what place the
|
||||
# student on the levels it was distilled at; the contract must not touch them.
|
||||
assert pipeline.default_video_shift == 12.0
|
||||
assert pipeline.default_audio_shift == 3.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extra",
|
||||
[{"flow_shift": 1.0}, {"audio_flow_shift": 1.0}, {"flow_shift": "bad"}],
|
||||
)
|
||||
def test_a_request_may_not_move_the_student_off_its_rungs(extra, tmp_path):
|
||||
fusion = _fusion(tmp_path)
|
||||
|
||||
with pytest.raises(OmniClientError, match="FastH3 requires"):
|
||||
_check_request(fusion, _sampling(extra_args=extra))
|
||||
|
||||
_check_request(fusion, _sampling(extra_args={"flow_shift": 12.0, "audio_flow_shift": 3.0}))
|
||||
|
||||
|
||||
def test_a_request_may_not_carry_a_lora_on_a_fused_server(tmp_path):
|
||||
fusion = _fusion(tmp_path)
|
||||
|
||||
# The dynamic LoRA manager is skipped for a fused adapter, so an adapter
|
||||
# asked for per request would be neither applied nor reported.
|
||||
with pytest.raises(OmniClientError, match="per-request lora is unavailable"):
|
||||
_check_request(fusion, _sampling(lora_request=SimpleNamespace(lora_int_id=1)))
|
||||
|
||||
|
||||
def test_offload_is_refused_because_it_bypasses_the_fusion(tmp_path):
|
||||
fusion = _fusion(tmp_path)
|
||||
for flag in ("enable_cpu_offload", "enable_layerwise_offload", "enable_distributed_layerwise_offload"):
|
||||
# A host-weight plan installs the transformer without load_weights(),
|
||||
# so the fusion and its completeness check would both be skipped.
|
||||
with pytest.raises(ValueError, match="cannot be combined with"):
|
||||
fusion.check_serving_contract(
|
||||
partition="fl2va",
|
||||
od_config=SimpleNamespace(**{flag: True}),
|
||||
video_shift=12.0,
|
||||
audio_shift=3.0,
|
||||
)
|
||||
|
||||
|
||||
def test_adopting_the_contract_refuses_a_vsa_variant_and_ref2va(tmp_path):
|
||||
sparse = tmp_path / "vsa" / "adapter_model.safetensors"
|
||||
_write_adapter(sparse, tensors={"transformer_blocks.0.attn.to_gate_compress.set_weight": torch.ones((2, 2))})
|
||||
contract = {"od_config": SimpleNamespace(), "video_shift": 12.0, "audio_shift": 3.0}
|
||||
with pytest.raises(ValueError, match="Video Sparse Attention variant"):
|
||||
_load(sparse.parent).check_serving_contract(partition="fl2va", **contract)
|
||||
|
||||
with pytest.raises(ValueError, match="cannot serve a Ref2VA partition"):
|
||||
_fusion(tmp_path).check_serving_contract(partition="ref2va", **contract)
|
||||
|
||||
|
||||
def test_a_full_rank_delta_on_a_fused_parameter_is_refused(tmp_path):
|
||||
# Only low-rank factors are placed into H3's fused QKV and gate/up layouts,
|
||||
# so a .diff aimed at one would otherwise be added transposed.
|
||||
path = tmp_path / "fasth3" / "adapter_model.safetensors"
|
||||
_write_adapter(path, tensors={"transformer_blocks.0.attn.to_q.diff": torch.ones((_INNER, _HIDDEN))})
|
||||
with pytest.raises(FastH3AdapterError, match="fused layout"):
|
||||
_claim(path.parent)
|
||||
|
||||
|
||||
def test_an_adapter_that_leaves_blocks_untouched_is_refused(tmp_path):
|
||||
path = tmp_path / "fasth3" / "adapter_model.safetensors"
|
||||
_write_adapter(path, blocks=2)
|
||||
|
||||
# Every tensor in this file is well formed and pairs up, so the fusion's
|
||||
# own completeness check passes; only the model's depth reveals that a
|
||||
# third of it would have been served as base H3 weights.
|
||||
with pytest.raises(FastH3AdapterError, match="missing=\\[2\\]"):
|
||||
_claim(path.parent, num_blocks=3)
|
||||
with pytest.raises(FastH3AdapterError, match="unknown=\\[1\\]"):
|
||||
_claim(path.parent, num_blocks=1)
|
||||
assert _claim(path.parent, num_blocks=2) is not None
|
||||
|
||||
|
||||
def test_an_adapter_shorter_than_it_declares_is_refused(tmp_path):
|
||||
path = tmp_path / "fasth3" / "adapter_model.safetensors"
|
||||
_write_adapter(path, metadata={"low_rank_tensors": "724", "diff_tensors": "85"})
|
||||
|
||||
# The writer records what it emitted, which is the file's one statement
|
||||
# about its own completeness.
|
||||
with pytest.raises(FastH3AdapterError, match="declares low_rank_tensors=724 but carries 12"):
|
||||
_claim(path.parent)
|
||||
|
||||
|
||||
def test_only_a_configured_adapter_path_is_read(tmp_path):
|
||||
path = tmp_path / "fasth3" / "adapter_model.safetensors"
|
||||
_write_adapter(path)
|
||||
transformer = SimpleNamespace(
|
||||
arch=SimpleNamespace(attention_head_dim=_HEAD_DIM, num_layers=1, token_refiner_num_layers=0)
|
||||
)
|
||||
unbuilt = object() # a transformer whose arch must not be touched
|
||||
|
||||
assert resolve_fasth3_fusion(SimpleNamespace(lora_path=str(path)), transformer) is not None
|
||||
assert resolve_fasth3_fusion(SimpleNamespace(lora_path=[str(path)]), transformer) is not None
|
||||
# Nothing configured, or more than one artifact, stays on the dynamic route
|
||||
# without reading anything off the model.
|
||||
assert resolve_fasth3_fusion(SimpleNamespace(lora_path=None), unbuilt) is None
|
||||
assert resolve_fasth3_fusion(SimpleNamespace(), unbuilt) is None
|
||||
assert resolve_fasth3_fusion(SimpleNamespace(lora_path=[str(path), str(path)]), unbuilt) is None
|
||||
|
||||
|
||||
def test_a_pipeline_that_fused_its_adapter_needs_no_lora_manager(tmp_path):
|
||||
assert _pipeline_stub(_fusion(tmp_path)).lora_is_fused is True
|
||||
assert _pipeline_stub().lora_is_fused is False
|
||||
|
||||
|
||||
def test_the_ladder_is_the_one_the_release_publishes():
|
||||
# FASTH3_DENOISE_STEPS is derived from it, so the two cannot drift apart.
|
||||
assert FASTH3_BASE_SCHEDULE.base_schedule == (0.999, 0.749, 0.5, 0.25, 0.0)
|
||||
assert FASTH3_BASE_SCHEDULE.num_inference_steps == FASTH3_DENOISE_STEPS == 4
|
||||
@@ -259,7 +259,11 @@ def test_h3_native_rejects_schedule_with_wrong_interval_count(tmp_path):
|
||||
("", "must not be empty"),
|
||||
(",,", "must not be empty"),
|
||||
("1.0,oops,0.15,0.0", "is malformed"),
|
||||
("0.9,0.7,0.4,0.15,0.0", "is malformed"),
|
||||
# A capped opening rung below 1.0 is a legal DMD2 schedule, so the
|
||||
# out-of-range cases are a first position above 1.0 and a tail that
|
||||
# never reaches clean latents.
|
||||
("1.5,0.7,0.4,0.15,0.0", "is malformed"),
|
||||
("1.0,0.7,0.4,0.15", "is malformed"),
|
||||
],
|
||||
)
|
||||
def test_h3_native_rejects_malformed_schedule_metadata(tmp_path, raw_schedule, message):
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,195 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""CPU tests for SenseNova-U1 attention head handling.
|
||||
|
||||
The model used to expand K/V from 8 heads to 32 with ``repeat_interleave``
|
||||
before calling the attention backend, so the backend's own GQA dispatch never
|
||||
saw a GQA shape, and it passed an all-zeros mask during single-token decode,
|
||||
which only served to keep SDPA off its fused kernels. These pin both contracts:
|
||||
K/V must reach the backend uncompressed, decode must carry no mask, and the
|
||||
compressed path must compute the same thing the expanded one did.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.attention.backends.sdpa import SDPAImpl
|
||||
from vllm_omni.diffusion.models.sensenova_u1.sensenova_u1_transformer import (
|
||||
SenseNovaU1Attention,
|
||||
SenseNovaU1Model,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
N_HEADS, N_KV_HEADS, HEAD_DIM, SEQ = 32, 8, 8, 6
|
||||
GROUPS = N_HEADS // N_KV_HEADS
|
||||
|
||||
|
||||
class _Recorder:
|
||||
"""Stands in for the Attention module and records what it was handed."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls: list[tuple[torch.Size, torch.Size, torch.Tensor | None]] = []
|
||||
|
||||
def __call__(self, query, key, value, attn_metadata):
|
||||
mask = None if attn_metadata is None else attn_metadata.attn_mask
|
||||
self.calls.append((query.shape, key.shape, mask))
|
||||
return query
|
||||
|
||||
|
||||
class _AttnHost:
|
||||
"""Carries only the attributes ``_run_attn``/``_run_attn_bshd`` touch."""
|
||||
|
||||
num_kv_groups = GROUPS
|
||||
_align_mask_dtype = staticmethod(SenseNovaU1Attention._align_mask_dtype)
|
||||
_run_attn = SenseNovaU1Attention._run_attn
|
||||
_run_attn_bshd = SenseNovaU1Attention._run_attn_bshd
|
||||
|
||||
def __init__(self):
|
||||
self.attn = _Recorder()
|
||||
|
||||
|
||||
def test_bhsd_path_hands_the_backend_compressed_kv():
|
||||
host = _AttnHost()
|
||||
q = torch.randn(1, N_HEADS, SEQ, HEAD_DIM)
|
||||
k = torch.randn(1, N_KV_HEADS, SEQ, HEAD_DIM)
|
||||
host._run_attn(q, k, k.clone(), None)
|
||||
_, key_shape, _ = host.attn.calls[-1]
|
||||
# [B, S, H, D] after the transpose, so heads sit on dim 2.
|
||||
assert key_shape[2] == N_KV_HEADS, f"K reached the backend with {key_shape[2]} heads"
|
||||
|
||||
|
||||
def test_bshd_path_hands_the_backend_compressed_kv():
|
||||
host = _AttnHost()
|
||||
q = torch.randn(1, SEQ, N_HEADS, HEAD_DIM)
|
||||
k = torch.randn(1, SEQ, N_KV_HEADS, HEAD_DIM)
|
||||
host._run_attn_bshd(q, k, k.clone(), None)
|
||||
_, key_shape, _ = host.attn.calls[-1]
|
||||
assert key_shape[2] == N_KV_HEADS, f"K reached the backend with {key_shape[2]} heads"
|
||||
|
||||
|
||||
def test_compressed_kv_computes_what_expansion_computed():
|
||||
"""The backend must return the same tensor either way, or this is a bugfix
|
||||
dressed up as a perf change."""
|
||||
torch.manual_seed(0)
|
||||
q = torch.randn(1, SEQ, N_HEADS, HEAD_DIM)
|
||||
k = torch.randn(1, SEQ, N_KV_HEADS, HEAD_DIM)
|
||||
v = torch.randn(1, SEQ, N_KV_HEADS, HEAD_DIM)
|
||||
impl = SDPAImpl(
|
||||
num_heads=N_HEADS,
|
||||
head_size=HEAD_DIM,
|
||||
softmax_scale=HEAD_DIM**-0.5,
|
||||
num_kv_heads=N_KV_HEADS,
|
||||
)
|
||||
compressed = impl._forward_impl(q, k, v)
|
||||
expanded = impl._forward_impl(q, k.repeat_interleave(GROUPS, dim=2), v.repeat_interleave(GROUPS, dim=2))
|
||||
torch.testing.assert_close(compressed, expanded, atol=1e-6, rtol=1e-6)
|
||||
|
||||
|
||||
class _MaskProbe(torch.nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.masks: list[torch.Tensor | None] = []
|
||||
|
||||
def forward(self, hidden_states, **kwargs):
|
||||
self.masks.append(kwargs["attention_mask"]["full_attention"])
|
||||
return hidden_states
|
||||
|
||||
|
||||
def _fake_rope(hidden_states, position_ids):
|
||||
"""The forward builds the three RoPE tables before the layer loop; the probe
|
||||
ignores them, so any 2-tuple will do."""
|
||||
return hidden_states, hidden_states
|
||||
|
||||
|
||||
class _ModelHost:
|
||||
"""Carries only the attributes ``SenseNovaU1Model.forward`` touches."""
|
||||
|
||||
forward = SenseNovaU1Model.forward
|
||||
|
||||
def __init__(self, probe):
|
||||
self.layers = [probe]
|
||||
self.norm = torch.nn.Identity()
|
||||
self.norm_mot_gen = torch.nn.Identity()
|
||||
self.rotary_emb = _fake_rope
|
||||
self.rotary_emb_hw = _fake_rope
|
||||
|
||||
|
||||
def _run_model(seq_len: int):
|
||||
probe = _MaskProbe()
|
||||
_ModelHost(probe).forward(
|
||||
inputs_embeds=torch.zeros(1, seq_len, 4),
|
||||
indexes=torch.zeros(3, seq_len, dtype=torch.long),
|
||||
)
|
||||
return probe.masks[-1]
|
||||
|
||||
|
||||
def test_single_token_decode_carries_no_mask():
|
||||
assert _run_model(1) is None, "decode still builds the all-zeros mask"
|
||||
|
||||
|
||||
def test_prefill_still_gets_a_causal_mask():
|
||||
mask = _run_model(3)
|
||||
assert mask is not None and mask.shape == (1, 1, 3, 3)
|
||||
assert mask[0, 0, 0, 0] == 0.0
|
||||
assert torch.isinf(mask[0, 0, 0, 1]) and mask[0, 0, 0, 1] < 0
|
||||
assert mask[0, 0, 2, 1] == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The decode path changes which SDPA kernel runs, so it is not bit-identical to
|
||||
# what it replaced. "Different" would be a regression if the new result were
|
||||
# worse, so pin the direction against an independent float64 reference. This
|
||||
# needs a fused GQA kernel, which only exists on CUDA.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _float64_attention(q, k, v):
|
||||
q64 = q.double()
|
||||
k64 = k.double().repeat_interleave(GROUPS, dim=1)
|
||||
v64 = v.double().repeat_interleave(GROUPS, dim=1)
|
||||
scores = (q64 @ k64.transpose(-1, -2)) * (q.shape[-1] ** -0.5)
|
||||
return torch.softmax(scores, dim=-1) @ v64
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required for fused GQA")
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.L4
|
||||
@pytest.mark.parametrize("kv_len", [271, 512, 2048])
|
||||
def test_decode_without_the_no_op_mask_is_closer_to_float64(kv_len):
|
||||
"""The old decode path expanded K/V and passed an all-zeros mask, which kept
|
||||
SDPA off its fused kernels. Dropping the mask is only defensible if the
|
||||
kernel it unlocks is at least as accurate."""
|
||||
dev, dtype = "cuda", torch.bfloat16
|
||||
heads, dim = 32, 128
|
||||
new_errs: list[float] = []
|
||||
old_errs: list[float] = []
|
||||
trials = 8
|
||||
for t in range(trials):
|
||||
torch.manual_seed(2000 + t)
|
||||
q = torch.randn(1, heads, 1, dim, device=dev, dtype=dtype)
|
||||
k = torch.randn(1, heads // GROUPS, kv_len, dim, device=dev, dtype=dtype)
|
||||
v = torch.randn(1, heads // GROUPS, kv_len, dim, device=dev, dtype=dtype)
|
||||
ref = _float64_attention(q, k, v)
|
||||
|
||||
zeros_mask = torch.zeros(1, 1, 1, kv_len, device=dev, dtype=dtype)
|
||||
old = torch.nn.functional.scaled_dot_product_attention(
|
||||
q,
|
||||
k.repeat_interleave(GROUPS, dim=1),
|
||||
v.repeat_interleave(GROUPS, dim=1),
|
||||
attn_mask=zeros_mask,
|
||||
)
|
||||
new = torch.nn.functional.scaled_dot_product_attention(q, k, v, enable_gqa=True)
|
||||
|
||||
new_errs.append((new.double() - ref).abs().mean().item())
|
||||
old_errs.append((old.double() - ref).abs().mean().item())
|
||||
|
||||
# A single trial can tie either way depending on the GPU and the CUDA build,
|
||||
# so gate on the aggregate: the maskless path must not be worse on average.
|
||||
new_mean = sum(new_errs) / trials
|
||||
old_mean = sum(old_errs) / trials
|
||||
wins = sum(n <= o for n, o in zip(new_errs, old_errs))
|
||||
assert new_mean <= old_mean, (
|
||||
f"kv_len={kv_len}: maskless mean error {new_mean:.4e} is worse than masked {old_mean:.4e} "
|
||||
f"({wins}/{trials} per-trial wins)"
|
||||
)
|
||||
@@ -0,0 +1,196 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""CPU tests for SenseNova-U1 distilled-LoRA fusion.
|
||||
|
||||
The checkpoints ship unfused ``q_proj_mot_gen`` / ``gate_proj`` keys while the
|
||||
pipeline runs fused layers, so each delta lands in its own row slice.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from safetensors.torch import save_file
|
||||
|
||||
from vllm_omni.diffusion.models.sensenova_u1.pipeline_sensenova_u1 import SenseNovaU1Pipeline
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
HIDDEN, KV, INTER, RANK, ALPHA = 8, 4, 16, 2, 4.0
|
||||
|
||||
|
||||
class _Layer(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.self_attn = nn.Module()
|
||||
self.self_attn.qkv_proj_mot_gen = nn.Linear(HIDDEN, HIDDEN + 2 * KV, bias=False)
|
||||
self.self_attn.o_proj_mot_gen = nn.Linear(HIDDEN, HIDDEN, bias=False)
|
||||
self.mlp_mot_gen = nn.Module()
|
||||
self.mlp_mot_gen.gate_up_proj = nn.Linear(HIDDEN, 2 * INTER, bias=False)
|
||||
self.mlp_mot_gen.down_proj = nn.Linear(INTER, HIDDEN, bias=False)
|
||||
|
||||
|
||||
class _Stub(SenseNovaU1Pipeline):
|
||||
"""Only the parameter tree matters here, so skip the real __init__."""
|
||||
|
||||
def __init__(self):
|
||||
nn.Module.__init__(self)
|
||||
self.language_model = nn.Module()
|
||||
self.language_model.model = nn.Module()
|
||||
self.language_model.model.layers = nn.ModuleList([_Layer()])
|
||||
|
||||
|
||||
def _triple(out: int, in_: int, fill: float) -> dict[str, torch.Tensor]:
|
||||
return {
|
||||
"lora_down.weight": torch.full((RANK, in_), fill, dtype=torch.float32),
|
||||
"lora_up.weight": torch.full((out, RANK), 1.0, dtype=torch.float32),
|
||||
"alpha": torch.tensor(ALPHA),
|
||||
}
|
||||
|
||||
|
||||
def _write(tmp_path, entries: dict[str, dict[str, torch.Tensor]]):
|
||||
flat = {f"{mod}.{leaf}": t for mod, parts in entries.items() for leaf, t in parts.items()}
|
||||
path = tmp_path / "lora.safetensors"
|
||||
save_file(flat, str(path))
|
||||
return str(path)
|
||||
|
||||
|
||||
def _prefix(i: int = 0) -> str:
|
||||
return f"language_model.model.layers.{i}"
|
||||
|
||||
|
||||
def test_qkv_shards_land_in_their_own_row_slices(tmp_path):
|
||||
pipe = _Stub()
|
||||
qkv = pipe.language_model.model.layers[0].self_attn.qkv_proj_mot_gen.weight
|
||||
with torch.no_grad():
|
||||
qkv.zero_()
|
||||
entries = {
|
||||
f"{_prefix()}.self_attn.q_proj_mot_gen": _triple(HIDDEN, HIDDEN, 1.0),
|
||||
f"{_prefix()}.self_attn.k_proj_mot_gen": _triple(KV, HIDDEN, 2.0),
|
||||
f"{_prefix()}.self_attn.v_proj_mot_gen": _triple(KV, HIDDEN, 3.0),
|
||||
}
|
||||
pipe.load_lora_weights(_write(tmp_path, entries))
|
||||
|
||||
scale = ALPHA / RANK
|
||||
assert torch.allclose(qkv[:HIDDEN], torch.full((HIDDEN, HIDDEN), scale * RANK * 1.0))
|
||||
assert torch.allclose(qkv[HIDDEN : HIDDEN + KV], torch.full((KV, HIDDEN), scale * RANK * 2.0))
|
||||
assert torch.allclose(qkv[HIDDEN + KV :], torch.full((KV, HIDDEN), scale * RANK * 3.0))
|
||||
|
||||
|
||||
def test_gate_and_up_land_in_their_own_halves(tmp_path):
|
||||
pipe = _Stub()
|
||||
gu = pipe.language_model.model.layers[0].mlp_mot_gen.gate_up_proj.weight
|
||||
with torch.no_grad():
|
||||
gu.zero_()
|
||||
entries = {
|
||||
f"{_prefix()}.mlp_mot_gen.gate_proj": _triple(INTER, HIDDEN, 1.0),
|
||||
f"{_prefix()}.mlp_mot_gen.up_proj": _triple(INTER, HIDDEN, 5.0),
|
||||
}
|
||||
pipe.load_lora_weights(_write(tmp_path, entries))
|
||||
|
||||
scale = ALPHA / RANK
|
||||
assert torch.allclose(gu[:INTER], torch.full((INTER, HIDDEN), scale * RANK * 1.0))
|
||||
assert torch.allclose(gu[INTER:], torch.full((INTER, HIDDEN), scale * RANK * 5.0))
|
||||
|
||||
|
||||
def test_unfused_targets_are_added_whole(tmp_path):
|
||||
pipe = _Stub()
|
||||
o = pipe.language_model.model.layers[0].self_attn.o_proj_mot_gen.weight
|
||||
before = o.detach().clone()
|
||||
entries = {f"{_prefix()}.self_attn.o_proj_mot_gen": _triple(HIDDEN, HIDDEN, 1.0)}
|
||||
pipe.load_lora_weights(_write(tmp_path, entries))
|
||||
assert torch.allclose(o - before, torch.full((HIDDEN, HIDDEN), (ALPHA / RANK) * RANK))
|
||||
|
||||
|
||||
def test_lora_that_matches_nothing_raises_instead_of_no_op(tmp_path):
|
||||
"""Without a loader the worker only warned and generated without the
|
||||
adapter, so a wrong path produced a plausible image and no error."""
|
||||
pipe = _Stub()
|
||||
entries = {f"{_prefix()}.self_attn.does_not_exist": _triple(HIDDEN, HIDDEN, 1.0)}
|
||||
with pytest.raises(ValueError, match="matched no parameter"):
|
||||
pipe.load_lora_weights(_write(tmp_path, entries))
|
||||
|
||||
|
||||
def test_shape_mismatch_raises(tmp_path):
|
||||
pipe = _Stub()
|
||||
entries = {f"{_prefix()}.self_attn.o_proj_mot_gen": _triple(HIDDEN + 1, HIDDEN, 1.0)}
|
||||
with pytest.raises(RuntimeError, match="must match the size"):
|
||||
pipe.load_lora_weights(_write(tmp_path, entries))
|
||||
|
||||
|
||||
def test_multiple_files_rejected(tmp_path):
|
||||
pipe = _Stub()
|
||||
path = _write(tmp_path, {f"{_prefix()}.self_attn.o_proj_mot_gen": _triple(HIDDEN, HIDDEN, 1.0)})
|
||||
with pytest.raises(ValueError, match="exactly one"):
|
||||
pipe.load_lora_weights([path, path])
|
||||
|
||||
|
||||
class _ShardedLayer(nn.Module):
|
||||
"""One rank of a two-way sharded fused QKV, with a vLLM-style weight loader.
|
||||
|
||||
The parameter holds half of each of q, k and v. The loader is handed the whole
|
||||
layer's tensor and keeps only this rank's slice, which is what vLLM does.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.weight = nn.Parameter(torch.zeros(HIDDEN // 2 + KV, HIDDEN))
|
||||
self.seen: list[tuple[str, tuple[int, ...]]] = []
|
||||
self.weight.weight_loader = self._weight_loader
|
||||
|
||||
def _weight_loader(self, param, loaded_weight, shard_id):
|
||||
self.seen.append((shard_id, tuple(loaded_weight.shape)))
|
||||
sizes = {"q": HIDDEN // 2, "k": KV // 2, "v": KV // 2}
|
||||
offsets = {"q": 0, "k": HIDDEN // 2, "v": HIDDEN // 2 + KV // 2}
|
||||
rows = sizes[shard_id]
|
||||
param.data[offsets[shard_id] : offsets[shard_id] + rows] = loaded_weight[:rows]
|
||||
|
||||
|
||||
class _ShardedStub(SenseNovaU1Pipeline):
|
||||
def __init__(self):
|
||||
nn.Module.__init__(self)
|
||||
self.language_model = nn.Module()
|
||||
self.language_model.model = nn.Module()
|
||||
layer = nn.Module()
|
||||
layer.self_attn = nn.Module()
|
||||
layer.self_attn.qkv_proj_mot_gen = _ShardedLayer()
|
||||
self.language_model.model.layers = nn.ModuleList([layer])
|
||||
|
||||
|
||||
def test_fused_delta_is_sharded_through_the_weight_loader(tmp_path):
|
||||
"""Under tensor parallelism the parameter is a rank-local shard while the
|
||||
checkpoint delta spans the whole layer, so the delta has to go through the
|
||||
layer's weight loader. Adding it directly raises a shape error."""
|
||||
pipe = _ShardedStub()
|
||||
layer = pipe.language_model.model.layers[0].self_attn.qkv_proj_mot_gen
|
||||
entries = {
|
||||
f"{_prefix()}.self_attn.q_proj_mot_gen": _triple(HIDDEN, HIDDEN, 1.0),
|
||||
f"{_prefix()}.self_attn.k_proj_mot_gen": _triple(KV, HIDDEN, 2.0),
|
||||
f"{_prefix()}.self_attn.v_proj_mot_gen": _triple(KV, HIDDEN, 3.0),
|
||||
}
|
||||
pipe.load_lora_weights(_write(tmp_path, entries))
|
||||
|
||||
# the loader saw the whole layer's tensors, not a pre-sharded slice
|
||||
assert layer.seen == [("q", (HIDDEN, HIDDEN)), ("k", (KV, HIDDEN)), ("v", (KV, HIDDEN))]
|
||||
|
||||
scale = ALPHA / RANK * RANK
|
||||
got = layer.weight
|
||||
assert torch.allclose(got[: HIDDEN // 2], torch.full((HIDDEN // 2, HIDDEN), scale * 1.0))
|
||||
assert torch.allclose(got[HIDDEN // 2 : HIDDEN // 2 + KV // 2], torch.full((KV // 2, HIDDEN), scale * 2.0))
|
||||
assert torch.allclose(got[HIDDEN // 2 + KV // 2 :], torch.full((KV // 2, HIDDEN), scale * 3.0))
|
||||
|
||||
|
||||
def test_fusion_does_not_retain_the_adapter_tensors(tmp_path):
|
||||
"""Fusion is one-way and this pipeline has no unload path, so holding the
|
||||
fp32 state dict would keep about 1.5 GiB alive for the shipped checkpoint."""
|
||||
pipe = _Stub()
|
||||
path = _write(tmp_path, {f"{_prefix()}.self_attn.o_proj_mot_gen": _triple(HIDDEN, HIDDEN, 1.0)})
|
||||
pipe.load_lora_weights(path, adapter_name="distill")
|
||||
|
||||
kept = pipe.lora_loaded["distill"]
|
||||
assert not isinstance(kept, dict)
|
||||
assert not isinstance(kept, torch.Tensor)
|
||||
|
||||
# the sentinel still makes a second load a no-op
|
||||
before = pipe.language_model.model.layers[0].self_attn.o_proj_mot_gen.weight.detach().clone()
|
||||
pipe.load_lora_weights(path, adapter_name="distill")
|
||||
torch.testing.assert_close(pipe.language_model.model.layers[0].self_attn.o_proj_mot_gen.weight, before)
|
||||
@@ -0,0 +1,800 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Tests for the paged decode cache and its CUDA graph.
|
||||
|
||||
The cache exists so a decode step has static shapes and can be captured. That
|
||||
puts the burden on two things a normal test would not look at: the prefix must
|
||||
survive the hand-off from the ordinary cache, and every quantity that varies
|
||||
between steps must live in a device tensor. A Python-level index is correct
|
||||
eagerly and silently wrong once captured, because capture bakes it in -- which
|
||||
is exactly what the last test here pins.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from vllm.lora.layers import BaseLayerWithLoRA
|
||||
|
||||
import vllm_omni.diffusion.models.sensenova_u1.paged_decode as paged_decode
|
||||
from vllm_omni.diffusion.models.sensenova_u1.paged_decode import (
|
||||
BLOCK_SIZE,
|
||||
BUCKETS,
|
||||
TAIL_STEP,
|
||||
PagedDecodeCache,
|
||||
_bucket_for,
|
||||
)
|
||||
from vllm_omni.diffusion.models.sensenova_u1.sensenova_u1_transformer import (
|
||||
SenseNovaU1Attention,
|
||||
)
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
LAYERS, KV_HEADS, HEAD_DIM = 2, 4, 8
|
||||
N_HEADS = KV_HEADS * 2
|
||||
|
||||
|
||||
class _Layer:
|
||||
def __init__(self, keys=None, values=None):
|
||||
self.keys, self.values = keys, values
|
||||
|
||||
|
||||
class _Cache:
|
||||
"""Stands in for the transformers cache: layers with [B, H, S, D] tensors."""
|
||||
|
||||
def __init__(self, layers):
|
||||
self.layers = layers
|
||||
|
||||
|
||||
def _dyn_cache(prefix, device=None):
|
||||
torch.manual_seed(0)
|
||||
return _Cache(
|
||||
[
|
||||
_Layer(
|
||||
torch.randn(1, KV_HEADS, prefix, HEAD_DIM, device=device),
|
||||
torch.randn(1, KV_HEADS, prefix, HEAD_DIM, device=device),
|
||||
)
|
||||
for _ in range(LAYERS)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_bucket_rounds_up_and_is_block_aligned():
|
||||
assert _bucket_for(1) == 512
|
||||
assert _bucket_for(512) == 512
|
||||
assert _bucket_for(513) == 1024
|
||||
assert _bucket_for(100_000) % BLOCK_SIZE == 0
|
||||
|
||||
|
||||
def test_past_the_last_bucket_the_schedule_grows_in_steps():
|
||||
"""Every bucket change reallocates the cache and re-captures the graph, so
|
||||
the tail of the schedule must not step by BLOCK_SIZE. A think edit with two
|
||||
2048x2048 inputs runs to about 9,100 tokens, so this regime is reachable,
|
||||
and a request decodes at most 1024 steps.
|
||||
"""
|
||||
last = BUCKETS[-1]
|
||||
assert _bucket_for(last + 1) == last + TAIL_STEP
|
||||
assert _bucket_for(100_000) % BLOCK_SIZE == 0
|
||||
for start in (last + 1, last + TAIL_STEP - 1, last + TAIL_STEP + 7):
|
||||
bucket, grows = _bucket_for(start), 0
|
||||
for n in range(start, start + 1025):
|
||||
if _bucket_for(n) != bucket:
|
||||
bucket = _bucket_for(n)
|
||||
grows += 1
|
||||
assert grows <= 1, f"start {start}: {grows} reallocations over 1024 decode steps"
|
||||
|
||||
|
||||
def test_prefix_survives_the_hand_off():
|
||||
dyn = _dyn_cache(37)
|
||||
paged = PagedDecodeCache.from_dynamic_cache(dyn, LAYERS, torch.device("cpu"), torch.float32)
|
||||
assert paged.length == 37
|
||||
for i in range(LAYERS):
|
||||
stored = paged.k[i].view(-1, KV_HEADS, HEAD_DIM)[:37]
|
||||
torch.testing.assert_close(stored, dyn.layers[i].keys[0].transpose(0, 1))
|
||||
|
||||
|
||||
def test_write_back_restores_what_was_handed_over():
|
||||
"""Decode is paged but the stage after it reads the ordinary cache."""
|
||||
dyn = _dyn_cache(21)
|
||||
original = [layer.keys.clone() for layer in dyn.layers]
|
||||
paged = PagedDecodeCache.from_dynamic_cache(dyn, LAYERS, torch.device("cpu"), torch.float32)
|
||||
paged.to_dynamic_cache(dyn)
|
||||
for before, layer in zip(original, dyn.layers):
|
||||
torch.testing.assert_close(layer.keys, before)
|
||||
|
||||
|
||||
def test_growing_keeps_the_tokens_already_stored():
|
||||
dyn = _dyn_cache(500)
|
||||
paged = PagedDecodeCache.from_dynamic_cache(dyn, LAYERS, torch.device("cpu"), torch.float32)
|
||||
assert paged.bucket == 512
|
||||
before = [k.view(-1, KV_HEADS, HEAD_DIM)[:500].clone() for k in paged.k]
|
||||
gen = paged.generation
|
||||
assert paged.grow(513) is True
|
||||
assert paged.bucket == 1024
|
||||
assert paged.generation > gen, "a reallocation must invalidate captured graphs"
|
||||
for old, new in zip(before, paged.k):
|
||||
torch.testing.assert_close(new.view(-1, KV_HEADS, HEAD_DIM)[:500], old)
|
||||
|
||||
|
||||
def test_a_failed_grow_leaves_the_cache_on_its_old_buffers(monkeypatch):
|
||||
"""A half-applied resize would strand a capture on freed storage.
|
||||
|
||||
``generation`` is half of the key a captured graph is stored under, so
|
||||
publishing new K without publishing V, the block table, the bucket and the
|
||||
generation leaves the old key selecting a graph whose recorded addresses
|
||||
have just been freed. A later request replays it. Injecting a failure into
|
||||
the last allocation is the cheapest way to reach that window.
|
||||
"""
|
||||
dyn = _dyn_cache(500)
|
||||
paged = PagedDecodeCache.from_dynamic_cache(dyn, LAYERS, torch.device("cpu"), torch.float32)
|
||||
before_k, before_v = list(paged.k), list(paged.v)
|
||||
before_table, before_bucket = paged.block_table, paged.bucket
|
||||
before_generation, before_length = paged.generation, paged.length
|
||||
|
||||
real_zeros = torch.zeros
|
||||
calls = []
|
||||
|
||||
def flaky(*args, **kwargs):
|
||||
calls.append(1)
|
||||
if len(calls) == 2 * LAYERS: # the last of the two allocations per layer
|
||||
raise RuntimeError("injected allocation failure")
|
||||
return real_zeros(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(torch, "zeros", flaky)
|
||||
with pytest.raises(RuntimeError, match="injected"):
|
||||
paged.grow(513)
|
||||
monkeypatch.undo()
|
||||
|
||||
assert [id(t) for t in paged.k] == [id(t) for t in before_k], "K was published on its own"
|
||||
assert [id(t) for t in paged.v] == [id(t) for t in before_v], "V was published on its own"
|
||||
assert paged.block_table is before_table
|
||||
assert paged.bucket == before_bucket
|
||||
assert paged.generation == before_generation, "the graph key moved without new buffers"
|
||||
assert paged.length == before_length
|
||||
|
||||
|
||||
def test_length_lives_in_device_tensors():
|
||||
"""Both the attended length and the write slot must be tensors.
|
||||
|
||||
A captured graph reads them at replay; a Python int would be frozen at
|
||||
capture time and every replay would attend the wrong span and overwrite the
|
||||
same slot.
|
||||
"""
|
||||
dyn = _dyn_cache(10)
|
||||
paged = PagedDecodeCache.from_dynamic_cache(dyn, LAYERS, torch.device("cpu"), torch.float32)
|
||||
paged.set_length(15)
|
||||
assert torch.is_tensor(paged.seqused) and int(paged.seqused) == 15
|
||||
assert torch.is_tensor(paged.pos) and int(paged.pos) == 14
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The capture contract. These need CUDA and the bundled flash-attention kernel.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
cuda_only = pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
|
||||
|
||||
@cuda_only
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.L4
|
||||
def test_paged_attention_matches_sdpa_over_the_same_buffer():
|
||||
import torch.nn.functional as F
|
||||
|
||||
from vllm_omni.diffusion.models.sensenova_u1.paged_decode import PagedDecodeCache
|
||||
|
||||
heads, kv_heads, dim, n = 8, 2, 64, 100
|
||||
dev, dt = torch.device("cuda"), torch.bfloat16
|
||||
torch.manual_seed(0)
|
||||
dyn = _Cache(
|
||||
[
|
||||
_Layer(
|
||||
torch.randn(1, kv_heads, n, dim, device=dev, dtype=dt),
|
||||
torch.randn(1, kv_heads, n, dim, device=dev, dtype=dt),
|
||||
)
|
||||
]
|
||||
)
|
||||
paged = PagedDecodeCache.from_dynamic_cache(dyn, 1, dev, dt)
|
||||
paged.set_length(n)
|
||||
q = torch.randn(1, heads, 1, dim, device=dev, dtype=dt)
|
||||
k = torch.zeros(1, kv_heads, 1, dim, device=dev, dtype=dt)
|
||||
scale = dim**-0.5
|
||||
# write the last slot back as itself so attend() is a pure read
|
||||
k[0, :, 0] = dyn.layers[0].keys[0, :, n - 1]
|
||||
v = torch.zeros_like(k)
|
||||
v[0, :, 0] = dyn.layers[0].values[0, :, n - 1]
|
||||
got = paged.attend(0, q, k, v, scale)
|
||||
flat_k = paged.k[0].view(-1, kv_heads, dim)[:n]
|
||||
flat_v = paged.v[0].view(-1, kv_heads, dim)[:n]
|
||||
want = F.scaled_dot_product_attention(
|
||||
q,
|
||||
flat_k.unsqueeze(0).transpose(1, 2).contiguous(),
|
||||
flat_v.unsqueeze(0).transpose(1, 2).contiguous(),
|
||||
enable_gqa=True,
|
||||
scale=scale,
|
||||
).transpose(1, 2)
|
||||
torch.testing.assert_close(got.float(), want.float(), atol=2e-3, rtol=2e-3)
|
||||
|
||||
|
||||
@cuda_only
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.L4
|
||||
def test_a_captured_graph_follows_a_later_length():
|
||||
"""One capture has to serve every length in the bucket.
|
||||
|
||||
If the attended span were a Python value the graph would keep attending the
|
||||
capture-time length, and the run would still look fast.
|
||||
"""
|
||||
import torch.nn.functional as F
|
||||
|
||||
from vllm_omni.diffusion.models.sensenova_u1.paged_decode import PagedDecodeCache
|
||||
|
||||
heads, kv_heads, dim, n = 8, 2, 64, 200
|
||||
dev, dt = torch.device("cuda"), torch.bfloat16
|
||||
torch.manual_seed(0)
|
||||
dyn = _Cache(
|
||||
[
|
||||
_Layer(
|
||||
torch.randn(1, kv_heads, n, dim, device=dev, dtype=dt),
|
||||
torch.randn(1, kv_heads, n, dim, device=dev, dtype=dt),
|
||||
)
|
||||
]
|
||||
)
|
||||
paged = PagedDecodeCache.from_dynamic_cache(dyn, 1, dev, dt)
|
||||
q = torch.randn(1, heads, 1, dim, device=dev, dtype=dt)
|
||||
k = torch.zeros(1, kv_heads, 1, dim, device=dev, dtype=dt)
|
||||
v = torch.zeros_like(k)
|
||||
scale = dim**-0.5
|
||||
|
||||
paged.set_length(n)
|
||||
k[0, :, 0] = dyn.layers[0].keys[0, :, n - 1]
|
||||
v[0, :, 0] = dyn.layers[0].values[0, :, n - 1]
|
||||
|
||||
side = torch.cuda.Stream()
|
||||
side.wait_stream(torch.cuda.current_stream())
|
||||
with torch.cuda.stream(side):
|
||||
for _ in range(3):
|
||||
paged.attend(0, q, k, v, scale)
|
||||
torch.cuda.current_stream().wait_stream(side)
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(graph):
|
||||
out = paged.attend(0, q, k, v, scale)
|
||||
|
||||
shorter = n - 43
|
||||
paged.set_length(shorter)
|
||||
graph.replay()
|
||||
torch.accelerator.synchronize()
|
||||
|
||||
flat_k = paged.k[0].view(-1, kv_heads, dim)[:shorter]
|
||||
flat_v = paged.v[0].view(-1, kv_heads, dim)[:shorter]
|
||||
want = F.scaled_dot_product_attention(
|
||||
q,
|
||||
flat_k.unsqueeze(0).transpose(1, 2).contiguous(),
|
||||
flat_v.unsqueeze(0).transpose(1, 2).contiguous(),
|
||||
enable_gqa=True,
|
||||
scale=scale,
|
||||
).transpose(1, 2)
|
||||
torch.testing.assert_close(out.float(), want.float(), atol=2e-3, rtol=2e-3)
|
||||
|
||||
|
||||
@cuda_only
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.L4
|
||||
def test_a_captured_graph_writes_each_step_to_its_own_slot():
|
||||
"""The write slot has to be a device tensor, not a Python index.
|
||||
|
||||
``test_a_captured_graph_follows_a_later_length`` pins the attended span.
|
||||
The slot written is the other half: a Python index is frozen at capture, so
|
||||
every replay would overwrite the capture-time slot and the cache would stop
|
||||
growing -- correct eagerly, silently wrong once captured.
|
||||
"""
|
||||
from vllm_omni.diffusion.models.sensenova_u1.paged_decode import PagedDecodeCache
|
||||
|
||||
heads, kv_heads, dim, n = 8, 2, 64, 100
|
||||
dev, dt = torch.device("cuda"), torch.bfloat16
|
||||
torch.manual_seed(0)
|
||||
dyn = _Cache(
|
||||
[
|
||||
_Layer(
|
||||
torch.randn(1, kv_heads, n, dim, device=dev, dtype=dt),
|
||||
torch.randn(1, kv_heads, n, dim, device=dev, dtype=dt),
|
||||
)
|
||||
]
|
||||
)
|
||||
paged = PagedDecodeCache.from_dynamic_cache(dyn, 1, dev, dt)
|
||||
q = torch.randn(1, heads, 1, dim, device=dev, dtype=dt)
|
||||
k = torch.zeros(1, kv_heads, 1, dim, device=dev, dtype=dt)
|
||||
v = torch.zeros_like(k)
|
||||
scale = dim**-0.5
|
||||
|
||||
# Capture on the step that writes slot n-1, restoring what is already there
|
||||
# so the capture itself changes nothing.
|
||||
paged.set_length(n)
|
||||
k[0, :, 0] = dyn.layers[0].keys[0, :, n - 1]
|
||||
v[0, :, 0] = dyn.layers[0].values[0, :, n - 1]
|
||||
side = torch.cuda.Stream()
|
||||
side.wait_stream(torch.cuda.current_stream())
|
||||
with torch.cuda.stream(side):
|
||||
for _ in range(3):
|
||||
paged.attend(0, q, k, v, scale)
|
||||
torch.cuda.current_stream().wait_stream(side)
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(graph):
|
||||
paged.attend(0, q, k, v, scale)
|
||||
|
||||
kept = paged.k[0].view(-1, kv_heads, dim)[n - 1].clone()
|
||||
|
||||
# The next decode step: one token further, different K/V.
|
||||
paged.set_length(n + 1)
|
||||
k[0, :, 0] = 1.5
|
||||
v[0, :, 0] = -2.5
|
||||
graph.replay()
|
||||
current_omni_platform.synchronize()
|
||||
|
||||
flat_k = paged.k[0].view(-1, kv_heads, dim)
|
||||
flat_v = paged.v[0].view(-1, kv_heads, dim)
|
||||
torch.testing.assert_close(flat_k[n].float(), torch.full_like(flat_k[n], 1.5).float())
|
||||
torch.testing.assert_close(flat_v[n].float(), torch.full_like(flat_v[n], -2.5).float())
|
||||
torch.testing.assert_close(flat_k[n - 1].float(), kept.float())
|
||||
|
||||
|
||||
@cuda_only
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.L4
|
||||
def test_a_whole_decode_loop_matches_the_unpaged_path():
|
||||
"""Per-step kernel equivalence is not loop equivalence.
|
||||
|
||||
With the switch off a single-token step attends over a cache grown by
|
||||
``torch.cat`` and no mask; with it on the same step reads a bucket-sized
|
||||
buffer through ``seqused_k``. Run the same sequence of steps through both,
|
||||
crossing a bucket boundary on the way, and require the same argmax at every
|
||||
step off a fixed projection -- which is the token id the pipeline would emit.
|
||||
"""
|
||||
import torch.nn.functional as F
|
||||
|
||||
from vllm_omni.diffusion.models.sensenova_u1.paged_decode import PagedDecodeCache
|
||||
|
||||
heads, kv_heads, dim = 8, 2, 64
|
||||
prefill, steps = 500, 40 # crosses the 512 bucket at step 13
|
||||
dev, dt = torch.device("cuda"), torch.bfloat16
|
||||
scale = dim**-0.5
|
||||
torch.manual_seed(7)
|
||||
|
||||
keys = torch.randn(1, kv_heads, prefill, dim, device=dev, dtype=dt)
|
||||
values = torch.randn(1, kv_heads, prefill, dim, device=dev, dtype=dt)
|
||||
paged = PagedDecodeCache.from_dynamic_cache(_Cache([_Layer(keys, values)]), 1, dev, dt)
|
||||
ref_k, ref_v = keys.clone(), values.clone()
|
||||
proj = torch.randn(dim * heads, 128, device=dev, dtype=torch.float32)
|
||||
|
||||
paged_ids, ref_ids, worst = [], [], 0.0
|
||||
for step in range(steps):
|
||||
q = torch.randn(1, heads, 1, dim, device=dev, dtype=dt)
|
||||
k = torch.randn(1, kv_heads, 1, dim, device=dev, dtype=dt)
|
||||
v = torch.randn(1, kv_heads, 1, dim, device=dev, dtype=dt)
|
||||
|
||||
if paged.length + 1 > paged.bucket:
|
||||
paged.grow(paged.length + 1)
|
||||
paged.set_length(paged.length + 1)
|
||||
got = paged.attend(0, q, k, v, scale)
|
||||
|
||||
ref_k = torch.cat([ref_k, k], dim=2)
|
||||
ref_v = torch.cat([ref_v, v], dim=2)
|
||||
want = F.scaled_dot_product_attention(q, ref_k, ref_v, enable_gqa=True, scale=scale).transpose(1, 2)
|
||||
|
||||
worst = max(worst, (got.float() - want.float()).abs().max().item())
|
||||
paged_ids.append(int((got.float().reshape(1, -1) @ proj).argmax()))
|
||||
ref_ids.append(int((want.float().reshape(1, -1) @ proj).argmax()))
|
||||
|
||||
assert paged.bucket == 1024, "the loop never crossed a bucket boundary"
|
||||
assert paged_ids == ref_ids, (
|
||||
f"argmax diverges at step {next(i for i, (a, b) in enumerate(zip(paged_ids, ref_ids)) if a != b)}"
|
||||
)
|
||||
assert worst < 5e-2, f"largest per-step deviation {worst:.3e}"
|
||||
|
||||
|
||||
class _StubLM(torch.nn.Module):
|
||||
"""Just enough CUDA work inside the capture for a pool to be allocated."""
|
||||
|
||||
def __init__(self, device):
|
||||
super().__init__()
|
||||
self.w = torch.randn(16, 64, device=device)
|
||||
|
||||
def forward(self, input_ids, indexes, past_key_values=None, use_cache=False, paged_cache=None):
|
||||
x = input_ids.float().expand(1, 16) @ self.w
|
||||
return SimpleNamespace(logits=x.unsqueeze(0))
|
||||
|
||||
|
||||
@cuda_only
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.L4
|
||||
def test_the_decode_graph_captures_into_the_shared_platform_pool(monkeypatch):
|
||||
"""``_decode_context`` builds a runner per request, so a private capture pool
|
||||
hands every request its own arena and never gives it back. Measured on a
|
||||
real model that was about 40 MiB of device memory per request, rising
|
||||
linearly over twelve sequential think requests while the same twelve with
|
||||
the switch off held flat.
|
||||
"""
|
||||
from vllm.platforms import current_platform
|
||||
|
||||
from vllm_omni.diffusion.models.sensenova_u1.paged_decode import DecodeGraphRunner
|
||||
|
||||
pools = []
|
||||
real_graph = torch.cuda.graph
|
||||
|
||||
def spy(graph, pool=None, **kwargs):
|
||||
pools.append(pool)
|
||||
return real_graph(graph, pool=pool, **kwargs)
|
||||
|
||||
monkeypatch.setattr(torch.cuda, "graph", spy)
|
||||
|
||||
dev = torch.device("cuda")
|
||||
cache = PagedDecodeCache.from_dynamic_cache(_dyn_cache(8, dev), LAYERS, dev, torch.float32)
|
||||
runner = DecodeGraphRunner(_StubLM(dev), cache, dev)
|
||||
runner.step(0, 0)
|
||||
|
||||
assert pools, "no graph was captured"
|
||||
assert pools[0] is not None, "captured into a fresh private pool"
|
||||
assert pools[0] == current_platform.get_global_graph_pool()
|
||||
assert not hasattr(runner, "_pool"), "the runner still keeps a pool of its own"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The cache is only worth anything if production actually reaches it, and the
|
||||
# kwargs it needs are not in every wheel that exports the kernel. The tests
|
||||
# above would all still pass with the `forward_und` branch deleted, so these
|
||||
# drive the real dispatch instead of the cache object.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _PagedProbe:
|
||||
def __init__(self, out):
|
||||
self.out = out
|
||||
self.calls: list[tuple[int, torch.Size, float]] = []
|
||||
|
||||
def attend(self, layer_idx, query, key, value, scaling):
|
||||
self.calls.append((layer_idx, query.shape, scaling))
|
||||
return self.out
|
||||
|
||||
|
||||
class _AttnHost:
|
||||
"""Carries only what ``forward_und`` touches on the way to the branch."""
|
||||
|
||||
forward_und = SenseNovaU1Attention.forward_und
|
||||
qkv_proj = q_norm = k_norm = q_norm_hw = k_norm_hw = None
|
||||
layer_idx = 3
|
||||
scaling = HEAD_DIM**-0.5
|
||||
|
||||
def __init__(self, seq, paged_out):
|
||||
self._qkv = (
|
||||
torch.randn(1, N_HEADS, seq, HEAD_DIM),
|
||||
torch.randn(1, KV_HEADS, seq, HEAD_DIM),
|
||||
torch.randn(1, KV_HEADS, seq, HEAD_DIM),
|
||||
)
|
||||
self.paged = _PagedProbe(paged_out)
|
||||
self.sdpa_calls: list[torch.Tensor | None] = []
|
||||
|
||||
def _project_and_rope(self, *args, **kwargs):
|
||||
return self._qkv
|
||||
|
||||
def _run_attn(self, query, key, value, mask):
|
||||
self.sdpa_calls.append(mask)
|
||||
return torch.zeros(1, N_HEADS, query.shape[2], HEAD_DIM)
|
||||
|
||||
def o_proj(self, hidden_states):
|
||||
return hidden_states * 2, None
|
||||
|
||||
|
||||
def test_forward_und_sends_a_single_token_decode_to_the_paged_cache():
|
||||
"""Goes through the real dispatch, so deleting the branch turns this red."""
|
||||
out = torch.randn(1, N_HEADS, 1, HEAD_DIM)
|
||||
host = _AttnHost(seq=1, paged_out=out)
|
||||
got = host.forward_und(torch.zeros(1, 1, 16), None, None, position_embeddings=None, paged_cache=host.paged)
|
||||
assert len(host.paged.calls) == 1, "the paged hook was never reached"
|
||||
assert host.sdpa_calls == [], "the same step also ran the SDPA path"
|
||||
assert host.paged.calls[0][0] == host.layer_idx
|
||||
torch.testing.assert_close(got, out.reshape(1, 1, -1).contiguous() * 2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"seq,mask",
|
||||
[(2, None), (1, torch.zeros(1, 1, 1, 1))],
|
||||
ids=["multi_token", "masked"],
|
||||
)
|
||||
def test_only_an_unmasked_single_token_step_takes_the_paged_path(seq, mask):
|
||||
"""Both other shapes are uncapturable, and the cache holds one row per step."""
|
||||
host = _AttnHost(seq=seq, paged_out=torch.zeros(1, N_HEADS, seq, HEAD_DIM))
|
||||
host.forward_und(torch.zeros(1, seq, 16), None, mask, position_embeddings=None, paged_cache=host.paged)
|
||||
assert host.paged.calls == [], "a step that cannot be captured reached the paged cache"
|
||||
assert len(host.sdpa_calls) == 1
|
||||
|
||||
|
||||
def test_a_kernel_missing_any_kwarg_we_pass_is_not_supported(monkeypatch):
|
||||
"""The call names every argument, so the probe has to cover every name.
|
||||
|
||||
An older wheel exports ``flash_attn_varlen_func`` without the paged kwargs,
|
||||
and a future one may rename the standard ones. Either way the probe is what
|
||||
keeps that install on SDPA instead of raising TypeError at the first decode
|
||||
step, with the feature on by default.
|
||||
"""
|
||||
|
||||
def no_paged_kwargs(q, k, v, max_seqlen_q, cu_seqlens_q, max_seqlen_k, cu_seqlens_k, causal=False):
|
||||
raise AssertionError("must not be called") # pragma: no cover
|
||||
|
||||
def renamed_standard_args(q, k, v, max_q, cu_q, max_k, seqused_k=None, block_table=None, causal=False):
|
||||
raise AssertionError("must not be called") # pragma: no cover
|
||||
|
||||
for kernel in (no_paged_kwargs, renamed_standard_args):
|
||||
monkeypatch.setattr(paged_decode, "_flash_varlen", lambda kernel=kernel: kernel)
|
||||
assert paged_decode.paged_decode_supported(torch.device("cuda"), HEAD_DIM) is False
|
||||
|
||||
def current_kernel(
|
||||
q,
|
||||
k,
|
||||
v,
|
||||
max_seqlen_q,
|
||||
cu_seqlens_q,
|
||||
max_seqlen_k,
|
||||
seqused_k=None,
|
||||
block_table=None,
|
||||
softmax_scale=None,
|
||||
causal=False,
|
||||
):
|
||||
raise AssertionError("must not be called") # pragma: no cover
|
||||
|
||||
monkeypatch.setattr(paged_decode, "_flash_varlen", lambda: current_kernel)
|
||||
assert paged_decode.paged_decode_supported(torch.device("cuda"), HEAD_DIM) is True
|
||||
|
||||
|
||||
def test_only_q_k_and_v_reach_the_kernel_positionally(monkeypatch):
|
||||
"""A positional standard argument binds to whatever a future wheel puts in
|
||||
that slot, and the probe above only checks that the names exist."""
|
||||
seen = {}
|
||||
|
||||
def recorder(*args, **kwargs):
|
||||
seen["args"], seen["kwargs"] = args, kwargs
|
||||
return torch.zeros(1, N_HEADS, HEAD_DIM)
|
||||
|
||||
monkeypatch.setattr(paged_decode, "_flash_varlen", lambda: recorder)
|
||||
cache = PagedDecodeCache(1, KV_HEADS, HEAD_DIM, 8, torch.device("cpu"), torch.float32)
|
||||
cache.set_length(4)
|
||||
cache.attend(
|
||||
0,
|
||||
torch.zeros(1, N_HEADS, 1, HEAD_DIM),
|
||||
torch.zeros(1, KV_HEADS, 1, HEAD_DIM),
|
||||
torch.zeros(1, KV_HEADS, 1, HEAD_DIM),
|
||||
1.0,
|
||||
)
|
||||
assert len(seen["args"]) == 3, "only q, k and v may be positional"
|
||||
assert set(seen["kwargs"]) == {
|
||||
"max_seqlen_q",
|
||||
"cu_seqlens_q",
|
||||
"max_seqlen_k",
|
||||
"seqused_k",
|
||||
"block_table",
|
||||
"softmax_scale",
|
||||
"causal",
|
||||
}
|
||||
|
||||
|
||||
def test_the_kill_switch_returns_no_cache(monkeypatch):
|
||||
"""The recipe documents `VLLM_OMNI_SENSENOVA_PAGED_DECODE=0` as the way back
|
||||
to the ordinary cache, so the switch needs a test rather than only prose."""
|
||||
from vllm_omni.diffusion.models.sensenova_u1.pipeline_sensenova_u1 import (
|
||||
SenseNovaU1Pipeline,
|
||||
)
|
||||
|
||||
monkeypatch.setenv("VLLM_OMNI_SENSENOVA_PAGED_DECODE", "0")
|
||||
# The env check returns before anything else on the pipeline is touched.
|
||||
host = object.__new__(SenseNovaU1Pipeline)
|
||||
assert SenseNovaU1Pipeline._decode_context(host, object()) is None
|
||||
|
||||
|
||||
def _pipeline_host(monkeypatch, device="cpu", language_model=None):
|
||||
"""A bare pipeline carrying only what ``_decode_context`` touches."""
|
||||
from vllm_omni.diffusion.models.sensenova_u1 import pipeline_sensenova_u1 as pipe_mod
|
||||
|
||||
monkeypatch.setenv("VLLM_OMNI_SENSENOVA_PAGED_DECODE", "1")
|
||||
monkeypatch.setattr(pipe_mod, "paged_decode_supported", lambda dev, head_dim: True)
|
||||
layer = SimpleNamespace(self_attn=SimpleNamespace(head_dim=HEAD_DIM))
|
||||
host = object.__new__(pipe_mod.SenseNovaU1Pipeline)
|
||||
# The pipeline is an nn.Module, and the language model assigned below is one
|
||||
# too, so the bookkeeping has to exist before the assignment.
|
||||
torch.nn.Module.__init__(host)
|
||||
host.device = device
|
||||
lm = language_model if language_model is not None else torch.nn.Module()
|
||||
lm.model = SimpleNamespace(layers=[layer] * LAYERS)
|
||||
host.language_model = lm
|
||||
return pipe_mod, host
|
||||
|
||||
|
||||
def test_the_decode_context_is_reused_across_requests(monkeypatch):
|
||||
"""A capture binds the addresses it recorded, so a cache built per request
|
||||
forces a capture per request. Two requests whose prefill fits the same
|
||||
bucket have to come back with the same cache and the same runner, and with
|
||||
the generation unchanged so the captures stay valid.
|
||||
"""
|
||||
pipe_mod, host = _pipeline_host(monkeypatch)
|
||||
monkeypatch.setattr(pipe_mod, "DecodeGraphRunner", lambda lm, cache, dev: SimpleNamespace(cache=cache))
|
||||
ctx = pipe_mod.SenseNovaU1Pipeline._decode_context
|
||||
|
||||
first = ctx(host, _dyn_cache(10))
|
||||
assert first is not None
|
||||
second_input = _dyn_cache(20)
|
||||
second = ctx(host, second_input)
|
||||
assert second[0] is first[0], "the cache was rebuilt for a request that fits it"
|
||||
assert second[1] is first[1], "the runner was rebuilt, so its captures are gone"
|
||||
assert second[0].generation == first[0].generation, "reuse must not invalidate a capture"
|
||||
assert second[0].length == 20
|
||||
|
||||
# The reused buffers must carry the new request's prefill, not the old one.
|
||||
flat = second[0].k[0].view(-1, KV_HEADS, HEAD_DIM)[:20]
|
||||
torch.testing.assert_close(flat, second_input.layers[0].keys[0].transpose(0, 1))
|
||||
|
||||
# A prefill past the bucket cannot share the buffers.
|
||||
bigger = ctx(host, _dyn_cache(BUCKETS[0] + 1))
|
||||
assert bigger[0] is not first[0], "a prefill past the bucket must get new buffers"
|
||||
|
||||
|
||||
def test_releasing_the_captures_forces_a_rebuild(monkeypatch):
|
||||
"""Sleep level 2 discards the memory a capture recorded, so what the
|
||||
pipeline reuses across requests has to be droppable."""
|
||||
pipe_mod, host = _pipeline_host(monkeypatch)
|
||||
monkeypatch.setattr(pipe_mod, "DecodeGraphRunner", lambda lm, cache, dev: SimpleNamespace(cache=cache))
|
||||
ctx = pipe_mod.SenseNovaU1Pipeline._decode_context
|
||||
|
||||
first = ctx(host, _dyn_cache(10))
|
||||
assert ctx(host, _dyn_cache(10))[0] is first[0], "the fixture is not reusing to begin with"
|
||||
pipe_mod.SenseNovaU1Pipeline.release_captured_graphs(host)
|
||||
after = ctx(host, _dyn_cache(10))
|
||||
assert after[0] is not first[0], "the cache survived the release"
|
||||
assert after[1] is not first[1], "the runner survived the release"
|
||||
|
||||
|
||||
class _FakeLoRAWrapper(BaseLayerWithLoRA):
|
||||
"""Stands in for what ``replace_submodule`` leaves in the module tree."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
|
||||
def test_no_capture_crosses_a_request_once_lora_wrappers_are_installed(monkeypatch):
|
||||
"""Base -> LoRA -> base. The reuse test cannot see an adapter change.
|
||||
|
||||
``DiffusionLoRAManager`` replaces the decode path's linear layers with
|
||||
``BaseLayerWithLoRA`` and binds, rescales or resets slot 0 per request, and
|
||||
the wrappers stay in the tree afterwards. A graph captured before an adapter
|
||||
was bound would replay without its matmuls; one captured with it bound would
|
||||
survive its removal. Neither moves the shape, dtype or bucket that decide
|
||||
reuse, so the wrappers themselves have to end it.
|
||||
"""
|
||||
pipe_mod, host = _pipeline_host(monkeypatch)
|
||||
monkeypatch.setattr(pipe_mod, "DecodeGraphRunner", lambda lm, cache, dev: SimpleNamespace(cache=cache))
|
||||
ctx = pipe_mod.SenseNovaU1Pipeline._decode_context
|
||||
|
||||
base = ctx(host, _dyn_cache(10))
|
||||
assert ctx(host, _dyn_cache(10))[0] is base[0], "the fixture is not reusing to begin with"
|
||||
|
||||
host.language_model.add_module("q_proj", _FakeLoRAWrapper())
|
||||
adapted = ctx(host, _dyn_cache(10))
|
||||
assert adapted[0] is not base[0], "an adapted request reused the base capture"
|
||||
assert adapted[1] is not base[1], "an adapted request reused the base runner"
|
||||
|
||||
back = ctx(host, _dyn_cache(10))
|
||||
assert back[0] is not adapted[0], "a later request reused the adapted capture"
|
||||
assert back[1] is not adapted[1], "a later request reused the adapted runner"
|
||||
assert getattr(host, "_paged_decode", None) is None, "an adapted capture was left on the pipeline"
|
||||
|
||||
|
||||
@cuda_only
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.L4
|
||||
def test_a_second_request_replays_the_first_capture(monkeypatch):
|
||||
"""Reuse is only worth anything if the capture survives it."""
|
||||
dev = torch.device("cuda")
|
||||
pipe_mod, host = _pipeline_host(monkeypatch, device="cuda", language_model=_StubLM(dev))
|
||||
ctx = pipe_mod.SenseNovaU1Pipeline._decode_context
|
||||
|
||||
cache, runner = ctx(host, _dyn_cache(10, dev))
|
||||
cache.set_length(cache.length + 1)
|
||||
runner.step(0, 0)
|
||||
assert runner.captures == 1
|
||||
|
||||
cache2, runner2 = ctx(host, _dyn_cache(12, dev))
|
||||
assert cache2 is cache and runner2 is runner
|
||||
cache2.set_length(cache2.length + 1)
|
||||
runner2.step(0, 0)
|
||||
assert runner.captures == 1, "the second request captured the graph again"
|
||||
|
||||
|
||||
class _Tok:
|
||||
"""The two tokenizer calls ``_generate_text`` makes."""
|
||||
|
||||
EOS = 7
|
||||
|
||||
def convert_tokens_to_ids(self, token):
|
||||
return self.EOS
|
||||
|
||||
def decode(self, ids, skip_special_tokens=False):
|
||||
return " ".join(str(i) for i in ids)
|
||||
|
||||
|
||||
class _Out:
|
||||
def __init__(self, logits, past_key_values):
|
||||
self.logits = logits
|
||||
self.past_key_values = past_key_values
|
||||
|
||||
|
||||
class _TextHost:
|
||||
"""Carries only what ``_generate_text`` touches."""
|
||||
|
||||
def __init__(self, context):
|
||||
self._context = context
|
||||
self.seen: list[object] = []
|
||||
self.tokenizer = _Tok()
|
||||
|
||||
def _decode_context(self, past_key_values):
|
||||
return self._context
|
||||
|
||||
def _ar_step(self, next_token, t_idx, past_key_values, decode=None):
|
||||
self.seen.append(decode)
|
||||
# Stop the loop: argmax of this row is the EOS id.
|
||||
logits = torch.full((1, 1, 8), -1.0)
|
||||
logits[0, 0, _Tok.EOS] = 1.0
|
||||
return _Out(logits, past_key_values)
|
||||
|
||||
|
||||
def test_text_decoding_uses_the_paged_context_too():
|
||||
"""Image-to-text and text-to-text run their own decode loop. It used to call
|
||||
``_ar_step`` without the context, so the paged path was documented but never
|
||||
reached outside think."""
|
||||
from vllm_omni.diffusion.models.sensenova_u1.pipeline_sensenova_u1 import (
|
||||
SenseNovaU1Pipeline,
|
||||
)
|
||||
|
||||
context = object()
|
||||
host = _TextHost(context)
|
||||
prefix = torch.full((1, 1, 8), -1.0)
|
||||
prefix[0, 0, 5] = 1.0 # not EOS, so one step runs
|
||||
SenseNovaU1Pipeline._generate_text(host, prefix, past_key_values=None, t_idx=0)
|
||||
assert host.seen == [context], f"text decoding ran with decode={host.seen}"
|
||||
|
||||
|
||||
class _WarmupReq:
|
||||
"""The engine's dummy request, which carries the reserved id."""
|
||||
|
||||
def __init__(self, request_id):
|
||||
self.request_id = request_id
|
||||
self.prompts = [{"prompt": "", "modalities": ["image"]}]
|
||||
|
||||
|
||||
def test_the_dummy_warmup_request_drives_one_decode_step(monkeypatch):
|
||||
"""The dummy request is a text-to-image run with think off, so it exercises
|
||||
the prefill shape and never the decode one. Without this hook whatever the
|
||||
deploy config compiles for decode lands on the first real request."""
|
||||
from vllm_omni.diffusion.models.sensenova_u1.pipeline_sensenova_u1 import (
|
||||
SenseNovaU1Pipeline,
|
||||
)
|
||||
from vllm_omni.diffusion.request import DUMMY_DIFFUSION_REQUEST_ID
|
||||
|
||||
calls: list[str] = []
|
||||
host = object.__new__(SenseNovaU1Pipeline)
|
||||
monkeypatch.setattr(host, "_warm_ar_decode", lambda: calls.append("warm"), raising=False)
|
||||
monkeypatch.setattr(
|
||||
SenseNovaU1Pipeline,
|
||||
"_parse_request",
|
||||
lambda self, req: (_ for _ in ()).throw(_StopForwardError()),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
with pytest.raises(_StopForwardError):
|
||||
SenseNovaU1Pipeline.forward(host, _WarmupReq(DUMMY_DIFFUSION_REQUEST_ID))
|
||||
assert calls == ["warm"], "the dummy warmup request did not drive a decode step"
|
||||
|
||||
calls.clear()
|
||||
with pytest.raises(_StopForwardError):
|
||||
SenseNovaU1Pipeline.forward(host, _WarmupReq("a-real-request"))
|
||||
assert calls == [], "a real request paid for the warmup"
|
||||
|
||||
|
||||
class _StopForwardError(Exception):
|
||||
"""Stops ``forward`` right after the warmup hook, so the test needs no model."""
|
||||
@@ -0,0 +1,107 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""CPU tests for SenseNova-U1 RMSNorm and the U1.5 config surface.
|
||||
|
||||
``Qwen3RMSNorm`` was a hand-rolled fp32 up/down-cast chain; it now calls the fused
|
||||
``F.rms_norm``. These pin the mathematics against an independent reference so a
|
||||
future kernel swap cannot silently change what the layer computes, and pin the two
|
||||
config fields that distinguish U1.5 from U1.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.sensenova_u1.sensenova_u1_transformer import Qwen3RMSNorm
|
||||
from vllm_omni.transformers_utils.configs.sensenova_u1 import SenseNovaU1Config
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _reference(x: torch.Tensor, weight: torch.Tensor, eps: float) -> torch.Tensor:
|
||||
"""RMSNorm written out in float64 so it is independent of the implementation."""
|
||||
x64 = x.to(torch.float64)
|
||||
var = x64.pow(2).mean(-1, keepdim=True)
|
||||
return (weight.to(torch.float64) * (x64 * torch.rsqrt(var + eps))).to(x.dtype)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hidden", [8, 128, 4096])
|
||||
@pytest.mark.parametrize(
|
||||
"dtype,atol",
|
||||
[(torch.float32, 1e-5), (torch.bfloat16, 3e-2), (torch.float16, 4e-3)],
|
||||
)
|
||||
def test_rmsnorm_matches_float64_reference(hidden: int, dtype: torch.dtype, atol: float):
|
||||
torch.manual_seed(0)
|
||||
norm = Qwen3RMSNorm(hidden, eps=1e-6).to(dtype)
|
||||
with torch.no_grad():
|
||||
norm.weight.copy_(torch.rand(hidden, dtype=dtype) + 0.5)
|
||||
x = torch.randn(7, hidden, dtype=dtype)
|
||||
got = norm(x)
|
||||
want = _reference(x, norm.weight, norm.variance_epsilon)
|
||||
assert got.dtype == dtype
|
||||
assert got.shape == x.shape
|
||||
torch.testing.assert_close(got.to(torch.float32), want.to(torch.float32), atol=atol, rtol=atol)
|
||||
|
||||
|
||||
def _cast_chain(x: torch.Tensor, weight: torch.Tensor, eps: float) -> torch.Tensor:
|
||||
"""The implementation this PR replaced, kept so its accuracy can be compared."""
|
||||
var = x.to(torch.float32).pow(2).mean(-1, keepdim=True)
|
||||
return weight * (x.to(torch.float32) * torch.rsqrt(var + eps)).to(weight.dtype)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hidden", [512, 4096])
|
||||
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
|
||||
def test_rmsnorm_is_closer_to_float64_than_the_cast_chain(hidden: int, dtype: torch.dtype):
|
||||
"""The cast chain rounds to ``dtype`` before the weight multiply and so rounds
|
||||
twice, while ``F.rms_norm`` rounds once. Restoring the cast chain fails here."""
|
||||
torch.manual_seed(0)
|
||||
norm = Qwen3RMSNorm(hidden, eps=1e-6).to(dtype)
|
||||
with torch.no_grad():
|
||||
norm.weight.copy_(torch.rand(hidden, dtype=dtype) + 0.5)
|
||||
x = torch.randn(64, hidden, dtype=dtype)
|
||||
|
||||
reference = _reference(x, norm.weight, norm.variance_epsilon).to(torch.float64)
|
||||
scale = reference.abs().mean()
|
||||
|
||||
def relative_error(out: torch.Tensor) -> float:
|
||||
return ((out.to(torch.float64) - reference).abs().mean() / scale).item()
|
||||
|
||||
fused = relative_error(norm(x))
|
||||
legacy = relative_error(_cast_chain(x, norm.weight, norm.variance_epsilon))
|
||||
assert fused < legacy, f"{dtype} hidden={hidden}: fused {fused:.3e} not better than {legacy:.3e}"
|
||||
|
||||
|
||||
def test_rmsnorm_is_scale_equivariant():
|
||||
"""RMSNorm divides by the RMS, so scaling the input must not change the output."""
|
||||
torch.manual_seed(0)
|
||||
norm = Qwen3RMSNorm(64, eps=1e-12)
|
||||
x = torch.randn(4, 64)
|
||||
torch.testing.assert_close(norm(x), norm(x * 7.5), atol=1e-4, rtol=1e-4)
|
||||
|
||||
|
||||
def test_rmsnorm_preserves_leading_dims():
|
||||
norm = Qwen3RMSNorm(16, eps=1e-6)
|
||||
for shape in [(16,), (3, 16), (2, 3, 16)]:
|
||||
assert norm(torch.randn(*shape)).shape == shape
|
||||
|
||||
|
||||
def test_rmsnorm_weight_is_applied():
|
||||
norm = Qwen3RMSNorm(4, eps=1e-6)
|
||||
x = torch.randn(2, 4)
|
||||
base = norm(x)
|
||||
with torch.no_grad():
|
||||
norm.weight.mul_(3.0)
|
||||
torch.testing.assert_close(norm(x), base * 3.0, atol=1e-5, rtol=1e-5)
|
||||
|
||||
|
||||
def test_u15_config_fields_default_to_u1_values():
|
||||
"""U1.5 flips exactly these two; the defaults must stay U1's so old checkpoints
|
||||
keep their behaviour when the keys are absent."""
|
||||
cfg = SenseNovaU1Config()
|
||||
assert cfg.use_pixel_head is False
|
||||
assert cfg.noise_scale_max_value == 10.0
|
||||
|
||||
|
||||
def test_u15_config_fields_are_read_from_kwargs():
|
||||
cfg = SenseNovaU1Config(use_pixel_head=True, noise_scale_max_value=16.0)
|
||||
assert cfg.use_pixel_head is True
|
||||
assert cfg.noise_scale_max_value == 16.0
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
import pytest
|
||||
|
||||
from vllm_omni.diffusion.sched import DMD2SigmaSchedule
|
||||
@@ -18,7 +18,8 @@ def test_step_count_is_intervals_not_boundaries():
|
||||
[
|
||||
[],
|
||||
[1.0],
|
||||
[0.9, 0.5, 0.0],
|
||||
[1.5, 0.5, 0.0],
|
||||
[0.0, 0.5, 0.0],
|
||||
[1.0, 0.5, 0.1],
|
||||
[1.0, 0.4, 0.6, 0.0],
|
||||
[1.0, 0.5, 0.5, 0.0],
|
||||
@@ -31,6 +32,15 @@ def test_malformed_positions_are_rejected(positions):
|
||||
DMD2SigmaSchedule.from_positions(positions)
|
||||
|
||||
|
||||
def test_a_capped_first_position_is_a_schedule_not_a_malformed_one():
|
||||
# A DMD2 student trained with max_timestep_ratio < 1 never sees pure noise,
|
||||
# so its opening rung sits just below 1.0 (FastH3: 999/1000).
|
||||
schedule = DMD2SigmaSchedule.from_positions([0.999, 0.749, 0.5, 0.25, 0.0])
|
||||
|
||||
assert schedule.base_schedule[0] == 0.999
|
||||
assert schedule.num_inference_steps == 4
|
||||
|
||||
|
||||
def test_one_base_schedule_drives_several_shift_scales():
|
||||
schedule = DMD2SigmaSchedule.from_positions([1.0, 0.7, 0.4, 0.15, 0.0])
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
@@ -179,6 +179,30 @@ def test_request_scoped_cache_dit_lifecycle_is_pipeline_opt_in():
|
||||
assert events == [backend]
|
||||
|
||||
|
||||
def test_release_captured_graphs_clears_runners_and_delegates_to_the_pipeline():
|
||||
"""Both halves in one place, so a pipeline that keeps captures is collected
|
||||
without the caller knowing which pipelines have one."""
|
||||
released = []
|
||||
runner = object.__new__(DiffusionModelRunner)
|
||||
runner.graph_runners = {"decode": object()}
|
||||
runner.pipeline = SimpleNamespace(release_captured_graphs=lambda: released.append(True))
|
||||
|
||||
runner.release_captured_graphs()
|
||||
|
||||
assert runner.graph_runners == {}
|
||||
assert released == [True]
|
||||
|
||||
|
||||
def test_release_captured_graphs_tolerates_a_pipeline_without_captures():
|
||||
"""Most pipelines keep none, and the runner may not carry `graph_runners`."""
|
||||
runner = object.__new__(DiffusionModelRunner)
|
||||
runner.pipeline = object()
|
||||
|
||||
runner.release_captured_graphs()
|
||||
|
||||
assert not hasattr(runner, "graph_runners")
|
||||
|
||||
|
||||
def _make_runner(cache_backend, cache_backend_name: str, enable_cache_dit_summary: bool = True):
|
||||
runner = object.__new__(DiffusionModelRunner)
|
||||
runner.vllm_config = object()
|
||||
@@ -533,7 +557,14 @@ def test_profile_run_executes_forward_without_scheduler_kv_validation(monkeypatc
|
||||
return original_execute(*args, **kwargs)
|
||||
|
||||
runner._execute_request_list = execute
|
||||
monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context)
|
||||
forward_context_calls = []
|
||||
|
||||
@contextmanager
|
||||
def record_forward_context(*args, **kwargs):
|
||||
forward_context_calls.append((args, kwargs))
|
||||
yield
|
||||
|
||||
monkeypatch.setattr(model_runner_module, "set_forward_context", record_forward_context)
|
||||
reset_peak_memory_stats = Mock()
|
||||
monkeypatch.setattr(
|
||||
model_runner_module.current_omni_platform,
|
||||
@@ -549,6 +580,8 @@ def test_profile_run_executes_forward_without_scheduler_kv_validation(monkeypatc
|
||||
|
||||
assert runner.pipeline.forward_calls == 1
|
||||
assert record_names == ["pipeline_memory_profile"]
|
||||
assert len(forward_context_calls) == 1
|
||||
assert forward_context_calls[0][1]["in_diffusion_kv_memory_profile"] is True
|
||||
runner._validate_diffusion_kv_metadata.assert_not_called()
|
||||
reset_peak_memory_stats.assert_not_called()
|
||||
synchronize.assert_called_once_with()
|
||||
@@ -575,7 +608,14 @@ def test_profile_run_executes_maximum_step_batch_without_resetting_peak(monkeypa
|
||||
|
||||
runner.pipeline.denoise_step = denoise_step
|
||||
runner._validate_diffusion_kv_metadata = Mock(side_effect=AssertionError("profile must bypass admission"))
|
||||
monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context)
|
||||
forward_context_calls = []
|
||||
|
||||
@contextmanager
|
||||
def record_forward_context(*args, **kwargs):
|
||||
forward_context_calls.append((args, kwargs))
|
||||
yield
|
||||
|
||||
monkeypatch.setattr(model_runner_module, "set_forward_context", record_forward_context)
|
||||
reset_peak_memory_stats = Mock()
|
||||
monkeypatch.setattr(
|
||||
model_runner_module.current_omni_platform,
|
||||
@@ -592,6 +632,8 @@ def test_profile_run_executes_maximum_step_batch_without_resetting_peak(monkeypa
|
||||
assert observed_batch_rows == [(2, 2)]
|
||||
assert runner.state_cache == {}
|
||||
assert runner.input_batch is None
|
||||
assert len(forward_context_calls) == 1
|
||||
assert forward_context_calls[0][1]["in_diffusion_kv_memory_profile"] is True
|
||||
runner._validate_diffusion_kv_metadata.assert_not_called()
|
||||
reset_peak_memory_stats.assert_not_called()
|
||||
synchronize.assert_called_once_with()
|
||||
|
||||
@@ -441,7 +441,6 @@ class TestPipelineStreamingOutputToEntrypoint:
|
||||
model="test_model",
|
||||
od_config=od_config,
|
||||
metadata=metadata,
|
||||
batch_size=1,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -526,6 +525,7 @@ class TestSupportedPipelines:
|
||||
runner = object.__new__(DiffusionModelRunner)
|
||||
runner.vllm_config = _make_vllm_config()
|
||||
runner.od_config = SimpleNamespace(
|
||||
max_num_seqs=1,
|
||||
enable_cpu_offload=False,
|
||||
enable_layerwise_offload=False,
|
||||
enforce_eager=True,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""
|
||||
Unit tests for DiffusionWorker class.
|
||||
@@ -143,6 +143,21 @@ class TestDiffusionWorkerSleep:
|
||||
assert "buffer1" in mock_gpu_worker._sleep_saved_buffers
|
||||
assert "buffer2" in mock_gpu_worker._sleep_saved_buffers
|
||||
|
||||
def test_sleep_level_2_releases_captures_through_the_model_runner(self, mocker: MockerFixture, mock_gpu_worker):
|
||||
"""Level 2 discards the memory captures were recorded against, and the
|
||||
runner owns the release. The worker asks it once and does not reach past
|
||||
it into the pipeline."""
|
||||
mock_platform = mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform")
|
||||
mock_platform.get_free_memory.side_effect = [10 * 1024**3, 12 * 1024**3]
|
||||
mock_platform.get_device_total_memory.return_value = 80 * 1024**3
|
||||
mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.get_process_gpu_memory", side_effect=[0, 0])
|
||||
mock_gpu_worker.model_runner.pipeline.named_buffers = mocker.Mock(return_value=[])
|
||||
|
||||
mock_gpu_worker.sleep(level=2)
|
||||
|
||||
mock_gpu_worker.model_runner.release_captured_graphs.assert_called_once_with()
|
||||
mock_gpu_worker.model_runner.pipeline.release_captured_graphs.assert_not_called()
|
||||
|
||||
def test_sleep_memory_freed_validation(self, mocker: MockerFixture, mock_gpu_worker):
|
||||
"""Test that sleep validates memory was actually freed."""
|
||||
mock_allocator_class = patch_cumem_allocator(mocker)
|
||||
|
||||
@@ -47,7 +47,7 @@ def client(mock_engine):
|
||||
with patch.object(InlineStageDiffusionClient, "_enrich_config"):
|
||||
od_config = MagicMock(spec=OmniDiffusionConfig)
|
||||
od_config.streaming_output = False
|
||||
c = InlineStageDiffusionClient(model="test_model", od_config=od_config, metadata=metadata, batch_size=1)
|
||||
c = InlineStageDiffusionClient(model="test_model", od_config=od_config, metadata=metadata)
|
||||
yield c
|
||||
c.shutdown()
|
||||
|
||||
@@ -234,5 +234,4 @@ def test_inline_client_requires_replica_id(mock_engine):
|
||||
model="test_model",
|
||||
od_config=od_config,
|
||||
metadata=metadata,
|
||||
batch_size=1,
|
||||
)
|
||||
@@ -1171,7 +1171,6 @@ class TestStageDiffusionClientErrorPropagation:
|
||||
metadata,
|
||||
"tcp://req",
|
||||
"tcp://resp",
|
||||
batch_size=1,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -387,7 +387,6 @@ class TestPromptUpdateIntegration:
|
||||
model="test_model",
|
||||
od_config=od_config,
|
||||
metadata=metadata,
|
||||
batch_size=1,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
@@ -7,6 +7,8 @@ from dataclasses import asdict, dataclass
|
||||
|
||||
import pytest
|
||||
|
||||
import vllm_omni.diffusion.stage_diffusion_proc as stage_diffusion_proc
|
||||
import vllm_omni.plugins as omni_plugins
|
||||
from vllm_omni.diffusion.stage_diffusion_proc import StageDiffusionProc
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
@@ -26,6 +28,33 @@ BASE_INFER_STEPS = 10
|
||||
DELAY_BASE = 0.01
|
||||
|
||||
|
||||
def test_run_diffusion_proc_sets_lifecycle_before_loading_plugins(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
events: list[str] = []
|
||||
|
||||
class StopProcessError(Exception):
|
||||
pass
|
||||
|
||||
class TestStageDiffusionProc(StageDiffusionProc):
|
||||
def __init__(self, model, od_config):
|
||||
events.append("proc")
|
||||
raise StopProcessError
|
||||
|
||||
monkeypatch.setattr(omni_plugins, "load_omni_general_plugins", lambda: events.append("plugins"))
|
||||
monkeypatch.setattr(stage_diffusion_proc, "set_death_signal", lambda _: events.append("death_signal"))
|
||||
monkeypatch.setattr(stage_diffusion_proc.signal, "signal", lambda *_: events.append("signal_handler"))
|
||||
|
||||
with pytest.raises(StopProcessError):
|
||||
TestStageDiffusionProc.run_diffusion_proc(
|
||||
model="test-model",
|
||||
od_config=None,
|
||||
handshake_address="test-address",
|
||||
local_client=True,
|
||||
headless=False,
|
||||
)
|
||||
|
||||
assert events == ["death_signal", "signal_handler", "signal_handler", "plugins", "proc"]
|
||||
|
||||
|
||||
class MockDiffusionEngine:
|
||||
async def step_streaming(self, request):
|
||||
def simulate_step_delay(height, width, num_inference_steps) -> float:
|
||||
|
||||
@@ -2959,3 +2959,34 @@ def test_abort_clears_native_codec_state_before_external_id_reuse(build_adapter)
|
||||
)
|
||||
|
||||
assert torch.equal(replacement_payload.codes.audio, torch.full((1, 31), 202, dtype=torch.long))
|
||||
|
||||
|
||||
def test_save_async_boundary_holds_generation_without_request_counter(build_adapter):
|
||||
"""A boundary must not advance past a generation nothing can catch up to.
|
||||
|
||||
When the producing stage maintains ``_omni_segment_generation`` on the
|
||||
request, a boundary may start the next segment's watermark at
|
||||
``generation + 1`` because the following frame will carry it. On paths
|
||||
where the request has no such counter, that speculative advance makes every
|
||||
subsequent frame look stale and starves the stream permanently
|
||||
(vllm-project/vllm-omni#6816).
|
||||
"""
|
||||
adapter, _ = build_adapter(stage_id=1)
|
||||
|
||||
request = _req("req-nogen", RequestStatus.WAITING, external_req_id="ext-nogen")
|
||||
request.resumable = True
|
||||
request._omni_segment_generation = None
|
||||
|
||||
adapter.save_async(multimodal_output=None, request=request, is_segment_finished=True)
|
||||
assert adapter._segment_generation["ext-nogen"] == 0
|
||||
|
||||
follow_up = _req("req-nogen-next", RequestStatus.WAITING, external_req_id="ext-nogen")
|
||||
follow_up.resumable = True
|
||||
follow_up.num_computed_tokens = 3
|
||||
follow_up._omni_segment_generation = None
|
||||
queued_before = len(adapter._pending_save_reqs)
|
||||
|
||||
adapter.save_async(multimodal_output=None, request=follow_up, is_segment_finished=False)
|
||||
|
||||
assert len(adapter._pending_save_reqs) == queued_before + 1
|
||||
assert adapter._pending_save_reqs[-1]["request"] is follow_up
|
||||
@@ -22,7 +22,7 @@ from vllm.inputs import TokensPrompt
|
||||
|
||||
from tests.helpers.clean import wait_for_gpu_memory_to_clear
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
|
||||
from tests.helpers.stage_config import get_deploy_config_path
|
||||
from vllm_omni.entrypoints.async_omni import AsyncOmni
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
@@ -52,26 +52,7 @@ def _colocate_async_deploy() -> str:
|
||||
CI yaml plus CuMem on every stage fails engine-core startup in the L3
|
||||
merge job: Stage 1 resolves ``devices: "1"`` while only GPU 0 is visible.
|
||||
"""
|
||||
return modify_stage_config(
|
||||
get_deploy_config_path("qwen3_omni_moe_thinking.yaml"),
|
||||
updates={
|
||||
"stages": {
|
||||
0: {
|
||||
"enable_sleep_mode": True,
|
||||
"enforce_eager": True,
|
||||
"trust_remote_code": True,
|
||||
"tensor_parallel_size": 1,
|
||||
"devices": "0",
|
||||
"gpu_memory_utilization": 0.9,
|
||||
"max_num_seqs": 1,
|
||||
"max_model_len": 8192,
|
||||
"max_num_batched_tokens": 8192,
|
||||
"enable_prefix_caching": False,
|
||||
"skip_mm_profiling": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
return get_deploy_config_path("ci/qwen3_omni_moe_colocate_async.yaml")
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""MiniMax online-serving tests."""
|
||||
@@ -0,0 +1,369 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Shared request and server helpers for MiniMax-H3 L3 tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import concurrent.futures
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import av
|
||||
import requests
|
||||
|
||||
from tests.helpers.assertions import assert_video_valid
|
||||
from tests.helpers.media import generate_synthetic_image
|
||||
from tests.helpers.runtime import OmniServerParams, OpenAIClientHandler
|
||||
|
||||
# DLO starts worker processes and uses shared-memory queues. Always select
|
||||
# spawn for these cases so a parent fork cannot inherit stale resource-tracker
|
||||
# state from a preceding server lifecycle.
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
|
||||
MODEL = os.environ.get("VLLM_TEST_MINIMAX_H3_MODEL", "MiniMaxAI/MiniMax-H3")
|
||||
WIDTH = 1344
|
||||
HEIGHT = 768
|
||||
FPS = 24
|
||||
NUM_INFERENCE_STEPS = 4
|
||||
# Keep a failed collective/preprocessing regression bounded well below the
|
||||
# per-file Buildkite timeout. Healthy H100 runs complete in under two minutes
|
||||
# after startup; ten minutes leaves ample room for a cold compile.
|
||||
REQUEST_TIMEOUT_SECONDS = 600
|
||||
|
||||
# FastH3's four-step preview is a load-time fusion adapter. Its model-level
|
||||
# deltas cannot use the dynamic PEFT LoRA manager, so this test deliberately
|
||||
# skips on branches that predate PR #6714 instead of starting a base-H3 server.
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
FASTH3_SUPPORTED = (_REPO_ROOT / "vllm_omni/diffusion/models/minimax_h3/fasth3.py").is_file()
|
||||
# The Turbo artifact is handled by the MiniMax-specific loader introduced in
|
||||
# PR #6550; the generic diffusion LoRA manager cannot parse that checkpoint.
|
||||
TURBO_SUPPORTED = (_REPO_ROOT / "vllm_omni/diffusion/models/minimax_h3/lora.py").is_file()
|
||||
FASTH3_WIDTH = 1024
|
||||
FASTH3_HEIGHT = 576
|
||||
FASTH3_DURATION = 4.4
|
||||
|
||||
DLO_SERVER_ARGS = [
|
||||
"--trust-remote-code",
|
||||
"--num-gpus",
|
||||
"2",
|
||||
"--tensor-parallel-size",
|
||||
"1",
|
||||
"--data-parallel-size",
|
||||
"2",
|
||||
"--request-batch-max-wait-ms",
|
||||
"500",
|
||||
"--usp",
|
||||
"1",
|
||||
"--ring",
|
||||
"1",
|
||||
"--text-encoder-tp-size",
|
||||
"1",
|
||||
"--vae-patch-parallel-size",
|
||||
"1",
|
||||
"--vae-parallel-mode",
|
||||
"tile",
|
||||
"--vae-use-tiling",
|
||||
"--enable-distributed-layerwise-offload",
|
||||
]
|
||||
|
||||
_FL2VA_IMAGE = base64.b64decode(generate_synthetic_image(WIDTH, HEIGHT, seed=42)["base64"])
|
||||
# Keep the DLO/DP2 Ref2VA wave on the image-only path. The separate video
|
||||
# reference path performs per-request ffmpeg/Qwen video preprocessing and has
|
||||
# repeatedly stalled this merge wave before the first pipeline log; it remains
|
||||
# covered by the Ref2VA contract/accuracy suites.
|
||||
_REF2VA_IMAGE = base64.b64decode(generate_synthetic_image(512, 288, seed=43)["base64"])
|
||||
|
||||
|
||||
def resolve_turbo_lora() -> str | None:
|
||||
"""Resolve the legacy Turbo LoRA artifact without network at collection."""
|
||||
configured = os.environ.get("VLLM_TEST_MINIMAX_H3_TURBO_LORA")
|
||||
if configured and Path(configured).is_file():
|
||||
return configured
|
||||
|
||||
try:
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
return hf_hub_download(
|
||||
repo_id="lightx2v/Minimax-h3-Turbo",
|
||||
filename="minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors",
|
||||
local_files_only=True,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_fasth3_lora() -> str | None:
|
||||
"""Resolve the dense/data-free FastH3 artifact without network at collection."""
|
||||
configured = os.environ.get("VLLM_TEST_MINIMAX_H3_FASTH3_LORA")
|
||||
if configured and Path(configured).is_file():
|
||||
return configured
|
||||
|
||||
try:
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
return hf_hub_download(
|
||||
repo_id="FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA",
|
||||
filename="dense-datafree/adapter_model.safetensors",
|
||||
local_files_only=True,
|
||||
)
|
||||
except Exception:
|
||||
# Buildkite downloads the artifact before pytest. Local collection
|
||||
# must stay offline-friendly when that optional cache is absent.
|
||||
return None
|
||||
|
||||
|
||||
FASTH3_LORA = resolve_fasth3_lora()
|
||||
TURBO_LORA = resolve_turbo_lora()
|
||||
|
||||
|
||||
def _assert_audio_stream_present(video: bytes) -> None:
|
||||
"""Assert that the generated MP4 contains decodable audio samples."""
|
||||
with av.open(io.BytesIO(video)) as container:
|
||||
audio_streams = [stream for stream in container.streams if stream.type == "audio"]
|
||||
assert audio_streams, "MiniMax-H3 MP4 has no audio stream"
|
||||
audio_frame = next(container.decode(audio=0), None)
|
||||
assert audio_frame is not None and audio_frame.samples > 0, "MiniMax-H3 MP4 audio stream is empty"
|
||||
|
||||
|
||||
def assert_h3_video(video: bytes, *, width: int, height: int) -> None:
|
||||
"""Check the common H3 video/audio response contract."""
|
||||
assert_video_valid(video, width=width, height=height, fps=FPS)
|
||||
_assert_audio_stream_present(video)
|
||||
|
||||
|
||||
def post_sync(
|
||||
client: OpenAIClientHandler,
|
||||
form_data: dict[str, str],
|
||||
files: Any = None,
|
||||
) -> bytes:
|
||||
"""Submit one synchronous video request and return its MP4 body."""
|
||||
response = requests.post(
|
||||
f"{client.base_url.rstrip('/')}/v1/videos/sync",
|
||||
data=form_data,
|
||||
files=files,
|
||||
headers={"Accept": "video/mp4"},
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
assert response.headers.get("content-type", "").startswith("video/mp4")
|
||||
assert response.content, "MiniMax-H3 returned an empty video body"
|
||||
return response.content
|
||||
|
||||
|
||||
def _h3_form(task: str, seed: int) -> dict[str, str]:
|
||||
return {
|
||||
"model": MODEL,
|
||||
"prompt": (
|
||||
"A cinematic live-action scene with a clear subject moving naturally; "
|
||||
"the atmosphere includes synchronized environmental sound."
|
||||
),
|
||||
"width": str(WIDTH),
|
||||
"height": str(HEIGHT),
|
||||
"fps": str(FPS),
|
||||
"num_inference_steps": str(NUM_INFERENCE_STEPS),
|
||||
"flow_shift": "12",
|
||||
"seed": str(seed),
|
||||
"extra_params": json.dumps(
|
||||
{
|
||||
"task": task,
|
||||
"duration": 4.0,
|
||||
"aspect_ratio": "16:9",
|
||||
"audio_flow_shift": 3.0,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def run_fl2va(client: OpenAIClientHandler, seed: int) -> bytes:
|
||||
return post_sync(
|
||||
client,
|
||||
_h3_form("fl2va", seed),
|
||||
files=fl2va_files(),
|
||||
)
|
||||
|
||||
|
||||
def run_ref2va(client: OpenAIClientHandler, seed: int) -> bytes:
|
||||
return post_sync(
|
||||
client,
|
||||
_h3_form("ref2va", seed),
|
||||
files=[("input_references", ("reference.jpg", io.BytesIO(_REF2VA_IMAGE), "image/jpeg"))],
|
||||
)
|
||||
|
||||
|
||||
def fl2va_files() -> dict[str, tuple[str, io.BytesIO, str]]:
|
||||
"""Return a fresh multipart first-frame payload for an FL2VA request."""
|
||||
return {"input_reference": ("first_frame.jpg", io.BytesIO(_FL2VA_IMAGE), "image/jpeg")}
|
||||
|
||||
|
||||
def run_dlo_wave(
|
||||
client: OpenAIClientHandler,
|
||||
request_fn: Callable[[OpenAIClientHandler, int], bytes],
|
||||
) -> list[bytes]:
|
||||
"""Run one complete two-request DLO/DP2 wave concurrently."""
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
||||
return list(executor.map(request_fn, (client, client), (2101, 2102)))
|
||||
|
||||
|
||||
def dlo_params(task_type: str) -> OmniServerParams:
|
||||
return OmniServerParams(
|
||||
model=MODEL,
|
||||
server_args=[*DLO_SERVER_ARGS, "--task-type", task_type],
|
||||
stage_init_timeout=1800,
|
||||
init_timeout=1800,
|
||||
)
|
||||
|
||||
|
||||
def turbo_params(lora_path: str | None) -> OmniServerParams:
|
||||
"""Build the two-card TP2 + DLO-no-AllGather Turbo layout."""
|
||||
return OmniServerParams(
|
||||
model=MODEL,
|
||||
server_args=[
|
||||
"--trust-remote-code",
|
||||
"--task-type",
|
||||
"fl2va",
|
||||
"--num-gpus",
|
||||
"2",
|
||||
"--tensor-parallel-size",
|
||||
"2",
|
||||
"--usp",
|
||||
"1",
|
||||
"--ring",
|
||||
"1",
|
||||
"--text-encoder-tp-size",
|
||||
"2",
|
||||
"--vae-patch-parallel-size",
|
||||
"2",
|
||||
"--vae-parallel-mode",
|
||||
"tile",
|
||||
"--vae-use-tiling",
|
||||
"--enable-distributed-layerwise-offload",
|
||||
"--dlo-no-use-allgather",
|
||||
"--lora-backend",
|
||||
"peft",
|
||||
"--lora-path",
|
||||
lora_path or "",
|
||||
],
|
||||
stage_init_timeout=1800,
|
||||
init_timeout=1800,
|
||||
)
|
||||
|
||||
|
||||
def turbo_form(seed: int) -> dict[str, str]:
|
||||
"""Build the official five-sigma-point Turbo FL2VA request."""
|
||||
return {
|
||||
"model": MODEL,
|
||||
"prompt": (
|
||||
"A man stands beside a yellow car at night; the car drives away "
|
||||
"as he follows it with his eyes and begins singing sadly."
|
||||
),
|
||||
"width": str(WIDTH),
|
||||
"height": str(HEIGHT),
|
||||
"fps": str(FPS),
|
||||
# The public Turbo artifact has four denoiser evaluations but its API
|
||||
# contract requests five sigma points.
|
||||
"num_inference_steps": "5",
|
||||
"flow_shift": "6",
|
||||
"seed": str(seed),
|
||||
"extra_params": json.dumps(
|
||||
{"task": "fl2va", "duration": FASTH3_DURATION, "aspect_ratio": "16:9", "audio_flow_shift": 3.0},
|
||||
separators=(",", ":"),
|
||||
),
|
||||
"lora": json.dumps(
|
||||
{"name": "h3-turbo-v1.0", "path": TURBO_LORA, "scale": 1.0},
|
||||
separators=(",", ":"),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def fasth3_params(lora_path: str | None) -> OmniServerParams:
|
||||
"""Build the requested two-card FastH3 parallel layout.
|
||||
|
||||
HSDP owns DiT sharding (the model tensor-parallel degree stays at its
|
||||
default 1), USP2 shards the sequence, text-encoder TP2 shards Qwen3-VL,
|
||||
and VAE patch parallel 2 is the project's VPP2 setting.
|
||||
"""
|
||||
return OmniServerParams(
|
||||
model=MODEL,
|
||||
server_args=[
|
||||
"--trust-remote-code",
|
||||
"--task-type",
|
||||
"fl2va",
|
||||
"--num-gpus",
|
||||
"2",
|
||||
"--usp",
|
||||
"2",
|
||||
"--ring",
|
||||
"1",
|
||||
"--use-hsdp",
|
||||
"--hsdp-shard-size",
|
||||
"2",
|
||||
"--text-encoder-tp-size",
|
||||
"2",
|
||||
"--vae-patch-parallel-size",
|
||||
"2",
|
||||
"--vae-parallel-mode",
|
||||
"tile",
|
||||
"--vae-use-tiling",
|
||||
"--lora-path",
|
||||
lora_path or "",
|
||||
],
|
||||
stage_init_timeout=1800,
|
||||
init_timeout=1800,
|
||||
)
|
||||
|
||||
|
||||
def fasth3_form(seed: int) -> dict[str, str]:
|
||||
"""Build the four-step T2VA request accepted by the FastH3 preview."""
|
||||
return {
|
||||
"model": MODEL,
|
||||
"prompt": (
|
||||
"A cinematic live-action scene with a clear subject moving naturally; "
|
||||
"the atmosphere includes synchronized environmental sound."
|
||||
),
|
||||
"width": str(FASTH3_WIDTH),
|
||||
"height": str(FASTH3_HEIGHT),
|
||||
"fps": str(FPS),
|
||||
"num_inference_steps": "4",
|
||||
"seed": str(seed),
|
||||
# FastH3 is fused at startup, so a request-level `lora` field is both
|
||||
# unnecessary and rejected by the load-time adapter contract.
|
||||
"extra_params": json.dumps(
|
||||
{
|
||||
"task": "t2va",
|
||||
"duration": FASTH3_DURATION,
|
||||
"aspect_ratio": "16:9",
|
||||
},
|
||||
separators=(",", ":"),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FASTH3_LORA",
|
||||
"FASTH3_SUPPORTED",
|
||||
"FASTH3_DURATION",
|
||||
"FASTH3_HEIGHT",
|
||||
"FASTH3_WIDTH",
|
||||
"FPS",
|
||||
"MODEL",
|
||||
"assert_h3_video",
|
||||
"dlo_params",
|
||||
"fasth3_form",
|
||||
"fasth3_params",
|
||||
"fl2va_files",
|
||||
"post_sync",
|
||||
"run_dlo_wave",
|
||||
"run_fl2va",
|
||||
"run_ref2va",
|
||||
"TURBO_LORA",
|
||||
"TURBO_SUPPORTED",
|
||||
"turbo_form",
|
||||
"turbo_params",
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""MiniMax-H3 FL2VA DLO + DP2 L3 online-serving case."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers.mark import hardware_marks
|
||||
from tests.helpers.runtime import OmniServer, OpenAIClientHandler
|
||||
|
||||
from ._common import assert_h3_video, dlo_params, run_dlo_wave, run_fl2va
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.advanced_model, pytest.mark.diffusion, pytest.mark.slow]
|
||||
H100_TWO_CARD_MARKS = hardware_marks(res={"cuda": "H100"}, num_cards=2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"omni_server",
|
||||
[pytest.param(dlo_params("fl2va"), id="minimax_h3_dlo_dp2_fl2va", marks=H100_TWO_CARD_MARKS)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_minimax_h3_dlo_dp2_fl2va(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
|
||||
"""Validate a complete concurrent DLO/DP2 wave with two FL2VA jobs."""
|
||||
for video in run_dlo_wave(openai_client, run_fl2va):
|
||||
assert_h3_video(video, width=1344, height=768)
|
||||
@@ -0,0 +1,27 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""MiniMax-H3 Ref2VA DLO + DP2 L3 online-serving case."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers.mark import hardware_marks
|
||||
from tests.helpers.runtime import OmniServer, OpenAIClientHandler
|
||||
|
||||
from ._common import assert_h3_video, dlo_params, run_dlo_wave, run_ref2va
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.advanced_model, pytest.mark.diffusion, pytest.mark.slow]
|
||||
H100_TWO_CARD_MARKS = hardware_marks(res={"cuda": "H100"}, num_cards=2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"omni_server",
|
||||
[pytest.param(dlo_params("ref2va"), id="minimax_h3_dlo_dp2_ref2va", marks=H100_TWO_CARD_MARKS)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_minimax_h3_dlo_dp2_ref2va(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
|
||||
"""Validate a complete concurrent DLO/DP2 wave with two image-only Ref2VA jobs."""
|
||||
for video in run_dlo_wave(openai_client, run_ref2va):
|
||||
assert_h3_video(video, width=1344, height=768)
|
||||
@@ -0,0 +1,56 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""MiniMax-H3 FastH3 four-step LoRA L3 online-serving case."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers.mark import hardware_marks
|
||||
from tests.helpers.runtime import OmniServer, OpenAIClientHandler
|
||||
|
||||
from ._common import (
|
||||
FASTH3_HEIGHT,
|
||||
FASTH3_LORA,
|
||||
FASTH3_SUPPORTED,
|
||||
FASTH3_WIDTH,
|
||||
assert_h3_video,
|
||||
fasth3_form,
|
||||
fasth3_params,
|
||||
post_sync,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.advanced_model, pytest.mark.diffusion, pytest.mark.slow]
|
||||
H100_TWO_CARD_MARKS = hardware_marks(res={"cuda": "H100"}, num_cards=2)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not FASTH3_SUPPORTED, reason="requires FastH3 support from MiniMax-H3 PR #6714")
|
||||
@pytest.mark.skipif(
|
||||
FASTH3_LORA is None,
|
||||
reason="set VLLM_TEST_MINIMAX_H3_FASTH3_LORA or populate the local HF cache",
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"omni_server",
|
||||
[
|
||||
pytest.param(
|
||||
fasth3_params(FASTH3_LORA),
|
||||
id="minimax_h3_fasth3_lora_hsdp2_usp2_vpp2",
|
||||
marks=H100_TWO_CARD_MARKS,
|
||||
)
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
def test_minimax_h3_fasth3_lora_hsdp2_usp2_vpp2(
|
||||
omni_server: OmniServer,
|
||||
openai_client: OpenAIClientHandler,
|
||||
) -> None:
|
||||
"""Run the dense/data-free FastH3 adapter on two H100-class cards.
|
||||
|
||||
The preview adapter is T2VA-only and must be fused through ``--lora-path``;
|
||||
it is not a request-switchable PEFT adapter. 1024x576 is intentionally
|
||||
below the 1344x768 H3 reference shape to stay inside an 80 GiB H100 after
|
||||
regional compile cache growth.
|
||||
"""
|
||||
video = post_sync(openai_client, fasth3_form(seed=3101))
|
||||
assert_h3_video(video, width=FASTH3_WIDTH, height=FASTH3_HEIGHT)
|
||||
@@ -0,0 +1,56 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""MiniMax-H3 Turbo LoRA + DLO L3 online-serving case."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers.mark import hardware_marks
|
||||
from tests.helpers.runtime import OmniServer, OpenAIClientHandler
|
||||
|
||||
from ._common import (
|
||||
TURBO_LORA,
|
||||
TURBO_SUPPORTED,
|
||||
assert_h3_video,
|
||||
fl2va_files,
|
||||
post_sync,
|
||||
turbo_form,
|
||||
turbo_params,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.advanced_model, pytest.mark.diffusion, pytest.mark.slow]
|
||||
H100_TWO_CARD_MARKS = hardware_marks(res={"cuda": "H100"}, num_cards=2)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not TURBO_SUPPORTED,
|
||||
reason="requires the MiniMax-H3 Turbo loader from PR #6550",
|
||||
)
|
||||
@pytest.mark.skipif(
|
||||
TURBO_LORA is None,
|
||||
reason="set VLLM_TEST_MINIMAX_H3_TURBO_LORA or populate the local HF cache",
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"omni_server",
|
||||
[
|
||||
pytest.param(
|
||||
turbo_params(TURBO_LORA),
|
||||
id="minimax_h3_dlo_turbo_lora_tp2",
|
||||
marks=H100_TWO_CARD_MARKS,
|
||||
)
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
def test_minimax_h3_dlo_turbo_lora_fl2va(
|
||||
omni_server: OmniServer,
|
||||
openai_client: OpenAIClientHandler,
|
||||
) -> None:
|
||||
"""Validate the official four-evaluation Turbo LoRA + DLO FL2VA request."""
|
||||
video = post_sync(
|
||||
openai_client,
|
||||
turbo_form(seed=2201),
|
||||
files=fl2va_files(),
|
||||
)
|
||||
assert_h3_video(video, width=1344, height=768)
|
||||
@@ -1,15 +1,15 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""
|
||||
Online serving tests for Boogu-Image-0.1-Edit (image-to-image via chat completions).
|
||||
Online serving tests for Boogu-Image-0.1-Edit and Edit-Turbo.
|
||||
|
||||
The Edit checkpoint shares the ``BooguImagePipeline`` class with the Base
|
||||
Both Edit checkpoints share the ``BooguImagePipeline`` class with the Base
|
||||
text-to-image checkpoint; the editing (TI2I) path activates when the request
|
||||
carries a reference image. Text guidance rides on ``guidance_scale`` (upstream
|
||||
default 4.0), and image guidance rides on ``guidance_scale_2`` (upstream default
|
||||
1.0 = off; a value > 1 enables the double-guidance path). Only a single
|
||||
reference image is supported.
|
||||
carries a reference image. The regular Edit checkpoint uses text guidance via
|
||||
``guidance_scale`` and optional image guidance via ``guidance_scale_2``.
|
||||
Edit-Turbo is guidance-distilled and uses four steps with guidance scale 1.0.
|
||||
Only a single reference image is supported.
|
||||
|
||||
- ``test_single_image_to_image_001``: one reference image, text guidance only.
|
||||
- ``test_double_guidance_001``: one reference image, text + image guidance.
|
||||
@@ -40,6 +40,10 @@ pytestmark = [pytest.mark.diffusion, pytest.mark.slow]
|
||||
EDIT_PROMPT = "Change the style to a colored pencil drawing."
|
||||
SINGLE_CARD_FEATURE_MARKS = hardware_marks(res={"cuda": "H100"})
|
||||
|
||||
EDIT_MODEL = "Boogu/Boogu-Image-0.1-Edit"
|
||||
EDIT_TURBO_MODEL = "Boogu/Boogu-Image-0.1-Edit-Turbo"
|
||||
EDIT_TURBO_REVISION = "hotfix-1k-20260708"
|
||||
|
||||
|
||||
def _get_diffusion_feature_cases(model: str):
|
||||
"""Return one ``default`` ``OmniServerParams`` row for ``model`` (no extra ``server_args``)."""
|
||||
@@ -54,13 +58,44 @@ def _get_diffusion_feature_cases(model: str):
|
||||
]
|
||||
|
||||
|
||||
def _get_single_edit_cases():
|
||||
"""Return the regular and distilled Edit server/request configurations."""
|
||||
return [
|
||||
pytest.param(
|
||||
OmniServerParams(model=EDIT_MODEL),
|
||||
{
|
||||
"num_inference_steps": 2,
|
||||
"guidance_scale": 5.0,
|
||||
},
|
||||
id="edit",
|
||||
marks=SINGLE_CARD_FEATURE_MARKS,
|
||||
),
|
||||
pytest.param(
|
||||
OmniServerParams(
|
||||
model=EDIT_TURBO_MODEL,
|
||||
server_args=["--revision", EDIT_TURBO_REVISION],
|
||||
),
|
||||
{
|
||||
"num_inference_steps": 4,
|
||||
"guidance_scale": 1.0,
|
||||
},
|
||||
id="edit-turbo-hotfix-1k",
|
||||
marks=SINGLE_CARD_FEATURE_MARKS,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"omni_server",
|
||||
_get_diffusion_feature_cases("Boogu/Boogu-Image-0.1-Edit"),
|
||||
indirect=True,
|
||||
("omni_server", "sampling_params"),
|
||||
_get_single_edit_cases(),
|
||||
indirect=["omni_server"],
|
||||
)
|
||||
def test_single_image_to_image_001(omni_server: OmniServer, openai_client: OpenAIClientHandler):
|
||||
"""Single-reference text-guided edit smoke for ``Boogu/Boogu-Image-0.1-Edit``."""
|
||||
def test_single_image_to_image_001(
|
||||
omni_server: OmniServer,
|
||||
sampling_params: dict,
|
||||
openai_client: OpenAIClientHandler,
|
||||
):
|
||||
"""Single-reference text-guided edit smoke for regular and Turbo Edit."""
|
||||
image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(512, 512)['base64']}"
|
||||
|
||||
messages = dummy_messages_from_mix_data(image_data_url=image_data_url, content_text=EDIT_PROMPT)
|
||||
@@ -69,9 +104,7 @@ def test_single_image_to_image_001(omni_server: OmniServer, openai_client: OpenA
|
||||
"model": omni_server.model,
|
||||
"messages": messages,
|
||||
"extra_body": {
|
||||
"num_inference_steps": 2,
|
||||
# Boogu text guidance (upstream default 4.0); > 1 enables CFG.
|
||||
"guidance_scale": 5.0,
|
||||
**sampling_params,
|
||||
"seed": 42,
|
||||
},
|
||||
}
|
||||
@@ -81,7 +114,7 @@ def test_single_image_to_image_001(omni_server: OmniServer, openai_client: OpenA
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"omni_server",
|
||||
_get_diffusion_feature_cases("Boogu/Boogu-Image-0.1-Edit"),
|
||||
_get_diffusion_feature_cases(EDIT_MODEL),
|
||||
indirect=True,
|
||||
)
|
||||
def test_double_guidance_001(omni_server: OmniServer, openai_client: OpenAIClientHandler):
|
||||
@@ -105,15 +138,20 @@ def test_double_guidance_001(omni_server: OmniServer, openai_client: OpenAIClien
|
||||
openai_client.send_diffusion_request(request_config)
|
||||
|
||||
|
||||
def _edits_request_config(model: str, *, guidance_scale_2: float | None = None) -> dict:
|
||||
def _edits_request_config(
|
||||
model: str,
|
||||
*,
|
||||
num_inference_steps: int = 2,
|
||||
guidance_scale: float = 5.0,
|
||||
guidance_scale_2: float | None = None,
|
||||
) -> dict:
|
||||
"""Multipart ``/v1/images/edits`` config: one synthetic reference + smoke-depth params."""
|
||||
image_bytes = base64.b64decode(generate_synthetic_image(512, 512)["base64"])
|
||||
data = {
|
||||
"model": model,
|
||||
"prompt": EDIT_PROMPT,
|
||||
"num_inference_steps": 2,
|
||||
# Boogu text guidance (upstream default 4.0); > 1 enables CFG.
|
||||
"guidance_scale": 5.0,
|
||||
"num_inference_steps": num_inference_steps,
|
||||
"guidance_scale": guidance_scale,
|
||||
"seed": 42,
|
||||
}
|
||||
if guidance_scale_2 is not None:
|
||||
@@ -133,19 +171,25 @@ def _assert_edits_response_has_image(responses) -> None:
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"omni_server",
|
||||
_get_diffusion_feature_cases("Boogu/Boogu-Image-0.1-Edit"),
|
||||
indirect=True,
|
||||
("omni_server", "sampling_params"),
|
||||
_get_single_edit_cases(),
|
||||
indirect=["omni_server"],
|
||||
)
|
||||
def test_images_edits_endpoint_001(omni_server: OmniServer, openai_client: OpenAIClientHandler):
|
||||
"""Single-reference text-guided edit via the OpenAI-compatible ``/v1/images/edits`` endpoint."""
|
||||
responses = openai_client.send_images_edits_http_request(_edits_request_config(omni_server.model))
|
||||
def test_images_edits_endpoint_001(
|
||||
omni_server: OmniServer,
|
||||
sampling_params: dict,
|
||||
openai_client: OpenAIClientHandler,
|
||||
):
|
||||
"""Single-reference regular and Turbo Edit via ``/v1/images/edits``."""
|
||||
responses = openai_client.send_images_edits_http_request(
|
||||
_edits_request_config(omni_server.model, **sampling_params)
|
||||
)
|
||||
_assert_edits_response_has_image(responses)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"omni_server",
|
||||
_get_diffusion_feature_cases("Boogu/Boogu-Image-0.1-Edit"),
|
||||
_get_diffusion_feature_cases(EDIT_MODEL),
|
||||
indirect=True,
|
||||
)
|
||||
def test_images_edits_endpoint_double_guidance_001(omni_server: OmniServer, openai_client: OpenAIClientHandler):
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""
|
||||
L4 expansion coverage for ``Boogu/Boogu-Image-0.1-Edit`` (image editing / TI2I).
|
||||
L4 expansion coverage for Boogu-Image Edit and Edit-Turbo (image editing / TI2I).
|
||||
|
||||
The nightly smoke module (``test_boogu_image_edit.py``) already covers single-image
|
||||
text-guided editing and the text+image double-guidance path. This expansion
|
||||
@@ -16,11 +16,12 @@ module adds the two edit paths smoke does not exercise:
|
||||
``height`` / ``width``: asserts the output resolution follows the reference
|
||||
(upstream ``align_res``, on by default for a single reference).
|
||||
|
||||
The Edit checkpoint shares ``BooguImagePipeline`` with the Base checkpoint; the
|
||||
TI2I path activates when the request carries a reference image. Only a single
|
||||
reference image is supported. Cases stay smoke-depth (``num_inference_steps=2``).
|
||||
CPU offload, Cache-DiT, and multi-GPU parallelism are not yet supported for this
|
||||
model and are intentionally omitted.
|
||||
Both Edit checkpoints share ``BooguImagePipeline`` with the Base checkpoint;
|
||||
the TI2I path activates when the request carries a reference image. Only a
|
||||
single reference image is supported. The regular Edit cases stay smoke-depth
|
||||
(``num_inference_steps=2``), while Edit-Turbo uses its tuned four-step
|
||||
configuration. CPU offload, Cache-DiT, and multi-GPU parallelism are not yet
|
||||
supported for these models and are intentionally omitted.
|
||||
|
||||
From ``tests/``::
|
||||
|
||||
@@ -41,6 +42,8 @@ os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
pytestmark = [pytest.mark.diffusion, pytest.mark.slow]
|
||||
|
||||
MODEL = "Boogu/Boogu-Image-0.1-Edit"
|
||||
EDIT_TURBO_MODEL = "Boogu/Boogu-Image-0.1-Edit-Turbo"
|
||||
EDIT_TURBO_REVISION = "hotfix-1k-20260708"
|
||||
EDIT_PROMPT = "Change the style to a colored pencil drawing."
|
||||
|
||||
SINGLE_CARD_FEATURE_MARKS = hardware_marks(res={"cuda": "H100"})
|
||||
@@ -57,6 +60,33 @@ def _get_diffusion_feature_cases(model: str):
|
||||
]
|
||||
|
||||
|
||||
def _get_align_res_cases():
|
||||
"""Return regular and Turbo Edit configurations for align-res coverage."""
|
||||
return [
|
||||
pytest.param(
|
||||
OmniServerParams(model=MODEL),
|
||||
{
|
||||
"num_inference_steps": 2,
|
||||
"guidance_scale": 5.0,
|
||||
},
|
||||
id="edit",
|
||||
marks=SINGLE_CARD_FEATURE_MARKS,
|
||||
),
|
||||
pytest.param(
|
||||
OmniServerParams(
|
||||
model=EDIT_TURBO_MODEL,
|
||||
server_args=["--revision", EDIT_TURBO_REVISION],
|
||||
),
|
||||
{
|
||||
"num_inference_steps": 4,
|
||||
"guidance_scale": 1.0,
|
||||
},
|
||||
id="edit-turbo-hotfix-1k",
|
||||
marks=SINGLE_CARD_FEATURE_MARKS,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True)
|
||||
def test_image_only_guidance(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
|
||||
"""Image-only guidance: text CFG off (1.0), image CFG on (2.0) -> image-only CFG branch."""
|
||||
@@ -76,8 +106,16 @@ def test_image_only_guidance(omni_server: OmniServer, openai_client: OpenAIClien
|
||||
openai_client.send_diffusion_request(request_config)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True)
|
||||
def test_align_res_output_size(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
|
||||
@pytest.mark.parametrize(
|
||||
("omni_server", "sampling_params"),
|
||||
_get_align_res_cases(),
|
||||
indirect=["omni_server"],
|
||||
)
|
||||
def test_align_res_output_size(
|
||||
omni_server: OmniServer,
|
||||
sampling_params: dict,
|
||||
openai_client: OpenAIClientHandler,
|
||||
) -> None:
|
||||
"""Output resolution follows the reference image (upstream ``align_res``).
|
||||
|
||||
No ``height`` / ``width`` is requested. The reference is 512x384 — both
|
||||
@@ -92,8 +130,7 @@ def test_align_res_output_size(omni_server: OmniServer, openai_client: OpenAICli
|
||||
"model": omni_server.model,
|
||||
"messages": messages,
|
||||
"extra_body": {
|
||||
"num_inference_steps": 2,
|
||||
"guidance_scale": 5.0,
|
||||
**sampling_params,
|
||||
"seed": 42,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""E2E online tests for MiniCPM-o 4.5 multimodal input and audio/text output.
|
||||
|
||||
Exercises async chunk streaming (``--async-chunk``) across separate Thinker,
|
||||
Talker, and Code2Wav stages.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from vllm.logger import DEFAULT_LOGGING_CONFIG
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.media import generate_synthetic_audio, generate_synthetic_image, generate_synthetic_video
|
||||
@@ -19,10 +27,36 @@ _MODEL = "openbmb/MiniCPM-o-4_5"
|
||||
_CI_DEPLOY = modify_stage_config(
|
||||
get_deploy_config_path("minicpmo_4_5.yaml"),
|
||||
updates={
|
||||
"stages": {0: {"default_sampling_params.max_tokens": 64}, 1: {"default_sampling_params.max_tokens": 1024}}
|
||||
"stages": {
|
||||
0: {"default_sampling_params.max_tokens": 64},
|
||||
1: {
|
||||
"default_sampling_params.max_tokens": 1024,
|
||||
# Content-consistency assertions must not depend on a random
|
||||
# codec trajectory: different valid TTS samples can append an
|
||||
# audible tail even when Thinker text is identical.
|
||||
"default_sampling_params.temperature": 0.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
_PROMPT_LOG_TEMP_DIR = tempfile.TemporaryDirectory(prefix="minicpmo45-prompt-selection-")
|
||||
_PROMPT_SELECTION_LOG = Path(_PROMPT_LOG_TEMP_DIR.name) / "prompt-selection.log"
|
||||
_PROMPT_LOGGING_CONFIG_PATH = Path(_PROMPT_LOG_TEMP_DIR.name) / "logging.json"
|
||||
_PROMPT_LOGGING_CONFIG = deepcopy(DEFAULT_LOGGING_CONFIG)
|
||||
_PROMPT_LOGGING_CONFIG["handlers"]["minicpmo_prompt_selection"] = {
|
||||
"class": "logging.FileHandler",
|
||||
"filename": str(_PROMPT_SELECTION_LOG),
|
||||
"formatter": "vllm",
|
||||
"level": "DEBUG",
|
||||
}
|
||||
_PROMPT_LOGGING_CONFIG["loggers"]["vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_code2wav"] = {
|
||||
"handlers": ["minicpmo_prompt_selection"],
|
||||
"level": "DEBUG",
|
||||
"propagate": True,
|
||||
}
|
||||
_PROMPT_LOGGING_CONFIG_PATH.write_text(json.dumps(_PROMPT_LOGGING_CONFIG), encoding="utf-8")
|
||||
|
||||
test_params = [
|
||||
pytest.param(
|
||||
OmniServerParams(
|
||||
@@ -30,6 +64,10 @@ test_params = [
|
||||
stage_config_path=_CI_DEPLOY,
|
||||
use_stage_cli=False,
|
||||
server_args=["--trust-remote-code", "--async-chunk"],
|
||||
env_dict={
|
||||
"VLLM_CONFIGURE_LOGGING": "1",
|
||||
"VLLM_LOGGING_CONFIG_PATH": str(_PROMPT_LOGGING_CONFIG_PATH),
|
||||
},
|
||||
),
|
||||
id="async_chunk",
|
||||
),
|
||||
@@ -74,6 +112,18 @@ _TTS_EXTRA_BODY = {
|
||||
}
|
||||
|
||||
|
||||
def _prompt_selection_log_offset() -> int:
|
||||
return _PROMPT_SELECTION_LOG.stat().st_size if _PROMPT_SELECTION_LOG.exists() else 0
|
||||
|
||||
|
||||
def _read_prompt_selection_log(offset: int) -> str:
|
||||
if not _PROMPT_SELECTION_LOG.exists():
|
||||
return ""
|
||||
with open(_PROMPT_SELECTION_LOG, "rb") as log_file:
|
||||
log_file.seek(offset)
|
||||
return log_file.read().decode("utf-8")
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.omni
|
||||
@@ -126,6 +176,51 @@ def test_text_to_audio_001(omni_server, openai_client) -> None:
|
||||
openai_client.send_omni_request(request_config)
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_text_to_audio_with_reference_audio(omni_server, openai_client) -> None:
|
||||
request_config = {
|
||||
"model": omni_server.model,
|
||||
"messages": dummy_messages_from_mix_data(system_prompt=get_system_prompt(), content_text=get_prompt()),
|
||||
"stream": True,
|
||||
"modalities": ["text", "audio"],
|
||||
"extra_body": {
|
||||
**_TTS_EXTRA_BODY,
|
||||
"ref_audio": f"data:audio/wav;base64,{generate_synthetic_audio(5, 1)['base64']}",
|
||||
},
|
||||
}
|
||||
|
||||
log_offset = _prompt_selection_log_offset()
|
||||
responses = openai_client.send_omni_request(request_config)
|
||||
prompt_log = _read_prompt_selection_log(log_offset)
|
||||
|
||||
assert responses[0].success
|
||||
assert responses[0].audio_bytes
|
||||
assert "prompt_cache_id=runtime-ref-" in prompt_log
|
||||
assert "minicpmo45_ref_" in prompt_log
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_text_to_audio_with_default_reference(omni_server, openai_client) -> None:
|
||||
request_config = {
|
||||
"model": omni_server.model,
|
||||
"messages": dummy_messages_from_mix_data(system_prompt=get_system_prompt(), content_text=get_prompt()),
|
||||
"stream": True,
|
||||
"modalities": ["text", "audio"],
|
||||
"extra_body": _TTS_EXTRA_BODY,
|
||||
}
|
||||
|
||||
responses = openai_client.send_omni_request(request_config)
|
||||
|
||||
assert responses[0].success
|
||||
assert responses[0].audio_bytes
|
||||
|
||||
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
|
||||
@@ -18,6 +18,17 @@ from tests.helpers.stage_config import get_deploy_config_path
|
||||
MODEL = os.environ.get("VLLM_TEST_NEMOTRON_VOICECHAT_MODEL", "nvidia/NVIDIA-NemotronLabs-VoiceChat-11B")
|
||||
DEPLOY_CONFIG = get_deploy_config_path("nemotron_labs_voicechat_duplex.yaml")
|
||||
TOKENIZER = os.environ.get("VLLM_TEST_NEMOTRON_VOICECHAT_LLM_PATH")
|
||||
|
||||
# The client gives up after CLIENT_TIMEOUT_S. The server-side stage-input
|
||||
# deadline must fire first, otherwise a stalled stream surfaces as a bare
|
||||
# client TimeoutError with no server-side error at all. See #6816.
|
||||
CLIENT_TIMEOUT_S = 300
|
||||
SERVER_INPUT_WAIT_TIMEOUT_S = 240
|
||||
|
||||
_SERVER_ENV: dict[str, str] = {"VLLM_OMNI_INPUT_WAIT_TIMEOUT_S": str(SERVER_INPUT_WAIT_TIMEOUT_S)}
|
||||
if TOKENIZER:
|
||||
_SERVER_ENV["NEMOTRON_VOICECHAT_LLM_PATH"] = TOKENIZER
|
||||
|
||||
pytestmark = [pytest.mark.full_model, pytest.mark.omni]
|
||||
|
||||
|
||||
@@ -29,7 +40,7 @@ pytestmark = [pytest.mark.full_model, pytest.mark.omni]
|
||||
OmniServerParams(
|
||||
model=MODEL,
|
||||
stage_config_path=DEPLOY_CONFIG,
|
||||
env_dict={"NEMOTRON_VOICECHAT_LLM_PATH": TOKENIZER} if TOKENIZER else None,
|
||||
env_dict=_SERVER_ENV,
|
||||
),
|
||||
id="native-duplex",
|
||||
)
|
||||
@@ -45,7 +56,7 @@ def test_native_duplex_turn_taking_streams_model_audio(omni_server, tmp_path: Pa
|
||||
(
|
||||
f"--url ws://{omni_server.host}:{omni_server.port}/v1/realtime --model {omni_server.model} "
|
||||
f"--input-wav {root / 'turn_taking.wav'} --input-channel 0 --max-frames 190 "
|
||||
f"--minimum-audio-chunks 48 --minimum-audio-rms 0.001 --no-realtime --timeout-s 300 "
|
||||
f"--minimum-audio-chunks 48 --minimum-audio-rms 0.001 --no-realtime --timeout-s {CLIENT_TIMEOUT_S} "
|
||||
f"--output-dir {tmp_path / 'native_duplex'}"
|
||||
).split()
|
||||
)
|
||||
|
||||
@@ -55,7 +55,6 @@ tts_server_params = [
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
|
||||
@@ -138,6 +138,23 @@ def test_voice_003(omni_server, online_client) -> None:
|
||||
online_client.send_audio_speech_request(request_config)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
def test_sample_rate_001(omni_server, online_client) -> None:
|
||||
"""The speech API resamples Qwen3-TTS output from native 24 kHz to 8 kHz."""
|
||||
request_config = {
|
||||
"model": omni_server.model,
|
||||
"input": "This response should be encoded as eight kilohertz audio.",
|
||||
"stream": False,
|
||||
"response_format": "wav",
|
||||
"sample_rate": 8000,
|
||||
"task_type": "CustomVoice",
|
||||
"voice": "vivian",
|
||||
}
|
||||
|
||||
online_client.send_audio_speech_request(request_config)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
def test_language_001(omni_server, online_client) -> None:
|
||||
|
||||
@@ -286,6 +286,28 @@ def test_qwen3_tts_code2wav_injects_max_position_embeddings(monkeypatch):
|
||||
}
|
||||
|
||||
|
||||
def test_remote_tokenizer_subfolder_download_does_not_report_failure(tmp_path, monkeypatch, mocker):
|
||||
"""A successful remote tokenizer download must not enter the error path."""
|
||||
tokenizer_dir = tmp_path / "CosyVoice-BlankEN"
|
||||
tokenizer_dir.mkdir()
|
||||
baseline_config = Mock()
|
||||
warning = mocker.patch("vllm_omni.engine.arg_utils.logger.warning")
|
||||
|
||||
monkeypatch.setattr("huggingface_hub.snapshot_download", lambda *args, **kwargs: str(tmp_path))
|
||||
monkeypatch.setattr(OmniEngineArgs, "_patch_empty_hf_config", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(EngineArgs, "create_model_config", lambda _self: baseline_config)
|
||||
monkeypatch.setattr(
|
||||
OmniModelConfig,
|
||||
"from_vllm_model_config",
|
||||
classmethod(lambda cls, model_config, **omni_kwargs: model_config),
|
||||
)
|
||||
|
||||
args = OmniEngineArgs(model="remote/cosyvoice3", model_arch="CosyVoice3Model")
|
||||
assert args.create_model_config() is baseline_config
|
||||
assert args.tokenizer == str(tokenizer_dir)
|
||||
warning.assert_not_called()
|
||||
|
||||
|
||||
def test_patch_missing_local_hf_config(tmp_path):
|
||||
"""Native model bundles may only contain a checkpoints/ directory."""
|
||||
(tmp_path / "checkpoints").mkdir()
|
||||
|
||||
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user