Compare commits

...
Author SHA1 Message Date
Hongsheng Liu bed9ad54cb Revert "[BugFix] ignore mm data from stages to async omni (#954)"
This reverts commit 6fb4058566.
2026-01-28 17:40:42 +08:00
dengyunyang 6fb4058566 [BugFix] ignore mm data from stages to async omni (#954)
Signed-off-by: dengyunyang <584797741@qq.com>
2026-01-28 17:10:05 +08:00
JohnJan ae96aa8a8b [Model]: add FLUX.1-dev model (#853) 2026-01-28 16:28:57 +08:00
Zeyu Huang | 黃澤宇 d64bbde561 [Frontend][Model] Support batch request with refined OmniDiffusionReq… (#797)
Signed-off-by: Huang, Zeyu <11222265+fhfuih@users.noreply.github.com>
2026-01-28 16:07:42 +08:00
zhou zhuoxin 9ece725fc5 [Bugfix] Add missing dependencies (onnxruntime, sox) for Qwen3-TTS support (#981)
Signed-off-by: zhou zhuoxin <zhouzhuoxin1508@outlook.com>
2026-01-28 15:20:07 +08:00
lif c4220f0979 [BugFix] Add /health and /v1/models endpoints for diffusion mode, fix streaming compatibility (#454)
Signed-off-by: majiayu000 <1835304752@qq.com>
2026-01-28 13:11:15 +08:00
Jiangyun ZhuandHongsheng Liu 1c8fcf436f [Perf] replace torch's conv with vLLM's conv to fix torch 2.9 performance regression (#982)
Signed-off-by: zjy0516 <riverclouds.zhu@qq.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-01-28 10:41:33 +08:00
dongbo910220 e8aa32b82b [Docs] Fix GLM-Image docstring indentation to resolve CI failure (#992)
Signed-off-by: dongbo910220 <1275604947@qq.com>
2026-01-28 12:55:17 +13:00
dongbo910220andHongsheng Liu 40cbd8ef7f [Fix] make images LoRA e2e less flaky (#978)
Signed-off-by: dongbo910220 <1275604947@qq.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-01-27 23:55:05 +08:00
Yueqian Lin 9bca8880fe Fix TTS speaker typo and add supported languages (#990)
Signed-off-by: linyueqian <linyueqian@outlook.com>
2026-01-27 23:19:51 +08:00
amy-why-3459 d2b3a24af3 [BugFix] Modify the method of obtaining external_request_id (#961)
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-01-27 20:56:02 +08:00
Yuanheng Zhao 2f5e32477c [NFC] Remove redundant torch.no_grad in models and pipelines (#854)
Signed-off-by: yuanheng <jonathan.zhaoyh@gmail.com>
2026-01-27 12:36:54 +00:00
yangsonglin13 16be03b909 [Feature] Implement YuanrongConnector based on OmniConnectorBase (#716)
Signed-off-by: yangsonglin13 <yangsonglin566@gmail.com>
2026-01-27 20:17:02 +08:00
wangyuandwangyu31577 2cc4375df8 [Test] Add chunk e2e test case for CI (#956)
Signed-off-by: wangyu31577 <wangyu31577@hundsun.com>
Co-authored-by: wangyu31577 <wangyu31577@hundsun.com>
2026-01-27 20:15:56 +08:00
TJian 46e96e18e3 [ROCm] [Perf] Add AITER Flash Attention (#941)
Signed-off-by: tjtanaa <tunjian.tan@embeddedllm.com>
2026-01-27 20:14:49 +08:00
Canlin Guo cadd77262d [NPU] Upgrade to v0.14.0 (#820)
Signed-off-by: gcanlin <canlinguosdu@gmail.com>
2026-01-27 16:45:32 +08:00
Andy Zhou 8efd912d32 [test] revert test flash attn file (#972)
Signed-off-by: AndyZhou952 <jzhoubc@connect.ust.hk>
2026-01-27 08:39:07 +00:00
Jiangyun Zhu 50b44303f1 [diffusion] add tp for FLUX.2-klein (#973)
Signed-off-by: zjy0516 <riverclouds.zhu@qq.com>
2026-01-27 08:37:25 +00:00
Andy Zhou d12d3d711b [test] fix test_image_generation_lora CI timeout (#975)
Signed-off-by: AndyZhou952 <jzhoubc@connect.ust.hk>
2026-01-27 15:51:51 +08:00
Yueqian Lin 77ff875170 Support Qwen3 tts online serving (#968)
Signed-off-by: linyueqian <linyueqian@outlook.com>
2026-01-27 14:37:21 +08:00
Jiangyun Zhu 4a3a1b6de8 [Perf] avoid cpu op in QwenImageCrossAttention (#942)
Signed-off-by: zjy0516 <riverclouds.zhu@qq.com>
2026-01-27 12:15:35 +08:00
Jiangyun ZhuandHongsheng Liu 17e79336aa [doc] add some additional information for the diffusion model support (#952)
Signed-off-by: zjy0516 <riverclouds.zhu@qq.com>
Signed-off-by: Jiangyun Zhu <riverclouds.zhu@qq.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-01-27 12:14:19 +08:00
XU MingshiandHongsheng Liu 1fe8e71433 [Diffusion][Feature] Non-Intrusive Sequence Parallelism (SP) Support for Wan2.2 (#966)
Signed-off-by: mxuax <mxuax@connect.ust.hk>
Signed-off-by: XU Mingshi <91017482+mxuax@users.noreply.github.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-01-27 11:33:38 +08:00
5037af1033 [Feature] Diffusion LoRA Adapter Support (PEFT compatible) for vLLM alignment (#758)
Signed-off-by: AndyZhou952 <jzhoubc@connect.ust.hk>
Signed-off-by: Andy Zhou <jzhoubc@connect.ust.hk>
Signed-off-by: dongbo910220 <1275604947@qq.com>
Signed-off-by: Andy Zhou <46011930+AndyZhou952@users.noreply.github.com>
Signed-off-by: Samit <285365963@qq.com>
Co-authored-by: dongbo910220 <1275604947@qq.com>
Co-authored-by: Samit <285365963@qq.com>
2026-01-27 10:22:40 +08:00
zhou zhuoxin 4f3fd4de37 [doc]Add Text-To-Audio Readme documentation (#958)
Signed-off-by: zhou zhuoxin <zhouzhuoxin1508@outlook.com>
2026-01-26 09:23:48 +00:00
SamitandHongsheng Liu 978a290d6e [Bugfix] Fix diffusion pipeline CFG (guidance_scale parsing failure bug) (#922)
Signed-off-by: samithuang <285365963@qq.com>
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-01-26 05:09:03 +00:00
52bf1e2f40 [Feature]Support async computation and communication across stages by chunks (#727)
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
Signed-off-by: Rein Yang <ruiruyang2@gmail.com>
Co-authored-by: Rein Yang <ruiruyang2@gmail.com>
Co-authored-by: CHEN <116010019@link.cuhk.edu.cn>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-01-26 11:21:24 +08:00
Jiangyun ZhuandHongsheng Liu 578ed1967e [Bugfix] raise error in diffusion engine and fix offload test (#933)
Signed-off-by: zjy0516 <riverclouds.zhu@qq.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-01-25 23:04:11 +08:00
Ding Zuhao 7f0825832a [Doc] Add Bagel model support to TeaCache documentation (#943)
Signed-off-by: Ding Zuhao <e1583181@u.nus.edu>
2026-01-25 23:42:02 +13:00
汪志鹏 06c9d9b463 [Bagel] Support TeaCache (#848)
Signed-off-by: princepride <wangzhipeng628@gmail.com>
Signed-off-by: 汪志鹏 <wangzhipeng628@gmail.com>
2026-01-25 14:52:27 +08:00
YinHankeandHongsheng Liu f9c69a8c40 [BugFix] Standardize StableAudio audio output (#842)
Signed-off-by: LudovicoYIN <hankeyin@gmail.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-01-24 22:27:58 +13:00
WeiQing Chen 3d9cd16df7 [examples] add --enable-cpu-offload args (#930)
Signed-off-by: David Chen <530634352@qq.com>
2026-01-23 22:43:00 +08:00
Jiangyun Zhu 28a6597c4c [Misc] Fix t2i online serving example (#928)
Signed-off-by: zjy0516 <riverclouds.zhu@qq.com>
2026-01-23 19:32:06 +08:00
21ee1ff6e6 [bugfix] qwen3-tts check_model_inputs (#924)
Signed-off-by: 齐保元 <qibaoyuan@xiaomi.com>
Co-authored-by: shijin zhang <75300765+Dovis01@users.noreply.github.com>
Co-authored-by: ning ding <nndding@gmail.com>
2026-01-23 09:37:53 +00:00
5d9918fa67 [Misc] Fix error log for the diffusion stage timeout (#915)
Signed-off-by: samithuang <285365963@qq.com>
Signed-off-by: Samit <285365963@qq.com>
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: Jiangyun Zhu <riverclouds.zhu@qq.com>
2026-01-23 15:16:40 +08:00
34c9d8f2a2 [Test] Add precision test cases for Qwen3-Omni-30B-A3B-Instruct in CI (#828)
Signed-off-by: wangyu31577 <wangyu31577@hundsun.com>
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Signed-off-by: wangyu <53896905+yenuo26@users.noreply.github.com>
Co-authored-by: wangyu31577 <wangyu31577@hundsun.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-01-23 17:49:34 +13:00
WeiQing Chen 8f4f091387 [Misc] update wechat image (#914)
Signed-off-by: David Chen <530634352@qq.com>
2026-01-23 17:14:48 +13:00
TJian 28d13e0dee [Doc] Fix the version in the documentation to v0.14.0rc1; fix dockerfile.rocm entrypoint (#913)
Signed-off-by: tjtanaa <tunjian.tan@embeddedllm.com>
2026-01-23 03:37:54 +00:00
Roger Wang b8499e4839 Bump version to 0.14.0rc1 (#910)
Signed-off-by: Roger Wang <hey@rogerw.io>
2026-01-22 17:09:48 -08:00
TJian 75dddcb2b5 [ROCm] [Doc] Add instructions to install ROCm dependencies to run Qwen3 TTS model (#905)
Signed-off-by: tjtanaa <tunjian.tan@embeddedllm.com>
2026-01-23 10:55:50 +13:00
Zhou Taichang e3b6254090 debug Qwen TTS (#902)
Signed-off-by: Taichang Zhou <tzhouam@connect.ust.hk>
2026-01-22 15:28:56 +00:00
193 changed files with 12556 additions and 4266 deletions

No files matched your search

+21 -1
View File
@@ -41,6 +41,23 @@ steps:
volumes: volumes:
- "/fsx/hf_cache:/fsx/hf_cache" - "/fsx/hf_cache:/fsx/hf_cache"
- label: "Diffusion Images API LoRA E2E"
timeout_in_minutes: 20
depends_on: image-build
commands:
- pytest -s -v tests/e2e/online_serving/test_images_generations_lora.py
agents:
queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU
plugins:
- docker#v5.2.0:
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
always-pull: true
propagate-environment: true
environment:
- "HF_HOME=/fsx/hf_cache"
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "Diffusion Model CPU offloading Test" - label: "Diffusion Model CPU offloading Test"
timeout_in_minutes: 20 timeout_in_minutes: 20
depends_on: image-build depends_on: image-build
@@ -170,7 +187,10 @@ steps:
depends_on: image-build depends_on: image-build
commands: commands:
- export VLLM_WORKER_MULTIPROC_METHOD=spawn - export VLLM_WORKER_MULTIPROC_METHOD=spawn
- pytest -s -v tests/e2e/offline_inference/test_qwen3_omni.py tests/e2e/online_serving/test_qwen3_omni.py tests/e2e/online_serving/test_async_omni.py - export VLLM_TEST_CLEAN_GPU_MEMORY="1"
- pytest -s -v tests/e2e/offline_inference/test_qwen3_omni.py
- pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py
- pytest -s -v tests/e2e/online_serving/test_async_omni.py
agents: agents:
queue: "mithril-h100-pool" queue: "mithril-h100-pool"
plugins: plugins:
+1
View File
@@ -52,6 +52,7 @@ VENV_PYTHON="${VENV_DIR}/bin/python"
"${VENV_PYTHON}" -m pytest -v -s tests/entrypoints/ "${VENV_PYTHON}" -m pytest -v -s tests/entrypoints/
"${VENV_PYTHON}" -m pytest -v -s tests/diffusion/cache/ "${VENV_PYTHON}" -m pytest -v -s tests/diffusion/cache/
"${VENV_PYTHON}" -m pytest -v -s tests/diffusion/lora/
"${VENV_PYTHON}" -m pytest -v -s tests/model_executor/models/qwen2_5_omni/test_audio_length.py "${VENV_PYTHON}" -m pytest -v -s tests/model_executor/models/qwen2_5_omni/test_audio_length.py
"${VENV_PYTHON}" -m pytest -v -s tests/worker/ "${VENV_PYTHON}" -m pytest -v -s tests/worker/
"${VENV_PYTHON}" -m pytest -v -s tests/distributed/omni_connectors/test_kv_flow.py "${VENV_PYTHON}" -m pytest -v -s tests/distributed/omni_connectors/test_kv_flow.py
+4 -1
View File
@@ -29,7 +29,10 @@ repos:
# only for staged files # only for staged files
- repo: https://github.com/rhysd/actionlint - repo: https://github.com/rhysd/actionlint
rev: v1.7.9 # v1.7.8+ sets `go 1.24.0` in go.mod, which older Go toolchains (and most
# current CI images) cannot parse. Pin to v1.7.7 until actionlint fixes the
# go.mod directive.
rev: v1.7.7
hooks: hooks:
- id: actionlint - id: actionlint
files: ^\.github/workflows/.*\.ya?ml$ files: ^\.github/workflows/.*\.ya?ml$
+5 -1
View File
@@ -17,6 +17,10 @@ RUN mkdir -p ${COMMON_WORKDIR}/vllm-omni
COPY . ${COMMON_WORKDIR}/vllm-omni COPY . ${COMMON_WORKDIR}/vllm-omni
RUN cd ${COMMON_WORKDIR}/vllm-omni && uv pip install --python "$(python3 -c 'import sys; print(sys.executable)')" --no-cache-dir ".[dev]" RUN cd ${COMMON_WORKDIR}/vllm-omni && uv pip install --python "$(python3 -c 'import sys; print(sys.executable)')" --no-cache-dir ".[dev]"
# When we are installing onnxruntime-rocm, we need to uninstall the system-installed onnxruntime first.
# These are the dependencies of Qwen3-TTS.
RUN uv pip uninstall onnxruntime --system && uv pip install --no-cache-dir onnxruntime-rocm sox --system
RUN ln -sf /usr/bin/python3 /usr/bin/python RUN ln -sf /usr/bin/python3 /usr/bin/python
CMD ["/bin/bash"] CMD ["/bin/bash"]
@@ -25,4 +29,4 @@ ENTRYPOINT []
#Set entrypoint for vllm-openai official images #Set entrypoint for vllm-openai official images
FROM final AS vllm-openai FROM final AS vllm-openai
ENTRYPOINT ["vllm", "serve"] ENTRYPOINT ["vllm", "serve", "--omni"]
+2
View File
@@ -53,8 +53,10 @@ Engine classes for offline and online inference.
Core scheduling and caching components. Core scheduling and caching components.
- [vllm_omni.core.sched.omni_ar_scheduler.KVCacheTransferData][]
- [vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler][] - [vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler][]
- [vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler][] - [vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler][]
- [vllm_omni.core.sched.output.OmniCachedRequestData][]
- [vllm_omni.core.sched.output.OmniNewRequestData][] - [vllm_omni.core.sched.output.OmniNewRequestData][]
- [vllm_omni.model_executor.models.qwen3_tts.tokenizer_25hz.vq.core_vq.DistributedGroupResidualVectorQuantization][] - [vllm_omni.model_executor.models.qwen3_tts.tokenizer_25hz.vq.core_vq.DistributedGroupResidualVectorQuantization][]
- [vllm_omni.model_executor.models.qwen3_tts.tokenizer_25hz.vq.core_vq.DistributedResidualVectorQuantization][] - [vllm_omni.model_executor.models.qwen3_tts.tokenizer_25hz.vq.core_vq.DistributedResidualVectorQuantization][]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 126 KiB

@@ -44,7 +44,7 @@ vllm_omni/
# Step-by-step-implementation # Step-by-step-implementation
## Step 1: Model Implementation ## Step 1: Model Implementation
The diffusion pipeline’s implementation follows **HuggingFace Diffusers**, and components that do not need modification can be imported directly. The diffusion pipeline’s implementation follows **HuggingFace Diffusers**.
### 1.1 Define the Pipeline Class ### 1.1 Define the Pipeline Class
Define the pipeline class, e.g., `QwenImageEditPipeline`, and initialize all required submodules, either from HuggingFace `diffusers` or custom implementations. In `QwenImageEditPipeline`, only `QwenImageTransformer2DModel` is re-implemented to support optimizations such as Ulysses-SP. When adding new models in the future, you can either reuse this re-implemented `QwenImageTransformer2DModel` or extend it as needed. Define the pipeline class, e.g., `QwenImageEditPipeline`, and initialize all required submodules, either from HuggingFace `diffusers` or custom implementations. In `QwenImageEditPipeline`, only `QwenImageTransformer2DModel` is re-implemented to support optimizations such as Ulysses-SP. When adding new models in the future, you can either reuse this re-implemented `QwenImageTransformer2DModel` or extend it as needed.
@@ -73,10 +73,75 @@ def get_qwen_image_edit_post_process_func(
The forward function of `QwenImageEditPipeline` follows the HuggingFace `diffusers` design for the most part. The key differences are: The forward function of `QwenImageEditPipeline` follows the HuggingFace `diffusers` design for the most part. The key differences are:
+ As described in the overview, arguments are passed through `OnniDiffusionRequest`, so we need to get user parameters from it accordingly. + As described in the overview, arguments are passed through `OnniDiffusionRequest`, so we need to get user parameters from it accordingly.
```python ```python
prompt = req.prompt if req.prompt is not None else prompt prompt = req.prompt
``` ```
+ pre/post-processing are handled by the framework elsewhere, so skip them. + pre/post-processing are handled by the framework elsewhere, so skip them.
### 1.4 Replace some ops or layers in DiT component
vLLM-Omni provides a set of optimized operators with better performance and built-in support for parallelism, including attention, rotary embeddings (RoPE), and linear layers.
Below is an example showing how to replace standard Transformer attention and FFN layers with vLLM-Omni implementations:
```python
from vllm_omni.diffusion.attention.layer import Attention
from vllm_omni.diffusion.layers.rope import RotaryEmbedding
class MyAttention(nn.Module):
def __init__(self):
super().__init__()
self.attn = Attention()
self.to_qkv = QKVParallelLinear()
self.to_out = RowParallelLinear()
self.rope = RotaryEmbedding(is_neox_style=False)
def forward(self, hidden_states):
qkv, _ = self.to_qkv(hidden_states)
q, k, v = qkv.split(...)
q, k = self.rope(...)
attn_output = self.attn(q, k, v)
output = self.to_out(attn_output)
class MyFFN(nn.Module):
def __init__(self):
super().__init__()
self.fc1 = ColumnParallelLinear()
self.fc2 = RowParallelLinear()
self.act = F.gelu
def forward(self, hidden_states):
hidden, _ = self.fc1(hidden_states)
hidden = self.act(hidden)
output = self.fc2(hidden)
return output
```
In this example:
+ Attention uses vLLM-Omni’s optimized attention kernel together with parallel QKV projection and RoPE.
+ Linear layers are replaced with column- and row-parallel variants to enable tensor parallelism.
+ The FFN follows a standard two-layer structure and can be further optimized (e.g., using fused or merged projections) if needed.
### 1.5 Provide a `_repeated_blocks` in DiT model
`_repeated_blocks` is the small and frequently-repeated block(s) of a model -- typically a transformer layer.
It's used for torch compile optimizations.
```python
_repeated_blocks = ["QwenImageTransformerBlock"]
```
### 1.6 (Optional) implement sequence parallelism
vLLM-Omni has a non-intrusive `_sp_plan` that enable sequence parallel without modifying `forward()` logic.
You can refer to [How to parallelize a new model](../../user_guide/diffusion/parallelism_acceleration.md)
### 1.7 (Optional) integrate with Cache-Dit
vLLM-Omni supports acceleration via [Cache-Dit](../../user_guide/diffusion/cache_dit_acceleration.md). Most models compatible with Diffusers can use Cache-Dit seamlessly. For new models, you can extend support by modifying`cache_dit_backend.py`
## Step 2: Extend OmniDiffusionRequest Fields ## Step 2: Extend OmniDiffusionRequest Fields
User-provided inputs are ultimately passed to the model’s forward method through OmniDiffusionRequest, so we add the required fields here to support the new model. User-provided inputs are ultimately passed to the model’s forward method through OmniDiffusionRequest, so we add the required fields here to support the new model.
```python ```python
@@ -139,6 +204,21 @@ Key point for writing the example:
+ Save or display the generated results so users can validate the integration. + Save or display the generated results so users can validate the integration.
## Step 5: Open a Pull Request
When submitting a pull request to add support for a new model, please include the following information in the PR description:
+ Output verification: provide generation outputs to verify correctness and model behavior.
+ Inference speed: provide a comparison with the corresponding implementation in Diffusers.
+ Parallelism support: specify the supported parallel sizes and any relevant limitations.
+ Cache acceleration: check whether the model can be accelerated using Cache-Dit or not.
Providing these details helps reviewers evaluate correctness, performance improvements, and parallel scalability of the new model integration.
# Testing # Testing
For comprehensive testing guidelines, please refer to the [Test File Structure and Style Guide](../ci/tests_style.md). For comprehensive testing guidelines, please refer to the [Test File Structure and Style Guide](../ci/tests_style.md).
+60 -154
View File
@@ -1,27 +1,35 @@
# Disaggregated Inference for Omni-Modality Models # Disaggregated Inference for Omni-Modality Models
This guide explains how to configure and use distributed connectors (vllm_omni/distributed/connectors) in vllm-omni for multi-stage pipelines. This guide explains how to configure and use distributed connectors
(`vllm_omni/distributed/omni_connectors`) in vllm-omni for multi-stage pipelines.
## 1. Overview Backend-specific setup lives in separate docs:
- [SharedMemoryConnector](omni_connectors/shared_memory_connector.md)
- [MooncakeConnector](omni_connectors/mooncake_connector.md)
- [YuanrongConnector](omni_connectors/yuanrong_connector.md)
## Overview
Connectors enable data transfer between pipeline stages (e.g., Thinker -> Talker). Connectors enable data transfer between pipeline stages (e.g., Thinker -> Talker).
Currently supported connectors operate in **D2H2D (Device-to-Host-to-Device)** mode: Current connectors operate in D2H2D (device to host to device) mode.
1. **SharedMemoryConnector**: Uses system shared memory.
2. **MooncakeConnector**: Uses [Mooncake](https://github.com/kvcache-ai/Mooncake).
* **SharedMemoryConnector (Default)**: Zero-copy (on host), lowest latency. Best for **single-node** deployments. Auto-configured if no connectors are specified. ## Connector Choices
* **MooncakeConnector**: TCP/RDMA based. Best for **multi-node** distributed deployments. Requires a Mooncake Master service.
## 2. API Design | Use Case | Recommended Connector | Notes |
| :--- | :--- | :--- |
| Single node | SharedMemoryConnector | Auto-configured if no connector is specified. |
| Multi node (Mooncake) | MooncakeConnector | Requires Mooncake Master + metadata server. |
| Multi node (Yuanrong) | YuanrongConnector | Requires Yuanrong Datasystem + etcd. |
The connector system is built around the `OmniConnectorBase` abstraction, which decouples data transport from stage logic. ## Core API
### Core Interface The connector system is built around `OmniConnectorBase`.
```python ```python
class OmniConnectorBase(ABC): class OmniConnectorBase(ABC):
@abstractmethod @abstractmethod
def put(self, from_stage: str, to_stage: str, request_id: str, data: Any) -> tuple[bool, int, Optional[dict]]: def put(self, from_stage: str, to_stage: str, put_key: str, data: Any) -> tuple[bool, int, Optional[dict]]:
""" """
Store data. Store data.
Returns: (success, serialized_size, metadata) Returns: (success, serialized_size, metadata)
@@ -29,145 +37,23 @@ class OmniConnectorBase(ABC):
pass pass
@abstractmethod @abstractmethod
def get(self, from_stage: str, to_stage: str, request_id: str, metadata: Optional[dict] = None) -> Optional[tuple[Any, int]]: def get(self, from_stage: str, to_stage: str, get_key: str, metadata: Optional[dict] = None) -> Optional[tuple[Any, int]]:
""" """
Retrieve data. Retrieve data.
Args: metadata - Transport-specific handles returned by put() (e.g., SHM name). Args: metadata - transport-specific handles returned by put() (e.g., SHM name).
Returns: (object, serialized_size) Returns: (object, serialized_size)
""" """
pass pass
``` ```
### Key Concept: Metadata Passing ### Metadata Passing
Unlike a pure key-value store, some connectors (like `SharedMemoryConnector`) generate transient resources (e.g., a shared memory block name) during `put()`. This `metadata` **must be passed via the control plane** (e.g., HTTP headers, queue messages) from the producer stage to the consumer stage so `get()` can locate the data.
## 3. Backends & Use Cases Some connectors (e.g., SharedMemoryConnector) generate transient resources during `put()`.
This `metadata` must be passed through the control plane so `get()` can locate the data.
### 3.1 SharedMemoryConnector ## Configuration Model
**Best for:** Single-node, high-performance IPC.
* **Mechanism:** Define connectors in runtime:
* **Small Payloads (< Threshold)**: Data is serialized and passed directly "inline" within the `metadata` dictionary. This avoids the overhead of creating SHM blocks for tiny messages.
* **Large Payloads (>= Threshold)**: Data is written to a named System Shared Memory block. The block name is returned in `metadata`.
* **Configuration:**
* `shm_threshold_bytes`: Size in bytes to switch from inline to SHM (default: 64KB).
### 3.2 MooncakeConnector
**Best for:** Multi-node distributed inference.
* **Mechanism:** Uses Mooncake's distributed KVCache store.
* **Data Plane**: TCP or RDMA for high-bandwidth transfer.
* **Control Plane**: Uses a centralized Mooncake Master and Metadata Server.
* **Keying**: Deterministic keys based on `request_id/from_stage_to_stage`.
* **Requirements**: Requires a running Mooncake Master service.
## 4. Relationship with vLLM
vLLM provides specialized distributed mechanisms for specific artifacts:
* **KV Transfer** (`vllm.distributed.kv_transfer`): Optimized for transferring KV caches between prefill and decode instances (using NCCL, Mooncake, etc.).
* **EC Transfer** (`vllm.distributed.ec_transfer`): Optimized for sharing encoder embeddings.
* **Device Communicators** (`vllm.distributed.device_communicators`): Low-level primitives (NCCL, SHM) for Tensor/Pipeline Parallelism.
`vllm-omni` complements this by introducing a **Generalized Connector Abstraction** (`OmniConnector`) for multimodal pipelines. While vLLM's connectors are artifact-specific, `vllm-omni`:
1. **Unifies Transport**: Provides a single API (`put`/`get`) to transport *any* stage artifact (Input Embeddings, Hidden States, Audio/Image Tensors, KV Cache, Final Output) between arbitrary pipeline stages (e.g., AudioEncoder -> LLM -> AudioGenerator).
2. **Extends Connectivity**: Enables flexible construction of complex DAGs (Directed Acyclic Graphs) where stages can run in the same process, same node, or across nodes, using the most appropriate backend (SHM, Mooncake, etc.) for each edge.
3. **Wraps & Adapts**: Can internally utilize vLLM's specialized `kv_transfer` for KV paths while using generic transports (SHM/Mooncake) for other data types, presenting a consistent interface to the application layer.
## 5. Installation (Mooncake)
If using `MooncakeConnector`, install the library first:
```bash
# For CUDA-enabled systems (Recommended)
pip install mooncake-transfer-engine
# For non-CUDA systems
pip install mooncake-transfer-engine-non-cuda
```
## 6. Using MooncakeConnector
### 6.1 Start Mooncake Master
Start the master service on your primary node:
```bash
# if you use mooncake SSD storage
mkdir -p ./mc_storage
mooncake_master \
--rpc_port=50051 \
--enable_http_metadata_server=true \
--http_metadata_server_host=0.0.0.0 \
--http_metadata_server_port=8080 \
--metrics_port=9003 \
--root_fs_dir=./mc_storage/ \
--cluster_id=mc-local-1 &
```
### 6.2 Configuration (YAML)
Edit your stage config (e.g., `qwen2_5_omni.yaml`).
**Step 1: Define Connector in Global Runtime**
```yaml
runtime:
connectors:
connector_of_mooncake:
name: MooncakeConnector
extra:
host: "127.0.0.1" # Local Worker IP
metadata_server: "http://<MASTER_IP>:8080/metadata"
master: "<MASTER_IP>:50051"
segment: 512000000 # 512MB segment
localbuf: 64000000 # 64MB buffer
proto: "tcp" # "tcp" or "rdma"
```
**Mooncake Configuration Parameters:**
* **host**: The hostname or IP address of the local machine (worker). Mooncake uses this to register itself in the metadata server so other nodes can find it.
* **metadata_server**: The URL of the metadata server. This is used for service discovery and connection establishment (e.g., exchanging QP information for RDMA).
* **master**: The address of the Mooncake Master Server (e.g., `<MASTER_IP>:50051`). This is used for global state management and control plane operations.
* **segment**: The size of the global memory segment in bytes (default: ~512MB). This defines the shared memory region accessible by Mooncake for data transfer.
* **localbuf**: The size of the local buffer in bytes (default: ~64MB). Used for local data buffering during transfer operations.
* **proto**: The transport protocol to use. Options:
* `tcp`: Standard TCP/IP (easier setup, universal compatibility).
* `rdma`: Remote Direct Memory Access (higher performance, requires RDMA-capable hardware).
For more details, refer to the [Mooncake Repository](https://github.com/kvcache-ai/Mooncake).
**Step 2: Reference in Stages**
Explicitly link stages using `input_connectors` and `output_connectors`:
```yaml
stage_args:
- stage_id: 0
# ...
output_connectors:
to_stage_1: connector_of_mooncake
- stage_id: 1
# ...
input_connectors:
from_stage_0: connector_of_mooncake
```
## 7. Using SharedMemoryConnector (Auto-Mode)
**Best for single-node.**
The system will automatically create `SharedMemoryConnector`s for any pipeline edge that does not have an explicit connector defined. This is inferred from:
1. `runtime.edges` list in the config.
2. `engine_input_source` dependencies defined in `stage_args`.
### Threshold Configuration
By default, payloads larger than **64KB** (default threshold) are transferred via shared memory, while smaller ones use the control queue (inline).
To adjust this threshold (e.g., to 1GB), add the following to your `runtime.connectors`:
```yaml ```yaml
runtime: runtime:
@@ -175,26 +61,46 @@ runtime:
connector_of_shared_memory: connector_of_shared_memory:
name: SharedMemoryConnector name: SharedMemoryConnector
extra: extra:
shm_threshold_bytes: 1024 # 1KB threshold shm_threshold_bytes: 65536
``` ```
## 8. Summary Wire stages to connectors:
| Use Case | Recommended Connector | Configuration | ```yaml
| :--- | :--- | :--- | stage_args:
| **Single Node** | `SharedMemoryConnector` | **None** (Automatic) or Custom Threshold | - stage_id: 0
| **Multi Node** | `MooncakeConnector` | Explicit YAML + Mooncake Master | output_connectors:
to_stage_1: connector_of_shared_memory
## 9. Operational Notes (important) - stage_id: 1
input_connectors:
from_stage_0: connector_of_shared_memory
```
- **Fail-fast config validation**: the loader raises if any expected edge is missing a connector. Define `input_connectors`/`output_connectors` or rely on auto-SHM filling; otherwise startup aborts. If a pipeline edge has no explicit connector, the system auto-creates a
- **Missing payloads halt the stage**: workers expect connector payloads; if metadata or connector config is missing, the stage raises and stops. Verify connector wiring and metadata propagation before production. SharedMemoryConnector for that edge.
## 10. Future Roadmap: Device-to-Device (D2D) Transport ## Relationship with vLLM
The current implementations (`SharedMemoryConnector`, `MooncakeConnector`) utilize a **D2H2D (Device-to-Host-to-Device)** data path. Tensors are moved to CPU memory (Host) for transport, which incurs PCIe overhead. vLLM provides specialized distributed mechanisms for specific artifacts:
As outlined in the design RFC, future versions will introduce **D2D (Device-to-Device)** connectors: - KV Transfer (`vllm.distributed.kv_transfer`): optimized for KV caches.
- EC Transfer (`vllm.distributed.ec_transfer`): optimized for encoder embeddings.
- Device Communicators (`vllm.distributed.device_communicators`): low-level primitives (NCCL, SHM).
* **Goal**: Direct GPU-to-GPU transfer (via NCCL, UCX, or IPC) to minimize latency for large tensor payloads. vllm-omni complements this with a generalized connector abstraction:
* **Mechanism**: The `OmniConnector` API allows `put()` to initiate a transfer and return a lightweight handle (metadata) via the control plane, while the heavy payload flows directly between devices.
1. Unifies transport via a single `put`/`get` API for any stage artifact.
2. Enables DAG-style pipelines across processes or nodes with per-edge transports.
3. Can wrap vLLM-specific transfers for KV paths while keeping a consistent interface.
## Operational Notes
- Fail-fast config validation: missing expected edges cause startup failures.
- Missing payloads halt stages: verify connector wiring and metadata propagation.
## Future Roadmap: D2D Transport
Current connectors use D2H2D paths. Future versions will introduce direct
device-to-device connectors (NCCL, UCX, IPC) to reduce latency for large
tensor payloads.
@@ -0,0 +1,74 @@
# MooncakeConnector
## When to Use
Best for multi-node distributed inference using Mooncake.
## Installation
```bash
# For CUDA-enabled systems (recommended)
pip install mooncake-transfer-engine
# For non-CUDA systems
pip install mooncake-transfer-engine-non-cuda
```
## Start Mooncake Master
```bash
# If you use Mooncake SSD storage
mkdir -p ./mc_storage
mooncake_master \
--rpc_port=50051 \
--enable_http_metadata_server=true \
--http_metadata_server_host=0.0.0.0 \
--http_metadata_server_port=8080 \
--metrics_port=9003 \
--root_fs_dir=./mc_storage/ \
--cluster_id=mc-local-1 &
```
## Configuration
Define the connector in runtime:
```yaml
runtime:
connectors:
connector_of_mooncake:
name: MooncakeConnector
extra:
host: "127.0.0.1"
metadata_server: "http://<MASTER_IP>:8080/metadata"
master: "<MASTER_IP>:50051"
segment: 512000000
localbuf: 64000000
proto: "tcp"
```
Wire stages to the connector:
```yaml
stage_args:
- stage_id: 0
output_connectors:
to_stage_1: connector_of_mooncake
- stage_id: 1
input_connectors:
from_stage_0: connector_of_mooncake
```
Parameters:
- host: local worker IP registered in the metadata server.
- metadata_server: metadata server URL for discovery and setup.
- master: Mooncake Master address.
- segment: global memory segment size in bytes.
- localbuf: local buffer size in bytes.
- proto: transport protocol ("tcp" or "rdma").
For more details, refer to the
[Mooncake repository](https://github.com/kvcache-ai/Mooncake).
@@ -0,0 +1,27 @@
# SharedMemoryConnector
## When to Use
Best for single-node deployments where stages run on the same host. It is
auto-configured when no explicit connector is specified for an edge.
## How It Works
- Small payloads (< threshold): serialized and passed inline in metadata.
- Large payloads (>= threshold): stored in shared memory; the SHM name is
returned in metadata.
## Configuration
```yaml
runtime:
connectors:
connector_of_shared_memory:
name: SharedMemoryConnector
extra:
shm_threshold_bytes: 65536
```
## Notes
- Auto-mode uses SharedMemoryConnector if no connector is declared for an edge.
@@ -0,0 +1,101 @@
# YuanrongConnector
## When to Use
Best for multi-node distributed inference using Yuanrong Datasystem.
## Mechanism
Uses Yuanrong Datasystem's distributed KV store (`datasystem.kv_client`).
- Data Plane: TCP or RDMA for high-bandwidth transfer.
- Control Plane: Yuanrong Datasystem workers and etcd.
- Keying: deterministic keys based on `put_key` (often composed as `request_id:fromStage_toStage`).
## Installation
```bash
pip install openyuanrong-datasystem
```
## Start etcd
```bash
# Download and install etcd (v3.5.12 or higher)
ETCD_VERSION="v3.5.12"
ETCD_ARCH="linux-arm64"
wget https://github.com/etcd-io/etcd/releases/download/${ETCD_VERSION}/etcd-${ETCD_VERSION}-${ETCD_ARCH}.tar.gz
tar -xvf etcd-${ETCD_VERSION}-${ETCD_ARCH}.tar.gz
cd etcd-${ETCD_VERSION}-${ETCD_ARCH}
sudo cp etcd etcdctl /usr/local/bin/
# Start etcd
etcd \
--name etcd-single \
--data-dir /tmp/etcd-data \
--listen-client-urls http://0.0.0.0:2379 \
--advertise-client-urls http://0.0.0.0:2379 \
--listen-peer-urls http://0.0.0.0:2380 \
--initial-advertise-peer-urls http://0.0.0.0:2380 \
--initial-cluster etcd-single=http://0.0.0.0:2380 &
# Verify etcd is running
etcdctl --endpoints "127.0.0.1:2379" put key "value"
etcdctl --endpoints "127.0.0.1:2379" get key
```
For production environments, refer to the
[official etcd clustering documentation](https://etcd.io/docs/current/op-guide/clustering/).
## Start Datasystem Worker
```bash
# Replace ${ETCD_IP} with etcd node IP, ${WORKER_IP} with local node IP
dscli start -w \
--worker_address "${WORKER_IP}:31501" \
--etcd_address "${ETCD_IP}:2379" \
--shared_memory_size_mb 20480
```
To stop the worker:
```bash
dscli stop --worker_address "${WORKER_IP}:31501"
```
## Configuration
Define the connector in runtime:
```yaml
runtime:
connectors:
connector_of_yuanrong:
name: YuanrongConnector
extra:
host: "127.0.0.1"
port: 31501
get_sub_timeout_ms: 1000
```
Wire stages to the connector:
```yaml
stage_args:
- stage_id: 0
output_connectors:
to_stage_1: connector_of_yuanrong
- stage_id: 1
input_connectors:
from_stage_0: connector_of_yuanrong
```
Parameters:
- host: datasystem worker host.
- port: datasystem worker port.
- get_sub_timeout_ms: get timeout in milliseconds (0 for no timeout).
For more details, refer to the
[Yuanrong Datasystem repository](https://atomgit.com/openeuler/yuanrong-datasystem).
@@ -17,7 +17,7 @@ Therefore, it is recommended to install vLLM and vLLM-Omni with a **fresh new**
# --8<-- [start:pre-built-wheels] # --8<-- [start:pre-built-wheels]
#### Installation of vLLM #### Installation of vLLM
Note: Pre-built wheels are currently only available for vLLM-Omni 0.11.0rc1 and 0.12.0rc1. For the latest version, 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 only available for vLLM-Omni 0.11.0rc1, 0.12.0rc1, 0.14.0rc1. For the latest version, please [build from source](https://docs.vllm.ai/projects/vllm-omni/en/latest/getting_started/installation/gpu/#build-wheel-from-source).
vLLM-Omni is built based on vLLM. Please install it with command below. vLLM-Omni is built based on vLLM. Please install it with command below.
@@ -93,7 +93,7 @@ docker run --runtime nvidia --gpus 2 \
--env "HF_TOKEN=$HF_TOKEN" \ --env "HF_TOKEN=$HF_TOKEN" \
-p 8091:8091 \ -p 8091:8091 \
--ipc=host \ --ipc=host \
vllm/vllm-omni:v0.14.0 \ vllm/vllm-omni:v0.14.0rc1 \
--model Qwen/Qwen3-Omni-30B-A3B-Instruct --port 8091 --model Qwen/Qwen3-Omni-30B-A3B-Instruct --port 8091
``` ```
@@ -23,15 +23,6 @@ vLLM-Omni current recommends the steps in under setup through Docker Images.
DOCKER_BUILDKIT=1 docker build -f docker/Dockerfile.rocm -t vllm-omni-rocm . DOCKER_BUILDKIT=1 docker build -f docker/Dockerfile.rocm -t vllm-omni-rocm .
``` ```
If you want to specify which GPU Arch to build for to cutdown build time:
```bash
DOCKER_BUILDKIT=1 docker build \
-f docker/Dockerfile.rocm \
--build-arg PYTORCH_ROCM_ARCH="gfx942;gfx950" \
-t vllm-omni-rocm .
```
#### Launch the docker image #### Launch the docker image
##### Launch with OpenAI API Server ##### Launch with OpenAI API Server
@@ -89,7 +80,7 @@ docker run --rm \
-v ~/.cache/huggingface:/root/.cache/huggingface \ -v ~/.cache/huggingface:/root/.cache/huggingface \
--env "HF_TOKEN=$HF_TOKEN" \ --env "HF_TOKEN=$HF_TOKEN" \
-p 8091:8091 \ -p 8091:8091 \
vllm/vllm-omni-rocm:v0.14.0 \ vllm/vllm-omni-rocm:v0.14.0rc1 \
--model Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 --model Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
``` ```
@@ -108,7 +99,7 @@ docker run --rm -it \
-v ~/.cache/huggingface:/root/.cache/huggingface \ -v ~/.cache/huggingface:/root/.cache/huggingface \
--env "HF_TOKEN=$HF_TOKEN" \ --env "HF_TOKEN=$HF_TOKEN" \
--entrypoint bash \ --entrypoint bash \
vllm/vllm-omni-rocm:v0.14.0 vllm/vllm-omni-rocm:v0.14.0rc1
``` ```
# --8<-- [end:pre-built-images] # --8<-- [end:pre-built-images]
+40
View File
@@ -46,6 +46,46 @@ if __name__ == "__main__":
images[0].save("coffee.png") images[0].save("coffee.png")
``` ```
You can pass a list of prompts and wait for them to process altogether, shown below.
!!! info
However, it is not currently recommended to do so
because not all models support batch inference,
and batch requesting mostly does not provide significant performance improvement (despite the impression that it does).
This feature is primarily for the sake of interface compatibility with vLLM and to allow for future improvements.
```python
from vllm_omni.entrypoints.omni import Omni
if __name__ == "__main__":
omni = Omni(
model="Tongyi-MAI/Z-Image-Turbo",
# stage_configs_path="./stage-config.yaml", # See below
)
prompts = [
"a cup of coffee on a table",
"a toy dinosaur on a sandy beach",
"a fox waking up in bed and yawning",
]
omni_outputs = omni.generate(prompts)
for i_prompt, prompt_output in enumerate(omni_outputs):
this_request_output = prompt_output.request_output[0]
this_images = this_request_output.images
for i_image, image in enumerate(this_images):
image.save(f"p{i_prompt}-img{i_image}.jpg")
print("saved to", f"p{i_prompt}-img{i_image}.jpg")
# saved to p0-img0.jpg
# saved to p1-img0.jpg
# saved to p2-img0.jpg
```
!!! info
For diffusion pipelines, the stage config field `stage_args.[].runtime.max_batch_size` is 1 by default, and the input
list is sliced into single-item requests before feeding into the diffusion pipeline. For models that do internally support
batched inputs, you can [modify this configuration](../configuration/stage_configs.md) to let the model accept a longer batch of prompts.
For more usages, please refer to [offline inference](../user_guide/examples/offline_inference/qwen2_5_omni.md) 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 OpenAI-Completions API
+1
View File
@@ -33,6 +33,7 @@ th {
|`LongCatImageEditPipeline` | LongCat-Image-Edit | `meituan-longcat/LongCat-Image-Edit` | |`LongCatImageEditPipeline` | LongCat-Image-Edit | `meituan-longcat/LongCat-Image-Edit` |
|`StableDiffusion3Pipeline` | Stable-Diffusion-3 | `stabilityai/stable-diffusion-3.5-medium` | |`StableDiffusion3Pipeline` | Stable-Diffusion-3 | `stabilityai/stable-diffusion-3.5-medium` |
|`Flux2KleinPipeline` | FLUX.2-klein | `black-forest-labs/FLUX.2-klein-4B`, `black-forest-labs/FLUX.2-klein-9B` | |`Flux2KleinPipeline` | FLUX.2-klein | `black-forest-labs/FLUX.2-klein-4B`, `black-forest-labs/FLUX.2-klein-9B` |
|`FluxPipeline` | FLUX.1-dev | `black-forest-labs/FLUX.1-dev` |
|`StableAudioPipeline` | Stable-Audio-Open | `stabilityai/stable-audio-open-1.0` | |`StableAudioPipeline` | Stable-Audio-Open | `stabilityai/stable-audio-open-1.0` |
|`Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-CustomVoice | `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` | |`Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-CustomVoice | `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` |
|`Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-VoiceDesign | `Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign` | |`Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-VoiceDesign | `Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign` |
@@ -18,6 +18,7 @@ Enable cache-dit acceleration by simply setting `cache_backend="cache_dit"`. Cac
```python ```python
from vllm_omni.entrypoints.omni import Omni from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
# Simplest way: just enable cache-dit with default parameters # Simplest way: just enable cache-dit with default parameters
omni = Omni( omni = Omni(
@@ -27,7 +28,7 @@ omni = Omni(
images = omni.generate( images = omni.generate(
"a beautiful landscape", "a beautiful landscape",
num_inference_steps=50, OmniDiffusionSamplingParams(num_inference_steps=50),
) )
``` ```
@@ -24,11 +24,13 @@ The following table shows which models are currently supported by parallelism me
| **LongCat-Image-Edit** | `meituan-longcat/LongCat-Image-Edit` | ✅ | ✅ | ❌ | ❌ | | **LongCat-Image-Edit** | `meituan-longcat/LongCat-Image-Edit` | ✅ | ✅ | ❌ | ❌ |
| **Ovis-Image** | `OvisAI/Ovis-Image` | ❌ | ❌ | ❌ | ❌ | | **Ovis-Image** | `OvisAI/Ovis-Image` | ❌ | ❌ | ❌ | ❌ |
| **Qwen-Image** | `Qwen/Qwen-Image` | ✅ | ✅ | ✅ | ✅ | | **Qwen-Image** | `Qwen/Qwen-Image` | ✅ | ✅ | ✅ | ✅ |
| **Qwen-Image-Edit** | `Qwen/Qwen-Image-Edit` | ✅ | ✅ | ✅ | ❌ | | **Qwen-Image-Edit** | `Qwen/Qwen-Image-Edit` | ✅ | ✅ | ✅ | ✅ |
| **Qwen-Image-Edit-2509** | `Qwen/Qwen-Image-Edit-2509` | ✅ | ✅ | ✅ | ❌ | | **Qwen-Image-Edit-2509** | `Qwen/Qwen-Image-Edit-2509` | ✅ | ✅ | ✅ | ✅ |
| **Qwen-Image-Layered** | `Qwen/Qwen-Image-Layered` | ✅ | ✅ | ✅ | ❌ | | **Qwen-Image-Layered** | `Qwen/Qwen-Image-Layered` | ✅ | ✅ | ✅ | ✅ |
| **Z-Image** | `Tongyi-MAI/Z-Image-Turbo` | ✅ | ✅ | ❌ | ✅ (TP=2 only) | | **Z-Image** | `Tongyi-MAI/Z-Image-Turbo` | ✅ | ✅ | ❌ | ✅ (TP=2 only) |
| **Stable-Diffusion3.5** | `stabilityai/stable-diffusion-3.5` | ❌ | ❌ | ❌ | ❌ | | **Stable-Diffusion3.5** | `stabilityai/stable-diffusion-3.5` | ❌ | ❌ | ❌ | ❌ |
| **FLUX.2-klein** | `black-forest-labs/FLUX.2-klein-4B` | ❌ | ❌ | ❌ | ✅ |
| **FLUX.1-dev** | `black-forest-labs/FLUX.1-dev` | ❌ | ❌ | ❌ | ✅ |
!!! note "TP Limitations for Diffusion Models" !!! note "TP Limitations for Diffusion Models"
@@ -48,7 +50,7 @@ The following table shows which models are currently supported by parallelism me
| Model | Model Identifier | Ulysses-SP | Ring-SP | Tensor-Parallel | | Model | Model Identifier | Ulysses-SP | Ring-SP | Tensor-Parallel |
|-------|------------------|------------|---------|--------------------------| |-------|------------------|------------|---------|--------------------------|
| **Wan2.2** | `Wan-AI/Wan2.2-T2V-A14B-Diffusers` | ❌ | ❌ | ❌ | | **Wan2.2** | `Wan-AI/Wan2.2-T2V-A14B-Diffusers` | ✅ | ✅ | ❌ |
### Tensor Parallelism ### Tensor Parallelism
@@ -66,10 +68,12 @@ omni = Omni(
) )
outputs = omni.generate( outputs = omni.generate(
prompt="a cat reading a book", "a cat reading a book",
num_inference_steps=9, OmniDiffusionSamplingParams(
width=512, num_inference_steps=9,
height=512, width=512,
height=512,
),
) )
``` ```
@@ -82,6 +86,7 @@ outputs = omni.generate(
An example of offline inference script using [Ulysses-SP](https://arxiv.org/pdf/2309.14509) is shown below: An example of offline inference script using [Ulysses-SP](https://arxiv.org/pdf/2309.14509) is shown below:
```python ```python
from vllm_omni import Omni from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.diffusion.data import DiffusionParallelConfig from vllm_omni.diffusion.data import DiffusionParallelConfig
ulysses_degree = 2 ulysses_degree = 2
@@ -90,7 +95,10 @@ omni = Omni(
parallel_config=DiffusionParallelConfig(ulysses_degree=2) parallel_config=DiffusionParallelConfig(ulysses_degree=2)
) )
outputs = omni.generate(prompt="A cat sitting on a windowsill", num_inference_steps=50, width=2048, height=2048) outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(num_inference_steps=50, width=2048, height=2048),
)
``` ```
See `examples/offline_inference/text_to_image/text_to_image.py` for a complete working example. See `examples/offline_inference/text_to_image/text_to_image.py` for a complete working example.
@@ -132,6 +140,7 @@ Ring-Attention ([arxiv paper](https://arxiv.org/abs/2310.01889)) splits the inpu
An example of offline inference script using Ring-Attention is shown below: An example of offline inference script using Ring-Attention is shown below:
```python ```python
from vllm_omni import Omni from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.diffusion.data import DiffusionParallelConfig from vllm_omni.diffusion.data import DiffusionParallelConfig
ring_degree = 2 ring_degree = 2
@@ -140,7 +149,10 @@ omni = Omni(
parallel_config=DiffusionParallelConfig(ring_degree=2) parallel_config=DiffusionParallelConfig(ring_degree=2)
) )
outputs = omni.generate(prompt="A cat sitting on a windowsill", num_inference_steps=50, width=2048, height=2048) outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(num_inference_steps=50, width=2048, height=2048),
)
``` ```
See `examples/offline_inference/text_to_image/text_to_image.py` for a complete working example. See `examples/offline_inference/text_to_image/text_to_image.py` for a complete working example.
@@ -182,6 +194,7 @@ You can combine both Ulysses-SP and Ring-Attention for larger scale parallelism.
```python ```python
from vllm_omni import Omni from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.diffusion.data import DiffusionParallelConfig from vllm_omni.diffusion.data import DiffusionParallelConfig
# Hybrid: 2 Ulysses × 2 Ring = 4 GPUs total # Hybrid: 2 Ulysses × 2 Ring = 4 GPUs total
@@ -190,7 +203,10 @@ omni = Omni(
parallel_config=DiffusionParallelConfig(ulysses_degree=2, ring_degree=2) parallel_config=DiffusionParallelConfig(ulysses_degree=2, ring_degree=2)
) )
outputs = omni.generate(prompt="A cat sitting on a windowsill", num_inference_steps=50, width=2048, height=2048) outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(num_inference_steps=50, width=2048, height=2048),
)
``` ```
##### Online Serving ##### Online Serving
@@ -373,11 +389,15 @@ omni = Omni(
) )
outputs = omni.generate( outputs = omni.generate(
prompt="turn this cat to a dog", {
negative_prompt="low quality, blurry", "prompt": "turn this cat to a dog",
true_cfg_scale=4.0, "negative_prompt": "low quality, blurry",
pil_image=input_image, },
num_inference_steps=50, OmniDiffusionSamplingParams(
true_cfg_scale=4.0,
pil_image=input_image,
num_inference_steps=50,
),
) )
``` ```
+15 -2
View File
@@ -8,6 +8,7 @@ Enable TeaCache by setting `cache_backend` to `"tea_cache"`:
```python ```python
from vllm_omni import Omni from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
# Simple configuration - model_type is automatically extracted from pipeline.__class__.__name__ # Simple configuration - model_type is automatically extracted from pipeline.__class__.__name__
omni = Omni( omni = Omni(
@@ -17,7 +18,12 @@ omni = Omni(
"rel_l1_thresh": 0.2 # Optional, defaults to 0.2 "rel_l1_thresh": 0.2 # Optional, defaults to 0.2
} }
) )
outputs = omni.generate(prompt="A cat sitting on a windowsill", num_inference_steps=50) outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(
num_inference_steps=50,
),
)
``` ```
### Using Environment Variable ### Using Environment Variable
@@ -68,13 +74,19 @@ Controls the balance between speed and quality. Lower values prioritize quality,
```python ```python
from vllm_omni import Omni from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
omni = Omni( omni = Omni(
model="Qwen/Qwen-Image", model="Qwen/Qwen-Image",
cache_backend="tea_cache", cache_backend="tea_cache",
cache_config={"rel_l1_thresh": 0.2} cache_config={"rel_l1_thresh": 0.2}
) )
outputs = omni.generate(prompt="A cat sitting on a windowsill", num_inference_steps=50) outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(
num_inference_steps=50,
),
)
``` ```
## Performance Tuning ## Performance Tuning
@@ -112,6 +124,7 @@ th {
| `QwenImageEditPipeline` | Qwen-Image-Edit | `Qwen/Qwen-Image-Edit` | | `QwenImageEditPipeline` | Qwen-Image-Edit | `Qwen/Qwen-Image-Edit` |
| `QwenImageEditPlusPipeline` | Qwen-Image-Edit-2509 | `Qwen/Qwen-Image-Edit-2509` | | `QwenImageEditPlusPipeline` | Qwen-Image-Edit-2509 | `Qwen/Qwen-Image-Edit-2509` |
| `QwenImageLayeredPipeline` | Qwen-Image-Layered | `Qwen/Qwen-Image-Layered` | | `QwenImageLayeredPipeline` | Qwen-Image-Layered | `Qwen/Qwen-Image-Layered` |
| `BagelForConditionalGeneration` | BAGEL (DiT-only) | `ByteDance-Seed/BAGEL-7B-MoT` |
### VideoGen ### VideoGen
+41 -9
View File
@@ -49,7 +49,7 @@ The following table shows which models are currently supported by each accelerat
| **Qwen-Image-Layered** | `Qwen/Qwen-Image-Layered` | ❌ | ✅ | ✅ | ✅ | ✅ | | **Qwen-Image-Layered** | `Qwen/Qwen-Image-Layered` | ❌ | ✅ | ✅ | ✅ | ✅ |
| **Z-Image** | `Tongyi-MAI/Z-Image-Turbo` | ✅ | ✅ | ❌ | ❌ | ❌ | | **Z-Image** | `Tongyi-MAI/Z-Image-Turbo` | ✅ | ✅ | ❌ | ❌ | ❌ |
| **Stable-Diffusion3.5** | `stabilityai/stable-diffusion-3.5` | ❌ | ✅ | ❌ | ❌ | ❌ | | **Stable-Diffusion3.5** | `stabilityai/stable-diffusion-3.5` | ❌ | ✅ | ❌ | ❌ | ❌ |
| **Bagel** | `ByteDance-Seed/BAGEL-7B-MoT` | ❌ | ✅ | ❌ | ❌ | ❌ | | **Bagel** | `ByteDance-Seed/BAGEL-7B-MoT` | ✅ | ✅ | ❌ | ❌ | ❌ |
### VideoGen ### VideoGen
@@ -96,6 +96,7 @@ To measure the parallelism methods, we run benchmarks with **Qwen/Qwen-Image** m
```python ```python
from vllm_omni import Omni from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
omni = Omni( omni = Omni(
model="Qwen/Qwen-Image", model="Qwen/Qwen-Image",
@@ -103,13 +104,19 @@ omni = Omni(
cache_config={"rel_l1_thresh": 0.2} # Optional, defaults to 0.2 cache_config={"rel_l1_thresh": 0.2} # Optional, defaults to 0.2
) )
outputs = omni.generate(prompt="A cat sitting on a windowsill", num_inference_steps=50) outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(
num_inference_steps=50,
),
)
``` ```
### Using Cache-DiT ### Using Cache-DiT
```python ```python
from vllm_omni import Omni from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
omni = Omni( omni = Omni(
model="Qwen/Qwen-Image", model="Qwen/Qwen-Image",
@@ -123,7 +130,12 @@ omni = Omni(
} }
) )
outputs = omni.generate(prompt="A cat sitting on a windowsill", num_inference_steps=50) outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(
num_inference_steps=50,
),
)
``` ```
### Using Ulysses-SP ### Using Ulysses-SP
@@ -131,6 +143,7 @@ outputs = omni.generate(prompt="A cat sitting on a windowsill", num_inference_st
Run text-to-image: Run text-to-image:
```python ```python
from vllm_omni import Omni from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.diffusion.data import DiffusionParallelConfig from vllm_omni.diffusion.data import DiffusionParallelConfig
ulysses_degree = 2 ulysses_degree = 2
@@ -139,13 +152,17 @@ omni = Omni(
parallel_config=DiffusionParallelConfig(ulysses_degree=ulysses_degree) parallel_config=DiffusionParallelConfig(ulysses_degree=ulysses_degree)
) )
outputs = omni.generate(prompt="A cat sitting on a windowsill", num_inference_steps=50, width=2048, height=2048) outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(num_inference_steps=50, width=2048, height=2048),
)
``` ```
Run image-to-image: Run image-to-image:
```python ```python
from vllm_omni import Omni from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.diffusion.data import DiffusionParallelConfig from vllm_omni.diffusion.data import DiffusionParallelConfig
ulysses_degree = 2 ulysses_degree = 2
@@ -154,8 +171,13 @@ omni = Omni(
parallel_config=DiffusionParallelConfig(ulysses_degree=ulysses_degree) parallel_config=DiffusionParallelConfig(ulysses_degree=ulysses_degree)
) )
outputs = omni.generate(prompt="turn this cat to a dog", outputs = omni.generate(
pil_image=input_image, num_inference_steps=50) {
"prompt": "turn this cat to a dog",
"multi_modal_data": {"image": input_image}
},
OmniDiffusionSamplingParams(num_inference_steps=50),
)
``` ```
### Using Ring-Attention ### Using Ring-Attention
@@ -163,6 +185,7 @@ outputs = omni.generate(prompt="turn this cat to a dog",
Run text-to-image: Run text-to-image:
```python ```python
from vllm_omni import Omni from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.diffusion.data import DiffusionParallelConfig from vllm_omni.diffusion.data import DiffusionParallelConfig
ring_degree = 2 ring_degree = 2
@@ -171,7 +194,10 @@ omni = Omni(
parallel_config=DiffusionParallelConfig(ring_degree=2) parallel_config=DiffusionParallelConfig(ring_degree=2)
) )
outputs = omni.generate(prompt="A cat sitting on a windowsill", num_inference_steps=50, width=2048, height=2048) outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(num_inference_steps=50, width=2048, height=2048),
)
``` ```
### Using CFG-Parallel ### Using CFG-Parallel
@@ -182,6 +208,7 @@ CFG-Parallel splits the CFG positive/negative branches across GPUs. Use it when
```python ```python
from vllm_omni import Omni from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.diffusion.data import DiffusionParallelConfig from vllm_omni.diffusion.data import DiffusionParallelConfig
cfg_parallel_size = 2 cfg_parallel_size = 2
@@ -190,8 +217,13 @@ omni = Omni(
parallel_config=DiffusionParallelConfig(cfg_parallel_size=cfg_parallel_size) parallel_config=DiffusionParallelConfig(cfg_parallel_size=cfg_parallel_size)
) )
outputs = omni.generate(prompt="turn this cat to a dog", outputs = omni.generate(
pil_image=input_image, num_inference_steps=50, true_cfg_scale=4.0) {
"prompt": "turn this cat to a dog",
"multi_modal_data": {"image": input_image}
},
OmniDiffusionSamplingParams(num_inference_steps=50, true_cfg_scale=4.0),
)
``` ```
## Documentation ## Documentation
@@ -0,0 +1,107 @@
# LoRA-Inference
Source <https://github.com/vllm-project/vllm-omni/tree/main/examples/offline_inference/lora_inference>.
This contains examples for using LoRA (Low-Rank Adaptation) adapters with vLLM-omni diffusion models for offline inference.
The example uses the `stabilityai/stable-diffusion-3.5-medium` as the default model, but you can replace it with other models in vLLM-omni.
## Overview
Similar to vLLM, vLLM-omni uses a unified LoRA handling mechanism:
- **Pre-loaded LoRA**: Loaded at initialization via `--lora-path` (pre-loaded into cache)
- **Per-request LoRA**: Loaded on-demand. In the example, the LoRA is loaded via `--lora-request-path` in each request
Both approaches use the same underlying mechanism - all LoRA adapters are handled uniformly through `set_active_adapter()`. If no LoRA request is provided in a request, all adapters are deactivated.
## Usage
### Pre-loaded LoRA (via --lora-path)
Load a LoRA adapter at initialization. This adapter is pre-loaded into the cache and can be activated by requests:
```bash
python -m examples.offline_inference.lora_inference.lora_inference \
--prompt "A piece of cheesecake" \
--lora-path /path/to/lora/ \
--lora-scale 1.0 \
--num_inference_steps 50 \
--height 1024 \
--width 1024 \
--output output_preloaded.png
```
**Note**: When using `--lora-path`, the adapter is loaded at init time with a stable ID derived from the adapter path. This example activates it automatically for the request.
### Per-request LoRA (via --lora-request-path)
Load a LoRA adapter on-demand for each request:
```bash
python -m examples.offline_inference.lora_inference.lora_inference \
--prompt "A piece of cheesecake" \
--lora-request-path /path/to/lora/ \
--lora-scale 1.0 \
--num_inference_steps 50 \
--height 1024 \
--width 1024 \
--output output_per_request.png
```
### No LoRA
If no LoRA request is provided, we will use the base model without any LoRA adapters:
```bash
python -m examples.offline_inference.lora_inference.lora_inference \
--prompt "A piece of cheesecake" \
--num_inference_steps 50 \
--height 1024 \
--width 1024 \
--output output_no_lora.png
```
## Parameters
### LoRA Parameters
- `--lora-path`: Path to LoRA adapter folder to pre-load at initialization (loads into cache with a stable ID derived from the path)
- `--lora-request-path`: Path to LoRA adapter folder for per-request loading
- `--lora-request-id`: Integer ID for the LoRA adapter (optional). If not provided and `--lora-request-path` is set, will derive a stable ID from the path.
- `--lora-scale`: Scale factor for LoRA weights (default: 1.0). Higher values increase the influence of the LoRA adapter.
### Standard Parameters
- `--prompt`: Text prompt for image generation (required)
- `--seed`: Random seed for reproducibility (default: 42)
- `--height`: Image height in pixels (default: 1024)
- `--width`: Image width in pixels (default: 1024)
- `--num_inference_steps`: Number of denoising steps (default: 50)
- `--output`: Output file path (default: `lora_output.png`)
## How LoRA Works
All LoRA adapters are handled uniformly:
1. **Initialization**: If `--lora-path` is provided, the adapter is loaded into cache with a stable ID derived from the adapter path
2. **Per-request**: If `--lora-request-path` is provided, the adapter is loaded/activated for that request
3. **No LoRA**: If no LoRA request is provided (`req.lora_request` is None), all adapters are deactivated
The system uses LRU cache management - adapters are cached and evicted when the cache is full (unless pinned).
## LoRA Adapter Format
LoRA adapters must be in PEFT (Parameter-Efficient Fine-Tuning) format. A typical LoRA adapter directory structure:
```
lora_adapter/
├── adapter_config.json
└── adapter_model.safetensors
```
## Example materials
??? abstract "lora_inference.py"
``````py
--8<-- "examples/offline_inference/lora_inference/lora_inference.py"
``````
@@ -23,7 +23,7 @@ if __name__ == "__main__":
images[0].save("coffee.png") images[0].save("coffee.png")
``` ```
Or put more than one prompt in a request, processing them sequentially. Or put more than one prompt in a request.
```python ```python
from vllm_omni.entrypoints.omni import Omni from vllm_omni.entrypoints.omni import Omni
@@ -40,6 +40,41 @@ if __name__ == "__main__":
image = output.request_output[0].images[0].save(f"{i}.jpg") image = output.request_output[0].images[0].save(f"{i}.jpg")
``` ```
!!! info
However, it is not currently recommended to do so
because not all models support batch inference,
and batch requesting mostly does not provide significant performance improvement (despite the impression that it does).
This feature is primarily for the sake of interface compatibility with vLLM and to allow for future improvements.
!!! info
For diffusion pipelines, the stage config field `stage_args.[].runtime.max_batch_size` is 1 by default, and the input
list is sliced into single-item requests before feeding into the diffusion pipeline. For models that do internally support
batched inputs, you can [modify this configuration](../../../configuration/stage_configs.md) to let the model accept a longer batch of prompts.
Apart from string prompt, vLLM-Omni also supports dictionary prompts in the same style as vLLM.
This is useful for models that support negative prompts.
```python
from vllm_omni.entrypoints.omni import Omni
if __name__ == "__main__":
omni = Omni(model="Qwen/Qwen-Image")
outputs = omni.generate([
{
"prompt": "a cup of coffee on a table",
"negative_prompt": "low resolution"
},
{
"prompt": "a toy dinosaur on a sandy beach",
"negative_prompt": "cinematic, realistic"
}
])
for i, output in enumerate(outputs):
image = output.request_output[0].images[0].save(f"{i}.jpg")
```
## Local CLI Usage ## Local CLI Usage
```bash ```bash
@@ -0,0 +1,69 @@
# LoRA-Inference
Source <https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/lora_inference>.
This example shows how to use **per-request LoRA** with vLLM-Omni diffusion models via the OpenAI-compatible Chat Completions API.
> Note: The LoRA adapter path must be readable on the **server** machine (usually a local path or a mounted directory).
> Note: This example uses `/v1/chat/completions`. LoRA payloads for other OpenAI endpoints are not implemented here.
## Start Server
```bash
# Pick a diffusion model (examples)
# export MODEL=stabilityai/stable-diffusion-3.5-medium
# export MODEL=Qwen/Qwen-Image
bash run_server.sh
```
## Call API (curl)
```bash
# Required: local LoRA folder on the server
export LORA_PATH=/path/to/lora_adapter
# Optional
export SERVER=http://localhost:8091
export PROMPT="A piece of cheesecake"
export LORA_NAME=my_lora
export LORA_SCALE=1.0
# Optional: if omitted, the server derives a stable id from LORA_PATH.
# export LORA_INT_ID=123
bash run_curl_lora_inference.sh
```
## Call API (Python)
```bash
python openai_chat_client.py \
--prompt "A piece of cheesecake" \
--lora-path /path/to/lora_adapter \
--lora-name my_lora \
--lora-scale 1.0 \
--output output.png
```
## LoRA Format
LoRA adapters should be in PEFT format, for example:
```
lora_adapter/
├── adapter_config.json
└── adapter_model.safetensors
```
??? abstract "openai_chat_client.py"
``````py
--8<-- "examples/online_serving/lora_inference/openai_chat_client.py"
``````
??? abstract "run_curl_lora_inference.sh"
``````py
--8<-- "examples/online_serving/lora_inference/run_curl_lora_inference.sh"
``````
??? abstract "run_server.sh"
``````py
--8<-- "examples/online_serving/lora_inference/run_server.sh"
``````
@@ -15,6 +15,12 @@ Please refer to [README.md](https://github.com/vllm-project/vllm-omni/tree/main/
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
``` ```
If you want to open async chunking for qwen3-omni, launch the server with command below
```bash
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 --stage-configs-path /vllm_omni/model_executor/stage_configs/qwen3_omni_moe_async_chunk.yaml
```
If you have custom stage configs file, launch the server with command below If you have custom stage configs file, launch the server with command below
```bash ```bash
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 --stage-configs-path /path/to/stage_configs_file vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 --stage-configs-path /path/to/stage_configs_file
+29 -19
View File
@@ -1,5 +1,8 @@
import argparse import argparse
import os import os
from typing import cast
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniPromptType
def parse_args(): def parse_args():
@@ -50,21 +53,24 @@ def parse_args():
def main(): def main():
args = parse_args() args = parse_args()
model_name = args.model model_name = args.model
prompts: list[OmniPromptType] = []
try: try:
# Preferred: load from txt file (one prompt per line) # Preferred: load from txt file (one prompt per line)
if getattr(args, "txt_prompts", None) and args.prompt_type == "text": if getattr(args, "txt_prompts", None) and args.prompt_type == "text":
with open(args.txt_prompts, encoding="utf-8") as f: with open(args.txt_prompts, encoding="utf-8") as f:
lines = [ln.strip() for ln in f.readlines()] lines = [ln.strip() for ln in f.readlines()]
args.prompts = [ln for ln in lines if ln != ""] prompts = [ln for ln in lines if ln != ""]
print(f"[Info] Loaded {len(args.prompts)} prompts from {args.txt_prompts}") print(f"[Info] Loaded {len(prompts)} prompts from {args.txt_prompts}")
else:
prompts = args.prompts
except Exception as e: except Exception as e:
print(f"[Error] Failed to load prompts: {e}") print(f"[Error] Failed to load prompts: {e}")
raise raise
if args.prompts is None: if not prompts:
# Default prompt for text2img test if none provided # Default prompt for text2img test if none provided
args.prompts = ["<|im_start|>A cute cat<|im_end|>"] prompts = ["<|im_start|>A cute cat<|im_end|>"]
print(f"[Info] No prompts provided, using default: {args.prompts}") print(f"[Info] No prompts provided, using default: {prompts}")
omni_outputs = [] omni_outputs = []
from PIL import Image from PIL import Image
@@ -77,21 +83,27 @@ def main():
print("[Info] Running in img2img mode (Stage 1 only)") print("[Info] Running in img2img mode (Stage 1 only)")
client = OmniDiffusion(model=model_name) client = OmniDiffusion(model=model_name)
generate_kwargs = {
"prompt": args.prompts,
"seed": 52,
"need_kv_receive": False,
"num_inference_steps": args.steps,
}
if args.image_path: if args.image_path:
if os.path.exists(args.image_path): if os.path.exists(args.image_path):
loaded_image = Image.open(args.image_path).convert("RGB") loaded_image = Image.open(args.image_path).convert("RGB")
generate_kwargs["pil_image"] = loaded_image prompts = [
{
"prompt": cast(str, p),
"multi_modal_data": {"image": loaded_image},
}
for p in prompts
]
else: else:
print(f"[Warning] Image path {args.image_path} does not exist.") print(f"[Warning] Image path {args.image_path} does not exist.")
result = client.generate(**generate_kwargs) result = client.generate(
prompts,
OmniDiffusionSamplingParams(
seed=52,
need_kv_receive=False,
num_inference_steps=args.steps,
),
)
# Ensure result is a list for iteration # Ensure result is a list for iteration
if not isinstance(result, list): if not isinstance(result, list):
@@ -100,8 +112,6 @@ def main():
omni_outputs = result omni_outputs = result
else: else:
import copy
from vllm_omni.entrypoints.omni import Omni from vllm_omni.entrypoints.omni import Omni
omni_kwargs = {} omni_kwargs = {}
@@ -144,11 +154,11 @@ def main():
prompt_dict = {"prompt": final_prompt_text, "modalities": ["image"]} prompt_dict = {"prompt": final_prompt_text, "modalities": ["image"]}
formatted_prompts.append(prompt_dict) formatted_prompts.append(prompt_dict)
params_list = copy.deepcopy(omni.default_sampling_params_list) params_list = omni.default_sampling_params_list
if args.modality == "text2img": if args.modality == "text2img":
params_list[0]["max_tokens"] = 1 params_list[0].max_tokens = 1 # type: ignore # The first stage is a SamplingParam (vllm)
if len(params_list) > 1: if len(params_list) > 1:
params_list[1]["num_inference_steps"] = args.steps params_list[1].num_inference_steps = args.steps # type: ignore # The second stage is an OmniDiffusionSamplingParam
omni_outputs = list(omni.generate(prompts=formatted_prompts, sampling_params_list=params_list)) omni_outputs = list(omni.generate(prompts=formatted_prompts, sampling_params_list=params_list))
@@ -79,6 +79,7 @@ from PIL import Image
from vllm_omni.diffusion.data import DiffusionParallelConfig, logger from vllm_omni.diffusion.data import DiffusionParallelConfig, logger
from vllm_omni.entrypoints.omni import Omni from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.utils.platform_utils import detect_device_type, is_npu from vllm_omni.utils.platform_utils import detect_device_type, is_npu
@@ -279,6 +280,11 @@ def parse_args() -> argparse.Namespace:
action="store_true", action="store_true",
help="Disable torch.compile and force eager execution.", help="Disable torch.compile and force eager execution.",
) )
parser.add_argument(
"--enable-cpu-offload",
action="store_true",
help="Enable CPU offloading for diffusion models.",
)
return parser.parse_args() return parser.parse_args()
@@ -344,6 +350,7 @@ def main():
cache_config=cache_config, cache_config=cache_config,
parallel_config=parallel_config, parallel_config=parallel_config,
enforce_eager=args.enforce_eager, enforce_eager=args.enforce_eager,
enable_cpu_offload=args.enable_cpu_offload,
) )
print("Pipeline loaded") print("Pipeline loaded")
@@ -368,25 +375,28 @@ def main():
print(f"{'=' * 60}\n") print(f"{'=' * 60}\n")
generation_start = time.perf_counter() generation_start = time.perf_counter()
# Generate edited image
generate_kwargs = {
"prompt": args.prompt,
"pil_image": input_image,
"negative_prompt": args.negative_prompt,
"generator": generator,
"true_cfg_scale": args.cfg_scale,
"guidance_scale": args.guidance_scale,
"num_inference_steps": args.num_inference_steps,
"num_outputs_per_prompt": args.num_outputs_per_prompt,
"layers": args.layers,
"resolution": args.resolution,
}
if profiler_enabled: if profiler_enabled:
print("[Profiler] Starting profiling...") print("[Profiler] Starting profiling...")
omni.start_profile() omni.start_profile()
outputs = omni.generate(**generate_kwargs) # Generate edited image
outputs = omni.generate(
{
"prompt": args.prompt,
"negative_prompt": args.negative_prompt,
"multi_modal_data": {"image": input_image},
},
OmniDiffusionSamplingParams(
generator=generator,
true_cfg_scale=args.cfg_scale,
guidance_scale=args.guidance_scale,
num_inference_steps=args.num_inference_steps,
num_outputs_per_prompt=args.num_outputs_per_prompt,
layers=args.layers,
resolution=args.resolution,
),
)
generation_end = time.perf_counter() generation_end = time.perf_counter()
generation_time = generation_end - generation_start generation_time = generation_end - generation_start
@@ -47,3 +47,4 @@ Key arguments:
- `--guidance_scale`: guidance scale for guidance-distilled models (default: 1.0, disabled). Unlike classifier-free guidance (--cfg_scale), guidance-distilled models take the guidance scale directly as an input parameter. Enabled when guidance_scale > 1. Ignored when not using guidance-distilled models. - `--guidance_scale`: guidance scale for guidance-distilled models (default: 1.0, disabled). Unlike classifier-free guidance (--cfg_scale), guidance-distilled models take the guidance scale directly as an input parameter. Enabled when guidance_scale > 1. Ignored when not using guidance-distilled models.
- `--num_inference_steps`: diffusion sampling steps (more steps = higher quality, slower). - `--num_inference_steps`: diffusion sampling steps (more steps = higher quality, slower).
- `--output`: path to save the generated PNG. - `--output`: path to save the generated PNG.
- `--enable-cpu-offload`: enable CPU offloading for diffusion models.
@@ -54,3 +54,4 @@ Key arguments:
- `--num_inference_steps`: Number of denoising steps (default 50). - `--num_inference_steps`: Number of denoising steps (default 50).
- `--fps`: Frames per second for the saved MP4 (requires `diffusers` export_to_video). - `--fps`: Frames per second for the saved MP4 (requires `diffusers` export_to_video).
- `--output`: Path to save the generated video. - `--output`: Path to save the generated video.
- `--enable-cpu-offload`: enable CPU offloading for diffusion models.
@@ -27,6 +27,7 @@ import PIL.Image
import torch import torch
from vllm_omni.entrypoints.omni import Omni from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.utils.platform_utils import detect_device_type, is_npu from vllm_omni.utils.platform_utils import detect_device_type, is_npu
@@ -58,6 +59,11 @@ def parse_args() -> argparse.Namespace:
) )
parser.add_argument("--output", type=str, default="i2v_output.mp4", help="Path to save the video (mp4).") parser.add_argument("--output", type=str, default="i2v_output.mp4", help="Path to save the video (mp4).")
parser.add_argument("--fps", type=int, default=16, help="Frames per second for the output video.") parser.add_argument("--fps", type=int, default=16, help="Frames per second for the output video.")
parser.add_argument(
"--enable-cpu-offload",
action="store_true",
help="Enable CPU offloading for diffusion models.",
)
return parser.parse_args() return parser.parse_args()
@@ -105,6 +111,7 @@ def main():
vae_use_tiling=vae_use_tiling, vae_use_tiling=vae_use_tiling,
boundary_ratio=args.boundary_ratio, boundary_ratio=args.boundary_ratio,
flow_shift=args.flow_shift, flow_shift=args.flow_shift,
enable_cpu_offload=args.enable_cpu_offload,
) )
if profiler_enabled: if profiler_enabled:
@@ -113,16 +120,20 @@ def main():
# omni.generate() returns Generator[OmniRequestOutput, None, None] # omni.generate() returns Generator[OmniRequestOutput, None, None]
frames = omni.generate( frames = omni.generate(
args.prompt, {
negative_prompt=args.negative_prompt, "prompt": args.prompt,
pil_image=image, "negative_prompt": args.negative_prompt,
height=height, "multi_modal_data": {"image": image},
width=width, },
generator=generator, OmniDiffusionSamplingParams(
guidance_scale=args.guidance_scale, height=height,
guidance_scale_2=args.guidance_scale_high, width=width,
num_inference_steps=args.num_inference_steps, generator=generator,
num_frames=args.num_frames, guidance_scale=args.guidance_scale,
guidance_scale_2=args.guidance_scale_high,
num_inference_steps=args.num_inference_steps,
num_frames=args.num_frames,
),
) )
# Extract video frames from OmniRequestOutput # Extract video frames from OmniRequestOutput
@@ -0,0 +1,98 @@
# LoRA Inference Examples
This directory contains examples for using LoRA (Low-Rank Adaptation) adapters with vLLM-omni diffusion models for offline inference.
The example uses the `stabilityai/stable-diffusion-3.5-medium` as the default model, but you can replace it with other models in vLLM-omni.
## Overview
Similar to vLLM, vLLM-omni uses a unified LoRA handling mechanism:
- **Pre-loaded LoRA**: Loaded at initialization via `--lora-path` (pre-loaded into cache)
- **Per-request LoRA**: Loaded on-demand. In the example, the LoRA is loaded via `--lora-request-path` in each request
Both approaches use the same underlying mechanism - all LoRA adapters are handled uniformly through `set_active_adapter()`. If no LoRA request is provided in a request, all adapters are deactivated.
## Usage
### Pre-loaded LoRA (via --lora-path)
Load a LoRA adapter at initialization. This adapter is pre-loaded into the cache and can be activated by requests:
```bash
python -m examples.offline_inference.lora_inference.lora_inference \
--prompt "A piece of cheesecake" \
--lora-path /path/to/lora/ \
--lora-scale 1.0 \
--num_inference_steps 50 \
--height 1024 \
--width 1024 \
--output output_preloaded.png
```
**Note**: When using `--lora-path`, the adapter is loaded at init time with a stable ID derived from the adapter path. This example activates it automatically for the request.
### Per-request LoRA (via --lora-request-path)
Load a LoRA adapter on-demand for each request:
```bash
python -m examples.offline_inference.lora_inference.lora_inference \
--prompt "A piece of cheesecake" \
--lora-request-path /path/to/lora/ \
--lora-scale 1.0 \
--num_inference_steps 50 \
--height 1024 \
--width 1024 \
--output output_per_request.png
```
### No LoRA
If no LoRA request is provided, we will use the base model without any LoRA adapters:
```bash
python -m examples.offline_inference.lora_inference.lora_inference \
--prompt "A piece of cheesecake" \
--num_inference_steps 50 \
--height 1024 \
--width 1024 \
--output output_no_lora.png
```
## Parameters
### LoRA Parameters
- `--lora-path`: Path to LoRA adapter folder to pre-load at initialization (loads into cache with a stable ID derived from the path)
- `--lora-request-path`: Path to LoRA adapter folder for per-request loading
- `--lora-request-id`: Integer ID for the LoRA adapter (optional). If not provided and `--lora-request-path` is set, will derive a stable ID from the path.
- `--lora-scale`: Scale factor for LoRA weights (default: 1.0). Higher values increase the influence of the LoRA adapter.
### Standard Parameters
- `--prompt`: Text prompt for image generation (required)
- `--seed`: Random seed for reproducibility (default: 42)
- `--height`: Image height in pixels (default: 1024)
- `--width`: Image width in pixels (default: 1024)
- `--num_inference_steps`: Number of denoising steps (default: 50)
- `--output`: Output file path (default: `lora_output.png`)
## How LoRA Works
All LoRA adapters are handled uniformly:
1. **Initialization**: If `--lora-path` is provided, the adapter is loaded into cache with a stable ID derived from the adapter path
2. **Per-request**: If `--lora-request-path` is provided, the adapter is loaded/activated for that request
3. **No LoRA**: If no LoRA request is provided (`req.lora_request` is None), all adapters are deactivated
The system uses LRU cache management - adapters are cached and evicted when the cache is full (unless pinned).
## LoRA Adapter Format
LoRA adapters must be in PEFT (Parameter-Efficient Fine-Tuning) format. A typical LoRA adapter directory structure:
```
lora_adapter/
├── adapter_config.json
└── adapter_model.safetensors
```
@@ -0,0 +1,147 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import argparse
from pathlib import Path
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.lora.request import LoRARequest
from vllm_omni.lora.utils import stable_lora_int_id
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate images with LoRA adapters.")
parser.add_argument("--model", default="stabilityai/stable-diffusion-3.5-medium", help="Model name or path.")
parser.add_argument("--prompt", required=True, help="Text prompt for image generation.")
parser.add_argument("--seed", type=int, default=42, help="Random seed for deterministic results.")
parser.add_argument("--height", type=int, default=1024, help="Height of generated image.")
parser.add_argument("--width", type=int, default=1024, help="Width of generated image.")
parser.add_argument(
"--num_inference_steps",
type=int,
default=50,
help="Number of denoising steps for the diffusion sampler.",
)
parser.add_argument(
"--output",
type=str,
default="lora_output.png",
help="Path to save the generated image (PNG).",
)
parser.add_argument(
"--lora-path",
type=str,
default=None,
help="Path to LoRA adapter folder to pre-load at initialization (PEFT format). "
"Note: pre-loading populates the cache; you still need to pass a lora_request to activate it.",
)
parser.add_argument(
"--lora-request-path",
type=str,
default=None,
help="Path to LoRA adapter folder for per-request activation (dynamic LoRA). "
"If --lora-request-id is not provided, a stable ID will be derived from this path.",
)
parser.add_argument(
"--lora-request-id",
type=int,
default=None,
help="Integer ID for the LoRA adapter (for dynamic LoRA). "
"If not provided and --lora-request-path is set, will derive a stable ID from the path.",
)
parser.add_argument(
"--lora-scale",
type=float,
default=1.0,
help="Scale factor for LoRA weights (default: 1.0).",
)
return parser.parse_args()
def main():
args = parse_args()
model = args.model
omni_kwargs = {}
if args.lora_path:
omni_kwargs["lora_path"] = args.lora_path
print(f"Using static LoRA from: {args.lora_path}")
omni = Omni(model=model, **omni_kwargs)
lora_request = None
if args.lora_request_path:
if args.lora_request_id is None:
lora_request_id = stable_lora_int_id(args.lora_request_path)
else:
lora_request_id = args.lora_request_id
lora_name = Path(args.lora_request_path).stem
lora_request = LoRARequest(
lora_name=lora_name,
lora_int_id=lora_request_id,
lora_path=args.lora_request_path,
)
print(f"Using per-request LoRA: name={lora_name}, id={lora_request_id}, scale={args.lora_scale}")
elif args.lora_path:
# pre-loaded LoRA
lora_request_id = stable_lora_int_id(args.lora_path)
lora_request = LoRARequest(
lora_name="preloaded",
lora_int_id=lora_request_id,
lora_path=args.lora_path,
)
print(f"Activating pre-loaded LoRA: id={lora_request_id}, scale={args.lora_scale}")
sampling_params = OmniDiffusionSamplingParams(
height=args.height,
width=args.width,
num_inference_steps=args.num_inference_steps,
)
if lora_request:
sampling_params.lora_request = lora_request
sampling_params.lora_scale = args.lora_scale
outputs = omni.generate(args.prompt, sampling_params)
if not outputs or len(outputs) == 0:
raise ValueError("No output generated from omni.generate()")
if isinstance(outputs, list):
first_output = outputs[0]
else:
first_output = outputs
images = None
if hasattr(first_output, "images") and first_output.images:
images = first_output.images
elif hasattr(first_output, "request_output") and first_output.request_output:
req_out = first_output.request_output
if isinstance(req_out, list) and len(req_out) > 0:
req_out = req_out[0]
if hasattr(req_out, "images") and req_out.images:
images = req_out.images
if not images:
raise ValueError("No images found in request_output")
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
suffix = output_path.suffix or ".png"
stem = output_path.stem or "lora_output"
if len(images) <= 1:
images[0].save(output_path)
print(f"Saved generated image to {output_path}")
else:
for idx, img in enumerate(images):
save_path = output_path.parent / f"{stem}_{idx}{suffix}"
img.save(save_path)
print(f"Saved generated image to {save_path}")
if __name__ == "__main__":
main()
@@ -13,6 +13,15 @@ Qwen3 TTS provides multiple task variants for speech generation:
## Setup ## Setup
Please refer to the [stage configuration documentation](https://docs.vllm.ai/projects/vllm-omni/en/latest/configuration/stage_configs/) to configure memory allocation appropriately for your hardware setup. Please refer to the [stage configuration documentation](https://docs.vllm.ai/projects/vllm-omni/en/latest/configuration/stage_configs/) to configure memory allocation appropriately for your hardware setup.
### ROCm Dependencies
You will need to install these two dependencies `onnxruntime-rocm` and `sox`.
```
pip uninstall onnxruntime # should be removed before we can install onnxruntime-rocm
pip install onnxruntime-rocm sox
```
## Quick Start ## Quick Start
Run a single sample for a task: Run a single sample for a task:
@@ -0,0 +1,37 @@
# Text-To-Audio
The `stabilityai/stable-audio-open-1.0` pipeline generates audio from text prompts.
## Prerequisites
If you use a gated model (e.g., `stabilityai/stable-audio-open-1.0`), ensure you have access:
1. **Accept Model License**: Visit the model page on Hugging Face (e.g., [stabilityai/stable-audio-open-1.0]) and accept the user agreement.
2. **Authenticate**: Log in to Hugging Face locally to access the gated model.
```bash
huggingface-cli login
```
## Local CLI Usage
```bash
python text_to_audio.py \
--model stabilityai/stable-audio-open-1.0 \
--prompt "The sound of a hammer hitting a wooden surface" \
--negative_prompt "Low quality" \
--seed 42 \
--guidance_scale 7.0 \
--audio_length 10.0 \
--num_inference_steps 100 \
--output stable_audio_output.wav
```
Key arguments:
- `--prompt`: text description (string).
- `--negative_prompt`: negative prompt for classifier-free guidance.
- `--seed`: integer seed for deterministic generation.
- `--guidance_scale`: classifier-free guidance scale.
- `--audio_length`: audio duration in seconds.
- `--num_inference_steps`: diffusion sampling steps.(more steps = higher quality, slower).
- `--output`: path to save the generated WAV file.
@@ -21,6 +21,7 @@ import numpy as np
import torch import torch
from vllm_omni.entrypoints.omni import Omni from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.utils.platform_utils import detect_device_type from vllm_omni.utils.platform_utils import detect_device_type
@@ -142,17 +143,21 @@ def main():
generation_start = time.perf_counter() generation_start = time.perf_counter()
# Generate audio # Generate audio
audio = omni.generate( outputs = omni.generate(
args.prompt, {
negative_prompt=args.negative_prompt, "prompt": args.prompt,
generator=generator, "negative_prompt": args.negative_prompt,
guidance_scale=args.guidance_scale,
num_inference_steps=args.num_inference_steps,
num_outputs_per_prompt=args.num_waveforms,
extra={
"audio_start_in_s": args.audio_start,
"audio_end_in_s": audio_end_in_s,
}, },
OmniDiffusionSamplingParams(
generator=generator,
guidance_scale=args.guidance_scale,
num_inference_steps=args.num_inference_steps,
num_outputs_per_prompt=args.num_waveforms,
extra_args={
"audio_start_in_s": args.audio_start,
"audio_end_in_s": audio_end_in_s,
},
),
) )
generation_end = time.perf_counter() generation_end = time.perf_counter()
@@ -166,6 +171,21 @@ def main():
suffix = output_path.suffix or ".wav" suffix = output_path.suffix or ".wav"
stem = output_path.stem or "stable_audio_output" stem = output_path.stem or "stable_audio_output"
# Extract audio from omni.generate() outputs
if not outputs:
raise ValueError("No output generated from omni.generate()")
output = outputs[0]
if not hasattr(output, "request_output") or not output.request_output:
raise ValueError("No request_output found in OmniRequestOutput")
request_output = output.request_output[0]
if not hasattr(request_output, "multimodal_output"):
raise ValueError("No multimodal_output found in request_output")
audio = request_output.multimodal_output.get("audio")
if audio is None:
raise ValueError("No audio output found in request_output")
# Handle different output formats # Handle different output formats
if isinstance(audio, torch.Tensor): if isinstance(audio, torch.Tensor):
audio = audio.cpu().float().numpy() audio = audio.cpu().float().numpy()
@@ -20,7 +20,7 @@ if __name__ == "__main__":
images[0].save("coffee.png") images[0].save("coffee.png")
``` ```
Or put more than one prompt in a request, processing them sequentially. Or put more than one prompt in a request.
```python ```python
from vllm_omni.entrypoints.omni import Omni from vllm_omni.entrypoints.omni import Omni
@@ -37,6 +37,41 @@ if __name__ == "__main__":
image = output.request_output[0].images[0].save(f"{i}.jpg") image = output.request_output[0].images[0].save(f"{i}.jpg")
``` ```
!!! info
However, it is not currently recommended to do so
because not all models support batch inference,
and batch requesting mostly does not provide significant performance improvement (despite the impression that it does).
This feature is primarily for the sake of interface compatibility with vLLM and to allow for future improvements.
!!! info
For diffusion pipelines, the stage config field `stage_args.[].runtime.max_batch_size` is 1 by default, and the input
list is sliced into single-item requests before feeding into the diffusion pipeline. For models that do internally support
batched inputs, you can [modify this configuration](../../../configuration/stage_configs.md) to let the model accept a longer batch of prompts.
Apart from string prompt, vLLM-Omni also supports dictionary prompts in the same style as vLLM.
This is useful for models that support negative prompts.
```python
from vllm_omni.entrypoints.omni import Omni
if __name__ == "__main__":
omni = Omni(model="Qwen/Qwen-Image")
outputs = omni.generate([
{
"prompt": "a cup of coffee on a table",
"negative_prompt": "low resolution"
},
{
"prompt": "a toy dinosaur on a sandy beach",
"negative_prompt": "cinematic, realistic"
}
])
for i, output in enumerate(outputs):
image = output.request_output[0].images[0].save(f"{i}.jpg")
```
## Local CLI Usage ## Local CLI Usage
```bash ```bash
@@ -61,6 +96,7 @@ Key arguments:
- `--num_inference_steps`: diffusion sampling steps (more steps = higher quality, slower). - `--num_inference_steps`: diffusion sampling steps (more steps = higher quality, slower).
- `--height/--width`: output resolution (defaults 1024x1024). - `--height/--width`: output resolution (defaults 1024x1024).
- `--output`: path to save the generated PNG. - `--output`: path to save the generated PNG.
- `--enable-cpu-offload`: enable CPU offloading for diffusion models.
> ℹ️ Qwen-Image currently publishes best-effort presets at `1328x1328`, `1664x928`, `928x1664`, `1472x1140`, `1140x1472`, `1584x1056`, and `1056x1584`. Adjust `--height/--width` accordingly for the most reliable outcomes. > ℹ️ Qwen-Image currently publishes best-effort presets at `1328x1328`, `1664x928`, `928x1664`, `1472x1140`, `1140x1472`, `1584x1056`, and `1056x1584`. Adjust `--height/--width` accordingly for the most reliable outcomes.
@@ -5,6 +5,7 @@ import gradio as gr
import torch import torch
from vllm_omni.entrypoints.omni import Omni from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.utils.platform_utils import detect_device_type, is_npu from vllm_omni.utils.platform_utils import detect_device_type, is_npu
@@ -101,12 +102,14 @@ def build_demo(args: argparse.Namespace) -> gr.Blocks:
generator = torch.Generator(device=device).manual_seed(seed) generator = torch.Generator(device=device).manual_seed(seed)
outputs = omni.generate( outputs = omni.generate(
prompt.strip(), prompt.strip(),
height=height, OmniDiffusionSamplingParams(
width=width, height=height,
generator=generator, width=width,
true_cfg_scale=float(cfg_scale_value), generator=generator,
num_inference_steps=num_steps, true_cfg_scale=float(cfg_scale_value),
num_outputs_per_prompt=num_images, num_inference_steps=num_steps,
num_outputs_per_prompt=num_images,
),
) )
images_outputs = [] images_outputs = []
for output in outputs: for output in outputs:
@@ -10,6 +10,7 @@ import torch
from vllm_omni.diffusion.data import DiffusionParallelConfig, logger from vllm_omni.diffusion.data import DiffusionParallelConfig, logger
from vllm_omni.entrypoints.omni import Omni from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.utils.platform_utils import detect_device_type, is_npu from vllm_omni.utils.platform_utils import detect_device_type, is_npu
@@ -96,6 +97,11 @@ def parse_args() -> argparse.Namespace:
action="store_true", action="store_true",
help="Disable torch.compile and force eager execution.", help="Disable torch.compile and force eager execution.",
) )
parser.add_argument(
"--enable-cpu-offload",
action="store_true",
help="Enable CPU offloading for diffusion models.",
)
parser.add_argument( parser.add_argument(
"--tensor_parallel_size", "--tensor_parallel_size",
type=int, type=int,
@@ -162,6 +168,7 @@ def main():
cache_config=cache_config, cache_config=cache_config,
parallel_config=parallel_config, parallel_config=parallel_config,
enforce_eager=args.enforce_eager, enforce_eager=args.enforce_eager,
enable_cpu_offload=args.enable_cpu_offload,
) )
if profiler_enabled: if profiler_enabled:
@@ -183,15 +190,19 @@ def main():
generation_start = time.perf_counter() generation_start = time.perf_counter()
outputs = omni.generate( outputs = omni.generate(
args.prompt, {
negative_prompt=args.negative_prompt, "prompt": args.prompt,
height=args.height, "negative_prompt": args.negative_prompt,
width=args.width, },
generator=generator, OmniDiffusionSamplingParams(
true_cfg_scale=args.cfg_scale, height=args.height,
guidance_scale=args.guidance_scale, width=args.width,
num_inference_steps=args.num_inference_steps, generator=generator,
num_outputs_per_prompt=args.num_images_per_prompt, true_cfg_scale=args.cfg_scale,
guidance_scale=args.guidance_scale,
num_inference_steps=args.num_inference_steps,
num_outputs_per_prompt=args.num_images_per_prompt,
),
) )
generation_end = time.perf_counter() generation_end = time.perf_counter()
generation_time = generation_end - generation_start generation_time = generation_end - generation_start
@@ -9,10 +9,11 @@ python text_to_video.py \
--prompt "Two anthropomorphic cats in comfy boxing gear and bright gloves fight intensely on a spotlighted stage." \ --prompt "Two anthropomorphic cats in comfy boxing gear and bright gloves fight intensely on a spotlighted stage." \
--negative_prompt "<optional quality filter>" \ --negative_prompt "<optional quality filter>" \
--height 480 \ --height 480 \
--width 640 \ --width 832 \
--num_frames 33 \ --num_frames 33 \
--guidance_scale 4.0 \ --guidance_scale 4.0 \
--guidance_scale_high 3.0 \ --guidance_scale_high 3.0 \
--flow_shift 12.0 \
--num_inference_steps 40 \ --num_inference_steps 40 \
--fps 16 \ --fps 16 \
--output t2v_out.mp4 --output t2v_out.mp4
@@ -21,10 +22,11 @@ python text_to_video.py \
Key arguments: Key arguments:
- `--prompt`: text description (string). - `--prompt`: text description (string).
- `--height/--width`: output resolution (defaults 720x1280). Dimensions should align with Wan VAE downsampling (multiples of 8). - `--height/--width`: output resolution (defaults 480x832, i.e. 480P). Dimensions should align with Wan VAE downsampling (multiples of 8).
- `--num_frames`: Number of frames (Wan default is 81). - `--num_frames`: Number of frames (Wan default is 81).
- `--guidance_scale` and `--guidance_scale_high`: CFG scale (applied to low/high).. - `--guidance_scale` and `--guidance_scale_high`: CFG scale (applied to low/high).
- `--negative_prompt`: optional list of artifacts to suppress (the PR demo used a long Chinese string). - `--negative_prompt`: optional list of artifacts to suppress (the PR demo used a long Chinese string).
- `--boundary_ratio`: Boundary split ratio for low/high DiT. - `--boundary_ratio`: Boundary split ratio for low/high DiT.
- `--fps`: frames per second for the saved MP4 (requires `diffusers` export_to_video). - `--fps`: frames per second for the saved MP4 (requires `diffusers` export_to_video).
- `--output`: path to save the generated video. - `--output`: path to save the generated video.
- `--enable-cpu-offload`: enable CPU offloading for diffusion models.
@@ -3,12 +3,15 @@
import argparse import argparse
import os import os
import time
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
import torch import torch
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.entrypoints.omni import Omni from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.utils.platform_utils import detect_device_type, is_npu from vllm_omni.utils.platform_utils import detect_device_type, is_npu
@@ -35,6 +38,28 @@ def parse_args() -> argparse.Namespace:
) )
parser.add_argument("--output", type=str, default="wan22_output.mp4", help="Path to save the video (mp4).") parser.add_argument("--output", type=str, default="wan22_output.mp4", help="Path to save the video (mp4).")
parser.add_argument("--fps", type=int, default=24, help="Frames per second for the output video.") parser.add_argument("--fps", type=int, default=24, help="Frames per second for the output video.")
parser.add_argument(
"--enable-cpu-offload",
action="store_true",
help="Enable CPU offloading for diffusion models.",
)
parser.add_argument(
"--ulysses_degree",
type=int,
default=1,
help="Number of GPUs used for ulysses sequence parallelism.",
)
parser.add_argument(
"--ring_degree",
type=int,
default=1,
help="Number of GPUs used for ring sequence parallelism.",
)
parser.add_argument(
"--enforce_eager",
action="store_true",
help="Disable torch.compile and force eager execution.",
)
return parser.parse_args() return parser.parse_args()
@@ -47,6 +72,13 @@ def main():
vae_use_slicing = is_npu() vae_use_slicing = is_npu()
vae_use_tiling = is_npu() vae_use_tiling = is_npu()
# Configure parallel settings (only SP is supported for Wan)
# Note: cfg_parallel and tensor_parallel are not implemented for Wan models
parallel_config = DiffusionParallelConfig(
ulysses_degree=args.ulysses_degree,
ring_degree=args.ring_degree,
)
# Check if profiling is requested via environment variable # Check if profiling is requested via environment variable
profiler_enabled = bool(os.getenv("VLLM_TORCH_PROFILER_DIR")) profiler_enabled = bool(os.getenv("VLLM_TORCH_PROFILER_DIR"))
@@ -56,23 +88,46 @@ def main():
vae_use_tiling=vae_use_tiling, vae_use_tiling=vae_use_tiling,
boundary_ratio=args.boundary_ratio, boundary_ratio=args.boundary_ratio,
flow_shift=args.flow_shift, flow_shift=args.flow_shift,
enable_cpu_offload=args.enable_cpu_offload,
parallel_config=parallel_config,
enforce_eager=args.enforce_eager,
) )
if profiler_enabled: if profiler_enabled:
print("[Profiler] Starting profiling...") print("[Profiler] Starting profiling...")
omni.start_profile() omni.start_profile()
# Print generation configuration
print(f"\n{'=' * 60}")
print("Generation Configuration:")
print(f" Model: {args.model}")
print(f" Inference steps: {args.num_inference_steps}")
print(f" Frames: {args.num_frames}")
print(f" Parallel configuration: ulysses_degree={args.ulysses_degree}, ring_degree={args.ring_degree}")
print(f" Video size: {args.width}x{args.height}")
print(f"{'=' * 60}\n")
generation_start = time.perf_counter()
frames = omni.generate( frames = omni.generate(
args.prompt, {
negative_prompt=args.negative_prompt, "prompt": args.prompt,
height=args.height, "negative_prompt": args.negative_prompt,
width=args.width, },
generator=generator, OmniDiffusionSamplingParams(
guidance_scale=args.guidance_scale, height=args.height,
guidance_scale_2=args.guidance_scale_high, width=args.width,
num_inference_steps=args.num_inference_steps, generator=generator,
num_frames=args.num_frames, guidance_scale=args.guidance_scale,
guidance_scale_2=args.guidance_scale_high,
num_inference_steps=args.num_inference_steps,
num_frames=args.num_frames,
),
) )
generation_end = time.perf_counter()
generation_time = generation_end - generation_start
# Print profiling results
print(f"Total generation time: {generation_time:.4f} seconds ({generation_time * 1000:.2f} ms)")
# Extract video frames from OmniRequestOutput # Extract video frames from OmniRequestOutput
if isinstance(frames, list) and len(frames) > 0: if isinstance(frames, list) and len(frames) > 0:
@@ -0,0 +1,54 @@
# Online LoRA Inference (Diffusion)
This example shows how to use **per-request LoRA** with vLLM-Omni diffusion models via the OpenAI-compatible Chat Completions API.
> Note: The LoRA adapter path must be readable on the **server** machine (usually a local path or a mounted directory).
> Note: This example uses `/v1/chat/completions`. LoRA payloads for other OpenAI endpoints are not implemented here.
## Start Server
```bash
# Pick a diffusion model (examples)
# export MODEL=stabilityai/stable-diffusion-3.5-medium
# export MODEL=Qwen/Qwen-Image
bash run_server.sh
```
## Call API (curl)
```bash
# Required: local LoRA folder on the server
export LORA_PATH=/path/to/lora_adapter
# Optional
export SERVER=http://localhost:8091
export PROMPT="A piece of cheesecake"
export LORA_NAME=my_lora
export LORA_SCALE=1.0
# Optional: if omitted, the server derives a stable id from LORA_PATH.
# export LORA_INT_ID=123
bash run_curl_lora_inference.sh
```
## Call API (Python)
```bash
python openai_chat_client.py \
--prompt "A piece of cheesecake" \
--lora-path /path/to/lora_adapter \
--lora-name my_lora \
--lora-scale 1.0 \
--output output.png
```
## LoRA Format
LoRA adapters should be in PEFT format, for example:
```
lora_adapter/
├── adapter_config.json
└── adapter_model.safetensors
```
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""
OpenAI-compatible chat client for diffusion LoRA inference.
Example:
python openai_chat_client.py \
--server http://localhost:8091 \
--prompt "A piece of cheesecake" \
--lora-path /path/to/lora_adapter \
--lora-name my_lora \
--lora-scale 1.0 \
--output output.png
"""
import argparse
import base64
from pathlib import Path
import requests
def generate_image(
*,
prompt: str,
server_url: str,
height: int | None,
width: int | None,
num_inference_steps: int | None,
seed: int | None,
lora_name: str | None,
lora_path: str | None,
lora_scale: float | None,
lora_int_id: int | None,
) -> bytes | None:
messages = [{"role": "user", "content": prompt}]
extra_body: dict = {}
if height is not None:
extra_body["height"] = height
if width is not None:
extra_body["width"] = width
if num_inference_steps is not None:
extra_body["num_inference_steps"] = num_inference_steps
if seed is not None:
extra_body["seed"] = seed
if lora_path:
lora_body: dict = {
"local_path": lora_path,
"name": lora_name or Path(lora_path).stem,
}
if lora_scale is not None:
lora_body["scale"] = float(lora_scale)
if lora_int_id is not None:
lora_body["int_id"] = int(lora_int_id)
extra_body["lora"] = lora_body
payload = {"messages": messages}
if extra_body:
payload["extra_body"] = extra_body
response = requests.post(
f"{server_url}/v1/chat/completions",
headers={"Content-Type": "application/json"},
json=payload,
timeout=300,
)
response.raise_for_status()
data = response.json()
content = data["choices"][0]["message"]["content"]
if isinstance(content, list) and content:
image_url = content[0].get("image_url", {}).get("url", "")
if image_url.startswith("data:image"):
_, b64_data = image_url.split(",", 1)
return base64.b64decode(b64_data)
raise RuntimeError(f"Unexpected response format: {content!r}")
def main() -> None:
parser = argparse.ArgumentParser(description="Diffusion LoRA OpenAI chat client")
parser.add_argument("--server", default="http://localhost:8091", help="Server URL")
parser.add_argument("--prompt", default="A piece of cheesecake", help="Text prompt")
parser.add_argument("--output", default="lora_online_output.png", help="Output image path")
parser.add_argument("--height", type=int, default=1024, help="Image height")
parser.add_argument("--width", type=int, default=1024, help="Image width")
parser.add_argument("--steps", type=int, default=50, help="num_inference_steps")
parser.add_argument("--seed", type=int, default=42, help="Random seed")
parser.add_argument("--lora-path", default=None, help="Server-local LoRA adapter folder (PEFT format)")
parser.add_argument("--lora-name", default=None, help="LoRA name (optional)")
parser.add_argument("--lora-scale", type=float, default=1.0, help="LoRA scale")
parser.add_argument(
"--lora-int-id",
type=int,
default=None,
help="LoRA integer id (cache key). If omitted, the server derives a stable id from lora_path.",
)
args = parser.parse_args()
image_bytes = generate_image(
prompt=args.prompt,
server_url=args.server,
height=args.height,
width=args.width,
num_inference_steps=args.steps,
seed=args.seed,
lora_name=args.lora_name,
lora_path=args.lora_path,
lora_scale=args.lora_scale if args.lora_path else None,
lora_int_id=args.lora_int_id if args.lora_path else None,
)
out_path = Path(args.output)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(image_bytes)
print(f"Saved: {out_path} ({len(image_bytes) / 1024:.1f} KiB)")
if __name__ == "__main__":
main()
@@ -0,0 +1,61 @@
#!/bin/bash
# Online diffusion LoRA inference via OpenAI-compatible chat API.
SERVER="${SERVER:-http://localhost:8091}"
PROMPT="${PROMPT:-A piece of cheesecake}"
LORA_PATH="${LORA_PATH:-}"
LORA_NAME="${LORA_NAME:-lora}"
LORA_SCALE="${LORA_SCALE:-1.0}"
LORA_INT_ID="${LORA_INT_ID:-}"
HEIGHT="${HEIGHT:-1024}"
WIDTH="${WIDTH:-1024}"
NUM_INFERENCE_STEPS="${NUM_INFERENCE_STEPS:-50}"
SEED="${SEED:-42}"
CURRENT_TIME=$(date +%Y%m%d%H%M%S)
OUTPUT="${OUTPUT:-lora_online_output_${CURRENT_TIME}.png}"
if [ -z "$LORA_PATH" ]; then
echo "ERROR: LORA_PATH is required (must be a server-local path)."
exit 1
fi
echo "Generating image with LoRA..."
echo "Server: $SERVER"
echo "Prompt: $PROMPT"
echo "LoRA: name=$LORA_NAME id=${LORA_INT_ID:-auto} scale=$LORA_SCALE path=$LORA_PATH"
echo "Output: $OUTPUT"
LORA_INT_ID_FIELD=""
if [ -n "$LORA_INT_ID" ]; then
LORA_INT_ID_FIELD=", \"int_id\": $LORA_INT_ID"
fi
curl -s "$SERVER/v1/chat/completions" \
-H "Content-Type: application/json" \
-d "{
\"messages\": [
{\"role\": \"user\", \"content\": \"$PROMPT\"}
],
\"extra_body\": {
\"height\": $HEIGHT,
\"width\": $WIDTH,
\"num_inference_steps\": $NUM_INFERENCE_STEPS,
\"seed\": $SEED,
\"lora\": {
\"name\": \"$LORA_NAME\",
\"local_path\": \"$LORA_PATH\",
\"scale\": $LORA_SCALE$LORA_INT_ID_FIELD
}
}
}" | jq -r '.choices[0].message.content[0].image_url.url' | sed 's/^data:image[^,]*,\s*//' | base64 -d > "$OUTPUT"
if [ -f "$OUTPUT" ]; then
echo "Image saved to: $OUTPUT"
echo "Size: $(du -h "$OUTPUT" | cut -f1)"
else
echo "Failed to generate image"
exit 1
fi
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# Online diffusion serving with vLLM-Omni (OpenAI-compatible API).
MODEL="${MODEL:-stabilityai/stable-diffusion-3.5-medium}"
PORT="${PORT:-8091}"
echo "Starting vLLM-Omni diffusion server..."
echo "Model: $MODEL"
echo "Port: $PORT"
if [ -z "${VLLM_BIN:-}" ]; then
if command -v vllm-omni >/dev/null 2>&1; then
VLLM_BIN="vllm-omni"
else
VLLM_BIN="vllm"
fi
fi
"$VLLM_BIN" serve "$MODEL" --omni \
--port "$PORT"
@@ -12,6 +12,12 @@ Please refer to [README.md](../../../README.md)
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
``` ```
If you want to open async chunking for qwen3-omni, launch the server with command below
```bash
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 --stage-configs-path /vllm_omni/model_executor/stage_configs/qwen3_omni_moe_async_chunk.yaml
```
If you have custom stage configs file, launch the server with command below If you have custom stage configs file, launch the server with command below
```bash ```bash
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 --stage-configs-path /path/to/stage_configs_file vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 --stage-configs-path /path/to/stage_configs_file
@@ -1,4 +1,5 @@
import base64 import base64
import concurrent.futures
import os import os
from typing import NamedTuple from typing import NamedTuple
@@ -414,51 +415,67 @@ def run_multimodal_generation(args) -> None:
else: else:
output_modalities = None output_modalities = None
chat_completion = client.chat.completions.create( # Test multiple concurrent completions
messages=[ num_concurrent_requests = args.num_concurrent_requests
get_system_prompt(),
prompt,
],
model=model_name,
modalities=output_modalities,
extra_body=extra_body,
stream=args.stream,
)
with concurrent.futures.ThreadPoolExecutor(max_workers=num_concurrent_requests) as executor:
# Submit multiple completion requests concurrently
futures = [
executor.submit(
client.chat.completions.create,
messages=[
get_system_prompt(),
prompt,
],
model=model_name,
modalities=output_modalities,
extra_body=extra_body,
stream=args.stream,
)
for _ in range(num_concurrent_requests)
]
# Wait for all requests to complete and collect results
chat_completions = [future.result() for future in concurrent.futures.as_completed(futures)]
assert len(chat_completions) == num_concurrent_requests
count = 0 count = 0
if not args.stream: if not args.stream:
for choice in chat_completion.choices: # Verify all completions succeeded
if choice.message.audio: for chat_completion in chat_completions:
audio_data = base64.b64decode(choice.message.audio.data) for choice in chat_completion.choices:
audio_file_path = f"audio_{count}.wav" if choice.message.audio:
with open(audio_file_path, "wb") as f: audio_data = base64.b64decode(choice.message.audio.data)
f.write(audio_data)
print(f"Audio saved to {audio_file_path}")
count += 1
elif choice.message.content:
print("Chat completion output from text:", choice.message.content)
else:
printed_content = False
for chunk in chat_completion:
for choice in chunk.choices:
if hasattr(choice, "delta"):
content = getattr(choice.delta, "content", None)
else:
content = None
if getattr(chunk, "modality", None) == "audio" and content:
audio_data = base64.b64decode(content)
audio_file_path = f"audio_{count}.wav" audio_file_path = f"audio_{count}.wav"
with open(audio_file_path, "wb") as f: with open(audio_file_path, "wb") as f:
f.write(audio_data) f.write(audio_data)
print(f"\nAudio saved to {audio_file_path}") print(f"Audio saved to {audio_file_path}")
count += 1 count += 1
elif choice.message.content:
print("Chat completion output from text:", choice.message.content)
else:
printed_content = False
for chat_completion in chat_completions:
for chunk in chat_completion:
for choice in chunk.choices:
if hasattr(choice, "delta"):
content = getattr(choice.delta, "content", None)
else:
content = None
elif getattr(chunk, "modality", None) == "text": if getattr(chunk, "modality", None) == "audio" and content:
if not printed_content: audio_data = base64.b64decode(content)
printed_content = True audio_file_path = f"audio_{count}.wav"
print("\ncontent:", end="", flush=True) with open(audio_file_path, "wb") as f:
print(content, end="", flush=True) f.write(audio_data)
print(f"\nAudio saved to {audio_file_path}")
count += 1
elif getattr(chunk, "modality", None) == "text":
if not printed_content:
printed_content = True
print("\ncontent:", end="", flush=True)
print(content, end="", flush=True)
def parse_args(): def parse_args():
@@ -517,6 +534,12 @@ def parse_args():
action="store_true", action="store_true",
help="Stream the response.", help="Stream the response.",
) )
parser.add_argument(
"--num-concurrent-requests",
type=int,
default=1,
help="Number of concurrent requests to send. Default is 1.",
)
return parser.parse_args() return parser.parse_args()
+170
View File
@@ -0,0 +1,170 @@
# Qwen3-TTS Online Serving
This directory contains examples for running Qwen3-TTS models with vLLM-Omni's online serving API.
## Supported Models
| Model | Task Type | Description |
|-------|-----------|-------------|
| `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` | CustomVoice | Predefined speaker voices with optional style control |
| `Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign` | VoiceDesign | Natural language voice style description |
| `Qwen/Qwen3-TTS-12Hz-1.7B-Base` | Base | Voice cloning from reference audio |
## Quick Start
### 1. Start the Server
```bash
# CustomVoice model (default)
./run_server.sh
# Or specify task type
./run_server.sh CustomVoice
./run_server.sh VoiceDesign
./run_server.sh Base
```
### 2. Run the Client
```bash
# CustomVoice: Use predefined speaker
python openai_speech_client.py \
--text "你好,我是通义千问" \
--voice Vivian \
--language Chinese
# CustomVoice with style instruction
python openai_speech_client.py \
--text "今天天气真好" \
--voice Ryan \
--instructions "用开心的语气说"
# VoiceDesign: Describe the voice style
python openai_speech_client.py \
--task-type VoiceDesign \
--text "哥哥,你回来啦" \
--instructions "体现撒娇稚嫩的萝莉女声,音调偏高"
# Base: Voice cloning
python openai_speech_client.py \
--task-type Base \
--text "Hello, this is a cloned voice" \
--ref-audio /path/to/reference.wav \
--ref-text "Original transcript of the reference audio"
```
### 3. Using curl
```bash
# Simple TTS request
curl -X POST http://localhost:8000/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
"input": "Hello, how are you?",
"voice": "Vivian",
"language": "English"
}' --output output.wav
# With style instruction
curl -X POST http://localhost:8000/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
"input": "I am so excited!",
"voice": "Vivian",
"instructions": "Speak with great enthusiasm"
}' --output excited.wav
```
## API Reference
### Endpoint
```
POST /v1/audio/speech
```
This endpoint follows the [OpenAI Audio Speech API](https://platform.openai.com/docs/api-reference/audio/createSpeech) format with additional Qwen3-TTS parameters.
### Request Body
```json
{
"input": "Text to synthesize",
"voice": "Vivian",
"response_format": "wav",
"task_type": "CustomVoice",
"language": "Auto",
"instructions": "Optional style instructions",
"ref_audio": "URL or base64 for voice cloning",
"ref_text": "Reference audio transcript",
"x_vector_only_mode": false,
"max_new_tokens": 2048
}
```
> **Note:** The `model` field is optional when serving a single model, as the server already knows which model is loaded.
### Response
Returns audio data in the requested format (default: WAV).
## Parameters
### Standard OpenAI Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `input` | string | required | Text to synthesize |
| `voice` | string | "Vivian" | Speaker/voice name |
| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, aac, opus |
| `speed` | float | 1.0 | Playback speed (0.25-4.0) |
| `model` | string | optional | Model name (optional when serving single model) |
### Qwen3-TTS Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `task_type` | string | "CustomVoice" | Task: CustomVoice, VoiceDesign, or Base |
| `language` | string | "Auto" | Language: Auto, Chinese, English, Japanese, Korean |
| `instructions` | string | "" | Voice style/emotion instructions |
| `max_new_tokens` | int | 2048 | Maximum tokens to generate |
### Voice Clone Parameters (Base task)
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `ref_audio` | string | Yes* | Reference audio (file path, URL, or base64) |
| `ref_text` | string | No | Transcript of reference audio (for ICL mode) |
| `x_vector_only_mode` | bool | false | Use speaker embedding only (no ICL) |
## Python Usage
```python
import httpx
# Simple request
response = httpx.post(
"http://localhost:8000/v1/audio/speech",
json={
"model": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
"input": "Hello world",
"voice": "Vivian",
},
timeout=300.0,
)
with open("output.wav", "wb") as f:
f.write(response.content)
```
## Limitations
- **No streaming**: Audio is generated completely before being returned. Streaming will be supported after the pipeline is disaggregated (see RFC #938).
- **Single request**: Batch processing is not yet optimized for online serving.
## Troubleshooting
1. **Connection refused**: Make sure the server is running on the correct port
2. **Out of memory**: Reduce `--gpu-memory-utilization` in run_server.sh
3. **Unsupported speaker**: Check supported speakers via model documentation
4. **Voice clone fails**: Ensure you're using the Base model variant for voice cloning
@@ -0,0 +1,235 @@
"""OpenAI-compatible client for Qwen3-TTS via /v1/audio/speech endpoint.
This script demonstrates how to use the OpenAI-compatible speech API
to generate audio from text using Qwen3-TTS models.
Examples:
# CustomVoice task (predefined speaker)
python openai_speech_client.py --text "Hello, how are you?" --voice Vivian
# CustomVoice with emotion instruction
python openai_speech_client.py --text "I'm so happy!" --voice Vivian \
--instructions "Speak with excitement"
# VoiceDesign task (voice from description)
python openai_speech_client.py --text "Hello world" \
--task-type VoiceDesign \
--instructions "A warm, friendly female voice"
# Base task (voice cloning)
python openai_speech_client.py --text "Hello world" \
--task-type Base \
--ref-audio "https://example.com/reference.wav" \
--ref-text "This is the reference transcript"
"""
import argparse
import base64
import os
import httpx
# Default server configuration
DEFAULT_API_BASE = "http://localhost:8000"
DEFAULT_API_KEY = "EMPTY"
def encode_audio_to_base64(audio_path: str) -> str:
"""Encode a local audio file to base64 data URL."""
if not os.path.exists(audio_path):
raise FileNotFoundError(f"Audio file not found: {audio_path}")
# Detect MIME type from extension
audio_path_lower = audio_path.lower()
if audio_path_lower.endswith(".wav"):
mime_type = "audio/wav"
elif audio_path_lower.endswith((".mp3", ".mpeg")):
mime_type = "audio/mpeg"
elif audio_path_lower.endswith(".flac"):
mime_type = "audio/flac"
elif audio_path_lower.endswith(".ogg"):
mime_type = "audio/ogg"
else:
mime_type = "audio/wav" # Default
with open(audio_path, "rb") as f:
audio_bytes = f.read()
audio_b64 = base64.b64encode(audio_bytes).decode("utf-8")
return f"data:{mime_type};base64,{audio_b64}"
def run_tts_generation(args) -> None:
"""Run TTS generation via OpenAI-compatible /v1/audio/speech API."""
# Build request payload
payload = {
"model": args.model,
"input": args.text,
"voice": args.voice,
"response_format": args.response_format,
}
# Add optional parameters
if args.instructions:
payload["instructions"] = args.instructions
if args.task_type:
payload["task_type"] = args.task_type
if args.language:
payload["language"] = args.language
if args.max_new_tokens:
payload["max_new_tokens"] = args.max_new_tokens
# Voice clone parameters (Base task)
if args.ref_audio:
if args.ref_audio.startswith(("http://", "https://")):
payload["ref_audio"] = args.ref_audio
else:
payload["ref_audio"] = encode_audio_to_base64(args.ref_audio)
if args.ref_text:
payload["ref_text"] = args.ref_text
if args.x_vector_only:
payload["x_vector_only_mode"] = True
print(f"Task type: {args.task_type or 'CustomVoice'}")
print(f"Text: {args.text}")
print(f"Voice: {args.voice}")
print("Generating audio...")
# Make the API call
api_url = f"{args.api_base}/v1/audio/speech"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {args.api_key}",
}
with httpx.Client(timeout=300.0) as client:
response = client.post(api_url, json=payload, headers=headers)
if response.status_code != 200:
print(f"Error: {response.status_code}")
print(response.text)
return
# Save audio response
output_path = args.output or "tts_output.wav"
with open(output_path, "wb") as f:
f.write(response.content)
print(f"Audio saved to: {output_path}")
def parse_args():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
description="OpenAI-compatible client for Qwen3-TTS via /v1/audio/speech",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
# Server configuration
parser.add_argument(
"--api-base",
type=str,
default=DEFAULT_API_BASE,
help=f"API base URL (default: {DEFAULT_API_BASE})",
)
parser.add_argument(
"--api-key",
type=str,
default=DEFAULT_API_KEY,
help="API key (default: EMPTY)",
)
parser.add_argument(
"--model",
"-m",
type=str,
default="Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
help="Model name/path",
)
# Task configuration
parser.add_argument(
"--task-type",
"-t",
type=str,
default=None,
choices=["CustomVoice", "VoiceDesign", "Base"],
help="TTS task type (default: CustomVoice)",
)
# Input text
parser.add_argument(
"--text",
type=str,
required=True,
help="Text to synthesize",
)
# Voice/speaker
parser.add_argument(
"--voice",
type=str,
default="Vivian",
help="Speaker/voice name (default: Vivian). Options: Vivian, Ryan, etc.",
)
parser.add_argument(
"--language",
type=str,
default=None,
help="Language: Auto, Chinese, English, etc.",
)
parser.add_argument(
"--instructions",
type=str,
default=None,
help="Voice style/emotion instructions",
)
# Base (voice clone) parameters
parser.add_argument(
"--ref-audio",
type=str,
default=None,
help="Reference audio file path or URL for voice cloning (Base task)",
)
parser.add_argument(
"--ref-text",
type=str,
default=None,
help="Reference audio transcript for voice cloning (Base task)",
)
parser.add_argument(
"--x-vector-only",
action="store_true",
help="Use x-vector only mode for voice cloning (no ICL)",
)
# Generation parameters
parser.add_argument(
"--max-new-tokens",
type=int,
default=None,
help="Maximum new tokens to generate",
)
# Output
parser.add_argument(
"--response-format",
type=str,
default="wav",
choices=["wav", "mp3", "flac", "pcm", "aac", "opus"],
help="Audio output format (default: wav)",
)
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output audio file path (default: tts_output.wav)",
)
return parser.parse_args()
if __name__ == "__main__":
args = parse_args()
run_tts_generation(args)
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
# Launch vLLM-Omni server for Qwen3-TTS models
#
# Usage:
# ./run_server.sh # Default: CustomVoice model
# ./run_server.sh CustomVoice # CustomVoice model
# ./run_server.sh VoiceDesign # VoiceDesign model
# ./run_server.sh Base # Base (voice clone) model
set -e
TASK_TYPE="${1:-CustomVoice}"
case "$TASK_TYPE" in
CustomVoice)
MODEL="Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
;;
VoiceDesign)
MODEL="Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
;;
Base)
MODEL="Qwen/Qwen3-TTS-12Hz-1.7B-Base"
;;
*)
echo "Unknown task type: $TASK_TYPE"
echo "Supported: CustomVoice, VoiceDesign, Base"
exit 1
;;
esac
echo "Starting Qwen3-TTS server with model: $MODEL"
vllm-omni serve "$MODEL" \
--stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_tts.yaml \
--host 0.0.0.0 \
--port 8000 \
--gpu-memory-utilization 0.9 \
--trust-remote-code \
--enforce-eager \
--omni
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Qwen-Image OpenAI-compatible chat client for image generation. Qwen-Image OpenAI-compatible image generation client.
Usage: Usage:
python openai_chat_client.py --prompt "A beautiful landscape" --output output.png python openai_chat_client.py --prompt "A beautiful landscape" --output output.png
@@ -25,14 +25,14 @@ def generate_image(
negative_prompt: str | None = None, negative_prompt: str | None = None,
num_outputs_per_prompt: int = 1, num_outputs_per_prompt: int = 1,
) -> bytes | None: ) -> bytes | None:
"""Generate an image using the chat completions API. """Generate an image using the images generation API.
Args: Args:
prompt: Text description of the image prompt: Text description of the image
server_url: Server URL server_url: Server URL
height: Image height in pixels height: Image height in pixels
width: Image width in pixels width: Image width in pixels
steps: Number of inference steps steps: Number of diffusion steps
true_cfg_scale: Qwen-Image CFG scale true_cfg_scale: Qwen-Image CFG scale
seed: Random seed seed: Random seed
negative_prompt: Negative prompt negative_prompt: Negative prompt
@@ -41,33 +41,31 @@ def generate_image(
Returns: Returns:
Image bytes or None if failed Image bytes or None if failed
""" """
messages = [{"role": "user", "content": prompt}] payload: dict[str, object] = {
"prompt": prompt,
"response_format": "b64_json",
"n": num_outputs_per_prompt,
}
if width is not None and height is not None:
payload["size"] = f"{width}x{height}"
elif width is not None:
payload["size"] = f"{width}x{width}"
elif height is not None:
payload["size"] = f"{height}x{height}"
# Build extra_body with generation parameters
extra_body = {}
if height is not None:
extra_body["height"] = height
if width is not None:
extra_body["width"] = width
if steps is not None: if steps is not None:
extra_body["num_inference_steps"] = steps payload["num_inference_steps"] = steps
if true_cfg_scale is not None: if true_cfg_scale is not None:
extra_body["true_cfg_scale"] = true_cfg_scale payload["true_cfg_scale"] = true_cfg_scale
if seed is not None:
extra_body["seed"] = seed
if negative_prompt: if negative_prompt:
extra_body["negative_prompt"] = negative_prompt payload["negative_prompt"] = negative_prompt
extra_body["num_outputs_per_prompt"] = num_outputs_per_prompt if seed is not None:
payload["seed"] = seed
# Build request payload
payload = {"messages": messages}
if extra_body:
payload["extra_body"] = extra_body
# Send request
try: try:
response = requests.post( response = requests.post(
f"{server_url}/v1/chat/completions", f"{server_url}/v1/images/generations",
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
json=payload, json=payload,
timeout=300, timeout=300,
@@ -75,15 +73,13 @@ def generate_image(
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
# Extract image from response items = data.get("data")
content = data["choices"][0]["message"]["content"] if isinstance(items, list) and items:
if isinstance(content, list) and len(content) > 0: first = items[0].get("b64_json") if isinstance(items[0], dict) else None
image_url = content[0].get("image_url", {}).get("url", "") if isinstance(first, str):
if image_url.startswith("data:image"): return base64.b64decode(first)
_, b64_data = image_url.split(",", 1)
return base64.b64decode(b64_data)
print(f"Unexpected response format: {content}") print(f"Unexpected response format: {data}")
return None return None
except Exception as e: except Exception as e:
@@ -1,35 +1,10 @@
#!/bin/bash #!/bin/bash
# Qwen-Image text-to-image curl example # Qwen-Image text-to-image curl example
SERVER="${SERVER:-http://localhost:8091}" curl -X POST http://localhost:8091/v1/images/generations \
PROMPT="${PROMPT:-a good boy in the ocean}"
CURRENT_TIME=$(date +%Y%m%d%H%M%S)
OUTPUT="${OUTPUT:-qwen_image_output_${CURRENT_TIME}.png}"
echo "Generating image..."
echo "Prompt: $PROMPT"
echo "Output: $OUTPUT"
curl -s "$SERVER/v1/chat/completions" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{ -d '{
\"messages\": [ "prompt": "a dragon laying over the spine of the Green Mountains of Vermont",
{\"role\": \"user\", \"content\": \"$PROMPT\"} "size": "1024x1024",
], "seed": 42
\"extra_body\": { }' | jq -r '.data[0].b64_json' | base64 -d > dragon.png
\"height\": 1024,
\"width\": 1024,
\"num_inference_steps\": 100,
\"true_cfg_scale\": 4.0,
\"seed\": 42,
\"num_outputs_per_prompt\": 1
}
}" | jq -r '.choices[0].message.content[0].image_url.url' | sed 's/^data:image[^,]*,\s*//' | base64 -d > "$OUTPUT"
if [ -f "$OUTPUT" ]; then
echo "Image saved to: $OUTPUT"
echo "Size: $(du -h "$OUTPUT" | cut -f1)"
else
echo "Failed to generate image"
exit 1
fi
+4 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "vllm-omni" name = "vllm-omni"
version = "0.12.0rc1" version = "0.14.0rc1"
description = "A framework for efficient model inference with omni-modality models" description = "A framework for efficient model inference with omni-modality models"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10,<3.14" requires-python = ">=3.10,<3.14"
@@ -42,6 +42,8 @@ dependencies = [
"fa3-fwd", # flash attention 3, maintained by @ZJY0516 "fa3-fwd", # flash attention 3, maintained by @ZJY0516
"openai-whisper>=20250625", "openai-whisper>=20250625",
"imageio[ffmpeg]>=2.37.2", "imageio[ffmpeg]>=2.37.2",
"onnxruntime>=1.19.0",
"sox>=1.5.0",
# "vllm==0.14.0", # TODO: fix the entrypoints overwrite problem # "vllm==0.14.0", # TODO: fix the entrypoints overwrite problem
] ]
@@ -200,4 +202,5 @@ extend-ignore-identifiers-re = [
".*nothink.*", ".*nothink.*",
".*NOTHINK.*", ".*NOTHINK.*",
".*nin.*", ".*nin.*",
"Ono_Anna",
] ]
+335 -84
View File
@@ -22,6 +22,7 @@ import psutil
import pytest import pytest
import torch import torch
import yaml import yaml
from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
from vllm.logger import init_logger from vllm.logger import init_logger
from vllm.utils.network_utils import get_open_port from vllm.utils.network_utils import get_open_port
@@ -52,31 +53,118 @@ def default_vllm_config():
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def clean_gpu_memory_between_tests(): def clean_gpu_memory_between_tests():
print("\n=== PRE-TEST GPU CLEANUP ===")
_run_pre_test_cleanup()
yield
_run_post_test_cleanup()
def _run_pre_test_cleanup():
if os.getenv("VLLM_TEST_CLEAN_GPU_MEMORY", "0") != "1": if os.getenv("VLLM_TEST_CLEAN_GPU_MEMORY", "0") != "1":
yield print("GPU cleanup disabled")
return return
# Wait for GPU memory to be cleared before starting the test print("Pre-test GPU status:")
import gc _print_simple_gpu_status()
from tests.utils import wait_for_gpu_memory_to_clear
num_gpus = torch.cuda.device_count() num_gpus = torch.cuda.device_count()
if num_gpus > 0: if num_gpus > 0:
try: try:
from tests.utils import wait_for_gpu_memory_to_clear
wait_for_gpu_memory_to_clear( wait_for_gpu_memory_to_clear(
devices=list(range(num_gpus)), devices=list(range(num_gpus)),
threshold_ratio=0.1, threshold_ratio=0.1,
) )
except ValueError as e: except Exception as e:
logger.info("Failed to clean GPU memory: %s", e) print(f"Pre-test cleanup note: {e}")
yield
# Clean up GPU memory after the test def _run_post_test_cleanup():
if os.getenv("VLLM_TEST_CLEAN_GPU_MEMORY", "0") != "1":
return
import gc
if torch.cuda.is_available(): if torch.cuda.is_available():
torch.cuda.empty_cache()
gc.collect() gc.collect()
torch.cuda.empty_cache()
print("Post-test GPU status:")
_print_simple_gpu_status()
_print_gpu_processes()
def _print_simple_gpu_status():
"""Print simple GPU memory status"""
if not torch.cuda.is_available():
print(" CUDA not available")
return
num_devices = torch.cuda.device_count()
for device_id in range(num_devices):
try:
torch.cuda.set_device(device_id)
allocated = torch.cuda.memory_allocated(device_id) / (1024**2)
reserved = torch.cuda.memory_reserved(device_id) / (1024**2)
print(f" GPU {device_id}: Allocated: {allocated:.1f}MB, Reserved: {reserved:.1f}MB")
except Exception:
print(f" GPU {device_id}: Error reading status")
def _print_gpu_processes():
"""Print GPU information including nvidia-smi and system processes"""
print("\n" + "=" * 80)
print("NVIDIA GPU Information (nvidia-smi)")
print("=" * 80)
try:
nvidia_result = subprocess.run(
["nvidia-smi"],
capture_output=True,
text=True,
timeout=5,
)
if nvidia_result.returncode == 0:
lines = nvidia_result.stdout.strip().split("\n")
for line in lines[:20]:
print(line)
if len(lines) > 20:
print(f"... (showing first 20 of {len(lines)} lines)")
else:
print("nvidia-smi command failed")
except (subprocess.TimeoutExpired, FileNotFoundError):
print("nvidia-smi not available or timed out")
except Exception as e:
print(f"Error running nvidia-smi: {e}")
print("\n" + "=" * 80)
print("Detailed GPU Processes (nvidia-smi pmon)")
print("=" * 80)
try:
pmon_result = subprocess.run(
["nvidia-smi", "pmon", "-c", "1"],
capture_output=True,
text=True,
timeout=3,
)
if pmon_result.returncode == 0 and pmon_result.stdout.strip():
print(pmon_result.stdout)
else:
print("No active GPU processes found via nvidia-smi pmon")
except Exception:
print("nvidia-smi pmon not available")
print("\n" + "=" * 80)
print("System Processes with GPU keywords")
print("=" * 80)
def dummy_messages_from_mix_data( def dummy_messages_from_mix_data(
@@ -528,7 +616,6 @@ def convert_audio_to_text(audio_data):
temperature=0.0, temperature=0.0,
word_timestamps=True, word_timestamps=True,
condition_on_previous_text=False, condition_on_previous_text=False,
initial_prompt="Please transcribe with proper word spacing.",
)["text"] )["text"]
if text: if text:
return text return text
@@ -538,75 +625,238 @@ def convert_audio_to_text(audio_data):
def modify_stage_config( def modify_stage_config(
yaml_path: str, yaml_path: str,
stage_updates: dict[int, dict[str, Any]], updates: dict[str, Any],
deletes: dict[str, Any] = None,
) -> str: ) -> str:
""" """
Batch modify configurations for multiple stages in a YAML file. Modify configurations in a YAML file, supporting both top-level and stage-specific modifications,
including addition, modification, and deletion of configurations.
Args: Args:
yaml_path: Path to the YAML configuration file. yaml_path: Path to the YAML configuration file.
stage_updates: Dictionary where keys are stage IDs and values are dictionaries of updates: Dictionary containing both top-level and stage-specific modifications to add or update.
modifications for that stage. Each modification dictionary uses Format: {
dot-separated paths as keys and new configuration values as values. 'async_chunk': True,
Example: { 'stage_args': {
0: {'engine_args.max_model_len': 5800}, 0: {'engine_args.max_model_len': 5800},
1: {'runtime.max_batch_size': 2} 1: {'runtime.max_batch_size': 2}
} }
}
deletes: Dictionary containing configurations to delete.
Format: {
'old_config': None, # Delete entire key
'stage_args': {
0: ['engine_args.old_param'],
1: ['runtime.unused_setting']
}
}
Returns: Returns:
str: Path to the newly created modified YAML file with timestamp suffix. str: Path to the newly created modified YAML file with timestamp suffix.
Example:
>>> output_file = modify_stage_config(
... 'config.yaml',
... {
... 0: {'engine_args.max_model_len': 5800},
... 1: {'runtime.max_batch_size': 2}
... }
... )
>>> print(f"Modified configuration saved to: {output_file}")
Modified configuration saved to: config_1698765432.yaml
""" """
path = Path(yaml_path) path = Path(yaml_path)
if not path.exists(): if not path.exists():
raise FileNotFoundError(f"yaml does not exist: {path}") raise FileNotFoundError(f"yaml does not exist: {path}")
try: try:
with open(yaml_path, encoding="utf-8") as f: with open(yaml_path, encoding="utf-8") as f:
config = yaml.safe_load(f) or {} config = yaml.safe_load(f) or {}
except Exception as e: except Exception as e:
raise ValueError(f"Cannot parse YAML file: {e}") raise ValueError(f"Cannot parse YAML file: {e}")
stage_args = config.get("stage_args", []) # Helper function to apply update
if not stage_args: def apply_update(config_dict: dict, key_path: str, value: Any) -> None:
raise ValueError("the stage_args does not exist") """Apply update to dictionary using dot-separated path."""
# Handle direct list assignment (e.g., engine_input_source: [1, 2])
if "." not in key_path:
# Simple key, set directly
config_dict[key_path] = value
return
for stage_id, config_dict in stage_updates.items(): current = config_dict
target_stage = None keys = key_path.split(".")
for stage in stage_args:
if stage.get("stage_id") == stage_id:
target_stage = stage
break
if target_stage is None: for i in range(len(keys) - 1):
available_ids = [s.get("stage_id") for s in stage_args if "stage_id" in s] key = keys[i]
raise KeyError(f"Stage ID {stage_id} is not exist, available IDs: {available_ids}")
for key_path, value in config_dict.items(): # Handle list indices
current = target_stage if key.isdigit() and isinstance(current, list):
keys = key_path.split(".") index = int(key)
for i in range(len(keys) - 1): if index < 0:
key = keys[i] raise ValueError(f"Negative list index not allowed: {index}")
if index >= len(current):
# Expand list if needed
while len(current) <= index:
# If we need to go deeper (more keys after this), create a dict
# Otherwise, create None placeholder
current.append({} if i < len(keys) - 2 else None)
current = current[index]
elif isinstance(current, dict):
# Handle dictionary keys
if key not in current: if key not in current:
raise KeyError(f"the {'.'.join(keys[: i + 1])} does not exist") # If there are more keys after this, create appropriate structure
if i < len(keys) - 1:
elif not isinstance(current[key], dict) and i < len(keys) - 2: # Check if next key is a digit (list index) or string (dict key)
raise ValueError(f"{'.'.join(keys[: i + 1])}' cannot continue deeper because it's not a dict") if keys[i + 1].isdigit():
current[key] = []
else:
current[key] = {}
else:
# This is the last key, create based on value type
current[key] = [] if isinstance(value, list) else {}
elif not isinstance(current[key], (dict, list)) and i < len(keys) - 1:
# If current value is not dict/list but we need to go deeper, replace it
if keys[i + 1].isdigit():
current[key] = []
else:
current[key] = {}
current = current[key] current = current[key]
current[keys[-1]] = value else:
# Current is not a dict or list, cannot traverse further
raise TypeError(
f"Cannot access {'.'.join(keys[: i + 1])} as a dict/list. It's a {type(current).__name__}"
)
# Set the final value
last_key = keys[-1]
if isinstance(current, list) and last_key.isdigit():
# Setting a value in a list by index
index = int(last_key)
if index < 0:
raise ValueError(f"Negative list index not allowed: {index}")
if index >= len(current):
# Expand list if needed
while len(current) <= index:
current.append(None)
current[index] = value
elif isinstance(current, dict):
# Special case: if the value is a list and we're setting a top-level key
# Example: updating engine_input_source with [1, 2]
current[last_key] = value
else:
# Current is not a dict, cannot set key
raise TypeError(f"Cannot set value at {key_path}. Current type is {type(current).__name__}, expected dict.")
# Helper function to delete by path
def delete_by_path(config_dict: dict, path: str) -> None:
"""Delete configuration by dot-separated path."""
if not path:
return
current = config_dict
keys = path.split(".")
# Traverse to the parent
for i in range(len(keys) - 1):
key = keys[i]
# Handle list indices
if key.isdigit() and isinstance(current, list):
index = int(key)
if index < 0 or index >= len(current):
raise KeyError(f"List index {index} out of bounds")
current = current[index]
elif isinstance(current, dict):
if key not in current:
raise KeyError(f"Path {'.'.join(keys[: i + 1])} does not exist")
current = current[key]
else:
raise TypeError(
f"Cannot access {'.'.join(keys[: i + 1])} as a dict/list. It's a {type(current).__name__}"
)
# Delete the item
last_key = keys[-1]
if isinstance(current, list) and last_key.isdigit():
index = int(last_key)
if index < 0 or index >= len(current):
raise KeyError(f"List index {index} out of bounds")
del current[index]
elif isinstance(current, dict) and last_key in current:
del current[last_key]
else:
raise KeyError(f"Path {path} does not exist")
# Apply deletions first
if deletes:
for key, value in deletes.items():
if key == "stage_args":
if value and isinstance(value, dict):
stage_args = config.get("stage_args", [])
if not stage_args:
raise ValueError("stage_args does not exist in config")
for stage_id, delete_paths in value.items():
if not delete_paths:
continue
# Find stage by ID
target_stage = None
for stage in stage_args:
if stage.get("stage_id") == stage_id:
target_stage = stage
break
if target_stage is None:
available_ids = [s.get("stage_id") for s in stage_args if "stage_id" in s]
raise KeyError(f"Stage ID {stage_id} not found, available: {available_ids}")
# Delete specified paths in this stage
for path in delete_paths:
if path: # Skip empty paths
delete_by_path(target_stage, path)
elif "." in key:
# Delete using dot-separated path
delete_by_path(config, key)
elif value is None and key in config:
# Delete entire key
del config[key]
# Apply updates
for key, value in updates.items():
if key == "stage_args":
if value and isinstance(value, dict):
stage_args = config.get("stage_args", [])
if not stage_args:
raise ValueError("stage_args does not exist in config")
for stage_id, stage_updates in value.items():
# Find stage by ID
target_stage = None
for stage in stage_args:
if stage.get("stage_id") == stage_id:
target_stage = stage
break
if target_stage is None:
available_ids = [s.get("stage_id") for s in stage_args if "stage_id" in s]
raise KeyError(f"Stage ID {stage_id} not found, available: {available_ids}")
# Apply updates to this stage
for path, val in stage_updates.items():
# Check if this is a simple key (not dot-separated)
# Example: 'engine_input_source' vs 'engine_args.max_model_len'
if "." not in path:
# Direct key assignment (e.g., updating a list value)
target_stage[path] = val
else:
# Dot-separated path (e.g., nested dict access)
apply_update(target_stage, path, val)
elif "." in key:
# Apply using dot-separated path
apply_update(config, key, value)
else:
# Direct top-level key
config[key] = value
# Save to new file with timestamp
timestamp = int(time.time())
base_name = yaml_path.rsplit(".", 1)[0] if "." in yaml_path else yaml_path
output_path = f"{base_name}_{timestamp}.yaml"
output_path = f"{yaml_path.split('.')[0]}_{int(time.time())}.yaml"
with open(output_path, "w", encoding="utf-8") as f: with open(output_path, "w", encoding="utf-8") as f:
yaml.dump(config, f, default_flow_style=False, sort_keys=False, allow_unicode=True, indent=2) yaml.dump(config, f, default_flow_style=None, sort_keys=False, allow_unicode=True, indent=2)
return output_path return output_path
@@ -673,24 +923,32 @@ class OmniServer:
raise RuntimeError(f"Server failed to start within {max_wait} seconds") raise RuntimeError(f"Server failed to start within {max_wait} seconds")
def _kill_process_tree(self, pid): def _kill_process_tree(self, pid):
"""kill process and its children""" """kill process and its children with verification"""
try: try:
parent = psutil.Process(pid) parent = psutil.Process(pid)
children = parent.children(recursive=True) children = parent.children(recursive=True)
# Get all PIDs first
all_pids = [pid] + [child.pid for child in children]
# Terminate children
for child in children: for child in children:
try: try:
child.terminate() child.terminate()
except psutil.NoSuchProcess: except psutil.NoSuchProcess:
pass pass
# Wait for children
gone, still_alive = psutil.wait_procs(children, timeout=10) gone, still_alive = psutil.wait_procs(children, timeout=10)
# Kill remaining children
for child in still_alive: for child in still_alive:
try: try:
child.kill() child.kill()
except psutil.NoSuchProcess: except psutil.NoSuchProcess:
pass pass
# Terminate parent
try: try:
parent.terminate() parent.terminate()
parent.wait(timeout=10) parent.wait(timeout=10)
@@ -700,6 +958,24 @@ class OmniServer:
except psutil.NoSuchProcess: except psutil.NoSuchProcess:
pass pass
# VERIFICATION: Check if all processes are gone
time.sleep(1) # Give system time
alive_processes = []
for check_pid in all_pids:
if psutil.pid_exists(check_pid):
alive_processes.append(check_pid)
if alive_processes:
print(f"Warning: Processes still alive: {alive_processes}")
# Optional: Try system kill
import subprocess
for alive_pid in alive_processes:
try:
subprocess.run(["kill", "-9", str(alive_pid)], timeout=2)
except Exception as e:
print(f"Cleanup failed: {e}")
except psutil.NoSuchProcess: except psutil.NoSuchProcess:
pass pass
@@ -709,31 +985,6 @@ class OmniServer:
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type, exc_val, exc_tb):
if self.proc: if self.proc:
try: self._kill_process_tree(self.proc.pid)
parent = psutil.Process(self.proc.pid) _run_post_test_cleanup()
children = parent.children(recursive=True) cleanup_dist_env_and_memory()
for child in children:
try:
child.terminate()
except psutil.NoSuchProcess:
pass
gone, still_alive = psutil.wait_procs(children, timeout=10)
for child in still_alive:
try:
child.kill()
except psutil.NoSuchProcess:
pass
try:
parent.terminate()
parent.wait(timeout=10)
except (psutil.NoSuchProcess, psutil.TimeoutExpired):
try:
parent.kill()
except psutil.NoSuchProcess:
pass
except psutil.NoSuchProcess:
pass
+171
View File
@@ -0,0 +1,171 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
from dataclasses import dataclass
import torch
from vllm_omni.diffusion.lora.layers.base_linear import DiffusionBaseLinearLayerWithLoRA
@dataclass
class _DummyLoRAConfig:
fully_sharded_loras: bool = False
class _DummyQuantMethod:
def __init__(self, weight: torch.Tensor):
self._weight = weight
def apply(self, _base_layer, x: torch.Tensor, bias: torch.Tensor | None):
y = x @ self._weight.t()
if bias is not None:
y = y + bias
return y
def test_diffusion_base_linear_apply_multi_slice():
# Build a fake diffusion LoRA layer with 2 slices and rank=2.
layer = DiffusionBaseLinearLayerWithLoRA.__new__(DiffusionBaseLinearLayerWithLoRA)
layer.tp_size = 1
layer.lora_config = _DummyLoRAConfig()
in_dim = 3
out_slices = (2, 1)
rank = 2
# Base weight: identity-ish mapping to make base output easy to reason about.
base_weight = torch.tensor(
[
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 1.0],
]
)
layer.base_layer = type("Base", (), {})()
layer.base_layer.quant_method = _DummyQuantMethod(base_weight)
# Allocate stacked weights: (max_loras=1, 1, rank, in_dim) and (1, 1, out_slice, rank)
a0 = torch.zeros((1, 1, rank, in_dim))
b0 = torch.zeros((1, 1, out_slices[0], rank))
a1 = torch.zeros((1, 1, rank, in_dim))
b1 = torch.zeros((1, 1, out_slices[1], rank))
# Slice 0: delta0 = (x @ A0.T) @ B0.T
A0 = torch.tensor([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]) # (2, 3)
B0 = torch.tensor([[1.0, 0.0], [0.0, 1.0]]) # (2, 2)
a0[0, 0, :, :] = A0
b0[0, 0, :, :] = B0
# Slice 1: delta1 = (x @ A1.T) @ B1.T
A1 = torch.tensor([[0.0, 0.0, 1.0], [1.0, 0.0, 0.0]]) # (2, 3)
B1 = torch.tensor([[2.0, 0.0]]) # (1, 2)
a1[0, 0, :, :] = A1
b1[0, 0, :, :] = B1
layer.lora_a_stacked = (a0, a1)
layer.lora_b_stacked = (b0, b1)
layer.output_slices = out_slices
x = torch.tensor([[1.0, 2.0, 3.0]])
out = layer.apply(x)
# Base output is identity: [1,2,3]
base_out = x @ base_weight.t()
# delta0:
# (x @ A0.T) = [1,2]
# [1,2] @ B0.T = [1,2]
delta0 = torch.tensor([[1.0, 2.0]])
# delta1:
# (x @ A1.T) = [3,1]
# [3,1] @ B1.T = [6]
delta1 = torch.tensor([[6.0]])
expected = torch.cat([base_out[:, :2] + delta0, base_out[:, 2:3] + delta1], dim=-1)
assert torch.allclose(out, expected)
def test_diffusion_base_linear_reset_lora_disables_fast_path(monkeypatch):
# Verify that after reset_lora(), apply() skips LoRA matmuls even if the
# LoRA tensors are still allocated and non-empty.
from vllm.lora.layers.base_linear import BaseLinearLayerWithLoRA
layer = DiffusionBaseLinearLayerWithLoRA.__new__(DiffusionBaseLinearLayerWithLoRA)
layer.tp_size = 1
layer.lora_config = _DummyLoRAConfig()
in_dim = 2
out_dim = 2
rank = 1
base_weight = torch.eye(in_dim)
layer.base_layer = type("Base", (), {})()
layer.base_layer.quant_method = _DummyQuantMethod(base_weight)
a = torch.ones((1, 1, rank, in_dim))
b = torch.tensor([[[[1.0], [2.0]]]]) # (1,1,out_dim,rank)
layer.lora_a_stacked = (a,)
layer.lora_b_stacked = (b,)
layer.output_slices = (out_dim,)
layer._diffusion_lora_active_slices = (True,)
x = torch.tensor([[1.0, 2.0]])
out_active = layer.apply(x)
assert torch.allclose(out_active, torch.tensor([[4.0, 8.0]]))
monkeypatch.setattr(BaseLinearLayerWithLoRA, "reset_lora", lambda self, index: None)
layer.reset_lora(0)
assert layer._diffusion_lora_active_slices == (False,)
out_inactive = layer.apply(x)
assert torch.allclose(out_inactive, x)
def test_diffusion_base_linear_apply_respects_inactive_slices():
# Build a fake diffusion LoRA layer with 2 slices and rank=2.
layer = DiffusionBaseLinearLayerWithLoRA.__new__(DiffusionBaseLinearLayerWithLoRA)
layer.tp_size = 1
layer.lora_config = _DummyLoRAConfig()
in_dim = 3
out_slices = (2, 1)
rank = 2
base_weight = torch.tensor(
[
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 1.0],
]
)
layer.base_layer = type("Base", (), {})()
layer.base_layer.quant_method = _DummyQuantMethod(base_weight)
a0 = torch.zeros((1, 1, rank, in_dim))
b0 = torch.zeros((1, 1, out_slices[0], rank))
a1 = torch.zeros((1, 1, rank, in_dim))
b1 = torch.zeros((1, 1, out_slices[1], rank))
A0 = torch.tensor([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]) # (2, 3)
B0 = torch.tensor([[1.0, 0.0], [0.0, 1.0]]) # (2, 2)
a0[0, 0, :, :] = A0
b0[0, 0, :, :] = B0
A1 = torch.tensor([[0.0, 0.0, 1.0], [1.0, 0.0, 0.0]]) # (2, 3)
B1 = torch.tensor([[2.0, 0.0]]) # (1, 2)
a1[0, 0, :, :] = A1
b1[0, 0, :, :] = B1
layer.lora_a_stacked = (a0, a1)
layer.lora_b_stacked = (b0, b1)
layer.output_slices = out_slices
layer._diffusion_lora_active_slices = (True, False)
x = torch.tensor([[1.0, 2.0, 3.0]])
out = layer.apply(x)
# Only the first slice should be adapted.
expected = torch.tensor([[2.0, 4.0, 3.0]])
assert torch.allclose(out, expected)
+343
View File
@@ -0,0 +1,343 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import torch
from vllm.lora.lora_weights import LoRALayerWeights
from vllm.lora.utils import get_supported_lora_modules
from vllm.model_executor.layers.linear import LinearBase
from vllm_omni.diffusion.lora.manager import DiffusionLoRAManager
from vllm_omni.lora.request import LoRARequest
class _DummyLoRALayer:
def __init__(self, n_slices: int, output_slices: tuple[int, ...]):
self.n_slices = n_slices
self.output_slices = output_slices
self.set_calls: list[
tuple[list[torch.Tensor | None] | torch.Tensor, list[torch.Tensor | None] | torch.Tensor]
] = []
self.reset_calls: int = 0
def set_lora(self, index: int, lora_a, lora_b):
assert index == 0
self.set_calls.append((lora_a, lora_b))
def reset_lora(self, index: int):
assert index == 0
self.reset_calls += 1
class _FakeLinearBase(LinearBase):
def __init__(self):
torch.nn.Module.__init__(self)
def test_lora_manager_supported_modules_are_stable_with_wrapped_layers(monkeypatch):
# Simulate a pipeline that already contains LoRA wrappers where the original
# LinearBase is nested under ".base_layer".
import vllm_omni.diffusion.lora.manager as manager_mod
class _DummyBaseLayerWithLoRA(torch.nn.Module):
def __init__(self, base_layer: torch.nn.Module):
super().__init__()
self.base_layer = base_layer
monkeypatch.setattr(manager_mod, "BaseLayerWithLoRA", _DummyBaseLayerWithLoRA)
pipeline = torch.nn.Module()
pipeline.transformer = torch.nn.Module()
pipeline.transformer.foo = _DummyBaseLayerWithLoRA(_FakeLinearBase())
# vLLM helper would see only the nested LinearBase and yield "base_layer".
assert get_supported_lora_modules(pipeline) == ["base_layer"]
manager = DiffusionLoRAManager(
pipeline=pipeline,
device=torch.device("cpu"),
dtype=torch.bfloat16,
max_cached_adapters=1,
)
assert "foo" in manager._supported_lora_modules
assert "base_layer" not in manager._supported_lora_modules
def test_lora_manager_replace_layers_does_not_rewrap_base_layer(monkeypatch):
import vllm_omni.diffusion.lora.manager as manager_mod
class _DummyBaseLayerWithLoRA(torch.nn.Module):
def __init__(self, base_layer: torch.nn.Module):
super().__init__()
self.base_layer = base_layer
monkeypatch.setattr(manager_mod, "BaseLayerWithLoRA", _DummyBaseLayerWithLoRA)
def _fake_from_layer_diffusion(*, layer: torch.nn.Module, **_kwargs):
if isinstance(layer, _FakeLinearBase):
return _DummyBaseLayerWithLoRA(layer)
return layer
replace_calls: list[str] = []
def _fake_replace_submodule(root: torch.nn.Module, module_name: str, submodule: torch.nn.Module):
replace_calls.append(module_name)
setattr(root, module_name, submodule)
monkeypatch.setattr(manager_mod, "from_layer_diffusion", _fake_from_layer_diffusion)
monkeypatch.setattr(manager_mod, "replace_submodule", _fake_replace_submodule)
pipeline = torch.nn.Module()
pipeline.transformer = torch.nn.Module()
pipeline.transformer.foo = _FakeLinearBase()
manager = DiffusionLoRAManager(
pipeline=pipeline,
device=torch.device("cpu"),
dtype=torch.bfloat16,
max_cached_adapters=1,
)
peft_helper = type("_PH", (), {"r": 1})()
manager._replace_layers_with_lora(peft_helper)
manager._replace_layers_with_lora(peft_helper)
# Only the top-level layer should have been replaced; nested ".base_layer"
# must be skipped to avoid nesting LoRA wrappers.
assert replace_calls == ["foo"]
def test_lora_manager_replaces_packed_layer_when_targeting_sublayers(monkeypatch):
import vllm_omni.diffusion.lora.manager as manager_mod
class _DummyBaseLayerWithLoRA(torch.nn.Module):
def __init__(self, base_layer: torch.nn.Module):
super().__init__()
self.base_layer = base_layer
monkeypatch.setattr(manager_mod, "BaseLayerWithLoRA", _DummyBaseLayerWithLoRA)
def _fake_from_layer_diffusion(*, layer: torch.nn.Module, **_kwargs):
return _DummyBaseLayerWithLoRA(layer)
replace_calls: list[str] = []
def _fake_replace_submodule(root: torch.nn.Module, module_name: str, submodule: torch.nn.Module):
replace_calls.append(module_name)
setattr(root, module_name, submodule)
monkeypatch.setattr(manager_mod, "from_layer_diffusion", _fake_from_layer_diffusion)
monkeypatch.setattr(manager_mod, "replace_submodule", _fake_replace_submodule)
pipeline = torch.nn.Module()
pipeline.packed_modules_mapping = {"to_qkv": ["to_q", "to_k", "to_v"]}
pipeline.transformer = torch.nn.Module()
pipeline.transformer.to_qkv = _FakeLinearBase()
manager = DiffusionLoRAManager(
pipeline=pipeline,
device=torch.device("cpu"),
dtype=torch.bfloat16,
max_cached_adapters=1,
)
# Treat the dummy layer as a packed 3-slice projection so the manager uses
# `packed_modules_mapping` to decide replacement based on target_modules.
monkeypatch.setattr(manager, "_get_packed_modules_list", lambda _module: ["q", "k", "v"])
peft_helper = type("_PH", (), {"r": 1, "target_modules": ["to_q"]})()
manager._replace_layers_with_lora(peft_helper)
assert replace_calls == ["to_qkv"]
def test_lora_manager_activates_fused_lora_on_packed_layer():
manager = DiffusionLoRAManager(
pipeline=torch.nn.Module(),
device=torch.device("cpu"),
dtype=torch.bfloat16,
max_cached_adapters=1,
)
packed_layer = _DummyLoRALayer(n_slices=3, output_slices=(2, 1, 1))
manager._lora_modules = {"transformer.blocks.0.attn.to_qkv": packed_layer}
rank = 2
A = torch.ones((rank, 4))
B = torch.arange(0, sum(packed_layer.output_slices) * rank, dtype=torch.bfloat16).view(-1, rank)
lora = LoRALayerWeights(
module_name="transformer.blocks.0.attn.to_qkv",
rank=rank,
lora_alpha=rank,
lora_a=A,
lora_b=B,
)
manager._registered_adapters = {
7: type(
"LM",
(),
{
"id": 7,
"loras": {"transformer.blocks.0.attn.to_qkv": lora},
"get_lora": lambda self, k: self.loras.get(k),
},
)()
}
manager._adapter_scales = {7: 0.5}
manager._activate_adapter(7)
assert packed_layer.reset_calls == 0
assert len(packed_layer.set_calls) == 1
lora_a_list, lora_b_list = packed_layer.set_calls[0]
assert isinstance(lora_a_list, list)
assert isinstance(lora_b_list, list)
assert len(lora_a_list) == 3
assert len(lora_b_list) == 3
assert all(torch.allclose(a, A) for a in lora_a_list)
# B should be split into 3 slices and scaled.
b0, b1, b2 = lora_b_list
assert b0.shape[0] == 2 and b1.shape[0] == 1 and b2.shape[0] == 1
assert torch.allclose(torch.cat([b0, b1, b2], dim=0), B * 0.5)
def test_lora_manager_activates_packed_lora_from_sublayers():
pipeline = torch.nn.Module()
pipeline.packed_modules_mapping = {"to_qkv": ["to_q", "to_k", "to_v"]}
manager = DiffusionLoRAManager(
pipeline=pipeline,
device=torch.device("cpu"),
dtype=torch.bfloat16,
max_cached_adapters=1,
)
packed_layer = _DummyLoRALayer(n_slices=3, output_slices=(2, 1, 1))
manager._lora_modules = {"transformer.blocks.0.attn.to_qkv": packed_layer}
rank = 2
loras: dict[str, LoRALayerWeights] = {}
for name, out_dim in zip(["to_q", "to_k", "to_v"], [2, 1, 1]):
loras[f"transformer.blocks.0.attn.{name}"] = LoRALayerWeights(
module_name=f"transformer.blocks.0.attn.{name}",
rank=rank,
lora_alpha=rank,
lora_a=torch.ones((rank, 4)) * (1 if name == "to_q" else 2),
lora_b=torch.ones((out_dim, rank)) * (3 if name == "to_q" else 4),
)
manager._registered_adapters = {
1: type("LM", (), {"id": 1, "loras": loras, "get_lora": lambda self, k: self.loras.get(k)})()
}
manager._adapter_scales = {1: 2.0}
manager._activate_adapter(1)
assert packed_layer.reset_calls == 0
assert len(packed_layer.set_calls) == 1
lora_a_list, lora_b_list = packed_layer.set_calls[0]
assert isinstance(lora_a_list, list)
assert isinstance(lora_b_list, list)
assert len(lora_a_list) == 3
assert len(lora_b_list) == 3
# Scale should apply to B only.
assert torch.allclose(lora_b_list[0], torch.ones((2, rank)) * 3 * 2.0)
assert torch.allclose(lora_b_list[1], torch.ones((1, rank)) * 4 * 2.0)
assert torch.allclose(lora_b_list[2], torch.ones((1, rank)) * 4 * 2.0)
def _dummy_lora_request(adapter_id: int) -> LoRARequest:
return LoRARequest(
lora_name=f"adapter_{adapter_id}",
lora_int_id=adapter_id,
lora_path=f"/tmp/adapter_{adapter_id}",
)
def test_lora_manager_evicts_lru_adapter_when_cache_full(monkeypatch):
manager = DiffusionLoRAManager(
pipeline=torch.nn.Module(),
device=torch.device("cpu"),
dtype=torch.bfloat16,
max_cached_adapters=2,
)
def _fake_load(_req: LoRARequest):
lora_model = type("LM", (), {"id": _req.lora_int_id})()
peft_helper = type("PH", (), {})()
return lora_model, peft_helper
monkeypatch.setattr(manager, "_load_adapter", _fake_load)
monkeypatch.setattr(manager, "_replace_layers_with_lora", lambda _peft: None)
monkeypatch.setattr(manager, "_activate_adapter", lambda _adapter_id: None)
req1 = _dummy_lora_request(1)
req2 = _dummy_lora_request(2)
req3 = _dummy_lora_request(3)
manager.set_active_adapter(req1, lora_scale=1.0)
manager.set_active_adapter(req2, lora_scale=1.0)
# Touch adapter 1 so adapter 2 becomes LRU.
manager.set_active_adapter(req1, lora_scale=1.0)
manager.set_active_adapter(req3, lora_scale=1.0)
assert set(manager.list_adapters()) == {1, 3}
def test_lora_manager_does_not_evict_pinned_adapter(monkeypatch):
manager = DiffusionLoRAManager(
pipeline=torch.nn.Module(),
device=torch.device("cpu"),
dtype=torch.bfloat16,
max_cached_adapters=2,
)
def _fake_load(_req: LoRARequest):
lora_model = type("LM", (), {"id": _req.lora_int_id})()
peft_helper = type("PH", (), {})()
return lora_model, peft_helper
monkeypatch.setattr(manager, "_load_adapter", _fake_load)
monkeypatch.setattr(manager, "_replace_layers_with_lora", lambda _peft: None)
monkeypatch.setattr(manager, "_activate_adapter", lambda _adapter_id: None)
manager.set_active_adapter(_dummy_lora_request(1), lora_scale=1.0)
assert manager.pin_adapter(1)
manager.set_active_adapter(_dummy_lora_request(2), lora_scale=1.0)
manager.set_active_adapter(_dummy_lora_request(3), lora_scale=1.0)
assert set(manager.list_adapters()) == {1, 3}
def test_lora_manager_warns_when_all_adapters_pinned(monkeypatch):
manager = DiffusionLoRAManager(
pipeline=torch.nn.Module(),
device=torch.device("cpu"),
dtype=torch.bfloat16,
max_cached_adapters=2,
)
def _fake_load(_req: LoRARequest):
lora_model = type("LM", (), {"id": _req.lora_int_id})()
peft_helper = type("PH", (), {})()
return lora_model, peft_helper
monkeypatch.setattr(manager, "_load_adapter", _fake_load)
monkeypatch.setattr(manager, "_replace_layers_with_lora", lambda _peft: None)
monkeypatch.setattr(manager, "_activate_adapter", lambda _adapter_id: None)
manager.set_active_adapter(_dummy_lora_request(1), lora_scale=1.0)
manager.set_active_adapter(_dummy_lora_request(2), lora_scale=1.0)
assert manager.pin_adapter(1)
assert manager.pin_adapter(2)
manager.max_cached_adapters = 1
manager._evict_if_needed()
assert set(manager.list_adapters()) == {1, 2}
@@ -1,10 +1,12 @@
import unittest import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import torch import torch
from vllm_omni.diffusion.models.bagel.pipeline_bagel import BagelPipeline from vllm_omni.diffusion.models.bagel.pipeline_bagel import BagelPipeline
from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.worker.gpu_ar_model_runner import GPUARModelRunner from vllm_omni.worker.gpu_ar_model_runner import GPUARModelRunner
@@ -109,11 +111,12 @@ class TestKVFlow(unittest.TestCase):
transfer_data.metadata = {"kv_lens": [self.seq_len], "ropes": [0]} transfer_data.metadata = {"kv_lens": [self.seq_len], "ropes": [0]}
# 2. Setup Request with Injected Data # 2. Setup Request with Injected Data
req = OmniDiffusionRequest(prompt="test") sp = OmniDiffusionSamplingParams(
from types import SimpleNamespace past_key_values=SimpleNamespace(**layer_blocks),
kv_metadata=transfer_data.metadata,
)
req.past_key_values = SimpleNamespace(**layer_blocks) req = OmniDiffusionRequest(["test"], sp)
req.kv_metadata = transfer_data.metadata
# 3. Setup Pipeline # 3. Setup Pipeline
pipeline = MockBagelPipeline() pipeline = MockBagelPipeline()
@@ -144,7 +147,7 @@ class TestKVFlow(unittest.TestCase):
current_cache = RealNaiveCache(self.num_layers) current_cache = RealNaiveCache(self.num_layers)
# --- Logic from Source Code --- # --- Logic from Source Code ---
injected_kv = req.past_key_values injected_kv = req.sampling_params.past_key_values
if isinstance(current_cache, RealNaiveCache) and hasattr(injected_kv, "key_cache"): if isinstance(current_cache, RealNaiveCache) and hasattr(injected_kv, "key_cache"):
# Assuming injected_kv is SimpleNamespace or object with list attrs # Assuming injected_kv is SimpleNamespace or object with list attrs
for layer_idx in range(len(injected_kv.key_cache)): for layer_idx in range(len(injected_kv.key_cache)):
@@ -176,9 +179,11 @@ class TestKVFlow(unittest.TestCase):
data_dict = runner_test_result.to_dict() data_dict = runner_test_result.to_dict()
# 3. Receiver (Request Setup) # 3. Receiver (Request Setup)
req = OmniDiffusionRequest(prompt="integration_test") sp = OmniDiffusionSamplingParams(
req.past_key_values = data_dict["layer_blocks"] past_key_values=data_dict["layer_blocks"],
req.kv_metadata = data_dict["metadata"] kv_metadata=data_dict["metadata"],
)
req = OmniDiffusionRequest(["integration_test"], sp) # noqa: F841
# 4. Receiver (Injection Simulation) # 4. Receiver (Injection Simulation)
# Use the logic verification again # Use the logic verification again
+13 -10
View File
@@ -7,10 +7,12 @@ Pytest configuration and fixtures for vllm-omni tests.
from typing import Any from typing import Any
import pytest import pytest
from vllm import TextPrompt
from vllm.distributed.parallel_state import cleanup_dist_env_and_memory from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
from vllm.sampling_params import SamplingParams
from tests.conftest import _run_post_test_cleanup
from vllm_omni.entrypoints.omni import Omni from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniSamplingParams
from vllm_omni.outputs import OmniRequestOutput from vllm_omni.outputs import OmniRequestOutput
PromptAudioInput = list[tuple[Any, int]] | tuple[Any, int] | None PromptAudioInput = list[tuple[Any, int]] | tuple[Any, int] | None
@@ -63,7 +65,7 @@ class OmniRunner:
**kwargs, **kwargs,
) )
def get_default_sampling_params_list(self) -> list[SamplingParams]: def get_default_sampling_params_list(self) -> list[OmniSamplingParams]:
""" """
Get a list of default sampling parameters for all stages. Get a list of default sampling parameters for all stages.
@@ -81,7 +83,7 @@ class OmniRunner:
videos: PromptVideoInput = None, videos: PromptVideoInput = None,
mm_processor_kwargs: dict[str, Any] | None = None, mm_processor_kwargs: dict[str, Any] | None = None,
modalities: list[str] | None = None, modalities: list[str] | None = None,
) -> list[dict[str, Any]]: ) -> list[TextPrompt]:
""" """
Construct Omni input format from prompts and multimodal data. Construct Omni input format from prompts and multimodal data.
@@ -174,7 +176,7 @@ class OmniRunner:
f"<|im_start|>assistant\n" f"<|im_start|>assistant\n"
) )
input_dict: dict[str, Any] = {"prompt": full_prompt} input_dict: TextPrompt = {"prompt": full_prompt}
if multi_modal_data: if multi_modal_data:
input_dict["multi_modal_data"] = multi_modal_data input_dict["multi_modal_data"] = multi_modal_data
if modalities: if modalities:
@@ -188,8 +190,8 @@ class OmniRunner:
def generate( def generate(
self, self,
prompts: list[dict[str, Any]], prompts: list[TextPrompt],
sampling_params_list: list[SamplingParams] | None = None, sampling_params_list: list[OmniSamplingParams] | None = None,
) -> list[OmniRequestOutput]: ) -> list[OmniRequestOutput]:
""" """
Generate outputs for the given prompts. Generate outputs for the given prompts.
@@ -211,7 +213,7 @@ class OmniRunner:
def generate_multimodal( def generate_multimodal(
self, self,
prompts: list[str] | str, prompts: list[str] | str,
sampling_params_list: list[SamplingParams] | None = None, sampling_params_list: list[OmniSamplingParams] | None = None,
system_prompt: str | None = None, system_prompt: str | None = None,
audios: PromptAudioInput = None, audios: PromptAudioInput = None,
images: PromptImageInput = None, images: PromptImageInput = None,
@@ -248,7 +250,7 @@ class OmniRunner:
def generate_audio( def generate_audio(
self, self,
prompts: list[str] | str, prompts: list[str] | str,
sampling_params_list: list[SamplingParams] | None = None, sampling_params_list: list[OmniSamplingParams] | None = None,
system_prompt: str | None = None, system_prompt: str | None = None,
audios: PromptAudioInput = None, audios: PromptAudioInput = None,
mm_processor_kwargs: dict[str, Any] | None = None, mm_processor_kwargs: dict[str, Any] | None = None,
@@ -275,7 +277,7 @@ class OmniRunner:
def generate_video( def generate_video(
self, self,
prompts: list[str] | str, prompts: list[str] | str,
sampling_params_list: list[SamplingParams] | None = None, sampling_params_list: list[OmniSamplingParams] | None = None,
system_prompt: str | None = None, system_prompt: str | None = None,
videos: PromptVideoInput = None, videos: PromptVideoInput = None,
mm_processor_kwargs: dict[str, Any] | None = None, mm_processor_kwargs: dict[str, Any] | None = None,
@@ -302,7 +304,7 @@ class OmniRunner:
def generate_image( def generate_image(
self, self,
prompts: list[str] | str, prompts: list[str] | str,
sampling_params_list: list[SamplingParams] | None = None, sampling_params_list: list[OmniSamplingParams] | None = None,
system_prompt: str | None = None, system_prompt: str | None = None,
images: PromptImageInput = None, images: PromptImageInput = None,
mm_processor_kwargs: dict[str, Any] | None = None, mm_processor_kwargs: dict[str, Any] | None = None,
@@ -335,6 +337,7 @@ class OmniRunner:
self.close() self.close()
del self.omni del self.omni
cleanup_dist_env_and_memory() cleanup_dist_env_and_memory()
_run_post_test_cleanup()
def close(self): def close(self):
"""Close and cleanup the Omni instance.""" """Close and cleanup the Omni instance."""
+45 -34
View File
@@ -15,6 +15,8 @@ from pathlib import Path
import pytest import pytest
import torch import torch
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
# ruff: noqa: E402 # ruff: noqa: E402
REPO_ROOT = Path(__file__).resolve().parents[2] REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path: if str(REPO_ROOT) not in sys.path:
@@ -40,42 +42,51 @@ def test_cache_dit(model_name: str):
"residual_diff_threshold": 0.24, "residual_diff_threshold": 0.24,
"max_continuous_cached_steps": 3, "max_continuous_cached_steps": 3,
} }
m = None
try:
m = Omni(
model=model_name,
cache_backend="cache_dit",
cache_config=cache_config,
)
m = Omni( # Use minimal settings for fast testing
model=model_name, height = 256
cache_backend="cache_dit", width = 256
cache_config=cache_config, num_inference_steps = 4 # Minimal steps for fast test
)
# Use minimal settings for fast testing outputs = m.generate(
height = 256 "a photo of a cat sitting on a laptop keyboard",
width = 256 OmniDiffusionSamplingParams(
num_inference_steps = 4 # Minimal steps for fast test height=height,
width=width,
num_inference_steps=num_inference_steps,
guidance_scale=0.0,
generator=torch.Generator("cuda").manual_seed(42),
num_outputs_per_prompt=1, # Single output for speed
),
)
# Extract images from request_output[0]['images']
first_output = outputs[0]
assert first_output.final_output_type == "image"
if not hasattr(first_output, "request_output") or not first_output.request_output:
raise ValueError("No request_output found in OmniRequestOutput")
outputs = m.generate( req_out = first_output.request_output[0]
"a photo of a cat sitting on a laptop keyboard", if not isinstance(req_out, OmniRequestOutput) or not hasattr(req_out, "images"):
height=height, raise ValueError("Invalid request_output structure or missing 'images' key")
width=width,
num_inference_steps=num_inference_steps,
guidance_scale=0.0,
generator=torch.Generator("cuda").manual_seed(42),
num_outputs_per_prompt=1, # Single output for speed
)
# Extract images from request_output[0]['images']
first_output = outputs[0]
assert first_output.final_output_type == "image"
if not hasattr(first_output, "request_output") or not first_output.request_output:
raise ValueError("No request_output found in OmniRequestOutput")
req_out = first_output.request_output[0] images = req_out.images
if not isinstance(req_out, OmniRequestOutput) or not hasattr(req_out, "images"):
raise ValueError("Invalid request_output structure or missing 'images' key")
images = req_out.images # Verify generation succeeded
assert images is not None
# Verify generation succeeded assert len(images) == 1
assert images is not None # Check image size
assert len(images) == 1 assert images[0].width == width
# Check image size assert images[0].height == height
assert images[0].width == width except Exception as e:
assert images[0].height == height print(f"Test failed with error: {e}")
raise
finally:
if m is not None and hasattr(m, "close"):
m.close()
@@ -3,8 +3,10 @@ from pathlib import Path
import pytest import pytest
import torch import torch
from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
from tests.utils import GPUMemoryMonitor from tests.utils import GPUMemoryMonitor
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.utils.platform_utils import is_npu, is_rocm from vllm_omni.utils.platform_utils import is_npu, is_rocm
# ruff: noqa: E402 # ruff: noqa: E402
@@ -17,34 +19,41 @@ from vllm_omni import Omni
models = ["riverclouds/qwen_image_random"] models = ["riverclouds/qwen_image_random"]
@pytest.mark.skipif(is_npu() or is_rocm(), reason="Hardware not supported") def inference(model_name: str, offload: bool = True):
@pytest.mark.parametrize("model_name", models) torch.cuda.empty_cache()
def test_cpu_offload_diffusion_model(model_name: str): device_index = torch.cuda.current_device()
def inference(offload: bool = True): monitor = GPUMemoryMonitor(device_index=device_index, interval=0.02)
torch.cuda.empty_cache() monitor.start()
device_index = torch.cuda.current_device() m = Omni(model=model_name, enable_cpu_offload=offload)
monitor = GPUMemoryMonitor(device_index=device_index, interval=0.02) torch.cuda.reset_peak_memory_stats(device=device_index)
monitor.start() height = 256
m = Omni(model=model_name, enable_cpu_offload=offload) width = 256
torch.cuda.reset_peak_memory_stats(device=device_index)
height = 256
width = 256
m.generate( m.generate(
"a photo of a cat sitting on a laptop keyboard", "a photo of a cat sitting on a laptop keyboard",
OmniDiffusionSamplingParams(
height=height, height=height,
width=width, width=width,
num_inference_steps=9, num_inference_steps=9,
guidance_scale=0.0, guidance_scale=0.0,
generator=torch.Generator("cuda").manual_seed(42), generator=torch.Generator("cuda").manual_seed(42),
) ),
peak = monitor.peak_used_mb )
monitor.stop() peak = monitor.peak_used_mb
monitor.stop()
return peak return peak
offload_peak_memory = inference(offload=True)
no_offload_peak_memory = inference(offload=False) @pytest.mark.skipif(is_npu() or is_rocm(), reason="Hardware not supported")
@pytest.mark.parametrize("model_name", models)
def test_cpu_offload_diffusion_model(model_name: str):
try:
no_offload_peak_memory = inference(model_name, offload=False)
cleanup_dist_env_and_memory()
offload_peak_memory = inference(model_name, offload=True)
except Exception:
pytest.fail("Inference failed")
print(f"Offload peak memory: {offload_peak_memory} MB") print(f"Offload peak memory: {offload_peak_memory} MB")
print(f"No offload peak memory: {no_offload_peak_memory} MB") print(f"No offload peak memory: {no_offload_peak_memory} MB")
assert offload_peak_memory + 2500 < no_offload_peak_memory, ( assert offload_peak_memory + 2500 < no_offload_peak_memory, (
@@ -0,0 +1,143 @@
import json
import os
import sys
from pathlib import Path
import pytest
import torch
from safetensors.torch import save_file
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.utils.platform_utils import is_npu
# ruff: noqa: E402
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from vllm_omni import Omni
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "1"
# This test is specific to Z-Image LoRA behavior. Keep it focused on a single
# model to reduce runtime and avoid extra downloads.
models = ["Tongyi-MAI/Z-Image-Turbo"]
# NPU still can't run Tongyi-MAI/Z-Image-Turbo properly.
if is_npu():
pytest.skip("Tongyi-MAI/Z-Image-Turbo is not supported on NPU yet.", allow_module_level=True)
@pytest.mark.parametrize("model_name", models)
def test_diffusion_model(model_name: str, tmp_path: Path):
def _extract_images(outputs: list[OmniRequestOutput]):
if not outputs:
raise ValueError("Empty outputs from Omni.generate()")
first_output = outputs[0]
assert first_output.final_output_type == "image"
if not hasattr(first_output, "request_output") or not first_output.request_output:
raise ValueError("No request_output found in OmniRequestOutput")
req_out = first_output.request_output[0]
if not isinstance(req_out, OmniRequestOutput) or not hasattr(req_out, "images"):
raise ValueError("Invalid request_output structure or missing 'images' key")
return req_out.images
def _write_zimage_lora(adapter_dir: Path) -> str:
adapter_dir.mkdir(parents=True, exist_ok=True)
# Z-Image transformer uses dim=3840 by default (see ZImageTransformer2DModel).
dim = 3840
module_name = "transformer.layers.0.attention.to_qkv"
rank = 1
lora_a = torch.zeros((rank, dim), dtype=torch.float32)
lora_a[0, 0] = 1.0
# QKVParallelLinear packs (Q, K, V). With tp=1 and n_kv_heads==n_heads in Z-Image,
# each slice is `dim`, so total out dim is `3 * dim`.
lora_b = torch.zeros((3 * dim, rank), dtype=torch.float32)
# Apply a visible delta to the Q slice only to keep the perturbation bounded.
lora_b[:dim, 0] = 0.1
save_file(
{
f"base_model.model.{module_name}.lora_A.weight": lora_a,
f"base_model.model.{module_name}.lora_B.weight": lora_b,
},
str(adapter_dir / "adapter_model.safetensors"),
)
(adapter_dir / "adapter_config.json").write_text(
json.dumps(
{
"r": rank,
"lora_alpha": rank,
"target_modules": [module_name],
}
),
encoding="utf-8",
)
return str(adapter_dir)
m = Omni(model=model_name)
try:
# high resolution may cause OOM on L4
height = 256
width = 256
prompt = "a photo of a cat sitting on a laptop keyboard"
outputs = m.generate(
prompt,
OmniDiffusionSamplingParams(
height=height,
width=width,
num_inference_steps=2,
guidance_scale=0.0,
generator=torch.Generator("cuda").manual_seed(42),
num_outputs_per_prompt=1,
),
)
images = _extract_images(outputs)
assert len(images) == 1
# check image size
assert images[0].width == width
assert images[0].height == height
# Real LoRA E2E: generate again with a real on-disk PEFT adapter and
# verify that output changes.
if model_name == "Tongyi-MAI/Z-Image-Turbo":
from vllm_omni.lora.request import LoRARequest
from vllm_omni.lora.utils import stable_lora_int_id
lora_dir = _write_zimage_lora(tmp_path / "zimage_lora")
lora_request = LoRARequest(
lora_name="test",
lora_int_id=stable_lora_int_id(lora_dir),
lora_path=lora_dir,
)
outputs_lora = m.generate(
prompt,
OmniDiffusionSamplingParams(
height=height,
width=width,
num_inference_steps=2,
guidance_scale=0.0,
generator=torch.Generator("cuda").manual_seed(42),
num_outputs_per_prompt=1,
lora_request=lora_request,
lora_scale=2.0,
),
)
images_lora = _extract_images(outputs_lora)
assert len(images_lora) == 1
assert images_lora[0].width == width
assert images_lora[0].height == height
import numpy as np
diff = np.abs(np.array(images[0], dtype=np.int16) - np.array(images_lora[0], dtype=np.int16)).mean()
assert diff > 0.0
finally:
m.close()
+25 -14
View File
@@ -25,6 +25,7 @@ from vllm_omni.diffusion.data import OmniDiffusionConfig, TransformerConfig
from vllm_omni.diffusion.distributed.utils import get_local_device from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.models.ovis_image.pipeline_ovis_image import OvisImagePipeline from vllm_omni.diffusion.models.ovis_image.pipeline_ovis_image import OvisImagePipeline
from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
@pytest.fixture @pytest.fixture
@@ -162,11 +163,13 @@ def test_basic_generation(ovis_pipeline):
"""Test the forward pass logic.""" """Test the forward pass logic."""
# Setup request # Setup request
req = OmniDiffusionRequest( req = OmniDiffusionRequest(
prompt="A photo of a cat", prompts=["A photo of a cat"],
height=256, sampling_params=OmniDiffusionSamplingParams(
width=256, height=256,
num_inference_steps=2, width=256,
guidance_scale=1.0, num_inference_steps=2,
guidance_scale=1.0,
),
) )
output = ovis_pipeline(req) output = ovis_pipeline(req)
@@ -184,12 +187,18 @@ def test_basic_generation(ovis_pipeline):
def test_guidance_scale(ovis_pipeline): def test_guidance_scale(ovis_pipeline):
"""Test that classifier-free guidance path is taken when scale > 1.0.""" """Test that classifier-free guidance path is taken when scale > 1.0."""
req = OmniDiffusionRequest( req = OmniDiffusionRequest(
prompt="A photo of a cat", prompts=[
negative_prompt="bad quality", {
height=256, "prompt": "A photo of a cat",
width=256, "negative_prompt": "bad quality",
num_inference_steps=1, }
guidance_scale=2.0, # Trigger CFG ],
sampling_params=OmniDiffusionSamplingParams(
height=256,
width=256,
num_inference_steps=1,
guidance_scale=2.0, # Trigger CFG
),
) )
ovis_pipeline(req) ovis_pipeline(req)
@@ -200,9 +209,11 @@ def test_resolution_check(ovis_pipeline):
"""Test resolution divisible validation logic if present.""" """Test resolution divisible validation logic if present."""
# Pass odd resolution # Pass odd resolution
req = OmniDiffusionRequest( req = OmniDiffusionRequest(
prompt="test", prompts=["test"],
height=250, # Not divisible by 16 (8*2) sampling_params=OmniDiffusionSamplingParams(
width=250, height=250, # Not divisible by 16 (8*2)
width=250,
),
) )
# Should warn but proceed (as per code I read earlier) or resize? # Should warn but proceed (as per code I read earlier) or resize?
@@ -7,6 +7,8 @@ E2E offline tests for Omni model with video input and audio output.
import os import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -21,6 +21,8 @@ import torch
import torch.distributed as dist import torch.distributed as dist
from PIL import Image from PIL import Image
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
# ruff: noqa: E402 # ruff: noqa: E402
REPO_ROOT = Path(__file__).resolve().parents[3] REPO_ROOT = Path(__file__).resolve().parents[3]
if str(REPO_ROOT) not in sys.path: if str(REPO_ROOT) not in sys.path:
@@ -81,12 +83,14 @@ def _run_baseline(model_name: str, dtype: torch.dtype, attn_backend: str, height
try: try:
outputs = baseline.generate( outputs = baseline.generate(
PROMPT, PROMPT,
height=height, OmniDiffusionSamplingParams(
width=width, height=height,
num_inference_steps=4, width=width,
guidance_scale=0.0, num_inference_steps=4,
generator=torch.Generator(get_device_name()).manual_seed(seed), guidance_scale=0.0,
num_outputs_per_prompt=1, generator=torch.Generator(get_device_name()).manual_seed(seed),
num_outputs_per_prompt=1,
),
) )
return outputs[0].request_output[0].images return outputs[0].request_output[0].images
finally: finally:
@@ -116,12 +120,14 @@ def _run_sp(
try: try:
outputs = sp.generate( outputs = sp.generate(
PROMPT, PROMPT,
height=height, OmniDiffusionSamplingParams(
width=width, height=height,
num_inference_steps=4, width=width,
guidance_scale=0.0, num_inference_steps=4,
generator=torch.Generator(get_device_name()).manual_seed(seed), guidance_scale=0.0,
num_outputs_per_prompt=1, generator=torch.Generator(get_device_name()).manual_seed(seed),
num_outputs_per_prompt=1,
),
) )
return outputs[0].request_output[0].images return outputs[0].request_output[0].images
finally: finally:
@@ -5,6 +5,7 @@ import numpy as np
import pytest import pytest
import torch import torch
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput from vllm_omni.outputs import OmniRequestOutput
# ruff: noqa: E402 # ruff: noqa: E402
@@ -29,30 +30,33 @@ def test_stable_audio_model(model_name: str):
sample_rate = 44100 # Stable Audio uses 44100 Hz sample_rate = 44100 # Stable Audio uses 44100 Hz
outputs = m.generate( outputs = m.generate(
"The sound of a dog barking", prompts={
negative_prompt="Low quality.", "prompt": "The sound of a dog barking",
num_inference_steps=4, # Minimal steps for speed "negative_prompt": "Low quality.",
guidance_scale=7.0,
generator=torch.Generator("cuda").manual_seed(42),
num_outputs_per_prompt=1,
extra={
"audio_start_in_s": audio_start_in_s,
"audio_end_in_s": audio_end_in_s,
}, },
sampling_params_list=OmniDiffusionSamplingParams(
num_inference_steps=4, # Minimal steps for speed
guidance_scale=7.0,
generator=torch.Generator("cuda").manual_seed(42),
num_outputs_per_prompt=1,
extra_args={
"audio_start_in_s": audio_start_in_s,
"audio_end_in_s": audio_end_in_s,
},
),
) )
# Extract audio from OmniRequestOutput # Extract audio from OmniRequestOutput
assert outputs is not None assert outputs is not None
first_output = outputs[0] first_output = outputs[0]
assert first_output.final_output_type == "image" # Generic output type assert first_output.final_output_type == "image"
assert hasattr(first_output, "request_output") and first_output.request_output assert hasattr(first_output, "request_output") and first_output.request_output
req_out = first_output.request_output[0] req_out = first_output.request_output[0]
assert isinstance(req_out, OmniRequestOutput) assert isinstance(req_out, OmniRequestOutput)
assert hasattr(req_out, "images") and len(req_out.images) >= 1 assert req_out.final_output_type == "audio"
assert hasattr(req_out, "multimodal_output") and req_out.multimodal_output
# For stable audio, the "images" field contains audio numpy arrays audio = req_out.multimodal_output.get("audio")
audio = req_out.images[0]
assert isinstance(audio, np.ndarray) assert isinstance(audio, np.ndarray)
# audio shape: (batch, channels, samples) # audio shape: (batch, channels, samples)
# For stable-audio-open-1.0: sample_rate=44100, so 2 seconds = 88200 samples # For stable-audio-open-1.0: sample_rate=44100, so 2 seconds = 88200 samples
+38 -27
View File
@@ -5,6 +5,7 @@ from pathlib import Path
import pytest import pytest
import torch import torch
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.utils.platform_utils import is_npu, is_rocm from vllm_omni.utils.platform_utils import is_npu, is_rocm
@@ -34,33 +35,43 @@ elif is_rocm():
@pytest.mark.parametrize("model_name", models) @pytest.mark.parametrize("model_name", models)
def test_diffusion_model(model_name: str): def test_diffusion_model(model_name: str):
m = Omni(model=model_name) m = None
# high resolution may cause OOM on L4 try:
height = 256 m = Omni(model=model_name)
width = 256 # high resolution may cause OOM on L4
outputs = m.generate( height = 256
"a photo of a cat sitting on a laptop keyboard", width = 256
height=height, outputs = m.generate(
width=width, "a photo of a cat sitting on a laptop keyboard",
num_inference_steps=2, OmniDiffusionSamplingParams(
guidance_scale=0.0, height=height,
generator=torch.Generator("cuda").manual_seed(42), width=width,
num_outputs_per_prompt=2, num_inference_steps=2,
) guidance_scale=0.0,
# Extract images from request_output[0]['images'] generator=torch.Generator("cuda").manual_seed(42),
first_output = outputs[0] num_outputs_per_prompt=2,
assert first_output.final_output_type == "image" ),
if not hasattr(first_output, "request_output") or not first_output.request_output: )
raise ValueError("No request_output found in OmniRequestOutput") # Extract images from request_output[0]['images']
first_output = outputs[0]
assert first_output.final_output_type == "image"
if not hasattr(first_output, "request_output") or not first_output.request_output:
raise ValueError("No request_output found in OmniRequestOutput")
req_out = first_output.request_output[0] req_out = first_output.request_output[0]
if not isinstance(req_out, OmniRequestOutput) or not hasattr(req_out, "images"): if not isinstance(req_out, OmniRequestOutput) or not hasattr(req_out, "images"):
raise ValueError("Invalid request_output structure or missing 'images' key") raise ValueError("Invalid request_output structure or missing 'images' key")
images = req_out.images images = req_out.images
assert len(images) == 2 assert len(images) == 2
# check image size # check image size
assert images[0].width == width assert images[0].width == width
assert images[0].height == height assert images[0].height == height
images[0].save("image_output.png") images[0].save("image_output.png")
except Exception as e:
print(f"Test failed with error: {e}")
raise
finally:
if m is not None and hasattr(m, "close"):
m.close()
+11 -7
View File
@@ -5,6 +5,8 @@ from pathlib import Path
import pytest import pytest
import torch import torch
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
# ruff: noqa: E402 # ruff: noqa: E402
REPO_ROOT = Path(__file__).resolve().parents[2] REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path: if str(REPO_ROOT) not in sys.path:
@@ -33,13 +35,15 @@ def test_video_diffusion_model(model_name: str):
width = 640 width = 640
num_frames = 5 num_frames = 5
outputs = m.generate( outputs = m.generate(
"A cat sitting on a table", prompts="A cat sitting on a table",
height=height, sampling_params_list=OmniDiffusionSamplingParams(
width=width, height=height,
num_frames=num_frames, width=width,
num_inference_steps=2, num_frames=num_frames,
guidance_scale=1.0, num_inference_steps=2,
generator=torch.Generator("cuda").manual_seed(42), guidance_scale=1.0,
generator=torch.Generator("cuda").manual_seed(42),
),
) )
first_output = outputs[0] first_output = outputs[0]
assert first_output.final_output_type == "image" assert first_output.final_output_type == "image"
+45 -34
View File
@@ -15,6 +15,8 @@ from pathlib import Path
import pytest import pytest
import torch import torch
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
# ruff: noqa: E402 # ruff: noqa: E402
REPO_ROOT = Path(__file__).resolve().parents[2] REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path: if str(REPO_ROOT) not in sys.path:
@@ -36,42 +38,51 @@ def test_teacache(model_name: str):
cache_config = { cache_config = {
"rel_l1_thresh": 0.2, # Default threshold "rel_l1_thresh": 0.2, # Default threshold
} }
m = None
try:
m = Omni(
model=model_name,
cache_backend="tea_cache",
cache_config=cache_config,
)
m = Omni( # Use minimal settings for fast testing
model=model_name, height = 256
cache_backend="tea_cache", width = 256
cache_config=cache_config, num_inference_steps = 4 # Minimal steps for fast test
)
# Use minimal settings for fast testing outputs = m.generate(
height = 256 "a photo of a cat sitting on a laptop keyboard",
width = 256 OmniDiffusionSamplingParams(
num_inference_steps = 4 # Minimal steps for fast test height=height,
width=width,
num_inference_steps=num_inference_steps,
guidance_scale=0.0,
generator=torch.Generator("cuda").manual_seed(42),
num_outputs_per_prompt=1, # Single output for speed
),
)
# Extract images from request_output[0]['images']
first_output = outputs[0]
assert first_output.final_output_type == "image"
if not hasattr(first_output, "request_output") or not first_output.request_output:
raise ValueError("No request_output found in OmniRequestOutput")
outputs = m.generate( req_out = first_output.request_output[0]
"a photo of a cat sitting on a laptop keyboard", if not isinstance(req_out, OmniRequestOutput) or not hasattr(req_out, "images"):
height=height, raise ValueError("Invalid request_output structure or missing 'images' key")
width=width,
num_inference_steps=num_inference_steps,
guidance_scale=0.0,
generator=torch.Generator("cuda").manual_seed(42),
num_outputs_per_prompt=1, # Single output for speed
)
# Extract images from request_output[0]['images']
first_output = outputs[0]
assert first_output.final_output_type == "image"
if not hasattr(first_output, "request_output") or not first_output.request_output:
raise ValueError("No request_output found in OmniRequestOutput")
req_out = first_output.request_output[0] images = req_out.images
if not isinstance(req_out, OmniRequestOutput) or not hasattr(req_out, "images"):
raise ValueError("Invalid request_output structure or missing 'images' key")
images = req_out.images # Verify generation succeeded
assert images is not None
# Verify generation succeeded assert len(images) == 1
assert images is not None # Check image size
assert len(images) == 1 assert images[0].width == width
# Check image size assert images[0].height == height
assert images[0].width == width except Exception as e:
assert images[0].height == height print(f"Test failed with error: {e}")
raise
finally:
if m is not None and hasattr(m, "close"):
m.close()
@@ -12,6 +12,8 @@ import torch
from PIL import Image from PIL import Image
from vllm.distributed.parallel_state import cleanup_dist_env_and_memory from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
# ruff: noqa: E402 # ruff: noqa: E402
REPO_ROOT = Path(__file__).resolve().parents[2] REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path: if str(REPO_ROOT) not in sys.path:
@@ -90,12 +92,14 @@ def _run_zimage_generate(
num_requests = 4 # 1 warmup + 3 timed num_requests = 4 # 1 warmup + 3 timed
gen = m.generate( gen = m.generate(
[PROMPT] * num_requests, [PROMPT] * num_requests,
height=height, OmniDiffusionSamplingParams(
width=width, height=height,
num_inference_steps=num_inference_steps, width=width,
guidance_scale=0.0, num_inference_steps=num_inference_steps,
seed=seed, guidance_scale=0.0,
num_outputs_per_prompt=1, seed=seed,
num_outputs_per_prompt=1,
),
py_generator=True, py_generator=True,
) )
+10 -9
View File
@@ -5,6 +5,7 @@ from contextlib import ExitStack
from pathlib import Path from pathlib import Path
import pytest import pytest
from vllm import SamplingParams
from vllm.inputs import PromptType from vllm.inputs import PromptType
from vllm_omni.entrypoints.async_omni import AsyncOmni, ClientRequestState from vllm_omni.entrypoints.async_omni import AsyncOmni, ClientRequestState
@@ -25,15 +26,15 @@ async def generate(
) -> tuple[int, str]: ) -> tuple[int, str]:
# Ensure generate doesn't complete too fast for cancellation test. # Ensure generate doesn't complete too fast for cancellation test.
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
thinker_sampling_params = { thinker_sampling_params = SamplingParams(
"temperature": 0.4, # Deterministic temperature=0.4, # Deterministic
"top_p": 0.9, top_p=0.9,
"top_k": 1, top_k=1,
"max_tokens": max_tokens, max_tokens=max_tokens,
"repetition_penalty": 1.05, repetition_penalty=1.05,
"stop_token_ids": [151645], # Qwen EOS token <|im_end|> stop_token_ids=[151645], # Qwen EOS token <|im_end|>
"seed": SEED, seed=SEED,
} )
sampling_params_list = [ sampling_params_list = [
thinker_sampling_params, thinker_sampling_params,
@@ -0,0 +1,259 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
E2E online serving test for /v1/images/generations with per-request LoRA.
This validates:
- The API server accepts a per-request `lora` object in the Images API payload.
- LoRA can be switched per request (adapter A -> adapter B -> no LoRA).
- Output correctness is asserted using a small image slice with tolerance.
"""
import base64
import json
import os
import signal
import subprocess
import sys
import time
from io import BytesIO
from pathlib import Path
import numpy as np
import pytest
import requests
import torch
from PIL import Image
from safetensors.torch import save_file
from vllm.utils.network_utils import get_open_port
from vllm_omni.utils.platform_utils import is_npu
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
MODEL = "Tongyi-MAI/Z-Image-Turbo"
PROMPT = "a photo of a cat sitting on a laptop keyboard"
SIZE = "256x256"
SEED = 42
class OmniServer:
"""Omniserver for vLLM-Omni tests."""
def __init__(
self,
model: str,
serve_args: list[str],
*,
env_dict: dict[str, str] | None = None,
) -> None:
self.model = model
self.serve_args = serve_args
self.env_dict = env_dict
self.proc: subprocess.Popen | None = None
self.host = "127.0.0.1"
self.port = get_open_port()
def _start_server(self) -> None:
env = os.environ.copy()
env["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
if self.env_dict is not None:
env.update(self.env_dict)
cmd = [
sys.executable,
"-m",
"vllm_omni.entrypoints.cli.main",
"serve",
self.model,
"--omni",
"--host",
self.host,
"--port",
str(self.port),
] + self.serve_args
print(f"Launching OmniServer with: {' '.join(cmd)}")
self.proc = subprocess.Popen(
cmd,
env=env,
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), # vllm-omni root
start_new_session=True,
)
# Wait for server to be ready.
max_wait = 1200
url = f"http://{self.host}:{self.port}/v1/models"
start_time = time.time()
while time.time() - start_time < max_wait:
try:
resp = requests.get(url, headers={"Authorization": "Bearer EMPTY"}, timeout=10)
if resp.status_code == 200:
print(f"Server ready on {self.host}:{self.port}")
return
except Exception:
pass
time.sleep(2)
raise RuntimeError(f"Server failed to become ready within {max_wait} seconds")
def __enter__(self):
self._start_server()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self.proc is None:
return
try:
os.killpg(self.proc.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
self.proc.wait(timeout=30)
except subprocess.TimeoutExpired:
try:
os.killpg(self.proc.pid, signal.SIGKILL)
except ProcessLookupError:
pass
self.proc.wait()
@pytest.fixture(scope="module")
def omni_server():
if is_npu():
pytest.skip("Tongyi-MAI/Z-Image-Turbo is not supported on NPU yet.")
with OmniServer(MODEL, ["--num-gpus", "1"]) as server:
yield server
def _write_zimage_lora(adapter_dir: Path, *, q_scale: float = 0.0, k_scale: float = 0.0, v_scale: float = 0.0):
adapter_dir.mkdir(parents=True, exist_ok=True)
# Z-Image transformer uses dim=3840 by default.
dim = 3840
module_name = "transformer.layers.0.attention.to_qkv"
rank = 1
lora_a = torch.zeros((rank, dim), dtype=torch.float32)
lora_a[0, 0] = 1.0
# QKVParallelLinear packs (Q, K, V) => out dim is 3 * dim (tp=1).
lora_b = torch.zeros((3 * dim, rank), dtype=torch.float32)
if q_scale:
lora_b[:dim, 0] = q_scale
if k_scale:
lora_b[dim : 2 * dim, 0] = k_scale
if v_scale:
lora_b[2 * dim :, 0] = v_scale
save_file(
{
f"base_model.model.{module_name}.lora_A.weight": lora_a,
f"base_model.model.{module_name}.lora_B.weight": lora_b,
},
str(adapter_dir / "adapter_model.safetensors"),
)
(adapter_dir / "adapter_config.json").write_text(
json.dumps(
{
"r": rank,
"lora_alpha": rank,
"target_modules": [module_name],
}
),
encoding="utf-8",
)
def _post_images(server: OmniServer, payload: dict) -> Image.Image:
url = f"http://{server.host}:{server.port}/v1/images/generations"
resp = requests.post(url, json=payload, headers={"Authorization": "Bearer EMPTY"}, timeout=900)
resp.raise_for_status()
data = resp.json()
b64 = data["data"][0]["b64_json"]
img_bytes = base64.b64decode(b64)
img = Image.open(BytesIO(img_bytes))
img.load()
return img.convert("RGB")
def _image_blue_tail_slice(img: Image.Image) -> np.ndarray:
arr = np.asarray(img, dtype=np.uint8)
assert arr.ndim == 3 and arr.shape[-1] == 3
tail = arr[-3:, -3:, -1].astype(np.float32)
assert tail.shape == (3, 3)
return tail
def _assert_slice_close(actual: np.ndarray, expected: np.ndarray, *, label: str) -> None:
assert actual.shape == (3, 3)
assert expected.shape == (3, 3)
diff = np.abs(actual - expected)
max_diff = float(diff.max())
mean_diff = float(diff.mean())
# NOTE: Different attention backends / torch.compile can introduce small
# floating-point drift that shows up as a few LSBs in uint8 pixels. Keep
# the reset check tolerant but bounded to avoid flaky CI.
assert max_diff <= 5.0 and mean_diff <= 3.0, (
f"{label} slice mismatch (max={max_diff:.1f}, mean={mean_diff:.1f}): {actual.tolist()}"
)
def _assert_slice_diff(actual: np.ndarray, baseline: np.ndarray, *, label: str) -> None:
assert actual.shape == (3, 3)
assert baseline.shape == (3, 3)
diff = np.abs(actual - baseline).mean()
assert diff > 0.1, f"{label} slice diff too small: {diff} ({actual.tolist()} vs {baseline.tolist()})"
def _basic_payload() -> dict:
return {
"prompt": PROMPT,
"n": 1,
"size": SIZE,
"num_inference_steps": 2,
"guidance_scale": 0.0,
"seed": SEED,
}
def test_images_generations_per_request_lora_switching(omni_server: OmniServer, tmp_path: Path) -> None:
# Base generation.
base_img = _post_images(omni_server, _basic_payload())
base_slice = _image_blue_tail_slice(base_img)
# Adapter A: apply delta to V slice only.
lora_a_dir = tmp_path / "zimage_lora_a"
_write_zimage_lora(lora_a_dir, v_scale=8.0)
payload_a = _basic_payload()
payload_a["lora"] = {"name": "a", "path": str(lora_a_dir), "scale": 64.0}
img_a = _post_images(omni_server, payload_a)
a_slice = _image_blue_tail_slice(img_a)
_assert_slice_diff(a_slice, base_slice, label="lora_a_vs_base")
a_vs_base = float(np.abs(a_slice - base_slice).mean())
# Adapter B: apply delta to K slice only (should differ from adapter A).
lora_b_dir = tmp_path / "zimage_lora_b"
_write_zimage_lora(lora_b_dir, k_scale=4.0)
payload_b = _basic_payload()
payload_b["lora"] = {"name": "b", "path": str(lora_b_dir), "scale": 64.0}
img_b = _post_images(omni_server, payload_b)
b_slice = _image_blue_tail_slice(img_b)
_assert_slice_diff(b_slice, base_slice, label="lora_b_vs_base")
_assert_slice_diff(b_slice, a_slice, label="lora_b_vs_lora_a")
b_vs_base = float(np.abs(b_slice - base_slice).mean())
b_vs_a = float(np.abs(b_slice - a_slice).mean())
# Ensure switching back to no-LoRA restores the base output.
base_img_2 = _post_images(omni_server, _basic_payload())
base_slice_2 = _image_blue_tail_slice(base_img_2)
_assert_slice_close(base_slice_2, base_slice, label="base_after_reset")
base_reset = float(np.abs(base_slice_2 - base_slice).mean())
# Ensure LoRA effects are clearly above the baseline drift.
min_delta = base_reset + 0.5
assert a_vs_base > min_delta, f"lora_a_vs_base drift too small: {a_vs_base} <= {min_delta}"
assert b_vs_base > min_delta, f"lora_b_vs_base drift too small: {b_vs_base} <= {min_delta}"
assert b_vs_a > min_delta, f"lora_b_vs_lora_a drift too small: {b_vs_a} <= {min_delta}"
+178 -180
View File
@@ -7,139 +7,86 @@ E2E Online tests for Qwen3-Omni model with video input and audio output.
import os import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
import base64
import concurrent.futures import concurrent.futures
import ctypes import threading
import signal
import socket
import subprocess
import sys
import time import time
from pathlib import Path from pathlib import Path
import openai import openai
import pytest import pytest
from vllm.assets.video import VideoAsset
from vllm.utils.network_utils import get_open_port
from tests.conftest import (
OmniServer,
convert_audio_to_text,
cosine_similarity_text,
dummy_messages_from_mix_data,
generate_synthetic_audio,
generate_synthetic_image,
generate_synthetic_video,
modify_stage_config,
)
from vllm_omni.utils import is_rocm from vllm_omni.utils import is_rocm
models = ["Qwen/Qwen3-Omni-30B-A3B-Instruct"] models = ["Qwen/Qwen3-Omni-30B-A3B-Instruct"]
def get_default_config():
return str(Path(__file__).parent.parent / "stage_configs" / "qwen3_omni_ci.yaml")
def get_chunk_config():
path = modify_stage_config(
get_default_config(),
updates={
"async_chunk": True,
"stage_args": {
0: {
"engine_args.custom_process_next_stage_input_func": "vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker_async_chunk"
},
1: {
"engine_args.custom_process_next_stage_input_func": "vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav_async_chunk"
},
},
},
deletes={"stage_args": {2: ["custom_process_input_func"]}},
)
return path
CHUNK_CONFIG_PATH = get_chunk_config()
# CI stage config for 2xH100-80G GPUs or AMD GPU MI325 # CI stage config for 2xH100-80G GPUs or AMD GPU MI325
if is_rocm(): if is_rocm():
# ROCm stage config optimized for MI325 GPU # ROCm stage config optimized for MI325 GPU
stage_configs = [str(Path(__file__).parent / "stage_configs" / "rocm" / "qwen3_omni_ci.yaml")] stage_configs = [str(Path(__file__).parent / "stage_configs" / "rocm" / "qwen3_omni_ci.yaml")]
else: else:
stage_configs = [str(Path(__file__).parent / "stage_configs" / "qwen3_omni_ci.yaml")] stage_configs = [get_default_config(), CHUNK_CONFIG_PATH]
# Create parameter combinations for model and stage config # Create parameter combinations for model and stage config
test_params = [(model, stage_config) for model in models for stage_config in stage_configs] test_params = [(model, stage_config) for model in models for stage_config in stage_configs]
class OmniServer: _omni_server_lock = threading.Lock()
"""Omniserver for vLLM-Omni tests."""
def __init__(
self,
model: str,
serve_args: list[str],
*,
env_dict: dict[str, str] | None = None,
) -> None:
self.model = model
self.serve_args = serve_args
self.env_dict = env_dict
self.proc: subprocess.Popen | None = None
self.host = "127.0.0.1"
self.port = get_open_port()
def _start_server(self) -> None:
"""Start the vLLM-Omni server subprocess."""
env = os.environ.copy()
env["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
if self.env_dict is not None:
env.update(self.env_dict)
cmd = [
sys.executable,
"-m",
"vllm_omni.entrypoints.cli.main",
"serve",
self.model,
"--omni",
"--host",
self.host,
"--port",
str(self.port),
] + self.serve_args
# Helper to ensure child process dies when parent dies
libc = ctypes.CDLL("libc.so.6")
def preexec_fn():
# Ensure the child process receives SIGTERM when the parent (this test runner) dies.
# This prevents orphaned processes if the test is killed unexpectedly.
PR_SET_PDEATHSIG = 1
libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM)
print(f"Launching OmniServer with: {' '.join(cmd)}")
self.proc = subprocess.Popen(
cmd,
env=env,
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), # Set working directory to vllm-omni root
start_new_session=True,
preexec_fn=preexec_fn,
)
# Wait for server to be ready
max_wait = 600 # 10 minutes
start_time = time.time()
while time.time() - start_time < max_wait:
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(1)
result = sock.connect_ex((self.host, self.port))
if result == 0:
print(f"Server ready on {self.host}:{self.port}")
return
except Exception:
pass
time.sleep(2)
raise RuntimeError(f"Server failed to start within {max_wait} seconds")
def __enter__(self):
self._start_server()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self.proc:
try:
os.killpg(self.proc.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
self.proc.wait(timeout=30)
except subprocess.TimeoutExpired:
try:
os.killpg(self.proc.pid, signal.SIGKILL)
except ProcessLookupError:
pass
self.proc.wait()
@pytest.fixture @pytest.fixture(scope="module")
def omni_server(request): def omni_server(request):
"""Start vLLM-Omni server as a subprocess with actual model weights. """Start vLLM-Omni server as a subprocess with actual model weights.
Uses module scope so the server starts only once for all tests. Uses session scope so the server starts only once for the entire test session.
Multi-stage initialization can take 10-20+ minutes. Multi-stage initialization can take 10-20+ minutes.
""" """
model, stage_config_path = request.param with _omni_server_lock:
with OmniServer(model, ["--stage-configs-path", stage_config_path, "--stage-init-timeout", "90"]) as server: model, stage_config_path = request.param
yield server
print(f"Starting OmniServer with model: {model}")
with OmniServer(model, ["--stage-configs-path", stage_config_path, "--stage-init-timeout", "120"]) as server:
print("OmniServer started successfully")
yield server
print("OmniServer stopping...")
print("OmniServer stopped")
@pytest.fixture @pytest.fixture
@@ -151,15 +98,6 @@ def client(omni_server):
) )
@pytest.fixture(scope="session")
def base64_encoded_video() -> str:
"""Base64 encoded video for testing."""
video = VideoAsset(name="baby_reading", num_frames=4)
with open(video.video_path, "rb") as f:
content = f.read()
return base64.b64encode(content).decode("utf-8")
def get_system_prompt(): def get_system_prompt():
return { return {
"role": "system", "role": "system",
@@ -193,71 +131,55 @@ def dummy_messages_from_video_data(
] ]
def get_prompt(prompt_type="text_only"):
prompts = {
"text_only": "What is the capital of China?",
"mix": "What is recited in the audio? What is in this image? Describe the video briefly.",
}
return prompts.get(prompt_type, prompts["text_only"])
def get_max_batch_size(size_type="few"):
batch_sizes = {"few": 5, "medium": 100, "large": 256}
return batch_sizes.get(size_type, 5)
@pytest.mark.skipif(is_rocm(), reason="Test skipped on AMD environment due to known output issues")
@pytest.mark.parametrize("omni_server", test_params, indirect=True) @pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_video_to_audio_concurrent( def test_mix_to_text_audio_001(client: openai.OpenAI, omni_server, request) -> None:
client: openai.OpenAI, """
omni_server, Test multi-modal input processing and text/audio output generation via OpenAI API.
base64_encoded_video: str, Deploy Setting: default yaml
) -> None: Input Modal: text + audio + video + image
"""Test processing video with multiple concurrent completions, generating audio output via OpenAI API.""" Output Modal: text + audio
# Create data URL for the base64 encoded video Input Setting: stream=True
video_data_url = f"data:video/mp4;base64,{base64_encoded_video}" Datasets: single request
"""
# TODO:This skip will be removed when the chunk scenario supports multimodal input.
param = request.node.callspec.params.get("omni_server")
messages = dummy_messages_from_video_data(video_data_url) if param[1] == CHUNK_CONFIG_PATH:
pytest.skip("The current chunk scenario does not support multimodal.")
# Test multiple concurrent completions # Test single completion
num_concurrent_requests = 5 e2e_list = list()
video_data_url = f"data:video/mp4;base64,{generate_synthetic_video(224, 224, 300)['base64']}"
with concurrent.futures.ThreadPoolExecutor(max_workers=num_concurrent_requests) as executor: image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(224, 224)['base64']}"
# Submit multiple completion requests concurrently audio_data_url = f"data:audio/wav;base64,{generate_synthetic_audio(5, 1)['base64']}"
futures = [ messages = dummy_messages_from_mix_data(
executor.submit( system_prompt=get_system_prompt(),
client.chat.completions.create, video_data_url=video_data_url,
model=omni_server.model, image_data_url=image_data_url,
messages=messages, audio_data_url=audio_data_url,
) content_text=get_prompt("mix"),
for _ in range(num_concurrent_requests)
]
# Wait for all requests to complete and collect results
chat_completions = [future.result() for future in concurrent.futures.as_completed(futures)]
# Verify all completions succeeded
assert len(chat_completions) == num_concurrent_requests
for chat_completion in chat_completions:
assert len(chat_completion.choices) == 2 # 1 for text output, 1 for audio output
# Verify text output
text_choice = chat_completion.choices[0]
assert text_choice.finish_reason == "length"
# Verify we got a response
text_message = text_choice.message
assert text_message.content is not None and len(text_message.content) >= 10
assert text_message.role == "assistant"
# Verify audio output
audio_choice = chat_completion.choices[1]
assert audio_choice.finish_reason == "stop"
audio_message = audio_choice.message
# Check if audio was generated
if hasattr(audio_message, "audio") and audio_message.audio:
assert audio_message.audio.data is not None
assert len(audio_message.audio.data) > 0
# Test streaming completion
chat_completion = client.chat.completions.create(
model=omni_server.model,
messages=messages,
stream=True,
) )
# Collect text and audio data from stream # Test single completion
start_time = time.perf_counter()
chat_completion = client.chat.completions.create(model=omni_server.model, messages=messages, stream=True)
text_content = "" text_content = ""
audio_data = None audio_data = None
for chunk in chat_completion: for chunk in chat_completion:
for choice in chunk.choices: for choice in chunk.choices:
if hasattr(choice, "delta"): if hasattr(choice, "delta"):
@@ -268,18 +190,94 @@ def test_video_to_audio_concurrent(
modality = getattr(chunk, "modality", None) modality = getattr(chunk, "modality", None)
if modality == "audio" and content: if modality == "audio" and content:
# Audio chunk - decode base64 content # Audio chunk - content
if audio_data is None: if audio_data is None:
audio_data = base64.b64decode(content) audio_data = content
else: else:
audio_data += base64.b64decode(content) audio_data += content
elif modality == "text" and content: elif modality == "text" and content:
# Text chunk - accumulate text content # Text chunk - accumulate text content
text_content += content if content else "" text_content += content if content else ""
# Verify text output # Verify E2E
assert text_content is not None and len(text_content) >= 2 current_e2e = time.perf_counter() - start_time
print(f"the request e2e is: {current_e2e}")
# TODO: Verify the E2E latency after confirmation baseline.
e2e_list.append(current_e2e)
# Verify audio output print(f"the avg e2e is: {sum(e2e_list) / len(e2e_list)}")
assert audio_data is not None # Verify all completions succeeded
assert len(audio_data) > 0 assert audio_data is not None, "No audio output is generated"
# Verify text output success
assert text_content is not None and len(text_content) >= 2, "No text output is generated"
assert any(
keyword in text_content.lower() for keyword in ["square", "quadrate", "sphere", "globe", "circle", "round"]
), "The output does not contain any of the keywords."
# Verify text output same as audio output
audio_content = convert_audio_to_text(audio_data)
print(f"text content is: {text_content}")
print(f"audio content is: {audio_content}")
similarity = cosine_similarity_text(audio_content.lower(), text_content.lower())
print(f"similarity is: {similarity}")
assert similarity > 0.9, "The audio content is not same as the text"
@pytest.mark.skipif(is_rocm(), reason="Test skipped on AMD environment due to known output issues")
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_text_to_text_audio_001(client: openai.OpenAI, omni_server) -> None:
"""
Test text input processing and text/audio output generation via OpenAI API.
Deploy Setting: default yaml
Input Modal: text
Output Modal: text + audio
Datasets: few requests
"""
num_concurrent_requests = get_max_batch_size()
messages = dummy_messages_from_mix_data(system_prompt=get_system_prompt(), content_text=get_prompt())
e2e_list = list()
with concurrent.futures.ThreadPoolExecutor(max_workers=num_concurrent_requests) as executor:
# Submit multiple completion requests concurrently
futures = [
executor.submit(client.chat.completions.create, model=omni_server.model, messages=messages)
for _ in range(num_concurrent_requests)
]
start_time = time.perf_counter()
# Wait for all requests to complete and collect results
chat_completions = list()
for future in concurrent.futures.as_completed(futures):
chat_completions.append(future.result())
# Verify E2E
current_e2e = time.perf_counter() - start_time
print(f"the request e2e is: {current_e2e}")
# TODO: Verify the E2E latency after confirmation baseline.
e2e_list.append(current_e2e)
print(f"the avg e2e is: {sum(e2e_list) / len(e2e_list)}")
# Verify all completions succeeded
assert len(chat_completions) == num_concurrent_requests, "Not all requests succeeded."
for chat_completion in chat_completions:
# Verify audio output success
audio_data = None
text_content = None
for choice in chat_completion.choices:
if choice.message.audio is not None:
audio_message = choice.message
audio_data = audio_message.audio.data
assert audio_message.audio.expires_at > time.time(), "The generated audio has expired."
if choice.message.content is not None:
# Verify text output success
text_content = choice.message.content
assert "beijing" in text_content.lower(), "The output do not contain keywords."
# Verify text output same as audio output
audio_content = convert_audio_to_text(audio_data)
print(f"text content is: {text_content}")
print(f"audio content is: {audio_content}")
similarity = cosine_similarity_text(audio_content.lower(), text_content.lower())
print(f"similarity is: {similarity}")
assert similarity > 0.9, "The audio content is not same as the text"
@@ -73,12 +73,14 @@ def get_max_batch_size(size_type="few"):
def get_deploy_config(deploy_type="TP1"): def get_deploy_config(deploy_type="TP1"):
result = { result = {
"TP1": { "TP1": {
0: { "stage_args": {
"engine_args.gpu_memory_utilization": 0.95, 0: {
"engine_args.tensor_parallel_size": 1, "engine_args.gpu_memory_utilization": 0.95,
"runtime.devices": "0", "engine_args.tensor_parallel_size": 1,
}, "runtime.devices": "0",
2: {"runtime.devices": "1"}, },
2: {"runtime.devices": "1"},
}
} }
} }
return result.get(deploy_type, result["TP1"]) return result.get(deploy_type, result["TP1"])
@@ -111,69 +113,6 @@ def test_text_to_text_001(test_config: tuple[str, str]) -> None:
assert "beijing" in text_choice.message.content.lower(), "The output do not contain keywords." assert "beijing" in text_choice.message.content.lower(), "The output do not contain keywords."
@pytest.mark.parametrize("test_config", test_params)
def test_text_to_text_audio_001(test_config: tuple[str, str]) -> None:
"""Test processing text, generating text and audio output via OpenAI API."""
model, stage_config_path = test_config
num_concurrent_requests = get_max_batch_size()
stage_config_path = modify_stage_config(
stage_config_path,
{
0: {"runtime.max_batch_size": num_concurrent_requests},
1: {"runtime.max_batch_size": num_concurrent_requests},
},
)
with OmniServer(model, ["--stage-configs-path", stage_config_path, "--stage-init-timeout", "90"]) as server:
messages = dummy_messages_from_mix_data(
system_prompt=get_system_prompt(), content_text="What is the capital of China?"
)
# Test single completion
api_client = client(server)
e2e_list = list()
with concurrent.futures.ThreadPoolExecutor(max_workers=num_concurrent_requests) as executor:
# Submit multiple completion requests concurrently
futures = [
executor.submit(api_client.chat.completions.create, model=server.model, messages=messages)
for _ in range(num_concurrent_requests)
]
start_time = time.perf_counter()
# Wait for all requests to complete and collect results
chat_completions = list()
for future in concurrent.futures.as_completed(futures):
chat_completions.append(future.result())
# Verify E2E
current_e2e = time.perf_counter() - start_time
print(f"the request e2e is: {current_e2e}")
# TODO: Verify the E2E latency after confirmation baseline.
e2e_list.append(current_e2e)
print(f"the avg e2e is: {sum(e2e_list) / len(e2e_list)}")
# Verify all completions succeeded
assert len(chat_completions) == num_concurrent_requests, "Not all requests succeeded."
for chat_completion in chat_completions:
# Verify audio output success
audio_message = chat_completion.choices[1].message
audio_data = audio_message.audio.data
assert audio_data is not None, "No audio output is generated"
assert audio_message.audio.expires_at > time.time(), "The generated audio has expired."
# Verify text output success
text_choice = chat_completion.choices[0]
text_content = text_choice.message.content
assert text_choice.message.content is not None, "No text output is generated"
assert "beijing" in text_choice.message.content.lower(), "The output do not contain keywords."
# Verify text output same as audio output
audio_content = convert_audio_to_text(audio_data)
print(f"text content is: {text_content}")
print(f"audio content is: {audio_content}")
assert cosine_similarity_text(audio_content.lower(), text_content.lower()) > 0.9, (
"The audio content is not same as the text"
)
@pytest.mark.parametrize("test_config", test_params) @pytest.mark.parametrize("test_config", test_params)
def test_audio_to_text_001(test_config: tuple[str, str]) -> None: def test_audio_to_text_001(test_config: tuple[str, str]) -> None:
"""Test processing text, generating text output via OpenAI API.""" """Test processing text, generating text output via OpenAI API."""
@@ -214,8 +153,10 @@ def test_audio_to_text_audio_001(test_config: tuple[str, str]) -> None:
stage_config_path = modify_stage_config( stage_config_path = modify_stage_config(
stage_config_path, stage_config_path,
{ {
0: {"runtime.max_batch_size": num_concurrent_requests}, "stage_args": {
1: {"runtime.max_batch_size": num_concurrent_requests}, 0: {"runtime.max_batch_size": num_concurrent_requests},
1: {"runtime.max_batch_size": num_concurrent_requests},
}
}, },
) )
with OmniServer(model, ["--stage-configs-path", stage_config_path, "--stage-init-timeout", "90"]) as server: with OmniServer(model, ["--stage-configs-path", stage_config_path, "--stage-init-timeout", "90"]) as server:
@@ -309,8 +250,10 @@ def test_image_to_text_audio_001(test_config: tuple[str, str]) -> None:
stage_config_path = modify_stage_config( stage_config_path = modify_stage_config(
stage_config_path, stage_config_path,
{ {
0: {"runtime.max_batch_size": num_concurrent_requests}, "stage_args": {
1: {"runtime.max_batch_size": num_concurrent_requests}, 0: {"runtime.max_batch_size": num_concurrent_requests},
1: {"runtime.max_batch_size": num_concurrent_requests},
}
}, },
) )
with OmniServer(model, ["--stage-configs-path", stage_config_path, "--stage-init-timeout", "90"]) as server: with OmniServer(model, ["--stage-configs-path", stage_config_path, "--stage-init-timeout", "90"]) as server:
+3 -3
View File
@@ -8,7 +8,7 @@ stage_args:
- stage_id: 0 - stage_id: 0
runtime: runtime:
devices: "0,1" devices: "0,1"
max_batch_size: 1 max_batch_size: 5
engine_args: engine_args:
model_stage: thinker model_stage: thinker
model_arch: Qwen3OmniMoeForConditionalGeneration model_arch: Qwen3OmniMoeForConditionalGeneration
@@ -39,7 +39,7 @@ stage_args:
- stage_id: 1 - stage_id: 1
runtime: runtime:
devices: "1" devices: "1"
max_batch_size: 1 max_batch_size: 5
engine_args: engine_args:
model_stage: talker model_stage: talker
model_arch: Qwen3OmniMoeForConditionalGeneration model_arch: Qwen3OmniMoeForConditionalGeneration
@@ -79,7 +79,7 @@ stage_args:
engine_output_type: audio # Final output: audio waveform engine_output_type: audio # Final output: audio waveform
gpu_memory_utilization: 0.1 gpu_memory_utilization: 0.1
distributed_executor_backend: "mp" distributed_executor_backend: "mp"
max_num_batched_tokens: 1000000 max_num_batched_tokens: 100000
hf_config_name: thinker_config hf_config_name: thinker_config
async_scheduling: false async_scheduling: false
engine_input_source: [1] engine_input_source: [1]
@@ -14,11 +14,13 @@ from unittest.mock import AsyncMock, Mock
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from PIL import Image from PIL import Image
from vllm import SamplingParams
from vllm_omni.entrypoints.openai.image_api_utils import ( from vllm_omni.entrypoints.openai.image_api_utils import (
encode_image_base64, encode_image_base64,
parse_size, parse_size,
) )
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
# Unit Tests # Unit Tests
@@ -111,7 +113,7 @@ class FakeAsyncOmni:
def __init__(self): def __init__(self):
self.stage_list = ["llm", "diffusion"] self.stage_list = ["llm", "diffusion"]
self.default_sampling_params_list = [{"temperature": 0.1}, {"top_p": 0.9}] self.default_sampling_params_list = [SamplingParams(temperature=0.1), OmniDiffusionSamplingParams()]
self.captured_sampling_params_list = None self.captured_sampling_params_list = None
async def generate(self, prompt, request_id, sampling_params_list): async def generate(self, prompt, request_id, sampling_params_list):
@@ -124,10 +126,13 @@ class FakeAsyncOmni:
def mock_async_diffusion(): def mock_async_diffusion():
"""Mock AsyncOmniDiffusion instance that returns fake images""" """Mock AsyncOmniDiffusion instance that returns fake images"""
mock = Mock() mock = Mock()
mock.is_running = True # For health endpoint
mock.check_health = AsyncMock() # For LLM mode health check
async def generate(**kwargs): async def generate(**kwargs):
# Return n PIL images wrapped in result object # Return n PIL images wrapped in result object
n = kwargs.get("num_outputs_per_prompt", 1) print("!!!!!!!!!!!!!!!!!!!!! kwargs", kwargs)
n = kwargs["sampling_params_list"][0].num_outputs_per_prompt
images = [Image.new("RGB", (64, 64), color="blue") for _ in range(n)] images = [Image.new("RGB", (64, 64), color="blue") for _ in range(n)]
return MockGenerationResult(images) return MockGenerationResult(images)
@@ -147,8 +152,9 @@ def test_client(mock_async_diffusion):
# Set up app state with diffusion engine # Set up app state with diffusion engine
app.state.engine_client = mock_async_diffusion app.state.engine_client = mock_async_diffusion
app.state.diffusion_engine = mock_async_diffusion # Also set for health endpoint
app.state.stage_configs = [{"stage_type": "diffusion"}] app.state.stage_configs = [{"stage_type": "diffusion"}]
app.state.served_model_names = "Qwen/Qwen-Image" app.state.diffusion_model_name = "Qwen/Qwen-Image" # For models endpoint
return TestClient(app) return TestClient(app)
@@ -169,10 +175,58 @@ def async_omni_test_client():
return TestClient(app) return TestClient(app)
@pytest.mark.skip(reason="Async API server uses different health check mechanism")
def test_health_endpoint(test_client): def test_health_endpoint(test_client):
"""Test health check endpoint - skipped for async server""" """Test health check endpoint for diffusion mode"""
pass response = test_client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
def test_health_endpoint_no_engine():
"""Test health check endpoint when no engine is initialized"""
from fastapi import FastAPI
from vllm_omni.entrypoints.openai.api_server import router
app = FastAPI()
app.include_router(router)
# Don't set any engine
client = TestClient(app)
response = client.get("/health")
assert response.status_code == 503
data = response.json()
assert data["status"] == "unhealthy"
def test_models_endpoint(test_client):
"""Test /v1/models endpoint for diffusion mode"""
response = test_client.get("/v1/models")
assert response.status_code == 200
data = response.json()
assert data["object"] == "list"
assert len(data["data"]) == 1
assert data["data"][0]["id"] == "Qwen/Qwen-Image"
assert data["data"][0]["object"] == "model"
def test_models_endpoint_no_engine():
"""Test /v1/models endpoint when no engine is initialized"""
from fastapi import FastAPI
from vllm_omni.entrypoints.openai.api_server import router
app = FastAPI()
app.include_router(router)
# Don't set any engine
client = TestClient(app)
response = client.get("/v1/models")
assert response.status_code == 200
data = response.json()
assert data["object"] == "list"
assert len(data["data"]) == 0
def test_generate_single_image(test_client): def test_generate_single_image(test_client):
@@ -217,11 +271,11 @@ def test_generate_images_async_omni_sampling_params(async_omni_test_client):
captured = engine.captured_sampling_params_list captured = engine.captured_sampling_params_list
assert captured is not None assert captured is not None
assert len(captured) == 2 assert len(captured) == 2
assert captured[0] == {"temperature": 0.1} assert captured[0].temperature == 0.1
assert captured[1]["num_outputs_per_prompt"] == 2 assert captured[1].num_outputs_per_prompt == 2
assert captured[1]["height"] == 256 assert captured[1].height == 256
assert captured[1]["width"] == 256 assert captured[1].width == 256
assert captured[1]["seed"] == 7 assert captured[1].seed == 7
def test_generate_multiple_images(test_client): def test_generate_multiple_images(test_client):
@@ -454,30 +508,11 @@ def test_parameters_passed_through(test_client, mock_async_diffusion):
# Ensure generate() was called exactly once # Ensure generate() was called exactly once
mock_async_diffusion.generate.assert_awaited_once() mock_async_diffusion.generate.assert_awaited_once()
call_kwargs = mock_async_diffusion.generate.call_args[1] call_kwargs = mock_async_diffusion.generate.call_args[1]["sampling_params_list"][0]
assert call_kwargs["num_inference_steps"] == 100 assert call_kwargs.num_inference_steps == 100
assert call_kwargs["guidance_scale"] == 7.5 assert call_kwargs.guidance_scale == 7.5
assert call_kwargs["true_cfg_scale"] == 3.0 assert call_kwargs.true_cfg_scale == 3.0
assert call_kwargs["seed"] == 42 assert call_kwargs.seed == 42
def test_optional_parameters_omitted(test_client, mock_async_diffusion):
"""Verify optional parameters not passed when omitted"""
response = test_client.post(
"/v1/images/generations",
json={
"prompt": "test",
"size": "512x512",
},
)
assert response.status_code == 200
# Ensure generate() was called exactly once
mock_async_diffusion.generate.assert_awaited_once()
call_kwargs = mock_async_diffusion.generate.call_args[1]
assert "num_inference_steps" not in call_kwargs
assert "guidance_scale" not in call_kwargs
assert "true_cfg_scale" not in call_kwargs
def test_model_field_omitted_works(test_client): def test_model_field_omitted_works(test_client):
@@ -10,8 +10,10 @@ from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from vllm_omni.entrypoints.openai.audio_utils_mixin import AudioMixin from vllm_omni.entrypoints.openai.audio_utils_mixin import AudioMixin
from vllm_omni.entrypoints.openai.protocol.audio import CreateAudio from vllm_omni.entrypoints.openai.protocol.audio import CreateAudio, OpenAICreateSpeechRequest
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech from vllm_omni.entrypoints.openai.serving_speech import (
OmniOpenAIServingSpeech,
)
from vllm_omni.outputs import OmniRequestOutput from vllm_omni.outputs import OmniRequestOutput
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -265,3 +267,78 @@ class TestSpeechAPI:
audio_obj = call_args[0] audio_obj = call_args[0]
assert isinstance(audio_obj, CreateAudio) assert isinstance(audio_obj, CreateAudio)
assert audio_obj.speed == 2.5 assert audio_obj.speed == 2.5
class TestTTSMethods:
"""Unit tests for TTS validation and parameter building."""
@pytest.fixture
def speech_server(self):
mock_engine_client = MagicMock()
mock_engine_client.errored = False
mock_engine_client.stage_list = None
mock_models = MagicMock()
mock_models.is_base_model.return_value = True
return OmniOpenAIServingSpeech(
engine_client=mock_engine_client,
models=mock_models,
request_logger=MagicMock(),
)
def test_is_tts_model(self, speech_server):
"""Test TTS model detection."""
# No stage_list -> False
assert speech_server._is_tts_model() is False
# With qwen3_tts stage -> True
mock_stage = MagicMock()
mock_stage.model_stage = "qwen3_tts"
speech_server.engine_client.stage_list = [mock_stage]
assert speech_server._is_tts_model() is True
def test_build_tts_prompt(self, speech_server):
"""Test TTS prompt format."""
prompt = speech_server._build_tts_prompt("Hello")
assert prompt == "<|im_start|>assistant\nHello<|im_end|>\n<|im_start|>assistant\n"
def test_validate_tts_request_basic(self, speech_server):
"""Test basic validation cases."""
# Empty input
req = OpenAICreateSpeechRequest(input="")
assert speech_server._validate_tts_request(req) == "Input text cannot be empty"
# Invalid language
req = OpenAICreateSpeechRequest(input="Hello", language="InvalidLang")
assert "Invalid language" in speech_server._validate_tts_request(req)
# Invalid speaker
req = OpenAICreateSpeechRequest(input="Hello", voice="Invalid")
assert "Invalid speaker" in speech_server._validate_tts_request(req)
# Valid request
req = OpenAICreateSpeechRequest(input="Hello", voice="Vivian")
assert speech_server._validate_tts_request(req) is None
def test_validate_tts_request_task_types(self, speech_server):
"""Test task-specific validation."""
# Base task requires ref_audio
req = OpenAICreateSpeechRequest(input="Hello", task_type="Base")
assert "ref_audio" in speech_server._validate_tts_request(req)
# VoiceDesign requires instructions
req = OpenAICreateSpeechRequest(input="Hello", task_type="VoiceDesign")
assert "instructions" in speech_server._validate_tts_request(req)
# ref_text only for Base
req = OpenAICreateSpeechRequest(input="Hello", ref_text="text")
assert "Base task" in speech_server._validate_tts_request(req)
def test_build_tts_params(self, speech_server):
"""Test TTS parameter building."""
req = OpenAICreateSpeechRequest(input="Hello", voice="Ryan", language="English")
params = speech_server._build_tts_params(req)
assert params["text"] == ["Hello"]
assert params["speaker"] == ["Ryan"]
assert params["language"] == ["English"]
assert params["task_type"] == ["CustomVoice"]
File diff suppressed because it is too large. Load diff
+15 -7
View File
@@ -5,6 +5,7 @@ from typing import Any
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest import pytest
from vllm import SamplingParams
from vllm_omni.entrypoints.stage_utils import SHUTDOWN_TASK from vllm_omni.entrypoints.stage_utils import SHUTDOWN_TASK
@@ -82,8 +83,9 @@ class _FakeStage:
self.stage_id = getattr(config, "stage_id", 0) self.stage_id = getattr(config, "stage_id", 0)
self.engine_args = config.engine_args self.engine_args = config.engine_args
self.model_stage = getattr(config.engine_args, "model_stage", None) self.model_stage = getattr(config.engine_args, "model_stage", None)
self.stage_type = "llm"
# set default sampling params # set default sampling params
self.default_sampling_params = {"temperature": 1.0} self.default_sampling_params = SamplingParams(temperature=1.0)
# Allow configuring final_output and final_output_type # Allow configuring final_output and final_output_type
self.final_output = config.final_output if hasattr(config, "final_output") else False self.final_output = config.final_output if hasattr(config, "final_output") else False
self.final_output_type = getattr(config, "final_output_type", None) self.final_output_type = getattr(config, "final_output_type", None)
@@ -637,8 +639,10 @@ def test_generate_pipeline_and_final_outputs(monkeypatch, fake_stage_config):
} }
) )
# Use dicts instead of object() for serializable sampling params sampling_params_list = [
sampling_params_list = [{"temperature": 0.7}, {"temperature": 0.8}] SamplingParams(temperature=0.7),
SamplingParams(temperature=0.8),
]
prompts = ["hi"] prompts = ["hi"]
outputs = omni.generate(prompts=prompts, sampling_params_list=sampling_params_list) outputs = omni.generate(prompts=prompts, sampling_params_list=sampling_params_list)
@@ -722,8 +726,13 @@ def test_generate_no_final_output_returns_empty(monkeypatch, fake_stage_config):
} }
) )
# Use dicts instead of object() for serializable sampling params outputs = omni.generate(
outputs = omni.generate(prompts=["p"], sampling_params_list=[{"temperature": 0.7}, {"temperature": 0.8}]) prompts=["p"],
sampling_params_list=[
SamplingParams(temperature=0.7),
SamplingParams(temperature=0.8),
],
)
assert outputs == [] assert outputs == []
@@ -922,8 +931,7 @@ def test_generate_handles_error_messages(monkeypatch, fake_stage_config):
) )
# Generate should handle error gracefully (log but continue) # Generate should handle error gracefully (log but continue)
# Use dict instead of object() for serializable sampling params sampling_params_list = [SamplingParams(temperature=0.7)]
sampling_params_list = [{"temperature": 0.7}]
outputs = omni.generate(prompts=["hi"], sampling_params_list=sampling_params_list) outputs = omni.generate(prompts=["hi"], sampling_params_list=sampling_params_list)
# Should return final output (error was logged but didn't stop processing) # Should return final output (error was logged but didn't stop processing)
assert isinstance(outputs, list) assert isinstance(outputs, list)
@@ -13,6 +13,7 @@ def test_omni_new_request_data_copies_payloads():
} }
request = SimpleNamespace( request = SimpleNamespace(
request_id="req-1", request_id="req-1",
external_req_id="ext-1",
prompt_token_ids=[101, 102], prompt_token_ids=[101, 102],
mm_features=None, mm_features=None,
sampling_params=None, sampling_params=None,
@@ -33,6 +34,7 @@ def test_omni_new_request_data_copies_payloads():
def test_omni_new_request_data_allows_missing_payloads(): def test_omni_new_request_data_allows_missing_payloads():
request = SimpleNamespace( request = SimpleNamespace(
request_id="req-2", request_id="req-2",
external_req_id="ext-2",
prompt_token_ids=[201, 202], prompt_token_ids=[201, 202],
mm_features=None, mm_features=None,
sampling_params=None, sampling_params=None,
+171
View File
@@ -0,0 +1,171 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for OmniRequestOutput class."""
from unittest.mock import Mock
from PIL import Image
from vllm_omni.outputs import OmniRequestOutput
class TestOmniRequestOutput:
"""Tests for OmniRequestOutput class."""
def test_from_diffusion(self):
"""Test creating output from diffusion model."""
images = [Image.new("RGB", (64, 64), color="red")]
output = OmniRequestOutput.from_diffusion(
request_id="test-123",
images=images,
prompt="a cat",
metrics={"steps": 50},
)
assert output.request_id == "test-123"
assert output.images == images
assert output.prompt == "a cat"
assert output.metrics == {"steps": 50}
assert output.is_diffusion_output
assert output.num_images == 1
def test_from_pipeline(self):
"""Test creating output from pipeline stage."""
mock_request_output = Mock()
mock_request_output.request_id = "pipeline-123"
mock_request_output.prompt_token_ids = [1, 2, 3]
mock_request_output.outputs = [Mock()]
mock_request_output.encoder_prompt_token_ids = None
mock_request_output.prompt_logprobs = None
mock_request_output.num_cached_tokens = 10
mock_request_output.kv_transfer_params = None
mock_request_output.multimodal_output = {"image": Mock()}
output = OmniRequestOutput.from_pipeline(
stage_id=0,
final_output_type="text",
request_output=mock_request_output,
)
assert output.request_id == "pipeline-123"
assert output.stage_id == 0
assert output.final_output_type == "text"
assert output.is_pipeline_output
def test_prompt_token_ids_property(self):
"""Test prompt_token_ids property for streaming compatibility."""
mock_request_output = Mock()
mock_request_output.prompt_token_ids = [1, 2, 3, 4, 5]
output = OmniRequestOutput.from_pipeline(
stage_id=0,
final_output_type="text",
request_output=mock_request_output,
)
assert output.prompt_token_ids == [1, 2, 3, 4, 5]
def test_prompt_token_ids_none_when_no_request_output(self):
"""Test prompt_token_ids returns None when no request_output."""
output = OmniRequestOutput.from_diffusion(
request_id="test-123",
images=[],
prompt="a cat",
)
assert output.prompt_token_ids is None
def test_outputs_property(self):
"""Test outputs property for chat completion compatibility."""
mock_output = Mock()
mock_request_output = Mock()
mock_request_output.outputs = [mock_output]
output = OmniRequestOutput.from_pipeline(
stage_id=0,
final_output_type="text",
request_output=mock_request_output,
)
assert output.outputs == [mock_output]
def test_outputs_empty_when_no_request_output(self):
"""Test outputs returns empty list when no request_output."""
output = OmniRequestOutput.from_diffusion(
request_id="test-123",
images=[],
prompt="a cat",
)
assert output.outputs == []
def test_encoder_prompt_token_ids_property(self):
"""Test encoder_prompt_token_ids property."""
mock_request_output = Mock()
mock_request_output.encoder_prompt_token_ids = [10, 20, 30]
output = OmniRequestOutput.from_pipeline(
stage_id=0,
final_output_type="text",
request_output=mock_request_output,
)
assert output.encoder_prompt_token_ids == [10, 20, 30]
def test_num_cached_tokens_property(self):
"""Test num_cached_tokens property."""
mock_request_output = Mock()
mock_request_output.num_cached_tokens = 42
output = OmniRequestOutput.from_pipeline(
stage_id=0,
final_output_type="text",
request_output=mock_request_output,
)
assert output.num_cached_tokens == 42
def test_multimodal_output_property(self):
"""Test multimodal_output property."""
mock_request_output = Mock()
mock_audio = Mock()
expected_output = {"audio": mock_audio}
mock_request_output.multimodal_output = expected_output
output = OmniRequestOutput.from_pipeline(
stage_id=0,
final_output_type="audio",
request_output=mock_request_output,
)
assert output.multimodal_output is expected_output
def test_to_dict_diffusion(self):
"""Test to_dict for diffusion output."""
output = OmniRequestOutput.from_diffusion(
request_id="test-123",
images=[Image.new("RGB", (64, 64), color="red")],
prompt="a cat",
metrics={"steps": 50},
)
result = output.to_dict()
assert result["request_id"] == "test-123"
assert result["finished"] is True
assert result["final_output_type"] == "image"
assert result["num_images"] == 1
assert result["prompt"] == "a cat"
def test_to_dict_pipeline(self):
"""Test to_dict for pipeline output."""
mock_request_output = Mock()
mock_request_output.request_id = "pipeline-123"
output = OmniRequestOutput.from_pipeline(
stage_id=0,
final_output_type="text",
request_output=mock_request_output,
)
result = output.to_dict()
assert result["request_id"] == "pipeline-123"
assert result["finished"] is True
assert result["final_output_type"] == "text"
assert result["stage_id"] == 0
+2
View File
@@ -2,8 +2,10 @@
Configuration module for vLLM-Omni. Configuration module for vLLM-Omni.
""" """
from vllm_omni.config.lora import LoRAConfig
from vllm_omni.config.model import OmniModelConfig from vllm_omni.config.model import OmniModelConfig
__all__ = [ __all__ = [
"OmniModelConfig", "OmniModelConfig",
"LoRAConfig",
] ]
+8
View File
@@ -0,0 +1,8 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# for now, it suffices to use vLLM's implementation directly
# as this is a user-facing variable, defined here to so that user can directly import LoRAConfig from vllm_omni
from vllm.config.lora import LoRAConfig
__all__ = ["LoRAConfig"]
+13 -4
View File
@@ -1,4 +1,5 @@
import warnings import warnings
from dataclasses import field
from typing import Any from typing import Any
import torch import torch
@@ -20,10 +21,7 @@ from vllm.transformers_utils.config import (
get_hf_text_config, get_hf_text_config,
get_pooling_config, get_pooling_config,
) )
from vllm.transformers_utils.gguf_utils import ( from vllm.transformers_utils.gguf_utils import is_gguf, maybe_patch_hf_config_from_gguf
is_gguf,
maybe_patch_hf_config_from_gguf,
)
from vllm.transformers_utils.utils import maybe_model_redirect from vllm.transformers_utils.utils import maybe_model_redirect
from vllm.v1.attention.backends.registry import AttentionBackendEnum from vllm.v1.attention.backends.registry import AttentionBackendEnum
@@ -42,6 +40,7 @@ class OmniModelConfig(ModelConfig):
Attributes: Attributes:
stage_id: Identifier for the stage in a multi-stage pipeline (default: 0) stage_id: Identifier for the stage in a multi-stage pipeline (default: 0)
async_chunk: If set to True, perform async chunk
model_stage: Stage type identifier, e.g., "thinker" or "talker" model_stage: Stage type identifier, e.g., "thinker" or "talker"
(default: "thinker") (default: "thinker")
model_arch: Model architecture name model_arch: Model architecture name
@@ -49,6 +48,8 @@ class OmniModelConfig(ModelConfig):
engine_output_type: Optional output type specification for the engine. engine_output_type: Optional output type specification for the engine.
Used to route outputs to appropriate processors (e.g., "image", Used to route outputs to appropriate processors (e.g., "image",
"audio", "latents"). If None, output type is inferred. "audio", "latents"). If None, output type is inferred.
stage_connector_config: Stage connector configuration dictionary.
Contains "name" (connector name), "extra" (extra connector config).
Example: Example:
>>> config = OmniModelConfig( >>> config = OmniModelConfig(
@@ -59,10 +60,18 @@ class OmniModelConfig(ModelConfig):
""" """
stage_id: int = 0 stage_id: int = 0
async_chunk: bool = False
model_stage: str = "thinker" model_stage: str = "thinker"
model_arch: str = "Qwen2_5OmniForConditionalGeneration" model_arch: str = "Qwen2_5OmniForConditionalGeneration"
engine_output_type: str | None = None engine_output_type: str | None = None
hf_config_name: str | None = None hf_config_name: str | None = None
custom_process_next_stage_input_func: str | None = None
stage_connector_config: dict[str, Any] = field(
default_factory=lambda: {
"name": "SharedMemoryConnector",
"extra": {},
}
)
omni_kv_config: dict | None = None omni_kv_config: dict | None = None
@property @property
+30
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import importlib
from collections import defaultdict from collections import defaultdict
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from time import time from time import time
@@ -19,6 +20,10 @@ from vllm.v1.outputs import ModelRunnerOutput
from vllm.v1.request import Request, RequestStatus from vllm.v1.request import Request, RequestStatus
from vllm.v1.spec_decode.metrics import SpecDecodingStats from vllm.v1.spec_decode.metrics import SpecDecodingStats
from vllm_omni.distributed.omni_connectors.adapter import get_chunk, put_chunk
from vllm_omni.distributed.omni_connectors.factory import OmniConnectorFactory
from vllm_omni.distributed.omni_connectors.utils.config import ConnectorSpec
logger = init_logger(__name__) logger = init_logger(__name__)
@@ -59,6 +64,25 @@ class OmniARScheduler(VLLMScheduler):
# Track requests that have already triggered prefill transfer to avoid duplicates # Track requests that have already triggered prefill transfer to avoid duplicates
self.transfer_triggered_requests: set[str] = set() self.transfer_triggered_requests: set[str] = set()
model_config = self.vllm_config.model_config
self.omni_connector = None
if model_config.async_chunk:
connector_config = model_config.stage_connector_config
connector_specs = ConnectorSpec(
name=connector_config.get("name", "SharedMemoryConnector"),
extra=connector_config.get("extra", {}),
)
self.omni_connector = OmniConnectorFactory.create_connector(connector_specs)
custom_process_next_stage_input_func = getattr(
self.vllm_config.model_config, "custom_process_next_stage_input_func", None
)
if custom_process_next_stage_input_func:
module_path, func_name = custom_process_next_stage_input_func.rsplit(".", 1)
module = importlib.import_module(module_path)
self.custom_process_next_stage_input_func = getattr(module, func_name)
self.stage_id = getattr(self.vllm_config.model_config, "stage_id", None)
def _get_kv_transfer_criteria(self) -> dict | None: def _get_kv_transfer_criteria(self) -> dict | None:
# Note: vllm_config is available in Scheduler after super().__init__ # Note: vllm_config is available in Scheduler after super().__init__
@@ -142,6 +166,7 @@ class OmniARScheduler(VLLMScheduler):
# Build omni entry preserving all base fields # Build omni entry preserving all base fields
omni_nr = OmniNewRequestData( omni_nr = OmniNewRequestData(
req_id=nr.req_id, req_id=nr.req_id,
external_req_id=(getattr(request, "external_req_id", None) if request else None),
prompt_token_ids=nr.prompt_token_ids, prompt_token_ids=nr.prompt_token_ids,
mm_features=nr.mm_features, mm_features=nr.mm_features,
sampling_params=nr.sampling_params, sampling_params=nr.sampling_params,
@@ -156,6 +181,8 @@ class OmniARScheduler(VLLMScheduler):
new_list.append(omni_nr) new_list.append(omni_nr)
scheduler_output.scheduled_new_reqs = new_list # type: ignore[assignment] scheduler_output.scheduled_new_reqs = new_list # type: ignore[assignment]
if self.omni_connector is not None:
get_chunk(self.omni_connector, scheduler_output)
# Add information about requests needing KV cache transfer # Add information about requests needing KV cache transfer
scheduler_output.finished_requests_needing_kv_transfer = self.get_finished_requests_needing_kv_transfer() scheduler_output.finished_requests_needing_kv_transfer = self.get_finished_requests_needing_kv_transfer()
@@ -337,6 +364,9 @@ class OmniARScheduler(VLLMScheduler):
num_nans_in_logits=request.num_nans_in_logits, num_nans_in_logits=request.num_nans_in_logits,
) )
) )
if self.omni_connector is not None:
custom_process_next_stage_input_func = self.custom_process_next_stage_input_func
put_chunk(self.omni_connector, pooler_output, request, custom_process_next_stage_input_func)
else: else:
# Invariant: EngineCore returns no partial prefill outputs. # Invariant: EngineCore returns no partial prefill outputs.
assert not prompt_logprobs_tensors assert not prompt_logprobs_tensors
@@ -12,11 +12,27 @@ from vllm.v1.engine import EngineCoreEventType, EngineCoreOutput, EngineCoreOutp
from vllm.v1.request import Request, RequestStatus from vllm.v1.request import Request, RequestStatus
from vllm.v1.spec_decode.metrics import SpecDecodingStats from vllm.v1.spec_decode.metrics import SpecDecodingStats
from vllm_omni.core.sched.output import OmniNewRequestData from vllm_omni.core.sched.output import OmniCachedRequestData, OmniNewRequestData
from vllm_omni.distributed.omni_connectors.adapter import get_chunk_for_generation
from vllm_omni.distributed.omni_connectors.factory import OmniConnectorFactory
from vllm_omni.distributed.omni_connectors.utils.config import ConnectorSpec
from vllm_omni.outputs import OmniModelRunnerOutput from vllm_omni.outputs import OmniModelRunnerOutput
class OmniGenerationScheduler(VLLMScheduler): class OmniGenerationScheduler(VLLMScheduler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
model_config = self.vllm_config.model_config
self.omni_connector = None
if model_config.async_chunk:
connector_config = model_config.stage_connector_config
connector_specs = ConnectorSpec(
name=connector_config.get("name", "SharedMemoryConnector"),
extra=connector_config.get("extra", {}),
)
self.omni_connector = OmniConnectorFactory.create_connector(connector_specs)
self.stage_id = getattr(self.vllm_config.model_config, "stage_id", None)
def schedule(self) -> SchedulerOutput: def schedule(self) -> SchedulerOutput:
"""Diffusion fast path: """Diffusion fast path:
- Feed all input tokens of the request at once - Feed all input tokens of the request at once
@@ -32,27 +48,54 @@ class OmniGenerationScheduler(VLLMScheduler):
req_to_new_blocks: dict[str, KVCacheBlocks] = {} req_to_new_blocks: dict[str, KVCacheBlocks] = {}
num_scheduled_tokens: dict[str, int] = {} num_scheduled_tokens: dict[str, int] = {}
scheduled_running_reqs: list[Request] = []
scheduled_spec_decode_tokens: dict[str, list[int]] = {} scheduled_spec_decode_tokens: dict[str, list[int]] = {}
scheduled_encoder_inputs: dict[str, list[int]] = {} scheduled_encoder_inputs: dict[str, list[int]] = {}
cached_prompt_token_ids: dict[str, list[int]] = {}
# Temporary queue: preserve waiting order, do not disturb non-diffusion requests # Temporary queue: preserve waiting order, do not disturb non-diffusion requests
skipped_waiting_requests = create_request_queue(self.policy) skipped_waiting_requests = create_request_queue(self.policy)
req_index = 0
while req_index < len(self.running) and token_budget > 0:
request = self.running[req_index]
if self.omni_connector is not None:
get_chunk_for_generation(self.omni_connector, request)
num_computed_tokens = request.num_computed_tokens
required_tokens = max(len(request.prompt_token_ids) - num_computed_tokens, 1)
num_new_tokens = min(required_tokens, token_budget)
new_blocks = self.kv_cache_manager.allocate_slots(
request,
num_new_tokens,
num_lookahead_tokens=self.num_lookahead_tokens,
)
if new_blocks is None:
# Allocation failed (e.g., VRAM pressure); stop fast path and
# fall back to default scheduling
# Put the current request back to the head of the waiting queue
# Note: the original queue order is preserved
break
if self.log_stats:
request.record_event(EngineCoreEventType.SCHEDULED, scheduled_timestamp)
req_to_new_blocks[request.request_id] = new_blocks
num_scheduled_tokens[request.request_id] = num_new_tokens
cached_prompt_token_ids[request.request_id] = request.prompt_token_ids
token_budget -= num_new_tokens
scheduled_running_reqs.append(request)
req_index += 1
# Fast path selection and scheduling (treat all as diffusion requests, # Fast path selection and scheduling (treat all as diffusion requests,
# independent of pooling_params) # independent of pooling_params)
while self.waiting and token_budget > 0 and len(self.running) < self.max_num_running_reqs: while self.waiting and token_budget > 0 and len(self.running) < self.max_num_running_reqs:
request = self.waiting.peek_request() request = self.waiting.peek_request()
if self.omni_connector is not None:
get_chunk_for_generation(self.omni_connector, request)
# Uniformly treat as diffusion. A feature flag can be added later # Uniformly treat as diffusion. A feature flag can be added later
# via config or request tag. # via config or request tag.
# Allocate all input tokens for the request in one shot # Allocate all input tokens for the request in one shot
# (allocate 1 placeholder if zero) # (allocate 1 placeholder if zero)
required_tokens = max(getattr(request, "num_prompt_tokens", 0), 1) required_tokens = max(len(request.prompt_token_ids), 1)
if required_tokens > token_budget: num_new_tokens = min(required_tokens, token_budget)
# Insufficient budget to process all inputs at once;
# stop fast path attempt
break
num_new_tokens = required_tokens
new_blocks = self.kv_cache_manager.allocate_slots( new_blocks = self.kv_cache_manager.allocate_slots(
request, request,
num_new_tokens, num_new_tokens,
@@ -68,7 +111,6 @@ class OmniGenerationScheduler(VLLMScheduler):
# Officially schedule this request # Officially schedule this request
request = self.waiting.pop_request() request = self.waiting.pop_request()
self.running.append(request) self.running.append(request)
request.status = RequestStatus.RUNNING
if self.log_stats: if self.log_stats:
request.record_event(EngineCoreEventType.SCHEDULED, scheduled_timestamp) request.record_event(EngineCoreEventType.SCHEDULED, scheduled_timestamp)
@@ -109,13 +151,24 @@ class OmniGenerationScheduler(VLLMScheduler):
] ]
# No running/resumed reqs scheduled in our fast path # No running/resumed reqs scheduled in our fast path
cached_reqs_data = self._make_cached_request_data( cached_reqs_data = self._make_cached_request_data(
running_reqs=[], running_reqs=scheduled_running_reqs,
resumed_reqs=[], resumed_reqs=[],
num_scheduled_tokens=num_scheduled_tokens, num_scheduled_tokens=num_scheduled_tokens,
spec_decode_tokens=scheduled_spec_decode_tokens, spec_decode_tokens=scheduled_spec_decode_tokens,
req_to_new_blocks=req_to_new_blocks, req_to_new_blocks=req_to_new_blocks,
) )
cached_reqs_data = OmniCachedRequestData(
req_ids=cached_reqs_data.req_ids,
resumed_req_ids=cached_reqs_data.resumed_req_ids,
new_token_ids=cached_reqs_data.new_token_ids,
all_token_ids=cached_reqs_data.all_token_ids,
new_block_ids=cached_reqs_data.new_block_ids,
num_computed_tokens=cached_reqs_data.num_computed_tokens,
num_output_tokens=cached_reqs_data.num_output_tokens,
prompt_token_ids=cached_prompt_token_ids,
)
total_num_scheduled_tokens = sum(num_scheduled_tokens.values()) total_num_scheduled_tokens = sum(num_scheduled_tokens.values())
scheduler_output = SchedulerOutput( scheduler_output = SchedulerOutput(
scheduled_new_reqs=new_reqs_data, scheduled_new_reqs=new_reqs_data,
@@ -157,6 +210,7 @@ class OmniGenerationScheduler(VLLMScheduler):
# Build omni entry preserving all base fields # Build omni entry preserving all base fields
omni_nr = OmniNewRequestData( omni_nr = OmniNewRequestData(
req_id=nr.req_id, req_id=nr.req_id,
external_req_id=(getattr(request, "external_req_id", None) if request else None),
prompt_token_ids=nr.prompt_token_ids, prompt_token_ids=nr.prompt_token_ids,
mm_features=nr.mm_features, mm_features=nr.mm_features,
sampling_params=nr.sampling_params, sampling_params=nr.sampling_params,
@@ -257,21 +311,20 @@ class OmniGenerationScheduler(VLLMScheduler):
new_logprobs = None new_logprobs = None
new_token_ids = generated_token_ids new_token_ids = generated_token_ids
kv_transfer_params = None kv_transfer_params = None
status_before_stop = request.status
pooler_output = None pooler_output = None
if pooler_outputs: if pooler_outputs:
pooler_output = pooler_outputs[req_index] pooler_output = pooler_outputs[req_index]
# Diffusion request: completes in one step; mark finished and free resources # Diffusion request: completes in one step; mark finished and free resources
request.status = RequestStatus.FINISHED_STOPPED if request.status == RequestStatus.FINISHED_STOPPED or (
# Optional: set a stop_reason for front-end clarity self.omni_connector is None and request.num_computed_tokens >= request.num_prompt_tokens
# (does not affect protocol) ):
request.stop_reason = request.stop_reason # or "generation_done" request.status = RequestStatus.FINISHED_STOPPED
kv_transfer_params = self._free_request(request) # Optional: set a stop_reason for front-end clarity
if status_before_stop == RequestStatus.RUNNING: # (does not affect protocol)
request.stop_reason = request.stop_reason # or "generation_done"
kv_transfer_params = self._free_request(request)
stopped_running_reqs.add(request) stopped_running_reqs.add(request)
else:
stopped_preempted_reqs.add(request)
# Extract sample logprobs if needed. # Extract sample logprobs if needed.
if request.sampling_params is not None and request.sampling_params.logprobs is not None and logprobs: if request.sampling_params is not None and request.sampling_params.logprobs is not None and logprobs:
+15 -1
View File
@@ -1,6 +1,6 @@
from dataclasses import dataclass from dataclasses import dataclass
from vllm.v1.core.sched.output import NewRequestData from vllm.v1.core.sched.output import CachedRequestData, NewRequestData
from vllm.v1.request import Request from vllm.v1.request import Request
from vllm_omni.engine import AdditionalInformationPayload, PromptEmbedsPayload from vllm_omni.engine import AdditionalInformationPayload, PromptEmbedsPayload
@@ -21,6 +21,8 @@ class OmniNewRequestData(NewRequestData):
# Optional serialized prompt embeddings # Optional serialized prompt embeddings
prompt_embeds: PromptEmbedsPayload | None = None prompt_embeds: PromptEmbedsPayload | None = None
# Optional external request ID for tracking
external_req_id: str | None = None
# Optional serialized additional information # Optional serialized additional information
additional_information: AdditionalInformationPayload | None = None additional_information: AdditionalInformationPayload | None = None
@@ -42,6 +44,7 @@ class OmniNewRequestData(NewRequestData):
""" """
return cls( return cls(
req_id=request.request_id, req_id=request.request_id,
external_req_id=request.external_req_id,
prompt_token_ids=request.prompt_token_ids, prompt_token_ids=request.prompt_token_ids,
mm_features=request.mm_features, mm_features=request.mm_features,
sampling_params=request.sampling_params, sampling_params=request.sampling_params,
@@ -53,3 +56,14 @@ class OmniNewRequestData(NewRequestData):
prefill_token_ids=prefill_token_ids, prefill_token_ids=prefill_token_ids,
additional_information=request.additional_information, additional_information=request.additional_information,
) )
@dataclass
class OmniCachedRequestData(CachedRequestData):
"""Cached request data for omni models with embeddings support.
Args:
prompt_token_ids: Mapping from request ID to list of prompt token IDs
"""
prompt_token_ids: dict[int, list[int]]
@@ -2,7 +2,6 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import torch import torch
from fa3_fwd_interface import flash_attn_func, flash_attn_varlen_func
from vllm.logger import init_logger from vllm.logger import init_logger
from vllm_omni.diffusion.attention.backends.abstract import ( from vllm_omni.diffusion.attention.backends.abstract import (
@@ -10,7 +9,13 @@ from vllm_omni.diffusion.attention.backends.abstract import (
AttentionImpl, AttentionImpl,
AttentionMetadata, AttentionMetadata,
) )
from vllm_omni.diffusion.attention.backends.utils.fa import _pad_input, _unpad_input, _upad_input from vllm_omni.diffusion.attention.backends.utils.fa import (
_pad_input,
_unpad_input,
_upad_input,
flash_attn_func,
flash_attn_varlen_func,
)
logger = init_logger(__name__) logger = init_logger(__name__)
@@ -15,6 +15,20 @@
import torch import torch
import torch.nn.functional as F import torch.nn.functional as F
from vllm_omni.utils.platform_utils import is_rocm
if is_rocm():
from vllm._aiter_ops import is_aiter_found_and_supported
# Choose to enable this by default on ROCm
# Whenever possible as it is the fastest backend
if is_aiter_found_and_supported():
from aiter import flash_attn_func, flash_attn_varlen_func # noqa: F401
else:
raise ImportError("Aiter is not found and supported on currentROCm device.")
else:
from fa3_fwd_interface import flash_attn_func, flash_attn_varlen_func # noqa: F401
def _index_first_axis(tensor, indices): def _index_first_axis(tensor, indices):
""" """
+22
View File
@@ -81,6 +81,28 @@ def get_attn_backend(head_size: int) -> type[AttentionBackend]:
"Falling back to TORCH_SDPA backend.""" "Falling back to TORCH_SDPA backend."""
) )
backend_name = "TORCH_SDPA" backend_name = "TORCH_SDPA"
elif detect_device_type() == "cuda" and is_rocm():
from vllm._aiter_ops import is_aiter_found_and_supported
compute_capability = torch.cuda.get_device_capability()
major, minor = compute_capability
# Choose to enable this by default on ROCm
# Whenever possible as it is the fastest backend
# is_aiter_found_and_supported() checks if aiter library is found
# and is aiter supported on the current platform
# aiter currently only is supported on gfx942 and gfx950
# https://github.com/vllm-project/vllm/blob/main/vllm/_aiter_ops.py
if is_aiter_found_and_supported() and 90 < major * 10 + minor < 100:
if backend_name is None:
backend_name = "FLASH_ATTN"
else:
if backend_name == "FLASH_ATTN":
logger.warning(
"Flash Attention requires `aiter` library which is only supported "
"on gfx942 and gfx950. Falling back to TORCH_SDPA backend."
)
backend_name = "TORCH_SDPA"
if backend_name is not None: if backend_name is not None:
backend_name_upper = backend_name.upper() backend_name_upper = backend_name.upper()
+1 -1
View File
@@ -791,7 +791,7 @@ def enable_cache_for_bagel(pipeline: Any, cache_config: Any) -> Callable[[int],
CUSTOM_DIT_ENABLERS.update( CUSTOM_DIT_ENABLERS.update(
{ {
"WanPipeline": enable_cache_for_wan22, "WanPipeline": enable_cache_for_wan22,
"FluxPipeline": enable_cache_for_flux, # "FluxPipeline": enable_cache_for_flux,
"LongCatImagePipeline": enable_cache_for_longcat_image, "LongCatImagePipeline": enable_cache_for_longcat_image,
"LongCatImageEditPipeline": enable_cache_for_longcat_image, "LongCatImageEditPipeline": enable_cache_for_longcat_image,
"StableDiffusion3Pipeline": enable_cache_for_sd3, "StableDiffusion3Pipeline": enable_cache_for_sd3,
+65 -26
View File
@@ -15,10 +15,42 @@ from vllm.logger import init_logger
from vllm_omni.diffusion.cache.base import CacheBackend from vllm_omni.diffusion.cache.base import CacheBackend
from vllm_omni.diffusion.cache.teacache.config import TeaCacheConfig from vllm_omni.diffusion.cache.teacache.config import TeaCacheConfig
from vllm_omni.diffusion.cache.teacache.hook import TeaCacheHook, apply_teacache_hook from vllm_omni.diffusion.cache.teacache.hook import TeaCacheHook, apply_teacache_hook
from vllm_omni.diffusion.data import DiffusionCacheConfig
logger = init_logger(__name__) logger = init_logger(__name__)
def enable_bagel_teacache(pipeline: Any, config: DiffusionCacheConfig) -> None:
"""
Enable TeaCache for Bagel model.
"""
teacache_config = TeaCacheConfig(
transformer_type="Bagel",
rel_l1_thresh=config.rel_l1_thresh,
coefficients=config.coefficients,
)
transformer = pipeline.bagel
original_forward_flow = transformer._forward_flow
import types
def forward_alias(self, *args, **kwargs):
return original_forward_flow(*args, **kwargs)
transformer.forward = types.MethodType(forward_alias, transformer)
apply_teacache_hook(transformer, teacache_config)
transformer._forward_flow = transformer.forward
pipeline.transformer = transformer
logger.info(
f"TeaCache applied with rel_l1_thresh={teacache_config.rel_l1_thresh}, "
f"transformer_class={teacache_config.transformer_type}"
)
CUSTOM_TEACACHE_ENABLERS = {"BagelPipeline": enable_bagel_teacache}
class TeaCacheBackend(CacheBackend): class TeaCacheBackend(CacheBackend):
""" """
TeaCache implementation using hooks. TeaCache implementation using hooks.
@@ -51,38 +83,45 @@ class TeaCacheBackend(CacheBackend):
- transformer: pipeline.transformer - transformer: pipeline.transformer
- transformer_type: pipeline.transformer.__class__.__name__ - transformer_type: pipeline.transformer.__class__.__name__
""" """
# Extract transformer and transformer_type from pipeline # Helper to get pipeline class name
transformer = pipeline.transformer pipeline_type = pipeline.__class__.__name__
transformer_type = transformer.__class__.__name__
# Create TeaCacheConfig from DiffusionCacheConfig with transformer_type # Check for pipeline-level custom enablers
# Access parameters via attribute access: config.rel_l1_thresh if pipeline_type in CUSTOM_TEACACHE_ENABLERS:
# rel_l1_thresh already has a default value of 0.2 in DiffusionCacheConfig logger.info(f"Using custom TeaCache enabler for model: {pipeline_type}")
try: CUSTOM_TEACACHE_ENABLERS[pipeline_type](pipeline, self.config)
teacache_config = TeaCacheConfig( else:
transformer_type=transformer_type, transformer = pipeline.transformer
rel_l1_thresh=self.config.rel_l1_thresh, transformer_type = transformer.__class__.__name__
coefficients=self.config.coefficients,
)
except Exception as e:
logger.error(f"Failed to create TeaCacheConfig: {e}")
raise ValueError(
f"Invalid TeaCache configuration: {e}. "
f"Expected keys: rel_l1_thresh, coefficients (optional). "
f"transformer_type is automatically extracted from pipeline.transformer.__class__.__name__."
)
# Apply hook to transformer # Create TeaCacheConfig from DiffusionCacheConfig with transformer_type
apply_teacache_hook(transformer, teacache_config) # Access parameters via attribute access: config.rel_l1_thresh
# rel_l1_thresh already has a default value of 0.2 in DiffusionCacheConfig
try:
teacache_config = TeaCacheConfig(
transformer_type=transformer_type,
rel_l1_thresh=self.config.rel_l1_thresh,
coefficients=self.config.coefficients,
)
except Exception as e:
logger.error(f"Failed to create TeaCacheConfig: {e}")
raise ValueError(
f"Invalid TeaCache configuration: {e}. "
f"Expected keys: rel_l1_thresh, coefficients (optional). "
f"transformer_type is automatically extracted from pipeline.transformer.__class__.__name__."
)
# Apply hook to transformer
apply_teacache_hook(transformer, teacache_config)
logger.info(
f"TeaCache applied with rel_l1_thresh={teacache_config.rel_l1_thresh}, "
f"transformer_class={teacache_config.transformer_type}"
)
# Mark as enabled # Mark as enabled
self.enabled = True self.enabled = True
logger.info(
f"TeaCache applied with rel_l1_thresh={teacache_config.rel_l1_thresh}, "
f"transformer_class={teacache_config.transformer_type}"
)
def refresh(self, pipeline: Any, num_inference_steps: int, verbose: bool = True) -> None: def refresh(self, pipeline: Any, num_inference_steps: int, verbose: bool = True) -> None:
""" """
Refresh TeaCache state for new generation. Refresh TeaCache state for new generation.
+3
View File
@@ -25,6 +25,9 @@ _MODEL_COEFFICIENTS = {
3.20000000e00, 3.20000000e00,
-2.00000000e-02, -2.00000000e-02,
], ],
# Bagel transformer coefficients
# Using Qwen's coefficients as reasonable default given shared architecture
"Bagel": [1.33313129e06, -1.68644226e05, 7.95050740e03, -1.63747873e02, 1.26352397e00],
# Z-Image transformer coefficients # Z-Image transformer coefficients
# Copied from Qwen-Image, need to be tuned specifically for Z-Image in future # Copied from Qwen-Image, need to be tuned specifically for Z-Image in future
"ZImageTransformer2DModel": [ "ZImageTransformer2DModel": [
+128 -1
View File
@@ -20,6 +20,8 @@ from typing import Any
import torch import torch
import torch.nn as nn import torch.nn as nn
from vllm_omni.diffusion.forward_context import get_forward_context
@dataclass @dataclass
class CacheContext: class CacheContext:
@@ -217,14 +219,37 @@ def extract_qwen_context(
"""Execute all Qwen transformer blocks.""" """Execute all Qwen transformer blocks."""
h = hidden_states h = hidden_states
e = encoder_hidden_states e = encoder_hidden_states
encoder_mask = encoder_hidden_states_mask
hidden_states_mask = None # default
if module.parallel_config is not None and module.parallel_config.sequence_parallel_size > 1:
ctx = get_forward_context()
if ctx.sp_original_seq_len is not None and ctx.sp_padding_size > 0:
# Create mask for the full (padded) sequence
# valid positions = True, padding positions = False
batch_size = hidden_states.shape[0]
padded_seq_len = ctx.sp_original_seq_len + ctx.sp_padding_size
hidden_states_mask = torch.ones(
batch_size,
padded_seq_len,
dtype=torch.bool,
device=hidden_states.device,
)
hidden_states_mask[:, ctx.sp_original_seq_len :] = False
# if mask is all true, set it to None
if hidden_states_mask is not None and hidden_states_mask.all():
hidden_states_mask = None
if encoder_mask is not None and encoder_mask.all():
encoder_mask = None
for block in module.transformer_blocks: for block in module.transformer_blocks:
e, h = block( e, h = block(
hidden_states=h, hidden_states=h,
encoder_hidden_states=e, encoder_hidden_states=e,
encoder_hidden_states_mask=encoder_hidden_states_mask, encoder_hidden_states_mask=encoder_mask,
temb=temb, temb=temb,
image_rotary_emb=image_rotary_emb, image_rotary_emb=image_rotary_emb,
joint_attention_kwargs=attention_kwargs, joint_attention_kwargs=attention_kwargs,
hidden_states_mask=hidden_states_mask,
) )
return (h, e) return (h, e)
@@ -254,6 +279,107 @@ def extract_qwen_context(
) )
def extract_bagel_context(
module: nn.Module,
x_t: torch.Tensor,
timestep: torch.Tensor | float | int,
packed_vae_token_indexes: torch.LongTensor,
packed_vae_position_ids: torch.LongTensor,
packed_text_ids: torch.LongTensor,
packed_text_indexes: torch.LongTensor,
packed_indexes: torch.LongTensor,
packed_position_ids: torch.LongTensor,
packed_seqlens: torch.IntTensor,
key_values_lens: torch.IntTensor,
past_key_values: Any,
packed_key_value_indexes: torch.LongTensor,
**kwargs: Any,
) -> CacheContext:
"""
Extract cache context for Bagel model.
Args:
module: Bagel instance
x_t: Latent image input
timestep: Current timestep
packed_vae_token_indexes: Indexes for VAE tokens in packed sequence
packed_vae_position_ids: Position IDs for VAE tokens
packed_text_ids: Text token IDs
packed_text_indexes: Indexes for text tokens in packed sequence
packed_indexes: Global indexes
packed_position_ids: Global position IDs
packed_seqlens: Sequence lengths
key_values_lens: KV cache lengths
past_key_values: KV cache
packed_key_value_indexes: KV cache indexes
**kwargs: Additional keyword arguments
Returns:
CacheContext with all information needed for generic caching
"""
# 1. Embed text
packed_text_embedding = module.language_model.model.embed_tokens(packed_text_ids)
packed_sequence = packed_text_embedding.new_zeros((sum(packed_seqlens), module.hidden_size))
packed_sequence[packed_text_indexes] = packed_text_embedding
# 2. Embed timestep
if not isinstance(timestep, torch.Tensor):
timestep = torch.tensor([timestep], device=x_t.device)
if timestep.dim() == 0:
timestep = timestep.unsqueeze(0)
# 3. Embed image (x_t)
packed_pos_embed = module.latent_pos_embed(packed_vae_position_ids)
packed_timestep_embeds = module.time_embedder(timestep)
x_t_emb = module.vae2llm(x_t) + packed_timestep_embeds + packed_pos_embed
if x_t_emb.dtype != packed_sequence.dtype:
x_t_emb = x_t_emb.to(packed_sequence.dtype)
packed_sequence[packed_vae_token_indexes] = x_t_emb
# Use the full packed sequence as modulated input to match hidden_states size
modulated_input = packed_sequence
def run_transformer_blocks():
extra_inputs = {}
if module.use_moe:
extra_inputs = {
"mode": "gen",
"packed_vae_token_indexes": packed_vae_token_indexes,
"packed_text_indexes": packed_text_indexes,
}
output = module.language_model.forward(
packed_query_sequence=packed_sequence,
query_lens=packed_seqlens,
packed_query_position_ids=packed_position_ids,
packed_query_indexes=packed_indexes,
past_key_values=past_key_values,
key_values_lens=key_values_lens,
packed_key_value_indexes=packed_key_value_indexes,
update_past_key_values=False,
is_causal=False,
**extra_inputs,
)
return (output.packed_query_sequence,)
def postprocess(h):
v_t = module.llm2vae(h)
v_t = v_t[packed_vae_token_indexes]
return v_t
return CacheContext(
modulated_input=modulated_input,
hidden_states=packed_sequence, # Use full packed sequence
encoder_hidden_states=None,
temb=packed_timestep_embeds, # Approximate
run_transformer_blocks=run_transformer_blocks,
postprocess=postprocess,
)
def extract_zimage_context( def extract_zimage_context(
module: nn.Module, module: nn.Module,
x: list[torch.Tensor], x: list[torch.Tensor],
@@ -448,6 +574,7 @@ def extract_zimage_context(
# on the transformer module and multiple pipelines can share the same transformer. # on the transformer module and multiple pipelines can share the same transformer.
EXTRACTOR_REGISTRY: dict[str, Callable] = { EXTRACTOR_REGISTRY: dict[str, Callable] = {
"QwenImageTransformer2DModel": extract_qwen_context, "QwenImageTransformer2DModel": extract_qwen_context,
"Bagel": extract_bagel_context,
"ZImageTransformer2DModel": extract_zimage_context, "ZImageTransformer2DModel": extract_zimage_context,
# Future models: # Future models:
# "FluxTransformer2DModel": extract_flux_context, # "FluxTransformer2DModel": extract_flux_context,
+1 -1
View File
@@ -122,7 +122,7 @@ class TeaCacheHook(ModelHook):
# - cfg_rank 0: positive branch # - cfg_rank 0: positive branch
# - cfg_rank > 0: negative branch # - cfg_rank > 0: negative branch
# Without CFG-parallel, branches alternate within a single rank # Without CFG-parallel, branches alternate within a single rank
if module.do_true_cfg: if getattr(module, "do_true_cfg", False):
cfg_parallel_size = get_classifier_free_guidance_world_size() cfg_parallel_size = get_classifier_free_guidance_world_size()
if cfg_parallel_size > 1: if cfg_parallel_size > 1:
cfg_rank = get_classifier_free_guidance_rank() cfg_rank = get_classifier_free_guidance_rank()
+15 -5
View File
@@ -276,12 +276,9 @@ class OmniDiffusionConfig:
# pipeline_config: PipelineConfig = field(default_factory=PipelineConfig, repr=False) # pipeline_config: PipelineConfig = field(default_factory=PipelineConfig, repr=False)
# LoRA parameters # LoRA parameters
# (Wenxuan) prefer to keep it here instead of in pipeline config to not make it complicated.
lora_path: str | None = None lora_path: str | None = None
lora_nickname: str = "default" # for swapping adapters in the pipeline lora_scale: float = 1.0
# can restrict layers to adapt, e.g. ["q_proj"] max_cpu_loras: int | None = None
# Will adapt only q, k, v, o by default.
lora_target_modules: list[str] | None = None
output_type: str = "pil" output_type: str = "pil"
@@ -446,11 +443,24 @@ class OmniDiffusionConfig:
# If it's neither dict nor DiffusionCacheConfig, convert to empty config # If it's neither dict nor DiffusionCacheConfig, convert to empty config
self.cache_config = DiffusionCacheConfig() self.cache_config = DiffusionCacheConfig()
if self.max_cpu_loras is None:
self.max_cpu_loras = 1
elif self.max_cpu_loras < 1:
raise ValueError("max_cpu_loras must be >= 1 for diffusion LoRA")
def update_multimodal_support(self) -> None: def update_multimodal_support(self) -> None:
self.supports_multimodal_inputs = self.model_class_name in {"QwenImageEditPlusPipeline"} self.supports_multimodal_inputs = self.model_class_name in {"QwenImageEditPlusPipeline"}
@classmethod @classmethod
def from_kwargs(cls, **kwargs: Any) -> "OmniDiffusionConfig": def from_kwargs(cls, **kwargs: Any) -> "OmniDiffusionConfig":
# Backwards-compatibility: older callers may use a diffusion-specific
# "static_lora_scale" kwarg. Normalize it to the canonical "lora_scale"
# before constructing the dataclass to avoid TypeError on unknown fields.
if "static_lora_scale" in kwargs:
if "lora_scale" not in kwargs:
kwargs["lora_scale"] = kwargs["static_lora_scale"]
kwargs.pop("static_lora_scale", None)
# Check environment variable as fallback for cache_backend # Check environment variable as fallback for cache_backend
# Support both old DIFFUSION_CACHE_ADAPTER and new DIFFUSION_CACHE_BACKEND for backwards compatibility # Support both old DIFFUSION_CACHE_ADAPTER and new DIFFUSION_CACHE_BACKEND for backwards compatibility
if "cache_backend" not in kwargs: if "cache_backend" not in kwargs:
+111 -88
View File
@@ -17,6 +17,7 @@ from vllm_omni.diffusion.registry import (
get_diffusion_pre_process_func, get_diffusion_pre_process_func,
) )
from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniTextPrompt
from vllm_omni.outputs import OmniRequestOutput from vllm_omni.outputs import OmniRequestOutput
logger = init_logger(__name__) logger = init_logger(__name__)
@@ -29,6 +30,13 @@ def supports_image_input(model_class_name: str) -> bool:
return bool(getattr(model_cls, "support_image_input", False)) return bool(getattr(model_cls, "support_image_input", False))
def supports_audio_output(model_class_name: str) -> bool:
model_cls = DiffusionModelRegistry._try_load_model_cls(model_class_name)
if model_cls is None:
return False
return bool(getattr(model_cls, "support_audio_output", False))
class DiffusionEngine: class DiffusionEngine:
"""The diffusion engine for vLLM-Omni diffusion models.""" """The diffusion engine for vLLM-Omni diffusion models."""
@@ -53,103 +61,118 @@ class DiffusionEngine:
self.close() self.close()
raise e raise e
def step(self, requests: list[OmniDiffusionRequest]): def step(self, request: OmniDiffusionRequest) -> list[OmniRequestOutput]:
try: # Apply pre-processing if available
# Apply pre-processing if available if self.pre_process_func is not None:
if self.pre_process_func is not None: preprocess_start_time = time.time()
preprocess_start_time = time.time() request = self.pre_process_func(request)
requests = self.pre_process_func(requests) preprocess_time = time.time() - preprocess_start_time
preprocess_time = time.time() - preprocess_start_time logger.info(f"Pre-processing completed in {preprocess_time:.4f} seconds")
logger.info(f"Pre-processing completed in {preprocess_time:.4f} seconds")
output = self.add_req_and_wait_for_response(requests) output = self.add_req_and_wait_for_response(request)
if output.error: if output.error:
raise Exception(f"{output.error}") raise Exception(f"{output.error}")
logger.info("Generation completed successfully.") logger.info("Generation completed successfully.")
if output.output is None: if output.output is None:
logger.warning("Output is None, returning empty OmniRequestOutput") logger.warning("Output is None, returning empty OmniRequestOutput")
# Return empty output for the first request return [
if len(requests) > 0: OmniRequestOutput.from_diffusion(
request = requests[0] request_id=request.request_ids[i] if i < len(request.request_ids) else "",
request_id = request.request_id or "" images=[],
prompt = request.prompt prompt=prompt,
if isinstance(prompt, list): metrics={},
prompt = prompt[0] if prompt else None latents=None,
return OmniRequestOutput.from_diffusion( )
for i, prompt in enumerate(request.prompts)
]
postprocess_start_time = time.time()
outputs = self.post_process_func(output.output) if self.post_process_func is not None else output.output
postprocess_time = time.time() - postprocess_start_time
logger.info(f"Post-processing completed in {postprocess_time:.4f} seconds")
# Convert to OmniRequestOutput format
# Ensure outputs is a list
if not isinstance(outputs, list):
outputs = [outputs] if outputs is not None else []
# Handle single request or multiple requests
if len(request.prompts) == 1:
# Single request: return single OmniRequestOutput
prompt = request.prompts[0]
request_id = request.request_ids[0] if request.request_ids else ""
metrics = {}
if output.trajectory_timesteps is not None:
metrics["trajectory_timesteps"] = output.trajectory_timesteps
if supports_audio_output(self.od_config.model_class_name):
audio_payload = outputs[0] if len(outputs) == 1 else outputs
return [
OmniRequestOutput.from_diffusion(
request_id=request_id, request_id=request_id,
images=[], images=[],
prompt=prompt, prompt=prompt,
metrics={}, metrics=metrics,
latents=None, latents=output.trajectory_latents,
) multimodal_output={"audio": audio_payload},
return None final_output_type="audio",
),
]
else:
return [
OmniRequestOutput.from_diffusion(
request_id=request_id,
images=outputs,
prompt=prompt,
metrics=metrics,
latents=output.trajectory_latents,
),
]
else:
# Multiple requests: return list of OmniRequestOutput
# Split images based on num_outputs_per_prompt for each request
results = []
output_idx = 0
postprocess_start_time = time.time() for i, prompt in enumerate(request.prompts):
images = self.post_process_func(output.output) if self.post_process_func is not None else output.output request_id = request.request_ids[i] if i < len(request.request_ids) else ""
postprocess_time = time.time() - postprocess_start_time
logger.info(f"Post-processing completed in {postprocess_time:.4f} seconds")
# Convert to OmniRequestOutput format # Get images for this request
# Ensure images is a list num_outputs = request.sampling_params.num_outputs_per_prompt
if not isinstance(images, list): request_outputs = outputs[output_idx : output_idx + num_outputs] if output_idx < len(outputs) else []
images = [images] if images is not None else [] output_idx += num_outputs
# Handle single request or multiple requests
if len(requests) == 1:
# Single request: return single OmniRequestOutput
request = requests[0]
request_id = request.request_id or ""
prompt = request.prompt
if isinstance(prompt, list):
prompt = prompt[0] if prompt else None
metrics = {} metrics = {}
if output.trajectory_timesteps is not None: if output.trajectory_timesteps is not None:
metrics["trajectory_timesteps"] = output.trajectory_timesteps metrics["trajectory_timesteps"] = output.trajectory_timesteps
return OmniRequestOutput.from_diffusion( if supports_audio_output(self.od_config.model_class_name):
request_id=request_id, audio_payload = request_outputs[0] if len(request_outputs) == 1 else request_outputs
images=images,
prompt=prompt,
metrics=metrics,
latents=output.trajectory_latents,
)
else:
# Multiple requests: return list of OmniRequestOutput
# Split images based on num_outputs_per_prompt for each request
results = []
image_idx = 0
for request in requests:
request_id = request.request_id or ""
prompt = request.prompt
if isinstance(prompt, list):
prompt = prompt[0] if prompt else None
# Get images for this request
num_outputs = request.num_outputs_per_prompt
request_images = images[image_idx : image_idx + num_outputs] if image_idx < len(images) else []
image_idx += num_outputs
metrics = {}
if output.trajectory_timesteps is not None:
metrics["trajectory_timesteps"] = output.trajectory_timesteps
results.append( results.append(
OmniRequestOutput.from_diffusion( OmniRequestOutput.from_diffusion(
request_id=request_id, request_id=request_id,
images=request_images, images=[],
prompt=prompt,
metrics=metrics,
latents=output.trajectory_latents,
multimodal_output={"audio": audio_payload},
final_output_type="audio",
)
)
else:
results.append(
OmniRequestOutput.from_diffusion(
request_id=request_id,
images=request_outputs,
prompt=prompt, prompt=prompt,
metrics=metrics, metrics=metrics,
latents=output.trajectory_latents, latents=output.trajectory_latents,
) )
) )
return results return results
except Exception as e:
logger.error(f"Generation failed: {e}")
return None
@staticmethod @staticmethod
def make_engine(config: OmniDiffusionConfig) -> "DiffusionEngine": def make_engine(config: OmniDiffusionConfig) -> "DiffusionEngine":
@@ -163,8 +186,8 @@ class DiffusionEngine:
""" """
return DiffusionEngine(config) return DiffusionEngine(config)
def add_req_and_wait_for_response(self, requests: list[OmniDiffusionRequest]): def add_req_and_wait_for_response(self, request: OmniDiffusionRequest):
return self.executor.add_req(requests) return self.executor.add_req(request)
def start_profile(self, trace_filename: str | None = None) -> None: def start_profile(self, trace_filename: str | None = None) -> None:
""" """
@@ -287,8 +310,6 @@ class DiffusionEngine:
def _dummy_run(self): def _dummy_run(self):
"""A dummy run to warm up the model.""" """A dummy run to warm up the model."""
prompt = "dummy run"
# note that num_inference_steps=1 will cause timestep and temb None in the pipeline
num_inference_steps = 1 num_inference_steps = 1
height = 1024 height = 1024
width = 1024 width = 1024
@@ -298,17 +319,19 @@ class DiffusionEngine:
dummy_image = PIL.Image.new("RGB", (width, height), color=(0, 0, 0)) dummy_image = PIL.Image.new("RGB", (width, height), color=(0, 0, 0))
else: else:
dummy_image = None dummy_image = None
prompt: OmniTextPrompt = {"prompt": "dummy run", "multi_modal_data": {"image": dummy_image}}
req = OmniDiffusionRequest( req = OmniDiffusionRequest(
prompt=prompt, prompts=[prompt],
height=height, sampling_params=OmniDiffusionSamplingParams(
width=width, height=height,
pil_image=dummy_image, width=width,
num_inference_steps=num_inference_steps, num_inference_steps=num_inference_steps,
num_outputs_per_prompt=1, num_outputs_per_prompt=1,
),
) )
logger.info("dummy run to warm up the model") logger.info("dummy run to warm up the model")
requests = self.pre_process_func([req]) if self.pre_process_func is not None else [req] request = self.pre_process_func(req) if self.pre_process_func is not None else req
self.add_req_and_wait_for_response(requests) self.add_req_and_wait_for_response(request)
def collective_rpc( def collective_rpc(
self, self,
+2 -2
View File
@@ -3,7 +3,7 @@ from typing import Any
from vllm.utils.import_utils import resolve_obj_by_qualname from vllm.utils.import_utils import resolve_obj_by_qualname
from vllm_omni.diffusion.data import OmniDiffusionConfig from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.request import OmniDiffusionRequest
@@ -59,7 +59,7 @@ class DiffusionExecutor(ABC):
pass pass
@abstractmethod @abstractmethod
def add_req(self, requests: list[OmniDiffusionRequest]): def add_req(self, requests: OmniDiffusionRequest) -> DiffusionOutput:
"""Add requests to the execution queue.""" """Add requests to the execution queue."""
pass pass
@@ -6,7 +6,7 @@ from typing import Any
from vllm.logger import init_logger from vllm.logger import init_logger
from vllm_omni.diffusion.data import SHUTDOWN_MESSAGE from vllm_omni.diffusion.data import SHUTDOWN_MESSAGE, DiffusionOutput
from vllm_omni.diffusion.executor.abstract import DiffusionExecutor from vllm_omni.diffusion.executor.abstract import DiffusionExecutor
from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.scheduler import Scheduler from vllm_omni.diffusion.scheduler import Scheduler
@@ -130,8 +130,8 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
return processes, result_handle return processes, result_handle
def add_req(self, requests: list[OmniDiffusionRequest]): def add_req(self, request: OmniDiffusionRequest) -> DiffusionOutput:
return self.scheduler.add_req(requests) return self.scheduler.add_req(request)
def collective_rpc( def collective_rpc(
self, self,
+6
View File
@@ -0,0 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from vllm_omni.diffusion.lora.manager import DiffusionLoRAManager
__all__ = ["DiffusionLoRAManager"]
@@ -0,0 +1,22 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from .base_linear import DiffusionBaseLinearLayerWithLoRA
from .column_parallel_linear import (
DiffusionColumnParallelLinearWithLoRA,
DiffusionMergedColumnParallelLinearWithLoRA,
DiffusionMergedQKVParallelLinearWithLoRA,
DiffusionQKVParallelLinearWithLoRA,
)
from .replicated_linear import DiffusionReplicatedLinearWithLoRA
from .row_parallel_linear import DiffusionRowParallelLinearWithLoRA
__all__ = [
"DiffusionBaseLinearLayerWithLoRA",
"DiffusionReplicatedLinearWithLoRA",
"DiffusionColumnParallelLinearWithLoRA",
"DiffusionMergedColumnParallelLinearWithLoRA",
"DiffusionRowParallelLinearWithLoRA",
"DiffusionQKVParallelLinearWithLoRA",
"DiffusionMergedQKVParallelLinearWithLoRA",
]
@@ -0,0 +1,152 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import torch
from vllm.lora.layers.base_linear import BaseLinearLayerWithLoRA
class DiffusionBaseLinearLayerWithLoRA(BaseLinearLayerWithLoRA):
"""
Diffusion-specific base that overrides apply() to use direct torch matmul
instead of punica_wrapper.
punica_wrapper is used to hold multiple LoRA slots and slices efficiently.
This matches the semantics of PunicaWrapperGPU.add_lora_linear():
- Shrink: buffer = (x @ lora_a.T)
- Expand: y += buffer @ lora_b.T
All other functionality (weight management, TP slicing, forward logic)
is inherited from vLLM's BaseLinearLayerWithLoRA.
"""
def create_lora_weights(
self,
max_loras: int,
lora_config,
model_config=None,
) -> None:
super().create_lora_weights(max_loras, lora_config, model_config)
# Keep a direct reference for attribute forwarding: `base_layer` is a
# registered submodule (stored under `_modules`), so direct access via
# `object.__getattribute__` will not find it. We stash a ref in
# `__dict__` for robust lookups in `__getattr__`.
modules = object.__getattribute__(self, "_modules")
base_layer = modules.get("base_layer") or object.__getattribute__(self, "__dict__").get("base_layer")
object.__setattr__(self, "_diffusion_base_layer_ref", base_layer)
n_slices = getattr(self, "n_slices", 1)
self._diffusion_lora_active_slices = (False,) * int(n_slices)
def reset_lora(self, index: int):
super().reset_lora(index)
n_slices = getattr(self, "n_slices", 1)
self._diffusion_lora_active_slices = (False,) * int(n_slices)
def set_lora(
self,
index: int,
lora_a: torch.Tensor | list[torch.Tensor | None],
lora_b: torch.Tensor | list[torch.Tensor | None],
):
super().set_lora(index, lora_a, lora_b) # type: ignore[arg-type]
n_slices = getattr(self, "n_slices", 1)
if isinstance(lora_a, list) or isinstance(lora_b, list):
assert isinstance(lora_a, list)
assert isinstance(lora_b, list)
active_slices = []
for a_i, b_i in zip(lora_a[:n_slices], lora_b[:n_slices]):
active_slices.append(a_i is not None and b_i is not None)
if len(active_slices) < n_slices:
active_slices.extend([False] * (n_slices - len(active_slices)))
self._diffusion_lora_active_slices = tuple(active_slices)
else:
# Single-slice layer.
self._diffusion_lora_active_slices = (True,)
def apply(self, x: torch.Tensor, bias: torch.Tensor | None = None) -> torch.Tensor:
"""
override: Use simple matmul instead of punica_wrapper.add_lora_linear().
This matches the exact computation in PunicaWrapperGPU.add_lora_linear()
for the single-LoRA case. For packed projections (e.g. fused QKV), we
apply LoRA per-slice using `output_slices`.
"""
output = self.base_layer.quant_method.apply(self.base_layer, x, bias)
if not hasattr(self, "lora_a_stacked") or not hasattr(self, "lora_b_stacked"):
return output
if not self.lora_a_stacked or not self.lora_b_stacked:
return output
# Fast path: if no LoRA is active for this layer, skip matmuls.
active_slices = getattr(self, "_diffusion_lora_active_slices", None)
if active_slices is not None and not any(active_slices):
return output
# In fully-sharded LoRA mode, vLLM uses an all-gather between shrink and
# expand for ColumnParallelLinear variants. This diffusion path doesn't
# implement that communication yet.
if getattr(self, "lora_config", None) is not None:
if self.lora_config.fully_sharded_loras and self.tp_size > 1:
raise NotImplementedError(
"Diffusion LoRA apply() does not support fully_sharded_loras with tensor parallelism yet."
)
original_shape = output.shape
x_flat = x.reshape(-1, x.shape[-1])
y_flat = output.reshape(-1, output.shape[-1])
output_slices = getattr(self, "output_slices", None)
if output_slices is None:
# Fallback: infer slice sizes from the allocated tensors.
output_slices = tuple(lora_b.shape[2] for lora_b in self.lora_b_stacked)
if len(output_slices) != len(self.lora_a_stacked) or len(output_slices) != len(self.lora_b_stacked):
raise RuntimeError(
"LoRA slice metadata mismatch: "
f"output_slices={len(output_slices)}, "
f"lora_a_stacked={len(self.lora_a_stacked)}, "
f"lora_b_stacked={len(self.lora_b_stacked)}"
)
offset = 0
for slice_idx, slice_size in enumerate(output_slices):
if active_slices is not None and slice_idx < len(active_slices) and not active_slices[slice_idx]:
offset += slice_size
continue
A = self.lora_a_stacked[slice_idx][0, 0, :, :] # (rank, in_dim)
B = self.lora_b_stacked[slice_idx][0, 0, :, :] # (out_dim, rank)
if A.numel() == 0 or B.numel() == 0:
offset += slice_size
continue
# LoRA shrink & expand as in add_lora_linear():
# buffer = (x @ A.T)
# y += buffer @ B.T
delta = (x_flat @ A.t()) @ B.t()
y_flat[:, offset : offset + slice_size] = y_flat[:, offset : offset + slice_size] + delta
offset += slice_size
return y_flat.view(original_shape)
def __getattr__(self, name: str):
# The diffusion model implementations may access attributes directly
# from linear layers (e.g. QKVParallelLinear.num_heads). vLLM's LoRA
# wrappers don't forward these attributes by default, so we delegate
# missing attribute lookups to the underlying base_layer.
try:
return super().__getattr__(name)
except AttributeError as exc:
base_layer = object.__getattribute__(self, "__dict__").get("_diffusion_base_layer_ref")
if base_layer is None:
base_layer = object.__getattribute__(self, "_modules").get("base_layer")
if base_layer is None:
raise exc
try:
return getattr(base_layer, name)
except AttributeError:
raise exc
@@ -0,0 +1,61 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
from vllm.lora.layers.column_parallel_linear import (
ColumnParallelLinearWithLoRA,
MergedColumnParallelLinearWithLoRA,
MergedQKVParallelLinearWithLoRA,
QKVParallelLinearWithLoRA,
)
from .base_linear import DiffusionBaseLinearLayerWithLoRA
class DiffusionColumnParallelLinearWithLoRA(
DiffusionBaseLinearLayerWithLoRA,
ColumnParallelLinearWithLoRA,
):
"""
Diffusion ColumnParallelLinear with LoRA.
Prioritize apply() in DiffusionBaseLinearLayerWithLoRA
"""
pass
class DiffusionMergedColumnParallelLinearWithLoRA(
DiffusionBaseLinearLayerWithLoRA,
MergedColumnParallelLinearWithLoRA,
):
"""
Diffusion MergedColumnParallelLinear (gate_up_proj) with LoRA.
Prioritize apply() in DiffusionBaseLinearLayerWithLoRA
"""
pass
class DiffusionQKVParallelLinearWithLoRA(
DiffusionBaseLinearLayerWithLoRA,
QKVParallelLinearWithLoRA,
):
"""
Diffusion QKVParallelLinear with single LoRA.
Prioritize apply() in DiffusionBaseLinearLayerWithLoRA
"""
pass
class DiffusionMergedQKVParallelLinearWithLoRA(
DiffusionBaseLinearLayerWithLoRA,
MergedQKVParallelLinearWithLoRA,
):
"""
Diffusion MergedQKVParallelLinear (to_qkv) with 3 LoRAs.
Prioritize apply() in DiffusionBaseLinearLayerWithLoRA
"""
pass
@@ -0,0 +1,20 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
from vllm.lora.layers.replicated_linear import ReplicatedLinearWithLoRA
from .base_linear import DiffusionBaseLinearLayerWithLoRA
class DiffusionReplicatedLinearWithLoRA(
DiffusionBaseLinearLayerWithLoRA,
ReplicatedLinearWithLoRA,
):
"""
Diffusion ReplicatedLinear with LoRA.
Prioritize apply() in DiffusionBaseLinearLayerWithLoRA
"""
pass
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff. Show more