Merge remote-tracking branch 'origin/main' into dev/vllm-align
This commit is contained in:
commit
064595a97c
112 files changed
+12530
-1431
No files matched your search
+3
-15
@@ -20,19 +20,7 @@ steps:
|
||||
- label: "Simple Unit Test"
|
||||
depends_on: image-build
|
||||
commands:
|
||||
- |
|
||||
pytest -v -s \
|
||||
tests/entrypoints/ \
|
||||
tests/diffusion/cache/ \
|
||||
tests/diffusion/lora/ \
|
||||
tests/model_executor/models/qwen2_5_omni/test_audio_length.py \
|
||||
tests/worker/ \
|
||||
tests/distributed/omni_connectors/test_kv_flow.py \
|
||||
--cov=vllm_omni \
|
||||
--cov-branch \
|
||||
--cov-report=term-missing \
|
||||
--cov-report=html \
|
||||
--cov-report=xml
|
||||
- "pytest -v -s -m 'core_model and cpu' --cov=vllm_omni --cov-branch --cov-report=term-missing --cov-report=html --cov-report=xml"
|
||||
agents:
|
||||
queue: "gpu_1_queue"
|
||||
plugins:
|
||||
@@ -118,7 +106,7 @@ steps:
|
||||
timeout_in_minutes: 15
|
||||
depends_on: image-build
|
||||
commands:
|
||||
- pytest -s -v tests/e2e/offline_inference/test_cache_dit.py tests/e2e/offline_inference/test_teacache.py
|
||||
- pytest -s -v -m 'core_model and cache and diffusion and not distributed_cuda and L4'
|
||||
agents:
|
||||
queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU
|
||||
plugins:
|
||||
@@ -135,7 +123,7 @@ steps:
|
||||
timeout_in_minutes: 20
|
||||
depends_on: image-build
|
||||
commands:
|
||||
- pytest -s -v tests/e2e/offline_inference/test_sequence_parallel.py
|
||||
- pytest -s -v tests/e2e/offline_inference/test_sequence_parallel.py -m core_model
|
||||
agents:
|
||||
queue: "gpu_4_queue" # g6.12xlarge instance on AWS, has 4 L4 GPU
|
||||
plugins:
|
||||
|
||||
@@ -44,7 +44,7 @@ steps:
|
||||
- export GPU_ARCHS=gfx942
|
||||
- export VLLM_LOGGING_LEVEL=DEBUG
|
||||
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
- pytest -s -v tests/e2e/offline_inference/test_cache_dit.py tests/e2e/offline_inference/test_teacache.py
|
||||
- pytest -s -v -m 'core_model and cache and diffusion and not distributed_rocm and MI325'
|
||||
|
||||
- label: "Diffusion Sequence Parallelism Test"
|
||||
timeout_in_minutes: 20
|
||||
@@ -56,7 +56,7 @@ steps:
|
||||
- export GPU_ARCHS=gfx942
|
||||
- export VLLM_LOGGING_LEVEL=DEBUG
|
||||
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
- pytest -s -v tests/e2e/offline_inference/test_sequence_parallel.py
|
||||
- pytest -s -v tests/e2e/offline_inference/test_sequence_parallel.py -m core_model
|
||||
|
||||
- label: "Diffusion Tensor Parallelism Test"
|
||||
timeout_in_minutes: 20
|
||||
|
||||
@@ -10,11 +10,13 @@ steps:
|
||||
queue: "cpu_queue_premerge"
|
||||
|
||||
- label: "Omni Model Test with H100"
|
||||
timeout_in_minutes: 180
|
||||
timeout_in_minutes: 60
|
||||
depends_on: image-build
|
||||
if: build.env("NIGHTLY") == "1"
|
||||
commands:
|
||||
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
- pytest -s -v tests/e2e/online_serving/test_qwen3_omni_expansion.py
|
||||
- pytest -s -v tests/examples/online_serving/test_qwen3_omni.py
|
||||
agents:
|
||||
queue: "mithril-h100-pool"
|
||||
plugins:
|
||||
@@ -43,3 +45,24 @@ steps:
|
||||
hostPath:
|
||||
path: /mnt/hf-cache
|
||||
type: DirectoryOrCreate
|
||||
|
||||
|
||||
|
||||
- label: "Omni Model Test"
|
||||
timeout_in_minutes: 60
|
||||
depends_on: image-build
|
||||
commands:
|
||||
- export VLLM_LOGGING_LEVEL=DEBUG
|
||||
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
- pytest -s -v tests/examples/online_serving/test_qwen2_5_omni.py
|
||||
agents:
|
||||
queue: "gpu_4_queue" # g6.12xlarge instance on AWS, has 4 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"
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'examples/**'
|
||||
- 'apps/**'
|
||||
- '.github/**'
|
||||
- '.buildkite/**'
|
||||
- '**.md'
|
||||
@@ -20,6 +21,7 @@ on:
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'examples/**'
|
||||
- 'apps/**'
|
||||
- '.github/**'
|
||||
- '.buildkite/**'
|
||||
- '*.yml'
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: ComfyUI Integration -- Publish to Comfy registry
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
paths:
|
||||
- "apps/ComfyUI-vLLM-Omni/**"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/ComfyUI-vLLM-Omni
|
||||
|
||||
jobs:
|
||||
publish-node:
|
||||
name: Publish Custom Node to registry
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ♻️ Check out code
|
||||
uses: actions/checkout@v4
|
||||
- name: 📦 Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
with:
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
@@ -0,0 +1,40 @@
|
||||
name: ComfyUI Integration -- Validate backwards compatibility
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
paths:
|
||||
- "apps/ComfyUI-vLLM-Omni/**"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/ComfyUI-vLLM-Omni
|
||||
|
||||
jobs:
|
||||
check-base-path:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
exists: ${{ steps.check.outputs.exists }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: .
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
fetch-depth: 1
|
||||
- id: check
|
||||
run: |
|
||||
if [ -d "apps/ComfyUI-vLLM-Omni" ]; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
validate:
|
||||
needs: check-base-path
|
||||
if: needs.check-base-path.outputs.exists == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: comfy-org/node-diff@main
|
||||
@@ -153,6 +153,14 @@ dmypy.json
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# Claude
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
|
||||
# Codex
|
||||
AGENTS.md
|
||||
.codex/
|
||||
|
||||
# cursor
|
||||
.cursor/
|
||||
|
||||
@@ -214,6 +222,7 @@ data/
|
||||
datasets/
|
||||
*.csv
|
||||
*.json
|
||||
!apps/ComfyUI-vLLM-Omni/example_workflows/*.json
|
||||
*.jsonl
|
||||
*.parquet
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# OSX useful to ignore
|
||||
*.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# IntelliJ Idea
|
||||
.idea
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Cookiecutter
|
||||
output/
|
||||
python_boilerplate/
|
||||
cookiecutter-pypackage-env/
|
||||
|
||||
# vscode settings
|
||||
.history/
|
||||
*.code-workspace
|
||||
|
||||
# Frontend extension
|
||||
node_modules/
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
node.zip
|
||||
.vscode/
|
||||
.claude/
|
||||
.codemate/
|
||||
@@ -0,0 +1,15 @@
|
||||
Apache Software License 2.0
|
||||
|
||||
Copyright (c) 2026, Zeyu Huang
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,170 @@
|
||||
# vLLM-Omni
|
||||
|
||||
vLLM-Omni offers a ComfyUI integration on top of its online serving API.
|
||||
It can send model inference requests to either a locally running vLLM-Omni service or a remote one.
|
||||
|
||||
## Requirement
|
||||
|
||||
- Python 3.12 or above
|
||||
- [ComfyUI installed](https://docs.comfy.org/installation/system_requirements)
|
||||
- [vLLM-Omni installed](https://docs.vllm.ai/projects/vllm-omni/en/latest/getting_started/installation/) on either the same device or another device discoverable via the internet.
|
||||
- No need to install additional packages apart from those already required by ComfyUI.
|
||||
|
||||
> [!TIP]
|
||||
> If you run both ComfyUI and vLLM-Omni on the same device, you can create separate virtual environments and use different Python versions for them.
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
Copy this folder to the `custom_nodes` subfolder of your ComfyUI installation. Your directory should look like `ComfyUI/custom_nodes/ComfyUI-vLLM-Omni`.
|
||||
|
||||
If you are running ComfyUI during copying, you should restart ComfyUI to load this extension.
|
||||
|
||||
> [!TIP]
|
||||
> You can use utility websites such as https://download-directory.github.io/ to download a subdirectory of a repo. Also checkout community discussions (e.g., https://stackoverflow.com/questions/7106012/download-a-single-folder-or-directory-from-a-github-repository) for more info.
|
||||
|
||||
On the device and virtual environment you run ComfyUI, launch ComfyUI with
|
||||
```bash
|
||||
cd ComfyUI
|
||||
|
||||
# The regular way
|
||||
python main.py
|
||||
|
||||
# If you are mainly using this node, launch it faster with
|
||||
python main.py --cpu
|
||||
```
|
||||
|
||||
On the device and virtual environment you run vLLM-Omni, start a model service with
|
||||
```bash
|
||||
vllm serve The_Model_ID_to_Serve --omni --port 8000
|
||||
```
|
||||
|
||||
Check **ComfyUI's sidebar -> Node Library**. There should be a new folder named **vLLM-Omni**.
|
||||
If no, check your shell running the ComfyUI process. There may be some error messages before the line `Import times for custom nodes:` and the line `To see the GUI go to: http://127.0.0.1:8188`.
|
||||
|
||||
## Quickstart
|
||||
|
||||
This extension offers the following nodes based on the output modalities (at **ComfyUI sidebar -> Node Library**):
|
||||
|
||||
- **Generate Image** for text-to-image and image-to-image tasks
|
||||
- **Multimodality Comprehension** for multimodality-to-text and multimodality-to-audio tasks
|
||||
- **TTS** and **TTS Voice Clone** for TTS tasks
|
||||
|
||||
This extension also offers example workflows (at **ComfyUI sidebar -> Templates -> vLLM-Omni**)
|
||||
|
||||
> [!INFO]
|
||||
> The node UI and feature designs are intended to match vLLM-Omni online serving interfaces. It cannot offer more than what the interfaces support.
|
||||
|
||||
To build a simple workflow yourself,
|
||||
|
||||
- Drag a generation node onto the canvas.
|
||||
- Depending on your need, grab built-in multimedia file loader nodes, such as **image->Load Image**, **image->video->Load Video**, **audio->Load Audio**
|
||||
- Depending on your need, grab built-in multimedia file preview nodes, such as **image->Preview Image**, **image->video->Save Video**, **audio->Preview Audio**. For text output, you can install [ComfyUI-Custom-Scripts plugin](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/) and grab its **utils->Show Text 🐍** node.
|
||||
- If you want to tune sampling parameters, grab corresponding nodes from **vLLM-Omni-> Sampling Params**.
|
||||
- For multi-stage models, you can connect multiple **AR Sampling Params** and **Diffusion Sampling Params** nodes to a **Multi-Stage Sampling Params List** node, and connect this node to the generation node.
|
||||
- For some multi-stage models like BAGEL, [only one stage's sampling parameters are exposed and tunable via vLLM-Omni's online serving API](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/examples/online_serving/bagel/). Thus, these models are treated as single-stage ones. Please check the vLLM-Omni documentation on how to correctly set each model's sampling parameters.
|
||||
- For multi-stage models where all stages are either autoregression or diffusion, you can also connect only a single Sampling Params node, indicating that this set of sampling parameters will be used for all stages.
|
||||
|
||||
**The following features are tested**:
|
||||
|
||||
- Single-node workflows for
|
||||
- Multimodal Comprehension (e.g., Qwen Omni, BAGEL)
|
||||
- Text-to-Image Generation (e.g., Qwen-Image)
|
||||
- Image-to-Image Generation (e.g., Qwen-Image-Edit)
|
||||
- TTS (e.g., Qwen TTS, including VoiceDesign, VoiceClone, CustomVoice)
|
||||
|
||||
**The following features are not currently tested**. They may work or break. You are welcomed to test it out and offer comments.
|
||||
|
||||
- Multi-node workflow that connects multiple model services together.
|
||||
|
||||
## Screenshots and Examples
|
||||
|
||||
### Multimodal comprehension (e.g., Qwen Omni series, BAGEL)
|
||||
|
||||
(Also available at **ComfyUI sidebar->Template->vLLM-Omni->vLLM-Omni Annotated Example**)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" src="https://raw.githubusercontent.com/vllm-project/vllm-omni/refs/heads/main/apps/ComfyUI-vLLM-Omni/docs/images/comfyui-comprehension.jpg">
|
||||
<img alt="vLLM-Omni Main Architecture" src="https://raw.githubusercontent.com/vllm-project/vllm-omni/refs/heads/main/apps/ComfyUI-vLLM-Omni/docs/images/comfyui-comprehension.jpg" width=55%>
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
> [!TIP]
|
||||
> Although this node enables all-modality input, you should check whether the specific model you host and request for supports the modalities you connect to the node.
|
||||
|
||||
You can configure per-stage sampling parameters for multi-stage models.
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" src="https://raw.githubusercontent.com/vllm-project/vllm-omni/refs/heads/main/apps/ComfyUI-vLLM-Omni/docs/images/comfyui-multi-stage.jpg">
|
||||
<img alt="vLLM-Omni Main Architecture" src="https://raw.githubusercontent.com/vllm-project/vllm-omni/refs/heads/main/apps/ComfyUI-vLLM-Omni/docs/images/comfyui-multi-stage.jpg" width=55%>
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
### Text-to-image and image-to-image generation (e.g., Z-Image-Turbo, Qwen-Image-Edit, BAGEL)
|
||||
|
||||
(Also available at **ComfyUI sidebar->Template->vLLM-Omni->vLLM-Omni Image Generation**)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" src="https://raw.githubusercontent.com/vllm-project/vllm-omni/refs/heads/main/apps/ComfyUI-vLLM-Omni/docs/images/comfyui-image-generation.jpg">
|
||||
<img alt="vLLM-Omni Main Architecture" src="https://raw.githubusercontent.com/vllm-project/vllm-omni/refs/heads/main/apps/ComfyUI-vLLM-Omni/docs/images/comfyui-image-generation.jpg" width=55%>
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
> [!TIP]
|
||||
> The node automatically choose text-to-image or image-to-image API endpoints depending on whether you connect an image input or not.
|
||||
|
||||
### TTS (e.g., Qwen TTS series)
|
||||
|
||||
(Also available at **ComfyUI sidebar->Template->vLLM-Omni->vLLM-Omni TTS**)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" src="https://raw.githubusercontent.com/vllm-project/vllm-omni/refs/heads/main/apps/ComfyUI-vLLM-Omni/docs/images/comfyui-tts.jpg">
|
||||
<img alt="vLLM-Omni Main Architecture" src="https://raw.githubusercontent.com/vllm-project/vllm-omni/refs/heads/main/apps/ComfyUI-vLLM-Omni/docs/images/comfyui-tts.jpg" width=55%>
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
> [!TIP]
|
||||
> There is a dedicated node for VoiceClone tasks with reference audio input. Other simple text-to-speech tasks should use the regular TTS node.
|
||||
|
||||
## Develop
|
||||
|
||||
Follow the [development convention and rules of vLLM-Omni](https://docs.vllm.ai/projects/vllm-omni/en/latest/contributing/).
|
||||
|
||||
## Limitation and Non-Goals
|
||||
|
||||
- Single server mode only. No automatic load balancing or failover.
|
||||
- Features set is bounded to vLLM-Omni's online service capability, including
|
||||
- The types of models supported in online mode,
|
||||
- The types of sampling parameters supported in the online mode,
|
||||
- The ways to send files (primarily through full-length base64 in JSON payload),
|
||||
- Figuring out errors in the payload (such as unsupported fields by a specific model) if the endpoint does not explicitly return an error,
|
||||
- (The lack of) Authentication
|
||||
- (The lack of) Progress indicator
|
||||
|
||||
## Support
|
||||
|
||||
If you are new to ComfyUI, please check out [its documentation](https://docs.comfy.org/) for usage instructions.
|
||||
|
||||
If you are new to vLLM-Omni, please also check out [its documentation](https://docs.vllm.ai/projects/vllm-omni/en/latest/) for usage instructions.
|
||||
|
||||
Whenever you find an issue or problem, please
|
||||
|
||||
- First find out if this is an upstream limitation of vLLM-Omni's online serving mode, by [checking their documentation](https://docs.vllm.ai/projects/vllm-omni/en/latest/examples/).
|
||||
- [Open an issue](https://github.com/vllm-project/vllm-omni/issues) that clearly describes this ComfyUI or online service problem.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
Features
|
||||
|
||||
- https://github.com/dougbtv/comfyui-vllm-omni/ The official reference implementation for ComfyUI integration with vLLM-Omni's DALL-E compatible image generation API.
|
||||
- https://github.com/Comfy-Org/ComfyUI/tree/master/comfy_extras ComfyUI's built-in node implementations.
|
||||
|
||||
UI/UX design references
|
||||
|
||||
- https://github.com/sgl-project/sglang/pull/15271 SGLang Diffusion's official ComfyUI integration for image and video generation.
|
||||
- https://github.com/SXQBW/ComfyUI-Qwen-Omni A third party ComfyUI integration for Qwen Omni series.
|
||||
- https://github.com/flybirdxx/ComfyUI-Qwen-TTS https://github.com/DarioFT/ComfyUI-Qwen3-TTS Third party ComfyUI integrations for Qwen TTS series.
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Top-level package for comfyui_vllm_omni.""" # noqa: N999 # This is not a python library intended to be imported
|
||||
|
||||
__all__ = [
|
||||
"NODE_CLASS_MAPPINGS",
|
||||
"NODE_DISPLAY_NAME_MAPPINGS",
|
||||
"WEB_DIRECTORY",
|
||||
]
|
||||
|
||||
__author__ = """Zeyu Huang"""
|
||||
__email__ = "11222265+fhfuih@users.noreply.github.com"
|
||||
__version__ = "0.0.1"
|
||||
|
||||
from .comfyui_vllm_omni.nodes import (
|
||||
VLLMOmniARSampling,
|
||||
VLLMOmniComprehension,
|
||||
VLLMOmniDiffusionSampling,
|
||||
VLLMOmniGenerateImage,
|
||||
VLLMOmniQwenTTSParams,
|
||||
VLLMOmniSamplingParamsList,
|
||||
VLLMOmniTTS,
|
||||
VLLMOmniVoiceClone,
|
||||
)
|
||||
|
||||
# A dictionary that contains all nodes you want to export with their names
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
# === Generation ===
|
||||
"VLLMOmniGenerateImage": VLLMOmniGenerateImage,
|
||||
"VLLMOmniComprehension": VLLMOmniComprehension,
|
||||
"VLLMOmniTTS": VLLMOmniTTS,
|
||||
"VLLMOmniVoiceClone": VLLMOmniVoiceClone,
|
||||
# === Params ===
|
||||
"VLLMOmniARSampling": VLLMOmniARSampling,
|
||||
"VLLMOmniDiffusionSampling": VLLMOmniDiffusionSampling,
|
||||
"VLLMOmniSamplingParamsList": VLLMOmniSamplingParamsList,
|
||||
"VLLMOmniQwenTTSParams": VLLMOmniQwenTTSParams,
|
||||
}
|
||||
|
||||
# A dictionary that contains the friendly/humanly readable titles for the nodes
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
# === Generation ===
|
||||
"VLLMOmniGenerateImage": "Generate Image",
|
||||
"VLLMOmniComprehension": "Multimodality Comprehension",
|
||||
"VLLMOmniTTS": "TTS (Text to Speech)",
|
||||
"VLLMOmniVoiceClone": "TTS Voice Cloning",
|
||||
# === Params ===
|
||||
"VLLMOmniARSampling": "AR Sampling Params",
|
||||
"VLLMOmniDiffusionSampling": "Diffusion Sampling Params",
|
||||
"VLLMOmniSamplingParamsList": "Multi-Stage Sampling Params List",
|
||||
"VLLMOmniQwenTTSParams": "Qwen TTS Params",
|
||||
}
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
@@ -0,0 +1 @@
|
||||
# noqa: N999 # This is not a python library intended to be imported
|
||||
@@ -0,0 +1,569 @@
|
||||
from typing import Literal, cast
|
||||
|
||||
import torch
|
||||
from comfy_api.input import AudioInput, VideoInput
|
||||
|
||||
from .utils.api_client import VLLMOmniClient
|
||||
from .utils.logger import get_logger
|
||||
from .utils.models import lookup_model_spec
|
||||
from .utils.types import AudioFormat
|
||||
from .utils.validators import (
|
||||
add_sampling_parameters_to_stage,
|
||||
validate_model_and_sampling_params_types,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class _VLLMOmniGenerateBase:
|
||||
"""Base class for vLLM-Omni generation nodes with shared functionality."""
|
||||
|
||||
CATEGORY = "vLLM-Omni"
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, url, model) -> str | Literal[True]:
|
||||
"""
|
||||
Can only validate this model's own input. Cannot check inputs from other nodes.
|
||||
See: https://docs.comfy.org/custom-nodes/backend/server_overview#validate_inputs
|
||||
"""
|
||||
if not url:
|
||||
return "URL must not be empty"
|
||||
if not model:
|
||||
return "Model must not be empty"
|
||||
return True
|
||||
|
||||
|
||||
class VLLMOmniGenerateImage(_VLLMOmniGenerateBase):
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"url": ("STRING", {"default": "http://localhost:8000/v1"}),
|
||||
"model": ("STRING", {"default": "Tongyi-MAI/Z-Image-Turbo"}),
|
||||
"prompt": ("STRING", {"multiline": True}),
|
||||
"negative_prompt": ("STRING", {"multiline": True, "default": ""}),
|
||||
"width": ("INT", {"default": 512, "min": 64, "max": 2048}),
|
||||
"height": ("INT", {"default": 512, "min": 64, "max": 2048}),
|
||||
},
|
||||
"optional": {
|
||||
"image": ("IMAGE",),
|
||||
"mask": ("MASK",),
|
||||
# "video": ("VIDEO",),
|
||||
# "audio": ("AUDIO",),
|
||||
"sampling_params": ("SAMPLING_PARAMS",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "generate"
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
url: str,
|
||||
model: str,
|
||||
prompt: str,
|
||||
width: int,
|
||||
height: int,
|
||||
negative_prompt: str | None = None,
|
||||
image: torch.Tensor | None = None,
|
||||
mask: torch.Tensor | None = None,
|
||||
audio: AudioInput | None = None, # Hidden & unused
|
||||
video: VideoInput | None = None, # Hidden & unused
|
||||
sampling_params: dict | list[dict] | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
logger.info("Uncaught kwargs: %s", kwargs)
|
||||
logger.debug("Got sampling params: %s", sampling_params)
|
||||
validate_model_and_sampling_params_types(model, sampling_params)
|
||||
if image is None and mask is not None:
|
||||
raise ValueError("Mask input provided without an image input.")
|
||||
|
||||
client = VLLMOmniClient(url)
|
||||
|
||||
spec, pattern = lookup_model_spec(model)
|
||||
is_bagel = pattern is not None and "bagel" in pattern.lower()
|
||||
|
||||
# Prefer DALL-E compatible API for simple (one-stage) diffusion models
|
||||
if (spec is None or spec["stages"] == ["diffusion"]) and not is_bagel:
|
||||
sampling_params = cast(dict | None, sampling_params)
|
||||
if audio is None and image is None and video is None:
|
||||
# No multimodal input --- use DALL-E image generation
|
||||
logger.info("Using DALL-E image generation endpoint")
|
||||
output = await client.generate_image(
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
width=width,
|
||||
height=height,
|
||||
negative_prompt=negative_prompt,
|
||||
sampling_params=sampling_params,
|
||||
)
|
||||
return (output,)
|
||||
elif image is not None and audio is None and video is None:
|
||||
# Image and text input --- use DALL-E image edit
|
||||
logger.info("Using DALL-E image edit endpoint")
|
||||
output = await client.edit_image(
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
image=image,
|
||||
width=width,
|
||||
height=height,
|
||||
negative_prompt=negative_prompt,
|
||||
mask=mask,
|
||||
sampling_params=sampling_params,
|
||||
)
|
||||
return (output,)
|
||||
|
||||
logger.info("Using chat completion endpoint")
|
||||
sampling_params = add_sampling_parameters_to_stage(
|
||||
model, sampling_params, "diffusion", width=width, height=height
|
||||
)
|
||||
logger.debug("Edited sampling params: %s", sampling_params)
|
||||
|
||||
output = await client.generate_image_chat_completion(
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
negative_prompt=negative_prompt,
|
||||
image=image,
|
||||
audio=audio,
|
||||
video=video,
|
||||
sampling_params=sampling_params,
|
||||
)
|
||||
|
||||
return (output,)
|
||||
|
||||
|
||||
class VLLMOmniComprehension(_VLLMOmniGenerateBase):
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"url": ("STRING", {"default": "http://localhost:8000/v1"}),
|
||||
"model": ("STRING", {"default": "Qwen/Qwen2.5-Omni-7B"}),
|
||||
"prompt": ("STRING", {"multiline": True}),
|
||||
"output_text": ("BOOLEAN", {"default": True}),
|
||||
"output_audio": ("BOOLEAN", {"default": True}),
|
||||
"use_audio_in_video": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": {
|
||||
"image": ("IMAGE",),
|
||||
"video": ("VIDEO",),
|
||||
"audio": ("AUDIO",),
|
||||
"sampling_params": ("SAMPLING_PARAMS",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "AUDIO")
|
||||
RETURN_NAMES = ("text_response", "audio_response")
|
||||
FUNCTION = "generate"
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, url, model, output_text, output_audio) -> str | Literal[True]: # type: ignore[reportIncompatibleMethodOverride]
|
||||
super_validation = super().VALIDATE_INPUTS(url, model)
|
||||
if isinstance(super_validation, str):
|
||||
return super_validation
|
||||
if not output_text and not output_audio:
|
||||
return "At least one of output_text or output_audio must be True."
|
||||
return True
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
url: str,
|
||||
model: str,
|
||||
prompt: str,
|
||||
image: torch.Tensor | None = None,
|
||||
audio: AudioInput | None = None,
|
||||
video: VideoInput | None = None,
|
||||
sampling_params: dict | list[dict] | None = None,
|
||||
output_text: bool = True,
|
||||
output_audio: bool = True,
|
||||
use_audio_in_video: bool = True,
|
||||
**kwargs,
|
||||
) -> tuple[str, AudioInput]:
|
||||
logger.info("Uncaught kwargs: %s", kwargs)
|
||||
logger.debug("Got sampling params: %s", sampling_params)
|
||||
validate_model_and_sampling_params_types(model, sampling_params)
|
||||
|
||||
client = VLLMOmniClient(url)
|
||||
spec, pattern = lookup_model_spec(model)
|
||||
is_bagel = pattern is not None and "bagel" in pattern.lower()
|
||||
|
||||
if is_bagel:
|
||||
# A lot of special handlings here...
|
||||
if output_audio:
|
||||
raise ValueError("BAGEL models do not support audio output.")
|
||||
if audio is not None or video is not None:
|
||||
raise ValueError("BAGEL models do not support audio or video input.")
|
||||
(
|
||||
text_response,
|
||||
_,
|
||||
) = await client.generate_comprehension_chat_completion(
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
image=image,
|
||||
audio=None,
|
||||
video=None,
|
||||
sampling_params=sampling_params,
|
||||
modalities=["text"],
|
||||
)
|
||||
else:
|
||||
modalities = []
|
||||
if output_text:
|
||||
modalities.append("text")
|
||||
if output_audio:
|
||||
modalities.append("audio")
|
||||
|
||||
if use_audio_in_video and video is not None:
|
||||
use_audio_in_video = True
|
||||
else:
|
||||
use_audio_in_video = False
|
||||
|
||||
(
|
||||
text_response,
|
||||
audio,
|
||||
) = await client.generate_comprehension_chat_completion(
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
image=image,
|
||||
audio=audio,
|
||||
video=video,
|
||||
sampling_params=sampling_params,
|
||||
modalities=modalities,
|
||||
# == extra kwargs ==
|
||||
mm_processor_kwargs={"use_audio_in_video": use_audio_in_video},
|
||||
)
|
||||
|
||||
if text_response is None:
|
||||
text_response = ""
|
||||
if audio is None:
|
||||
channels = 1
|
||||
duration = 1
|
||||
sample_rate = 44100
|
||||
num_samples = int(round(duration * sample_rate))
|
||||
waveform = torch.zeros((1, channels, num_samples), dtype=torch.float32)
|
||||
audio = {"waveform": waveform, "sample_rate": sample_rate}
|
||||
|
||||
return (text_response, audio)
|
||||
|
||||
|
||||
class VLLMOmniTTS(_VLLMOmniGenerateBase):
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"url": ("STRING", {"default": "http://localhost:8000/v1"}),
|
||||
"model": (
|
||||
"STRING",
|
||||
{"default": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"},
|
||||
),
|
||||
"input": ("STRING", {"multiline": True}),
|
||||
"voice": ("STRING", {"default": "Vivian"}),
|
||||
"response_format": (["mp3", "opus", "aac", "flac", "wav", "pcm"],),
|
||||
"speed": (
|
||||
"FLOAT",
|
||||
{"default": 1.0, "min": 0.25, "max": 4.0, "step": 0.01},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"model_specific_params": ("TTS_PARAMS",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("AUDIO",)
|
||||
RETURN_NAMES = ("audio",)
|
||||
FUNCTION = "generate"
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
url: str,
|
||||
model: str,
|
||||
input: str,
|
||||
voice: str,
|
||||
response_format: AudioFormat,
|
||||
speed: float,
|
||||
model_specific_params: dict | None,
|
||||
**kwargs,
|
||||
) -> tuple[AudioInput]:
|
||||
logger.info("Got extra kwargs in TTS: %s", kwargs)
|
||||
|
||||
is_qwen_tts = "qwen3-tts" in model.lower()
|
||||
extra_params_type = None if model_specific_params is None else model_specific_params["type"]
|
||||
if not is_qwen_tts and extra_params_type == "qwen-tts":
|
||||
raise ValueError(
|
||||
"You have provided Qwen-specific TTS params."
|
||||
"However, the model appears to not be a Qwen TTS model (no 'Qwen3-TTS' in model name)."
|
||||
)
|
||||
|
||||
combined_params = {**kwargs, **(model_specific_params or {})}
|
||||
combined_params.pop("type", None) # Internal fields in model_specific_params
|
||||
|
||||
client = VLLMOmniClient(url)
|
||||
|
||||
audio = await client.generate_speech(
|
||||
model=model,
|
||||
input=input,
|
||||
voice=voice,
|
||||
response_format=response_format,
|
||||
speed=speed,
|
||||
**combined_params,
|
||||
)
|
||||
return (audio,)
|
||||
|
||||
|
||||
class VLLMOmniVoiceClone(_VLLMOmniGenerateBase):
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"url": ("STRING", {"default": "http://localhost:8000/v1"}),
|
||||
"model": ("STRING", {"default": "Qwen/Qwen3-TTS-12Hz-1.7B-Base"}),
|
||||
"input": ("STRING", {"multiline": True}),
|
||||
"voice": ("STRING", {"default": "Vivian"}),
|
||||
"response_format": (["mp3", "opus", "aac", "flac", "wav", "pcm"],),
|
||||
"speed": (
|
||||
"FLOAT",
|
||||
{"default": 1.0, "min": 0.25, "max": 4.0, "step": 0.01},
|
||||
),
|
||||
"ref_audio": ("AUDIO",),
|
||||
"ref_text": ("STRING", {"multiline": True}),
|
||||
"x_vector_only_mode": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"optional": {
|
||||
"model_specific_params": ("TTS_PARAMS",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("AUDIO",)
|
||||
RETURN_NAMES = ("audio",)
|
||||
FUNCTION = "generate"
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
url: str,
|
||||
model: str,
|
||||
input: str,
|
||||
voice: str,
|
||||
response_format: AudioFormat,
|
||||
speed: float,
|
||||
ref_audio: AudioInput,
|
||||
ref_text: str,
|
||||
x_vector_only_mode: bool,
|
||||
model_specific_params: dict | None,
|
||||
**kwargs,
|
||||
):
|
||||
is_qwen_tts = "qwen3-tts" in model.lower()
|
||||
extra_params_type = None if model_specific_params is None else model_specific_params["type"]
|
||||
if not is_qwen_tts and extra_params_type == "qwen-tts":
|
||||
raise ValueError(
|
||||
"You have provided Qwen-specific TTS params."
|
||||
"However, the model appears to not be a Qwen TTS model (no 'Qwen3-TTS' in model name)."
|
||||
)
|
||||
|
||||
combined_params = {
|
||||
"ref_audio": ref_audio,
|
||||
"ref_text": ref_text,
|
||||
"x_vector_only_mode": x_vector_only_mode,
|
||||
**kwargs,
|
||||
**(model_specific_params or {}),
|
||||
}
|
||||
combined_params.pop("type", None) # Internal fields in model_specific_params
|
||||
|
||||
client = VLLMOmniClient(url)
|
||||
|
||||
audio = await client.generate_speech(
|
||||
model=model,
|
||||
input=input,
|
||||
voice=voice,
|
||||
response_format=response_format,
|
||||
speed=speed,
|
||||
**combined_params,
|
||||
)
|
||||
return (audio,)
|
||||
|
||||
|
||||
class VLLMOmniARSampling:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"max_tokens": ("INT", {"default": 100, "min": 1, "max": 10000}),
|
||||
"temperature": (
|
||||
"FLOAT",
|
||||
{"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.01},
|
||||
),
|
||||
"top_p": (
|
||||
"FLOAT",
|
||||
{"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01},
|
||||
),
|
||||
"repetition_penalty": (
|
||||
"FLOAT",
|
||||
{"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.01},
|
||||
),
|
||||
# === Put seed at last. ===
|
||||
# Whenever a field named "seed" is present, ComfyUI adds another field called "control after generate"
|
||||
"seed": (
|
||||
"INT",
|
||||
{
|
||||
"default": -1,
|
||||
"min": -1,
|
||||
"step": 1,
|
||||
"tooltip": "-1 means to not provide a seed.",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SAMPLING_PARAMS",)
|
||||
RETURN_NAMES = ("AR sampling params",)
|
||||
FUNCTION = "get_params"
|
||||
CATEGORY = "vLLM-Omni/Sampling Params"
|
||||
|
||||
def get_params(self, seed, **kwargs):
|
||||
params = {
|
||||
"type": "autoregression", # for internal use, removed before sending the request
|
||||
**kwargs,
|
||||
}
|
||||
if seed >= 0:
|
||||
params["seed"] = seed
|
||||
return (params,)
|
||||
|
||||
|
||||
class VLLMOmniDiffusionSampling:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"n": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1,
|
||||
"min": 0,
|
||||
"max": 10,
|
||||
"step": 1,
|
||||
"tooltip": "Number of images to generate",
|
||||
},
|
||||
),
|
||||
"num_inference_steps": (
|
||||
"INT",
|
||||
{
|
||||
"default": 50,
|
||||
"min": 1,
|
||||
"max": 1000,
|
||||
"tooltip": "Number of denoising steps (higher = better quality, slower).",
|
||||
},
|
||||
),
|
||||
"guidance_scale": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 7.5,
|
||||
"min": 0.0,
|
||||
"max": 20.0,
|
||||
"step": 0.1,
|
||||
"tooltip": "Classifier-free guidance scale (higher = more prompt adherence).",
|
||||
},
|
||||
),
|
||||
"true_cfg_scale": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 1.0,
|
||||
"min": 0.0,
|
||||
"max": 20.0,
|
||||
"step": 0.5,
|
||||
"tooltip": "True CFG scale for advanced control (model-specific).",
|
||||
},
|
||||
),
|
||||
"vae_use_slicing": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "Enable VAE slicing for reduced memory usage (slight quality trade-off)",
|
||||
},
|
||||
),
|
||||
# === Put seed at last. ===
|
||||
# Whenever a field named "seed" is present, ComfyUI adds another field called "control after generate"
|
||||
"seed": (
|
||||
"INT",
|
||||
{
|
||||
"default": -1,
|
||||
"min": -1,
|
||||
"step": 1,
|
||||
"tooltip": "-1 means to not provide a seed.",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SAMPLING_PARAMS",)
|
||||
RETURN_NAMES = ("diffusion sampling params",)
|
||||
FUNCTION = "get_params"
|
||||
CATEGORY = "vLLM-Omni/Sampling Params"
|
||||
|
||||
def get_params(self, seed, **kwargs):
|
||||
params = {
|
||||
"type": "diffusion", # for internal use, removed before sending the request
|
||||
**kwargs,
|
||||
}
|
||||
if seed >= 0:
|
||||
params["seed"] = seed
|
||||
return (params,)
|
||||
|
||||
|
||||
class VLLMOmniSamplingParamsList:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"param1": ("SAMPLING_PARAMS",),
|
||||
},
|
||||
"optional": {
|
||||
"param2": ("SAMPLING_PARAMS",),
|
||||
"param3": ("SAMPLING_PARAMS",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SAMPLING_PARAMS",)
|
||||
RETURN_NAMES = ("param list",)
|
||||
FUNCTION = "aggregate"
|
||||
CATEGORY = "vLLM-Omni/Sampling Params"
|
||||
|
||||
def aggregate(self, param1: dict, param2: dict | None = None, param3: dict | None = None):
|
||||
for i, p in enumerate((param1, param2, param3)):
|
||||
if isinstance(p, list):
|
||||
raise ValueError(
|
||||
f"Input {i} is a Multi-Stage Sampling Params List. "
|
||||
f"Expected a single sampling parameters node (either AR or Diffusion)."
|
||||
)
|
||||
|
||||
params = [param1]
|
||||
if param2 is not None:
|
||||
params.append(param2)
|
||||
if param3 is not None:
|
||||
params.append(param3)
|
||||
return (params,)
|
||||
|
||||
|
||||
class VLLMOmniQwenTTSParams:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"task_type": (
|
||||
["CustomVoice", "VoiceDesign", "Base"],
|
||||
{"default": "CustomVoice"},
|
||||
),
|
||||
"language": (
|
||||
["Auto", "Chinese", "English", "Japanese", "Korean"],
|
||||
{"default": "Auto"},
|
||||
),
|
||||
"instructions": ("STRING", {"multiline": True}),
|
||||
"max_new_tokens": ("INT", {"default": 2048, "min": 1}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("TTS_PARAMS",)
|
||||
RETURN_NAMES = ("Qwen TTS params",)
|
||||
FUNCTION = "get_params"
|
||||
CATEGORY = "vLLM-Omni/TTS Params"
|
||||
|
||||
def get_params(self, **kwargs):
|
||||
return ({"type": "qwen-tts", **kwargs},)
|
||||
@@ -0,0 +1,473 @@
|
||||
"""
|
||||
An high-level API client adapter that forwards ComfyUI inputs to vLLM-Omni's REST API,
|
||||
and transforms the API responses back to ComfyUI formats.
|
||||
|
||||
The image generation part is derived from dougbtv/comfyui-vllm-omni by Doug (@dougbtv).
|
||||
Original source at https://github.com/dougbtv/comfyui-vllm-omni, distributed under the MIT License.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import av.error
|
||||
import torch
|
||||
from comfy_api.input import AudioInput, VideoInput
|
||||
|
||||
from .format import (
|
||||
audio_to_base64,
|
||||
base64_to_audio,
|
||||
base64_to_image_tensor,
|
||||
bytes_to_audio,
|
||||
image_tensor_to_base64,
|
||||
image_tensor_to_png_bytes,
|
||||
video_to_base64,
|
||||
)
|
||||
from .logger import get_logger, pretty_printer
|
||||
from .models import lookup_model_spec
|
||||
from .types import AudioFormat
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class VLLMOmniClient:
|
||||
def __init__(self, base_url: str, timeout: float = 300.0):
|
||||
self.base_url = base_url
|
||||
self.timeout = aiohttp.ClientTimeout(total=timeout)
|
||||
|
||||
async def generate_image(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
prompt: str,
|
||||
width: int,
|
||||
height: int,
|
||||
negative_prompt: str | None = None,
|
||||
sampling_params: dict | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Run text-to-image generation via DALLE API"""
|
||||
await self._check_model_exist(model)
|
||||
|
||||
size = f"{width}x{height}"
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
"response_format": "b64_json",
|
||||
}
|
||||
if negative_prompt:
|
||||
payload["negative_prompt"] = negative_prompt
|
||||
if sampling_params is not None:
|
||||
# Only select specific sampling params
|
||||
for k in (
|
||||
"n",
|
||||
"num_inference_steps",
|
||||
"guidance_scale",
|
||||
"true_cfg_scale",
|
||||
"vae_use_slicing",
|
||||
):
|
||||
if k in sampling_params and sampling_params[k] is not None:
|
||||
payload[k] = sampling_params[k]
|
||||
logger.debug("img gen payload: %s", payload)
|
||||
|
||||
url = self.base_url + "/images/generations"
|
||||
async with aiohttp.ClientSession(timeout=self.timeout) as session:
|
||||
try:
|
||||
async with session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
) as response:
|
||||
if not response.ok:
|
||||
error_text = await response.text()
|
||||
raise (ValueError if response.status < 500 else RuntimeError)(
|
||||
f"vLLM-Omni API returned status {response.status}: {error_text}"
|
||||
)
|
||||
|
||||
try:
|
||||
data = await response.json()
|
||||
except aiohttp.ContentTypeError as e:
|
||||
raise RuntimeError(f"Invalid JSON response from vLLM-Omni: {e}")
|
||||
if "data" not in data:
|
||||
raise RuntimeError("API response missing 'data' field - expected OpenAI DALL-E format")
|
||||
if not data["data"]:
|
||||
raise RuntimeError("API returned empty data array")
|
||||
|
||||
image_tensors = []
|
||||
for idx, img in enumerate(data["data"]):
|
||||
if "b64_json" not in img:
|
||||
raise RuntimeError(f"API returned image #{idx} without 'b64_json' field")
|
||||
base64_str = img["b64_json"]
|
||||
tensor = base64_to_image_tensor(base64_str)
|
||||
image_tensors.append(tensor)
|
||||
logger.debug("Image #%d has shape %s", idx, tensor.shape)
|
||||
|
||||
batch_tensor = torch.stack(image_tensors, dim=0)
|
||||
logger.debug("batch_tensor output has shape: %s", batch_tensor.shape)
|
||||
return batch_tensor
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
raise RuntimeError(f"Network error connecting to vLLM-Omni at {url}: {e}")
|
||||
|
||||
async def edit_image(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
prompt: str,
|
||||
image: torch.Tensor,
|
||||
width: int,
|
||||
height: int,
|
||||
negative_prompt: str | None = None,
|
||||
mask: torch.Tensor | None = None,
|
||||
sampling_params: dict | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Run image editing via DALLE API"""
|
||||
await self._check_model_exist(model)
|
||||
|
||||
size = f"{width}x{height}"
|
||||
image_filename = "image.png" # Required for multipart form
|
||||
form = aiohttp.FormData()
|
||||
form.add_field("model", model)
|
||||
form.add_field(
|
||||
"image",
|
||||
image_tensor_to_png_bytes(image, image_filename),
|
||||
filename=image_filename,
|
||||
content_type="image/png",
|
||||
)
|
||||
form.add_field("prompt", prompt)
|
||||
form.add_field("size", size)
|
||||
if negative_prompt:
|
||||
form.add_field("negative_prompt", negative_prompt)
|
||||
if sampling_params is not None:
|
||||
# Only select specific sampling params
|
||||
for k in ("n", "num_inference_steps", "guidance_scale", "true_cfg_scale"):
|
||||
if k in sampling_params and sampling_params[k] is not None:
|
||||
form.add_field(k, str(sampling_params[k]))
|
||||
if mask is not None:
|
||||
mask_filename = "mask.png"
|
||||
form.add_field(
|
||||
"mask",
|
||||
image_tensor_to_png_bytes(mask, mask_filename),
|
||||
filename=mask_filename,
|
||||
content_type="image/png",
|
||||
)
|
||||
|
||||
url = self.base_url + "/images/edits"
|
||||
async with aiohttp.ClientSession(timeout=self.timeout) as session:
|
||||
try:
|
||||
async with session.post(url, data=form) as response:
|
||||
if not response.ok:
|
||||
error_text = await response.text()
|
||||
raise (ValueError if response.status < 500 else RuntimeError)(
|
||||
f"vLLM-Omni API returned status {response.status}: {error_text}"
|
||||
)
|
||||
|
||||
try:
|
||||
data = await response.json()
|
||||
except aiohttp.ContentTypeError as e:
|
||||
raise RuntimeError(f"Invalid JSON response from vLLM-Omni: {e}")
|
||||
|
||||
if "data" not in data:
|
||||
raise RuntimeError("API response missing 'data' field - expected OpenAI DALL-E format")
|
||||
if not data["data"]:
|
||||
raise RuntimeError("API returned empty data array")
|
||||
|
||||
image_tensors = []
|
||||
for idx, img in enumerate(data["data"]):
|
||||
if "b64_json" not in img:
|
||||
raise RuntimeError(f"API returned image #{idx} without 'b64_json' field")
|
||||
base64_str = img["b64_json"]
|
||||
tensor = base64_to_image_tensor(base64_str)
|
||||
image_tensors.append(tensor)
|
||||
|
||||
return torch.stack(image_tensors, dim=0)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
raise RuntimeError(f"Network error connecting to vLLM-Omni at {url}: {e}")
|
||||
|
||||
async def generate_image_chat_completion(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
prompt: str,
|
||||
negative_prompt: str | None = None,
|
||||
image: torch.Tensor | None = None,
|
||||
audio: AudioInput | None = None,
|
||||
video: VideoInput | None = None,
|
||||
sampling_params: dict | list[dict] | None = None,
|
||||
) -> torch.Tensor:
|
||||
payload = VLLMOmniClient._prepare_chat_completion_messages(
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
negative_prompt=negative_prompt,
|
||||
image=image,
|
||||
audio=audio,
|
||||
video=video,
|
||||
sampling_params=sampling_params,
|
||||
modalities=["image"],
|
||||
)
|
||||
choices = await self._generate_base_chat_completion(model, payload)
|
||||
|
||||
image_tensors = []
|
||||
for idx, img_content in enumerate(choices[0]["message"]["content"]):
|
||||
base64_str = img_content.get("image_url", {}).get("url", "")
|
||||
if not base64_str:
|
||||
raise RuntimeError(f"API returned image #{idx} without image url")
|
||||
tensor = base64_to_image_tensor(base64_str)
|
||||
image_tensors.append(tensor)
|
||||
|
||||
return torch.stack(image_tensors, dim=0)
|
||||
|
||||
async def generate_comprehension_chat_completion(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
prompt: str,
|
||||
image: torch.Tensor | None = None,
|
||||
audio: AudioInput | None = None,
|
||||
video: VideoInput | None = None,
|
||||
sampling_params: dict | list[dict] | None = None,
|
||||
modalities: list[str] = ["text", "audio"],
|
||||
**extra_body,
|
||||
) -> tuple[str | None, AudioInput | None]:
|
||||
# Response may contain two choices: one with text, one with audio
|
||||
payload = VLLMOmniClient._prepare_chat_completion_messages(
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
negative_prompt=None,
|
||||
image=image,
|
||||
audio=audio,
|
||||
video=video,
|
||||
sampling_params=sampling_params,
|
||||
modalities=modalities,
|
||||
**extra_body,
|
||||
)
|
||||
|
||||
choices = await self._generate_base_chat_completion(model, payload)
|
||||
text_response = None
|
||||
audio_base64 = None
|
||||
for choice in choices:
|
||||
try:
|
||||
text_response = choice["message"]["content"]
|
||||
except (KeyError, TypeError):
|
||||
# Either this case (text response) or the audio response case will be hit. Checking None's later.
|
||||
pass
|
||||
try:
|
||||
audio_base64 = choice["message"]["audio"]["data"]
|
||||
except (KeyError, TypeError):
|
||||
# Either this case (text response) or the audio response case will be hit. Checking None's later.
|
||||
pass
|
||||
if audio_base64 is None and text_response is None:
|
||||
raise RuntimeError(
|
||||
"API response missing both '.message.audio' and 'message.content' fields."
|
||||
f"The choices object is {choices}"
|
||||
)
|
||||
if audio_base64 is not None:
|
||||
audio = base64_to_audio(audio_base64)
|
||||
logger.debug(
|
||||
"audio sample rate %d, audio shape %s, duration in second %f",
|
||||
audio["sample_rate"],
|
||||
audio["waveform"].shape,
|
||||
audio["waveform"].shape[2] / audio["sample_rate"],
|
||||
)
|
||||
else:
|
||||
audio = None
|
||||
return text_response, audio
|
||||
|
||||
async def generate_speech(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
input: str,
|
||||
voice: str,
|
||||
response_format: AudioFormat,
|
||||
speed: float,
|
||||
**extra_params,
|
||||
) -> AudioInput:
|
||||
await self._check_model_exist(model)
|
||||
|
||||
ref_audio: AudioInput | None = extra_params.pop("ref_audio", None)
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"input": input,
|
||||
"voice": voice,
|
||||
"response_format": response_format,
|
||||
"speed": speed,
|
||||
**extra_params,
|
||||
}
|
||||
|
||||
if ref_audio is not None:
|
||||
audio_base64 = audio_to_base64(ref_audio)
|
||||
payload["ref_audio"] = audio_base64
|
||||
|
||||
logger.debug("Omni TTS payload: %s", pretty_printer.pformat(payload))
|
||||
|
||||
url = self.base_url + "/audio/speech"
|
||||
async with aiohttp.ClientSession(timeout=self.timeout) as session:
|
||||
try:
|
||||
async with session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
) as response:
|
||||
if not response.ok:
|
||||
error_text = await response.text()
|
||||
raise (ValueError if response.status < 500 else RuntimeError)(
|
||||
f"vLLM-Omni API returned status {response.status}: {error_text}"
|
||||
)
|
||||
|
||||
try:
|
||||
audio_bytes = await response.read()
|
||||
except aiohttp.ContentTypeError as e:
|
||||
raise RuntimeError(f"Invalid JSON response from vLLM-Omni: {e}")
|
||||
|
||||
try:
|
||||
audio = bytes_to_audio(audio_bytes)
|
||||
except av.error.InvalidDataError as e:
|
||||
raise ValueError(
|
||||
f"Invalid audio data received from vLLM-Omni: {e}"
|
||||
"Check if you have input unsupported arguments (such as 'voice')"
|
||||
)
|
||||
return audio
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
raise RuntimeError(f"Network error connecting to vLLM-Omni at {url}: {e}")
|
||||
|
||||
async def _generate_base_chat_completion(self, model: str, payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
logger.debug("Omni payload: %s", pretty_printer.pformat(payload))
|
||||
await self._check_model_exist(model)
|
||||
|
||||
url = self.base_url + "/chat/completions"
|
||||
async with aiohttp.ClientSession(timeout=self.timeout) as session:
|
||||
try:
|
||||
async with session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
) as response:
|
||||
if not response.ok:
|
||||
error_text = await response.text()
|
||||
raise (ValueError if response.status < 500 else RuntimeError)(
|
||||
f"vLLM-Omni API returned status {response.status}: {error_text}"
|
||||
)
|
||||
|
||||
try:
|
||||
data = await response.json()
|
||||
except aiohttp.ContentTypeError as e:
|
||||
raise RuntimeError(f"Invalid JSON response from vLLM-Omni: {e}")
|
||||
|
||||
logger.debug(
|
||||
"chat completion response: %s",
|
||||
pretty_printer.pformat(data),
|
||||
)
|
||||
|
||||
try:
|
||||
return data["choices"]
|
||||
except (KeyError, TypeError):
|
||||
raise RuntimeError("Invalid JSON response from vLLM-Omni: missing 'choices' field")
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
raise RuntimeError(f"Network error connecting to vLLM-Omni at {self.base_url}: {e}")
|
||||
|
||||
async def _check_model_exist(self, model: str):
|
||||
url = self.base_url + "/models"
|
||||
async with aiohttp.ClientSession(timeout=self.timeout) as session:
|
||||
try:
|
||||
async with session.get(
|
||||
url,
|
||||
headers={"Content-Type": "application/json"},
|
||||
) as response:
|
||||
if not response.ok:
|
||||
error_text = await response.text()
|
||||
raise (ValueError if response.status < 500 else RuntimeError)(
|
||||
f"vLLM-Omni API returned status {response.status} "
|
||||
f"when getting hosted model list: {error_text}"
|
||||
)
|
||||
|
||||
try:
|
||||
data = await response.json()
|
||||
except aiohttp.ContentTypeError as e:
|
||||
raise RuntimeError(f"Invalid JSON response when getting hosted model list from vLLM-Omni: {e}")
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
raise RuntimeError(f"Network error connecting to vLLM-Omni at {self.base_url}: {e}")
|
||||
try:
|
||||
model_list = data["data"]
|
||||
model_found = next((True for m in model_list if m["id"] == model), False)
|
||||
except (KeyError, TypeError):
|
||||
raise RuntimeError(f"Invalid JSON response of the hosted model list: {data}")
|
||||
|
||||
if not model_found:
|
||||
raise ValueError(f"Model {model} not served at {self.base_url}.")
|
||||
|
||||
@staticmethod
|
||||
def _prepare_chat_completion_messages(
|
||||
*,
|
||||
model: str,
|
||||
prompt: str,
|
||||
negative_prompt: str | None,
|
||||
image: torch.Tensor | None = None,
|
||||
audio: AudioInput | None = None,
|
||||
video: VideoInput | None = None,
|
||||
sampling_params: dict | list[dict] | None = None,
|
||||
modalities: list[str] | None = None, # diffusion don't have this field
|
||||
**extra_body,
|
||||
):
|
||||
message_content: list[dict] = [{"type": "text", "text": prompt}]
|
||||
if image is not None:
|
||||
message_content.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": image_tensor_to_base64(image)},
|
||||
}
|
||||
)
|
||||
if audio is not None:
|
||||
message_content.append({"type": "audio_url", "audio_url": {"url": audio_to_base64(audio)}})
|
||||
if video is not None:
|
||||
message_content.append({"type": "video_url", "video_url": {"url": video_to_base64(video)}})
|
||||
messages = [{"role": "user", "content": message_content}]
|
||||
|
||||
combined_extra_body: dict[str, Any] = {}
|
||||
if sampling_params is not None:
|
||||
spec, _ = lookup_model_spec(model)
|
||||
is_single_sampling_param = isinstance(sampling_params, dict) or len(sampling_params) == 1
|
||||
|
||||
# Exclude internal key
|
||||
if isinstance(sampling_params, dict):
|
||||
sampling_params = {k: v for k, v in sampling_params.items() if k != "type"}
|
||||
else:
|
||||
sampling_params = [{k: v for k, v in sp.items() if k != "type"} for sp in sampling_params]
|
||||
|
||||
if (spec is None and is_single_sampling_param) or (spec is not None and spec["stages"] == ["diffusion"]):
|
||||
# Diffusion format: extra_body directly contains sampling params.
|
||||
# Validation should have taken care of matching sampling params' types.
|
||||
# * Use this mode if the model is a simple one-stage diffusion model.
|
||||
# * Fallback to this mode if model is not registered and a single sampling param is provided.
|
||||
sampling_params = sampling_params if isinstance(sampling_params, dict) else sampling_params[0]
|
||||
combined_extra_body: dict[str, Any] = {**sampling_params}
|
||||
if "n" in combined_extra_body:
|
||||
combined_extra_body["num_outputs_per_prompt"] = combined_extra_body["n"]
|
||||
del combined_extra_body["n"]
|
||||
else:
|
||||
# Use AR style payload, extra_body has a sampling_params_list field
|
||||
combined_extra_body: dict[str, Any] = {"sampling_params_list": sampling_params}
|
||||
|
||||
if negative_prompt:
|
||||
combined_extra_body["negative_prompt"] = negative_prompt
|
||||
|
||||
if extra_body:
|
||||
combined_extra_body.update(extra_body)
|
||||
|
||||
payload: dict[str, Any] = {"messages": messages, "model": model}
|
||||
if combined_extra_body:
|
||||
payload["extra_body"] = combined_extra_body
|
||||
if modalities:
|
||||
payload["modalities"] = modalities
|
||||
|
||||
spec, _ = lookup_model_spec(model)
|
||||
if spec:
|
||||
preprocessor = spec.get("payload_preprocessor", None)
|
||||
if preprocessor is not None:
|
||||
payload = preprocessor(payload)
|
||||
|
||||
return payload
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Image/tensor format helpers.
|
||||
|
||||
The image generation part is derived from dougbtv/comfyui-vllm-omni by Doug (@dougbtv).
|
||||
Original source at https://github.com/dougbtv/comfyui-vllm-omni, distributed under the MIT License.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import mimetypes
|
||||
from io import BytesIO
|
||||
|
||||
import av
|
||||
import numpy as np
|
||||
import torch
|
||||
from comfy_api.input import AudioInput, VideoInput
|
||||
from comfy_extras import nodes_audio
|
||||
from PIL import Image
|
||||
|
||||
from .logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def base64_to_image_tensor(base64_str: str, mode: str = "RGB") -> torch.Tensor:
|
||||
"""
|
||||
Convert base64-encoded image to ComfyUI image tensor.
|
||||
|
||||
Args:
|
||||
base64_str: Base64-encoded image string
|
||||
mode: PIL image mode (default RGB for transparency support)
|
||||
|
||||
Returns:
|
||||
torch.Tensor with shape (1, H, W, C) in float32 [0, 1] range
|
||||
|
||||
Raises:
|
||||
ValueError: If base64 string is invalid or image cannot be decoded
|
||||
"""
|
||||
if base64_str.startswith("data:image"):
|
||||
_, base64_str = base64_str.split(",", 1)
|
||||
|
||||
try:
|
||||
# Decode base64 to bytes
|
||||
image_bytes = base64.b64decode(base64_str)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid base64 string: {e}")
|
||||
|
||||
# Create BytesIO object for PIL
|
||||
image_bytesio = BytesIO(image_bytes)
|
||||
|
||||
# Open with PIL and convert to desired mode
|
||||
try:
|
||||
pil_image = Image.open(image_bytesio)
|
||||
pil_image = pil_image.convert(mode)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to open image: {e}")
|
||||
|
||||
image_array = np.asarray(pil_image).astype(np.float32) / 255.0
|
||||
image_tensor = torch.from_numpy(image_array)
|
||||
return image_tensor
|
||||
|
||||
|
||||
def image_tensor_to_png_bytes(tensor: torch.Tensor, filename: str = "image.png") -> BytesIO:
|
||||
"""
|
||||
Convert ComfyUI image tensor to PNG BytesIO for multipart upload.
|
||||
|
||||
This function converts a ComfyUI IMAGE tensor to a PNG-encoded BytesIO object
|
||||
suitable for multipart/form-data upload. The BytesIO object has its .name
|
||||
attribute set, which is required by aiohttp for file uploads.
|
||||
|
||||
Args:
|
||||
tensor: ComfyUI IMAGE tensor with shape (B, H, W, C), dtype float32, range [0, 1]
|
||||
filename: Name attribute to set on BytesIO (default: "image.png")
|
||||
|
||||
Returns:
|
||||
BytesIO object containing PNG-encoded image with .name attribute set
|
||||
|
||||
Raises:
|
||||
ValueError: If tensor format is invalid (not 4D, wrong dtype, etc.)
|
||||
"""
|
||||
if tensor.ndim != 4:
|
||||
raise ValueError(f"Expected 4D tensor with shape (B, H, W, C), got {tensor.ndim}D tensor")
|
||||
|
||||
image_tensor = tensor[0] # Shape: (H, W, C)
|
||||
image_np = (image_tensor.cpu().numpy() * 255.0).astype(np.uint8)
|
||||
pil_image = Image.fromarray(image_np)
|
||||
|
||||
# Save to BytesIO as image file
|
||||
img_bytes = BytesIO()
|
||||
# Set name attribute (required for multipart upload and mimetype detection)
|
||||
img_bytes.name = filename
|
||||
try:
|
||||
pil_image.save(img_bytes)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to save image as file: {e}")
|
||||
|
||||
# Reset position to beginning
|
||||
img_bytes.seek(0)
|
||||
|
||||
return img_bytes
|
||||
|
||||
|
||||
def image_tensor_to_base64(tensor: torch.Tensor, filename: str = "image.png") -> str:
|
||||
"""
|
||||
Convert ComfyUI image tensor to base64-encoded image string.
|
||||
|
||||
Args:
|
||||
tensor: ComfyUI IMAGE tensor with shape (B, H, W, C), dtype float32, range [0, 1]
|
||||
filename: Name attribute to set on BytesIO (default: "image.png")
|
||||
format: File format of the output image file buffer (default: "PNG")
|
||||
|
||||
Returns:
|
||||
Base64-encoded image string
|
||||
|
||||
Raises:
|
||||
ValueError: If tensor format is invalid (not 4D, wrong dtype, etc.)
|
||||
"""
|
||||
img_bytes = image_tensor_to_png_bytes(tensor, filename)
|
||||
img_bytes.seek(0)
|
||||
byte_data = img_bytes.read()
|
||||
base64_str = base64.b64encode(byte_data).decode("utf-8")
|
||||
mime_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
return f"data:{mime_type};base64,{base64_str}"
|
||||
|
||||
|
||||
def video_to_bytes(video: VideoInput, filename: str = "video.mp4") -> BytesIO:
|
||||
output_buffer = BytesIO()
|
||||
output_buffer.name = filename
|
||||
video.save_to(output_buffer)
|
||||
output_buffer.seek(0)
|
||||
return output_buffer
|
||||
|
||||
|
||||
def video_to_base64(video: VideoInput, filename: str = "video.mp4") -> str:
|
||||
video_buffer = video_to_bytes(video, filename)
|
||||
video_buffer.seek(0)
|
||||
byte_data = video_buffer.read()
|
||||
base64_str = base64.b64encode(byte_data).decode("utf-8")
|
||||
mime_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
return f"data:{mime_type};base64,{base64_str}"
|
||||
|
||||
|
||||
def audio_to_bytes(audio: AudioInput, filename: str = "audio.mp3", quality: str = "128k") -> BytesIO:
|
||||
waveform = audio["waveform"][0] # Shape: (C, T)
|
||||
sample_rate = audio["sample_rate"]
|
||||
format = filename.rsplit(".", maxsplit=1)[1]
|
||||
layout = "mono" if waveform.shape[0] == 1 else "stereo"
|
||||
|
||||
output_buffer = BytesIO()
|
||||
output_buffer.name = filename
|
||||
output_container = av.open(output_buffer, mode="w", format=format)
|
||||
if format == "opus":
|
||||
out_stream = output_container.add_stream("libopus", rate=sample_rate, layout=layout)
|
||||
if quality == "64k":
|
||||
out_stream.bit_rate = 64000 # type: ignore # copy from ComfyUI comfy_api/latest/_ui.py
|
||||
elif quality == "96k":
|
||||
out_stream.bit_rate = 96000 # type: ignore # copy from ComfyUI comfy_api/latest/_ui.py
|
||||
elif quality == "128k":
|
||||
out_stream.bit_rate = 128000 # type: ignore # copy from ComfyUI comfy_api/latest/_ui.py
|
||||
elif quality == "192k":
|
||||
out_stream.bit_rate = 192000 # type: ignore # copy from ComfyUI comfy_api/latest/_ui.py
|
||||
elif quality == "320k":
|
||||
out_stream.bit_rate = 320000 # type: ignore # copy from ComfyUI comfy_api/latest/_ui.py
|
||||
elif format == "mp3":
|
||||
out_stream = output_container.add_stream("libmp3lame", rate=sample_rate, layout=layout)
|
||||
if quality == "V0":
|
||||
out_stream.codec_context.qscale = 1 # type: ignore # copy from ComfyUI comfy_api/latest/_ui.py
|
||||
elif quality == "128k":
|
||||
out_stream.bit_rate = 128000 # type: ignore # copy from ComfyUI comfy_api/latest/_ui.py
|
||||
elif quality == "320k":
|
||||
out_stream.bit_rate = 320000 # type: ignore # copy from ComfyUI comfy_api/latest/_ui.py
|
||||
else: # format == "flac":
|
||||
out_stream = output_container.add_stream("flac", rate=sample_rate, layout=layout)
|
||||
|
||||
frame = av.AudioFrame.from_ndarray(
|
||||
waveform.movedim(0, 1).reshape(1, -1).float().numpy(),
|
||||
format="flt",
|
||||
layout=layout,
|
||||
)
|
||||
frame.sample_rate = sample_rate
|
||||
frame.pts = 0
|
||||
output_container.mux(out_stream.encode(frame)) # type: ignore # copy from ComfyUI comfy_api/latest/_ui.py
|
||||
# Flush encoder
|
||||
output_container.mux(out_stream.encode(None)) # type: ignore # copy from ComfyUI comfy_api/latest/_ui.py
|
||||
output_container.close()
|
||||
output_buffer.seek(0)
|
||||
|
||||
return output_buffer
|
||||
|
||||
|
||||
def audio_to_base64(audio: AudioInput, filename: str = "audio.mp3", quality: str = "128k") -> str:
|
||||
audio_buffer = audio_to_bytes(audio, filename, quality)
|
||||
audio_buffer.seek(0)
|
||||
byte_data = audio_buffer.read()
|
||||
base64_str = base64.b64encode(byte_data).decode("utf-8")
|
||||
mime_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
return f"data:{mime_type};base64,{base64_str}"
|
||||
|
||||
|
||||
def bytes_to_audio(audio_bytes: bytes) -> AudioInput:
|
||||
"""
|
||||
Convert audio bytes to ComfyUI audio tensor.
|
||||
|
||||
Args:
|
||||
audio_bytes: Audio file bytes
|
||||
Returns:
|
||||
torch.Tensor with shape (B, C, T) in float32 range [-1, 1]
|
||||
"""
|
||||
audio_buffer = BytesIO(audio_bytes)
|
||||
waveform, sample_rate = nodes_audio.load(audio_buffer) # type: ignore # Although expect string argument, it calls av.open underneath, which supports BytesIO (file-like)
|
||||
return {"waveform": waveform.unsqueeze(0), "sample_rate": sample_rate}
|
||||
|
||||
|
||||
def base64_to_audio(base64_str: str) -> AudioInput:
|
||||
"""
|
||||
Convert base64-encoded audio to ComfyUI audio tensor.
|
||||
|
||||
Args:
|
||||
base64_str: Base64-encoded audio string
|
||||
Returns:
|
||||
torch.Tensor with shape (B, C, T) in float32 range [-1, 1]
|
||||
"""
|
||||
if base64_str.startswith("data:audio"):
|
||||
_, base64_str = base64_str.split(",", 1)
|
||||
|
||||
try:
|
||||
# Decode base64 to bytes
|
||||
audio_bytes = base64.b64decode(base64_str)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid base64 string: {e}")
|
||||
|
||||
return bytes_to_audio(audio_bytes)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Centralized logger configuration for vLLM-Omni ComfyUI."""
|
||||
|
||||
import logging
|
||||
import pprint
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""
|
||||
Get or create a logger with proper formatting.
|
||||
|
||||
Args:
|
||||
name: Logger name (typically __name__ of the calling module)
|
||||
|
||||
Returns:
|
||||
Configured logger instance
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
|
||||
# Only configure if not already configured
|
||||
if not logger.handlers:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Create console handler
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setLevel(logging.INFO)
|
||||
|
||||
# Create formatter
|
||||
formatter = logging.Formatter(
|
||||
fmt="(ComfyUI-vLLM-Omni) [%(levelname)s] %(asctime)s [%(filename)s:%(lineno)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
# Add handler to logger
|
||||
logger.addHandler(handler)
|
||||
|
||||
# Prevent propagation to root logger
|
||||
logger.propagate = False
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
class OmitBase64PrettyPrinter(pprint.PrettyPrinter):
|
||||
"""
|
||||
A PrettyPrinter that redacts specific field names with '...'
|
||||
wherever they appear in nested structures.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _format(self, obj: Any, stream, indent: int, allowance: int, context, level: int) -> None:
|
||||
# Check if this is a dict with redacted keys
|
||||
if isinstance(obj, dict):
|
||||
# Create a copy with redacted values
|
||||
display_obj = {}
|
||||
for key, value in obj.items():
|
||||
if key == "data" or key == "url":
|
||||
if value.startswith("data:"):
|
||||
base64_header = value.split(",", 1)[0]
|
||||
display_obj[key] = f"{base64_header},***"
|
||||
elif value.startswith("http://") or value.startswith("https://"):
|
||||
display_obj[key] = value
|
||||
elif len(value) > 10:
|
||||
display_obj[key] = f"{value[:10]}***"
|
||||
else:
|
||||
display_obj[key] = value
|
||||
else:
|
||||
display_obj[key] = value
|
||||
obj = display_obj
|
||||
|
||||
# Handle list/tuple/set containing dicts that might have redacted keys
|
||||
# (pprint will recursively call _format on nested items, so this
|
||||
# handles arbitrary nesting automatically)
|
||||
|
||||
super()._format(obj, stream, indent, allowance, context, level)
|
||||
|
||||
|
||||
pretty_printer = OmitBase64PrettyPrinter()
|
||||
|
||||
|
||||
# ========== PPrint EXAMPLES ==========
|
||||
|
||||
if __name__ == "__main__":
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are Qwen, a virtual human developed by the Qwen Team, Alibaba Group,"
|
||||
"capable of perceiving auditory and visual inputs, as well as generating text and speech."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "What sound is it, and what is the drawing about?",
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "What sound is it, and what is the drawing about?",
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAwAAAA"},
|
||||
},
|
||||
{
|
||||
"type": "audio_url",
|
||||
"audio_url": {"url": "data:audio/mpeg;base64,SUQzBAAAAAAAIlRTU0UAAAAOAAADT="},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"extra_body": {"mm_processor_kwargs": {"use_audio_in_video": False}},
|
||||
"modalities": ["text"],
|
||||
}
|
||||
|
||||
# Create printer that redacts 'password' and 'token' fields
|
||||
|
||||
print("\nRedactingPrettyPrinter (hides secrets):")
|
||||
pretty_printer.pprint(data)
|
||||
@@ -0,0 +1,119 @@
|
||||
import re
|
||||
|
||||
from .types import Modality, ModelMode, Spec
|
||||
|
||||
|
||||
def _bagel_payload_preprocessor(payload: dict) -> dict:
|
||||
try:
|
||||
for message in payload["messages"]:
|
||||
for content in message["content"]:
|
||||
if content["type"] == "text":
|
||||
content["text"] = "<|im_start|>" + content["text"] + "<|im_end|>"
|
||||
except (KeyError, TypeError):
|
||||
raise RuntimeError("Internal Error: malformatted BAGEL payload")
|
||||
extra_body = payload.pop("extra_body", {})
|
||||
return {**payload, **extra_body}
|
||||
|
||||
|
||||
def _qwen25_payload_preprocessor(payload: dict) -> dict:
|
||||
if payload["messages"][0]["role"] != "system":
|
||||
payload["messages"] = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are Qwen, a virtual human developed by the Qwen Team, Alibaba Group,"
|
||||
"capable of perceiving auditory and visual inputs, as well as generating text and speech."
|
||||
),
|
||||
},
|
||||
*payload["messages"],
|
||||
]
|
||||
return payload
|
||||
|
||||
|
||||
_MODEL_PIPELINE_SPECS: dict[str, Spec] = {
|
||||
r"BAGEL-7B-MoT": {
|
||||
"stages": [
|
||||
"diffusion" # The vLLM-Omni interface treats it as a single-stage diffusion model
|
||||
],
|
||||
"modes": [
|
||||
{
|
||||
"mode": ModelMode.COMPREHENSION,
|
||||
"input_modalities": [Modality.TEXT, Modality.IMAGE],
|
||||
}
|
||||
],
|
||||
"payload_preprocessor": _bagel_payload_preprocessor,
|
||||
},
|
||||
r"Qwen2.5-Omni*": {
|
||||
"stages": ["autoregression", "autoregression", "autoregression"],
|
||||
"payload_preprocessor": _qwen25_payload_preprocessor,
|
||||
"modes": [
|
||||
{
|
||||
"mode": ModelMode.COMPREHENSION,
|
||||
"input_modalities": [
|
||||
Modality.TEXT,
|
||||
Modality.IMAGE,
|
||||
Modality.VIDEO,
|
||||
Modality.AUDIO,
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
r"Qwen3-Omni*": {
|
||||
"stages": ["autoregression", "autoregression", "autoregression"],
|
||||
"modes": [
|
||||
{
|
||||
"mode": ModelMode.COMPREHENSION,
|
||||
"input_modalities": [
|
||||
Modality.TEXT,
|
||||
Modality.IMAGE,
|
||||
Modality.VIDEO,
|
||||
Modality.AUDIO,
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
# Convert dict keys to regex patterns
|
||||
MODEL_PIPELINE_SPECS: dict[re.Pattern, Spec] = {}
|
||||
for k, v in _MODEL_PIPELINE_SPECS.items():
|
||||
MODEL_PIPELINE_SPECS[re.compile(k)] = v
|
||||
del _MODEL_PIPELINE_SPECS
|
||||
|
||||
|
||||
def lookup_model_spec(model: str) -> tuple[Spec | None, str | None]:
|
||||
try:
|
||||
last_component = model.rstrip("/").rsplit("/", 1)[-1]
|
||||
except IndexError:
|
||||
last_component = model
|
||||
for pattern, spec in MODEL_PIPELINE_SPECS.items():
|
||||
if pattern.search(last_component):
|
||||
return spec, pattern.pattern
|
||||
return None, None
|
||||
|
||||
|
||||
# ============== DEMONSTRATION ==============
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_paths = [
|
||||
"Qwen/Qwen2.5-Omni-7B",
|
||||
"MyModels/Qwen2.5-Omni-3B",
|
||||
"/root/home/Qwen2.5-Omni-7B",
|
||||
"Qwen/Qwen3-Omni",
|
||||
"Qwen/Qwen3-Omni-30B-A3B-Instruct",
|
||||
"Custom/Path/UnknownModel-Instruct",
|
||||
"Not/Matching/Anything",
|
||||
]
|
||||
|
||||
test_payload = {"messages": [{"role": "user", "content": "prompt"}]}
|
||||
|
||||
print("Testing registry lookups:\n")
|
||||
for path in test_paths:
|
||||
spec, _ = lookup_model_spec(path)
|
||||
if spec:
|
||||
if preprocessor := spec.get("payload_preprocessor"):
|
||||
result = preprocessor(test_payload)
|
||||
print(f"✓ {path:<40} → {result}")
|
||||
else:
|
||||
print(f"✓ {path:<40} → No preprocessor")
|
||||
else:
|
||||
print(f"✗ {path:<40} → No match")
|
||||
@@ -0,0 +1,39 @@
|
||||
from collections.abc import Callable
|
||||
from enum import Enum, auto
|
||||
from typing import (
|
||||
Any,
|
||||
Literal,
|
||||
NotRequired,
|
||||
TypeAlias,
|
||||
TypedDict,
|
||||
)
|
||||
|
||||
AudioFormat: TypeAlias = Literal["mp3", "opus", "aac", "flac", "wav", "pcm"]
|
||||
|
||||
|
||||
class ModelMode(Enum):
|
||||
IMAGE_GENERATION = auto()
|
||||
VIDEO_GENERATION = auto()
|
||||
AUDIO_GENERATION = auto()
|
||||
COMPREHENSION = auto()
|
||||
|
||||
|
||||
class Modality(Enum):
|
||||
TEXT = auto() # maybe not useful. Prompt is always required
|
||||
IMAGE = auto()
|
||||
VIDEO = auto()
|
||||
AUDIO = auto()
|
||||
|
||||
|
||||
class ModelModeSpec(TypedDict):
|
||||
mode: ModelMode
|
||||
input_modalities: list[Modality]
|
||||
|
||||
|
||||
PayloadPreprocessor: TypeAlias = Callable[[dict[str, Any]], dict[str, Any]]
|
||||
|
||||
|
||||
class Spec(TypedDict):
|
||||
stages: list[Literal["diffusion", "autoregression"]]
|
||||
modes: list[ModelModeSpec]
|
||||
payload_preprocessor: NotRequired[PayloadPreprocessor]
|
||||
@@ -0,0 +1,84 @@
|
||||
from .logger import get_logger
|
||||
from .models import lookup_model_spec
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def validate_model_and_sampling_params_types(
|
||||
model_name: str,
|
||||
sampling_param_list: dict | list[dict] | None = None,
|
||||
):
|
||||
# Check if model name exists
|
||||
if not model_name:
|
||||
raise ValueError("Model name must not be empty.")
|
||||
|
||||
# Skip if no spec or no sampling params
|
||||
pipeline_spec, _ = lookup_model_spec(model_name)
|
||||
if pipeline_spec is None:
|
||||
logger.info("skipping sampling params check because spec is not found")
|
||||
return
|
||||
if sampling_param_list is None:
|
||||
return
|
||||
|
||||
# Check the number of stages and their data types
|
||||
stages = pipeline_spec["stages"]
|
||||
if isinstance(sampling_param_list, list):
|
||||
# Check that the lengths match
|
||||
if len(stages) != len(sampling_param_list):
|
||||
raise ValueError(
|
||||
f"Sampling parameter list length {len(sampling_param_list)} does not match "
|
||||
f"number of stages {len(stages)} for model {model_name}."
|
||||
)
|
||||
# Check that each stage's type match
|
||||
for i, sp in enumerate(sampling_param_list):
|
||||
if "type" not in sp:
|
||||
raise RuntimeError("Internal error: unknown sampling parameter type")
|
||||
if sp["type"] != stages[i]:
|
||||
raise ValueError(
|
||||
f"Sampling parameter type ({sp['type']}) does not match "
|
||||
f"stage type ({stages[i]}) at index {i} for model {model_name}."
|
||||
)
|
||||
elif isinstance(sampling_param_list, dict):
|
||||
if "type" not in sampling_param_list:
|
||||
raise RuntimeError("Internal error: unknown sampling parameter type")
|
||||
# Check that the provided single sampling param matches all stages
|
||||
elif any(stage != sampling_param_list["type"] for stage in stages):
|
||||
raise ValueError(
|
||||
f"When passing a single sampling parameter node, all stages of the model must match "
|
||||
f"the provided sampling parameter's type. "
|
||||
f"However, the stages of model {model_name} are: {stages}. "
|
||||
f"The provided sampling parameter is {sampling_param_list['type']}"
|
||||
)
|
||||
|
||||
|
||||
def add_sampling_parameters_to_stage(
|
||||
model_name: str,
|
||||
sampling_param_list: dict | list[dict] | None,
|
||||
stage_type: str,
|
||||
/,
|
||||
**params_to_add,
|
||||
) -> dict | list[dict]:
|
||||
"""
|
||||
Given a model's name and the sampling parameter list to query this model,
|
||||
add arbitrary additional parameters to the sampling parameters of all stages of the given type.
|
||||
"""
|
||||
pipeline_spec, _ = lookup_model_spec(model_name)
|
||||
if not pipeline_spec:
|
||||
logger.warning(
|
||||
f"Since the model {model_name} is not in our list, we cannot ensure if "
|
||||
f"the fields ({tuple(params_to_add.keys())}) are added to the correct stage's sampling params. "
|
||||
f"We will do it heuristically."
|
||||
)
|
||||
pipeline_spec = {"stages": ["diffusion"]}
|
||||
|
||||
stages = pipeline_spec["stages"]
|
||||
if isinstance(sampling_param_list, dict):
|
||||
sampling_param_list.update(params_to_add)
|
||||
elif sampling_param_list is None:
|
||||
sampling_param_list = params_to_add.copy()
|
||||
else:
|
||||
for i, stage in enumerate(stages):
|
||||
if stage == stage_type:
|
||||
sampling_param_list[i].update(params_to_add)
|
||||
|
||||
return sampling_param_list
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 413 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 212 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 303 KiB |
@@ -0,0 +1 @@
|
||||
{"id":"1c99f525-0a37-45ba-a28a-7df7c3af66b4","revision":0,"last_node_id":12,"last_link_id":14,"nodes":[{"id":1,"type":"VLLMOmniComprehension","pos":[1191.2177053682556,144.66829928181377],"size":[400,268],"flags":{},"order":8,"mode":0,"inputs":[{"localized_name":"image","name":"image","shape":7,"type":"IMAGE","link":1},{"localized_name":"video","name":"video","shape":7,"type":"VIDEO","link":2},{"localized_name":"audio","name":"audio","shape":7,"type":"AUDIO","link":3},{"localized_name":"sampling_params","name":"sampling_params","shape":7,"type":"SAMPLING_PARAMS","link":14},{"localized_name":"url","name":"url","type":"STRING","widget":{"name":"url"},"link":null},{"localized_name":"model","name":"model","type":"STRING","widget":{"name":"model"},"link":null},{"localized_name":"prompt","name":"prompt","type":"STRING","widget":{"name":"prompt"},"link":null},{"localized_name":"output_text","name":"output_text","type":"BOOLEAN","widget":{"name":"output_text"},"link":null},{"localized_name":"output_audio","name":"output_audio","type":"BOOLEAN","widget":{"name":"output_audio"},"link":null},{"localized_name":"use_audio_in_video","name":"use_audio_in_video","type":"BOOLEAN","widget":{"name":"use_audio_in_video"},"link":null}],"outputs":[{"localized_name":"text_response","name":"text_response","type":"STRING","links":[8]},{"localized_name":"audio_response","name":"audio_response","type":"AUDIO","links":[9]}],"properties":{"Node name for S&R":"VLLMOmniComprehension"},"widgets_values":["http://localhost:8000/v1","Qwen/Qwen2.5-Omni-7B","",true,true,true]},{"id":3,"type":"LoadVideo","pos":[729.5984141255855,-198.631920454299],"size":[282.798828125,233.0743408203125],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"file","name":"file","type":"COMBO","widget":{"name":"file"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"VIDEO","name":"VIDEO","type":"VIDEO","links":[2]}],"properties":{"Node name for S&R":"LoadVideo"},"widgets_values":["draw.mp4","image"]},{"id":4,"type":"LoadAudio","pos":[729.8037086965753,99.86963519703949],"size":[282.798828125,136],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"audio","name":"audio","type":"COMBO","widget":{"name":"audio"},"link":null},{"localized_name":"audioUI","name":"audioUI","type":"AUDIO_UI","widget":{"name":"audioUI"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"AUDIOUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"AUDIO","name":"AUDIO","type":"AUDIO","links":[3]}],"properties":{"Node name for S&R":"LoadAudio"},"widgets_values":["Megan-Fox.mp3",null,null]},{"id":5,"type":"VLLMOmniARSampling","pos":[510.3517536642828,658.073751009259],"size":[270,178],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"max_tokens","name":"max_tokens","type":"INT","widget":{"name":"max_tokens"},"link":null},{"localized_name":"temperature","name":"temperature","type":"FLOAT","widget":{"name":"temperature"},"link":null},{"localized_name":"top_p","name":"top_p","type":"FLOAT","widget":{"name":"top_p"},"link":null},{"localized_name":"repetition_penalty","name":"repetition_penalty","type":"FLOAT","widget":{"name":"repetition_penalty"},"link":null},{"localized_name":"seed","name":"seed","type":"INT","widget":{"name":"seed"},"link":null}],"outputs":[{"localized_name":"AR sampling params","name":"AR sampling params","type":"SAMPLING_PARAMS","links":[12]}],"properties":{"Node name for S&R":"VLLMOmniARSampling"},"widgets_values":[100,1,1,1,-1,"randomize"]},{"id":7,"type":"VLLMOmniARSampling","pos":[503.33235181647115,419.34158016181806],"size":[270,178],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"max_tokens","name":"max_tokens","type":"INT","widget":{"name":"max_tokens"},"link":null},{"localized_name":"temperature","name":"temperature","type":"FLOAT","widget":{"name":"temperature"},"link":null},{"localized_name":"top_p","name":"top_p","type":"FLOAT","widget":{"name":"top_p"},"link":null},{"localized_name":"repetition_penalty","name":"repetition_penalty","type":"FLOAT","widget":{"name":"repetition_penalty"},"link":null},{"localized_name":"seed","name":"seed","type":"INT","widget":{"name":"seed"},"link":null}],"outputs":[{"localized_name":"AR sampling params","name":"AR sampling params","type":"SAMPLING_PARAMS","links":[5,13]}],"properties":{"Node name for S&R":"VLLMOmniARSampling"},"widgets_values":[100,1,1,1,-1,"randomize"]},{"id":8,"type":"VLLMOmniSamplingParamsList","pos":[820.6056617389042,426.38372037182273],"size":[263.066015625,66],"flags":{},"order":7,"mode":0,"inputs":[{"localized_name":"param1","name":"param1","type":"SAMPLING_PARAMS","link":5},{"localized_name":"param2","name":"param2","shape":7,"type":"SAMPLING_PARAMS","link":12},{"localized_name":"param3","name":"param3","shape":7,"type":"SAMPLING_PARAMS","link":13}],"outputs":[{"localized_name":"param list","name":"param list"Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"id":"91f75acc-8040-40f6-865a-2e8a7cfd6672","revision":0,"last_node_id":11,"last_link_id":23,"nodes":[{"id":3,"type":"PreviewImage","pos":[1281.8455167767304,-69.02638461454333],"size":[305.6628685610174,307.0336884172169],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":22}],"outputs":[],"properties":{"Node name for S&R":"PreviewImage"},"widgets_values":[]},{"id":11,"type":"VLLMOmniGenerateImage","pos":[816.0962515873642,-137.51112584854445],"size":[400,278],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"image","name":"image","shape":7,"type":"IMAGE","link":21},{"localized_name":"mask","name":"mask","shape":7,"type":"MASK","link":null},{"localized_name":"sampling_params","name":"sampling_params","shape":7,"type":"SAMPLING_PARAMS","link":23},{"localized_name":"url","name":"url","type":"STRING","widget":{"name":"url"},"link":null},{"localized_name":"model","name":"model","type":"STRING","widget":{"name":"model"},"link":null},{"localized_name":"prompt","name":"prompt","type":"STRING","widget":{"name":"prompt"},"link":null},{"localized_name":"negative_prompt","name":"negative_prompt","type":"STRING","widget":{"name":"negative_prompt"},"link":null},{"localized_name":"width","name":"width","type":"INT","widget":{"name":"width"},"link":null},{"localized_name":"height","name":"height","type":"INT","widget":{"name":"height"},"link":null}],"outputs":[{"localized_name":"image","name":"image","type":"IMAGE","links":[22]}],"properties":{"Node name for S&R":"VLLMOmniGenerateImage"},"widgets_values":["http://localhost:8000/v1","Qwen/Qwen-Image-Edit","Put this figure in a realistic mountain view","",512,512]},{"id":8,"type":"VLLMOmniDiffusionSampling","pos":[478.59266934006774,183.67711984955648],"size":[284.205078125,202],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"n","name":"n","type":"INT","widget":{"name":"n"},"link":null},{"localized_name":"num_inference_steps","name":"num_inference_steps","type":"INT","widget":{"name":"num_inference_steps"},"link":null},{"localized_name":"guidance_scale","name":"guidance_scale","type":"FLOAT","widget":{"name":"guidance_scale"},"link":null},{"localized_name":"true_cfg_scale","name":"true_cfg_scale","type":"FLOAT","widget":{"name":"true_cfg_scale"},"link":null},{"localized_name":"vae_use_slicing","name":"vae_use_slicing","type":"BOOLEAN","widget":{"name":"vae_use_slicing"},"link":null},{"localized_name":"seed","name":"seed","type":"INT","widget":{"name":"seed"},"link":null}],"outputs":[{"localized_name":"diffusion sampling params","name":"diffusion sampling params","type":"SAMPLING_PARAMS","links":[23]}],"properties":{"Node name for S&R":"VLLMOmniDiffusionSampling"},"widgets_values":[4,50,1,1,false,42,"fixed"]},{"id":10,"type":"MarkdownNote","pos":[227.99306819575278,-231.24143306069843],"size":[240.3326478922474,136.3505820791881],"flags":{},"order":2,"mode":0,"inputs":[],"outputs":[],"title":"Note: Task and Input","properties":{},"widgets_values":["vLLM-Omni nodes are categorized based on the output modality. The \"Generate Image\" node supports both text-to-image generation or image-to-image generation (a.k.a. image editing). The node will route to the correct endpoint depending on whether an input image is present or not."],"color":"#432","bgcolor":"#000"},{"id":4,"type":"LoadImage","pos":[496.31859627609606,-229.71277089860084],"size":[270,314],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[21]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"LoadImage"},"widgets_values":["example.png","image"]}],"links":[[21,4,0,11,0,"IMAGE"],[22,11,0,3,0,"IMAGE"],[23,8,0,11,2,"SAMPLING_PARAMS"]],"groups":[{"id":1,"title":"Input","bounding":[213.1706010087147,-313.20095750667554,560.8246471437027,407.4246532472182],"color":"#3f789e","font_size":24,"flags":{}}],"config":{},"extra":{"workflowRendererVersion":"LG","ds":{"scale":1.1469075819486894,"offset":[276.8427259980847,549.4885226647582]}},"version":0.4}
|
||||
@@ -0,0 +1 @@
|
||||
{"id":"c0a11241-f0e6-4f5c-8488-b3d344a9df3d","revision":0,"last_node_id":7,"last_link_id":3,"nodes":[{"id":4,"type":"PreviewAudio","pos":[690.1759063763212,226.76444957981545],"size":[270,88],"flags":{},"order":5,"mode":0,"inputs":[{"localized_name":"audio","name":"audio","type":"AUDIO","link":2},{"localized_name":"audioUI","name":"audioUI","type":"AUDIO_UI","widget":{"name":"audioUI"},"link":null}],"outputs":[],"properties":{"Node name for S&R":"PreviewAudio"},"widgets_values":[]},{"id":5,"type":"LoadAudio","pos":[-83.61093919682321,103.80510898465587],"size":[282.798828125,136],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"audio","name":"audio","type":"COMBO","widget":{"name":"audio"},"link":null},{"localized_name":"audioUI","name":"audioUI","type":"AUDIO_UI","widget":{"name":"audioUI"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"AUDIOUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"AUDIO","name":"AUDIO","type":"AUDIO","links":[3]}],"properties":{"Node name for S&R":"LoadAudio"},"widgets_values":["Megan-Fox.mp3",null,null]},{"id":3,"type":"VLLMOmniVoiceClone","pos":[248.63466280064262,168.13112893706375],"size":[400,306],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"ref_audio","name":"ref_audio","type":"AUDIO","link":3},{"localized_name":"model_specific_params","name":"model_specific_params","shape":7,"type":"TTS_PARAMS","link":1},{"localized_name":"url","name":"url","type":"STRING","widget":{"name":"url"},"link":null},{"localized_name":"model","name":"model","type":"STRING","widget":{"name":"model"},"link":null},{"localized_name":"input","name":"input","type":"STRING","widget":{"name":"input"},"link":null},{"localized_name":"voice","name":"voice","type":"STRING","widget":{"name":"voice"},"link":null},{"localized_name":"response_format","name":"response_format","type":"COMBO","widget":{"name":"response_format"},"link":null},{"localized_name":"speed","name":"speed","type":"FLOAT","widget":{"name":"speed"},"link":null},{"localized_name":"ref_text","name":"ref_text","type":"STRING","widget":{"name":"ref_text"},"link":null},{"localized_name":"x_vector_only_mode","name":"x_vector_only_mode","type":"BOOLEAN","widget":{"name":"x_vector_only_mode"},"link":null}],"outputs":[{"localized_name":"audio","name":"audio","type":"AUDIO","links":[2]}],"properties":{"Node name for S&R":"VLLMOmniVoiceClone"},"widgets_values":["http://localhost:8000/v1","Qwen/Qwen3-TTS-12Hz-1.7B-Base","Someone just spilled a cup of coffee on my jacket this morning in the subway. Now I have to wear this stained jacket for a whole day!","","mp3",1,"",true]},{"id":6,"type":"MarkdownNote","pos":[255.52667408769662,27.26842775454861],"size":[381.58204108637847,88],"flags":{},"order":2,"mode":0,"inputs":[],"outputs":[],"title":"Note: TTS Nodes","properties":{},"widgets_values":["Apart from the Voice Cloning node, there is also another TTS node for simple text-to-speech tasks (without extra inputs for reference audio)."],"color":"#432","bgcolor":"#000"},{"id":7,"type":"MarkdownNote","pos":[-196.99453003423977,598.3693693727904],"size":[382.4539509209102,88],"flags":{},"order":3,"mode":0,"inputs":[],"outputs":[],"title":"Note: Model-Specific Parameters","properties":{},"widgets_values":["TTS models often require some tailor-made parameters. If you need to customize these parameters, grab one that matches the model you are requesting from \"TTS Params\" subfolder."],"color":"#432","bgcolor":"#000"},{"id":2,"type":"VLLMOmniQwenTTSParams","pos":[-207.8925310560497,342.7942867650348],"size":[400,200],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"task_type","name":"task_type","type":"COMBO","widget":{"name":"task_type"},"link":null},{"localized_name":"language","name":"language","type":"COMBO","widget":{"name":"language"},"link":null},{"localized_name":"instructions","name":"instructions","type":"STRING","widget":{"name":"instructions"},"link":null},{"localized_name":"max_new_tokens","name":"max_new_tokens","type":"INT","widget":{"name":"max_new_tokens"},"link":null}],"outputs":[{"localized_name":"Qwen TTS params","name":"Qwen TTS params","type":"TTS_PARAMS","links":[1]}],"properties":{"Node name for S&R":"VLLMOmniQwenTTSParams"},"widgets_values":["Base","Auto","Super angry",2048]}],"links":[[1,2,0,3,1,"TTS_PARAMS"],[2,3,0,4,0,"AUDIO"],[3,5,0,3,0,"AUDIO"]],"groups":[{"id":1,"title":"Model-Specific Parameters","bounding":[-228.38328407738007,264.42790274716276,443.9887293164586,439.6853171248897],"color":"#3f789e","font_size":24,"flags":{}}],"config":{},"extra":{"workflowRendererVersion":"LG","ds":{"scale":1.1469075819486911,"offset":[898.8819468322333,258.7179979718389]}},"version":0.4}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @file This file is intended to add dynamic fields to vLLM-Omni nodes
|
||||
* based on widget (in-node form fields) and input (connection link) values and changes.
|
||||
* However, this functionality is currently disabled/commented out.
|
||||
* Because it introduces too much complexity,
|
||||
* and it may even conflict with the current backend (Python) validation for unknown reasons (pending ComfyUI upstream fixes).
|
||||
*/
|
||||
|
||||
import { app } from "../../scripts/app.js";
|
||||
app.registerExtension({
|
||||
name: "vllm.vllm_omni",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (!nodeData.name.startsWith("VLLMOmni")) {
|
||||
return
|
||||
}
|
||||
// Stub frontend plugin for now
|
||||
},
|
||||
async setup() {
|
||||
console.info("vLLM-Omni Setup complete!")
|
||||
},
|
||||
})
|
||||
@@ -53,7 +53,7 @@ bash benchmarks/qwen3-omni/vllm_omni/eval_qwen3_moe_omni.sh
|
||||
```
|
||||
|
||||
What it does:
|
||||
- Runs `examples/offline_inference/qwen3_omni/end2end.py` with `--enable-stats`.
|
||||
- Runs `examples/offline_inference/qwen3_omni/end2end.py` with `--log-stats`.
|
||||
- Uses `benchmarks/build_dataset/top100.txt` and writes to:
|
||||
- Logs: `benchmarks/qwen3-omni/vllm_omni/logs/`
|
||||
- `omni_llm_pipeline_text.orchestrator.stats.jsonl` — per-stage latency stats.
|
||||
|
||||
@@ -23,7 +23,7 @@ else
|
||||
python $end2end_script_path --output-wav $outputs_dir \
|
||||
--query-type text \
|
||||
--txt-prompts $build_dataset_path \
|
||||
--enable-stats \
|
||||
--log-stats \
|
||||
--log-dir $log_dir
|
||||
echo "Logs and outputs are saved in ${log_dir} and ${outputs_dir} respectively:"
|
||||
echo " - omni_llm_pipeline_text run dir/base name"
|
||||
|
||||
@@ -44,6 +44,7 @@ nav:
|
||||
- Cache-DiT: user_guide/diffusion/cache_dit_acceleration.md
|
||||
- Parallelism Acceleration: user_guide/diffusion/parallelism_acceleration.md
|
||||
- CPU Offloading: user_guide/diffusion/cpu_offload_diffusion.md
|
||||
- ComfyUI: features/comfyui.md
|
||||
- Developer Guide:
|
||||
- General:
|
||||
- contributing/README.md
|
||||
@@ -53,6 +54,12 @@ nav:
|
||||
- contributing/model/README.md
|
||||
- contributing/model/adding_omni_model.md
|
||||
- contributing/model/adding_diffusion_model.md
|
||||
- Advanced Features:
|
||||
- contributing/features/cfg_parallel.md
|
||||
- contributing/features/sequence_parallel.md
|
||||
- contributing/features/tensor_parallel.md
|
||||
- contributing/features/cache_dit.md
|
||||
- contributing/features/teacache.md
|
||||
- CI: contributing/ci
|
||||
- Design Documents:
|
||||
- design/index.md
|
||||
|
||||
+5
-3
@@ -15,9 +15,6 @@ Main entry points for vLLM-Omni inference and serving.
|
||||
- [vllm_omni.entrypoints.cli.benchmark.serve.OmniBenchmarkServingSubcommand][]
|
||||
- [vllm_omni.entrypoints.cli.serve.OmniServeCommand][]
|
||||
- [vllm_omni.entrypoints.client_request_state.ClientRequestState][]
|
||||
- [vllm_omni.entrypoints.log_utils.OrchestratorMetrics][]
|
||||
- [vllm_omni.entrypoints.log_utils.StageRequestMetrics][]
|
||||
- [vllm_omni.entrypoints.log_utils.StageStats][]
|
||||
- [vllm_omni.entrypoints.omni.Omni][]
|
||||
- [vllm_omni.entrypoints.omni.OmniBase][]
|
||||
- [vllm_omni.entrypoints.omni_diffusion.OmniDiffusion][]
|
||||
@@ -114,3 +111,8 @@ Worker classes and model runners for distributed inference.
|
||||
- [vllm_omni.worker.gpu_generation_worker.GPUGenerationWorker][]
|
||||
- [vllm_omni.worker.gpu_model_runner.OmniGPUModelRunner][]
|
||||
- [vllm_omni.worker.mixins.OmniWorkerMixin][]
|
||||
|
||||
|
||||
## Metrics
|
||||
|
||||
- [vllm_omni.metrics.OrchestratorAggregator][]
|
||||
@@ -5,33 +5,33 @@ By adding markers before test functions, tests can later be executed uniformly b
|
||||
## Current Markers
|
||||
Defined in `pyproject.toml`:
|
||||
|
||||
| Marker | Description |
|
||||
| ------------------ | ------------------------------------------------------- |
|
||||
| `core_model` | Core model tests (run in each PR) |
|
||||
| `diffusion` | Diffusion model tests |
|
||||
| `omni` | Omni model tests |
|
||||
| `cache` | Cache backend tests |
|
||||
| `parallel` | Parallelism/distributed tests |
|
||||
| `cpu` | Tests that run on CPU |
|
||||
| `gpu` | Tests that run on GPU (auto-added) |
|
||||
| `cuda` | Tests that run on CUDA (auto-added) |
|
||||
| `rocm` | Tests that run on AMD/ROCm (auto-added) |
|
||||
| `npu` | Tests that run on NPU/Ascend (auto-added) |
|
||||
| `H100` | Tests that require H100 GPU |
|
||||
| `L4` | Tests that require L4 GPU |
|
||||
| `MI325` | Tests that require MI325 GPU (AMD/ROCm) |
|
||||
| `A2` | Tests that require A2 NPU |
|
||||
| `A3` | Tests that require A3 NPU |
|
||||
| `distributed_cuda` | Tests that require multi cards on CUDA platform |
|
||||
| `distributed_rocm` | Tests that require multi cards on ROCm platform |
|
||||
| `distributed_npu` | Tests that require multi cards on NPU platform |
|
||||
| `skipif_cuda` | Skip if the num of CUDA cards is less than the required |
|
||||
| `skipif_rocm` | Skip if the num of ROCm cards is less than the required |
|
||||
| `skipif_npu` | Skip if the num of NPU cards is less than the required |
|
||||
| `slow` | Slow tests (may skip in quick CI) |
|
||||
| `benchmark` | Benchmark tests |
|
||||
| Marker | Description |
|
||||
| ------------------ | --------------------------------------------------------- |
|
||||
| `core_model` | Core model tests (run in each PR) |
|
||||
| `diffusion` | Diffusion model tests |
|
||||
| `omni` | Omni model tests |
|
||||
| `cache` | Cache backend tests |
|
||||
| `parallel` | Parallelism/distributed tests |
|
||||
| `cpu` | Tests that run on CPU |
|
||||
| `gpu` | Tests that run on GPU * |
|
||||
| `cuda` | Tests that run on CUDA * |
|
||||
| `rocm` | Tests that run on AMD/ROCm * |
|
||||
| `npu` | Tests that run on NPU/Ascend * |
|
||||
| `H100` | Tests that require H100 GPU * |
|
||||
| `L4` | Tests that require L4 GPU * |
|
||||
| `MI325` | Tests that require MI325 GPU (AMD/ROCm) * |
|
||||
| `A2` | Tests that require A2 NPU * |
|
||||
| `A3` | Tests that require A3 NPU * |
|
||||
| `distributed_cuda` | Tests that require multi cards on CUDA platform * |
|
||||
| `distributed_rocm` | Tests that require multi cards on ROCm platform * |
|
||||
| `distributed_npu` | Tests that require multi cards on NPU platform * |
|
||||
| `skipif_cuda` | Skip if the num of CUDA cards is less than the required * |
|
||||
| `skipif_rocm` | Skip if the num of ROCm cards is less than the required * |
|
||||
| `skipif_npu` | Skip if the num of NPU cards is less than the required * |
|
||||
| `slow` | Slow tests (may skip in quick CI) |
|
||||
| `benchmark` | Benchmark tests |
|
||||
|
||||
For those markers shown as auto-added, they will be added by the `@hardware_test` decorator.
|
||||
\* Means those markers are auto-added, and they will be added by the `@hardware_test` decorator.
|
||||
|
||||
### Example usage for markers
|
||||
|
||||
@@ -71,10 +71,7 @@ This decorator is intended to make hardware-aware, cross-platform test authoring
|
||||
Support for `skipif_rocm` and `skipif_npu` will be implemented later.
|
||||
|
||||
|
||||
5. **Runs each test in a new process**
|
||||
Automatically wraps the distributed test with a decorator (`@create_new_process_for_each_test`) to ensure isolation and compatibility with multi-process hardware backends.
|
||||
|
||||
6. **Works with pytest filtering**
|
||||
5. **Works with pytest filtering**
|
||||
Allows tests to be filtered and selected at runtime using standard pytest marker expressions (e.g., `-m "distributed_cuda and L4"`).
|
||||
|
||||
#### Example usage for decorator
|
||||
@@ -94,7 +91,6 @@ This decorator is intended to make hardware-aware, cross-platform test authoring
|
||||
```
|
||||
- `res` must be a dict; supported resources: CUDA (L4/H100), ROCm (MI325), NPU (A2/A3)
|
||||
- `num_cards` can be int (all platforms) or dict (per platform); defaults to 1 when missing
|
||||
- `hardware_test` automatically applies `@create_new_process_for_each_test` for distributed tests.
|
||||
- Distributed markers (`distributed_cuda`, `distributed_rocm`, `distributed_npu`) are auto-added for multi-card cases
|
||||
- Filtering examples:
|
||||
- CUDA only: `pytest -m "distributed_cuda and L4"`
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
# Support Cache-DiT
|
||||
|
||||
This section describes how to add cache-dit acceleration to a new diffusion pipeline. We use the Qwen-Image pipeline and LongCat-Image pipeline as reference implementations.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Standard Models: Automatic Support](#standard-models-automatic-support)
|
||||
- [Custom Architectures: Writing Custom Implementation](#custom-architectures-writing-custom-implementation)
|
||||
- [Testing](#testing)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Reference Implementations](#reference-implementations)
|
||||
- [Summary](#summary)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
### What is Cache-DiT?
|
||||
|
||||
Cache-DiT is an acceleration library for Diffusion Transformers (DiT) that caches intermediate computation results across denoising steps. The core insight is that adjacent denoising steps often produce similar intermediate features, so we can skip redundant computations by reusing cached results.
|
||||
|
||||
The library supports three main caching strategies:
|
||||
|
||||
- **DBCache:** Dynamic block-level caching that selectively computes or caches transformer blocks based on residual differences
|
||||
- **TaylorSeer:** Calibration-based prediction that estimates block outputs using Taylor expansion
|
||||
- **SCM (Step Computation Masking):** Dynamic step skipping based on configurable policies
|
||||
|
||||
### Architecture
|
||||
|
||||
vLLM-omni integrates cache-dit through the `CacheDiTBackend` class, which provides a unified interface for managing cache-dit acceleration on diffusion models.
|
||||
|
||||
| Method/Class | Purpose | Behavior |
|
||||
|--------------|---------|----------|
|
||||
| [`CacheDiTBackend`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/cache/#vllm_omni.diffusion.cache.CacheBackend) | Unified backend interface | Automatically handles enabler selection and cache refresh |
|
||||
| [`enable_cache_for_dit()`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/cache/cache_dit_backend/#vllm_omni.diffusion.cache.cache_dit_backend.enable_cache_for_dit) | Apply caching to transformer | Configures DBCache on transformer blocks |
|
||||
|
||||
**Key APIs from Cache-DiT:**
|
||||
|
||||
[Cache-DiT API Reference](https://cache-dit.readthedocs.io/en/latest/user_guide/CACHE_API/)
|
||||
|
||||
| API | Description |
|
||||
|-----|-------------|
|
||||
| `BlockAdapter` | Core abstraction for applying cache-dit to transformers. Specifies transformer module(s), block list(s), and forward signature pattern(s). |
|
||||
| `ForwardPattern` | Defines block forward signature patterns: `Pattern_0`, `Pattern_1`, `Pattern_2` |
|
||||
| `ParamsModifier` | Per-transformer or per-block-list cache configuration customization |
|
||||
| `DBCacheConfig` | Configuration for DBCache parameters (warmup steps, cached steps, thresholds) |
|
||||
| `refresh_context()` | Update cache context | Called when `num_inference_steps` changes |
|
||||
|
||||
---
|
||||
|
||||
## Standard Models: Automatic Support
|
||||
|
||||
Most DiT models follow this pattern:
|
||||
- Single transformer with one `ModuleList` of blocks
|
||||
- Standard forward signature
|
||||
- Compatible with cache-dit's automatic detection
|
||||
|
||||
**Examples:** Qwen-Image, Z-Image
|
||||
|
||||
For standard single-transformer models, **no code changes are needed**. The `CacheDiTBackend` automatically uses `enable_cache_for_dit()`:
|
||||
|
||||
```python
|
||||
from vllm_omni import Omni
|
||||
|
||||
# Works automatically for standard models
|
||||
omni = Omni(
|
||||
model="Qwen/Qwen-Image", # Standard single-transformer model
|
||||
cache_backend="cache_dit",
|
||||
cache_config={
|
||||
"Fn_compute_blocks": 1,
|
||||
"Bn_compute_blocks": 0,
|
||||
"max_warmup_steps": 4,
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**What happens automatically:**
|
||||
|
||||
```python
|
||||
def enable_cache_for_dit(pipeline: Any, cache_config: Any) -> Callable[[int], None]:
|
||||
"""Default enabler for standard single-transformer DiT models."""
|
||||
|
||||
# Build cache configuration
|
||||
db_cache_config = DBCacheConfig(
|
||||
num_inference_steps=None, # Will be set during first inference
|
||||
Fn_compute_blocks=cache_config.Fn_compute_blocks,
|
||||
Bn_compute_blocks=cache_config.Bn_compute_blocks,
|
||||
max_warmup_steps=cache_config.max_warmup_steps,
|
||||
max_cached_steps=cache_config.max_cached_steps,
|
||||
max_continuous_cached_steps=cache_config.max_continuous_cached_steps,
|
||||
residual_diff_threshold=cache_config.residual_diff_threshold,
|
||||
)
|
||||
|
||||
# Enable cache-dit on transformer
|
||||
cache_dit.enable_cache(
|
||||
pipeline.transformer,
|
||||
cache_config=db_cache_config,
|
||||
)
|
||||
|
||||
# Return refresh function for dynamic num_inference_steps updates
|
||||
def refresh_cache_context(pipeline: Any, num_inference_steps: int, verbose: bool = True):
|
||||
cache_dit.refresh_context(pipeline.transformer, num_inference_steps=num_inference_steps, verbose=verbose)
|
||||
|
||||
return refresh_cache_context
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom Architectures: Writing Custom Implementation
|
||||
|
||||
Some models require custom handling:
|
||||
|
||||
- **Dual-transformer:** Models with separate high-noise and low-noise transformers (e.g., Wan2.2)
|
||||
- **Multi-block-list:** Models with multiple block lists in one transformer (e.g., LongCatImage with `transformer_blocks` + `single_transformer_blocks`)
|
||||
- **Special forward patterns:** Models with non-standard block execution patterns
|
||||
|
||||
### Example 1: Dual-Transformer Model (Wan2.2)
|
||||
|
||||
Wan2.2 uses two transformers: one for high-noise steps and one for low-noise steps.
|
||||
|
||||
**Key difference:** Use `BlockAdapter` to wrap multiple transformers with separate configurations.
|
||||
|
||||
```python
|
||||
# Standard: cache_dit.enable_cache(pipeline.transformer, ...)
|
||||
# Custom: Use BlockAdapter to handle multiple transformers
|
||||
cache_dit.enable_cache(
|
||||
BlockAdapter(
|
||||
transformer=[pipeline.transformer, pipeline.transformer_2], # Multiple transformers
|
||||
blocks=[pipeline.transformer.blocks, pipeline.transformer_2.blocks],
|
||||
forward_pattern=[ForwardPattern.Pattern_2, ForwardPattern.Pattern_2],
|
||||
params_modifiers=[
|
||||
ParamsModifier(...), # Config for high-noise transformer
|
||||
ParamsModifier(...), # Config for low-noise transformer (different params)
|
||||
],
|
||||
),
|
||||
cache_config=db_cache_config,
|
||||
)
|
||||
```
|
||||
|
||||
**Key difference:** `refresh_context` must be called on each transformer separately.
|
||||
|
||||
```python
|
||||
# Standard: cache_dit.refresh_context(pipeline.transformer, num_inference_steps=N)
|
||||
# Custom: Refresh each transformer with its own step count
|
||||
def refresh_cache_context(pipeline, num_inference_steps, verbose=True):
|
||||
high_steps, low_steps = _split_inference_steps(num_inference_steps)
|
||||
cache_dit.refresh_context(pipeline.transformer, num_inference_steps=high_steps, ...)
|
||||
cache_dit.refresh_context(pipeline.transformer_2, num_inference_steps=low_steps, ...)
|
||||
```
|
||||
|
||||
### Example 2: Multi-Block-List Model (LongCatImage)
|
||||
|
||||
LongCatImage has a single transformer with two block lists: `transformer_blocks` and `single_transformer_blocks`.
|
||||
|
||||
**Key difference:** Use `BlockAdapter` to specify multiple block lists within one transformer.
|
||||
|
||||
```python
|
||||
# Standard: cache_dit.enable_cache(pipeline.transformer, ...)
|
||||
# - Automatically detects single block list
|
||||
# Custom: Use BlockAdapter to specify multiple block lists
|
||||
cache_dit.enable_cache(
|
||||
BlockAdapter(
|
||||
transformer=pipeline.transformer, # Single transformer
|
||||
blocks=[
|
||||
pipeline.transformer.transformer_blocks, # Block list 1
|
||||
pipeline.transformer.single_transformer_blocks, # Block list 2
|
||||
],
|
||||
forward_pattern=[ForwardPattern.Pattern_1, ForwardPattern.Pattern_1],
|
||||
params_modifiers=[modifier],
|
||||
),
|
||||
cache_config=db_cache_config,
|
||||
)
|
||||
```
|
||||
|
||||
> **Note:** For single transformer with multiple block lists, `refresh_context` works the same as standard models.
|
||||
|
||||
### Registering Custom Implementations
|
||||
|
||||
After writing your custom enabler, register it in `CUSTOM_DIT_ENABLERS` in `vllm_omni/diffusion/cache/cache_dit_backend.py`:
|
||||
|
||||
```python
|
||||
CUSTOM_DIT_ENABLERS = {
|
||||
"Wan22Pipeline": enable_cache_for_wan22,
|
||||
"LongCatImagePipeline": enable_cache_for_longcat_image,
|
||||
"YourCustomPipeline": enable_cache_for_your_model, # Add here
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
After adding cache-dit support, test with:
|
||||
|
||||
```python
|
||||
from vllm_omni import Omni
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
# Test your custom model
|
||||
omni = Omni(
|
||||
model="your-model-name",
|
||||
cache_backend="cache_dit",
|
||||
cache_config={
|
||||
"Fn_compute_blocks": 1,
|
||||
"Bn_compute_blocks": 0,
|
||||
"max_warmup_steps": 4,
|
||||
"residual_diff_threshold": 0.24,
|
||||
}
|
||||
)
|
||||
|
||||
images = omni.generate(
|
||||
"a beautiful landscape",
|
||||
OmniDiffusionSamplingParams(num_inference_steps=50),
|
||||
)
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
|
||||
1. Cache is applied (check logs for "Cache-dit enabled successfully on xxx")
|
||||
2. Performance improvement (should be around 1.5x-2x faster)
|
||||
3. Image quality (compare with `cache_backend=None`)
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: Cache not applied
|
||||
|
||||
**Symptoms:** No speedup observed, no cache-related log messages.
|
||||
|
||||
**Causes & Solutions:**
|
||||
|
||||
- **Enabler not registered:**
|
||||
|
||||
**Problem:** Pipeline name not in `CUSTOM_DIT_ENABLERS` registry.
|
||||
|
||||
**Solution:** Verify `pipeline.__class__.__name__` matches the registry key and add your enabler to `CUSTOM_DIT_ENABLERS`.
|
||||
|
||||
### Issue: Quality degradation
|
||||
|
||||
**Symptoms:** Generated images have artifacts or lower quality compared to non-cached inference.
|
||||
|
||||
**Causes & Solutions:**
|
||||
|
||||
- **Cache parameters too aggressive:**
|
||||
|
||||
**Solution:**
|
||||
```python
|
||||
cache_config={
|
||||
"residual_diff_threshold": 0.12, # Lower from 0.24 (try 0.12-0.18)
|
||||
"max_warmup_steps": 6, # Increase from 4 (try 6-8)
|
||||
"max_continuous_cached_steps": 2, # Reduce if higher
|
||||
}
|
||||
```
|
||||
|
||||
Check the [user guide for cache_dit](../../user_guide/diffusion/cache_dit_acceleration.md) for more adjustable parameters.
|
||||
|
||||
---
|
||||
|
||||
## Reference Implementations
|
||||
|
||||
Complete examples in the codebase:
|
||||
|
||||
| Model | Path | Pattern | Notes |
|
||||
|-------|------|---------|-------|
|
||||
| **Standard DiT** | [`cache_dit_backend.py::enable_cache_for_dit`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/cache/cache_dit_backend/#vllm_omni.diffusion.cache.cache_dit_backend.enable_cache_for_dit) | Default enabler | Single transformer, automatic |
|
||||
| **Wan2.2** | [`cache_dit_backend.py::enable_cache_for_wan22`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/cache/cache_dit_backend/#vllm_omni.diffusion.cache.cache_dit_backend.enable_cache_for_wan22) | Dual-transformer | Separate high/low noise transformers |
|
||||
| **LongCat** | [`cache_dit_backend.py::enable_cache_for_longcat_image`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/cache/cache_dit_backend/#vllm_omni.diffusion.cache.cache_dit_backend.enable_cache_for_longcat_image) | Multi-block-list | Two block lists in one transformer |
|
||||
| **BAGEL** | [`cache_dit_backend.py::enable_cache_for_bagel`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/cache/cache_dit_backend/#vllm_omni.diffusion.cache.cache_dit_backend.enable_cache_for_bagel) | Omni model | Complex architecture |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Adding cache-dit support:
|
||||
|
||||
1. ✅ **Check model type** - Standard models work automatically, custom architectures need enablers
|
||||
2. ✅ **Write enabler** (if needed) - Use `BlockAdapter` for complex architectures
|
||||
3. ✅ **Register enabler** (if needed) - Add to `CUSTOM_DIT_ENABLERS` dictionary
|
||||
4. ✅ **Return refresh function** (if needed) - Handle `num_inference_steps` changes
|
||||
5. ✅ **Test** - Verify with `cache_backend="cache_dit"`
|
||||
|
||||
For most models, the default enabler is sufficient. Only write custom enablers for complex architectures!
|
||||
@@ -0,0 +1,249 @@
|
||||
# Support CFG-Parallel
|
||||
|
||||
This section describes how to add CFG-Parallel (Classifier-Free Guidance Parallel) to a diffusion pipeline. We use the Qwen-Image pipeline as the reference implementation.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Step-by-Step Implementation](#step-by-step-implementation)
|
||||
- [Customization](#customization)
|
||||
- [Testing](#testing)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Reference Implementations](#reference-implementations)
|
||||
- [Summary](#summary)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
### What is CFG-Parallel?
|
||||
|
||||
In standard Classifier-Free Guidance, each diffusion step requires two forward passes through the transformer:
|
||||
|
||||
1. **Positive/Conditional**: Guided by the text prompt
|
||||
2. **Negative/Unconditional**: Typically using empty or negative prompt
|
||||
|
||||
CFG-Parallel eliminates this bottleneck by distributing the two forward passes across different GPU ranks, allowing them to execute simultaneously rather than sequentially.
|
||||
|
||||
### Architecture
|
||||
|
||||
vLLM-omni provides `CFGParallelMixin` that encapsulates all CFG parallel logic. Pipelines inherit from this mixin and implement a `diffuse()` method that orchestrates the denoising loop.
|
||||
|
||||
| Method | Purpose | Automatic Behavior |
|
||||
|--------|---------|-------------------|
|
||||
| [`predict_noise_maybe_with_cfg()`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/distributed/cfg_parallel/) | Predict noise with CFG | Detects parallel mode, distributes computation, gathers results |
|
||||
| [`scheduler_step_maybe_with_cfg()`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/distributed/cfg_parallel/) | Step scheduler with sync | Rank 0 steps, broadcasts latents to all ranks |
|
||||
| [`combine_cfg_noise()`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/distributed/cfg_parallel/) | Combine positive/negative | Applies CFG formula with optional normalization |
|
||||
| [`predict_noise()`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/distributed/cfg_parallel/) | Forward pass wrapper | Override for custom transformer calls |
|
||||
| [`cfg_normalize_function()`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/distributed/cfg_parallel/) | Normalize CFG output | Override for custom normalization |
|
||||
|
||||
### How It Works
|
||||
|
||||
`predict_noise_maybe_with_cfg()` automatically detects and switches between two execution modes:
|
||||
|
||||
- **CFG-Parallel mode** (when `cfg_world_size > 1`):
|
||||
- Rank 0 computes positive prompt prediction
|
||||
- Rank 1 computes negative prompt prediction
|
||||
- Results are gathered via `all_gather()`
|
||||
- Combined on rank 0 using CFG formula
|
||||
|
||||
- **Sequential mode** (when `cfg_world_size == 1`):
|
||||
- Single rank computes both positive and negative predictions
|
||||
- Directly combines them with CFG formula
|
||||
|
||||
`scheduler_step_maybe_with_cfg()` ensures consistent latent states across all ranks:
|
||||
|
||||
- **CFG-Parallel mode**:
|
||||
- Only rank 0 performs the scheduler step (applies noise prediction to update latents)
|
||||
- Updated latents are broadcast to all other ranks via `broadcast()`
|
||||
- All ranks maintain synchronized latent states for the next iteration
|
||||
|
||||
- **Sequential mode**:
|
||||
- Single rank directly performs the scheduler step
|
||||
- No synchronization needed
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Implementation
|
||||
|
||||
### Step 1: Inherit `CFGParallelMixin`
|
||||
|
||||
Allow your pipeline to inherit from `CFGParallelMixin` and implements the `diffuse()` method for your specific model.
|
||||
|
||||
**Example (Qwen-Image):**
|
||||
|
||||
```python
|
||||
from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin
|
||||
import torch.nn as nn
|
||||
class YourModelPipeline(nn.Module, CFGParallelMixin):
|
||||
def diffuse(self, ...) -> torch.Tensor:
|
||||
for i, t in enumerate(timesteps):
|
||||
# Prepare positive_kwargs (conditional) and negative_kwargs (unconditional)
|
||||
positive_kwargs = {...} # hidden_states, encoder_hidden_states, etc.
|
||||
negative_kwargs = {...} if do_true_cfg else None
|
||||
|
||||
# Key method 1: Predict noise with automatic CFG parallel handling
|
||||
noise_pred = self.predict_noise_maybe_with_cfg(
|
||||
do_true_cfg=do_true_cfg,
|
||||
true_cfg_scale=true_cfg_scale,
|
||||
positive_kwargs=positive_kwargs,
|
||||
negative_kwargs=negative_kwargs,
|
||||
)
|
||||
|
||||
# Key method 2: Step scheduler with automatic CFG synchronization
|
||||
latents = self.scheduler_step_maybe_with_cfg(
|
||||
noise_pred, t, latents, do_true_cfg
|
||||
)
|
||||
|
||||
return latents
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
|
||||
- `positive_kwargs`: transformer arguments for conditional (text-guided) prediction
|
||||
- `negative_kwargs`: transformer arguments for unconditional prediction (set to `None` if CFG disabled)
|
||||
- For image editing pipelines, add `output_slice=image_seq_len` to extract the generative image portion
|
||||
|
||||
### Step 2: Call `diffuse`
|
||||
|
||||
Call `self.diffuse` in your pipeline's forward function:
|
||||
|
||||
```python
|
||||
import torch.nn as nn
|
||||
class YourModelPipeline(nn.Module, CFGParallelMixin):
|
||||
def forward(
|
||||
self,
|
||||
prompt: str,
|
||||
negative_prompt: str | None = None,
|
||||
guidance_scale: float = 3.5,
|
||||
num_inference_steps: int = 50,
|
||||
**kwargs,
|
||||
):
|
||||
# Encode prompts, Initialize latents, Get timesteps
|
||||
...
|
||||
# Run diffusion loop (calls the mixin's diffuse method)
|
||||
latents = self.diffuse(
|
||||
prompt_embeds=prompt_embeds,
|
||||
prompt_embeds_mask=prompt_embeds_mask,
|
||||
negative_prompt_embeds=negative_embeds,
|
||||
negative_prompt_embeds_mask=negative_mask,
|
||||
latents=latents,
|
||||
timesteps=timesteps,
|
||||
do_true_cfg=do_true_cfg,
|
||||
true_cfg_scale=guidance_scale,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Customization
|
||||
|
||||
### Override `predict_noise()` for Custom Transformer Calls
|
||||
|
||||
If your transformer requires custom prediction function, you can rewrite `predict_noise` function. Taking Wan2.2 as an example, which has two transformer models. The actual transformer to be called is determined by `self.transformer`.
|
||||
|
||||
```python
|
||||
class Wan22Pipeline(nn.Module, CFGParallelMixin):
|
||||
def predict_noise(self, current_model: nn.Module | None = None, **kwargs: Any) -> torch.Tensor:
|
||||
if current_model is None:
|
||||
current_model = self.transformer
|
||||
return current_model(**kwargs)[0]
|
||||
```
|
||||
|
||||
|
||||
### Override `cfg_normalize_function()` for Custom Normalization
|
||||
|
||||
Some models have their own normalization function. Taking LongCat Image model as an example:
|
||||
|
||||
```python
|
||||
class LongCatImagePipeline(nn.Module, CFGParallelMixin):
|
||||
def cfg_normalize_function(self, noise_pred, comb_pred, cfg_renorm_min=0.0):
|
||||
"""
|
||||
Normalize the combined noise prediction.
|
||||
"""
|
||||
cond_norm = torch.norm(noise_pred, dim=-1, keepdim=True)
|
||||
noise_norm = torch.norm(comb_pred, dim=-1, keepdim=True)
|
||||
scale = (cond_norm / (noise_norm + 1e-8)).clamp(min=cfg_renorm_min, max=1.0)
|
||||
noise_pred = comb_pred * scale
|
||||
return noise_pred
|
||||
|
||||
# The original cfg_normalize_function function in CFGParallelMixin
|
||||
# cond_norm = torch.norm(noise_pred, dim=-1, keepdim=True)
|
||||
# noise_norm = torch.norm(comb_pred, dim=-1, keepdim=True)
|
||||
# noise_pred = comb_pred * (cond_norm / noise_norm)
|
||||
# return noise_pred
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
After adding CFG-Parallel support, test with:
|
||||
|
||||
```bash
|
||||
cd examples/offline_inference/text_to_image
|
||||
python text_to_image.py \
|
||||
--model Your-org/your-model \
|
||||
--prompt "a cup of coffee on the table" \
|
||||
--negative_prompt "ugly, unclear" \
|
||||
--cfg_scale 4.0 \
|
||||
--num_inference_steps 50 \
|
||||
--output "cfg_enabled.png" \
|
||||
--cfg_parallel_size 2
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
|
||||
1. Check logs for CFG parallel being activated
|
||||
2. Record the `e2e_time_ms` in the log and compare with CFG-Parallel disabled
|
||||
3. Compare the generated result quality with baseline
|
||||
4. Record comparison results in your PR
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: CFG parallel not activating
|
||||
|
||||
**Symptoms:** Generation still slow, logs don't show CFG parallel being used.
|
||||
|
||||
**Causes & Solutions:**
|
||||
|
||||
- **CFG is not enabled:**
|
||||
|
||||
**Problem:** Guidance scale too low or negative prompt not provided.
|
||||
|
||||
**Solution:** Ensure `guidance_scale > 1.0` and negative prompt is provided:
|
||||
```python
|
||||
images = pipeline(
|
||||
prompt="a cat",
|
||||
negative_prompt="", # Must provide (even if empty)
|
||||
guidance_scale=3.5, # Must be > 1.0
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reference Implementations
|
||||
|
||||
Complete examples in the codebase:
|
||||
|
||||
| Model | Path | Pattern | Notes |
|
||||
|-------|------|---------|-------|
|
||||
| **Qwen-Image** | `vllm_omni/diffusion/models/qwen_image/cfg_parallel.py` | Mixin | Dual-stream transformer |
|
||||
| **Qwen-Image-Edit** | `vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_edit.py` | Mixin | Image editing with `output_slice` |
|
||||
| **Wan2.2** | `vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py` | Mixin | Dual-transformer architecture |
|
||||
| **CFGParallelMixin** | `vllm_omni/diffusion/distributed/cfg_parallel.py` | Base implementation | Core mixin class |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Adding CFG-Parallel support:
|
||||
|
||||
1. ✅ **Create mixin** - Inherit from `CFGParallelMixin` and implement `diffuse()` method
|
||||
2. ✅ **(Optional) Customize** - Override `predict_noise()` or `cfg_normalize_function()` for custom behavior
|
||||
3. ✅ **Test** - Verify with `--cfg_parallel_size 2` and compare performance
|
||||
@@ -0,0 +1,405 @@
|
||||
# Support Sequence Parallel
|
||||
|
||||
This section describes how to add Sequence Parallel (SP) to a diffusion transformer model. We use the Qwen-Image transformer and Wan2.2 transformer as reference implementations.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Approach 1: Non-Intrusive `_sp_plan` (Recommended)](#approach-1-non-intrusive-_sp_plan-recommended)
|
||||
- [Approach 2: Intrusive Modification (For Complex Cases)](#approach-2-intrusive-modification-for-complex-cases)
|
||||
- [Testing](#testing)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Reference Implementations](#reference-implementations)
|
||||
- [Summary](#summary)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
### What is Sequence Parallel?
|
||||
|
||||
**Terminology Note:** Our "Sequence Parallelism" (SP) corresponds to "Context Parallelism" (CP) in the [diffusers library](https://github.com/huggingface/diffusers/blob/main/src/diffusers/models/_modeling_parallel.py). We use "Sequence Parallelism" to align with vLLM-Omni's terminology.
|
||||
|
||||
Diffusion transformers process long sequences of image patches or video frames. For high-resolution generation, these sequences can become very large. Enabling SP allows each GPU to process only a portion of the sequence, with attention mechanisms (Ulysses/Ring) handling cross-GPU communication transparently.
|
||||
|
||||
### Architecture
|
||||
|
||||
The major APIs for Sequence Parallel:
|
||||
|
||||
```python
|
||||
from vllm_omni.diffusion.distributed.sp_plan import (
|
||||
SequenceParallelInput, # For sharding (splitting) tensors
|
||||
SequenceParallelOutput, # For gathering tensors
|
||||
)
|
||||
from vllm_omni.diffusion.distributed.sp_sharding import sp_shard, sp_gather
|
||||
```
|
||||
|
||||
| Method/Class | Purpose | Behavior |
|
||||
|--------------|---------|----------|
|
||||
| `SequenceParallelInput` | Declare input sharding in `_sp_plan` | Auto-shards tensors at module input |
|
||||
| `SequenceParallelOutput` | Declare output gathering in `_sp_plan` | Auto-gathers tensors at module output |
|
||||
| `sp_shard()` | Manual tensor sharding | Splits tensor across SP workers |
|
||||
| `sp_gather()` | Manual tensor gathering | Gathers sharded tensors from all workers |
|
||||
|
||||
---
|
||||
|
||||
## Approach 1: Non-Intrusive `_sp_plan` (Recommended)
|
||||
|
||||
The `_sp_plan` mechanism allows SP **without modifying `forward()` logic**. The framework automatically registers hooks to shard inputs and gather outputs at module boundaries.
|
||||
|
||||
**When to use:**
|
||||
- Standard transformer architectures
|
||||
- Tensor operations happen at `nn.Module` boundaries
|
||||
- Predictable sharding/gathering patterns
|
||||
|
||||
**How it works:**
|
||||
1. Declare `_sp_plan` dict in your transformer class
|
||||
2. Framework automatically applies hooks when `sequence_parallel_size > 1`
|
||||
3. Hooks shard/gather tensors at specified module boundaries
|
||||
4. Attention layers handle cross-GPU communication internally
|
||||
|
||||
```python
|
||||
class StandardTransformer(nn.Module):
|
||||
_sp_plan = {
|
||||
# Shard hidden_states at first transformer block input
|
||||
"blocks.0": {
|
||||
"hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3),
|
||||
},
|
||||
# Gather at final output projection
|
||||
"proj_out": SequenceParallelOutput(gather_dim=1, expected_dims=3),
|
||||
}
|
||||
```
|
||||
|
||||
`StandardTransformer` has a transformer blocks list `self.blocks = nn.ModuleList([...])`, and a projection output layer `self.proj_out`. The `_sp_plan` above defines that when SP is enabled, sharding the input tensor to the first transformer block, and gathering the sharded tensor at the final output projection layer.
|
||||
|
||||
**Requirements:**
|
||||
- Tensor operations that need sharding/gathering must happen at **`nn.Module` boundaries**
|
||||
- Inline Python operations (e.g., `torch.cat`, `pad_sequence`) **cannot be hooked**
|
||||
|
||||
**Solution for inline operations:** Extract into a submodule (see Step 2 below).
|
||||
|
||||
### Step 1: Understand Module Boundaries
|
||||
|
||||
Identify where tensors need to be sharded or gathered in your model's forward pass:
|
||||
|
||||
```python
|
||||
class MyTransformer(nn.Module):
|
||||
def __init__(self):
|
||||
self.patch_embed = PatchEmbed() # ← Boundary 1
|
||||
self.pos_embed = RoPE() # ← Boundary 2
|
||||
self.blocks = nn.ModuleList([...]) # ← Boundary 3
|
||||
self.norm_out = LayerNorm()
|
||||
self.proj_out = Linear() # ← Boundary 4
|
||||
|
||||
def forward(self, x):
|
||||
x = self.patch_embed(x) # ← Shard before this?
|
||||
pos = self.pos_embed(x) # ← Shard RoPE outputs?
|
||||
for block in self.blocks:
|
||||
x = block(x, pos) # ← Blocks process sharded x
|
||||
x = self.norm_out(x)
|
||||
output = self.proj_out(x) # ← Gather after this?
|
||||
return output
|
||||
```
|
||||
|
||||
### Step 2: Handle Inline Operations
|
||||
|
||||
If your `forward()` contains inline tensor operations, **extract them into submodules**.
|
||||
|
||||
**Example: Z-Image concatenates image + text features inline**
|
||||
|
||||
```python
|
||||
# ❌ BAD: Inline operation - hooks cannot intercept
|
||||
class ZImageTransformer(nn.Module):
|
||||
def forward(self, x, cap_feats):
|
||||
# This concatenation happens inline - _sp_plan can't shard it!
|
||||
unified = torch.cat([x, cap_feats], dim=1)
|
||||
|
||||
for layer in self.layers:
|
||||
unified = layer(unified)
|
||||
|
||||
return unified
|
||||
|
||||
# ✅ GOOD: Extract into submodule
|
||||
class UnifiedPrepare(nn.Module):
|
||||
"""Submodule to concatenate image and text features."""
|
||||
def forward(self, x, cap_feats):
|
||||
return torch.cat([x, cap_feats], dim=1)
|
||||
|
||||
class ZImageTransformer(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.unified_prepare = UnifiedPrepare() # Now a module!
|
||||
self.layers = nn.ModuleList([...])
|
||||
|
||||
def forward(self, x, cap_feats):
|
||||
# Now _sp_plan can shard the output of unified_prepare!
|
||||
unified = self.unified_prepare(x, cap_feats)
|
||||
|
||||
for layer in self.layers:
|
||||
unified = layer(unified)
|
||||
|
||||
return unified
|
||||
```
|
||||
|
||||
**Other common cases:**
|
||||
- `pad_sequence()` → `PadSequenceModule`
|
||||
- `torch.cat()` → `ConcatModule`
|
||||
- `tensor.reshape()` → `ReshapeModule`
|
||||
- Complex preprocessing → `PreprocessModule`
|
||||
|
||||
### Step 3: Write `_sp_plan` for Your Model
|
||||
|
||||
Create a class-level `_sp_plan` dictionary specifying where to shard/gather tensors.
|
||||
|
||||
Typically, there are two patterns for diffusion models:
|
||||
|
||||
**Pattern 1: Shard at first block, gather at output projection**
|
||||
|
||||
Most common pattern for standard transformers:
|
||||
|
||||
```python
|
||||
from vllm_omni.diffusion.distributed.sp_plan import (
|
||||
SequenceParallelInput, # For sharding (splitting) tensors
|
||||
SequenceParallelOutput, # For gathering tensors
|
||||
)
|
||||
class StandardTransformer(nn.Module):
|
||||
_sp_plan = {
|
||||
# Shard hidden_states at first transformer block input
|
||||
"blocks.0": {
|
||||
"hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3),
|
||||
},
|
||||
# Gather at final output projection
|
||||
"proj_out": SequenceParallelOutput(gather_dim=1, expected_dims=3),
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern 2: Shard RoPE embeddings separately**
|
||||
|
||||
When RoPE is computed in a separate module:
|
||||
|
||||
```python
|
||||
from vllm_omni.diffusion.distributed.sp_plan import (
|
||||
SequenceParallelInput, # For sharding (splitting) tensors
|
||||
SequenceParallelOutput, # For gathering tensors
|
||||
)
|
||||
class TransformerWithRoPE(nn.Module):
|
||||
_sp_plan = {
|
||||
# Shard RoPE module OUTPUTS (returns tuple of cos, sin)
|
||||
"rope": {
|
||||
0: SequenceParallelInput(split_dim=1, expected_dims=4, split_output=True), # cos
|
||||
1: SequenceParallelInput(split_dim=1, expected_dims=4, split_output=True), # sin
|
||||
},
|
||||
# Shard transformer block INPUT
|
||||
"blocks.0": {
|
||||
"hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3),
|
||||
},
|
||||
# Gather at output
|
||||
"proj_out": SequenceParallelOutput(gather_dim=1, expected_dims=3),
|
||||
}
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
**SequenceParallelInput Parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `split_dim` | int | Dimension to split (usually `1` for sequence) |
|
||||
| `expected_dims` | int \| None | Expected tensor rank for validation (optional) |
|
||||
| `split_output` | bool | `False`: shard **input** params; `True`: shard **output** tensors |
|
||||
| `auto_pad` | bool | Auto-pad if sequence not divisible by world_size (default: `False`) |
|
||||
|
||||
**SequenceParallelOutput Parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `gather_dim` | int | Dimension to gather (usually `1` for sequence) |
|
||||
| `expected_dims` | int \| None | Expected tensor rank for validation (optional) |
|
||||
|
||||
**Module Naming Conventions:**
|
||||
|
||||
| Key | Meaning | Python equivalent |
|
||||
|-----|---------|-------------------|
|
||||
| `""` | Root model | `model` |
|
||||
| `"blocks.0"` | First element of ModuleList | `model.blocks[0]` |
|
||||
| `"blocks.*"` | All elements of ModuleList | `for b in model.blocks` |
|
||||
| `"rope"` | Named submodule | `model.rope` |
|
||||
| `"outputs.main"` | ModuleDict entry | `model.outputs["main"]` |
|
||||
|
||||
**Dictionary Value Types:**
|
||||
|
||||
| Key type | `split_output` | Description |
|
||||
|----------|----------------|-------------|
|
||||
| `"param_name"` (str) | `False` | Shard **input parameter** by name |
|
||||
| `0`, `1`, ... (int) | `True` | Shard **output tuple** by index |
|
||||
|
||||
---
|
||||
|
||||
## Approach 2: Intrusive Modification (For Complex Cases)
|
||||
|
||||
For models with dynamic sharding logic that cannot be expressed via `_sp_plan`, manually insert shard/gather calls.
|
||||
|
||||
**When to use:**
|
||||
- Dynamic/conditional sharding logic
|
||||
- Complex tensor manipulations that can't be encapsulated
|
||||
- Temporary workaround during development
|
||||
|
||||
```python
|
||||
from vllm_omni.diffusion.distributed.sp_sharding import sp_shard, sp_gather
|
||||
|
||||
def forward(self, hidden_states, ...):
|
||||
if self.parallel_config.sequence_parallel_size > 1:
|
||||
hidden_states = sp_shard(hidden_states, dim=1)
|
||||
|
||||
# ... computation ...
|
||||
|
||||
if self.parallel_config.sequence_parallel_size > 1:
|
||||
output = sp_gather(output, dim=1)
|
||||
|
||||
return output
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
After implementing Sequence Parallel support, thoroughly test your implementation to ensure correctness and performance across different configurations.
|
||||
|
||||
**Test Different `sp_size`:**
|
||||
|
||||
Test your model with various sequence parallel world sizes to verify correctness and identify optimal configurations:
|
||||
|
||||
```bash
|
||||
cd examples/offline_inference/text_to_image
|
||||
python text_to_image.py \
|
||||
--model Your-org/your-model \
|
||||
--prompt "a cup of coffee on the table" \
|
||||
--num_inference_steps 50 \
|
||||
--ulysses_degree 2 \
|
||||
--ring_degree 2 \
|
||||
--output sp_test_image_ulysses=2_ring=2.png
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
|
||||
1. **Correctness:** Output should be identical across all `sp_size` values
|
||||
2. **Speed:** Throughput should remain stable or improve (especially for large sequences)
|
||||
3. **Logs:** Check for any shape mismatch or communication errors
|
||||
|
||||
**Test with Tensor Parallel:**
|
||||
|
||||
Sequence Parallel can be combined with other parallelism strategies:
|
||||
|
||||
```bash
|
||||
cd examples/offline_inference/text_to_image
|
||||
python text_to_image.py \
|
||||
--model Your-org/your-model \
|
||||
--prompt "a cup of coffee on the table" \
|
||||
--num_inference_steps 50 \
|
||||
--ulysses_degree 2 \
|
||||
--tensor_parallel_size 2 \
|
||||
--output sp_test_image_ulysses=2_tp=2.png
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: Shape mismatch errors
|
||||
|
||||
**Symptoms:** `RuntimeError: shape mismatch` during forward pass.
|
||||
|
||||
**Causes & Solutions:**
|
||||
|
||||
- **RoPE dimension mismatch:**
|
||||
|
||||
**Problem:** RoPE embeddings not sharded, but hidden_states is sharded.
|
||||
|
||||
**Solution:** Shard RoPE outputs in `_sp_plan`:
|
||||
```python
|
||||
_sp_plan = {
|
||||
"rope": {
|
||||
0: SequenceParallelInput(split_dim=1, expected_dims=4, split_output=True),
|
||||
1: SequenceParallelInput(split_dim=1, expected_dims=4, split_output=True),
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
- **Sequence Length not divisible by sp_size:**
|
||||
|
||||
**Problem:** `SequenceParallelInput(auto_pad=False)` - auto_pad should be True to enable automatic sequence padding.
|
||||
|
||||
**Solution:** In `SequenceParallelInput`, set `auto_pad=True`:
|
||||
```python
|
||||
"blocks.0": {
|
||||
"hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3, auto_pad=True)
|
||||
}
|
||||
```
|
||||
|
||||
### Issue: Inline operations not sharded
|
||||
|
||||
**Symptoms:** Some tensors remain full-sized, not sharded.
|
||||
|
||||
**Causes & Solutions:**
|
||||
|
||||
- **Operations happen inline in `forward()`, not at module boundaries:**
|
||||
|
||||
**Problem:**
|
||||
```python
|
||||
def forward(self, x, cap):
|
||||
unified = torch.cat([x, cap], dim=1) # ← Inline operation!
|
||||
# _sp_plan can't hook this
|
||||
```
|
||||
|
||||
**Solution:** Extract into submodule:
|
||||
```python
|
||||
class ConcatModule(nn.Module):
|
||||
def forward(self, x, cap):
|
||||
return torch.cat([x, cap], dim=1)
|
||||
|
||||
class MyModel(nn.Module):
|
||||
_sp_plan = {
|
||||
"concat": {
|
||||
0: SequenceParallelInput(split_dim=1, expected_dims=4, split_output=True),
|
||||
1: SequenceParallelInput(split_dim=1, expected_dims=4, split_output=True),
|
||||
},
|
||||
...
|
||||
}
|
||||
def __init__(self):
|
||||
self.concat = ConcatModule() # Now hookable!
|
||||
|
||||
def forward(self, x, cap):
|
||||
unified = self.concat(x, cap) # ← Can be sharded via _sp_plan
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reference Implementations
|
||||
|
||||
Complete examples in the codebase:
|
||||
|
||||
| Model | Path | Pattern | Notes |
|
||||
|-------|------|---------|-------|
|
||||
| **Qwen-Image** | `vllm_omni/diffusion/models/qwen_image/qwen_image_transformer.py` | Dual-stream + preprocessing | auto_pad, separate RoPE |
|
||||
| **Wan2.2** | `vllm_omni/diffusion/models/wan2_2/wan2_2_transformer.py` | Dual-Transformer + RoPE | Video transformer |
|
||||
| **Z-Image** | `vllm_omni/diffusion/models/z_image/z_image_transformer.py` | Unified sequence | Concatenated input |
|
||||
| **SP Plan Types** | `vllm_omni/diffusion/distributed/sp_plan.py` | Type definitions | SequenceParallelInput/Output |
|
||||
| **Hook Implementation** | `vllm_omni/diffusion/hooks/sequence_parallel.py` | Hook mechanics | How hooks work |
|
||||
| **Tests** | `tests/diffusion/distributed/test_sp_plan_hooks.py` | Test examples | Validation patterns |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Adding Sequence Parallel support to a transformer:
|
||||
|
||||
1. ✅ **Choose approach** - Use `_sp_plan` for standard cases, intrusive modification for complex cases
|
||||
2. ✅ **Identify sharding boundaries** - Where should tensors be split/gathered?
|
||||
3. ✅ **Extract inline operations** - Move `torch.cat`, `pad_sequence`, etc. to submodules
|
||||
4. ✅ **Define `_sp_plan`** - Declare shard/gather points as class attribute
|
||||
5. ✅ **Use `auto_pad` for variable lengths** - Support non-uniform sequences
|
||||
6. ✅ **Test** - Verify with different `ulysses_degree` and `ring_degree` combinations
|
||||
@@ -0,0 +1,459 @@
|
||||
# Support TeaCache
|
||||
|
||||
This section describes how to add TeaCache to a diffusion transformer model. We use the Qwen-Image transformer as the reference implementation.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Step-by-Step Implementation](#step-by-step-implementation)
|
||||
- [Customization](#customization)
|
||||
- [Testing](#testing)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Reference Implementations](#reference-implementations)
|
||||
- [Summary](#summary)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
### What is TeaCache?
|
||||
|
||||
TeaCache speeds up diffusion inference by caching transformer block computations when consecutive timesteps are similar. It provides **1.5x-2.0x speedup** with minimal quality loss.
|
||||
|
||||
The core insight is that the modulated input (after normalization and timestep conditioning) changes gradually across timesteps. By measuring the L1 distance between consecutive modulated inputs and comparing it to a threshold, TeaCache decides whether to execute the full transformer blocks or reuse the cached residual from the previous step.
|
||||
|
||||
vLLM-omni provides a **hook-based** TeaCache system that requires **zero changes to model code**. The hook completely intercepts the transformer's forward pass and implements adaptive caching transparently. This design allows easy integration with any transformer model by simply writing an extractor function.
|
||||
|
||||
### Architecture
|
||||
|
||||
The TeaCache system consists of three main components:
|
||||
|
||||
| Component | Purpose | Location |
|
||||
|-----------|---------|----------|
|
||||
| [`CacheContext`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/cache/#vllm_omni.diffusion.cache.CacheContext) | Dataclass containing model-specific information for caching | `vllm_omni/diffusion/cache/teacache/context.py` |
|
||||
| [`EXTRACTOR_REGISTRY`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/cache/teacache/extractors/#vllm_omni.diffusion.cache.teacache.extractors.EXTRACTOR_REGISTRY) | Maps transformer class names to extractor functions | `vllm_omni/diffusion/cache/teacache/extractors.py` |
|
||||
| [`TeaCacheConfig`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/cache/#vllm_omni.diffusion.cache.TeaCacheConfig) | Configuration including thresholds and polynomial coefficients | `vllm_omni/diffusion/cache/teacache/config.py` |
|
||||
|
||||
The hook handles all caching logic automatically, including:
|
||||
|
||||
- CFG-aware state management (separate states for positive/negative branches)
|
||||
- CFG-parallel compatibility
|
||||
- L1 distance computation with polynomial rescaling
|
||||
- Residual caching and reuse
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Implementation
|
||||
|
||||
To add TeaCache support for a new model, you need to:
|
||||
|
||||
1. Write an **extractor function** that returns a `CacheContext` object
|
||||
2. Register the extractor in the `EXTRACTOR_REGISTRY`
|
||||
3. Add model-specific polynomial coefficients to `TeaCacheConfig`
|
||||
|
||||
### Step 1: Model-Specific Preprocessing
|
||||
|
||||
Extract and process model inputs. This typically involves:
|
||||
- Embedding image/latent inputs
|
||||
- Processing text encoder outputs (if dual-stream)
|
||||
- Creating timestep embeddings
|
||||
- Computing positional embeddings
|
||||
|
||||
**Example (Qwen-Image):**
|
||||
|
||||
```python
|
||||
def extract_qwen_context(
|
||||
module: nn.Module,
|
||||
hidden_states: torch.Tensor,
|
||||
encoder_hidden_states: torch.Tensor,
|
||||
encoder_hidden_states_mask: torch.Tensor,
|
||||
timestep: torch.Tensor,
|
||||
img_shapes: torch.Tensor,
|
||||
txt_seq_lens: torch.Tensor,
|
||||
guidance: torch.Tensor | None = None,
|
||||
**kwargs: Any,
|
||||
) -> CacheContext:
|
||||
# Validate model structure
|
||||
if not hasattr(module, "transformer_blocks") or len(module.transformer_blocks) == 0:
|
||||
raise ValueError("Module must have transformer_blocks")
|
||||
|
||||
# Preprocessing: embed inputs
|
||||
hidden_states = module.img_in(hidden_states)
|
||||
timestep = timestep.to(device=hidden_states.device, dtype=hidden_states.dtype)
|
||||
encoder_hidden_states = module.txt_norm(encoder_hidden_states)
|
||||
encoder_hidden_states = module.txt_in(encoder_hidden_states)
|
||||
|
||||
# Create timestep embedding
|
||||
if guidance is not None:
|
||||
guidance = guidance.to(hidden_states.dtype) * 1000
|
||||
temb = (
|
||||
module.time_text_embed(timestep, hidden_states)
|
||||
if guidance is None
|
||||
else module.time_text_embed(timestep, guidance, hidden_states)
|
||||
)
|
||||
|
||||
# Compute position embeddings
|
||||
image_rotary_emb = module.pos_embed(img_shapes, txt_seq_lens, device=hidden_states.device)
|
||||
```
|
||||
|
||||
### Step 2: Extract Modulated Input
|
||||
|
||||
The modulated input is used for cache decisions. Extract it from the **first transformer block** after normalization and modulation.
|
||||
|
||||
**Example (Qwen-Image):**
|
||||
|
||||
```python
|
||||
# Extract modulated input from first transformer block
|
||||
block = module.transformer_blocks[0]
|
||||
img_mod_params = block.img_mod(temb)
|
||||
img_mod1, _ = img_mod_params.chunk(2, dim=-1)
|
||||
img_modulated, _ = block.img_norm1(hidden_states, img_mod1)
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
|
||||
- Use the **first block** to extract modulated input early
|
||||
- Apply the same normalization and modulation as the actual forward pass
|
||||
- The tensor should represent the processed features that will change across timesteps
|
||||
|
||||
### Step 3: Define Transformer Execution
|
||||
|
||||
Create a callable that executes all transformer blocks. This encapsulates the main computation loop.
|
||||
|
||||
**Example (Qwen-Image dual-stream):**
|
||||
|
||||
```python
|
||||
def run_transformer_blocks():
|
||||
"""Execute all Qwen transformer blocks."""
|
||||
h = hidden_states
|
||||
e = encoder_hidden_states
|
||||
|
||||
for block in module.transformer_blocks:
|
||||
e, h = block(
|
||||
hidden_states=h,
|
||||
encoder_hidden_states=e,
|
||||
encoder_hidden_states_mask=encoder_hidden_states_mask,
|
||||
temb=temb,
|
||||
image_rotary_emb=image_rotary_emb,
|
||||
)
|
||||
return (h, e) # Return both image and text hidden states
|
||||
```
|
||||
|
||||
**Example (Single-stream model like Flux):**
|
||||
|
||||
```python
|
||||
def run_transformer_blocks():
|
||||
"""Execute all Flux transformer blocks."""
|
||||
h = hidden_states
|
||||
|
||||
for block in module.transformer_blocks:
|
||||
h = block(h, temb=temb)
|
||||
return (h,) # Return only image hidden states
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
|
||||
- Return format:
|
||||
- For single-stream models: return `(hidden_states,)`
|
||||
- For dual-stream models: return `(hidden_states, encoder_hidden_states)`
|
||||
|
||||
### Step 4: Define Postprocessing
|
||||
|
||||
Create a callable that applies final transformations to produce the model output.
|
||||
|
||||
**Example (Qwen-Image):**
|
||||
|
||||
```python
|
||||
return_dict = kwargs.get("return_dict", True)
|
||||
|
||||
def postprocess(h):
|
||||
"""Apply Qwen-specific output postprocessing."""
|
||||
h = module.norm_out(h, temb)
|
||||
output = module.proj_out(h)
|
||||
if not return_dict:
|
||||
return (output,)
|
||||
return Transformer2DModelOutput(sample=output)
|
||||
```
|
||||
|
||||
### Step 5: Return CacheContext
|
||||
|
||||
Package all information into a `CacheContext` object.
|
||||
|
||||
```python
|
||||
return CacheContext(
|
||||
modulated_input=img_modulated,
|
||||
hidden_states=hidden_states,
|
||||
encoder_hidden_states=encoder_hidden_states, # or None for single-stream
|
||||
temb=temb,
|
||||
run_transformer_blocks=run_transformer_blocks,
|
||||
postprocess=postprocess,
|
||||
)
|
||||
```
|
||||
|
||||
**CacheContext Fields:**
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|-------|------|---------|
|
||||
| `modulated_input` | `torch.Tensor` | Tensor used for cache decision (similarity comparison) |
|
||||
| `hidden_states` | `torch.Tensor` | Current hidden states (will be modified by caching) |
|
||||
| `encoder_hidden_states` | `torch.Tensor | None` | Encoder states for dual-stream models, `None` for single-stream |
|
||||
| `temb` | `torch.Tensor` | Timestep embedding tensor |
|
||||
| `run_transformer_blocks` | `Callable[[], tuple]` | Executes transformer blocks, returns `(hidden_states, [encoder_hidden_states])` |
|
||||
| `postprocess` | `Callable[[torch.Tensor], Any]` | Applies final transformations to produce model output |
|
||||
| `extra_states` | `dict | None` | Optional dict for additional model-specific state |
|
||||
|
||||
### Step 6: Register the Extractor
|
||||
|
||||
Add your extractor to the `EXTRACTOR_REGISTRY` in `vllm_omni/diffusion/cache/teacache/extractors.py`:
|
||||
|
||||
```python
|
||||
EXTRACTOR_REGISTRY: dict[str, Callable] = {
|
||||
"QwenImageTransformer2DModel": extract_qwen_context,
|
||||
"Bagel": extract_bagel_context,
|
||||
"ZImageTransformer2DModel": extract_zimage_context,
|
||||
"YourModelTransformer2DModel": extract_your_model_context, # Add here
|
||||
}
|
||||
```
|
||||
|
||||
**Key:** Use the transformer class name (`module.__class__.__name__`)
|
||||
|
||||
### Step 7: Add Model Coefficients
|
||||
|
||||
Add polynomial rescaling coefficients to `vllm_omni/diffusion/cache/teacache/config.py`:
|
||||
|
||||
```python
|
||||
_MODEL_COEFFICIENTS = {
|
||||
"QwenImageTransformer2DModel": [
|
||||
-4.50000000e02,
|
||||
2.80000000e02,
|
||||
-4.50000000e01,
|
||||
3.20000000e00,
|
||||
-2.00000000e-02,
|
||||
],
|
||||
"YourModelTransformer2DModel": [ # Add your model's coefficients
|
||||
# 5 polynomial coefficients (can reuse similar model's coefficients initially)
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
**Initial approach:** Start with coefficients from a similar model architecture, then tune empirically following [Customization](#customization) section.
|
||||
|
||||
---
|
||||
|
||||
## Customization
|
||||
|
||||
### Coefficient Estimation
|
||||
|
||||
While you can start with coefficients from a similar model architecture, estimating custom coefficients for your specific model typically improves TeaCache performance.
|
||||
|
||||
**Why Estimate Coefficients?**
|
||||
|
||||
The polynomial coefficients rescale L1 distances between consecutive modulated inputs to better predict when cached residuals can be reused. Model-specific coefficients account for:
|
||||
|
||||
- Architecture differences (layer count, hidden size, attention patterns)
|
||||
- Training data characteristics
|
||||
- Noise prediction behavior across timesteps
|
||||
|
||||
| Approach | Performance | Effort |
|
||||
|----------|-------------|--------|
|
||||
| Using defaults from similar model | Within 5-10% of optimal | Low |
|
||||
| Estimating custom coefficients | Best performance | Medium |
|
||||
|
||||
#### Implement Data Collection Adapter
|
||||
|
||||
Add an adapter in `vllm_omni/diffusion/cache/teacache/coefficient_estimator.py`:
|
||||
|
||||
```python
|
||||
class YourModelAdapter:
|
||||
"""Adapter for coefficient estimation on your model."""
|
||||
|
||||
@staticmethod
|
||||
def load_pipeline(model_path: str, device: str, dtype: torch.dtype) -> Any:
|
||||
"""Load your diffusion pipeline."""
|
||||
from your_model_package import YourModelPipeline
|
||||
|
||||
pipeline = YourModelPipeline.from_pretrained(
|
||||
model_path,
|
||||
torch_dtype=dtype,
|
||||
)
|
||||
pipeline = pipeline.to(device)
|
||||
return pipeline
|
||||
|
||||
@staticmethod
|
||||
def get_transformer(pipeline: Any) -> tuple[Any, str]:
|
||||
"""Extract transformer from pipeline."""
|
||||
return pipeline.transformer, "YourTransformer2DModel"
|
||||
|
||||
@staticmethod
|
||||
def install_hook(transformer: Any, hook: DataCollectionHook) -> None:
|
||||
"""Install data collection hook on transformer."""
|
||||
from vllm_omni.diffusion.hooks import HookRegistry
|
||||
|
||||
registry = HookRegistry.get_or_create(transformer)
|
||||
registry.register_hook(hook._HOOK_NAME, hook)
|
||||
|
||||
|
||||
# Register your adapter
|
||||
_MODEL_ADAPTERS["YourModel"] = YourModelAdapter
|
||||
```
|
||||
|
||||
#### Collect Data and Estimate
|
||||
|
||||
```python
|
||||
from vllm_omni.diffusion.cache.teacache.coefficient_estimator import (
|
||||
TeaCacheCoefficientEstimator,
|
||||
)
|
||||
from datasets import load_dataset
|
||||
from tqdm import tqdm
|
||||
|
||||
# Initialize estimator
|
||||
estimator = TeaCacheCoefficientEstimator(
|
||||
model_path="/path/to/your/model",
|
||||
model_type="YourModel",
|
||||
)
|
||||
|
||||
# Load diverse prompts (paper recommends ~70 prompts)
|
||||
dataset = load_dataset("nateraw/parti-prompts", split="train")
|
||||
prompts = dataset["Prompt"][:70]
|
||||
|
||||
# Collect data
|
||||
for prompt in tqdm(prompts, desc="Collecting data"):
|
||||
estimator.collect_from_prompt(prompt=prompt, num_inference_steps=50)
|
||||
|
||||
# Estimate coefficients
|
||||
coeffs = estimator.estimate(poly_order=4)
|
||||
print(f"Estimated coefficients: {coeffs.tolist()}")
|
||||
```
|
||||
|
||||
**Data Statistics Guide:**
|
||||
|
||||
| Metric | Good Range | Warning Signs |
|
||||
|--------|------------|---------------|
|
||||
| **Count** | 2000-5000+ | < 1000: too few prompts |
|
||||
| **Input Diffs (x)** | 0.01-0.10 | Very small (<0.001): model may not modulate properly |
|
||||
| **Output Diffs (y)** | Should correlate with x | No correlation: check extractor |
|
||||
| **Coefficient magnitude** | -1e6 to 1e6 | > 1e8: numerical instability |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
After adding TeaCache support, test with:
|
||||
|
||||
```python
|
||||
from vllm_omni import Omni
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
omni = Omni(
|
||||
model="your-model-name",
|
||||
cache_backend="tea_cache",
|
||||
cache_config={
|
||||
"rel_l1_thresh": 0.2,
|
||||
"coefficients": [1.33e6, -1.69e5, 7.95e3, -1.64e2, 1.26], # Your coefficients
|
||||
}
|
||||
)
|
||||
|
||||
images = omni.generate(
|
||||
"a beautiful landscape",
|
||||
OmniDiffusionSamplingParams(num_inference_steps=50),
|
||||
)
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
|
||||
1. **Check logs** - Look for TeaCache initialization messages
|
||||
2. **Compare performance** - Measure speedup vs baseline (expect 1.5x-2.0x)
|
||||
3. **Verify output quality** - Visually compare cached vs uncached outputs (should be nearly identical)
|
||||
|
||||
See more detailed examples in [user guide for teacache](../../user_guide/diffusion/teacache.md).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: "Unknown model type"
|
||||
|
||||
**Symptoms:** Error message indicating the model type is not recognized when enabling TeaCache.
|
||||
|
||||
**Causes & Solutions:**
|
||||
|
||||
- **Extractor not registered:**
|
||||
|
||||
**Problem:** The transformer class name doesn't exist in `EXTRACTOR_REGISTRY`.
|
||||
|
||||
**Solution:** Check the class name and add to registry:
|
||||
```python
|
||||
# Check transformer class name
|
||||
print(pipeline.transformer.__class__.__name__)
|
||||
|
||||
# Add to EXTRACTOR_REGISTRY
|
||||
EXTRACTOR_REGISTRY["YourTransformer2DModel"] = extract_your_context
|
||||
```
|
||||
|
||||
- **Transformer class name mismatch:**
|
||||
|
||||
**Solution:** Ensure the registry key matches exactly with `module.__class__.__name__`.
|
||||
|
||||
### Issue: "Cannot find coefficients"
|
||||
|
||||
**Symptoms:** Error when initializing TeaCache about missing model coefficients.
|
||||
|
||||
**Causes & Solutions:**
|
||||
|
||||
- **Missing coefficients in config:**
|
||||
|
||||
**Solution:** Add coefficients to `_MODEL_COEFFICIENTS` in `config.py`, or pass custom coefficients:
|
||||
```python
|
||||
omni = Omni(
|
||||
model="your-model",
|
||||
cache_backend="tea_cache",
|
||||
cache_config={"coefficients": [1.0, -0.5, 0.1, -0.01, 0.001]}
|
||||
)
|
||||
```
|
||||
|
||||
### Issue: Quality Degradation
|
||||
|
||||
**Symptoms:** Output images look noticeably different or have artifacts compared to baseline.
|
||||
|
||||
**Causes & Solutions:**
|
||||
|
||||
- **Threshold too high:**
|
||||
|
||||
**Problem:** `rel_l1_thresh` is too aggressive, causing cache reuse when outputs differ significantly.
|
||||
|
||||
**Solution:** Lower the threshold:
|
||||
```python
|
||||
cache_config={"rel_l1_thresh": 0.1} # Try 0.1-0.2
|
||||
```
|
||||
|
||||
- **Coefficients not tuned:**
|
||||
|
||||
**Solution:** Estimate model-specific coefficients using the coefficient estimation process described above.
|
||||
|
||||
---
|
||||
|
||||
## Reference Implementations
|
||||
|
||||
Complete examples in the codebase:
|
||||
|
||||
| Model | Path | Pattern | Notes |
|
||||
|-------|------|---------|-------|
|
||||
| **Qwen-Image** | `vllm_omni/diffusion/cache/teacache/extractors.py` | Dual-stream | `extract_qwen_context` |
|
||||
| **Bagel** | `vllm_omni/diffusion/cache/teacache/extractors.py` | Omni model | `extract_bagel_context` |
|
||||
| **TeaCache Core** | `vllm_omni/diffusion/cache/teacache/` | Base implementation | Hook and config |
|
||||
| **Coefficient Estimator** | `vllm_omni/diffusion/cache/teacache/coefficient_estimator.py` | Estimation tool | Adapter pattern |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Adding TeaCache support:
|
||||
|
||||
1. ✅ **Write extractor** - Create function returning `CacheContext` with model-specific preprocessing
|
||||
2. ✅ **Register extractor** - Add to `EXTRACTOR_REGISTRY` with transformer class name
|
||||
3. ✅ **Add coefficients** - Add polynomial coefficients to `_MODEL_COEFFICIENTS`
|
||||
4. ✅ **Test** - Verify with `cache_backend="tea_cache"`
|
||||
@@ -0,0 +1,279 @@
|
||||
# Support Tensor Parallel
|
||||
|
||||
This section describes how to add Tensor Parallel (TP) to a diffusion transformer model. We use the Z-Image transformer as the reference implementation.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Step-by-Step Implementation](#step-by-step-implementation)
|
||||
- [Testing](#testing)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Reference Implementations](#reference-implementations)
|
||||
- [Summary](#summary)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
### What is Tensor Parallel?
|
||||
|
||||
Tensor Parallel (TP) is a model parallelism technique that **shards model weights** across multiple GPUs. Each GPU holds only a portion of the model's parameters and computes only part of each layer's output.
|
||||
|
||||
Diffusion transformers contain large attention and MLP layers. We can use Tensor Parallel to shard the model dimension across multiple GPUs, allowing larger models to fit in memory while achieving near-linear speedup.
|
||||
|
||||
### Architecture
|
||||
|
||||
The Tensor Parallel implementation relies vLLM's Parallel Layers:
|
||||
|
||||
[vLLM Parallel Layers API Reference](https://docs.vllm.ai/en/latest/contributing/model/basic/?h=column#3-optional-implement-tensor-parallelism-and-quantization-support)
|
||||
|
||||
**Parallel Layer Types:**
|
||||
|
||||
| Layer Type | Purpose | Weight Partitioning |
|
||||
|------------|---------|---------------------|
|
||||
| `ColumnParallelLinear` | First FFN layer, separated QKV | Columns (output dimension) |
|
||||
| `RowParallelLinear` | Second FFN layer, attention output | Rows (input dimension) |
|
||||
| `QKVParallelLinear` | Multi-head/grouped-query attention QKV | Handles head replication automatically |
|
||||
| `ReplicatedLinear` | Layers that shouldn't be sharded | No partitioning (replicated) |
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Implementation
|
||||
|
||||
|
||||
### Step 1: Identify Linear Layers
|
||||
|
||||
Find all `nn.Linear` layers in your transformer that need to be sharded.
|
||||
|
||||
**Key questions:**
|
||||
- Which layers should be column parallel (weight split by columns)?
|
||||
- Which layers should be row parallel (weight split by rows)?
|
||||
|
||||
### Step 2: Replace Linear Layers with Parallel Equivalents
|
||||
|
||||
Replace `nn.Linear` with parallel layers from `vllm.model_executor.layers.linear`.
|
||||
|
||||
**Example (MLP Block - Up-Down Pattern):**
|
||||
|
||||
```python
|
||||
class FeedForward(nn.Module):
|
||||
def __init__(self, dim: int, hidden_dim: int):
|
||||
super().__init__()
|
||||
# Column parallel: weight split by columns [hidden_dim/N, dim]
|
||||
self.w1 = ColumnParallelLinear(
|
||||
dim,
|
||||
hidden_dim,
|
||||
bias=False,
|
||||
return_bias=False,
|
||||
)
|
||||
self.act = nn.GELU()
|
||||
|
||||
self.w2 = RowParallelLinear(
|
||||
hidden_dim,
|
||||
dim,
|
||||
bias=False,
|
||||
input_is_parallel=True, # Input already sharded from w1
|
||||
return_bias=False,
|
||||
)
|
||||
|
||||
def forward(self, x):
|
||||
# x: [batch, seq, dim] (replicated on all GPUs)
|
||||
# w1 outputs sharded [batch, seq, hidden_dim/N]
|
||||
x = self.w1(x)
|
||||
# act operates on sharded tensors (no communication)
|
||||
x = self.act(x)
|
||||
# w2 outputs full dim [batch, seq, dim] via all-reduce
|
||||
x = self.w2(x)
|
||||
return x
|
||||
```
|
||||
|
||||
**Example (Attention - QKV-Out Pattern):**
|
||||
|
||||
```python
|
||||
from vllm_omni.diffusion.attention.layer import Attention
|
||||
class YourModelAttention(nn.Module):
|
||||
def __init__(self, dim: int, num_heads: int, num_kv_heads: int):
|
||||
super().__init__()
|
||||
self.head_dim = dim // num_heads
|
||||
|
||||
# Column parallel: QKV weight split by columns
|
||||
# Each GPU gets num_heads/N heads
|
||||
self.to_qkv = QKVParallelLinear(
|
||||
hidden_size=dim,
|
||||
head_size=self.head_dim,
|
||||
total_num_heads=num_heads,
|
||||
total_num_kv_heads=num_kv_heads,
|
||||
bias=False,
|
||||
return_bias=False,
|
||||
)
|
||||
|
||||
# Row parallel: output weight split by rows
|
||||
self.to_out = RowParallelLinear(
|
||||
dim,
|
||||
dim,
|
||||
bias=False,
|
||||
input_is_parallel=True, # Input sharded from attention
|
||||
return_bias=False,
|
||||
)
|
||||
|
||||
self.attn = Attention(
|
||||
num_heads=self.to_qkv.num_heads, # Each GPU gets num_heads/N heads
|
||||
head_size=self.head_dim,
|
||||
softmax_scale=1.0 / (self.head_dim**0.5),
|
||||
causal=False,
|
||||
num_kv_heads=self.to_qkv.num_kv_heads,
|
||||
)
|
||||
|
||||
def forward(self, x):
|
||||
# x: [batch, seq, dim] (replicated)
|
||||
# to_qkv outputs sharded [batch, seq, (q+k+v) * head_dim/N]
|
||||
qkv = self.to_qkv(x)
|
||||
# Split into Q, K, V (each sharded on heads)
|
||||
q, k, v = qkv.split([...], dim=-1)
|
||||
# Attention computed independently on each GPU
|
||||
out = self.attn(q, k, v)
|
||||
# to_out all-reduces to full dim
|
||||
out = self.to_out(out)
|
||||
return out
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
|
||||
- `ColumnParallelLinear` → `RowParallelLinear` is the standard pairing
|
||||
- Set `input_is_parallel=True` on `RowParallelLinear` when input comes from `ColumnParallelLinear`
|
||||
- Use `QKVParallelLinear` for attention projections (handles head replication automatically)
|
||||
|
||||
### Step 3: Validate TP Constraints
|
||||
|
||||
For correct TP operation, these dimensions **must be divisible** by `tensor_parallel_size`:
|
||||
|
||||
| Dimension | Reason | Example Error |
|
||||
|-----------|--------|---------------|
|
||||
| `num_heads` | Heads sharded by QKVParallelLinear | `num_heads=30, tp=4` ❌ (30 % 4 ≠ 0) |
|
||||
| `num_kv_heads` | KV heads sharded by QKVParallelLinear | `num_kv_heads=30, tp=4` ❌ (30 % 4 ≠ 0) |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
After adding Tensor Parallel support, test with:
|
||||
|
||||
```python
|
||||
from vllm_omni import Omni
|
||||
from vllm_omni.diffusion.data import DiffusionParallelConfig
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
parallel_config = DiffusionParallelConfig(tensor_parallel_size=2)
|
||||
omni = Omni(model="your-model-name", parallel_config=parallel_config)
|
||||
|
||||
output = omni.generate(
|
||||
"a cup of coffee on the table",
|
||||
OmniDiffusionSamplingParams(num_inference_steps=50),
|
||||
)
|
||||
```
|
||||
|
||||
**Or via command line:**
|
||||
|
||||
```bash
|
||||
cd examples/offline_inference/text_to_image
|
||||
python text_to_image.py \
|
||||
--model Your-org/your-model \
|
||||
--prompt "a cup of coffee on the table" \
|
||||
--negative_prompt "ugly, unclear" \
|
||||
--cfg_scale 4.0 \
|
||||
--num_inference_steps 50 \
|
||||
--output "tp_enabled.png" \
|
||||
--tensor_parallel_size 2
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
|
||||
1. Check the `e2e_time_ms` in the log for speedup
|
||||
2. Compare generated image quality with TP disabled
|
||||
3. Verify memory usage is reduced proportionally
|
||||
4. Record comparison results in your PR
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: TP not activating
|
||||
|
||||
**Symptoms:** Model runs on single GPU, no memory savings or speedup.
|
||||
|
||||
**Causes & Solutions:**
|
||||
|
||||
- **Still using `nn.Linear`:**
|
||||
|
||||
**Problem:** Linear layers not replaced with parallel equivalents.
|
||||
|
||||
**Solution:** Replace with parallel layers:
|
||||
```python
|
||||
# ❌ BAD
|
||||
self.proj = nn.Linear(dim, dim)
|
||||
|
||||
# ✅ GOOD
|
||||
self.proj = RowParallelLinear(dim, dim, input_is_parallel=True)
|
||||
```
|
||||
|
||||
### Issue: Dimension mismatch errors
|
||||
|
||||
**Symptoms:** `RuntimeError: shape mismatch` during forward pass.
|
||||
|
||||
**Causes & Solutions:**
|
||||
|
||||
- **Missing `input_is_parallel=True`:**
|
||||
|
||||
**Problem:** RowParallelLinear expects sharded input but receives full tensor.
|
||||
|
||||
**Solution:** Set `input_is_parallel=True` when input comes from ColumnParallelLinear:
|
||||
```python
|
||||
# ✅ GOOD: Correct pairing
|
||||
self.w1 = ColumnParallelLinear(dim, hidden_dim, return_bias=False,)
|
||||
self.w2 = RowParallelLinear(
|
||||
hidden_dim,
|
||||
dim,
|
||||
input_is_parallel=True, # Input sharded from w1
|
||||
return_bias=False,
|
||||
)
|
||||
```
|
||||
|
||||
- **Incorrect split dimensions:**
|
||||
|
||||
**Problem:** QKV split sizes don't match sharded dimensions.
|
||||
|
||||
**Solution:** Use `self.to_qkv.num_heads` (local heads per GPU):
|
||||
```python
|
||||
# ❌ BAD: Uses total heads
|
||||
q_size = self.total_num_heads * self.head_dim
|
||||
|
||||
# ✅ GOOD: Uses local heads
|
||||
q_size = self.to_qkv.num_heads * self.head_dim
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reference Implementations
|
||||
|
||||
Complete examples in the codebase:
|
||||
|
||||
| Model | Path | Pattern | Notes |
|
||||
|-------|------|---------|-------|
|
||||
| **Z-Image** | `vllm_omni/diffusion/models/z_image/z_image_transformer.py` | Standard TP | Full implementation with validation |
|
||||
| **FLUX** | `vllm_omni/diffusion/models/flux/flux_transformer.py` | Dual-stream | Image + text streams |
|
||||
| **Qwen-Image** | `vllm_omni/diffusion/models/qwen_image/qwen_image_transformer.py` | Standard TP | With RoPE |
|
||||
| **TP Tests** | `tests/e2e/offline_inference/test_zimage_tensor_parallel.py` | E2E testing | TP correctness and performance |
|
||||
| **Constraint Tests** | `tests/diffusion/models/z_image/test_zimage_tp_constraints.py` | Unit testing | Validation logic |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Adding Tensor Parallel support to a transformer:
|
||||
|
||||
1. ✅ **Identify linear layers** - Which layers should be sharded?
|
||||
2. ✅ **Replace with parallel layers** - Use QKVParallelLinear, ColumnParallelLinear, RowParallelLinear
|
||||
3. ✅ **Validate TP constraints** - Ensure dimensions divisible by TP size
|
||||
4. ✅ **Test** - Verify with `tensor_parallel_size=N`, check memory, speed, and quality
|
||||
@@ -0,0 +1,165 @@
|
||||
|
||||
# Metrics vLLM-Omni:
|
||||
|
||||
You can use these metrics in production to monitor the health and performance of the vLLM-omni system. Typical scenarios include:
|
||||
- **Performance Monitoring**: Track throughput (e.g., `e2e_avg_tokens_per_s`), latency (e.g., `e2e_total_ms`), and resource utilization to verify that the system meets expected standards.
|
||||
- **Debugging and Troubleshooting**: Use detailed per-request metrics to diagnose issues, such as high transfer times or unexpected token counts.
|
||||
|
||||
## How to Enable and View Metrics
|
||||
|
||||
### 1. Start the Service with Metrics Logging
|
||||
|
||||
```bash
|
||||
vllm serve /workspace/models/Qwen3-Omni-30B-A3B-Instruct --omni --port 8014 --log-stats
|
||||
```
|
||||
|
||||
### 2. Send a Request
|
||||
|
||||
```bash
|
||||
python openai_chat_completion_client_for_multimodal_generation.py --query-type use_image
|
||||
```
|
||||
|
||||
### 3. What You Will See
|
||||
|
||||
With `--log-stats` enabled, the server will output detailed metrics logs after each request. Example output:
|
||||
|
||||
|
||||
#### Overall Summary
|
||||
|
||||
| Field | Value |
|
||||
|-----------------------------|--------------|
|
||||
| e2e_requests | 1 |
|
||||
| e2e_wall_time_ms | 41,299.190 |
|
||||
| e2e_total_tokens | 5,202 |
|
||||
| e2e_avg_time_per_request_ms | 41,299.190 |
|
||||
| e2e_avg_tokens_per_s | 125.959 |
|
||||
| e2e_stage_0_wall_time_ms | 10,192.289 |
|
||||
| e2e_stage_1_wall_time_ms | 30,541.409 |
|
||||
| e2e_stage_2_wall_time_ms | 207.496 |
|
||||
|
||||
#### RequestE2EStats
|
||||
|
||||
| Field | Value |
|
||||
|-------------------------|------------|
|
||||
| e2e_total_ms | 41,299.133 |
|
||||
| e2e_total_tokens | 5,202 |
|
||||
| transfers_total_time_ms | 245.895 |
|
||||
| transfers_total_kbytes | 138,089.939|
|
||||
|
||||
#### StageRequestStats
|
||||
|
||||
| Field | 0 | 1 | 2 |
|
||||
|------------------------|--------|--------|--------|
|
||||
| audio_generated_frames | 0 | 0 | 525,525|
|
||||
| batch_id | 38 | 274 | 0 |
|
||||
| batch_size | 1 | 1 | 1 |
|
||||
| num_tokens_in | 4,860 | 4,826 | 4,384 |
|
||||
| num_tokens_out | 67 | 275 | 0 |
|
||||
| postprocess_time_ms | 256.158| 0.491 | 0.000 |
|
||||
| stage_gen_time_ms | 9,910.007|30,379.198|160.745|
|
||||
|
||||
#### TransferEdgeStats
|
||||
|
||||
| Field | 0->1 | 1->2 |
|
||||
|---------------------|-------------|------------|
|
||||
| size_kbytes | 109,277.349 | 28,812.591 |
|
||||
| tx_time_ms | 78.701 | 18.790 |
|
||||
| rx_decode_time_ms | 111.865 | 31.706 |
|
||||
| in_flight_time_ms | 2.015 | 2.819 |
|
||||
|
||||
|
||||
These logs include:
|
||||
- **Overall summary**: total requests, wall time, average tokens/sec, etc.
|
||||
- **E2E table**: per-request latency and token counts.
|
||||
- **Stage table**: per-stage batch and timing details.
|
||||
- **Transfer table**: data transfer and timing for each edge.
|
||||
|
||||
You can use these logs to monitor system health, debug performance, and analyze request-level metrics as described above.
|
||||
|
||||
|
||||
## Metrics Scope: Offline vs Online Inference
|
||||
|
||||
For **offline inference** (batch mode), the summary includes both system-level metrics (aggregated across all requests) and per-request metrics. In this case, `e2e_requests` can be greater than 1, reflecting multiple completed requests in a batch.
|
||||
|
||||
For **online inference** (serving mode), the summary is always per-request. `e2e_requests` is always 1, and only request-level metrics are reported for each completion.
|
||||
|
||||
---
|
||||
|
||||
## Parameter Details
|
||||
|
||||
| Field | Meaning |
|
||||
|---------------------------|----------------------------------------------------------------------------------------------|
|
||||
| `e2e_requests` | Number of completed requests. |
|
||||
| `e2e_wall_time_ms` | Wall-clock time span from run start to last completion, in ms. |
|
||||
| `e2e_total_tokens` | Total tokens counted across all completed requests (stage0 input + all stage outputs). |
|
||||
| `e2e_avg_time_per_request_ms` | Average wall time per request: `e2e_wall_time_ms / e2e_requests`. |
|
||||
| `e2e_avg_tokens_per_s` | Average token throughput over wall time: `e2e_total_tokens * 1000 / e2e_wall_time_ms`. |
|
||||
| `e2e_stage_{i}_wall_time_ms` | Wall-clock time span for stage i, in ms. Each stage's wall time is reported as a separate field, e.g., `e2e_stage_0_wall_time_ms`, `e2e_stage_1_wall_time_ms`, etc. |
|
||||
|
||||
---
|
||||
|
||||
## E2E Table (per request)
|
||||
|
||||
| Field | Meaning |
|
||||
|---------------------------|-----------------------------------------------------------------------|
|
||||
| `e2e_total_ms` | End-to-end latency in ms. |
|
||||
| `e2e_total_tokens` | Total tokens for the request (stage0 input + all stage outputs). |
|
||||
| `transfers_total_time_ms` | Sum of transfer edge `total_time_ms` for this request. |
|
||||
| `transfers_total_kbytes` | Sum of transfer kbytes for this request. |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Stage Table (per stage event / request)
|
||||
|
||||
| Field | Meaning |
|
||||
|---------------------------|-------------------------------------------------------------------------------------------------|
|
||||
| `batch_id` | Batch index. |
|
||||
| `batch_size` | Batch size. |
|
||||
| `num_tokens_in` | Input tokens to the stage. |
|
||||
| `num_tokens_out` | Output tokens from the stage. |
|
||||
| `stage_gen_time_ms` | Stage compute time in ms, excluding postprocessing time (reported separately as `postprocess_time_ms`). |
|
||||
| `image_num` | Number of images generated (for diffusion/image stages). |
|
||||
| `resolution` | Image resolution (for diffusion/image stages). |
|
||||
| `postprocess_time_ms` | Diffusion/image: post-processing time in ms. |
|
||||
|
||||
---
|
||||
|
||||
## Transfer Table (per edge / request)
|
||||
|
||||
| Field | Meaning |
|
||||
|----------------------|---------------------------------------------------------------------------|
|
||||
| `size_kbytes` | Total kbytes transferred. |
|
||||
| `tx_time_ms` | Sender transfer time in ms. |
|
||||
| `rx_decode_time_ms` | Receiver decode time in ms. |
|
||||
| `in_flight_time_ms` | In-flight time in ms. |
|
||||
|
||||
|
||||
## Expectation of the Numbers (Verification)
|
||||
|
||||
**Formulas:**
|
||||
- `e2e_total_tokens = Stage0's num_tokens_in + sum(all stages' num_tokens_out)`
|
||||
- `transfers_total_time_ms = sum(tx_time_ms + rx_decode_time_ms + in_flight_time_ms)` for every edge
|
||||
|
||||
**Using the example above:**
|
||||
|
||||
### e2e_total_tokens
|
||||
- Stage0's `num_tokens_in`: **4,860**
|
||||
- Stage0's `num_tokens_out`: **67**
|
||||
- Stage1's `num_tokens_out`: **275**
|
||||
- Stage2's `num_tokens_out`: **0**
|
||||
|
||||
So,
|
||||
```
|
||||
e2e_total_tokens = 4,860 + 67 + 275 + 0 = 5,202
|
||||
```
|
||||
This matches the table value: `e2e_total_tokens = 5,202`.
|
||||
|
||||
### transfers_total_time_ms
|
||||
For each edge:
|
||||
- 0->1: tx_time_ms (**78.701**) + rx_decode_time_ms (**111.865**) + in_flight_time_ms (**2.015**) = **192.581**
|
||||
- 1->2: tx_time_ms (**18.790**) + rx_decode_time_ms (**31.706**) + in_flight_time_ms (**2.819**) = **53.315**
|
||||
|
||||
Sum: 192.581 + 53.315 = **245.896**
|
||||
|
||||
The table shows `transfers_total_time_ms = 245.895`, which matches the calculation (difference is due to rounding).
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,70 @@
|
||||
# vLLM-Omni ComfyUI Integration
|
||||
|
||||
vLLM-Omni offers a ComfyUI integration on top of its online serving API.
|
||||
It can send model inference requests to either a locally running vLLM-Omni service or a remote one.
|
||||
|
||||
## Requirement
|
||||
|
||||
- Python 3.12 or above
|
||||
- [ComfyUI installed](https://docs.comfy.org/installation/system_requirements)
|
||||
- [vLLM-Omni installed](https://docs.vllm.ai/projects/vllm-omni/en/latest/getting_started/installation/) on either the same device or another device discoverable via the internet.
|
||||
- No need to install additional packages apart from those already required by ComfyUI.
|
||||
|
||||
!!! tip
|
||||
If you run both ComfyUI and vLLM-Omni on the same device, you can create separate virtual environments and use different Python versions for them.
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
Copy the `apps/ComfyUI-vLLM-Omni` folder to the `custom_nodes` subfolder of your ComfyUI installation. Your directory should look like `ComfyUI/custom_nodes/ComfyUI-vLLM-Omni`.
|
||||
|
||||
If you are running ComfyUI during copying, you should restart ComfyUI to load this extension.
|
||||
|
||||
!!! tip
|
||||
You can use utility websites such as https://download-directory.github.io/ to download a subdirectory of a repo. Also checkout community discussions (e.g., https://stackoverflow.com/questions/7106012/download-a-single-folder-or-directory-from-a-github-repository) for more info.
|
||||
|
||||
On the device and virtual environment you run ComfyUI, launch ComfyUI with
|
||||
```bash
|
||||
cd ComfyUI
|
||||
|
||||
# The regular way
|
||||
python main.py
|
||||
|
||||
# If you are mainly using this node, launch it faster with
|
||||
python main.py --cpu
|
||||
```
|
||||
|
||||
On the device and virtual environment you run vLLM-Omni, start a model service with
|
||||
```bash
|
||||
vllm serve The_Model_ID_to_Serve --omni --port 8000
|
||||
```
|
||||
|
||||
Check **ComfyUI's sidebar -> Node Library**. There should be a new folder named **vLLM-Omni**.
|
||||
If no, check your shell running the ComfyUI process. There may be some error messages before the line `Import times for custom nodes:` and the line `To see the GUI go to: http://127.0.0.1:8188`.
|
||||
|
||||
## Quickstart
|
||||
|
||||
This extension offers the following nodes based on the output modalities:
|
||||
|
||||
- **Generate Image** for text-to-image and image-to-image tasks
|
||||
- **Multimodality Comprehension** for multimodality-to-text and multimodality-to-audio tasks
|
||||
- **TTS** and **TTS Voice Clone** for TTS tasks
|
||||
|
||||
This extension also offers example workflows (at **ComfyUI sidebar -> Templates -> vLLM-Omni**)
|
||||
|
||||
!!! info
|
||||
The node UI and feature designs are intended to match vLLM-Omni online serving interfaces. It cannot offer more than what the interfaces support.
|
||||
|
||||
To build a simple workflow yourself,
|
||||
|
||||
- Drag a generation node onto the canvas.
|
||||
- Depending on your need, grab built-in multimedia file loader nodes, such as **image->Load Image**, **image->video->Load Video**, **audio->Load Audio**
|
||||
- Depending on your need, grab built-in multimedia file preview nodes, such as **image->Preview Image**, **image->video->Save Video**, **audio->Preview Audio**. For text output, you can install [ComfyUI-Custom-Scripts plugin](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/) and grab its **utils->Show Text 🐍** node.
|
||||
- If you want to tune sampling parameters, grab corresponding nodes from **vLLM-Omni-> Sampling Params**.
|
||||
- For multi-stage models, you can connect multiple **AR Sampling Params** and **Diffusion Sampling Params** nodes to a **Multi-Stage Sampling Params List** node, and connect this node to the generation node.
|
||||
- For some multi-stage models like BAGEL, [only one stage's sampling parameters are exposed and tunable via vLLM-Omni's online serving API](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/examples/online_serving/bagel/). Thus, these models are treated as single-stage ones. Please check the vLLM-Omni documentation on how to correctly set each model's sampling parameters.
|
||||
- For multi-stage models where all stages are either autoregression or diffusion, you can also connect only a single Sampling Params node, indicating that this set of sampling parameters will be used for all stages.
|
||||
|
||||
## Examples & Screenshots
|
||||
|
||||
Please read the [ComfyUI integration's README](https://github.com/vllm-project/vllm-omni/tree/main/apps/ComfyUI-vLLM-Omni) for more info.
|
||||
@@ -20,6 +20,7 @@ th {
|
||||
| `Qwen3OmniMoeForConditionalGeneration` | Qwen3-Omni | `Qwen/Qwen3-Omni-30B-A3B-Instruct` |
|
||||
| `Qwen2_5OmniForConditionalGeneration` | Qwen2.5-Omni | `Qwen/Qwen2.5-Omni-7B`, `Qwen/Qwen2.5-Omni-3B` |
|
||||
| `BagelForConditionalGeneration` | BAGEL (DiT-only) | `ByteDance-Seed/BAGEL-7B-MoT` |
|
||||
| `HunyuanImage3ForCausalMM` | HunyuanImage3.0 (DiT-only) | `tencent/HunyuanImage-3.0`, `tencent/HunyuanImage-3.0-Instruct` |
|
||||
| `QwenImagePipeline` | Qwen-Image | `Qwen/Qwen-Image` |
|
||||
| `QwenImagePipeline` | Qwen-Image-2512 | `Qwen/Qwen-Image-2512` |
|
||||
| `QwenImageEditPipeline` | Qwen-Image-Edit | `Qwen/Qwen-Image-Edit` |
|
||||
|
||||
@@ -233,143 +233,6 @@ To measure the parallelism methods, we run benchmarks with **Qwen/Qwen-Image** m
|
||||
| Hybrid Ulysses + Ring | 2 | 2 | 24.3s | 1.87x |
|
||||
|
||||
|
||||
##### How to parallelize a new model
|
||||
|
||||
NOTE: "Terminology: SP vs CP"
|
||||
Our "Sequence Parallelism" (SP) corresponds to "Context Parallelism" (CP) in the [diffusers library](https://github.com/huggingface/diffusers/blob/main/src/diffusers/models/_modeling_parallel.py).
|
||||
We use "Sequence Parallelism" to align with vLLM-Omni's terminology.
|
||||
|
||||
---
|
||||
|
||||
###### Non-intrusive `_sp_plan` (Recommended)
|
||||
|
||||
The `_sp_plan` mechanism allows SP without modifying `forward()` logic. The framework automatically registers hooks to shard inputs and gather outputs at module boundaries.
|
||||
|
||||
**Requirements for `forward()` function:**
|
||||
|
||||
- Tensor operations that need sharding/gathering must happen at **`nn.Module` boundaries** (not inline Python operations)
|
||||
- If your `forward()` contains inline tensor operations (e.g., `torch.cat`, `pad_sequence`) that need sharding, **extract them into a submodule**
|
||||
|
||||
**When to create a submodule:**
|
||||
|
||||
```python
|
||||
# ❌ BAD: Inline operations - hooks cannot intercept
|
||||
def forward(self, x, cap_feats):
|
||||
unified = torch.cat([x, cap_feats], dim=1) # Cannot be sharded via _sp_plan
|
||||
...
|
||||
|
||||
# ✅ GOOD: Extract into a submodule
|
||||
class UnifiedPrepare(nn.Module):
|
||||
def forward(self, x, cap_feats):
|
||||
return torch.cat([x, cap_feats], dim=1) # Now can be sharded via _sp_plan
|
||||
|
||||
class MyModel(nn.Module):
|
||||
def __init__(self):
|
||||
self.unified_prepare = UnifiedPrepare() # Submodule
|
||||
|
||||
def forward(self, x, cap_feats):
|
||||
unified = self.unified_prepare(x, cap_feats) # Hook can intercept here
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
###### Defining `_sp_plan`
|
||||
|
||||
**Type definitions** (see [diffusers `_modeling_parallel.py`](https://github.com/huggingface/diffusers/blob/main/src/diffusers/models/_modeling_parallel.py) for reference):
|
||||
|
||||
```python
|
||||
from vllm_omni.diffusion.distributed.sp_plan import (
|
||||
SequenceParallelInput, # Corresponds to diffusers' ContextParallelInput
|
||||
SequenceParallelOutput, # Corresponds to diffusers' ContextParallelOutput
|
||||
)
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `split_dim` | Dimension to split/gather (usually `1` for sequence) |
|
||||
| `expected_dims` | Expected tensor rank for validation (optional) |
|
||||
| `split_output` | `False`: shard **input** parameters; `True`: shard **output** tensors |
|
||||
| `auto_pad` | Auto-pad if sequence not divisible by world_size (Ulysses only) |
|
||||
|
||||
**Key naming convention:**
|
||||
|
||||
| Key | Meaning | Python equivalent |
|
||||
|-----|---------|-------------------|
|
||||
| `""` | Root model | `model` |
|
||||
| `"blocks.0"` | First element of ModuleList | `model.blocks[0]` |
|
||||
| `"blocks.*"` | All elements of ModuleList | `for b in model.blocks` |
|
||||
| `"outputs.main"` | ModuleDict entry | `model.outputs["main"]` |
|
||||
|
||||
**Dictionary key types:**
|
||||
|
||||
| Key type | `split_output` | Description |
|
||||
|----------|----------------|-------------|
|
||||
| `"param_name"` (str) | `False` | Shard **input parameter** by name |
|
||||
| `0`, `1` (int) | `True` | Shard **output tuple** by index |
|
||||
|
||||
**Example** (similar to [diffusers `transformer_wan.py`](https://github.com/huggingface/diffusers/blob/main/src/diffusers/models/transformers/transformer_wan.py)):
|
||||
|
||||
```python
|
||||
class MyTransformer(nn.Module):
|
||||
_sp_plan = {
|
||||
# Shard rope module OUTPUTS (returns tuple)
|
||||
"rope": {
|
||||
0: SequenceParallelInput(split_dim=1, expected_dims=4, split_output=True), # cos
|
||||
1: SequenceParallelInput(split_dim=1, expected_dims=4, split_output=True), # sin
|
||||
},
|
||||
# Shard transformer block INPUT parameter
|
||||
"blocks.0": {
|
||||
"hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3),
|
||||
},
|
||||
# Gather at final projection
|
||||
"proj_out": SequenceParallelOutput(gather_dim=1, expected_dims=3),
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
###### Hook flow
|
||||
|
||||
```
|
||||
Input → [SequenceParallelSplitHook: pre_forward] → Module.forward() → [post_forward] → ...
|
||||
↓
|
||||
... → [SequenceParallelGatherHook: post_forward] → Output
|
||||
```
|
||||
|
||||
1. **SplitHook** shards tensors before/after the target module
|
||||
2. **Attention layers** handle Ulysses/Ring communication internally
|
||||
3. **GatherHook** collects sharded outputs
|
||||
|
||||
The framework automatically applies these hooks when `sequence_parallel_size > 1`.
|
||||
|
||||
---
|
||||
|
||||
###### Method 2: Intrusive modification (For complex cases)
|
||||
|
||||
For models with dynamic sharding logic that cannot be expressed via `_sp_plan`:
|
||||
|
||||
```python
|
||||
from vllm_omni.diffusion.distributed.sp_sharding import sp_shard, sp_gather
|
||||
|
||||
def forward(self, hidden_states, ...):
|
||||
if self.parallel_config.sequence_parallel_size > 1:
|
||||
hidden_states = sp_shard(hidden_states, dim=1)
|
||||
# ... computation ...
|
||||
output = sp_gather(output, dim=1)
|
||||
return output
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
###### Choosing the right approach
|
||||
|
||||
| Scenario | Approach |
|
||||
|----------|----------|
|
||||
| Standard transformer | `_sp_plan` |
|
||||
| Inline tensor ops need sharding | Extract to submodule + `_sp_plan` |
|
||||
| Dynamic/conditional sharding | Intrusive modification |
|
||||
|
||||
|
||||
### CFG-Parallel
|
||||
|
||||
#### Offline Inference
|
||||
@@ -426,142 +289,3 @@ You can enable CFG-Parallel in online serving for diffusion models via `--cfg-pa
|
||||
```bash
|
||||
vllm serve Qwen/Qwen-Image-Edit --omni --port 8091 --cfg-parallel-size 2
|
||||
```
|
||||
|
||||
#### How to parallelize a pipeline
|
||||
|
||||
This section describes how to add CFG-Parallel to a diffusion **pipeline**. We use the Qwen-Image pipeline (`vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image.py`) as the reference implementation.
|
||||
|
||||
In `QwenImagePipeline`, each diffusion step runs two denoiser forward passes sequentially:
|
||||
|
||||
- positive (prompt-conditioned)
|
||||
- negative (negative-prompt-conditioned)
|
||||
|
||||
CFG-Parallel assigns these two branches to different ranks in the **CFG group** and synchronizes the results.
|
||||
|
||||
vLLM-omni provides `CFGParallelMixin` base class that encapsulates the CFG parallel logic. By inheriting from this mixin and calling its methods, pipelines can easily implement CFG parallel without writing repetitive code.
|
||||
|
||||
**Key Methods in CFGParallelMixin:**
|
||||
- `predict_noise_maybe_with_cfg()`: Automatically handles CFG parallel noise prediction
|
||||
- `scheduler_step_maybe_with_cfg()`: Scheduler step with automatic CFG rank synchronization
|
||||
|
||||
**Example Implementation:**
|
||||
|
||||
```python
|
||||
class QwenImageCFGParallelMixin(CFGParallelMixin):
|
||||
"""
|
||||
Base Mixin class for Qwen Image pipelines providing shared CFG methods.
|
||||
"""
|
||||
|
||||
def diffuse(
|
||||
self,
|
||||
prompt_embeds: torch.Tensor,
|
||||
prompt_embeds_mask: torch.Tensor,
|
||||
negative_prompt_embeds: torch.Tensor,
|
||||
negative_prompt_embeds_mask: torch.Tensor,
|
||||
latents: torch.Tensor,
|
||||
img_shapes: torch.Tensor,
|
||||
txt_seq_lens: torch.Tensor,
|
||||
negative_txt_seq_lens: torch.Tensor,
|
||||
timesteps: torch.Tensor,
|
||||
do_true_cfg: bool,
|
||||
guidance: torch.Tensor,
|
||||
true_cfg_scale: float,
|
||||
image_latents: torch.Tensor | None = None,
|
||||
cfg_normalize: bool = True,
|
||||
additional_transformer_kwargs: dict[str, Any] | None = None,
|
||||
) -> torch.Tensor:
|
||||
self.transformer.do_true_cfg = do_true_cfg
|
||||
|
||||
for i, t in enumerate(timesteps):
|
||||
timestep = t.expand(latents.shape[0]).to(device=latents.device, dtype=latents.dtype)
|
||||
|
||||
# Prepare kwargs for positive (conditional) prediction
|
||||
positive_kwargs = {
|
||||
"hidden_states": latents,
|
||||
"timestep": timestep / 1000,
|
||||
"guidance": guidance,
|
||||
"encoder_hidden_states_mask": prompt_embeds_mask,
|
||||
"encoder_hidden_states": prompt_embeds,
|
||||
"img_shapes": img_shapes,
|
||||
"txt_seq_lens": txt_seq_lens,
|
||||
}
|
||||
|
||||
# Prepare kwargs for negative (unconditional) prediction
|
||||
if do_true_cfg:
|
||||
negative_kwargs = {
|
||||
"hidden_states": latents,
|
||||
"timestep": timestep / 1000,
|
||||
"guidance": guidance,
|
||||
"encoder_hidden_states_mask": negative_prompt_embeds_mask,
|
||||
"encoder_hidden_states": negative_prompt_embeds,
|
||||
"img_shapes": img_shapes,
|
||||
"txt_seq_lens": negative_txt_seq_lens,
|
||||
}
|
||||
else:
|
||||
negative_kwargs = None
|
||||
|
||||
# Predict noise with automatic CFG parallel handling
|
||||
# - In CFG parallel mode: rank0 computes positive, rank1 computes negative
|
||||
# - Automatically gathers results and combines them on rank0
|
||||
noise_pred = self.predict_noise_maybe_with_cfg(
|
||||
do_true_cfg=do_true_cfg,
|
||||
true_cfg_scale=true_cfg_scale,
|
||||
positive_kwargs=positive_kwargs,
|
||||
negative_kwargs=negative_kwargs,
|
||||
cfg_normalize=cfg_normalize,
|
||||
)
|
||||
|
||||
# Step scheduler with automatic CFG synchronization
|
||||
# - Only rank0 computes the scheduler step
|
||||
# - Automatically broadcasts updated latents to all ranks
|
||||
latents = self.scheduler_step_maybe_with_cfg(
|
||||
noise_pred, t, latents, do_true_cfg
|
||||
)
|
||||
|
||||
return latents
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
1. Prepare separate `positive_kwargs` and `negative_kwargs` for conditional and unconditional predictions
|
||||
2. Call `predict_noise_maybe_with_cfg()` which:
|
||||
- Detects if CFG parallel is enabled (`get_classifier_free_guidance_world_size() > 1`)
|
||||
- Distributes computation: rank0 processes positive, rank1 processes negative
|
||||
- Gathers predictions and combines them using `combine_cfg_noise()` on rank0
|
||||
- Returns combined noise prediction (only valid on rank0)
|
||||
3. Call `scheduler_step_maybe_with_cfg()` which:
|
||||
- Only rank0 computes the scheduler step
|
||||
- Broadcasts the updated latents to all ranks for synchronization
|
||||
|
||||
**How to customize**
|
||||
|
||||
Some pipelines may need to customize the following functions in `CFGParallelMixin`:
|
||||
1. You may need to edit `predict_noise` function for custom behaviors.
|
||||
```python
|
||||
def predict_noise(self, *args, **kwargs):
|
||||
"""
|
||||
Forward pass through transformer to predict noise.
|
||||
|
||||
Subclasses should override this if they need custom behavior,
|
||||
but the default implementation calls self.transformer.
|
||||
"""
|
||||
return self.transformer(*args, **kwargs)[0]
|
||||
|
||||
```
|
||||
2. The default normalization function after combining the noise predictions from both branches is as follows. You may need to customize it.
|
||||
```python
|
||||
def cfg_normalize_function(self, noise_pred, comb_pred):
|
||||
"""
|
||||
Normalize the combined noise prediction.
|
||||
|
||||
Args:
|
||||
noise_pred: positive noise prediction
|
||||
comb_pred: combined noise prediction after CFG
|
||||
|
||||
Returns:
|
||||
Normalized noise prediction tensor
|
||||
"""
|
||||
cond_norm = torch.norm(noise_pred, dim=-1, keepdim=True)
|
||||
noise_norm = torch.norm(comb_pred, dim=-1, keepdim=True)
|
||||
noise_pred = comb_pred * (cond_norm / noise_norm)
|
||||
return noise_pred
|
||||
```
|
||||
@@ -36,7 +36,7 @@ def parse_args():
|
||||
)
|
||||
|
||||
# OmniLLM init args
|
||||
parser.add_argument("--enable-stats", action="store_true", default=False)
|
||||
parser.add_argument("--log-stats", action="store_true", default=False)
|
||||
parser.add_argument("--init-sleep-seconds", type=int, default=20)
|
||||
parser.add_argument("--batch-timeout", type=int, default=5)
|
||||
parser.add_argument("--init-timeout", type=int, default=300)
|
||||
@@ -120,7 +120,7 @@ def main():
|
||||
|
||||
omni_kwargs.update(
|
||||
{
|
||||
"log_stats": args.enable_stats,
|
||||
"log_stats": args.log_stats,
|
||||
"init_sleep_seconds": args.init_sleep_seconds,
|
||||
"batch_timeout": args.batch_timeout,
|
||||
"init_timeout": args.init_timeout,
|
||||
|
||||
@@ -322,7 +322,7 @@ def main(args):
|
||||
query_result = query_func()
|
||||
omni_llm = Omni(
|
||||
model=model_name,
|
||||
log_stats=args.enable_stats,
|
||||
log_stats=args.log_stats,
|
||||
stage_init_timeout=args.stage_init_timeout,
|
||||
batch_timeout=args.batch_timeout,
|
||||
init_timeout=args.init_timeout,
|
||||
@@ -439,7 +439,7 @@ def parse_args():
|
||||
help="Query type.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--enable-stats",
|
||||
"--log-stats",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Enable writing detailed statistics (default: disabled)",
|
||||
|
||||
@@ -297,7 +297,7 @@ def main(args):
|
||||
omni_llm = Omni(
|
||||
model=model_name,
|
||||
stage_configs_path=args.stage_configs_path,
|
||||
log_stats=args.enable_stats,
|
||||
log_stats=args.log_stats,
|
||||
stage_init_timeout=args.stage_init_timeout,
|
||||
)
|
||||
|
||||
@@ -424,7 +424,7 @@ def parse_args():
|
||||
help="Query type.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--enable-stats",
|
||||
"--log-stats",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Enable writing detailed statistics (default: disabled)",
|
||||
|
||||
@@ -219,7 +219,7 @@ def main(args):
|
||||
omni = Omni(
|
||||
model=model_name,
|
||||
stage_configs_path=args.stage_configs_path,
|
||||
log_stats=args.enable_stats,
|
||||
log_stats=args.log_stats,
|
||||
stage_init_timeout=args.stage_init_timeout,
|
||||
)
|
||||
|
||||
@@ -275,7 +275,7 @@ def parse_args():
|
||||
help="Query type.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--enable-stats",
|
||||
"--log-stats",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Enable writing detailed statistics (default: disabled)",
|
||||
|
||||
@@ -175,6 +175,10 @@ markers = [
|
||||
"slow: Slow tests (may skip in quick CI)",
|
||||
"benchmark: Benchmark tests",
|
||||
]
|
||||
filterwarnings = [
|
||||
"ignore:.*does not have '__test__' attribute.*:UserWarning",
|
||||
"ignore:.*does not have '__bases__' attribute.*:UserWarning",
|
||||
]
|
||||
|
||||
[tool.typos.default]
|
||||
extend-ignore-identifiers-re = [
|
||||
@@ -184,6 +188,7 @@ extend-ignore-identifiers-re = [
|
||||
".*arange",
|
||||
".*MoBA",
|
||||
".*temperal_downsample",
|
||||
".*_nd",
|
||||
".*nothink.*",
|
||||
".*NOTHINK.*",
|
||||
".*nin.*",
|
||||
|
||||
@@ -12,3 +12,4 @@ torchsde>=0.2.6
|
||||
openai-whisper>=20250625
|
||||
imageio[ffmpeg]>=2.37.2
|
||||
sox>=1.5.0
|
||||
prettytable>=3.8.0
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from tests.conftest import OmniServer
|
||||
from tests.utils import hardware_test
|
||||
|
||||
models = ["Qwen/Qwen3-Omni-30B-A3B-Instruct"]
|
||||
stage_configs = [str(Path(__file__).parent.parent / "e2e" / "stage_configs" / "qwen3_omni_ci.yaml")]
|
||||
@@ -29,6 +30,9 @@ def omni_server(request):
|
||||
print("OmniServer stopped")
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.benchmark
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=2)
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_bench_serve_chat(omni_server):
|
||||
command = [
|
||||
|
||||
+18
-15
@@ -588,8 +588,6 @@ def convert_audio_to_text(audio_data):
|
||||
"""
|
||||
Convert base64 encoded audio data to text using speech recognition.
|
||||
"""
|
||||
import whisper
|
||||
|
||||
audio_data = base64.b64decode(audio_data)
|
||||
output_path = f"./test_{int(time.time())}"
|
||||
with open(output_path, "wb") as audio_file:
|
||||
@@ -597,6 +595,13 @@ def convert_audio_to_text(audio_data):
|
||||
|
||||
print(f"audio data is saved: {output_path}")
|
||||
|
||||
text = convert_audio_file_to_text(output_path=output_path)
|
||||
return text
|
||||
|
||||
|
||||
def convert_audio_file_to_text(output_path):
|
||||
import whisper
|
||||
|
||||
model = whisper.load_model("base")
|
||||
text = model.transcribe(
|
||||
output_path,
|
||||
@@ -604,6 +609,10 @@ def convert_audio_to_text(audio_data):
|
||||
word_timestamps=True,
|
||||
condition_on_previous_text=False,
|
||||
)["text"]
|
||||
del model
|
||||
if torch.cuda.is_available():
|
||||
gc.collect()
|
||||
torch.cuda.empty_cache()
|
||||
if text:
|
||||
return text
|
||||
else:
|
||||
@@ -614,7 +623,6 @@ def merge_base64_and_convert_to_text(base64_list):
|
||||
"""
|
||||
Merge a list of base64 encoded audio data and convert to text.
|
||||
"""
|
||||
import whisper
|
||||
from pydub import AudioSegment
|
||||
|
||||
merged_audio = None
|
||||
@@ -627,17 +635,8 @@ def merge_base64_and_convert_to_text(base64_list):
|
||||
merged_audio += seg
|
||||
output_path = f"./test_{int(time.time())}"
|
||||
merged_audio.export(output_path, format="wav")
|
||||
model = whisper.load_model("base")
|
||||
text = model.transcribe(
|
||||
output_path,
|
||||
temperature=0.0,
|
||||
word_timestamps=True,
|
||||
condition_on_previous_text=False,
|
||||
)["text"]
|
||||
if text:
|
||||
return text
|
||||
else:
|
||||
return ""
|
||||
text = convert_audio_file_to_text(output_path)
|
||||
return text
|
||||
|
||||
|
||||
def modify_stage_config(
|
||||
@@ -886,6 +885,7 @@ class OmniServer:
|
||||
model: str,
|
||||
serve_args: list[str],
|
||||
*,
|
||||
port: int | None = None,
|
||||
env_dict: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
_run_pre_test_cleanup(enable_force=True)
|
||||
@@ -896,7 +896,10 @@ class OmniServer:
|
||||
self.env_dict = env_dict
|
||||
self.proc: subprocess.Popen | None = None
|
||||
self.host = "127.0.0.1"
|
||||
self.port = get_open_port()
|
||||
if port is None:
|
||||
self.port = get_open_port()
|
||||
else:
|
||||
self.port = port
|
||||
|
||||
def _start_server(self) -> None:
|
||||
"""Start the vLLM-Omni server subprocess."""
|
||||
|
||||
@@ -22,6 +22,8 @@ from vllm_omni.diffusion.cache.selector import get_cache_backend
|
||||
from vllm_omni.diffusion.cache.teacache.backend import TeaCacheBackend
|
||||
from vllm_omni.diffusion.data import DiffusionCacheConfig
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class TestCacheDiTBackend:
|
||||
"""Test CacheDiTBackend implementation."""
|
||||
|
||||
@@ -5,10 +5,13 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.lora.layers.base_linear import DiffusionBaseLinearLayerWithLoRA
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DummyLoRAConfig:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from vllm.lora.lora_weights import LoRALayerWeights
|
||||
from vllm.lora.utils import get_supported_lora_modules
|
||||
@@ -11,6 +12,8 @@ from vllm.model_executor.layers.linear import LinearBase
|
||||
from vllm_omni.diffusion.lora.manager import DiffusionLoRAManager
|
||||
from vllm_omni.lora.request import LoRARequest
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _DummyLoRALayer:
|
||||
def __init__(self, n_slices: int, output_slices: tuple[int, ...]):
|
||||
|
||||
@@ -17,6 +17,8 @@ import torch
|
||||
|
||||
from vllm_omni.diffusion.worker.diffusion_worker import DiffusionWorker
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_od_config():
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.utils import hardware_test
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
from vllm_omni.distributed.omni_connectors.kv_transfer_manager import (
|
||||
OmniKVCacheConfig,
|
||||
@@ -9,6 +8,8 @@ from vllm_omni.distributed.omni_connectors.kv_transfer_manager import (
|
||||
)
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.cache]
|
||||
|
||||
|
||||
class MockConnector:
|
||||
def __init__(self):
|
||||
@@ -58,11 +59,6 @@ def common_constants():
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.cache
|
||||
@hardware_test(
|
||||
res={"cuda": "L4"},
|
||||
num_cards=2,
|
||||
)
|
||||
def test_manager_extraction(kv_config, mock_connector, common_constants):
|
||||
"""Test extraction and sending logic in OmniKVTransferManager."""
|
||||
num_layers = common_constants["num_layers"]
|
||||
@@ -109,11 +105,6 @@ def test_manager_extraction(kv_config, mock_connector, common_constants):
|
||||
assert data["layer_blocks"]["key_cache"][0].shape == expected_shape
|
||||
|
||||
|
||||
@pytest.mark.cache
|
||||
@hardware_test(
|
||||
res={"cuda": "L4"},
|
||||
num_cards=2,
|
||||
)
|
||||
def test_manager_reception(kv_config, mock_connector, common_constants):
|
||||
"""Test reception and injection logic in OmniKVTransferManager."""
|
||||
num_layers = common_constants["num_layers"]
|
||||
@@ -171,11 +162,6 @@ def test_manager_reception(kv_config, mock_connector, common_constants):
|
||||
assert req.kv_metadata["seq_len"] == seq_len
|
||||
|
||||
|
||||
@pytest.mark.cache
|
||||
@hardware_test(
|
||||
res={"cuda": "L4"},
|
||||
num_cards=2,
|
||||
)
|
||||
def test_integration_flow(common_constants):
|
||||
"""Simulate extraction -> connector -> reception."""
|
||||
num_layers = common_constants["num_layers"]
|
||||
@@ -211,7 +197,8 @@ def test_integration_flow(common_constants):
|
||||
recv_timeout=1.0,
|
||||
)
|
||||
receiver_manager = OmniKVTransferManager(receiver_config)
|
||||
receiver_manager._connector = connector # Share the same mock connector instance
|
||||
# Share the same mock connector instance
|
||||
receiver_manager._connector = connector
|
||||
|
||||
req = OmniDiffusionRequest(
|
||||
prompts=["test_integ"],
|
||||
@@ -228,11 +215,6 @@ def test_integration_flow(common_constants):
|
||||
assert req.kv_metadata["seq_len"] == 10
|
||||
|
||||
|
||||
@pytest.mark.cache
|
||||
@hardware_test(
|
||||
res={"cuda": "L4"},
|
||||
num_cards=2,
|
||||
)
|
||||
def test_manager_extraction_no_connector(kv_config, common_constants):
|
||||
"""Test extraction when connector is unavailable (should still return IDs)."""
|
||||
block_size = common_constants["block_size"]
|
||||
|
||||
@@ -15,6 +15,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.utils import hardware_test
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
# ruff: noqa: E402
|
||||
@@ -32,6 +33,10 @@ os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "1"
|
||||
models = ["riverclouds/qwen_image_random"]
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.cache
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"})
|
||||
@pytest.mark.parametrize("model_name", models)
|
||||
def test_cache_dit(model_name: str):
|
||||
"""Test cache-dit backend with diffusion model."""
|
||||
|
||||
@@ -5,7 +5,7 @@ import pytest
|
||||
import torch
|
||||
from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
|
||||
|
||||
from tests.utils import GPUMemoryMonitor
|
||||
from tests.utils import GPUMemoryMonitor, hardware_test
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
@@ -45,6 +45,9 @@ def inference(model_name: str, offload: bool = True):
|
||||
return peak
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.diffusion
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"})
|
||||
@pytest.mark.skipif(current_omni_platform.is_npu() or current_omni_platform.is_rocm(), reason="Hardware not supported")
|
||||
@pytest.mark.parametrize("model_name", models)
|
||||
def test_cpu_offload_diffusion_model(model_name: str):
|
||||
|
||||
@@ -13,10 +13,10 @@ from vllm.assets.video import VideoAsset
|
||||
from vllm.envs import VLLM_USE_MODELSCOPE
|
||||
from vllm.multimodal.image import convert_image_mode
|
||||
|
||||
from tests.utils import create_new_process_for_each_test, hardware_test
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
from .conftest import OmniRunner
|
||||
from .utils import create_new_process_for_each_test
|
||||
|
||||
models = ["Qwen/Qwen2.5-Omni-3B"]
|
||||
|
||||
@@ -34,8 +34,10 @@ test_params = [(model, stage_config) for model in models]
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.parametrize("test_config", test_params)
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards={"cuda": 4, "rocm": 2})
|
||||
@create_new_process_for_each_test("spawn")
|
||||
@pytest.mark.parametrize("test_config", test_params)
|
||||
def test_mixed_modalities_to_audio(omni_runner: type[OmniRunner], test_config: tuple[str, str]) -> None:
|
||||
"""Test processing audio, image, and video together, generating audio output."""
|
||||
model, stage_config_path = test_config
|
||||
@@ -94,8 +96,10 @@ def test_mixed_modalities_to_audio(omni_runner: type[OmniRunner], test_config: t
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.parametrize("test_config", test_params)
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards={"cuda": 4, "rocm": 2})
|
||||
@create_new_process_for_each_test("spawn")
|
||||
@pytest.mark.parametrize("test_config", test_params)
|
||||
def test_mixed_modalities_to_text_only(omni_runner: type[OmniRunner], test_config: tuple[str, str]) -> None:
|
||||
"""Test processing audio, image, and video together, generating audio output."""
|
||||
model, stage_config_path = test_config
|
||||
|
||||
@@ -14,6 +14,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
from vllm.assets.video import VideoAsset
|
||||
|
||||
from tests.utils import hardware_test
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
from .conftest import OmniRunner
|
||||
@@ -31,6 +32,9 @@ else:
|
||||
test_params = [(model, stage_config) for model in models for stage_config in stage_configs]
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
|
||||
@pytest.mark.parametrize("test_config", test_params)
|
||||
def test_video_to_audio(omni_runner: type[OmniRunner], test_config) -> None:
|
||||
"""Test processing video, generating audio output."""
|
||||
|
||||
@@ -20,16 +20,17 @@ import torch
|
||||
import torch.distributed as dist
|
||||
from PIL import Image
|
||||
|
||||
from tests.utils import hardware_test
|
||||
from vllm_omni import Omni
|
||||
from vllm_omni.diffusion.data import DiffusionParallelConfig
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
# ruff: noqa: E402
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from vllm_omni import Omni
|
||||
from vllm_omni.diffusion.data import DiffusionParallelConfig
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
# Test configuration
|
||||
MODELS = ["riverclouds/qwen_image_random"]
|
||||
@@ -144,10 +145,20 @@ def _run_inference(
|
||||
# SP configurations: (ulysses_degree, ring_degree, height, width, warmup, is_perf_test)
|
||||
# - warmup: whether to run warmup for this SP config
|
||||
# - is_perf_test: whether this is a performance test (show speedup metrics)
|
||||
SP_CONFIGS = [
|
||||
(2, 1, DEFAULT_HEIGHT, DEFAULT_WIDTH, True, True), # Ulysses-2 - performance test
|
||||
SP_CONFIGS_L2 = [
|
||||
# Ulysses-2 - performance test
|
||||
(2, 1, DEFAULT_HEIGHT, DEFAULT_WIDTH, True, True),
|
||||
(1, 2, DEFAULT_HEIGHT, DEFAULT_WIDTH, True, True), # Ring-2 - performance test
|
||||
(2, 2, DEFAULT_HEIGHT, DEFAULT_WIDTH, False, False), # Hybrid - correctness only
|
||||
# Hybrid - correctness only
|
||||
(2, 2, DEFAULT_HEIGHT, DEFAULT_WIDTH, False, False),
|
||||
]
|
||||
|
||||
SP_CONFIGS_L3 = [
|
||||
# Ulysses-2 - performance test
|
||||
(2, 1, DEFAULT_HEIGHT, DEFAULT_WIDTH, True, True),
|
||||
(1, 2, DEFAULT_HEIGHT, DEFAULT_WIDTH, True, True), # Ring-2 - performance test
|
||||
# Hybrid - correctness only
|
||||
(2, 2, DEFAULT_HEIGHT, DEFAULT_WIDTH, False, False),
|
||||
(4, 1, 272, 272, False, False), # Ulysses-4 - shape and correctness
|
||||
]
|
||||
|
||||
@@ -162,6 +173,10 @@ def _get_sp_mode(ulysses_degree: int, ring_degree: int) -> str:
|
||||
return f"hybrid-{ulysses_degree}x{ring_degree}"
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parallel
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards={"cuda": 2, "rocm": 2})
|
||||
@pytest.mark.parametrize("model_name", MODELS)
|
||||
def test_sp_correctness(model_name: str):
|
||||
"""Test that SP inference produces correct outputs and measure performance.
|
||||
@@ -184,7 +199,130 @@ def test_sp_correctness(model_name: str):
|
||||
print(f"Available GPUs: {device_count}")
|
||||
print("=" * 70)
|
||||
|
||||
for ulysses_degree, ring_degree, height, width, sp_warmup, is_perf_test in SP_CONFIGS:
|
||||
for ulysses_degree, ring_degree, height, width, sp_warmup, is_perf_test in SP_CONFIGS_L2:
|
||||
sp_size = ulysses_degree * ring_degree
|
||||
sp_mode = _get_sp_mode(ulysses_degree, ring_degree)
|
||||
|
||||
if device_count < sp_size:
|
||||
print(f"\n[{sp_mode}] SKIPPED (requires {sp_size} GPUs)")
|
||||
continue
|
||||
|
||||
# Determine baseline warmup: only for default size (performance tests)
|
||||
cache_key = (height, width)
|
||||
baseline_warmup = height == DEFAULT_HEIGHT and width == DEFAULT_WIDTH
|
||||
|
||||
# Get or compute baseline for this (height, width)
|
||||
if cache_key not in baseline_cache:
|
||||
print(f"\n--- Running baseline {height}x{width} (warmup={baseline_warmup}) ---")
|
||||
baseline = _run_inference(
|
||||
model_name,
|
||||
torch.bfloat16,
|
||||
"sdpa",
|
||||
height=height,
|
||||
width=width,
|
||||
warmup=baseline_warmup,
|
||||
)
|
||||
assert len(baseline.images) == 1
|
||||
baseline_cache[cache_key] = baseline
|
||||
print(f"[baseline] {height}x{width}: {baseline.elapsed_ms:.0f}ms")
|
||||
else:
|
||||
baseline = baseline_cache[cache_key]
|
||||
|
||||
# Run SP
|
||||
print(f"\n--- Running {sp_mode} (warmup={sp_warmup}) ---")
|
||||
sp_result = _run_inference(
|
||||
model_name,
|
||||
torch.bfloat16,
|
||||
"sdpa",
|
||||
ulysses_degree=ulysses_degree,
|
||||
ring_degree=ring_degree,
|
||||
height=height,
|
||||
width=width,
|
||||
warmup=sp_warmup,
|
||||
)
|
||||
assert len(sp_result.images) == 1
|
||||
|
||||
# Compare outputs (correctness)
|
||||
mean_diff, max_diff = _diff_metrics(baseline.images[0], sp_result.images[0])
|
||||
|
||||
# Build result entry
|
||||
result = {
|
||||
"mode": sp_mode,
|
||||
"sp_size": sp_size,
|
||||
"height": height,
|
||||
"width": width,
|
||||
"baseline_ms": baseline.elapsed_ms,
|
||||
"sp_ms": sp_result.elapsed_ms,
|
||||
"mean_diff": mean_diff,
|
||||
"max_diff": max_diff,
|
||||
"is_perf_test": is_perf_test,
|
||||
}
|
||||
results.append(result)
|
||||
|
||||
# Output based on test type
|
||||
if is_perf_test:
|
||||
speedup = baseline.elapsed_ms / sp_result.elapsed_ms if sp_result.elapsed_ms > 0 else 0
|
||||
result["speedup"] = speedup
|
||||
print(
|
||||
f"[{sp_mode}] {sp_size} GPUs | "
|
||||
f"baseline: {baseline.elapsed_ms:.0f}ms, sp: {sp_result.elapsed_ms:.0f}ms, "
|
||||
f"speedup: {speedup:.2f}x"
|
||||
)
|
||||
else:
|
||||
print(f"[{sp_mode}] {sp_size} GPUs | sp: {sp_result.elapsed_ms:.0f}ms (correctness only)")
|
||||
|
||||
print(f"[{sp_mode}] diff: mean={mean_diff:.6e}, max={max_diff:.6e}")
|
||||
|
||||
# Assert correctness
|
||||
assert mean_diff <= DIFF_MEAN_THRESHOLD and max_diff <= DIFF_MAX_THRESHOLD, (
|
||||
f"[{sp_mode}] SP output differs from baseline: mean={mean_diff:.6e}, max={max_diff:.6e}"
|
||||
)
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 70)
|
||||
print("SUMMARY")
|
||||
print("=" * 70)
|
||||
print(f"{'Mode':<15} {'GPUs':<6} {'Size':<10} {'Baseline':<12} {'SP':<12} {'Speedup':<10} {'Status'}")
|
||||
print("-" * 70)
|
||||
for r in results:
|
||||
speedup_str = f"{r['speedup']:.2f}x" if r.get("speedup") else "N/A"
|
||||
baseline_str = f"{r['baseline_ms']:.0f}ms" if r["is_perf_test"] else "N/A"
|
||||
status = "PASS" if r["mean_diff"] <= DIFF_MEAN_THRESHOLD else "FAIL"
|
||||
print(
|
||||
f"{r['mode']:<15} {r['sp_size']:<6} {r['height']}x{r['width']:<5} "
|
||||
f"{baseline_str:<12} {r['sp_ms']:.0f}ms{'':<7} {speedup_str:<10} {status}"
|
||||
)
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
# TODO: After PR#1272 is merged, add markers
|
||||
# @pytest.mark.advanced_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parallel
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards={"cuda": 4, "rocm": 2})
|
||||
@pytest.mark.parametrize("model_name", MODELS)
|
||||
def test_sp_correctness_advanced(model_name: str):
|
||||
"""Test that SP inference produces correct outputs and measure performance.
|
||||
|
||||
Runs baseline once per unique (height, width), then tests all SP configs.
|
||||
|
||||
Note: Run with `pytest -v -s` to see detailed output.
|
||||
"""
|
||||
device_count = current_omni_platform.get_device_count()
|
||||
|
||||
# Cache baseline results by (height, width)
|
||||
# Key: (height, width), Value: (result, warmup_used)
|
||||
baseline_cache: dict[tuple[int, int], InferenceResult] = {}
|
||||
|
||||
# Collect results for summary
|
||||
results: list[dict] = []
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(f"Sequence Parallel Test - Model: {model_name}")
|
||||
print(f"Available GPUs: {device_count}")
|
||||
print("=" * 70)
|
||||
|
||||
for ulysses_degree, ring_degree, height, width, sp_warmup, is_perf_test in SP_CONFIGS_L3:
|
||||
sp_size = ulysses_degree * ring_degree
|
||||
sp_mode = _get_sp_mode(ulysses_degree, ring_degree)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.utils import hardware_test
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
|
||||
@@ -19,6 +20,9 @@ from vllm_omni import Omni
|
||||
models = ["linyueqian/stable_audio_random"]
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.diffusion
|
||||
@hardware_test(res={"cuda": "L4"})
|
||||
@pytest.mark.parametrize("model_name", models)
|
||||
def test_stable_audio_model(model_name: str):
|
||||
m = Omni(model=model_name)
|
||||
|
||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.utils import hardware_test
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
@@ -32,6 +33,9 @@ elif current_omni_platform.is_rocm():
|
||||
models = ["Tongyi-MAI/Z-Image-Turbo"]
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.diffusion
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards={"cuda": 1, "rocm": 2})
|
||||
@pytest.mark.parametrize("model_name", models)
|
||||
def test_diffusion_model(model_name: str):
|
||||
m = None
|
||||
|
||||
@@ -15,6 +15,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.utils import hardware_test
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
# ruff: noqa: E402
|
||||
@@ -31,6 +32,10 @@ os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "1"
|
||||
models = ["riverclouds/qwen_image_random"]
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.cache
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"})
|
||||
@pytest.mark.parametrize("model_name", models)
|
||||
def test_teacache(model_name: str):
|
||||
"""Test TeaCache backend with diffusion model."""
|
||||
|
||||
@@ -12,18 +12,18 @@ import torch
|
||||
from PIL import Image
|
||||
from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
|
||||
|
||||
from tests.utils import GPUMemoryMonitor, hardware_test
|
||||
from vllm_omni import Omni
|
||||
from vllm_omni.diffusion.data import DiffusionParallelConfig
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
# 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 tests.utils import GPUMemoryMonitor
|
||||
from vllm_omni import Omni
|
||||
from vllm_omni.diffusion.data import DiffusionParallelConfig
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
# os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "1"
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
@@ -127,7 +127,10 @@ def _run_zimage_generate(
|
||||
cleanup_dist_env_and_memory()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parallel
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards={"cuda": 4, "rocm": 2})
|
||||
def test_zimage_tensor_parallel_tp2(tmp_path: Path):
|
||||
if current_omni_platform.is_npu() or current_omni_platform.is_rocm():
|
||||
pytest.skip("Z-Image TP e2e test is only supported on CUDA for now.")
|
||||
|
||||
@@ -8,7 +8,8 @@ import pytest
|
||||
from vllm import SamplingParams
|
||||
from vllm.inputs import PromptType
|
||||
|
||||
from vllm_omni.entrypoints.async_omni import AsyncOmni, ClientRequestState
|
||||
from tests.utils import hardware_test
|
||||
from vllm_omni.entrypoints.async_omni import AsyncOmni
|
||||
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
|
||||
@@ -57,6 +58,9 @@ async def generate(
|
||||
return count, request_id
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
|
||||
@pytest.mark.asyncio
|
||||
async def test_abort():
|
||||
with ExitStack() as after:
|
||||
@@ -111,51 +115,3 @@ async def test_abort():
|
||||
num_generated_tokens, request_id = await task
|
||||
assert num_generated_tokens == NUM_EXPECTED_TOKENS
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_and_log_summary(monkeypatch):
|
||||
from vllm_omni.entrypoints.utils import get_final_stage_id_for_e2e
|
||||
|
||||
RealCRS = ClientRequestState
|
||||
capture_metrics = {}
|
||||
|
||||
class MockCRS(RealCRS):
|
||||
def __init__(self, request_id: str):
|
||||
super().__init__(request_id)
|
||||
capture_metrics[request_id] = self
|
||||
|
||||
monkeypatch.setattr("vllm_omni.entrypoints.async_omni.ClientRequestState", MockCRS)
|
||||
monkeypatch.setattr("vllm_omni.entrypoints.client_request_state.ClientRequestState", MockCRS)
|
||||
|
||||
with ExitStack() as after:
|
||||
# Avoid SHM IPC in tests to prevent /dev/shm exhaustion and SIGBUS.
|
||||
engine = AsyncOmni(
|
||||
model=model,
|
||||
stage_configs_path=stage_config,
|
||||
shm_threshold_bytes=sys.maxsize,
|
||||
)
|
||||
after.callback(engine.shutdown)
|
||||
prompt = "Hello my name is Robert and "
|
||||
NUM_EXPECTED_TOKENS = 64
|
||||
NUM_REQUESTS = 3
|
||||
request_ids = [f"request-{i}" for i in range(NUM_REQUESTS)]
|
||||
|
||||
# Create concurrent requests.
|
||||
tasks: list[asyncio.Task] = []
|
||||
for idx, request_id in enumerate(request_ids):
|
||||
tasks.append(asyncio.create_task(generate(engine, request_id, prompt, NUM_EXPECTED_TOKENS)))
|
||||
|
||||
# Confirm the requests are okay.
|
||||
for idx, task in enumerate(tasks):
|
||||
await task
|
||||
output_modalities = ["text"]
|
||||
final_stage_id_for_e2e = get_final_stage_id_for_e2e(
|
||||
output_modalities, engine.output_modalities, engine.stage_list
|
||||
)
|
||||
summary = capture_metrics[request_ids[idx]].metrics.build_and_log_summary(final_stage_id_for_e2e)
|
||||
|
||||
# Check that total tokens matches sum of stage tokens.
|
||||
assert summary["e2e_total_tokens"] == sum(stage["tokens"] for stage in summary["stages"])
|
||||
# Check that total time matches sum of stage times.
|
||||
assert summary["e2e_total_time_ms"] >= sum(stage["total_time_ms"] for stage in summary["stages"])
|
||||
@@ -22,6 +22,8 @@ from PIL import Image
|
||||
from vllm.assets.image import ImageAsset
|
||||
from vllm.utils.network_utils import get_open_port
|
||||
|
||||
from tests.utils import hardware_test
|
||||
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
# Increase timeout for downloading assets from S3 (default 5s is too short for CI)
|
||||
os.environ.setdefault("VLLM_IMAGE_FETCH_TIMEOUT", "60")
|
||||
@@ -178,6 +180,9 @@ def _decode_data_url_to_image_bytes(data_url: str) -> bytes:
|
||||
return base64.b64decode(b64_data)
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.diffusion
|
||||
@hardware_test(res={"cuda": "H100", "rocm": "MI325"})
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_i2i_multi_image_input_qwen_image_edit_2509(
|
||||
omni_server,
|
||||
|
||||
@@ -23,6 +23,7 @@ from PIL import Image
|
||||
from safetensors.torch import save_file
|
||||
|
||||
from tests.conftest import OmniServer
|
||||
from tests.utils import hardware_test
|
||||
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
|
||||
@@ -144,6 +145,9 @@ def _basic_payload() -> dict:
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.diffusion
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"})
|
||||
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())
|
||||
|
||||
@@ -28,6 +28,7 @@ from tests.conftest import (
|
||||
merge_base64_and_convert_to_text,
|
||||
modify_stage_config,
|
||||
)
|
||||
from tests.utils import hardware_test
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
models = ["Qwen/Qwen3-Omni-30B-A3B-Instruct"]
|
||||
@@ -145,6 +146,9 @@ def get_max_batch_size(size_type="few"):
|
||||
return batch_sizes.get(size_type, 5)
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_mix_to_text_audio_001(client: openai.OpenAI, omni_server, request) -> None:
|
||||
"""
|
||||
@@ -215,6 +219,9 @@ def test_mix_to_text_audio_001(client: openai.OpenAI, omni_server, request) -> N
|
||||
assert similarity > 0.9, "The audio content is not same as the text"
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_text_to_text_audio_001(client: openai.OpenAI, omni_server) -> None:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# stage config for running qwen2.5-omni with architecture of OmniLLM.
|
||||
|
||||
# The following config has been verified on 2x 24GB GPU (L4/RTX3090/RTX4090).
|
||||
# This config is optimized for CI e2e tests.
|
||||
stage_args:
|
||||
- stage_id: 0
|
||||
runtime:
|
||||
process: true # Run this stage in a separate process
|
||||
devices: "0" # Visible devices for this stage (CUDA_VISIBLE_DEVICES/torch.cuda.set_device)
|
||||
max_batch_size: 1
|
||||
engine_args:
|
||||
model_stage: thinker
|
||||
model_arch: Qwen2_5OmniForConditionalGeneration
|
||||
worker_cls: vllm_omni.worker.gpu_ar_worker.GPUARWorker
|
||||
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
|
||||
max_model_len: 32768
|
||||
max_num_batched_tokens: 32768
|
||||
max_num_seqs: 1
|
||||
gpu_memory_utilization: 0.8
|
||||
skip_mm_profiling: true
|
||||
enforce_eager: true # Now we only support eager mode
|
||||
trust_remote_code: true
|
||||
engine_output_type: latent
|
||||
enable_prefix_caching: false
|
||||
is_comprehension: true
|
||||
final_output: true
|
||||
final_output_type: text
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
top_p: 1.0
|
||||
top_k: -1
|
||||
max_tokens: 128
|
||||
seed: 42
|
||||
detokenize: True
|
||||
repetition_penalty: 1.1
|
||||
- stage_id: 1
|
||||
runtime:
|
||||
process: true
|
||||
devices: "1"
|
||||
max_batch_size: 1
|
||||
engine_args:
|
||||
model_stage: talker
|
||||
model_arch: Qwen2_5OmniForConditionalGeneration
|
||||
worker_cls: vllm_omni.worker.gpu_ar_worker.GPUARWorker
|
||||
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
|
||||
max_model_len: 32768
|
||||
max_num_batched_tokens: 32768
|
||||
max_num_seqs: 1
|
||||
gpu_memory_utilization: 0.8
|
||||
skip_mm_profiling: true
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
engine_output_type: latent
|
||||
engine_input_source: [0]
|
||||
custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen2_5_omni.thinker2talker
|
||||
default_sampling_params:
|
||||
temperature: 0.9
|
||||
top_p: 0.8
|
||||
top_k: 40
|
||||
max_tokens: 128
|
||||
seed: 42
|
||||
detokenize: True
|
||||
repetition_penalty: 1.05
|
||||
stop_token_ids: [8294]
|
||||
- stage_id: 2
|
||||
runtime:
|
||||
process: true
|
||||
devices: "0" # Example: use a different GPU than the previous stage; use "0" if single GPU
|
||||
max_batch_size: 1
|
||||
engine_args:
|
||||
model_stage: code2wav
|
||||
model_arch: Qwen2_5OmniForConditionalGeneration
|
||||
worker_cls: vllm_omni.worker.gpu_generation_worker.GPUGenerationWorker
|
||||
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
|
||||
gpu_memory_utilization: 0.15
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
engine_output_type: audio
|
||||
max_num_batched_tokens: 4069
|
||||
engine_input_source: [1]
|
||||
final_output: true
|
||||
final_output_type: audio
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
top_p: 1.0
|
||||
top_k: -1
|
||||
max_tokens: 128
|
||||
seed: 42
|
||||
detokenize: True
|
||||
repetition_penalty: 1.1
|
||||
|
||||
# Top-level runtime config (concise): default windows and stage edges
|
||||
runtime:
|
||||
enabled: true
|
||||
defaults:
|
||||
window_size: -1 # Simplified: trigger downstream only after full upstream completion
|
||||
max_inflight: 1 # Simplified: process serially within each stage
|
||||
edges:
|
||||
- from: 0 # thinker → talker: trigger only after receiving full input (-1)
|
||||
to: 1
|
||||
window_size: -1
|
||||
- from: 1 # talker → code2wav: trigger only after receiving full input (-1)
|
||||
to: 2
|
||||
window_size: -1
|
||||
@@ -23,6 +23,8 @@ from vllm_omni.entrypoints.openai.image_api_utils import (
|
||||
)
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
# Unit Tests
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ from unittest.mock import MagicMock
|
||||
import pytest
|
||||
from vllm.sampling_params import SamplingParams
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_comprehension_stage():
|
||||
|
||||
@@ -16,6 +16,8 @@ from vllm_omni.entrypoints.openai.serving_speech import (
|
||||
)
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.entrypoints import omni as omni_module
|
||||
from vllm_omni.entrypoints.async_omni import AsyncOmni
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_default_stage_config_includes_cache_backend(monkeypatch):
|
||||
"""Ensure cache_backend/cache_config are preserved in default diffusion stage."""
|
||||
|
||||
@@ -9,6 +9,8 @@ import pytest
|
||||
from vllm_omni.entrypoints.stage_utils import SHUTDOWN_TASK
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
# Suppress noisy DeprecationWarnings from optional Swig bindings imported by vLLM dependencies.
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import pytest
|
||||
|
||||
from vllm_omni.inputs.preprocess import OmniInputPreprocessor
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _make_preprocessor(monkeypatch):
|
||||
preprocessor = object.__new__(OmniInputPreprocessor)
|
||||
|
||||
@@ -9,6 +9,8 @@ from vllm import SamplingParams
|
||||
|
||||
from vllm_omni.entrypoints.stage_utils import SHUTDOWN_TASK
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
# Suppress noisy DeprecationWarnings from optional Swig bindings imported by vLLM dependencies.
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
@@ -296,9 +298,9 @@ def _setup_log_mocks(monkeypatch):
|
||||
# Mock OrchestratorMetrics to be a simple class that doesn't require file operations
|
||||
|
||||
class _FakeOrchestratorMetrics:
|
||||
def __init__(self, num_stages, enable_stats, wall_start_ts):
|
||||
def __init__(self, num_stages, log_stats, wall_start_ts):
|
||||
self.num_stages = num_stages
|
||||
self.enable_stats = enable_stats
|
||||
self.log_stats = log_stats
|
||||
self.stage_first_ts = [None] * num_stages
|
||||
self.stage_last_ts = [None] * num_stages
|
||||
self.e2e_done = set()
|
||||
@@ -316,7 +318,7 @@ def _setup_log_mocks(monkeypatch):
|
||||
return "Fake summary"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.entrypoints.omni.OrchestratorMetrics",
|
||||
"vllm_omni.entrypoints.omni.OrchestratorAggregator",
|
||||
_FakeOrchestratorMetrics,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.core.sched.output import OmniNewRequestData
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_omni_new_request_data_copies_payloads():
|
||||
prompt_embeds = torch.randn(2, 3)
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.entrypoints.omni_stage import _build_od_config
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_build_od_config_includes_diffusion_fields():
|
||||
engine_args = {
|
||||
|
||||
@@ -6,6 +6,8 @@ import pytest
|
||||
|
||||
from vllm_omni.entrypoints.stage_utils import set_stage_devices
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _make_dummy_torch(call_log):
|
||||
class _Props:
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
Example online tests for Qwen2.5-Omni-7B model.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import OmniServer, convert_audio_file_to_text, cosine_similarity_text
|
||||
|
||||
models = ["Qwen/Qwen2.5-Omni-7B"]
|
||||
|
||||
|
||||
stage_configs = [str(Path(__file__).parent.parent.parent / "e2e" / "stage_configs" / "qwen2_5_omni_ci.yaml")]
|
||||
|
||||
example_dir = str(Path(__file__).parent.parent.parent.parent / "examples" / "online_serving" / "qwen2_5_omni")
|
||||
# Create parameter combinations for model and stage config
|
||||
test_params = [(model, stage_config) for model in models for stage_config in stage_configs]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def omni_server(request):
|
||||
"""Start vLLM-Omni server as a subprocess with actual model weights.
|
||||
Uses module scope so the server starts only once per test module.
|
||||
Multi-stage initialization can take 10-20+ minutes.
|
||||
"""
|
||||
model, stage_config_path = request.param
|
||||
|
||||
print(f"Starting OmniServer with model: {model}")
|
||||
print("This may take 10-20+ minutes for initialization...")
|
||||
|
||||
with OmniServer(
|
||||
model, ["--stage-configs-path", stage_config_path, "--stage-init-timeout", "120"], port=8091
|
||||
) as server:
|
||||
print("OmniServer started successfully")
|
||||
yield server
|
||||
print("OmniServer stopped")
|
||||
|
||||
|
||||
def run_cmd(command):
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"STDERR: {result.stderr}")
|
||||
raise subprocess.CalledProcessError(result.returncode, command)
|
||||
|
||||
all_output = result.stdout
|
||||
print(f"All output:\n{all_output}")
|
||||
return all_output
|
||||
|
||||
|
||||
def extract_content_after_keyword(keywords, text):
|
||||
matches = re.findall(rf"{keywords}\s*(.+)", text, re.DOTALL)
|
||||
|
||||
if not matches:
|
||||
raise AssertionError(f"Keywords {keywords} not found in provided text output")
|
||||
return matches[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_send_multimodal_request_001(omni_server) -> None:
|
||||
command = [
|
||||
"python",
|
||||
os.path.join(example_dir, "openai_chat_completion_client_for_multimodal_generation.py"),
|
||||
"--query-type",
|
||||
"mixed_modalities",
|
||||
]
|
||||
|
||||
result = run_cmd(command)
|
||||
|
||||
text_content = extract_content_after_keyword("Chat completion output from text:", result)
|
||||
|
||||
# Verify text output same as audio output
|
||||
audio_content = convert_audio_file_to_text(output_path="./audio_0.wav")
|
||||
print(f"text content is: {text_content}")
|
||||
print(f"audio content is: {audio_content}")
|
||||
|
||||
assert all(keyword in text_content for keyword in ["baby", "book"]), (
|
||||
"The output does not contain any of the keywords in video description."
|
||||
)
|
||||
# There is currently an issue with incorrect image descriptions.
|
||||
# assert "cherry blossom" in text_content, "The output does not contain any of the keywords in image description."
|
||||
assert "lamb" in text_content, "The output does not contain any of the keywords in audio description."
|
||||
|
||||
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"
|
||||
|
||||
# TODO: Verify the E2E latency after confirmation baseline.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_send_multimodal_request_002(omni_server) -> None:
|
||||
command = [
|
||||
"python",
|
||||
os.path.join(example_dir, "openai_chat_completion_client_for_multimodal_generation.py"),
|
||||
"--query-type",
|
||||
"mixed_modalities",
|
||||
"--prompt",
|
||||
"Analyze all the media content and provide a comprehensive summary.",
|
||||
]
|
||||
result = run_cmd(command)
|
||||
|
||||
text_content = extract_content_after_keyword("Chat completion output from text:", result)
|
||||
|
||||
# Verify text output same as audio output
|
||||
audio_content = convert_audio_file_to_text(output_path="./audio_0.wav")
|
||||
print(f"text content is: {text_content}")
|
||||
assert all(keyword in text_content for keyword in ["baby", "book"]), (
|
||||
"The output does not contain any of the keywords in video description."
|
||||
)
|
||||
# There is currently an issue with incorrect image descriptions.
|
||||
# assert "cherry blossom" in text_content, "The output does not contain any of the keywords in image description."
|
||||
assert "lamb" in text_content, "The output does not contain any of the keywords in audio description."
|
||||
|
||||
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"
|
||||
|
||||
# TODO: Verify the E2E latency after confirmation baseline.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_send_multimodal_request_003(omni_server) -> None:
|
||||
command = ["bash", os.path.join(example_dir, "run_curl_multimodal_generation.sh"), "mixed_modalities"]
|
||||
|
||||
result = run_cmd(command)
|
||||
|
||||
text_content = extract_content_after_keyword("Output of request:", result)
|
||||
|
||||
# Verify text output same as audio output
|
||||
print(f"text content is: {text_content}")
|
||||
assert all(keyword in text_content for keyword in ["baby", "book"]), (
|
||||
"The output does not contain any of the keywords in video description."
|
||||
)
|
||||
# There is currently an issue with incorrect image descriptions.
|
||||
# assert "cherry blossom" in text_content, "The output does not contain any of the keywords in image description."
|
||||
assert "lamb" in text_content, "The output does not contain any of the keywords in audio description."
|
||||
|
||||
# TODO: Verify the E2E latency after confirmation baseline.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_modality_control_001(omni_server) -> None:
|
||||
command = [
|
||||
"python",
|
||||
os.path.join(example_dir, "openai_chat_completion_client_for_multimodal_generation.py"),
|
||||
"--query-type",
|
||||
"mixed_modalities",
|
||||
"--modalities",
|
||||
"text",
|
||||
]
|
||||
|
||||
result = run_cmd(command)
|
||||
|
||||
text_content = extract_content_after_keyword("Chat completion output from text:", result)
|
||||
|
||||
# Verify text output
|
||||
print(f"text content is: {text_content}")
|
||||
assert all(keyword in text_content for keyword in ["baby", "book"]), (
|
||||
"The output does not contain any of the keywords in video description."
|
||||
)
|
||||
# There is currently an issue with incorrect image descriptions.
|
||||
# assert "cherry blossom" in text_content, "The output does not contain any of the keywords in image description."
|
||||
assert "lamb" in text_content, "The output does not contain any of the keywords in audio description."
|
||||
|
||||
# TODO: Verify the E2E latency after confirmation baseline.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_modality_control_002(omni_server) -> None:
|
||||
command = [
|
||||
"python",
|
||||
os.path.join(example_dir, "openai_chat_completion_client_for_multimodal_generation.py"),
|
||||
"--query-type",
|
||||
"mixed_modalities",
|
||||
"--modalities",
|
||||
"audio",
|
||||
]
|
||||
|
||||
run_cmd(command)
|
||||
# Verify text output same as audio output
|
||||
audio_content = convert_audio_file_to_text(output_path="./audio_0.wav")
|
||||
print(f"audio content is: {audio_content}")
|
||||
assert all(keyword in audio_content for keyword in ["baby", "book"]), (
|
||||
"The output does not contain any of the keywords in video description."
|
||||
)
|
||||
# There is currently an issue with incorrect image descriptions.
|
||||
# assert "cherry blossom" in audio_content, "The output does not contain any of the keywords in image description."
|
||||
assert "lamb" in audio_content, "The output does not contain any of the keywords in audio description."
|
||||
|
||||
# TODO: Verify the E2E latency after confirmation baseline.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_modality_control_003(omni_server) -> None:
|
||||
command = [
|
||||
"python",
|
||||
os.path.join(example_dir, "openai_chat_completion_client_for_multimodal_generation.py"),
|
||||
"--query-type",
|
||||
"mixed_modalities",
|
||||
"--modalities",
|
||||
"audio,text",
|
||||
]
|
||||
|
||||
result = run_cmd(command)
|
||||
|
||||
text_content = extract_content_after_keyword("Chat completion output from text:", result)
|
||||
|
||||
# Verify text output same as audio output
|
||||
audio_content = convert_audio_file_to_text(output_path="./audio_0.wav")
|
||||
print(f"text content is: {text_content}")
|
||||
assert all(keyword in text_content for keyword in ["baby", "book"]), (
|
||||
"The output does not contain any of the keywords in video description."
|
||||
)
|
||||
# There is currently an issue with incorrect image descriptions.
|
||||
# assert "cherry blossom" in text_content, "The output does not contain any of the keywords in image description."
|
||||
assert "lamb" in text_content, "The output does not contain any of the keywords in audio description."
|
||||
|
||||
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"
|
||||
|
||||
# TODO: Verify the E2E latency after confirmation baseline.
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="There is a known issue with stream error.")
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_stream_001(omni_server) -> None:
|
||||
command = [
|
||||
"python",
|
||||
os.path.join(example_dir, "openai_chat_completion_client_for_multimodal_generation.py"),
|
||||
"--query-type",
|
||||
"mixed_modalities",
|
||||
"--stream",
|
||||
]
|
||||
|
||||
result = run_cmd(command)
|
||||
|
||||
text_content = extract_content_after_keyword("content:", result)
|
||||
|
||||
# Verify text output same as audio output
|
||||
audio_content = convert_audio_file_to_text(output_path="./audio_0.wav")
|
||||
print(f"text content is: {text_content}")
|
||||
assert all(keyword in text_content for keyword in ["baby", "book"]), (
|
||||
"The output does not contain any of the keywords in video description."
|
||||
)
|
||||
# There is currently an issue with incorrect image descriptions.
|
||||
# assert "cherry blossom" in text_content, "The output does not contain any of the keywords in image description."
|
||||
assert "lamb" in text_content, "The output does not contain any of the keywords in audio description."
|
||||
|
||||
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"
|
||||
# TODO: Verify the E2E latency after confirmation baseline.
|
||||
|
||||
|
||||
# TODO: test local web ui
|
||||
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
Example Online tests for Qwen3-Omni model.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import OmniServer, convert_audio_file_to_text, cosine_similarity_text
|
||||
|
||||
models = ["Qwen/Qwen3-Omni-30B-A3B-Instruct"]
|
||||
|
||||
|
||||
stage_configs = [str(Path(__file__).parent.parent.parent / "e2e" / "stage_configs" / "qwen3_omni_ci.yaml")]
|
||||
|
||||
example_dir = str(Path(__file__).parent.parent.parent.parent / "examples" / "online_serving" / "qwen3_omni")
|
||||
# Create parameter combinations for model and stage config
|
||||
test_params = [(model, stage_config) for model in models for stage_config in stage_configs]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def omni_server(request):
|
||||
"""Start vLLM-Omni server as a subprocess with actual model weights.
|
||||
Uses module scope so the server starts only once per test module..
|
||||
Multi-stage initialization can take 10-20+ minutes.
|
||||
"""
|
||||
model, stage_config_path = request.param
|
||||
|
||||
print(f"Starting OmniServer with model: {model}")
|
||||
print("This may take 10-20+ minutes for initialization...")
|
||||
|
||||
with OmniServer(
|
||||
model, ["--stage-configs-path", stage_config_path, "--stage-init-timeout", "120"], port=8091
|
||||
) as server:
|
||||
print("OmniServer started successfully")
|
||||
yield server
|
||||
print("OmniServer stopped")
|
||||
|
||||
|
||||
def run_cmd(command):
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"STDERR: {result.stderr}")
|
||||
raise subprocess.CalledProcessError(result.returncode, command)
|
||||
|
||||
all_output = result.stdout
|
||||
print(f"All output:\n{all_output}")
|
||||
return all_output
|
||||
|
||||
|
||||
def extract_content_after_keyword(keywords, text):
|
||||
matches = re.findall(rf"{keywords}\s*(.+)", text, re.DOTALL)
|
||||
|
||||
if not matches:
|
||||
raise AssertionError(f"Keywords {keywords} not found in provided text output")
|
||||
return matches[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_send_multimodal_request_001(omni_server) -> None:
|
||||
command = [
|
||||
"python",
|
||||
os.path.join(example_dir, "openai_chat_completion_client_for_multimodal_generation.py"),
|
||||
"--query-type",
|
||||
"use_image",
|
||||
]
|
||||
|
||||
result = run_cmd(command)
|
||||
text_content = extract_content_after_keyword("Chat completion output from text:", result)
|
||||
# Verify text output same as audio output
|
||||
audio_content = convert_audio_file_to_text(output_path="./audio_0.wav")
|
||||
print(f"text content is: {text_content}")
|
||||
print(f"audio content is: {audio_content}")
|
||||
|
||||
assert "cherry blossom" in text_content, "The output does not contain any of the keywords."
|
||||
|
||||
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"
|
||||
|
||||
# TODO: Verify the E2E latency after confirmation baseline.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_send_multimodal_request_002(omni_server) -> None:
|
||||
command = [
|
||||
"python",
|
||||
os.path.join(example_dir, "openai_chat_completion_client_for_multimodal_generation.py"),
|
||||
"--query-type",
|
||||
"use_video",
|
||||
"--prompt",
|
||||
"What are the main activities shown in this video?",
|
||||
]
|
||||
result = run_cmd(command)
|
||||
|
||||
text_content = extract_content_after_keyword("Chat completion output from text:", result)
|
||||
|
||||
# Verify text output same as audio output
|
||||
audio_content = convert_audio_file_to_text(output_path="./audio_0.wav")
|
||||
print(f"text content is: {text_content}")
|
||||
print(f"audio content is: {audio_content}")
|
||||
assert all(keyword in text_content for keyword in ["baby", "book"]), (
|
||||
"The output does not contain any of the keywords."
|
||||
)
|
||||
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"
|
||||
|
||||
# TODO: Verify the E2E latency after confirmation baseline.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_send_multimodal_request_003(omni_server) -> None:
|
||||
command = ["bash", os.path.join(example_dir, "run_curl_multimodal_generation.sh"), "use_image"]
|
||||
|
||||
result = run_cmd(command)
|
||||
|
||||
text_content = extract_content_after_keyword("Output of request:", result)
|
||||
|
||||
# Verify text output same as audio output
|
||||
print(f"text content is: {text_content}")
|
||||
assert "cherry blossom" in text_content, "The output does not contain any of the keywords."
|
||||
# TODO: Verify the E2E latency after confirmation baseline.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_modality_control_001(omni_server) -> None:
|
||||
command = [
|
||||
"python",
|
||||
os.path.join(example_dir, "openai_chat_completion_client_for_multimodal_generation.py"),
|
||||
"--query-type",
|
||||
"use_image",
|
||||
"--modalities",
|
||||
"text",
|
||||
]
|
||||
|
||||
result = run_cmd(command)
|
||||
|
||||
text_content = extract_content_after_keyword("Chat completion output from text:", result)
|
||||
|
||||
# Verify text output
|
||||
print(f"text content is: {text_content}")
|
||||
assert "cherry blossom" in text_content, "The output does not contain any of the keywords."
|
||||
# TODO: Verify the E2E latency after confirmation baseline.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_modality_control_002(omni_server) -> None:
|
||||
command = [
|
||||
"python",
|
||||
os.path.join(example_dir, "openai_chat_completion_client_for_multimodal_generation.py"),
|
||||
"--query-type",
|
||||
"use_image",
|
||||
"--modalities",
|
||||
"audio",
|
||||
]
|
||||
|
||||
run_cmd(command)
|
||||
# Verify text output same as audio output
|
||||
audio_content = convert_audio_file_to_text(output_path="./audio_0.wav")
|
||||
print(f"audio content is: {audio_content}")
|
||||
assert "cherry blossom" in audio_content, "The output does not contain any of the keywords."
|
||||
|
||||
# TODO: Verify the E2E latency after confirmation baseline.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_modality_control_003(omni_server) -> None:
|
||||
command = [
|
||||
"python",
|
||||
os.path.join(example_dir, "openai_chat_completion_client_for_multimodal_generation.py"),
|
||||
"--query-type",
|
||||
"use_image",
|
||||
"--modalities",
|
||||
"audio,text",
|
||||
]
|
||||
|
||||
result = run_cmd(command)
|
||||
|
||||
text_content = extract_content_after_keyword("Chat completion output from text:", result)
|
||||
|
||||
# Verify text output same as audio output
|
||||
audio_content = convert_audio_file_to_text(output_path="./audio_0.wav")
|
||||
print(f"text content is: {text_content}")
|
||||
assert "cherry blossom" in audio_content, "The output does not contain any of the keywords."
|
||||
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"
|
||||
|
||||
# TODO: Verify the E2E latency after confirmation baseline.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_stream_001(omni_server) -> None:
|
||||
command = [
|
||||
"python",
|
||||
os.path.join(example_dir, "openai_chat_completion_client_for_multimodal_generation.py"),
|
||||
"--query-type",
|
||||
"use_image",
|
||||
"--stream",
|
||||
]
|
||||
|
||||
result = run_cmd(command)
|
||||
|
||||
text_content = extract_content_after_keyword("content:", result)
|
||||
|
||||
# Verify text output same as audio output
|
||||
audio_content = convert_audio_file_to_text(output_path="./audio_0.wav")
|
||||
print(f"text content is: {text_content}")
|
||||
assert "cherry blossom" in audio_content, "The output does not contain any of the keywords."
|
||||
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"
|
||||
# TODO: Verify the E2E latency after confirmation baseline.
|
||||
|
||||
|
||||
# TODO: test local web ui
|
||||
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.metrics import OrchestratorAggregator
|
||||
from vllm_omni.metrics.stats import RequestE2EStats, StageRequestStats, StageStats
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _get_request_entry(table: list[dict], request_id: str) -> dict:
|
||||
for entry in table:
|
||||
if entry.get("request_id") == request_id:
|
||||
return entry
|
||||
raise AssertionError(f"request_id={request_id} not found")
|
||||
|
||||
|
||||
def test_orchestrator_aggregator_builds_summary() -> None:
|
||||
agg = OrchestratorAggregator(num_stages=2, log_stats=True, wall_start_ts=0.0, final_stage_id_for_e2e=1)
|
||||
agg.stage_first_ts[0] = 0.0
|
||||
agg.stage_last_ts[0] = 0.03
|
||||
agg.stage_first_ts[1] = 0.05
|
||||
agg.stage_last_ts[1] = 0.07
|
||||
|
||||
agg.on_forward(0, 1, "r1", size_bytes=1024, tx_ms=5.0, used_shm=False)
|
||||
|
||||
agg.on_stage_metrics(
|
||||
0,
|
||||
"r1",
|
||||
StageRequestStats(
|
||||
batch_id=1,
|
||||
batch_size=1,
|
||||
num_tokens_in=3,
|
||||
num_tokens_out=3,
|
||||
stage_gen_time_ms=30.0,
|
||||
rx_transfer_bytes=0,
|
||||
rx_decode_time_ms=0.0,
|
||||
rx_in_flight_time_ms=0.0,
|
||||
stage_stats=StageStats(),
|
||||
),
|
||||
)
|
||||
agg.on_stage_metrics(
|
||||
1,
|
||||
"r1",
|
||||
StageRequestStats(
|
||||
batch_id=1,
|
||||
batch_size=1,
|
||||
num_tokens_in=0,
|
||||
num_tokens_out=4,
|
||||
stage_gen_time_ms=20.0,
|
||||
rx_transfer_bytes=1024,
|
||||
rx_decode_time_ms=5.0,
|
||||
rx_in_flight_time_ms=2.0,
|
||||
stage_stats=StageStats(),
|
||||
),
|
||||
)
|
||||
agg.on_finalize_request(1, "r1", req_start_ts=0.0)
|
||||
|
||||
summary = agg.build_and_log_summary()
|
||||
overall = summary["overall_summary"]
|
||||
assert overall["e2e_requests"] == 1
|
||||
|
||||
stage_entry = _get_request_entry(summary["stage_table"], "r1")
|
||||
stage_ids = [row["stage_id"] for row in stage_entry["stages"]]
|
||||
assert stage_ids == [0, 1]
|
||||
|
||||
transfer_entry = _get_request_entry(summary["trans_table"], "r1")
|
||||
assert transfer_entry["transfers"][0]["edge"] == "0->1"
|
||||
assert transfer_entry["transfers"][0]["size_kbytes"] == 1.0
|
||||
|
||||
e2e_entry = _get_request_entry(summary["e2e_table"], "r1")
|
||||
assert e2e_entry["e2e_total_tokens"] == 10
|
||||
|
||||
|
||||
def test_build_and_log_summary_e2e_only() -> None:
|
||||
agg = OrchestratorAggregator(num_stages=1, log_stats=True, wall_start_ts=0.0, final_stage_id_for_e2e=0)
|
||||
agg.e2e_events.append(
|
||||
RequestE2EStats(
|
||||
request_id="r",
|
||||
e2e_total_ms=10.0,
|
||||
e2e_total_tokens=5,
|
||||
transfers_total_time_ms=0.0,
|
||||
transfers_total_bytes=0,
|
||||
)
|
||||
)
|
||||
|
||||
summary = agg.build_and_log_summary()
|
||||
e2e_entry = _get_request_entry(summary["e2e_table"], "r")
|
||||
assert e2e_entry["e2e_total_tokens"] == 5
|
||||
stage_entry = _get_request_entry(summary["stage_table"], "r")
|
||||
assert stage_entry["stages"] == []
|
||||
|
||||
|
||||
def test_build_and_log_summary_multiple_requests() -> None:
|
||||
agg = OrchestratorAggregator(
|
||||
num_stages=2, log_stats=True, wall_start_ts=0.0, final_stage_id_for_e2e={"r1": 1, "r2": 0}
|
||||
)
|
||||
|
||||
# Request r1 goes through both stages
|
||||
agg.on_stage_metrics(
|
||||
0,
|
||||
"r1",
|
||||
StageRequestStats(
|
||||
batch_id=1,
|
||||
batch_size=1,
|
||||
num_tokens_in=2,
|
||||
num_tokens_out=4,
|
||||
stage_gen_time_ms=10.0,
|
||||
rx_transfer_bytes=0,
|
||||
rx_decode_time_ms=0.0,
|
||||
rx_in_flight_time_ms=0.0,
|
||||
stage_stats=StageStats(),
|
||||
),
|
||||
)
|
||||
agg.on_stage_metrics(
|
||||
1,
|
||||
"r1",
|
||||
StageRequestStats(
|
||||
batch_id=1,
|
||||
batch_size=1,
|
||||
num_tokens_in=4,
|
||||
num_tokens_out=5,
|
||||
stage_gen_time_ms=8.0,
|
||||
rx_transfer_bytes=0,
|
||||
rx_decode_time_ms=0.0,
|
||||
rx_in_flight_time_ms=0.0,
|
||||
stage_stats=StageStats(),
|
||||
),
|
||||
)
|
||||
agg.on_finalize_request(1, "r1", req_start_ts=0.0)
|
||||
|
||||
# Request r2 only goes through stage 0
|
||||
agg.on_stage_metrics(
|
||||
0,
|
||||
"r2",
|
||||
StageRequestStats(
|
||||
batch_id=2,
|
||||
batch_size=1,
|
||||
num_tokens_in=1,
|
||||
num_tokens_out=2,
|
||||
stage_gen_time_ms=12.0,
|
||||
rx_transfer_bytes=0,
|
||||
rx_decode_time_ms=0.0,
|
||||
rx_in_flight_time_ms=0.0,
|
||||
stage_stats=StageStats(),
|
||||
),
|
||||
)
|
||||
agg.on_finalize_request(0, "r2", req_start_ts=0.0)
|
||||
|
||||
summary = agg.build_and_log_summary()
|
||||
assert len(summary["stage_table"]) == 2
|
||||
assert {entry["request_id"] for entry in summary["e2e_table"]} == {"r1", "r2"}
|
||||
# Check that r1 has two stages and r2 has one
|
||||
r1_stage_entry = next(e for e in summary["stage_table"] if e["request_id"] == "r1")
|
||||
r2_stage_entry = next(e for e in summary["stage_table"] if e["request_id"] == "r2")
|
||||
assert len(r1_stage_entry["stages"]) == 2
|
||||
assert len(r2_stage_entry["stages"]) == 1
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_resolve_max_mel_frames_default():
|
||||
from vllm_omni.model_executor.models.qwen2_5_omni.audio_length import resolve_max_mel_frames
|
||||
|
||||
+1
-11
@@ -495,18 +495,8 @@ def hardware_test(*, res: dict[str, str], num_cards: int | dict[str, int] = 1):
|
||||
raise ValueError(f"Unsupported platform: {platform}")
|
||||
all_marks.extend(marks)
|
||||
|
||||
create_new_process_flag = False
|
||||
for cards in num_cards_dict.values():
|
||||
if cards > 1:
|
||||
create_new_process_flag = True
|
||||
break
|
||||
|
||||
def wrapper(f: Callable[_P, None]) -> Callable[_P, None]:
|
||||
if create_new_process_flag:
|
||||
# only for distributed tests
|
||||
func = create_new_process_for_each_test()(f)
|
||||
else:
|
||||
func = f
|
||||
func = f
|
||||
for mark in reversed(all_marks):
|
||||
func = mark(func)
|
||||
return func
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.worker.gpu_generation_model_runner import GPUGenerationModelRunner
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _DummyInputBatch:
|
||||
def __init__(self):
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.worker.gpu_model_runner import OmniGPUModelRunner
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class DummyBuffer:
|
||||
"""A minimal buffer wrapper that exposes the `.gpu` attribute."""
|
||||
|
||||
@@ -14,10 +14,11 @@ from vllm.logger import init_logger
|
||||
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
|
||||
from vllm_omni.diffusion.attention.backends.sdpa import SDPABackend
|
||||
from vllm_omni.diffusion.attention.parallel import build_parallel_attention_strategy
|
||||
from vllm_omni.diffusion.attention.parallel.base import NoParallelAttention
|
||||
from vllm_omni.diffusion.attention.parallel.ring import RingParallelAttention
|
||||
from vllm_omni.diffusion.attention.selector import get_attn_backend
|
||||
from vllm_omni.diffusion.distributed.parallel_state import get_sp_group
|
||||
from vllm_omni.diffusion.forward_context import get_forward_context
|
||||
from vllm_omni.diffusion.forward_context import get_forward_context, is_forward_context_available
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
@@ -87,6 +88,21 @@ class Attention(nn.Module):
|
||||
gather_idx=gather_idx,
|
||||
use_sync=use_sync,
|
||||
)
|
||||
# Fallback strategy when SP is not active (outside sharded regions)
|
||||
self._no_parallel_strategy = NoParallelAttention()
|
||||
|
||||
def _get_active_parallel_strategy(self):
|
||||
"""Get the parallel strategy based on current SP active state.
|
||||
|
||||
Returns NoParallelAttention if we're outside an SP sharded region
|
||||
(e.g., in noise_refiner/context_refiner before unified_prepare in Z-Image).
|
||||
This avoids unnecessary SP communication for layers not covered by _sp_plan.
|
||||
"""
|
||||
if is_forward_context_available():
|
||||
ctx = get_forward_context()
|
||||
if not ctx.sp_active:
|
||||
return self._no_parallel_strategy
|
||||
return self.parallel_strategy
|
||||
|
||||
def forward(
|
||||
self,
|
||||
@@ -95,20 +111,23 @@ class Attention(nn.Module):
|
||||
value: torch.Tensor,
|
||||
attn_metadata: AttentionMetadata = None,
|
||||
) -> torch.Tensor:
|
||||
# Get the appropriate parallel strategy based on SP active state
|
||||
strategy = self._get_active_parallel_strategy()
|
||||
|
||||
# 1. Prepare inputs (Communication / Resharding)
|
||||
# For Ulysses: AllToAll Q/K/V; Slicing joint_q/k/v
|
||||
# For Ring: Concat joint_q
|
||||
query, key, value, attn_metadata, ctx = self.parallel_strategy.pre_attention(query, key, value, attn_metadata)
|
||||
query, key, value, attn_metadata, ctx = strategy.pre_attention(query, key, value, attn_metadata)
|
||||
|
||||
# 2. Kernel Execution (Computation)
|
||||
if self.use_ring:
|
||||
if self.use_ring and strategy is not self._no_parallel_strategy:
|
||||
out = self._run_ring_attention(query, key, value, attn_metadata)
|
||||
else:
|
||||
out = self._run_local_attention(query, key, value, attn_metadata)
|
||||
|
||||
# 3. Post-processing (Reverse Communication)
|
||||
# For Ulysses: AllToAll Output, and AllGather Joint Output
|
||||
out = self.parallel_strategy.post_attention(out, ctx)
|
||||
out = strategy.post_attention(out, ctx)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
@@ -102,16 +102,20 @@ class DiffusionEngine:
|
||||
if not isinstance(outputs, list):
|
||||
outputs = [outputs] if outputs is not None else []
|
||||
|
||||
metrics = {
|
||||
"image_num": int(request.sampling_params.num_outputs_per_prompt),
|
||||
"resolution": int(request.sampling_params.resolution),
|
||||
"postprocess_time_ms": postprocess_time * 1000,
|
||||
}
|
||||
if self.pre_process_func is not None:
|
||||
metrics["preprocessing_time_ms"] = preprocess_time * 1000
|
||||
|
||||
# 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 [
|
||||
@@ -149,10 +153,6 @@ class DiffusionEngine:
|
||||
request_outputs = outputs[output_idx : output_idx + num_outputs] if output_idx < len(outputs) else []
|
||||
output_idx += num_outputs
|
||||
|
||||
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 = request_outputs[0] if len(request_outputs) == 1 else request_outputs
|
||||
results.append(
|
||||
|
||||
@@ -757,6 +757,7 @@ def initialize_model_parallel(
|
||||
backend=backend,
|
||||
parallel_mode="data",
|
||||
)
|
||||
vllm_parallel_state._DP = _DP
|
||||
|
||||
global _CFG
|
||||
assert _CFG is None, "classifier_free_guidance group is already initialized"
|
||||
@@ -774,6 +775,7 @@ def initialize_model_parallel(
|
||||
backend=backend,
|
||||
parallel_mode="pipeline",
|
||||
)
|
||||
vllm_parallel_state._PP = _PP
|
||||
|
||||
global _SP
|
||||
assert _SP is None, "sequence parallel group is already initialized"
|
||||
|
||||
@@ -31,6 +31,16 @@ class ForwardContext:
|
||||
# Original sequence length before padding (for removing padding in gather)
|
||||
sp_original_seq_len: int | None = None
|
||||
|
||||
# SP active scope tracking
|
||||
# Tracks the depth of SP sharding - incremented on shard, decremented on gather
|
||||
# Used by attention layers to determine if SP communication should be enabled
|
||||
_sp_shard_depth: int = 0
|
||||
|
||||
@property
|
||||
def sp_active(self) -> bool:
|
||||
"""Returns True when inside an SP sharded region (between shard and gather)."""
|
||||
return self._sp_shard_depth > 0
|
||||
|
||||
def __post_init__(self):
|
||||
pass
|
||||
|
||||
|
||||
@@ -235,6 +235,8 @@ class SequenceParallelSplitHook(ModelHook):
|
||||
|
||||
def post_forward(self, module: nn.Module, output: Any) -> Any:
|
||||
"""Shard outputs for split_output=True entries."""
|
||||
from vllm_omni.diffusion.forward_context import get_forward_context, is_forward_context_available
|
||||
|
||||
is_tensor = isinstance(output, torch.Tensor)
|
||||
is_tensor_list = isinstance(output, (list, tuple)) and all(isinstance(x, torch.Tensor) for x in output)
|
||||
|
||||
@@ -243,6 +245,7 @@ class SequenceParallelSplitHook(ModelHook):
|
||||
return output
|
||||
|
||||
output_list = [output] if is_tensor else list(output)
|
||||
actually_sharded = False
|
||||
|
||||
for index, spm in self.metadata.items():
|
||||
if not isinstance(index, int):
|
||||
@@ -252,7 +255,14 @@ class SequenceParallelSplitHook(ModelHook):
|
||||
if index >= len(output_list):
|
||||
raise ValueError(f"Index {index} out of bounds for output of length {len(output_list)}.")
|
||||
|
||||
output_list[index] = self._prepare_sp_input(output_list[index], spm, self._last_args, self._last_kwargs)
|
||||
original = output_list[index]
|
||||
output_list[index] = self._prepare_sp_input(original, spm, self._last_args, self._last_kwargs)
|
||||
if output_list[index] is not original:
|
||||
actually_sharded = True
|
||||
|
||||
# Mark SP as active only if at least one tensor was actually sharded
|
||||
if actually_sharded and is_forward_context_available():
|
||||
get_forward_context()._sp_shard_depth += 1
|
||||
|
||||
return output_list[0] if is_tensor else type(output)(output_list)
|
||||
|
||||
@@ -445,6 +455,8 @@ class SequenceParallelGatherHook(ModelHook):
|
||||
ctx = get_forward_context()
|
||||
original_seq_len = ctx.sp_original_seq_len
|
||||
|
||||
actually_gathered = False
|
||||
|
||||
for i, spm in enumerate(self.metadata):
|
||||
if spm is None:
|
||||
continue
|
||||
@@ -465,6 +477,12 @@ class SequenceParallelGatherHook(ModelHook):
|
||||
logger.debug(f"Removed padding: gathered shape {gathered.shape} (original_seq_len={original_seq_len})")
|
||||
|
||||
output[i] = gathered
|
||||
actually_gathered = True
|
||||
|
||||
# Mark SP as inactive only if at least one tensor was actually gathered
|
||||
if actually_gathered and is_forward_context_available():
|
||||
ctx = get_forward_context()
|
||||
ctx._sp_shard_depth = max(0, ctx._sp_shard_depth - 1)
|
||||
|
||||
return output[0] if is_tensor else type(output)(output)
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Hunyuan Image 3 diffusion model components."""
|
||||
|
||||
from vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_image_3_transformer import (
|
||||
HunyuanImage3Model,
|
||||
HunyuanImage3Text2ImagePipeline,
|
||||
)
|
||||
from vllm_omni.diffusion.models.hunyuan_image_3.pipeline_hunyuan_image_3 import (
|
||||
HunyuanImage3Pipeline,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"HunyuanImage3Pipeline",
|
||||
"HunyuanImage3Model",
|
||||
"HunyuanImage3Text2ImagePipeline",
|
||||
]
|
||||
@@ -0,0 +1,668 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from diffusers.configuration_utils import ConfigMixin, register_to_config
|
||||
from diffusers.models.modeling_outputs import AutoencoderKLOutput
|
||||
from diffusers.models.modeling_utils import ModelMixin
|
||||
from diffusers.utils import BaseOutput
|
||||
from diffusers.utils.torch_utils import randn_tensor
|
||||
from einops import rearrange
|
||||
from torch import Tensor, nn
|
||||
|
||||
|
||||
class DiagonalGaussianDistribution:
|
||||
def __init__(self, parameters: torch.Tensor, deterministic: bool = False):
|
||||
if parameters.ndim == 3:
|
||||
dim = 2 # (B, L, C)
|
||||
elif parameters.ndim == 5 or parameters.ndim == 4:
|
||||
dim = 1 # (B, C, T, H ,W) / (B, C, H, W)
|
||||
else:
|
||||
raise NotImplementedError
|
||||
self.parameters = parameters
|
||||
self.mean, self.logvar = torch.chunk(parameters, 2, dim=dim)
|
||||
self.logvar = torch.clamp(self.logvar, -30.0, 20.0)
|
||||
self.deterministic = deterministic
|
||||
self.std = torch.exp(0.5 * self.logvar)
|
||||
self.var = torch.exp(self.logvar)
|
||||
if self.deterministic:
|
||||
self.var = self.std = torch.zeros_like(
|
||||
self.mean, device=self.parameters.device, dtype=self.parameters.dtype
|
||||
)
|
||||
|
||||
def sample(self, generator: torch.Generator | None = None) -> torch.FloatTensor:
|
||||
# make sure sample is on the same device as the parameters and has same dtype
|
||||
sample = randn_tensor(
|
||||
self.mean.shape,
|
||||
generator=generator,
|
||||
device=self.parameters.device,
|
||||
dtype=self.parameters.dtype,
|
||||
)
|
||||
x = self.mean + self.std * sample
|
||||
return x
|
||||
|
||||
|
||||
@dataclass
|
||||
class DecoderOutput(BaseOutput):
|
||||
sample: torch.FloatTensor
|
||||
posterior: DiagonalGaussianDistribution | None = None
|
||||
|
||||
|
||||
def swish(x: Tensor) -> Tensor:
|
||||
return x * torch.sigmoid(x)
|
||||
|
||||
|
||||
def forward_with_checkpointing(module, *inputs, use_checkpointing=False):
|
||||
def create_custom_forward(module):
|
||||
def custom_forward(*inputs):
|
||||
return module(*inputs)
|
||||
|
||||
return custom_forward
|
||||
|
||||
if use_checkpointing:
|
||||
return torch.utils.checkpoint.checkpoint(create_custom_forward(module), *inputs, use_reentrant=False)
|
||||
else:
|
||||
return module(*inputs)
|
||||
|
||||
|
||||
class Conv3d(nn.Conv3d):
|
||||
"""
|
||||
Perform Conv3d on patches with numerical differences from nn.Conv3d within 1e-5.
|
||||
Only symmetric padding is supported.
|
||||
"""
|
||||
|
||||
def forward(self, input):
|
||||
B, C, T, H, W = input.shape
|
||||
memory_count = (C * T * H * W) * 2 / 1024**3
|
||||
if memory_count > 2:
|
||||
n_split = math.ceil(memory_count / 2)
|
||||
assert n_split >= 2
|
||||
chunks = torch.chunk(input, chunks=n_split, dim=-3)
|
||||
padded_chunks = []
|
||||
for i in range(len(chunks)):
|
||||
if self.padding[0] > 0:
|
||||
padded_chunk = F.pad(
|
||||
chunks[i],
|
||||
(0, 0, 0, 0, self.padding[0], self.padding[0]),
|
||||
mode="constant" if self.padding_mode == "zeros" else self.padding_mode,
|
||||
value=0,
|
||||
)
|
||||
if i > 0:
|
||||
padded_chunk[:, :, : self.padding[0]] = chunks[i - 1][:, :, -self.padding[0] :]
|
||||
if i < len(chunks) - 1:
|
||||
padded_chunk[:, :, -self.padding[0] :] = chunks[i + 1][:, :, : self.padding[0]]
|
||||
else:
|
||||
padded_chunk = chunks[i]
|
||||
padded_chunks.append(padded_chunk)
|
||||
padding_bak = self.padding
|
||||
self.padding = (0, self.padding[1], self.padding[2])
|
||||
outputs = []
|
||||
for i in range(len(padded_chunks)):
|
||||
outputs.append(super().forward(padded_chunks[i]))
|
||||
self.padding = padding_bak
|
||||
return torch.cat(outputs, dim=-3)
|
||||
else:
|
||||
return super().forward(input)
|
||||
|
||||
|
||||
class AttnBlock(nn.Module):
|
||||
"""Attention with torch sdpa implementation."""
|
||||
|
||||
def __init__(self, in_channels: int):
|
||||
super().__init__()
|
||||
self.in_channels = in_channels
|
||||
|
||||
self.norm = nn.GroupNorm(num_groups=32, num_channels=in_channels, eps=1e-6, affine=True)
|
||||
|
||||
self.q = Conv3d(in_channels, in_channels, kernel_size=1)
|
||||
self.k = Conv3d(in_channels, in_channels, kernel_size=1)
|
||||
self.v = Conv3d(in_channels, in_channels, kernel_size=1)
|
||||
self.proj_out = Conv3d(in_channels, in_channels, kernel_size=1)
|
||||
|
||||
def attention(self, h_: Tensor) -> Tensor:
|
||||
h_ = self.norm(h_)
|
||||
q = self.q(h_)
|
||||
k = self.k(h_)
|
||||
v = self.v(h_)
|
||||
|
||||
b, c, f, h, w = q.shape
|
||||
q = rearrange(q, "b c f h w -> b 1 (f h w) c").contiguous()
|
||||
k = rearrange(k, "b c f h w -> b 1 (f h w) c").contiguous()
|
||||
v = rearrange(v, "b c f h w -> b 1 (f h w) c").contiguous()
|
||||
h_ = nn.functional.scaled_dot_product_attention(q, k, v)
|
||||
|
||||
return rearrange(h_, "b 1 (f h w) c -> b c f h w", f=f, h=h, w=w, c=c, b=b)
|
||||
|
||||
def forward(self, x: Tensor) -> Tensor:
|
||||
return x + self.proj_out(self.attention(x))
|
||||
|
||||
|
||||
class ResnetBlock(nn.Module):
|
||||
def __init__(self, in_channels: int, out_channels: int):
|
||||
super().__init__()
|
||||
self.in_channels = in_channels
|
||||
out_channels = in_channels if out_channels is None else out_channels
|
||||
self.out_channels = out_channels
|
||||
|
||||
self.norm1 = nn.GroupNorm(num_groups=32, num_channels=in_channels, eps=1e-6, affine=True)
|
||||
self.conv1 = Conv3d(in_channels, out_channels, kernel_size=3, stride=1, padding=1)
|
||||
self.norm2 = nn.GroupNorm(num_groups=32, num_channels=out_channels, eps=1e-6, affine=True)
|
||||
self.conv2 = Conv3d(out_channels, out_channels, kernel_size=3, stride=1, padding=1)
|
||||
if self.in_channels != self.out_channels:
|
||||
self.nin_shortcut = Conv3d(in_channels, out_channels, kernel_size=1, stride=1, padding=0)
|
||||
|
||||
def forward(self, x):
|
||||
h = x
|
||||
h = self.norm1(h)
|
||||
h = swish(h)
|
||||
h = self.conv1(h)
|
||||
|
||||
h = self.norm2(h)
|
||||
h = swish(h)
|
||||
h = self.conv2(h)
|
||||
|
||||
if self.in_channels != self.out_channels:
|
||||
x = self.nin_shortcut(x)
|
||||
return x + h
|
||||
|
||||
|
||||
class DownsampleDCAE(nn.Module):
|
||||
def __init__(self, in_channels: int, out_channels: int, add_temporal_downsample: bool = True):
|
||||
super().__init__()
|
||||
factor = 2 * 2 * 2 if add_temporal_downsample else 1 * 2 * 2
|
||||
assert out_channels % factor == 0
|
||||
self.conv = Conv3d(in_channels, out_channels // factor, kernel_size=3, stride=1, padding=1)
|
||||
|
||||
self.add_temporal_downsample = add_temporal_downsample
|
||||
self.group_size = factor * in_channels // out_channels
|
||||
|
||||
def forward(self, x: Tensor):
|
||||
r1 = 2 if self.add_temporal_downsample else 1
|
||||
h = self.conv(x)
|
||||
h = rearrange(h, "b c (f r1) (h r2) (w r3) -> b (r1 r2 r3 c) f h w", r1=r1, r2=2, r3=2)
|
||||
shortcut = rearrange(x, "b c (f r1) (h r2) (w r3) -> b (r1 r2 r3 c) f h w", r1=r1, r2=2, r3=2)
|
||||
|
||||
B, C, T, H, W = shortcut.shape
|
||||
shortcut = shortcut.view(B, h.shape[1], self.group_size, T, H, W).mean(dim=2)
|
||||
return h + shortcut
|
||||
|
||||
|
||||
class UpsampleDCAE(nn.Module):
|
||||
def __init__(self, in_channels: int, out_channels: int, add_temporal_upsample: bool = True):
|
||||
super().__init__()
|
||||
factor = 2 * 2 * 2 if add_temporal_upsample else 1 * 2 * 2
|
||||
self.conv = Conv3d(in_channels, out_channels * factor, kernel_size=3, stride=1, padding=1)
|
||||
|
||||
self.add_temporal_upsample = add_temporal_upsample
|
||||
self.repeats = factor * out_channels // in_channels
|
||||
|
||||
def forward(self, x: Tensor):
|
||||
r1 = 2 if self.add_temporal_upsample else 1
|
||||
h = self.conv(x)
|
||||
h = rearrange(h, "b (r1 r2 r3 c) f h w -> b c (f r1) (h r2) (w r3)", r1=r1, r2=2, r3=2)
|
||||
shortcut = x.repeat_interleave(repeats=self.repeats, dim=1)
|
||||
shortcut = rearrange(shortcut, "b (r1 r2 r3 c) f h w -> b c (f r1) (h r2) (w r3)", r1=r1, r2=2, r3=2)
|
||||
return h + shortcut
|
||||
|
||||
|
||||
class Encoder(nn.Module):
|
||||
"""
|
||||
The encoder network of AutoencoderKLConv3D.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
in_channels: int,
|
||||
z_channels: int,
|
||||
block_out_channels: tuple[int, ...],
|
||||
num_res_blocks: int,
|
||||
ffactor_spatial: int,
|
||||
ffactor_temporal: int,
|
||||
downsample_match_channel: bool = True,
|
||||
):
|
||||
super().__init__()
|
||||
assert block_out_channels[-1] % (2 * z_channels) == 0
|
||||
|
||||
self.z_channels = z_channels
|
||||
self.block_out_channels = block_out_channels
|
||||
self.num_res_blocks = num_res_blocks
|
||||
|
||||
# downsampling
|
||||
self.conv_in = Conv3d(in_channels, block_out_channels[0], kernel_size=3, stride=1, padding=1)
|
||||
|
||||
self.down = nn.ModuleList()
|
||||
block_in = block_out_channels[0]
|
||||
for i_level, ch in enumerate(block_out_channels):
|
||||
block = nn.ModuleList()
|
||||
block_out = ch
|
||||
for _ in range(self.num_res_blocks):
|
||||
block.append(ResnetBlock(in_channels=block_in, out_channels=block_out))
|
||||
block_in = block_out
|
||||
down = nn.Module()
|
||||
down.block = block
|
||||
|
||||
add_spatial_downsample = bool(i_level < np.log2(ffactor_spatial))
|
||||
add_temporal_downsample = add_spatial_downsample and bool(
|
||||
i_level >= np.log2(ffactor_spatial // ffactor_temporal)
|
||||
)
|
||||
if add_spatial_downsample or add_temporal_downsample:
|
||||
assert i_level < len(block_out_channels) - 1
|
||||
block_out = block_out_channels[i_level + 1] if downsample_match_channel else block_in
|
||||
down.downsample = DownsampleDCAE(block_in, block_out, add_temporal_downsample)
|
||||
block_in = block_out
|
||||
self.down.append(down)
|
||||
|
||||
# middle
|
||||
self.mid = nn.Module()
|
||||
self.mid.block_1 = ResnetBlock(in_channels=block_in, out_channels=block_in)
|
||||
self.mid.attn_1 = AttnBlock(block_in)
|
||||
self.mid.block_2 = ResnetBlock(in_channels=block_in, out_channels=block_in)
|
||||
|
||||
# end
|
||||
self.norm_out = nn.GroupNorm(num_groups=32, num_channels=block_in, eps=1e-6, affine=True)
|
||||
self.conv_out = Conv3d(block_in, 2 * z_channels, kernel_size=3, stride=1, padding=1)
|
||||
|
||||
self.gradient_checkpointing = False
|
||||
|
||||
def forward(self, x: Tensor) -> Tensor:
|
||||
use_checkpointing = bool(self.training and self.gradient_checkpointing)
|
||||
|
||||
# downsampling
|
||||
h = self.conv_in(x)
|
||||
for i_level in range(len(self.block_out_channels)):
|
||||
for i_block in range(self.num_res_blocks):
|
||||
h = forward_with_checkpointing(
|
||||
self.down[i_level].block[i_block], h, use_checkpointing=use_checkpointing
|
||||
)
|
||||
if hasattr(self.down[i_level], "downsample"):
|
||||
h = forward_with_checkpointing(self.down[i_level].downsample, h, use_checkpointing=use_checkpointing)
|
||||
|
||||
# middle
|
||||
h = forward_with_checkpointing(self.mid.block_1, h, use_checkpointing=use_checkpointing)
|
||||
h = forward_with_checkpointing(self.mid.attn_1, h, use_checkpointing=use_checkpointing)
|
||||
h = forward_with_checkpointing(self.mid.block_2, h, use_checkpointing=use_checkpointing)
|
||||
|
||||
# end
|
||||
group_size = self.block_out_channels[-1] // (2 * self.z_channels)
|
||||
shortcut = rearrange(h, "b (c r) f h w -> b c r f h w", r=group_size).mean(dim=2)
|
||||
h = self.norm_out(h)
|
||||
h = swish(h)
|
||||
h = self.conv_out(h)
|
||||
h += shortcut
|
||||
return h
|
||||
|
||||
|
||||
class Decoder(nn.Module):
|
||||
"""
|
||||
The decoder network of AutoencoderKLConv3D.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
z_channels: int,
|
||||
out_channels: int,
|
||||
block_out_channels: tuple[int, ...],
|
||||
num_res_blocks: int,
|
||||
ffactor_spatial: int,
|
||||
ffactor_temporal: int,
|
||||
upsample_match_channel: bool = True,
|
||||
):
|
||||
super().__init__()
|
||||
assert block_out_channels[0] % z_channels == 0
|
||||
|
||||
self.z_channels = z_channels
|
||||
self.block_out_channels = block_out_channels
|
||||
self.num_res_blocks = num_res_blocks
|
||||
|
||||
# z to block_in
|
||||
block_in = block_out_channels[0]
|
||||
self.conv_in = Conv3d(z_channels, block_in, kernel_size=3, stride=1, padding=1)
|
||||
|
||||
# middle
|
||||
self.mid = nn.Module()
|
||||
self.mid.block_1 = ResnetBlock(in_channels=block_in, out_channels=block_in)
|
||||
self.mid.attn_1 = AttnBlock(block_in)
|
||||
self.mid.block_2 = ResnetBlock(in_channels=block_in, out_channels=block_in)
|
||||
|
||||
# upsampling
|
||||
self.up = nn.ModuleList()
|
||||
for i_level, ch in enumerate(block_out_channels):
|
||||
block = nn.ModuleList()
|
||||
block_out = ch
|
||||
for _ in range(self.num_res_blocks + 1):
|
||||
block.append(ResnetBlock(in_channels=block_in, out_channels=block_out))
|
||||
block_in = block_out
|
||||
up = nn.Module()
|
||||
up.block = block
|
||||
|
||||
add_spatial_upsample = bool(i_level < np.log2(ffactor_spatial))
|
||||
add_temporal_upsample = bool(i_level < np.log2(ffactor_temporal))
|
||||
if add_spatial_upsample or add_temporal_upsample:
|
||||
assert i_level < len(block_out_channels) - 1
|
||||
block_out = block_out_channels[i_level + 1] if upsample_match_channel else block_in
|
||||
up.upsample = UpsampleDCAE(block_in, block_out, add_temporal_upsample)
|
||||
block_in = block_out
|
||||
self.up.append(up)
|
||||
|
||||
# end
|
||||
self.norm_out = nn.GroupNorm(num_groups=32, num_channels=block_in, eps=1e-6, affine=True)
|
||||
self.conv_out = Conv3d(block_in, out_channels, kernel_size=3, stride=1, padding=1)
|
||||
|
||||
self.gradient_checkpointing = False
|
||||
|
||||
def forward(self, z: Tensor) -> Tensor:
|
||||
use_checkpointing = bool(self.training and self.gradient_checkpointing)
|
||||
|
||||
# z to block_in
|
||||
repeats = self.block_out_channels[0] // (self.z_channels)
|
||||
h = self.conv_in(z) + z.repeat_interleave(repeats=repeats, dim=1)
|
||||
|
||||
# middle
|
||||
h = forward_with_checkpointing(self.mid.block_1, h, use_checkpointing=use_checkpointing)
|
||||
h = forward_with_checkpointing(self.mid.attn_1, h, use_checkpointing=use_checkpointing)
|
||||
h = forward_with_checkpointing(self.mid.block_2, h, use_checkpointing=use_checkpointing)
|
||||
|
||||
# upsampling
|
||||
for i_level in range(len(self.block_out_channels)):
|
||||
for i_block in range(self.num_res_blocks + 1):
|
||||
h = forward_with_checkpointing(self.up[i_level].block[i_block], h, use_checkpointing=use_checkpointing)
|
||||
if hasattr(self.up[i_level], "upsample"):
|
||||
h = forward_with_checkpointing(self.up[i_level].upsample, h, use_checkpointing=use_checkpointing)
|
||||
|
||||
# end
|
||||
h = self.norm_out(h)
|
||||
h = swish(h)
|
||||
h = self.conv_out(h)
|
||||
return h
|
||||
|
||||
|
||||
class AutoencoderKLConv3D(ModelMixin, ConfigMixin):
|
||||
"""
|
||||
Autoencoder model with KL-regularized latent space based on 3D convolutions.
|
||||
"""
|
||||
|
||||
_supports_gradient_checkpointing = True
|
||||
|
||||
@register_to_config
|
||||
def __init__(
|
||||
self,
|
||||
in_channels: int,
|
||||
out_channels: int,
|
||||
latent_channels: int,
|
||||
block_out_channels: tuple[int, ...],
|
||||
layers_per_block: int,
|
||||
ffactor_spatial: int,
|
||||
ffactor_temporal: int,
|
||||
sample_size: int,
|
||||
sample_tsize: int,
|
||||
scaling_factor: float | None = None,
|
||||
shift_factor: float | None = None,
|
||||
downsample_match_channel: bool = True,
|
||||
upsample_match_channel: bool = True,
|
||||
only_encoder: bool = False, # only build encoder for saving memory
|
||||
only_decoder: bool = False, # only build decoder for saving memory
|
||||
):
|
||||
super().__init__()
|
||||
self.ffactor_spatial = ffactor_spatial
|
||||
self.ffactor_temporal = ffactor_temporal
|
||||
self.scaling_factor = scaling_factor
|
||||
self.shift_factor = shift_factor
|
||||
|
||||
# build model
|
||||
if not only_decoder:
|
||||
self.encoder = Encoder(
|
||||
in_channels=in_channels,
|
||||
z_channels=latent_channels,
|
||||
block_out_channels=block_out_channels,
|
||||
num_res_blocks=layers_per_block,
|
||||
ffactor_spatial=ffactor_spatial,
|
||||
ffactor_temporal=ffactor_temporal,
|
||||
downsample_match_channel=downsample_match_channel,
|
||||
)
|
||||
if not only_encoder:
|
||||
self.decoder = Decoder(
|
||||
z_channels=latent_channels,
|
||||
out_channels=out_channels,
|
||||
block_out_channels=list(reversed(block_out_channels)),
|
||||
num_res_blocks=layers_per_block,
|
||||
ffactor_spatial=ffactor_spatial,
|
||||
ffactor_temporal=ffactor_temporal,
|
||||
upsample_match_channel=upsample_match_channel,
|
||||
)
|
||||
|
||||
# slicing and tiling related
|
||||
self.use_slicing = False
|
||||
self.slicing_bsz = 1
|
||||
self.use_spatial_tiling = False
|
||||
self.use_temporal_tiling = False
|
||||
self.use_tiling_during_training = False
|
||||
|
||||
# only relevant if vae tiling is enabled
|
||||
self.tile_sample_min_size = sample_size
|
||||
self.tile_latent_min_size = sample_size // ffactor_spatial
|
||||
self.tile_sample_min_tsize = sample_tsize
|
||||
self.tile_latent_min_tsize = sample_tsize // ffactor_temporal
|
||||
self.tile_overlap_factor = 0.25
|
||||
|
||||
# use torch.compile for faster encode speed
|
||||
self.use_compile = False
|
||||
|
||||
def _set_gradient_checkpointing(self, module, value=False):
|
||||
if isinstance(module, (Encoder, Decoder)):
|
||||
module.gradient_checkpointing = value
|
||||
|
||||
def blend_h(self, a: torch.Tensor, b: torch.Tensor, blend_extent: int):
|
||||
blend_extent = min(a.shape[-1], b.shape[-1], blend_extent)
|
||||
for x in range(blend_extent):
|
||||
b[:, :, :, :, x] = a[:, :, :, :, -blend_extent + x] * (1 - x / blend_extent) + b[:, :, :, :, x] * (
|
||||
x / blend_extent
|
||||
)
|
||||
return b
|
||||
|
||||
def blend_v(self, a: torch.Tensor, b: torch.Tensor, blend_extent: int):
|
||||
blend_extent = min(a.shape[-2], b.shape[-2], blend_extent)
|
||||
for y in range(blend_extent):
|
||||
b[:, :, :, y, :] = a[:, :, :, -blend_extent + y, :] * (1 - y / blend_extent) + b[:, :, :, y, :] * (
|
||||
y / blend_extent
|
||||
)
|
||||
return b
|
||||
|
||||
def blend_t(self, a: torch.Tensor, b: torch.Tensor, blend_extent: int):
|
||||
blend_extent = min(a.shape[-3], b.shape[-3], blend_extent)
|
||||
for x in range(blend_extent):
|
||||
b[:, :, x, :, :] = a[:, :, -blend_extent + x, :, :] * (1 - x / blend_extent) + b[:, :, x, :, :] * (
|
||||
x / blend_extent
|
||||
)
|
||||
return b
|
||||
|
||||
def spatial_tiled_encode(self, x: torch.Tensor):
|
||||
"""spatial tailing for frames"""
|
||||
B, C, T, H, W = x.shape
|
||||
overlap_size = int(self.tile_sample_min_size * (1 - self.tile_overlap_factor)) # 256 * (1 - 0.25) = 192
|
||||
blend_extent = int(self.tile_latent_min_size * self.tile_overlap_factor) # 8 * 0.25 = 2
|
||||
row_limit = self.tile_latent_min_size - blend_extent # 8 - 2 = 6
|
||||
|
||||
rows = []
|
||||
for i in range(0, H, overlap_size):
|
||||
row = []
|
||||
for j in range(0, W, overlap_size):
|
||||
tile = x[:, :, :, i : i + self.tile_sample_min_size, j : j + self.tile_sample_min_size]
|
||||
tile = self.encoder(tile)
|
||||
row.append(tile)
|
||||
rows.append(row)
|
||||
result_rows = []
|
||||
for i, row in enumerate(rows):
|
||||
result_row = []
|
||||
for j, tile in enumerate(row):
|
||||
if i > 0:
|
||||
tile = self.blend_v(rows[i - 1][j], tile, blend_extent)
|
||||
if j > 0:
|
||||
tile = self.blend_h(row[j - 1], tile, blend_extent)
|
||||
result_row.append(tile[:, :, :, :row_limit, :row_limit])
|
||||
result_rows.append(torch.cat(result_row, dim=-1))
|
||||
moments = torch.cat(result_rows, dim=-2)
|
||||
return moments
|
||||
|
||||
def temporal_tiled_encode(self, x: torch.Tensor):
|
||||
"""temporal tailing for frames"""
|
||||
B, C, T, H, W = x.shape
|
||||
overlap_size = int(self.tile_sample_min_tsize * (1 - self.tile_overlap_factor)) # 64 * (1 - 0.25) = 48
|
||||
blend_extent = int(self.tile_latent_min_tsize * self.tile_overlap_factor) # 8 * 0.25 = 2
|
||||
t_limit = self.tile_latent_min_tsize - blend_extent # 8 - 2 = 6
|
||||
|
||||
row = []
|
||||
for i in range(0, T, overlap_size):
|
||||
tile = x[:, :, i : i + self.tile_sample_min_tsize, :, :]
|
||||
if self.use_spatial_tiling and (
|
||||
tile.shape[-1] > self.tile_sample_min_size or tile.shape[-2] > self.tile_sample_min_size
|
||||
):
|
||||
tile = self.spatial_tiled_encode(tile)
|
||||
else:
|
||||
tile = self.encoder(tile)
|
||||
row.append(tile)
|
||||
result_row = []
|
||||
for i, tile in enumerate(row):
|
||||
if i > 0:
|
||||
tile = self.blend_t(row[i - 1], tile, blend_extent)
|
||||
result_row.append(tile[:, :, :t_limit, :, :])
|
||||
moments = torch.cat(result_row, dim=-3)
|
||||
return moments
|
||||
|
||||
def spatial_tiled_decode(self, z: torch.Tensor):
|
||||
"""spatial tailing for frames"""
|
||||
B, C, T, H, W = z.shape
|
||||
overlap_size = int(self.tile_latent_min_size * (1 - self.tile_overlap_factor)) # 8 * (1 - 0.25) = 6
|
||||
blend_extent = int(self.tile_sample_min_size * self.tile_overlap_factor) # 256 * 0.25 = 64
|
||||
row_limit = self.tile_sample_min_size - blend_extent # 256 - 64 = 192
|
||||
|
||||
rows = []
|
||||
for i in range(0, H, overlap_size):
|
||||
row = []
|
||||
for j in range(0, W, overlap_size):
|
||||
tile = z[:, :, :, i : i + self.tile_latent_min_size, j : j + self.tile_latent_min_size]
|
||||
decoded = self.decoder(tile)
|
||||
row.append(decoded)
|
||||
rows.append(row)
|
||||
|
||||
result_rows = []
|
||||
for i, row in enumerate(rows):
|
||||
result_row = []
|
||||
for j, tile in enumerate(row):
|
||||
if i > 0:
|
||||
tile = self.blend_v(rows[i - 1][j], tile, blend_extent)
|
||||
if j > 0:
|
||||
tile = self.blend_h(row[j - 1], tile, blend_extent)
|
||||
result_row.append(tile[:, :, :, :row_limit, :row_limit])
|
||||
result_rows.append(torch.cat(result_row, dim=-1))
|
||||
dec = torch.cat(result_rows, dim=-2)
|
||||
return dec
|
||||
|
||||
def temporal_tiled_decode(self, z: torch.Tensor):
|
||||
"""temporal tailing for frames"""
|
||||
B, C, T, H, W = z.shape
|
||||
overlap_size = int(self.tile_latent_min_tsize * (1 - self.tile_overlap_factor)) # 8 * (1 - 0.25) = 6
|
||||
blend_extent = int(self.tile_sample_min_tsize * self.tile_overlap_factor) # 64 * 0.25 = 16
|
||||
t_limit = self.tile_sample_min_tsize - blend_extent # 64 - 16 = 48
|
||||
assert 0 < overlap_size < self.tile_latent_min_tsize
|
||||
|
||||
row = []
|
||||
for i in range(0, T, overlap_size):
|
||||
tile = z[:, :, i : i + self.tile_latent_min_tsize, :, :]
|
||||
if self.use_spatial_tiling and (
|
||||
tile.shape[-1] > self.tile_latent_min_size or tile.shape[-2] > self.tile_latent_min_size
|
||||
):
|
||||
decoded = self.spatial_tiled_decode(tile)
|
||||
else:
|
||||
decoded = self.decoder(tile)
|
||||
row.append(decoded)
|
||||
|
||||
result_row = []
|
||||
for i, tile in enumerate(row):
|
||||
if i > 0:
|
||||
tile = self.blend_t(row[i - 1], tile, blend_extent)
|
||||
result_row.append(tile[:, :, :t_limit, :, :])
|
||||
dec = torch.cat(result_row, dim=-3)
|
||||
return dec
|
||||
|
||||
def encode(self, x: Tensor, return_dict: bool = True):
|
||||
"""
|
||||
Encodes the input by passing through the encoder network.
|
||||
Support slicing and tiling for memory efficiency.
|
||||
"""
|
||||
|
||||
def _encode(x):
|
||||
if self.use_temporal_tiling and x.shape[-3] > self.tile_sample_min_tsize:
|
||||
return self.temporal_tiled_encode(x)
|
||||
if self.use_spatial_tiling and (
|
||||
x.shape[-1] > self.tile_sample_min_size or x.shape[-2] > self.tile_sample_min_size
|
||||
):
|
||||
return self.spatial_tiled_encode(x)
|
||||
|
||||
if self.use_compile:
|
||||
|
||||
@torch.compile
|
||||
def encoder(x):
|
||||
return self.encoder(x)
|
||||
|
||||
return encoder(x)
|
||||
return self.encoder(x)
|
||||
|
||||
if len(x.shape) != 5: # (B, C, T, H, W)
|
||||
x = x[:, :, None]
|
||||
assert len(x.shape) == 5 # (B, C, T, H, W)
|
||||
if x.shape[2] == 1:
|
||||
x = x.expand(-1, -1, self.ffactor_temporal, -1, -1)
|
||||
else:
|
||||
assert x.shape[2] != self.ffactor_temporal and x.shape[2] % self.ffactor_temporal == 0
|
||||
|
||||
if self.use_slicing and x.shape[0] > 1:
|
||||
if self.slicing_bsz == 1:
|
||||
encoded_slices = [_encode(x_slice) for x_slice in x.split(1)]
|
||||
else:
|
||||
sections = [self.slicing_bsz] * (x.shape[0] // self.slicing_bsz)
|
||||
if x.shape[0] % self.slicing_bsz != 0:
|
||||
sections.append(x.shape[0] % self.slicing_bsz)
|
||||
encoded_slices = [_encode(x_slice) for x_slice in x.split(sections)]
|
||||
h = torch.cat(encoded_slices)
|
||||
else:
|
||||
h = _encode(x)
|
||||
posterior = DiagonalGaussianDistribution(h)
|
||||
|
||||
if not return_dict:
|
||||
return (posterior,)
|
||||
|
||||
return AutoencoderKLOutput(latent_dist=posterior)
|
||||
|
||||
def decode(self, z: Tensor, return_dict: bool = True, generator=None):
|
||||
"""
|
||||
Decodes the input by passing through the decoder network.
|
||||
Support slicing and tiling for memory efficiency.
|
||||
"""
|
||||
|
||||
def _decode(z):
|
||||
if self.use_temporal_tiling and z.shape[-3] > self.tile_latent_min_tsize:
|
||||
return self.temporal_tiled_decode(z)
|
||||
if self.use_spatial_tiling and (
|
||||
z.shape[-1] > self.tile_latent_min_size or z.shape[-2] > self.tile_latent_min_size
|
||||
):
|
||||
return self.spatial_tiled_decode(z)
|
||||
return self.decoder(z)
|
||||
|
||||
if self.use_slicing and z.shape[0] > 1:
|
||||
decoded_slices = [_decode(z_slice) for z_slice in z.split(1)]
|
||||
decoded = torch.cat(decoded_slices)
|
||||
else:
|
||||
decoded = _decode(z)
|
||||
|
||||
if z.shape[-3] == 1:
|
||||
decoded = decoded[:, :, -1:]
|
||||
|
||||
if not return_dict:
|
||||
return (decoded,)
|
||||
|
||||
return DecoderOutput(sample=decoded)
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -85,6 +85,11 @@ _DIFFUSION_MODELS = {
|
||||
"pipeline_sd3",
|
||||
"StableDiffusion3Pipeline",
|
||||
),
|
||||
"HunyuanImage3ForCausalMM": (
|
||||
"hunyuan_image_3",
|
||||
"pipeline_hunyuan_image_3",
|
||||
"HunyuanImage3Pipeline",
|
||||
),
|
||||
"Flux2KleinPipeline": (
|
||||
"flux2_klein",
|
||||
"pipeline_flux2_klein",
|
||||
|
||||
@@ -87,18 +87,17 @@ class DiffusionModelRunner:
|
||||
return nullcontext()
|
||||
|
||||
# Load model within forward context
|
||||
with set_forward_context(vllm_config=self.vllm_config, omni_diffusion_config=self.od_config):
|
||||
load_config = LoadConfig()
|
||||
model_loader = DiffusersPipelineLoader(load_config)
|
||||
time_before_load = time.perf_counter()
|
||||
load_config = LoadConfig()
|
||||
model_loader = DiffusersPipelineLoader(load_config)
|
||||
time_before_load = time.perf_counter()
|
||||
|
||||
with get_memory_context():
|
||||
with DeviceMemoryProfiler() as m:
|
||||
self.pipeline = model_loader.load_model(
|
||||
od_config=self.od_config,
|
||||
load_device=load_device,
|
||||
)
|
||||
time_after_load = time.perf_counter()
|
||||
with get_memory_context():
|
||||
with DeviceMemoryProfiler() as m:
|
||||
self.pipeline = model_loader.load_model(
|
||||
od_config=self.od_config,
|
||||
load_device=load_device,
|
||||
)
|
||||
time_after_load = time.perf_counter()
|
||||
|
||||
logger.info(
|
||||
"Model loading took %.4f GiB and %.6f seconds",
|
||||
|
||||
@@ -13,10 +13,11 @@ from contextlib import AbstractContextManager, nullcontext
|
||||
|
||||
import torch
|
||||
import zmq
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.config import CompilationConfig, VllmConfig, set_current_vllm_config
|
||||
from vllm.distributed.device_communicators.shm_broadcast import MessageQueue
|
||||
from vllm.logger import init_logger
|
||||
from vllm.utils.mem_utils import GiB_bytes
|
||||
from vllm.v1.worker.workspace import init_workspace_manager
|
||||
|
||||
from vllm_omni.diffusion.data import (
|
||||
DiffusionOutput,
|
||||
@@ -84,13 +85,16 @@ class DiffusionWorker:
|
||||
current_omni_platform.set_device(self.device)
|
||||
|
||||
# Create vllm_config for parallel configuration
|
||||
vllm_config = VllmConfig()
|
||||
vllm_config = VllmConfig(compilation_config=CompilationConfig())
|
||||
vllm_config.parallel_config.tensor_parallel_size = self.od_config.parallel_config.tensor_parallel_size
|
||||
vllm_config.parallel_config.data_parallel_size = self.od_config.parallel_config.data_parallel_size
|
||||
self.vllm_config = vllm_config
|
||||
|
||||
# Initialize distributed environment
|
||||
with set_forward_context(vllm_config=vllm_config, omni_diffusion_config=self.od_config):
|
||||
with (
|
||||
set_forward_context(vllm_config=self.vllm_config, omni_diffusion_config=self.od_config),
|
||||
set_current_vllm_config(self.vllm_config),
|
||||
):
|
||||
init_distributed_environment(world_size=world_size, rank=rank)
|
||||
logger.info(f"Worker {self.rank}: Initialized device and distributed environment.")
|
||||
|
||||
@@ -104,16 +108,17 @@ class DiffusionWorker:
|
||||
tensor_parallel_size=parallel_config.tensor_parallel_size,
|
||||
pipeline_parallel_size=parallel_config.pipeline_parallel_size,
|
||||
)
|
||||
init_workspace_manager(self.device)
|
||||
|
||||
# Create model runner and load model
|
||||
self.model_runner = DiffusionModelRunner(
|
||||
vllm_config=self.vllm_config,
|
||||
od_config=self.od_config,
|
||||
device=self.device,
|
||||
)
|
||||
self.model_runner.load_model(
|
||||
memory_pool_context_fn=self._maybe_get_memory_pool_context,
|
||||
)
|
||||
# Create model runner and load model
|
||||
self.model_runner = DiffusionModelRunner(
|
||||
vllm_config=self.vllm_config,
|
||||
od_config=self.od_config,
|
||||
device=self.device,
|
||||
)
|
||||
self.model_runner.load_model(
|
||||
memory_pool_context_fn=self._maybe_get_memory_pool_context,
|
||||
)
|
||||
assert self.model_runner.pipeline is not None
|
||||
self.lora_manager = DiffusionLoRAManager(
|
||||
pipeline=self.model_runner.pipeline,
|
||||
|
||||
@@ -15,6 +15,7 @@ if TYPE_CHECKING:
|
||||
from .connectors.base import OmniConnectorBase
|
||||
|
||||
from vllm_omni.entrypoints.stage_utils import OmniStageTaskType
|
||||
from vllm_omni.metrics import OrchestratorAggregator
|
||||
|
||||
from .utils.logging import get_connector_logger
|
||||
|
||||
@@ -30,7 +31,7 @@ def try_send_via_connector(
|
||||
sampling_params: Any,
|
||||
original_prompt: Any,
|
||||
next_stage_queue_submit_fn: Callable[[dict[str, Any]], None],
|
||||
metrics: Any,
|
||||
metrics: OrchestratorAggregator,
|
||||
) -> bool:
|
||||
"""
|
||||
Attempts to send data via OmniConnector.
|
||||
|
||||
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user