Merge origin/main into dev/rebase-v0.16.0.

Resolve arg_utils merge conflict and align CI base image tag to v0.16.0 for the vLLM rebase track.

Co-authored-by: Cursor <cursoragent@cursor.com>

Signed-off-by: Taichang Zhou <tzhouam@connect.ust.hk>
This commit is contained in:
Taichang ZhouandCursor committed 2026-02-12 21:18:03 +08:00
commit e2ced80d59
64 files changed
+5186 -1228

No files matched your search

+1 -36
View File
@@ -37,24 +37,7 @@ steps:
timeout_in_minutes: 20
depends_on: image-build
commands:
- pytest -s -v tests/e2e/offline_inference/test_t2i_model.py
agents:
queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU
plugins:
- docker#v5.2.0:
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
always-pull: true
propagate-environment: true
environment:
- "HF_HOME=/fsx/hf_cache"
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "Diffusion Images API LoRA E2E"
timeout_in_minutes: 20
depends_on: image-build
commands:
- pytest -s -v tests/e2e/online_serving/test_images_generations_lora.py
- pytest -s -v tests/e2e/offline_inference/test_t2i_model.py -m "core_model and diffusion" --run-level "core_model"
agents:
queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU
plugins:
@@ -137,24 +120,6 @@ steps:
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "Diffusion Tensor Parallelism Test"
timeout_in_minutes: 20
depends_on: image-build
commands:
- pytest -s -v tests/e2e/offline_inference/test_zimage_parallelism.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
shm-size: "8gb"
environment:
- "HF_HOME=/fsx/hf_cache"
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "Diffusion GPU Worker Test"
timeout_in_minutes: 20
depends_on: image-build
+1 -1
View File
@@ -29,7 +29,7 @@ steps:
timeout_in_minutes: 20
depends_on: image-build
commands:
- pytest -s -v tests/e2e/offline_inference/test_t2i_model.py
- pytest -s -v tests/e2e/offline_inference/test_t2i_model.py -m "advanced_model and diffusion" --run-level "advanced_model"
agents:
queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU
plugins:
+2 -2
View File
@@ -12,8 +12,8 @@ PLEASE FILL IN THE PR DESCRIPTION HERE ENSURING ALL CHECKLIST ITEMS (AT THE BOTT
<summary> Essential Elements of an Effective PR Description Checklist </summary>
- [ ] The purpose of the PR, such as "Fix some issue (link existing issues this PR will resolve)".
- [ ] The test plan, such as providing test command.
- [ ] The test results, such as pasting the results comparison before and after, or e2e results
- [ ] The test plan. Please providing the test scripts & test commands. Please state the reasons if your codes don't require additional test scripts. For test file guidelines, please check the [test style doc](https://docs.vllm.ai/projects/vllm-omni/en/latest/contributing/ci/tests_style/)
- [ ] The test results. Please pasting the results comparison before and after, or e2e results.
- [ ] (Optional) The necessary documentation update, such as updating `supported_models.md` and `examples` for a new model. **Please run `mkdocs serve` to sync the documentation editions to `./docs`.**
- [ ] (Optional) Release notes update. If your change is user facing, please update the release notes draft.
</details>
+2 -2
View File
@@ -1,5 +1,5 @@
ARG VLLM_BASE_IMAGE=vllm/vllm-openai
ARG VLLM_BASE_TAG=v0.15.0
ARG VLLM_BASE_TAG=v0.16.0
FROM ${VLLM_BASE_IMAGE}:${VLLM_BASE_TAG}
ARG APP_DIR=/workspace/vllm-omni
WORKDIR ${APP_DIR}
@@ -14,7 +14,7 @@ RUN apt-get update && \
# Install vLLM wheel for the specific commit
# URL format: https://wheels.vllm.ai/<commit_hash>/
# Current vLLM commit: f97ca671766c5201404e9fc812e35bf2c4e95a01
# Current vLLM wheel commit: ec12d39d44739bee408ec1473acc09e75daf1a5d
# Note: --prerelease=allow is required because nightly/commit wheels use
# pre-release version numbers (e.g. 2.10.0.dev...) which uv ignores by default,
# causing it to fall back to the older stable release from PyPI.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 123 KiB

+678
View File
@@ -0,0 +1,678 @@
# Multi-Level Automated Testing System Documentation
## Document Overview
This testing system aims to build a complete, efficient, and well-structured quality assurance framework for the development, integration, and release of model services. It draws on the concept of the test pyramid from modern software engineering, progressively expanding testing activities from basic code logic verification to complex end-to-end (E2E) functionality, performance, accuracy, and even long-term stability validation.
Through five levels (L1-L5) and common (Common) specifications, the system clarifies the testing objectives, scope, execution frequency, and required resources for different development stages (e.g., each commit, PR merge, daily build, pre-release). This ensures that models meet high standards for functionality, performance, and reliability across various deployment scenarios (online serving and offline inference).
<table>
<thead>
<tr>
<th>Level</th>
<th>Scope & Focus</th>
<th>Time Cost</th>
<th>Test Dir</th>
<th>Doc</th>
<th>Frequency</th>
<th>Hardware</th>
</tr>
</thead>
<tbody>
<tr>
<td rowspan="2"><strong>Common</strong></td>
<td>Contribution Guideline & PR checklist</td>
<td>/</td>
<td>/</td>
<td>.github/PULL_REQUEST_TEMPLATE.md <a href="../tests_style/"> Test Style (PR Checklist)</a></td>
<td>/</td>
<td>/</td>
</tr>
<tr>
<td>CI Failure Description</td>
<td>/</td>
<td>/</td>
<td><a href="../failures/"> CI Failures</a></td>
<td>/</td>
<td>/</td>
</tr>
<tr>
<td><strong>L1</strong><br>(Unit & Logic)</td>
<td>Unit tests for components like entrypoints, models</td>
<td rowspan="2">&lt;15min</td>
<td>/tests/{component_name}/test_xxx</td>
<td>
<a href="#chapter-1-l1-l2-level-testing-unit-testing-and-basic-end-to-end-verification">Chapter 1</a><br>
Section 1 L1&amp;L2: Purpose, Test Content, Directory Location, Example
</td>
<td>PR with ready label (also can run locally)</td>
<td>CPU</td>
</tr>
<tr>
<td><strong>L2</strong><br>(E2E across models & GPU-required UT)</td>
<td>Online & Offline (basic deployment scenarios):<br>dummy, normal inference function (output format, stream), some instance startup UT</td>
<td>
/tests/e2e/online_serving/test_{model_name}.py<br>
/tests/e2e/offline_inference/test_{model_name}.py
</td>
<td>
<a href="#chapter-1-l1-l2-level-testing-unit-testing-and-basic-end-to-end-verification">Chapter 1</a><br>
L1&amp;L2: Purpose, Test Content, Directory Location, Example
</td>
<td>PR with ready label</td>
<td>GPU</td>
</tr>
<tr>
<td><strong>L3</strong><br>(Important Perf & Integration & Accuracy)</td>
<td>Online & Offline (multiple deployment scenarios):<br>real model, normal inference function, normal accuracy</td>
<td>&lt;30min</td>
<td>
/tests/e2e/online_serving/test_{model_name}_expansion.py<br>
/tests/e2e/offline_inference/test_{model_name}_expansion.py
</td>
<td>
<a href="#chapter-2-l3-level-testing-core-integration-performance-and-accuracy-verification">Chapter 2</a><br>
L3: Purpose, Test Content, Directory Location, Example
</td>
<td>PR Merged (Also run L1&L2 Tests)</td>
<td>GPU</td>
</tr>
<tr>
<td><strong>L4</strong><br>(Perf & Integration & Accuracy)</td>
<td>Online & Offline: full functional scenarios + performance test + doc test</td>
<td>&lt;3 hour</td>
<td>
<strong>Full Function:</strong><br>
/tests/e2e/online_serving/test_{model_name}_expansion.py<br>
/tests/e2e/offline_inference/test_{model_name}_expansion.py<br>
<strong>Performance:</strong><br>
/tests/e2e/perf/nightly.json<br>
<strong>Doc Test:</strong><br>
tests/example/online_serving/test_{model_name}.py<br>
tests/example/offline_inference/test_{model_name}.py
</td>
<td>
<a href="#chapter-3-l4-level-testing-full-functionality-performance-and-documentation-testing">Chapter 3</a><br>
L4: Purpose, Test Content, Directory Location, Example
</td>
<td>Nightly</td>
<td>GPU</td>
</tr>
<tr>
<td><strong>L5</strong><br>(Stability & Reliability)</td>
<td>Online & Offline: long-term stability test + reliability test</td>
<td> Depends on reality </td>
<td>
<strong>Stability:</strong><br>
tests/e2e/stability/weekly.json<br>
<strong>Reliability:</strong><br>
tests/e2e/reliability/test_{model_name}.py
</td>
<td>
<a href="#chapter-4-l5-level-testing-stability-and-reliability-testing">Chapter 4</a><br>
L5: Purpose, Test Content, Directory Location, Example
</td>
<td>Weekly / Days before Release</td>
<td>GPU</td>
</tr>
</tbody>
</table>
---
<details>
<summary> The folder structure for tests file based on the 5 levels design</summary>
Legend: `✅` = test exists, `⬜` = suggested to add.
```
vllm_omni/ tests/
├── config/ → ├── config/
│ ├── model.py │ └── test_model.py ⬜
│ └── lora.py │ └── test_lora.py ⬜
│
├── core/ → ├── core/
│ └── sched/ │ └── sched/
│ ├── omni_ar_scheduler.py │ ├── test_omni_ar_scheduler.py ⬜
│ ├── omni_generation_scheduler.py │ ├── test_omni_generation_scheduler.py ⬜
│ └── output.py │ └── test_output.py ✅ currently in entrypoints/test_omni_new_request_data.py (tests output.OmniNewRequestData)
│
├── diffusion/ → ├── diffusion/
│ ├── diffusion_engine.py │ ├── test_diffusion_engine.py ⬜
│ ├── attention/ │ ├── attention/
│ │ ├── layer.py │ │ ├── test_attention_sp.py ✅
│ │ └── backends/ │ │ └── test_flash_attn.py ✅
│ ├── distributed/ │ ├── distributed/
│ │ └── ... │ │ ├── test_comm.py ✅
│ │ │ │ ├── test_cfg_parallel.py ✅
│ │ │ │ └── test_sp_plan_hooks.py ✅
│ ├── lora/ │ ├── lora/
│ │ └── ... │ │ ├── test_base_linear.py ✅
│ │ │ │ └── test_lora_manager.py ✅
│ ├── models/ │ ├── models/
│ │ ├── qwen_image/ │ │ ├── qwen_image/ (e2e coverage)
│ │ ├── z_image/ │ │ └── z_image/
│ │ └── ... │ │ └── test_zimage_tp_constraints.py ✅
│ └── worker/ │ └── worker/
│ ├── diffusion_worker.py │ └── test_diffusion_worker.py ✅ file at diffusion/test_diffusion_worker.py
│ └── diffusion_model_runner.py │
│
├── distributed/ → ├── distributed/
│ └── omni_connectors/ │ └── omni_connectors/
│ ├── adapter.py │ ├── test_adapter_and_flow.py ✅
│ ├── kv_transfer_manager.py │ ├── test_basic_connectors.py ✅
│ ├── connectors/ │ ├── test_kv_flow.py ✅
│ └── utils/ │ └── test_omni_connector_configs.py ✅
│
├── engine/ → ├── engine/
│ ├── input_processor.py │ ├── test_input_processor.py ⬜ (no processor.py in source)
│ ├── output_processor.py │ └── test_output_processor.py ⬜
│ └── arg_utils.py │ └── test_arg_utils.py ⬜
│
├── entrypoints/ → ├── entrypoints/
│ ├── omni.py │ ├── test_omni.py ⬜ (E2E covered by e2e/offline, e2e/online)
│ ├── omni_llm.py │ ├── test_omni_llm.py ✅
│ ├── omni_stage.py │ ├── test_omni_stage.py ⬜ (partial in test_omni_stage_diffusion_config.py)
│ ├── omni_diffusion.py │ ├── test_omni_diffusion.py ✅
│ ├── async_omni.py │ ├── test_async_omni.py ✅ actually in e2e/online_serving/test_async_omni.py
│ ├── async_omni_diffusion.py │ ├── test_async_omni_diffusion_config.py ✅
│ ├── stage_utils.py │ ├── test_stage_utils.py ✅
│ ├── cli/ │ ├── cli/ (benchmarks/test_serve_cli.py covers CLI serve)
│ │ └── ... │ │ └── test_*.py ⬜
│ └── openai/ │ └── openai_api/ # maps to entrypoints/openai/
│ ├── api_server.py │ ├── test_api_server.py ⬜ (e2e indirect coverage)
│ ├── serving_chat.py │ ├── test_serving_chat_sampling_params.py ✅
│ ├── serving_speech.py │ ├── test_serving_speech.py ✅
│ └── image_api_utils.py │ └── test_image_server.py ✅
│
├── inputs/ → ├── inputs/
│ ├── data.py │ ├── test_data.py ⬜
│ ├── parse.py │ ├── test_parse.py ⬜
│ └── preprocess.py │ └── test_preprocess.py ✅ currently in entrypoints/test_omni_input_preprocessor.py
│
├── model_executor/ → ├── model_executor/
│ ├── layers/ │ ├── layers/
│ │ └── mrope.py │ │ └── test_mrope.py ⬜
│ ├── model_loader/ │ ├── model_loader/
│ │ └── weight_utils.py │ │ └── test_weight_utils.py ⬜
│ ├── models/ │ ├── models/
│ │ ├── qwen2_5_omni/ │ │ ├── qwen2_5_omni/
│ │ │ ├── qwen2_5_omni_thinker.py │ │ │ ├── test_audio_length.py ✅
│ │ │ ├── qwen2_5_omni_talker.py │ │ │ ├── test_qwen2_5_omni_thinker.py ⬜
│ │ │ └── qwen2_5_omni_token2wav.py │ │ │ ├── test_qwen2_5_omni_talker.py ⬜
│ │ └── qwen3_omni/ │ │ │ └── test_qwen2_5_omni_token2wav.py ⬜
│ │ └── ... │ │ └── qwen3_omni/
│ ├── stage_configs/ │ │ └── test_*.py ⬜
│ │ └── *.yaml │ └── stage_configs/ (used by e2e, test_*.py can be added) ⬜
│ └── stage_input_processors/ │ └── stage_input_processors/
│ └── ... │ └── test_*.py ⬜
│
├── sample/ → ├── sample/
│ └── __init__.py │ └── test_*.py ⬜
│
├── utils/ → ├── utils/
│ └── __init__.py │ └── test_*.py ⬜ (no platform_utils.py currently)
│
├── worker/ → ├── worker/
│ ├── gpu_ar_model_runner.py │ ├── test_gpu_ar_model_runner.py ⬜
│ ├── gpu_ar_worker.py │ ├── test_gpu_ar_worker.py ⬜
│ ├── gpu_generation_model_runner.py │ ├── test_gpu_generation_model_runner.py ✅
│ ├── gpu_generation_worker.py │ ├── test_gpu_generation_worker.py ⬜
│ ├── gpu_model_runner.py │ ├── test_omni_gpu_model_runner.py ✅
│ └── mixins.py │ └── (npu under platforms/npu/worker/) # not worker/npu/
│
├── platforms/ → (no tests/platforms/, e2e and stage_configs provide indirect coverage)
│ ├── cuda/
│ ├── npu/worker/ # NPU worker here, not vllm_omni/worker/npu/
│ ├── rocm/
│ └── xpu/worker/
│
├── outputs.py → test_outputs.py ✅ (at tests root)
├── (logger, patch, request, version) → (no corresponding unit test)
│
└── e2e (tests side only) → ├── e2e/
├── online_serving/ ✅ non-empty
│ ├── test_async_omni.py
│ ├── test_qwen3_omni.py
│ ├── test_qwen3_omni_expansion.py
│ ├── test_image_gen_edit.py
│ ├── test_images_generations_lora.py
│ └── stage_configs/
└── offline_inference/ ✅
├── test_qwen2_5_omni.py
├── test_qwen3_omni.py
├── test_bagel_text2img.py
├── test_t2i_model.py
├── test_t2v_model.py
├── test_ovis_image.py
├── test_zimage_tensor_parallel.py
├── test_cache_dit.py
├── test_teacache.py
├── test_stable_audio_model.py
├── test_diffusion_cpu_offload.py
├── test_diffusion_layerwise_offload.py
├── test_diffusion_lora.py
├── test_sequence_parallel.py
└── stage_configs/
├── qwen2_5_omni_ci.yaml
├── qwen3_omni_ci.yaml
├── bagel_*.yaml
└── npu/, rocm/, etc.
```
</details>
## Common Specifications
Before entering specific testing levels, the project establishes two common specifications aimed at standardizing the development process and quickly locating issues.
1. ***PR Checklist ([Tests Style](../ci/tests_style.md))***: This template defines the self-check items that must be completed before submitting a code review (Pull Request). It ensures that each code change meets basic requirements such as code style, dependency updates, and documentation synchronization before entering the automated testing pipeline, serving as the first manual line of defense for quality assurance.
2. ***CI Failure Explanation ([CI Failures](../ci/failures.md))***: This document archives and explains common failure patterns in the Continuous Integration (CI) pipeline, error log interpretation, and preliminary troubleshooting steps. It helps developers and testers quickly diagnose the causes of automated test failures, improving problem-solving efficiency.
## Chapter 1: L1 & L2 Level Testing - Unit Testing and Basic End-to-End Verification
### 1.1 Testing Purpose
L1 and L2 level testing form the foundation of the quality assurance system. L1 level testing focuses on verifying the internal logic correctness of code units (e.g., functions, classes), ensuring each independent component behaves as designed.
L2 level testing builds upon L1 by introducing GPU resources and verifying that the end-to-end (E2E) process of the model in basic deployment scenarios is smooth. For example, it uses dummy models to confirm that core interfaces like the inference pipeline, output format, and streaming response work properly. The common goal of these two levels is to provide developers with rapid feedback, discovering and fixing issues early in the development cycle .
### 1.2 Testing Content and Scope
- ***L1 (Unit & Logic Testing)***:
- - ***Scope***: Tests internal functions and methods of core components such as `entrypoints`, `models`.
- ***Focus***: Branch coverage, exception handling, algorithm logic correctness. Does not involve external dependencies or the complete service stack.
- ***Time Cost***: Execution time is controlled within ***15 minutes*** to ensure fast feedback.
- ***L2 (Basic End-to-End Testing)***:
- - ***Scope***: Covers two basic deployment scenarios: `online` (serving) and `offline` (inference).
- ***Focus***: Uses `dummy` models or lightweight real models to verify that the entire chain from request input to result output works normally, including output data structure, streaming (stream) support, etc. Also includes some unit tests that require launching independent service instances.
- ***Characteristic***: Requires ***GPU*** resources to perform model computations.
### 1.3 Test Directory and Execution Files
A clear directory structure is key to managing test cases efficiently.
- ***L1 Test Directory***: `/tests/{component_name}/test_xxx.py`
- - Here, `{component_name}` corresponds to modules in the source code, such as `distributed`, `entrypoints`, etc., and `test_xxx.py` is the specific test file.
- ***L2 Test Directory***:
- - Online Serving: `/tests/e2e/online_serving/test_{model_name}.py`
- Offline Inference: `/tests/e2e/offline_inference/test_{model_name}.py`
### 1.4 Execution Method and Example
- ***Trigger Timing***: **`PR with ready label`**. That is, when a developer adds a "ready for review" or similar label to a PR on platforms like GitHub, L1 and L2 tests are automatically triggered.
- ***Execution Environment***: L1 uses ***CPU*** environment; L2 requires ***GPU*** environment.
- ***Script Example***:
<details>
<summary> L1 Test Examples</summary>
Examples from `tests/model_executor/models/qwen2_5_omni/test_audio_length.py`
```python
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
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
assert resolve_max_mel_frames(None, default=30000) == 30000
assert resolve_max_mel_frames(None, default=6000) == 6000
def test_resolve_max_mel_frames_explicit():
from vllm_omni.model_executor.models.qwen2_5_omni.audio_length import resolve_max_mel_frames
# Explicit argument always wins over default
assert resolve_max_mel_frames(123, default=30000) == 123
assert resolve_max_mel_frames(6000, default=30000) == 6000
assert resolve_max_mel_frames(0, default=30000) == 0
@pytest.mark.parametrize("repeats", [2, 4])
@pytest.mark.parametrize("code_len", [0, 1, 32768])
@pytest.mark.parametrize("max_mel_frames", [None, -1, 0, 1, 6000, 30000])
def test_cap_and_align_mel_length_no_mismatch(repeats, code_len, max_mel_frames):
"""Guard that any max_mel_frames yields a mel length aligned to repeats, and
consistent with the truncated code length (prevents concat mismatch).
"""
from vllm_omni.model_executor.models.qwen2_5_omni.audio_length import cap_and_align_mel_length
target_code_len, target_mel_len = cap_and_align_mel_length(
code_len=code_len,
repeats=repeats,
max_mel_frames=max_mel_frames,
)
assert isinstance(target_code_len, int)
assert isinstance(target_mel_len, int)
if code_len == 0:
assert target_code_len == 0
assert target_mel_len == 0
return
assert target_code_len >= 1
assert target_mel_len >= repeats
assert target_mel_len % repeats == 0
assert target_mel_len == target_code_len * repeats
assert target_code_len <= code_len
if max_mel_frames is not None and int(max_mel_frames) > 0 and int(max_mel_frames) >= repeats:
assert target_mel_len <= int(max_mel_frames)
```
</details>
<details>
<summary> L2 Test Examples</summary>
You can refer to Test Examples in Chapter 2 to see example test cases that incorporate both L2 and L3 testing logic.
</details>
- - ***Run Command***:
`pytest -s -v /tests/e2e/online_serving/test_{model_name}.py`
`pytest -s -v -m 'core_model and cpu' --run-level=core_model`
## Chapter 2: L3 Level Testing - Core Integration, Performance, and Accuracy Verification
### 2.1 Testing Purpose
L3 level testing executes after code is merged into the main branch. Its core purpose is to verify the integration effect, key performance indicators, and output accuracy of ***real models*** in ***multiple deployment scenarios***
. It acts as the "quality gatekeeper" for the main branch, ensuring that no merge breaks the core capabilities of the model service. Testing needs to provide clear conclusions within a relatively short time (<30min), balancing test depth with feedback speed.
### 2.2 Testing Content and Scope
- ***Deployment Scenarios***: Covers richer `online` and `offline` deployment configurations, which may include different hardware configurations, batch sizes, concurrency levels, etc.
- ***Core Verification***:
- 1. ***Inference Functionality***: Ensures real models can perform forward computation normally and return results.
2. ***Accuracy Compliance***: Verifies that the model's evaluation metrics (e.g., accuracy) meet the expected baseline, preventing code changes from introducing accuracy issues.
3. ***Important Performance***: Verifies whether performance (e.g., P99 latency, throughput) in core scenarios meets preset thresholds.
### 2.3 Test Directory and Execution Files
- ***Functional Testing***:
- - Online Serving: `/tests/e2e/online_serving/test_{model_name}_expansion.py`
- Offline Inference: `/tests/e2e/offline_inference/test_{model_name}_expansion.py`
- (Note: `_expansion.py` likely means it contains more comprehensive scenario cases compared to L2 tests).
### 2.4 Execution Method and Example
- ***Trigger Timing***: **`PR Merged`**. Automatically triggered after code review is approved and merged into the main branch.
- ***Execution Environment***: ***GPU*** servers.
- ***Script Example***:
???+ example "Test Examples"
**2.4.1 Mark Declaration Section**
```python
@pytest.mark.advanced_model
@pytest.mark.core_model
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
```
**Explanation**:
@pytest.mark.advanced_model: Marks the test as L3 or L4 level, indicating that this test case performs deep validation, using real models for performance, integration, and accuracy testing. This forms a "basic-advanced" correspondence with the core_model mark at the L2 level.
@pytest.mark.core_model: Marks the test as L1 or L2 level, indicating that this test case validates the basic functionality of the core model. It uses mock weights and only checks if the relevant interface functions correctly.
@pytest.mark.parametrize: A parameterization decorator that allows abstracting test data into parameters, enabling reuse of the same test logic across different data configurations. indirect=True indicates that parameters will be passed to the fixture for processing.
**Notes**: If you believe the test case only needs to execute basic run logic at the PR-level CI, you can mark it only with @pytest.mark.core_model. If you believe it only needs to execute deep validation run logic at the merge or nightly level, you can mark it only with @pytest.mark.advanced_model. If you believe the test case needs to accommodate both basic run and deep validation test logic, you should mark it with both @pytest.mark.core_model and @pytest.mark.advanced_model.
**2.4.2 Test Function Definition and Documentation**
```python
def test_mix_to_text_audio_001(omni_server, openai_client) -> None:
"""
Test multi-modal input processing and text/audio output generation via OpenAI API.
Deploy Setting: default yaml
Input Modal: text + audio + video + image
Output Modal: text + audio
Input Setting: stream=True
Datasets: single request
"""
```
**Explanation**:
**Function Naming Convention**: Uses the test_ prefix, describes the test scenario mix_to_text_audio, and the number 001 indicates the first test case for this scenario.
**Parameter Explanation**:
omni_server: Omni server instance obtained via fixture, containing model information and configuration.
openai_client: Unified OpenAI client processing instance, encapsulating request sending and response validation logic.
Docstring: Describes the test purpose, deployment settings, input/output modalities, streaming settings, and dataset type in detail, providing clear context for test maintenance.
**2.4.3 Multimodal Data Preparation**
```python
video_data_url = f"data:video/mp4;base64,{generate_synthetic_video(224, 224, 300)['base64']}"
image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(224, 224)['base64']}"
audio_data_url = f"data:audio/wav;base64,{generate_synthetic_audio(5, 1)['base64']}"
```
**Explanation**:
**Data Generation Functions**: Use the generate_synthetic_* series of functions to generate synthetic test data, avoiding reliance on external resources and ensuring test reproducibility and stability.
**Parameter Explanation**:
Video: width, height, duration_frames
Image: width, height
Audio: duration_seconds, channels
**2.4.4 Request Configuration and Keyword Validation**
```python
request_config = {
"model": omni_server.model,
"messages": messages,
"stream": True,
"key_words": {
"audio": ["water", "cricket"],
"video": ["sphere", "globe", "circle", "round"],
"image": ["square", "quadrate"],
"text": ["beijing"]
},
}
```
**Explanation**:
**Model Specification**: Uses omni_server.model to ensure the test aligns with the model configured on the server.
**Keyword Validation Mechanism**: This is an innovative design of the template to address the specific needs of multimodal testing:
Audio Keywords: Validate whether the generated text's description of audio content contains expected elements (e.g., "water" for water sounds, "cricket" for cricket sounds). If you provide multiple keywords, the validation is considered successful if at least one keyword is present.
**Video Keywords**: Validate whether the generated text's description of video content contains expected elements. If you provide multiple keywords, the validation is considered successful if at least one keyword is present.
Image Keywords: Validate whether the generated text's description of image content contains expected elements. If you provide multiple keywords, the validation is considered successful if at least one keyword is present.
Text Keywords: Validate whether the generated text contains expected elements. If you provide multiple keywords, the validation is considered successful if at least one keyword is present.
**2.4.5 Request Execution**
```python
openai_client.send_request(request_config, request_num=1)
```
**Explanation**:
**Unified Client**: Uses the OpenAIClientHandler instance to send requests. This client encapsulates error handling, retry mechanisms, and response validation logic.
**Single Request**: The comment clearly states this is a single-request completion test. For concurrent testing, it can be extended to multiple requests using request_num = n.
**Implicit Validation**: The send_request method internally includes validation logic dynamically selected based on the --run-level parameter: core_model performs basic validation, while advanced_model performs deep validation.
- ***Run Command***: `pytest -s -v /tests/e2e/online_serving/test_{model_name}.py -m advanced_model --run-level=advanced_model`
## Chapter 3: L4 Level Testing - Full Functionality, Performance, and Documentation Testing
### 3.1 Testing Purpose
L4 level testing is a comprehensive quality audit before a version release. It expands upon L3, executing ***full*** functional scenarios, conducting systematic ***performance stress tests***, and simultaneously verifying the correctness of accompanying ***example documentation***. Its purpose is to perform deep validation of the system during off-peak nighttime hours, providing quality trend reports for daytime development and data support for release decisions.
### 3.2 Testing Content and Scope
- ***Full Functionality Testing***: Executes all test cases defined in `test_{model_name}_expansion.py`, covering all implemented features, positive flows, boundary conditions, and exception handling.
- ***Performance Testing***: Uses the `/tests/e2e/perf/nightly.json` configuration file to drive performance testing tools for stress, load, and endurance tests, collecting metrics like throughput, response time, and resource utilization.
- ***Documentation Testing***: Verifies whether the example code provided to users is runnable and its results match the description.
### 3.3 Test Directory and Execution Files
- ***Functional Testing***: Same directories as L3.
- ***Performance Test Configuration***: `/tests/e2e/perf/nightly.json`
- ***Documentation Example Tests***:
- - `tests/example/online_serving/test_{model_name}.py`
- `tests/example/offline_inference/test_{model_name}.py`
### 3.4 Execution Method and Example
- ***Trigger Timing***: **`Nightly`**, automatically executed every night.
- ***Execution Environment***: ***GPU*** server clusters to meet the resource demands of performance testing.
- ***Script Example***:
<details>
<summary> Test Examples</summary>
When you want to add L4-level performance test cases, you can refer to the following format for case addition in tests/perf/tests/test.json:
```JSON
{
"test_name": "test_qwen3_omni",
"server_params": {
"model": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
"stage_config_name": "qwen3_omni.yaml"
},
"benchmark_params": [
{
"dataset_name": "random",
"num_prompts": [10, 20],
"request_rate": [0.5, 1],
"random_input_len": 2500,
"random_output_len": 900,
"ignore_eos": true,
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": 100000,
"mean_audio_ttfp_ms": 100000,
"mean_audio_rtf": 100000
}
}
]
}
```
#### Parameter Explanation
***Overview***
| Field | Required | Description |
| ---------------- | -------- | --------------------------------------------------------------- |
| test_name | Yes | Unique identifier for the test case |
| server_params | Yes | Server-side configuration parameters |
| benchmark_params | Yes | Benchmark running parameters (supports multiple configurations) |
#### server_params Configuration
##### Basic Parameters
| Parameter | Required | Example | Description |
| ----------------- | -------- | ---------------------------------- | ----------------------------- |
| model | Yes | "Qwen/Qwen3-Omni-30B-A3B-Instruct" | Model name or path |
| stage_config_name | Yes | "qwen3_omni.yaml" | Stage configuration file name |
##### Dynamic Configuration (update/delete)
Supports incremental modifications based on the basic configuration:
| Operation | Description |
| --------- | ------------------------------------ |
| update | Update or add configuration items |
| delete | Delete specified configuration items |
***Example***:
```
"update": {
"async_chunk": true, // Enable asynchronous chunk processing
"stage_args": {
"0": {
"engine_args.custom_process_next_stage_input_func": "vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker_async_chunk"
}
}
},
"delete": {
"stage_args": {
"2": ["custom_process_input_func"] // Delete this configuration for stage 2
}
}
```
#### benchmark_params Configuration
You can add any benchmark running parameters you need here. For all optional parameters, refer to the [benchmark documentation](https://github.com/vllm-project/vllm-omni/blob/main/docs/cli/bench/serve.md). General modifications are as follows:
1. Change the ---xxx-xx-xx running parameters to xxx_xx_xx format and fill them as keys in the JSON file.
2. For boolean variables in the running parameters, modify them to forms such as ignore_eos: true/false and fill them into the JSON file.
3. Add the baseline parameter to specify the required validation values, ensuring the validation metric names match those in the result.json generated by the benchmark.
4. The qps and concurrency modes are mutually exclusive. For detailed explanations, see the table below:
| Parameter | Type | Required | Example/Values | Description |
| --------------- | ----------- | -------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| num_prompts | int / array | Yes | 10,[10, 20, 30] | Number of requests. Supports single values or arrays. If a single value is used, it will be automatically expanded to match the number of qps or max_concurrency, e.g., [10,10,10]. If an array is used, its length must match the number of qps or max_concurrency. |
| request_rate | int / array | No | 1, [1, 2, 3] | Queries per second. Supports single values or arrays. If a single value is used, it will be automatically expanded to match the number of num_prompts, e.g., [1,1,1]. If an array is used, its length must match the number of num_prompts. |
| max_concurrency | int / array | No | 1, [1, 2, 3] | Queries per second. Supports single values or arrays. If a single value is used, it will be automatically expanded to match the number of num_prompts, e.g., [1,1,1]. If an array is used, its length must match the number of num_prompts. |
</details>
- - ***Run Command***: (Specific commands would depend on the performance testing tool and configuration defined in `nightly.json`).
## Chapter 4: L5 Level Testing - Stability and Reliability Testing
### 4.1 Testing Purpose
L5 level testing focuses on the performance of model services under ***long-running*** and ***abnormal fault*** scenarios. It aims to uncover deep-seated issues that only manifest under sustained pressure or extreme conditions, such as memory leaks, resource contention, gradual performance degradation, and lack of fault tolerance mechanisms. This is the final, yet crucial, line of defense for ensuring service high availability and production environment robustness.
### 4.2 Testing Content and Scope
- ***Long-term Stability (Stability) Testing***: Uses the `tests/e2e/stability/weekly.json` configuration to run the service under moderate load for an extended period (e.g., over 12 hours), monitoring whether metrics like memory/VRAM usage, response time, and throughput degrade over time, and whether the service process remains stable.
- ***Reliability Testing***: Uses `tests/e2e/reliability/test_{model_name}.py` to actively simulate various fault and abnormal scenarios, such as: dependent service interruption, abnormal input data, network flicker, hardware resource preemption, etc., to verify the system's fault tolerance, self-healing, and graceful degradation capabilities.
### 4.3 Test Directory and Execution Files
- ***Stability Test Configuration***: `tests/e2e/stability/weekly.json`
- ***Reliability Test Suite***: `tests/e2e/reliability/test_{model_name}.py`
### 4.4 Execution Method and Example
- ***Trigger Timing***: **`Weekly`** (weekly) or **`Days before Release`** (several days before a major release). Due to long execution times, the frequency is lower.
- ***Execution Environment***: ***GPU*** servers, requiring a stable and exclusive testing environment.
- ***Script Example***:
<details>
<summary> Test Examples</summary>
```python
# WIP
```
</details>
- - ***Stability***: (Execution would be driven by the configuration in `weekly.json`).
- ***Reliability***: `pytest -s -v tests/e2e/reliability/test_{model_name}.py`
## Summary
This multi-level testing system achieves continuous, progressive validation of model service quality by tightly integrating testing activities with the development workflow (commit, review, merge, release). From rapid unit testing to comprehensive end-to-end testing, and further to in-depth performance, stability, and reliability verification, each level has clear objectives, collectively building a robust quality protection net. By following this system, teams can deliver high-quality, highly reliable model services more efficiently.
+8 -2
View File
@@ -1,4 +1,10 @@
# CI Failures
What should I do when a CI job fails on my PR, but I don't think my PR caused
the failure?
What should I do when a CI job fails on my PR, but I don't think my PR caused the failure?
## Common Case 1:
🚨 Error: The command was interrupted by a signal: signal: terminated
Reason: The test is terminated due to exceed the time limits. Sometimes rebuild the test will pass.
+2 -1
View File
@@ -7,7 +7,8 @@ Defined in `pyproject.toml`:
| Marker | Description |
| ------------------ | --------------------------------------------------------- |
| `core_model` | Core model tests (run in each PR) |
| `core_model` | L1&L2 tests (run in each PR) |
| `advanced_model` | L3&L4 level tests (run in each merge or nightly) |
| `diffusion` | Diffusion model tests |
| `omni` | Omni model tests |
| `cache` | Cache backend tests |
+326 -139
View File
@@ -2,8 +2,18 @@
To ensure project maintainability and sustainable development, we encourage contributors to submit test code (unit tests, system tests, or end-to-end tests) alongside their code changes. This document outlines the guidelines for organizing and naming test files.
## Checklist before submitting your test files
1. The file is saved in an appropriate place and the file name is clear.
2. The coding style follows the requirements outlined below.
3. All test functions have appropriate pytest markers.
4. For tests that need run in CI, please ensure it labeled as ``@pytest.mark.core_model` the test is configured under the `./buildkite/` folder.
## Test Types
For more details about our [Five Levels Tests design](../ci/CI_5levels.md).
### Unit Tests and System Tests
For unit tests and system tests, we strongly recommend placing test files in the same directory structure as the source code being tested, using the naming convention `test_*.py`.
@@ -18,95 +28,141 @@ End-to-end tests verify the complete functionality of a system or component. For
## Test Directory Structure
The ideal directory structure mirrors the source code organization:
The ideal directory structure mirrors the source code organization. Legend: `✅` = test exists, `⬜` = suggested to add.
```
vllm_omni/ tests/
├── config/ → ├── config/
│ └── model.py │ └── test_model.py
vllm_omni/ tests/
├── config/ → ├── config/
│ ├── model.py │ └── test_model.py ⬜
│ └── lora.py │ └── test_lora.py ⬜
│
├── core/ → ├── core/
│ └── sched/ │ └── sched/ # Maps to core/sched/
│ ├── omni_ar_scheduler.py │ ├── test_omni_ar_scheduler.py
│ ├── omni_generation_scheduler.py │ ├── test_omni_generation_scheduler.py
│ └── output.py │ └── test_output.py
├── core/ → ├── core/
│ └── sched/ │ └── sched/
│ ├── omni_ar_scheduler.py │ ├── test_omni_ar_scheduler.py ⬜
│ ├── omni_generation_scheduler.py │ ├── test_omni_generation_scheduler.py ⬜
│ └── output.py │ └── test_output.py ✅ currently in entrypoints/test_omni_new_request_data.py (tests output.OmniNewRequestData)
│
├── diffusion/ → ├── diffusion/
│ ├── diffusion_engine.py │ ├── test_diffusion_engine.py
│ ├── omni_diffusion.py │ ├── test_omni_diffusion.py
│ ├── attention/ │ ├── attention/ # Maps to diffusion/attention/
│ │ └── backends/ │ │ └── test_*.py
│ ├── models/ │ ├── models/ # Maps to diffusion/models/
│ │ ├── qwen_image/ │ │ ├── qwen_image/
│ │ │ └── ... │ │ │ └── test_*.py
│ │ └── z_image/ │ │ └── z_image/
│ │ └── ... │ │ └── test_*.py
│ └── worker/ │ └── worker/ # Maps to diffusion/worker/
│ └── ... │ └── test_*.py
├── diffusion/ → ├── diffusion/
│ ├── diffusion_engine.py │ ├── test_diffusion_engine.py ⬜
│ ├── attention/ │ ├── attention/
│ │ ├── layer.py │ │ ├── test_attention_sp.py ✅
│ │ └── backends/ │ │ └── test_flash_attn.py ✅
│ ├── distributed/ │ ├── distributed/
│ │ └── ... │ │ ├── test_comm.py ✅
│ │ │ │ ├── test_cfg_parallel.py ✅
│ │ │ │ └── test_sp_plan_hooks.py ✅
│ ├── lora/ │ ├── lora/
│ │ └── ... │ │ ├── test_base_linear.py ✅
│ │ │ │ └── test_lora_manager.py ✅
│ ├── models/ │ ├── models/
│ │ ├── qwen_image/ │ │ ├── qwen_image/ (e2e coverage)
│ │ ├── z_image/ │ │ └── z_image/
│ │ └── ... │ │ └── test_zimage_tp_constraints.py ✅
│ └── worker/ │ └── worker/
│ ├── diffusion_worker.py │ └── test_diffusion_worker.py ✅ file at diffusion/test_diffusion_worker.py
│ └── diffusion_model_runner.py │
│
├── distributed/ → ├── distributed/
│ └── ... │ └── test_*.py
├── distributed/ → ├── distributed/
│ └── omni_connectors/ │ └── omni_connectors/
│ ├── adapter.py │ ├── test_adapter_and_flow.py ✅
│ ├── kv_transfer_manager.py │ ├── test_basic_connectors.py ✅
│ ├── connectors/ │ ├── test_kv_flow.py ✅
│ └── utils/ │ └── test_omni_connector_configs.py ✅
│
├── engine/ → ├── engine/
│ ├── processor.py │ ├── test_processor.py
│ └── output_processor.py │ └── test_output_processor.py
├── engine/ → ├── engine/
│ ├── input_processor.py │ ├── test_input_processor.py ⬜ (no processor.py in source)
│ ├── output_processor.py │ └── test_output_processor.py ⬜
│ └── arg_utils.py │ └── test_arg_utils.py ⬜
│
├── entrypoints/ → ├── entrypoints/
│ ├── omni_llm.py │ ├── test_omni_llm.py # UT: OmniLLM core logic (mocked)
│ ├── omni_stage.py │ ├── test_omni_stage.py # UT: OmniStage logic
│ ├── omni.py │ ├── test_omni.py # E2E: Omni class (offline inference)
│ ├── async_omni.py │ ├── test_async_omni.py # E2E: AsyncOmni class
│ ├── cli/ │ ├── cli/ # Maps to entrypoints/cli/
│ │ └── ... │ │ └── test_*.py
│ └── openai/ │ └── openai/ # Maps to entrypoints/openai/
│ ├── api_server.py │ ├── test_api_server.py # E2E: API server (online serving)
│ └── serving_chat.py │ └── test_serving_chat.py
├── entrypoints/ → ├── entrypoints/
│ ├── omni.py │ ├── test_omni.py ⬜ (E2E covered by e2e/offline, e2e/online)
│ ├── omni_llm.py │ ├── test_omni_llm.py ✅
│ ├── omni_stage.py │ ├── test_omni_stage.py ⬜ (partial in test_omni_stage_diffusion_config.py)
│ ├── omni_diffusion.py │ ├── test_omni_diffusion.py ✅
│ ├── async_omni.py │ ├── test_async_omni.py ✅ actually in e2e/online_serving/test_async_omni.py
│ ├── async_omni_diffusion.py │ ├── test_async_omni_diffusion_config.py ✅
│ ├── stage_utils.py │ ├── test_stage_utils.py ✅
│ ├── cli/ │ ├── cli/ (benchmarks/test_serve_cli.py covers CLI serve)
│ │ └── ... │ │ └── test_*.py ⬜
│ └── openai/ │ └── openai_api/ # maps to entrypoints/openai/
│ ├── api_server.py │ ├── test_api_server.py ⬜ (e2e indirect coverage)
│ ├── serving_chat.py │ ├── test_serving_chat_sampling_params.py ✅
│ ├── serving_speech.py │ ├── test_serving_speech.py ✅
│ └── image_api_utils.py │ └── test_image_server.py ✅
│
├── inputs/ → ├── inputs/
│ ├── data.py │ ├── test_data.py
│ ├── parse.py │ ├── test_parse.py
│ └── preprocess.py │ └── test_preprocess.py
├── inputs/ → ├── inputs/
│ ├── data.py │ ├── test_data.py ⬜
│ ├── parse.py │ ├── test_parse.py ⬜
│ └── preprocess.py │ └── test_preprocess.py ✅ currently in entrypoints/test_omni_input_preprocessor.py
│
├── model_executor/ → ├── model_executor/
│ ├── layers/ │ ├── layers/
│ │ └── mrope.py │ │ └── test_mrope.py
│ ├── model_loader/ │ ├── model_loader/
│ │ └── weight_utils.py │ │ └── test_weight_utils.py
│ ├── models/ │ ├── models/
│ │ ├── qwen2_5_omni/ │ │ ├── qwen2_5_omni/
│ │ │ ├── qwen2_5_omni_thinker.py │ │ │ ├── test_qwen2_5_omni_thinker.py # UT
│ │ │ ├── qwen2_5_omni_talker.py │ │ │ ├── test_qwen2_5_omni_talker.py # UT
│ │ │ └── qwen2_5_omni_token2wav.py │ │ │ └── test_qwen2_5_omni_token2wav.py # UT
│ │ └── qwen3_omni/ │ │ └── qwen3_omni/
│ │ └── ... │ │ └── test_*.py
│ ├── stage_configs/ │ └── stage_configs/ # Configuration tests (if needed)
│ │ └── ... │ └── test_*.py
│ └── stage_input_processors/ │ └── stage_input_processors/
│ └── ... │ └── test_*.py
├── model_executor/ → ├── model_executor/
│ ├── layers/ │ ├── layers/
│ │ └── mrope.py │ │ └── test_mrope.py ⬜
│ ├── model_loader/ │ ├── model_loader/
│ │ └── weight_utils.py │ │ └── test_weight_utils.py ⬜
│ ├── models/ │ ├── models/
│ │ ├── qwen2_5_omni/ │ │ ├── qwen2_5_omni/
│ │ │ ├── qwen2_5_omni_thinker.py │ │ │ ├── test_audio_length.py ✅
│ │ │ ├── qwen2_5_omni_talker.py │ │ │ ├── test_qwen2_5_omni_thinker.py ⬜
│ │ │ └── qwen2_5_omni_token2wav.py │ │ │ ├── test_qwen2_5_omni_talker.py ⬜
│ │ └── qwen3_omni/ │ │ │ └── test_qwen2_5_omni_token2wav.py ⬜
│ │ └── ... │ │ └── qwen3_omni/
│ ├── stage_configs/ │ │ └── test_*.py ⬜
│ │ └── *.yaml │ └── stage_configs/ (used by e2e, test_*.py can be added) ⬜
│ └── stage_input_processors/ │ └── stage_input_processors/
│ └── ... │ └── test_*.py ⬜
│
├── sample/ → ├── sample/
│ └── ... │ └── test_*.py
├── sample/ → ├── sample/
│ └── __init__.py │ └── test_*.py ⬜
│
├── utils/ → ├── utils/
│ └── platform_utils.py │ └── test_platform_utils.py
├── utils/ → ├── utils/
│ └── __init__.py │ └── test_*.py ⬜ (no platform_utils.py currently)
│
├── worker/ → ├── worker/
├── gpu_ar_worker.py │ ├── test_gpu_ar_worker.py
├── gpu_generation_worker.py │ ├── test_gpu_generation_worker.py
├── gpu_model_runner.py │ ├── test_gpu_model_runner.py
└── npu/ │ └── npu/ # Maps to worker/npu/
└── ... │ └── test_*.py
├── worker/ → ├── worker/
│ ├── gpu_ar_model_runner.py │ ├── test_gpu_ar_model_runner.py ⬜
│ ├── gpu_ar_worker.py │ ├── test_gpu_ar_worker.py ⬜
│ ├── gpu_generation_model_runner.py │ ├── test_gpu_generation_model_runner.py ✅
│ ├── gpu_generation_worker.py │ ├── test_gpu_generation_worker.py ⬜
│ ├── gpu_model_runner.py │ ├── test_omni_gpu_model_runner.py ✅
│ └── mixins.py │ └── (npu under platforms/npu/worker/) # not worker/npu/
│
└── e2e/ → ├── e2e/ # End-to-end scenarios (no 1:1 source mirror)
├── online_serving/ # Full-stack online serving flows
│ └── (empty for now)
└── offline_inference/ # Full offline inference flows
├── test_qwen2_5_omni.py # Moved from multi_stages/
├── test_qwen3_omni.py # Moved from multi_stages_h100/
├── test_t2i_model.py # Moved from single_stage/
└── stage_configs/ # Shared stage configs
├── qwen2_5_omni_ci.yaml
└── qwen3_omni_ci.yaml
├── platforms/ → (no tests/platforms/, e2e and stage_configs provide indirect coverage)
│ ├── cuda/
│ ├── npu/worker/ # NPU worker here, not vllm_omni/worker/npu/
│ ├── rocm/
│ └── xpu/worker/
│
├── outputs.py → test_outputs.py ✅ (at tests root)
├── (logger, patch, request, version) → (no corresponding unit test)
│
└── e2e (tests side only) → ├── e2e/
├── online_serving/ ✅ non-empty
│ ├── test_async_omni.py
│ ├── test_qwen3_omni.py
│ ├── test_qwen3_omni_expansion.py
│ ├── test_image_gen_edit.py
│ ├── test_images_generations_lora.py
│ └── stage_configs/
└── offline_inference/ ✅
├── test_qwen2_5_omni.py
├── test_qwen3_omni.py
├── test_bagel_text2img.py
├── test_t2i_model.py
├── test_t2v_model.py
├── test_ovis_image.py
├── test_zimage_tensor_parallel.py
├── test_cache_dit.py
├── test_teacache.py
├── test_stable_audio_model.py
├── test_diffusion_cpu_offload.py
├── test_diffusion_layerwise_offload.py
├── test_diffusion_lora.py
├── test_sequence_parallel.py
└── stage_configs/
├── qwen2_5_omni_ci.yaml
├── qwen3_omni_ci.yaml
├── bagel_*.yaml
└── npu/, rocm/, etc.
```
@@ -117,6 +173,8 @@ vllm_omni/ tests/
- **E2E Tests**: Place in `tests/e2e/offline_inference/` or `tests/e2e/online_serving/` with descriptive names. Example: `tests/e2e/offline_inference/test_qwen3_omni.py`, `tests/e2e/offline_inference/test_diffusion_model.py`
- **Expansion Tests**
### Best Practices
1. **Mirror Source Structure**: Test directories should mirror the source code structure
@@ -144,81 +202,217 @@ vllm_omni/ tests/
### Template
#### E2E - Online serving
E2E Online tests for Qwen3-Omni model with mix input and audio+text output. Based on `tests/e2e/online_serving/test_qwen3_omni.py`.
```python
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Online E2E smoke test for an omni model (video,text,audio → audio).
E2E Online tests for Qwen3-Omni model with mix input and audio+text output.
"""
import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
import threading
from pathlib import Path
import pytest
import openai
import pytest
from tests.utils import hardware_test
from tests.conftest import (
OmniServer,
convert_audio_to_text,
cosine_similarity_text,
dummy_messages_from_mix_data,
generate_synthetic_video,
merge_base64_and_convert_to_text,
)
from vllm_omni.platforms import current_omni_platform
# Optional: set process start method for workers
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
# Edit: model name and stage config path
models = ["Qwen/Qwen3-Omni-30B-A3B-Instruct"]
#If you use the default configuration file, you can directly use the following address.
def get_default_config():
return str(Path(__file__).parent.parent / "stage_configs" / "qwen3_omni_ci.yaml")
#If you need to modify the configuration file, you can use modify_stage_config.
def get_chunk_config():
path = modify_stage_config(
get_default_config(),
updates={
"async_chunk": True,
"stage_args": {
0: {
"engine_args.custom_process_next_stage_input_func": "vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker_async_chunk"
},
1: {
"engine_args.custom_process_next_stage_input_func": "vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav_async_chunk"
},
},
},
deletes={"stage_args": {2: ["custom_process_input_func"]}},
)
return path
stage_configs = [get_default_config(), CHUNK_CONFIG_PATH]
models = ["{your model name}"] #Edit here to load your model
stage_configs = [str(Path(__file__).parent / "stage_configs" / {your model yaml})] #Edit here to load your model yaml
test_params = [(model, stage_config) for model in models for stage_config in stage_configs]
#OmniServer,Used to start the vllm-omni server
class OmniServer:
xxx
#Please use this method to launch the online instance.
_omni_server_lock = threading.Lock()
@pytest.fixture(scope="module")
def omni_server(request):
"""Start vLLM-Omni server as a subprocess. Use module scope for multi-stage init (10-20+ min)."""
with _omni_server_lock:
model, stage_config_path = request.param
with OmniServer(
model,
["--stage-configs-path", stage_config_path, "--stage-init-timeout", "120"],
) as server:
yield server
@pytest.fixture
def omni_server(request):
model, stage_config_path = request.param
with OmniServer(model, ["--stage-configs-path", stage_config_path]) as server:
yield server
#handle request message
@pytest.fixture(scope="session")
def base64_encoded_video() -> str:
xxx
@pytest.fixture(scope="session")
def dummy_messages_from_video_data(video_data_url: str, content_text: str) -> str:
xxx
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(
res={"cuda": "L4", "rocm": "MI325", "npu": "A2"},
num_cards={"cuda": 2, "rocm": 2, "npu": 4},
)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_video_to_audio(
client: openai.OpenAI,
omni_server,
base64_encoded_video: str,
) -> None:
#set message
video_data_url = f"data:video/mp4;base64, {base64_encoded_video}"
messages = dummy_messages_from_video_data(video_data_url)
#send request
chat_completion = client.chat.completions.create(
model=omni_server.model,
messages=messages,
def client(omni_server):
"""OpenAI client for the running vLLM-Omni server."""
return openai.OpenAI(
base_url=f"http://{omni_server.host}:{omni_server.port}/v1",
api_key="EMPTY",
)
#verify text output
text_choice = chat_completion.choices[0]
assert text_choice.finish_reason == "length"
#Please use function definitions above the test function to define the prompts and other parameters you need.
def get_system_prompt():
return {
"role": "system",
"content": [
{
"type": "text",
"text": (
"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."
),
}
],
}
#verify audio output
audio_choice = chat_completion.choices[1]
audio_message = audio_choice.message
if hasattr(audio_message, "audio") and audio_message.audio:
assert audio_message.audio.data is not None
assert len(audio_message.audio.data) > 0
...
#Please define test case tags according to the instructions in the marker documentation.
@pytest.mark.core_model
@pytest.mark.omni
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_mix_to_text_audio_001(client: openai.OpenAI, omni_server, request) -> None:
# PLEASE FOLLOW THESE TEMPLATE INSTRUCTIONS:
# ============================================================================
# TEMPLATE USAGE GUIDE:
# 1. Copy this entire function as a starting point for multi-modal tests
# 2. Update the test name to reflect your specific test scenario
# 3. Modify input/output modalities as needed (see OPTIONS section below)
# 4. Adjust assertions based on your expected outcomes
# 5. Add custom validation logic for your specific use case
# ============================================================================
#Please list the relevant test points.
"""
Test multi-modal input processing and text/audio output generation via OpenAI API.
Deploy Setting: default yaml
Input Modal: text + audio + video + image
Output Modal: text + audio
Input Setting: stream=True
Datasets: single request
"""
# SECTION 1: TEST SETUP AND INITIALIZATION
# =========================================
# INSTRUCTIONS: Initialize test variables and prepare test environment
# MODIFY: Add any additional test setup required for your scenario
e2e_list = list()
# SECTION 2: TEST DATA GENERATION
# ================================
# INSTRUCTIONS: Generate or load test data for each input modality
# MODIFY: Replace synthetic generators with your actual data sources
# VIDEO DATA - Generate synthetic video for testing
# FORMAT: data:video/mp4;base64,{base64_encoded_video}
# PARAMETERS: width, height, duration_frames
video_data_url = f"data:video/mp4;base64,{generate_synthetic_video(224, 224, 300)['base64']}"
# IMAGE DATA - Generate synthetic image for testing
# FORMAT: data:image/jpeg;base64,{base64_encoded_image}
# PARAMETERS: width, height
image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(224, 224)['base64']}"
# AUDIO DATA - Generate synthetic audio for testing
# FORMAT: data:audio/wav;base64,{base64_encoded_audio}
# PARAMETERS: duration_seconds, channels
audio_data_url = f"data:audio/wav;base64,{generate_synthetic_audio(5, 1)['base64']}"
# SECTION 3: MESSAGE CONSTRUCTION
# ================================
# INSTRUCTIONS: Assemble the complete message payload for API request
# MODIFY: Add/remove modalities or change prompt structure as needed
# USAGE: Construct a message containing all input modalities
# IMPORTANT: Ensure the message structure matches OpenAI API expectations
# CUSTOMIZATION POINTS:
# - system_prompt: Controls the assistant's behavior
# - content_text: The user's text prompt/question
# - *_data_url: URLs for media content (video/image/audio)
messages = dummy_messages_from_mix_data(
system_prompt=get_system_prompt(),
video_data_url=video_data_url,
image_data_url=image_data_url,
audio_data_url=audio_data_url,
content_text=get_prompt("mix"),
)
# SECTION 4: API REQUEST EXECUTION
# =================================
# INSTRUCTIONS: Make the API call and measure performance
# MODIFY: Add timeout, retry logic, or additional parameters
start_time = time.perf_counter()
chat_completion = client.chat.completions.create(model=omni_server.model, messages=messages, stream=True)
#Call using your preferred method and obtain the final audio and text outputs.
...
# SECTION 5: OUTPUT VALIDATION
# =============================
# INSTRUCTIONS: Verify that outputs meet expected criteria
# MODIFY: Adjust validation logic for your specific requirements
# ASSERTION 1: E2E Validation
# PURPOSE: Verify that the E2E latency is less than the baseline.
current_e2e = time.perf_counter() - start_time
print(f"the request e2e is: {current_e2e}")
e2e_list.append(current_e2e)
print(f"the avg e2e is: {sum(e2e_list) / len(e2e_list)}")
# ASSERTION 2: Text Output Validation
# PURPOSE: Verify that text output was generated with keyword content
assert text_content is not None and len(text_content) >= 2, "No text output is generated"
assert any(
keyword in text_content.lower() for keyword in ["square", "quadrate", "sphere", "globe", "circle", "round"]
), "The output does not contain any of the keywords."
# ASSERTION 3: Cross-Modal Consistency
# PURPOSE: Verify text and audio outputs convey the same information
# CUSTOMIZATION: Adjust similarity threshold (0.9) based on accuracy requirements
assert audio_data is not None, "No audio output is generated"
audio_content = merge_base64_and_convert_to_text(audio_data)
print(f"text content is: {text_content}")
print(f"audio content is: {audio_content}")
similarity = cosine_similarity_text(audio_content.lower(), text_content.lower())
print(f"similarity is: {similarity}")
assert similarity > 0.9, "The audio content is not same as the text"
```
#### E2E - Offline inference
```python
# SPDX-License-Identifier: Apache-2.0
@@ -271,10 +465,3 @@ def test_video_to_audio(omni_runner: type[OmniRunner], model: str) -> None:
has_audio = any(o.final_output_type == "audio" for o in outputs)
assert has_audio
```
## Checklist before submitting your test files
1. The file is saved in an appropriate place and the file name is clear.
2. The coding style follows the requirements outlined above.
3. **All test functions have appropriate pytest markers**
4. For tests that need run in CI, please ensure the test is configured under the `./buildkite/` folder.
@@ -763,6 +763,37 @@ omni = Omni(model="your-model",
)
```
### CPU Offload
See detailed guide: [CPU Offloading for Diffusion Models](../../user_guide/diffusion/cpu_offload_diffusion.md)
vLLM-Omni provides two offloading strategies to reduce GPU memory usage:
1. **Model-level offload**: Mutual exclusion between DiT and encoders (only one on GPU at a time)
2. **Layerwise (Blockwise) offload**: Keeps only a single transformer block on GPU at a time with compute-memory overlap
**Usage:** Enable offload when initializing:
```python
# Model-level offload
omni = Omni(model="your-model", enable_cpu_offload=True)
# Layerwise offload
omni = Omni(model="your-model", enable_layerwise_offload=True)
```
**To support layerwise offloading:** Define the blocks attribute name in your transformer:
```python
class WanTransformer3DModel(nn.Module):
_layerwise_offload_blocks_attr = "blocks" # Attribute name containing transformer blocks
def __init__(self):
self.blocks = nn.ModuleList([...]) # Transformer blocks
```
**Note:** Layerwise offloading is primarily recommended for large **video generation models** where the compute cost per block is high enough to effectively overlap with memory prefetch operations.
---
## Troubleshooting
+151
View File
@@ -0,0 +1,151 @@
# Custom Pipeline Extension Guide
Transformer already support Custom Pipeline via
https://github.com/huggingface/diffusers/blob/main/docs/source/en/using-diffusers/custom_pipeline_overview.md
This guide demonstrates how to use the newly added features for extending vLLM-Omni's diffusion pipeline with custom functionality.
## Overview
Three main features enable custom pipeline extension:
1. **`WorkerWrapperBase`**: A wrapper class that enables dynamic worker extension with custom functionality
2. **`load_format`**: A parameter that controls how diffusion models are loaded, including support for custom pipelines
3. **`CustomPipelineWorkerExtension`**: An extension class that enables pipeline re-initialization with custom implementations
## Features
### WorkerWrapperBase
`WorkerWrapperBase` is a wrapper class that creates `DiffusionWorker` instances with optional extension support. It enables dynamic inheritance, allowing you to add custom methods and functionality to workers without modifying the base worker class.
**Key capabilities:**
- Dynamic worker class extension via `worker_extension_cls`
- Support for custom pipeline initialization via `custom_pipeline_args`
- Method delegation to underlying worker
- Attribute access forwarding
**Location:** `vllm_omni/diffusion/worker/diffusion_worker.py`
### load_format Parameter
The `load_format` parameter controls how diffusion models are loaded. It supports the following values:
- **`"default"`**: Standard model loading using the model registry (default behavior)
- **`"custom_pipeline"`**: Load a custom pipeline class specified by `custom_pipeline_name`
- **`"dummy"`**: Skip model loading (useful for testing or when pipeline will be initialized separately)
**Location:** `vllm_omni/diffusion/model_loader/diffusers_loader.py`
### CustomPipelineWorkerExtension
`CustomPipelineWorkerExtension` is a mixin class that extends `DiffusionWorker` with the ability to re-initialize the pipeline with a custom implementation.
**Key method:**
- `re_init_pipeline(custom_pipeline_args)`: Re-initializes the pipeline with custom arguments, properly cleaning up the old pipeline first
**Location:** `vllm_omni/diffusion/worker/diffusion_worker.py`
## Usage Example
### Step 1: Create a Custom Pipeline
Create a custom pipeline class that extends an existing pipeline. In this example, we extend `QwenImageEditPipeline` to add trajectory tracking:
```python
# custom_pipeline.py
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image_edit import QwenImageEditPipeline
import torch
class CustomPipeline(QwenImageEditPipeline):
def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""):
super().__init__(od_config=od_config, prefix=prefix)
def forward(self, req, prompt=None, negative_prompt=None, **kwargs):
# Call parent's forward to get normal output
output = super().forward(req=req, prompt=prompt, negative_prompt=negative_prompt, **kwargs)
# Add custom trajectory data
actual_num_steps = req.sampling_params.num_inference_steps or kwargs.get('num_inference_steps', 50)
output.trajectory_timesteps = torch.linspace(1000, 0, actual_num_steps, dtype=torch.float32)
output.trajectory_latents = torch.randn(actual_num_steps, 1, 16, 64, 64, dtype=torch.float32)
return output
```
### Step 2: Use the Custom Pipeline with Omni
Initialize the `Omni` engine with custom pipeline configuration:
```python
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
# Initialize with custom pipeline
omni = Omni(
model="Qwen/Qwen-Image-Edit",
diffusion_load_format="dummy", # Skip initial loading
custom_pipeline_args={
"pipeline_class": "custom_pipeline.CustomPipeline"
},
)
# Generate with the custom pipeline
outputs = omni.generate(
...
)
# Access custom trajectory data
output = outputs[0].request_output[0]
print(f"Trajectory timesteps shape: {output.metrics['trajectory_timesteps'].shape}")
print(f"Trajectory latents shape: {output.latents.shape}")
```
### Step 3: Run the Example
The example provided in this directory demonstrates the complete workflow:
```bash
cd examples/offline_inference/custom_pipeline/image_to_image
# Run with custom pipeline
python image_edit.py \
--model Qwen/Qwen-Image-Edit-2511 \
--image cherry_blossom.jpg \
--prompt "Let this mascot dance under the moon, surrounded by floating stars" \
--output output_image_edit.png \
--num_inference_steps 10
```
## Advanced Usage
### Custom Worker Extension
You can create custom worker extensions to add new methods beyond pipeline re-initialization:
```python
from typing import Any
from vllm_omni.diffusion.worker.diffusion_worker import CustomPipelineWorkerExtension
class MyCustomExtension(CustomPipelineWorkerExtension):
def custom_method(self):
"""Your custom worker method."""
return "custom_result"
def another_method(self, data: Any):
"""Another custom method."""
# Access worker internals via self
return self.model_runner.some_operation(data)
omni = Omni(
model="Qwen/Qwen-Image-Edit",
diffusion_load_format="dummy",
custom_pipeline_args={
"pipeline_class": "custom_pipeline.CustomPipeline"
},
worker_extension_cls=MyCustomExtension,
# Note: worker_extension_cls is an internal parameter
# CustomPipelineWorkerExtension will automatically init pipeline when custom_pipeline_args is provided
)
```
@@ -1,101 +1,143 @@
# CPU Offloading for Diffusion Model
# CPU Offloading for Diffusion Models
## Overview
vLLM-Omni provides two offloading strategies to reduce GPU memory usage for diffusion models, allowing you to run larger models on GPUs with limited VRAM:
vLLM-Omni provides two offloading strategies to reduce GPU memory usage for diffusion models:
1. **Model-level (Component) Offloading**: Swaps entire model components (DiT transformer, VAE, encoders) between GPU and CPU.
2. **Layerwise (Blockwise) Offloading**: Keeps only a single or a few transformer blocks on GPU at a time, with compute - memory copy overlap.
1. **Model-level (Sequential) Offloading**: Mutual exclusion between DiT model and encoder - only one is on GPU at a time.
2. **Layerwise (Blockwise) Offloading**: Keeps only one transformer block on GPU at a time with compute-memory overlap.
Both approaches use pinned memory for faster CPU-GPU transfers. For now, the two offloading strategies could not be used at the same time.
Both strategies use pinned memory for faster CPU-GPU transfers. The strategies are **mutually exclusive** for now - if both are enabled, layerwise takes priority.
## Model-level CPU Offloading
## Model-level (Sequential) Offloading
### Implementation
### How It Works
CPU offload lets the diffusion worker move large model components between GPU and CPU memory on demand. It keeps the DiT transformer resident on GPU only while it is actively running, and swaps it out when encoders modules need the device. This reduces peak VRAM usage so bigger checkpoints run on smaller GPUs, or multiple requests can share the same GPU.
Model-level offloading implements mutual exclusion between DiT transformer and encoder modules using pre forward hooks:
**Execution Flow**:
1. Text encoders run on GPU while the DiT transformer is offloaded to CPU.
2. Before denoising, weights are prefetched back to GPU, honoring pinned-memory copies for speed.
3. After the diffusion step, the transformer returns to CPU and the process repeats as needed.
- **When encoders run**: DiT transformer is offloaded to CPU
- **When DiT runs**: Encoders are offloaded to CPU
- **VAE**: Stays resident on GPU
Transfers use pinned host buffers, and the worker coordinates swaps via mutex-style hooks so components never compete for memory.
Before each module's forward pass, the hook automatically moves it to GPU while offloading the other module group to CPU. Transfers use pinned memory for speed.
### Configuration
You can enable CPU offload in two ways:
1. **Python API**: set `enable_cpu_offload=True`.
### Usage
**Python API:**
```python
from vllm_omni import Omni
if __name__ == "__main__":
m = Omni(model="Qwen/Qwen-Image",enable_cpu_offload=True)
m = Omni(model="Wan-AI/Wan2.2-T2V-A14B-Diffusers", enable_cpu_offload=True)
```
2. **CLI**: pass `--enable-cpu-offload` to the diffusion service entrypoint.
**CLI:**
```bash
vllm-omni serve diffusion Wan-AI/Wan2.2-T2V-A14B-Diffusers --enable-cpu-offload
```
### Limitations
- Cold start latency increases for over one minute for some models(e.g., Qwen-Image)
- Cold start latency increases
- Adds overhead from CPU-GPU transfers between encoder and denoising phases
- Support single GPU only for now
## Layerwise (Blockwise) Offloading
### Implementation
Layerwise offload operates at transformer block granularity, keeping a single transformer block, or a specified number of blocks, on GPU while others stay in CPU memory.
### How It Works
Unlike full model-wise CPU offload which swaps entire components like DiT and encoders, layerwise offloading applies a sliding window way of loading and offloading weights between gpu and cpu: while block `i` computes, block `i+1` get prefetched asynchronously via pinned memory. In this way, only partial blocks(s) reside on GPU at any moment during inference, so that greatly decrease the memory occupancy.
Layerwise offloading keeps only one transformer block on GPU at a time.
**Execution Flow**:
As each block completes, the next block is prefetched to GPU while the current block is freed. The pre and forward hooks utilized by layerwise offloading apply a separate CUDA stream (`copy_stream`) to overlap weight transfer with computation, and retain flattened tensors in pinned CPU memory for block parameters re-materialization. Encoders, VAE, and non-block DiT modules (embeddings, norms) always stay on GPU.
1. During model initialization, all components are loaded to CPU first. Then components other than DiT model(s) in the pipeline, such as VAE and encoders, are moved to GPU. The weights of target transformer blocks are collected as contiguous tensors per layer on CPU with pinned memory; and non-block modules (embeddings, norms, etc) in the DiT model are moved to and stay on GPU.
2. The first block(s) are transferred to GPU during initialization of `LayerwiseOffloader`, before the first denoising step of the very first request.
3. As each block executes, the next block prefetches on a separate CUDA stream for compute - memory copy overlap. After execution, the current block is immediately freed from GPU memory.
4. When the last block completes, the first block prefetches for the next denoising step.
**Execution Flow:**
| Block | Pre-forward Hook | Forward | Post-forward Hook |
|-------|------------------|---------|-------------------|
| block-0 | Prefetch block-1 (async) | Compute block-0 | Free block-0 |
| block-1 | Prefetch block-2 (async) | Compute block-1 | Free block-1 |
| ... | ... | ... | ... |
| block-(n-1) | **Prefetch block-0** (async) | Compute block-(n-1) | Free block-(n-1) |
Example of hook executions of a DiT model with n layers, by default keep a single layer on GPU:
| Layer (block) idx | forward pre-hook | forward | forward post-hook |
|-------------------|--------------------------------|------------------|---------------------------|
| layer-0 | prefetch layer 1 (copy stream) | compute layer 0 | free layer-0 gpu weights |
| layer-1 | prefetch layer 2 (copy stream) | compute layer 1 | free layer-1 gpu weights |
| layer-2 | prefetch layer 3 (copy stream) | compute layer 2 | free layer-2 gpu weights |
| ... | ... | ... | ... |
| layer-(n-1) | **prefetch layer 0 (copy stream)** | compute layer (n-1) | free layer (n-1) gpu weights |
Each transformer block has a `LayerwiseOffloadHook` that prefetches the next block before forward and frees the current block after forward.
Layerwise offloading is primarily recommended for large **video generation models** where the compute cost per block is high enough to effectively overlap with memory prefetch operations. For example, Wan2.2 T2V and I2V pipelines.
### Configuration
1. **Python API**: set `enable_layerwise_offload=True` and optionally `layerwise_num_gpu_layers`.
### Usage
**Python API:**
```python
from vllm_omni import Omni
if __name__ == "__main__":
m = Omni(
model="Wan-AI/Wan2.2-T2V-A14B-Diffusers",
enable_layerwise_offload=True,
...
)
# Text-to-video
m = Omni(model="Wan-AI/Wan2.2-T2V-A14B-Diffusers", enable_layerwise_offload=True)
# Or image-to-video
m = Omni(model="Wan-AI/Wan2.2-I2V-A14B-Diffusers", enable_layerwise_offload=True)
```
2. **CLI**: pass `--enable-layerwise-offload` and `--layerwise-num-gpu-layers` to the diffusion service entrypoint.
**CLI:**
```bash
# Text-to-video
vllm-omni serve diffusion Wan-AI/Wan2.2-T2V-A14B-Diffusers --enable-layerwise-offload
### Supported Models
# Or image-to-video
vllm-omni serve diffusion Wan-AI/Wan2.2-I2V-A14B-Diffusers --enable-layerwise-offload
```
| Architecture | Models | Example HF Models | DiT Model Cls | Blocks Attr Name |
|--------------|--------|-------------------|----------|----------|
| `QwenImagePipeline` | Qwen-Image-Edit | `Qwen/Qwen-Image` | `QwenImageTransformer2DModel` | "transformer_blocks" |
| `Wan22Pipeline` | Wan2.2 | `Wan-AI/Wan2.2-T2V-A14B-Diffusers` | `WanTransformer3DModel` | "blocks" |
### To Support a Model
NOTE: Models must define `_layerwise_offload_blocks_attr` class attribute so that the layerwise offloader finds the target transformer blocks.
Models must define the blocks attribute name for layerwise offloading:
```python
class WanTransformer3DModel(nn.Module):
_layerwise_offload_blocks_attr = "blocks" # Attribute name containing transformer blocks
def __init__(self):
self.blocks = nn.ModuleList([...]) # Transformer blocks
```
### Limitations
- Cold start latency increases because of
1) components are loaded to CPU first at the very first during initialization,
1) components are loaded to CPU first at the very first during initialization,
2) weight consolidation and pinning
- Performance depends on CPU <-> GPU interconnection (e.g., PCIe bandwidth).
- Performance depends on compute cost and H2D bandwidth as well
- Support single GPU only for now
### Implementation Notes
**Module Discovery**
The offloader automatically discovers pipeline components:
- **DiT modules**: `transformer`, `transformer_2`, `dit`
- **Encoders**: `text_encoder`, `text_encoder_2`, `text_encoder_3`, `image_encoder`
- **VAE**: `vae`
**Hook System**
Both strategies use vLLM-Omni's hook registry system (`HookRegistry` and `ModelHook`) to register pre/post forward callbacks on modules, enabling automatic swapping without modifying model code.
**Backend Architecture**
```
OffloadBackend (base class)
├── ModelLevelOffloadBackend → uses SequentialOffloadHook
└── LayerWiseOffloadBackend → uses LayerwiseOffloadHook
```
Factory function `get_offload_backend()` selects the appropriate backend based on configuration.
## Supported Models
| Architecture | Example Models | DiT Class | Model-Level Offload | Layerwise Offload | Blocks Attr (Layerwise specific) |
|--------------|----------------|-----------|---------------------|-------------------|-------------|
| Wan22Pipeline | `Wan-AI/Wan2.2-T2V-A14B-Diffusers` | `WanTransformer3DModel` | ✓ | ✓ | `"blocks"` |
| Wan22I2VPipeline | `Wan-AI/Wan2.2-I2V-A14B-Diffusers` | `WanTransformer3DModel` | ✓ | ✓ | `"blocks"` |
| QwenImagePipeline | `Qwen/Qwen-Image` | `QwenImageTransformer2DModel` | ✓ | ✓ | `"transformer_blocks"` |
**Notes:**
- Model-Level Offloading is expected to be supported by all common diffusion models (DiT and encoders) naturally
- Layerwise Offloading requires DiT class to define `_layerwise_offload_blocks_attr` pointing to transformer blocks
@@ -30,7 +30,7 @@ The following table shows which models are currently supported by parallelism me
| **Qwen-Image-Edit-2509** | `Qwen/Qwen-Image-Edit-2509` | ✅ | ✅ | ✅ | ✅ | ❌ |
| **Qwen-Image-Layered** | `Qwen/Qwen-Image-Layered` | ✅ | ✅ | ✅ | ✅ | ❌ |
| **Z-Image** | `Tongyi-MAI/Z-Image-Turbo` | ✅ | ✅ | ❌ | ✅ (TP=2 only) | ✅ |
| **Stable-Diffusion3.5** | `stabilityai/stable-diffusion-3.5` | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Stable-Diffusion3.5** | `stabilityai/stable-diffusion-3.5` | ❌ | ❌ | ❌ | ✅ | ❌ |
| **FLUX.2-klein** | `black-forest-labs/FLUX.2-klein-4B` | ❌ | ❌ | ❌ | ✅ | ❌ |
| **FLUX.1-dev** | `black-forest-labs/FLUX.1-dev` | ❌ | ❌ | ✅ | ✅ | ❌ |
+16 -1
View File
@@ -46,6 +46,12 @@ def parse_args():
parser.add_argument("--stage-configs-path", type=str, default=None)
parser.add_argument("--steps", type=int, default=50, help="Number of inference steps.")
parser.add_argument("--cfg-text-scale", type=float, default=4.0, help="Text CFG scale (default: 4.0)")
parser.add_argument("--cfg-img-scale", type=float, default=1.5, help="Image CFG scale (default: 1.5)")
parser.add_argument(
"--negative-prompt", type=str, default=None, help="Negative prompt (not yet supported, reserved for future)"
)
args = parser.parse_args()
return args
@@ -102,6 +108,10 @@ def main():
seed=52,
need_kv_receive=False,
num_inference_steps=args.steps,
extra_args={
"cfg_text_scale": args.cfg_text_scale,
"cfg_img_scale": args.cfg_img_scale,
},
),
)
@@ -158,7 +168,12 @@ def main():
if args.modality == "text2img":
params_list[0].max_tokens = 1 # type: ignore # The first stage is a SamplingParam (vllm)
if len(params_list) > 1:
params_list[1].num_inference_steps = args.steps # type: ignore # The second stage is an OmniDiffusionSamplingParam
diffusion_params = params_list[1]
diffusion_params.num_inference_steps = args.steps # type: ignore
diffusion_params.extra_args = { # type: ignore
"cfg_text_scale": args.cfg_text_scale,
"cfg_img_scale": args.cfg_img_scale,
}
omni_outputs = list(omni.generate(prompts=formatted_prompts, sampling_params_list=params_list))
@@ -0,0 +1,80 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import logging
from typing import Any
import PIL.Image
import torch
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image_edit import QwenImageEditPipeline
from vllm_omni.diffusion.request import OmniDiffusionRequest
logger = logging.getLogger(__name__)
class CustomPipeline(QwenImageEditPipeline):
def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""):
super().__init__(od_config=od_config, prefix=prefix)
def forward(
self,
req: OmniDiffusionRequest,
prompt: str | list[str] | None = None,
negative_prompt: str | list[str] | None = None,
image: PIL.Image.Image | torch.Tensor | None = None,
true_cfg_scale: float = 4.0,
height: int | None = None,
width: int | None = None,
num_inference_steps: int = 50,
sigmas: list[float] | None = None,
guidance_scale: float = 1.0,
num_images_per_prompt: int = 1,
generator: torch.Generator | list[torch.Generator] | None = None,
latents: torch.Tensor | None = None,
prompt_embeds: torch.Tensor | None = None,
prompt_embeds_mask: torch.Tensor | None = None,
negative_prompt_embeds: torch.Tensor | None = None,
negative_prompt_embeds_mask: torch.Tensor | None = None,
output_type: str | None = "pil",
attention_kwargs: dict[str, Any] | None = None,
callback_on_step_end_tensor_inputs: list[str] = ["latents"],
max_sequence_length: int = 512,
) -> DiffusionOutput:
"""Forward pass for image editing with dummy trajectory data."""
# Call parent's forward to get the normal output
output = super().forward(
req=req,
prompt=prompt,
negative_prompt=negative_prompt,
image=image,
true_cfg_scale=true_cfg_scale,
height=height,
width=width,
num_inference_steps=num_inference_steps,
sigmas=sigmas,
guidance_scale=guidance_scale,
num_images_per_prompt=num_images_per_prompt,
generator=generator,
latents=latents,
prompt_embeds=prompt_embeds,
prompt_embeds_mask=prompt_embeds_mask,
negative_prompt_embeds=negative_prompt_embeds,
negative_prompt_embeds_mask=negative_prompt_embeds_mask,
output_type=output_type,
attention_kwargs=attention_kwargs,
callback_on_step_end_tensor_inputs=callback_on_step_end_tensor_inputs,
max_sequence_length=max_sequence_length,
)
# Get actual num_inference_steps used
actual_num_steps = req.sampling_params.num_inference_steps or num_inference_steps
# Create dummy trajectory data
dummy_trajectory_latents = torch.randn(actual_num_steps, 1, 16, 64, 64, dtype=torch.float32)
# Inject dummy trajectory data into output
output.trajectory_latents = dummy_trajectory_latents
return output
@@ -0,0 +1,262 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
======================================================
Qwen Image Editor (via vLLM-Omni)
======================================================
Example CLI for Qwen-Image-Edit and compatible models.
This script edits, combines, or layers images according to a text prompt
using the vLLM Omni diffusion backend.
Examples
---------
Single image edit:
python image_edit.py \
--image input.png \
--prompt "Let this mascot dance under the moon, surrounded by floating stars and poetic bubbles saying 'Be Kind'" \
--output output.png
Multiple image composition:
python image_edit.py \
--image input1.png input2.png \
--prompt "Combine these into a single magical composition" \
--output combined.png
Accelerated with cache_dit:
python image_edit.py \
--image input.png \
--prompt "Edit description" \
--cache_backend cache_dit \
--cache_dit_enable_taylorseer
Layered RGBA output:
python image_edit.py \
--model "Qwen/Qwen-Image-Layered" \
--image input.png \
--prompt "" \
--output layered \
--layers 4 \
--color-format RGBA
"""
import argparse
import asyncio
import os
import time
from pathlib import Path
import torch
from PIL import Image
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.platforms import current_omni_platform
# ===========================
# Argument Parser
# ===========================
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Edit or generate images using Qwen-Image-Edit.")
parser.add_argument("--model", default="Qwen/Qwen-Image-Edit", help="Model name or local path.")
parser.add_argument("--image", type=str, nargs="+", required=True, help="Input image file(s).")
parser.add_argument("--prompt", type=str, required=True, help="Edit description prompt.")
parser.add_argument("--negative_prompt", type=str, default=None)
parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--cfg_scale", type=float, default=4.0)
parser.add_argument("--guidance_scale", type=float, default=1.0)
parser.add_argument("--output", type=str, default="output.png")
parser.add_argument("--num_outputs_per_prompt", type=int, default=1)
parser.add_argument("--num_inference_steps", type=int, default=50)
parser.add_argument("--cache_backend", type=str, default=None, choices=["cache_dit", "tea_cache"])
parser.add_argument("--ulysses_degree", type=int, default=1)
parser.add_argument("--ring_degree", type=int, default=1)
parser.add_argument("--tensor_parallel_size", type=int, default=1)
parser.add_argument("--layers", type=int, default=4)
parser.add_argument("--resolution", type=int, default=640)
parser.add_argument("--color-format", type=str, default="RGB")
# Acceleration + Optimization Options
parser.add_argument("--cache_dit_fn_compute_blocks", type=int, default=1)
parser.add_argument("--cache_dit_bn_compute_blocks", type=int, default=0)
parser.add_argument("--cache_dit_max_warmup_steps", type=int, default=4)
parser.add_argument("--cache_dit_residual_diff_threshold", type=float, default=0.24)
parser.add_argument("--cache_dit_max_continuous_cached_steps", type=int, default=3)
parser.add_argument("--cache_dit_enable_taylorseer", action="store_true", default=False)
parser.add_argument("--cache_dit_taylorseer_order", type=int, default=1)
parser.add_argument(
"--cache_dit_scm_steps_mask_policy", type=str, default=None, choices=[None, "slow", "medium", "fast", "ultra"]
)
parser.add_argument("--cache_dit_scm_steps_policy", type=str, default="dynamic", choices=["dynamic", "static"])
parser.add_argument("--tea_cache_rel_l1_thresh", type=float, default=0.2)
parser.add_argument("--cfg_parallel_size", type=int, default=1, choices=[1, 2])
parser.add_argument("--enforce_eager", action="store_true")
parser.add_argument("--vae_use_slicing", action="store_true")
parser.add_argument("--vae_use_tiling", action="store_true")
parser.add_argument("--enable-cpu-offload", action="store_true")
return parser.parse_args()
# ===========================
# Core Logic
# ===========================
async def main():
args = parse_args()
# ---- Load input images ----
input_images: list[Image.Image] = []
for image_path in args.image:
if not os.path.exists(image_path):
raise FileNotFoundError(f"Input image not found: {image_path}")
input_images.append(Image.open(image_path).convert(args.color_format))
# Single or multi-image input
input_image: Image.Image | list[Image.Image] = input_images[0] if len(input_images) == 1 else input_images
# ---- Torch setup ----
generator = torch.Generator(device=current_omni_platform.device_type).manual_seed(args.seed)
parallel_config = DiffusionParallelConfig(
ulysses_degree=args.ulysses_degree,
ring_degree=args.ring_degree,
cfg_parallel_size=args.cfg_parallel_size,
tensor_parallel_size=args.tensor_parallel_size,
)
# ---- Cache Config ----
if args.cache_backend == "cache_dit":
cache_config = {
"Fn_compute_blocks": args.cache_dit_fn_compute_blocks,
"Bn_compute_blocks": args.cache_dit_bn_compute_blocks,
"max_warmup_steps": args.cache_dit_max_warmup_steps,
"residual_diff_threshold": args.cache_dit_residual_diff_threshold,
"max_continuous_cached_steps": args.cache_dit_max_continuous_cached_steps,
"enable_taylorseer": args.cache_dit_enable_taylorseer,
"taylorseer_order": args.cache_dit_taylorseer_order,
"scm_steps_mask_policy": args.cache_dit_scm_steps_mask_policy,
"scm_steps_policy": args.cache_dit_scm_steps_policy,
}
elif args.cache_backend == "tea_cache":
cache_config = {"rel_l1_thresh": args.tea_cache_rel_l1_thresh}
else:
cache_config = None
# ---- Initialize Omni ----
omni = Omni(
model=args.model,
vae_use_slicing=args.vae_use_slicing,
vae_use_tiling=args.vae_use_tiling,
cache_backend=args.cache_backend,
cache_config=cache_config,
parallel_config=parallel_config,
enforce_eager=args.enforce_eager,
enable_cpu_offload=args.enable_cpu_offload,
diffusion_load_format="dummy",
custom_pipeline_args={"pipeline_class": "custom_pipeline.CustomPipeline"},
)
print(">>> Pipeline loaded successfully")
# ---- Profiling + Info ----
profiler_enabled = bool(os.getenv("VLLM_TORCH_PROFILER_DIR"))
print(f"\n{'=' * 60}")
print("Generation Configuration")
print(f"Model: {args.model}")
print(f"Inference steps: {args.num_inference_steps}")
print(f"Cache: {args.cache_backend or 'None'}")
print(f"Input(s): {len(input_images)} image(s)")
for idx, img in enumerate(input_images):
print(f" • Image {idx + 1}: size={img.size}")
print(
f"Parallel config: Ulysses={args.ulysses_degree}, Ring={args.ring_degree}, "
f"CFG Parallel={args.cfg_parallel_size}, Tensor Parallel={args.tensor_parallel_size}"
)
print(f"{'=' * 60}\n")
if profiler_enabled:
omni.start_profile()
# ---- Generation ----
t0 = time.perf_counter()
outputs = omni.generate(
{
"prompt": args.prompt,
"negative_prompt": args.negative_prompt,
"multi_modal_data": {"image": input_image},
},
OmniDiffusionSamplingParams(
generator=generator,
true_cfg_scale=args.cfg_scale,
guidance_scale=args.guidance_scale,
num_inference_steps=args.num_inference_steps,
num_outputs_per_prompt=args.num_outputs_per_prompt,
layers=args.layers,
resolution=args.resolution,
),
)
t1 = time.perf_counter()
print(f"Generation completed in {t1 - t0:.2f}s")
if profiler_enabled:
omni.stop_profile()
# ---- Output Validation ----
if not outputs:
raise ValueError("No output produced from omni.generate()")
first_out = outputs[0].request_output[0]
req_out: OmniRequestOutput = first_out
# Verify trajectory data (from custom pipeline)
print(f"\n{'=' * 60}")
print("TRAJECTORY VERIFICATION:")
assert hasattr(req_out, "latents") and req_out.latents is not None
print(f" ✓ trajectory_latents shape: {req_out.latents.shape}")
print(f" ✓ trajectory_latents dtype: {req_out.latents.dtype}")
print(f" ✓ trajectory_latents mean: {req_out.latents.mean().item():.6f}")
print(f" ✓ trajectory_latents std: {req_out.latents.std().item():.6f}")
assert req_out.latents.shape[0] == args.num_inference_steps, (
f"Expected {args.num_inference_steps} latent snapshots, got {req_out.latents.shape[0]}"
)
print(f"{'=' * 60}\n")
# ---- Save images ----
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
suffix = output_path.suffix or ".png"
stem = output_path.stem or "output"
images = req_out.images
if args.num_outputs_per_prompt <= 1:
img = images[0]
if isinstance(img, list): # Layered
for i, sub_img in enumerate(img):
save_path = output_path.parent / f"{stem}_{i}{suffix}"
sub_img.save(save_path)
print(f"Saved layer {i}: {save_path.resolve()}")
else:
img.save(output_path)
print(f"Saved: {output_path.resolve()}")
else:
for idx, img in enumerate(images):
if isinstance(img, list):
for sub_idx, sub_img in enumerate(img):
save_path = output_path.parent / f"{stem}_{idx}_{sub_idx}{suffix}"
sub_img.save(save_path)
print(f"Saved composite: {save_path.resolve()}")
else:
save_path = output_path.parent / f"{stem}_{idx}{suffix}"
img.save(save_path)
print(f"Saved: {save_path.resolve()}")
# ===========================
# Entrypoint
# ===========================
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,6 @@
python image_edit.py \
--model Qwen/Qwen-Image-Edit-2511 \
--image cherry_blossom.jpg \
--prompt "Let this mascot dance under the moon, surrounded by floating stars and poetic bubbles such as 'Be Kind'" \
--output output_image_edit.png \
--num_inference_steps 10
@@ -300,12 +300,6 @@ def parse_args() -> argparse.Namespace:
action="store_true",
help="Enable layerwise (blockwise) offloading on DiT modules.",
)
parser.add_argument(
"--layerwise-num-gpu-layers",
type=int,
default=1,
help="Number of ready layers (blocks) to keep on GPU during generation.",
)
return parser.parse_args()
@@ -362,7 +356,6 @@ def main():
omni = Omni(
model=args.model,
enable_layerwise_offload=args.enable_layerwise_offload,
layerwise_num_gpu_layers=args.layerwise_num_gpu_layers,
vae_use_slicing=args.vae_use_slicing,
vae_use_tiling=args.vae_use_tiling,
cache_backend=args.cache_backend,
@@ -80,12 +80,6 @@ def parse_args() -> argparse.Namespace:
action="store_true",
help="Enable layerwise (blockwise) offloading on DiT modules.",
)
parser.add_argument(
"--layerwise-num-gpu-layers",
type=int,
default=1,
help="Number of ready layers (blocks) to keep on GPU during generation.",
)
parser.add_argument(
"--cfg-parallel-size",
type=int,
@@ -139,7 +133,6 @@ def main():
omni = Omni(
model=args.model,
enable_layerwise_offload=args.enable_layerwise_offload,
layerwise_num_gpu_layers=args.layerwise_num_gpu_layers,
vae_use_slicing=args.vae_use_slicing,
vae_use_tiling=args.vae_use_tiling,
boundary_ratio=args.boundary_ratio,
@@ -113,12 +113,6 @@ def parse_args() -> argparse.Namespace:
action="store_true",
help="Enable layerwise (blockwise) offloading on DiT modules.",
)
parser.add_argument(
"--layerwise-num-gpu-layers",
type=int,
default=1,
help="Number of ready layers (blocks) to keep on GPU during generation.",
)
parser.add_argument(
"--quantization",
type=str,
@@ -222,7 +216,6 @@ def main():
omni = Omni(
model=args.model,
enable_layerwise_offload=args.enable_layerwise_offload,
layerwise_num_gpu_layers=args.layerwise_num_gpu_layers,
vae_use_slicing=args.vae_use_slicing,
vae_use_tiling=args.vae_use_tiling,
cache_backend=args.cache_backend,
@@ -84,12 +84,6 @@ def parse_args() -> argparse.Namespace:
action="store_true",
help="Enable layerwise (blockwise) offloading on DiT modules.",
)
parser.add_argument(
"--layerwise-num-gpu-layers",
type=int,
default=1,
help="Number of ready layers (blocks) to keep on GPU during generation.",
)
parser.add_argument(
"--ulysses-degree",
type=int,
@@ -155,7 +149,6 @@ def main():
omni = Omni(
model=args.model,
enable_layerwise_offload=args.enable_layerwise_offload,
layerwise_num_gpu_layers=args.layerwise_num_gpu_layers,
vae_use_slicing=args.vae_use_slicing,
vae_use_tiling=args.vae_use_tiling,
boundary_ratio=args.boundary_ratio,
+1
View File
@@ -13,3 +13,4 @@ openai-whisper>=20250625
imageio[ffmpeg]>=2.37.2
sox>=1.5.0
prettytable>=3.8.0
aenum==3.1.16
+578
View File
@@ -0,0 +1,578 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Unit tests for patch.py
"""
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from vllm.benchmarks.lib.endpoint_request_func import RequestFuncInput
from vllm_omni.benchmarks.patch.patch import MixRequestFuncOutput, async_request_openai_chat_omni_completions
pytestmark = [pytest.mark.core_model, pytest.mark.benchmark, pytest.mark.cpu]
class MockResponse:
"""Mock aiohttp response for testing"""
def __init__(self, status, chunks, delay_between_chunks=0):
self.status = status
self.reason = "OK" if status == 200 else "Error"
self._chunks = chunks
self._delay = delay_between_chunks
self.content = self
async def iter_any(self):
for chunk in self._chunks:
if self._delay > 0:
await asyncio.sleep(self._delay)
yield chunk
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
def create_sse_chunk(data_dict):
"""Helper to create SSE formatted chunk"""
return f"data: {json.dumps(data_dict)}\n\n".encode()
# ============================================================================
# output_tokens Tests
# ============================================================================
@pytest.mark.asyncio
async def test_output_tokens_assigned_with_metrics():
"""Test that output.output_tokens is assigned when metrics are present"""
# Arrange
request_input = RequestFuncInput(
model="test-model",
model_name="test-model",
prompt="test prompt",
api_url="http://test.com/v1/chat/completions",
prompt_len=10,
output_len=20,
)
# Create response chunks with metrics
chunks = [
create_sse_chunk(
{
"choices": [{"delta": {"content": "Hello"}}],
"modality": "text",
}
),
create_sse_chunk(
{
"choices": [{"delta": {"content": " world"}}],
"modality": "text",
"metrics": {"num_tokens_out": 42, "num_tokens_in": 10},
}
),
b"data: [DONE]\n\n",
]
mock_response = MockResponse(200, chunks)
mock_session = AsyncMock()
mock_session.post = MagicMock(return_value=mock_response)
# Act
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
# Assert
assert output.success is True
assert output.output_tokens == 42, "output_tokens should be assigned from metrics"
assert output.generated_text == "Hello world"
@pytest.mark.asyncio
async def test_output_tokens_not_assigned_without_metrics():
"""Test that output.output_tokens defaults to 0 when no metrics present"""
# Arrange
request_input = RequestFuncInput(
model="test-model",
model_name="test-model",
prompt="test prompt",
api_url="http://test.com/v1/chat/completions",
prompt_len=10,
output_len=20,
)
# Create response chunks without metrics
chunks = [
create_sse_chunk(
{
"choices": [{"delta": {"content": "Hello"}}],
"modality": "text",
}
),
create_sse_chunk(
{
"choices": [{"delta": {"content": " world"}}],
"modality": "text",
}
),
b"data: [DONE]\n\n",
]
mock_response = MockResponse(200, chunks)
mock_session = AsyncMock()
mock_session.post = MagicMock(return_value=mock_response)
# Act
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
# Assert
assert output.success is True
assert output.output_tokens == 0, "output_tokens should default to 0 when no metrics"
assert output.generated_text == "Hello world"
@pytest.mark.asyncio
async def test_output_tokens_assigned_multiple_metrics():
"""Test that output.output_tokens is updated with the latest metrics value"""
# Arrange
request_input = RequestFuncInput(
model="test-model",
model_name="test-model",
prompt="test prompt",
api_url="http://test.com/v1/chat/completions",
prompt_len=10,
output_len=20,
)
# Create response chunks with multiple metrics updates
chunks = [
create_sse_chunk(
{
"choices": [{"delta": {"content": "Hello"}}],
"modality": "text",
"metrics": {"num_tokens_out": 5},
}
),
create_sse_chunk(
{
"choices": [{"delta": {"content": " world"}}],
"modality": "text",
"metrics": {"num_tokens_out": 10},
}
),
create_sse_chunk(
{
"choices": [{"delta": {"content": "!"}}],
"modality": "text",
"metrics": {"num_tokens_out": 15},
}
),
b"data: [DONE]\n\n",
]
mock_response = MockResponse(200, chunks)
mock_session = AsyncMock()
mock_session.post = MagicMock(return_value=mock_response)
# Act
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
# Assert
assert output.success is True
assert output.output_tokens == 15, "output_tokens should be updated to the latest value"
assert output.generated_text == "Hello world!"
@pytest.mark.asyncio
async def test_output_tokens_with_audio_and_text():
"""Test output_tokens assignment in mixed audio and text response"""
# Arrange
request_input = RequestFuncInput(
model="test-model",
model_name="test-model",
prompt="test prompt",
api_url="http://test.com/v1/chat/completions",
prompt_len=10,
output_len=20,
)
# Create response chunks with both audio and text, with metrics
chunks = [
create_sse_chunk(
{
"choices": [{"delta": {"content": "Text response"}}],
"modality": "text",
}
),
create_sse_chunk(
{
"choices": [{"delta": {"content": ""}}],
"modality": "audio",
}
),
create_sse_chunk(
{
"modality": "text",
"metrics": {"num_tokens_out": 25, "num_tokens_in": 10},
}
),
b"data: [DONE]\n\n",
]
mock_response = MockResponse(200, chunks)
mock_session = AsyncMock()
mock_session.post = MagicMock(return_value=mock_response)
# Act
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
# Assert
assert output.success is True
assert output.output_tokens == 25, "output_tokens should be assigned even with audio modality"
@pytest.mark.asyncio
async def test_output_tokens_with_missing_num_tokens_out():
"""Test that output_tokens defaults to 0 when num_tokens_out is missing in metrics"""
# Arrange
request_input = RequestFuncInput(
model="test-model",
model_name="test-model",
prompt="test prompt",
api_url="http://test.com/v1/chat/completions",
prompt_len=10,
output_len=20,
)
# Create response chunks with metrics but without num_tokens_out
chunks = [
create_sse_chunk(
{
"choices": [{"delta": {"content": "Hello"}}],
"modality": "text",
}
),
create_sse_chunk(
{
"choices": [{"delta": {"content": " world"}}],
"modality": "text",
"metrics": {"num_tokens_in": 10}, # Missing num_tokens_out
}
),
b"data: [DONE]\n\n",
]
mock_response = MockResponse(200, chunks)
mock_session = AsyncMock()
mock_session.post = MagicMock(return_value=mock_response)
# Act
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
# Assert
assert output.success is True
assert output.output_tokens == 0, "output_tokens should default to 0 when num_tokens_out is missing"
@pytest.mark.asyncio
async def test_output_tokens_initialization():
"""Test that MixRequestFuncOutput initializes output_tokens correctly"""
# Arrange & Act
output = MixRequestFuncOutput()
# Assert
assert hasattr(output, "output_tokens"), "MixRequestFuncOutput should have output_tokens attribute"
assert output.output_tokens == 0, "output_tokens should be initialized to 0"
# ============================================================================
# text_latency Tests
# ============================================================================
class TestTextLatencyAttribute:
"""Tests for text_latency attribute existence and assignment"""
def test_mix_request_func_output_has_text_latency(self):
"""Test that MixRequestFuncOutput has text_latency attribute"""
output = MixRequestFuncOutput()
assert hasattr(output, "text_latency"), "MixRequestFuncOutput should have text_latency attribute"
def test_text_latency_initial_value(self):
"""Test that text_latency initializes to a default value"""
output = MixRequestFuncOutput()
# Check if attribute exists and has a value (should be 0.0 or similar default)
text_latency = getattr(output, "text_latency", None)
assert text_latency is not None or hasattr(output, "text_latency"), "text_latency attribute should exist"
@pytest.mark.asyncio
async def test_text_latency_assigned_with_text_response(self):
"""Test that text_latency is assigned when text response is received"""
request_input = RequestFuncInput(
model="test-model",
model_name="test-model",
prompt="test prompt",
api_url="http://test.com/v1/chat/completions",
prompt_len=10,
output_len=20,
)
# Create response chunks with text modality
chunks = [
create_sse_chunk(
{
"choices": [{"delta": {"content": "Hello"}}],
"modality": "text",
}
),
create_sse_chunk(
{
"choices": [{"delta": {"content": " world"}}],
"modality": "text",
}
),
b"data: [DONE]\n\n",
]
mock_response = MockResponse(200, chunks)
mock_session = AsyncMock()
mock_session.post = MagicMock(return_value=mock_response)
# Act
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
# Assert
assert output.success is True
assert hasattr(output, "text_latency"), "Output should have text_latency attribute"
assert output.text_latency > 0, "text_latency should be greater than 0 for text response"
@pytest.mark.asyncio
async def test_text_latency_updated_with_multiple_text_chunks(self):
"""Test that text_latency is updated as more text chunks arrive"""
request_input = RequestFuncInput(
model="test-model",
model_name="test-model",
prompt="test prompt",
api_url="http://test.com/v1/chat/completions",
prompt_len=10,
output_len=20,
)
# Create response chunks with small delays to simulate streaming
chunks = [
create_sse_chunk(
{
"choices": [{"delta": {"content": "First"}}],
"modality": "text",
}
),
create_sse_chunk(
{
"choices": [{"delta": {"content": " second"}}],
"modality": "text",
}
),
create_sse_chunk(
{
"choices": [{"delta": {"content": " third"}}],
"modality": "text",
}
),
b"data: [DONE]\n\n",
]
mock_response = MockResponse(200, chunks)
mock_session = AsyncMock()
mock_session.post = MagicMock(return_value=mock_response)
# Act
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
# Assert
assert output.success is True
assert hasattr(output, "text_latency"), "Output should have text_latency attribute"
assert output.text_latency > 0, "text_latency should accumulate"
assert output.generated_text == "First second third"
@pytest.mark.asyncio
async def test_text_latency_with_only_audio_response(self):
"""Test text_latency behavior when only audio is received"""
request_input = RequestFuncInput(
model="test-model",
model_name="test-model",
prompt="test prompt",
api_url="http://test.com/v1/chat/completions",
prompt_len=10,
output_len=20,
)
# Create response chunks with only audio modality (no text)
chunks = [
create_sse_chunk(
{
"choices": [{"delta": {"content": ""}}],
"modality": "audio",
}
),
b"data: [DONE]\n\n",
]
mock_response = MockResponse(200, chunks)
mock_session = AsyncMock()
mock_session.post = MagicMock(return_value=mock_response)
# Act
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
# Assert
assert output.success is True
assert hasattr(output, "text_latency"), "Output should have text_latency attribute even with audio-only"
# text_latency should either be 0 or the initial value since no text was processed
assert output.text_latency >= 0, "text_latency should be non-negative"
@pytest.mark.asyncio
async def test_text_latency_not_affected_by_metrics(self):
"""Test that text_latency is independent of metrics data"""
request_input = RequestFuncInput(
model="test-model",
model_name="test-model",
prompt="test prompt",
api_url="http://test.com/v1/chat/completions",
prompt_len=10,
output_len=20,
)
# Create response with text and metrics
chunks = [
create_sse_chunk(
{
"choices": [{"delta": {"content": "Response text"}}],
"modality": "text",
}
),
create_sse_chunk(
{
"choices": [{"delta": {"content": ""}}],
"modality": "text",
"metrics": {"num_tokens_out": 100, "num_tokens_in": 20},
}
),
b"data: [DONE]\n\n",
]
mock_response = MockResponse(200, chunks)
mock_session = AsyncMock()
mock_session.post = MagicMock(return_value=mock_response)
# Act
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
# Assert
assert output.success is True
assert hasattr(output, "text_latency"), "text_latency should exist"
assert output.text_latency > 0, "text_latency should be set for text response"
# Verify metrics are also present
assert output.output_tokens == 100, "metrics should not affect text_latency"
@pytest.mark.asyncio
async def test_text_latency_mixed_modalities(self):
"""Test text_latency with mixed text and audio modalities"""
request_input = RequestFuncInput(
model="test-model",
model_name="test-model",
prompt="test prompt",
api_url="http://test.com/v1/chat/completions",
prompt_len=10,
output_len=20,
)
# Create response with both text and audio
chunks = [
create_sse_chunk(
{
"choices": [{"delta": {"content": "Text"}}],
"modality": "text",
}
),
create_sse_chunk(
{
"choices": [{"delta": {"content": ""}}],
"modality": "audio",
}
),
create_sse_chunk(
{
"choices": [{"delta": {"content": " more text"}}],
"modality": "text",
}
),
b"data: [DONE]\n\n",
]
mock_response = MockResponse(200, chunks)
mock_session = AsyncMock()
mock_session.post = MagicMock(return_value=mock_response)
# Act
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
# Assert
assert output.success is True
assert hasattr(output, "text_latency"), "text_latency should exist with mixed modalities"
assert output.text_latency > 0, "text_latency should be set when text is present"
assert output.generated_text == "Text more text"
@pytest.mark.asyncio
async def test_text_latency_value_consistency(self):
"""Test that text_latency matches latency minus ttft relationship"""
request_input = RequestFuncInput(
model="test-model",
model_name="test-model",
prompt="test prompt",
api_url="http://test.com/v1/chat/completions",
prompt_len=10,
output_len=20,
)
chunks = [
create_sse_chunk(
{
"choices": [{"delta": {"content": "Hello"}}],
"modality": "text",
}
),
create_sse_chunk(
{
"choices": [{"delta": {"content": " world"}}],
"modality": "text",
}
),
b"data: [DONE]\n\n",
]
mock_response = MockResponse(200, chunks)
mock_session = AsyncMock()
mock_session.post = MagicMock(return_value=mock_response)
# Act
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
# Assert
assert output.success is True
assert hasattr(output, "text_latency"), "text_latency should exist"
assert hasattr(output, "ttft"), "ttft should exist"
assert hasattr(output, "latency"), "latency should exist"
# text_latency should be between ttft and total latency
assert output.ttft <= output.text_latency <= output.latency, (
"text_latency should be between ttft and total latency"
)
if __name__ == "__main__":
pytest.main([__file__, "-v", "-s"])
+54 -21
View File
@@ -36,7 +36,7 @@ def mock_od_config():
@pytest.fixture
def mock_gpu_worker(mock_od_config):
"""Create a DiffusionWorker with mocked initialization."""
with patch.object(DiffusionWorker, "init_device"):
with patch.object(DiffusionWorker, "init_device"), patch.object(DiffusionWorker, "load_model"):
worker = DiffusionWorker(local_rank=0, rank=0, od_config=mock_od_config)
# Mock the model_runner with pipeline
worker.model_runner = Mock()
@@ -81,18 +81,18 @@ class TestDiffusionWorkerLoadWeights:
class TestDiffusionWorkerSleep:
"""Test DiffusionWorker.sleep method."""
@patch("vllm_omni.diffusion.worker.diffusion_worker.get_process_gpu_memory")
@patch("vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform")
@patch("vllm.device_allocator.cumem.CuMemAllocator")
def test_sleep_level_1(self, mock_allocator_class, mock_platform, mock_gpu_worker):
def test_sleep_level_1(self, mock_allocator_class, mock_platform, mock_get_process_memory, mock_gpu_worker):
"""Test sleep mode level 1 (offload weights only)."""
# Setup memory info mocks
# Before sleep: 1GB free
# After sleep: 3GB free (freed 2GB)
mock_platform.get_free_memory.side_effect = [
1 * 1024**3, # Before sleep
3 * 1024**3, # After sleep
# Setup process-scoped memory mocks
# Before sleep: 3GB used
# After sleep: 1GB used (freed 2GB)
mock_get_process_memory.side_effect = [
3 * 1024**3,
1 * 1024**3,
]
mock_platform.get_device_total_memory.return_value = 8 * 1024**3
# Setup allocator mock
mock_allocator = Mock()
@@ -108,16 +108,16 @@ class TestDiffusionWorkerSleep:
# Verify buffers were NOT saved (level 1 doesn't save buffers)
assert len(mock_gpu_worker._sleep_saved_buffers) == 0
@patch("vllm_omni.diffusion.worker.diffusion_worker.get_process_gpu_memory")
@patch("vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform")
@patch("vllm.device_allocator.cumem.CuMemAllocator")
def test_sleep_level_2(self, mock_allocator_class, mock_platform, mock_gpu_worker):
def test_sleep_level_2(self, mock_allocator_class, mock_platform, mock_get_process_memory, mock_gpu_worker):
"""Test sleep mode level 2 (offload all, save buffers)."""
# Setup memory info mocks
mock_platform.get_free_memory.side_effect = [
1 * 1024**3, # Before sleep
5 * 1024**3, # After sleep (freed 4GB)
# Setup process-scoped memory mocks
mock_get_process_memory.side_effect = [
5 * 1024**3, # Before sleep
1 * 1024**3, # After sleep (freed 4GB)
]
mock_platform.get_device_total_memory.return_value = 8 * 1024**3
# Setup allocator mock
mock_allocator = Mock()
@@ -146,16 +146,22 @@ class TestDiffusionWorkerSleep:
assert "buffer1" in mock_gpu_worker._sleep_saved_buffers
assert "buffer2" in mock_gpu_worker._sleep_saved_buffers
@patch("vllm_omni.diffusion.worker.diffusion_worker.get_process_gpu_memory")
@patch("vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform")
@patch("vllm.device_allocator.cumem.CuMemAllocator")
def test_sleep_memory_freed_validation(self, mock_allocator_class, mock_platform, mock_gpu_worker):
def test_sleep_memory_freed_validation(
self,
mock_allocator_class,
mock_platform,
mock_get_process_memory,
mock_gpu_worker,
):
"""Test that sleep validates memory was actually freed."""
# Simulate memory increase (should trigger assertion error)
mock_platform.get_free_memory.side_effect = [
3 * 1024**3, # Before sleep: 3GB free
1 * 1024**3, # After sleep: 1GB free (negative freed!)
# Simulate process memory increase (should trigger assertion error)
mock_get_process_memory.side_effect = [
1 * 1024**3, # Before sleep: 1GB used
3 * 1024**3, # After sleep: 3GB used (negative freed)
]
mock_platform.get_device_total_memory.return_value = 8 * 1024**3
mock_allocator = Mock()
mock_allocator_class.get_instance = Mock(return_value=mock_allocator)
@@ -165,6 +171,33 @@ class TestDiffusionWorkerSleep:
with pytest.raises(AssertionError, match="Memory usage increased after sleeping"):
mock_gpu_worker.sleep(level=1)
@patch("vllm_omni.diffusion.worker.diffusion_worker.get_process_gpu_memory")
@patch("vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform")
@patch("vllm.device_allocator.cumem.CuMemAllocator")
def test_sleep_falls_back_to_device_memory_when_nvml_unavailable(
self,
mock_allocator_class,
mock_platform,
mock_get_process_memory,
mock_gpu_worker,
):
"""Test sleep uses device-scoped fallback when NVML is unavailable."""
mock_get_process_memory.side_effect = [None, None]
mock_platform.get_free_memory.side_effect = [
1 * 1024**3, # Before sleep
3 * 1024**3, # After sleep
]
mock_platform.get_device_total_memory.return_value = 8 * 1024**3
mock_allocator = Mock()
mock_allocator_class.get_instance = Mock(return_value=mock_allocator)
mock_allocator.sleep = Mock()
result = mock_gpu_worker.sleep(level=1)
mock_allocator.sleep.assert_called_once_with(offload_tags=("weights",))
assert result is True
class TestDiffusionWorkerWakeUp:
"""Test DiffusionWorker.wake_up method."""
+540
View File
@@ -0,0 +1,540 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Unit tests for WorkerWrapperBase class.
This module tests the WorkerWrapperBase implementation:
- Initialization with and without worker extensions
- Custom pipeline initialization
- Method delegation via execute_method
- Attribute delegation via __getattr__
- Dynamic worker class extension
"""
from typing import Any
from unittest.mock import Mock, patch
import pytest
from vllm_omni.diffusion.worker.diffusion_worker import (
CustomPipelineWorkerExtension,
DiffusionWorker,
WorkerWrapperBase,
)
# -------------------------------------------------------------------------
# Fixtures
# -------------------------------------------------------------------------
@pytest.fixture
def mock_od_config():
"""Create a mock OmniDiffusionConfig for use in tests."""
config = Mock()
config.num_gpus = 1
config.master_port = 12345
config.enable_sleep_mode = False
config.cache_backend = None
config.cache_config = None
config.model = "test-model"
config.diffusion_load_format = None
config.dtype = "float32"
config.max_cpu_loras = 0
config.lora_path = None
config.lora_scale = 1.0
return config
class TestExtension:
"""Simple test extension adding one custom method."""
def custom_method(self):
return "extension_method"
class MockCustomPipeline:
"""Mock custom pipeline for testing."""
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
def __call__(self, *args, **kwargs):
return "pipeline_output"
# -------------------------------------------------------------------------
# Tests: Initialization
# -------------------------------------------------------------------------
class TestWorkerWrapperBaseInitialization:
"""Test WorkerWrapperBase initialization behavior."""
@patch.object(DiffusionWorker, "__init__", return_value=None)
def test_basic_initialization(self, mock_worker_init, mock_od_config):
"""Test basic initialization without extensions."""
wrapper = WorkerWrapperBase(
gpu_id=0,
od_config=mock_od_config,
base_worker_class=DiffusionWorker,
)
assert wrapper.gpu_id == 0
assert wrapper.od_config == mock_od_config
assert wrapper.base_worker_class == DiffusionWorker
assert wrapper.worker_extension_cls is None
assert wrapper.custom_pipeline_args is None
assert wrapper.worker is not None
mock_worker_init.assert_called_once_with(
local_rank=0,
rank=0,
od_config=mock_od_config,
)
# -------------------------------------------------------------------------
# Tests: Worker Extension Functionality
# -------------------------------------------------------------------------
class TestWorkerWrapperBaseExtension:
"""Test WorkerWrapperBase worker extension functionality."""
def test_prepare_worker_class_without_extension(self, mock_od_config):
"""Test _prepare_worker_class without a worker extension."""
with patch.object(DiffusionWorker, "__init__", return_value=None):
wrapper = WorkerWrapperBase(
gpu_id=0,
od_config=mock_od_config,
base_worker_class=DiffusionWorker,
)
worker_class = wrapper._prepare_worker_class()
assert worker_class == DiffusionWorker
def test_prepare_worker_class_with_extension_class(self, mock_od_config):
"""Test _prepare_worker_class with an explicit extension class."""
class TestExtension:
def custom_method(self):
return "extension_method"
with patch.object(DiffusionWorker, "__init__", return_value=None):
wrapper = WorkerWrapperBase(
gpu_id=0,
od_config=mock_od_config,
base_worker_class=DiffusionWorker,
worker_extension_cls=TestExtension,
)
assert hasattr(wrapper.worker.__class__, "custom_method")
assert TestExtension in wrapper.worker.__class__.__bases__
@patch("vllm.utils.import_utils.resolve_obj_by_qualname")
def test_prepare_worker_class_with_extension_string(self, mock_resolve, mock_od_config):
"""Test _prepare_worker_class with worker extension as string."""
mock_resolve.return_value = TestExtension
with patch.object(DiffusionWorker, "__init__", return_value=None):
wrapper = WorkerWrapperBase(
gpu_id=0,
od_config=mock_od_config,
base_worker_class=DiffusionWorker,
worker_extension_cls="tests.diffusion.test_worker_wrapper_base.TestExtension",
)
assert hasattr(wrapper.worker.__class__, "custom_method")
# -------------------------------------------------------------------------
# Tests: Method Delegation
# -------------------------------------------------------------------------
class TestWorkerWrapperBaseDelegation:
"""Test WorkerWrapperBase delegation to wrapped worker."""
@patch.object(DiffusionWorker, "__init__", return_value=None)
def test_generate_delegation(self, mock_worker_init, mock_od_config):
"""Test that generate() delegates to worker.generate()."""
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
mock_output = Mock()
wrapper.worker.generate = Mock(return_value=mock_output)
mock_requests = [Mock()]
result = wrapper.generate(mock_requests)
wrapper.worker.generate.assert_called_once_with(mock_requests)
assert result == mock_output
@patch.object(DiffusionWorker, "__init__", return_value=None)
def test_execute_model_delegation(self, mock_worker_init, mock_od_config):
"""Test that execute_model() delegates to worker.execute_model()."""
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
mock_output = Mock()
wrapper.worker.execute_model = Mock(return_value=mock_output)
mock_reqs = [Mock()]
result = wrapper.execute_model(mock_reqs, mock_od_config)
wrapper.worker.execute_model.assert_called_once_with(mock_reqs, mock_od_config)
assert result == mock_output
@patch.object(DiffusionWorker, "__init__", return_value=None)
def test_load_weights_delegation(self, mock_worker_init, mock_od_config):
"""Test that load_weights() delegates to worker.load_weights()."""
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
expected_result = {"weight1", "weight2"}
wrapper.worker.load_weights = Mock(return_value=expected_result)
mock_weights = [("weight1", Mock()), ("weight2", Mock())]
result = wrapper.load_weights(mock_weights)
wrapper.worker.load_weights.assert_called_once_with(mock_weights)
assert result == expected_result
@patch.object(DiffusionWorker, "__init__", return_value=None)
def test_sleep_delegation(self, mock_worker_init, mock_od_config):
"""Test that sleep() delegates to worker.sleep()."""
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
wrapper.worker.sleep = Mock(return_value=True)
result = wrapper.sleep(level=1)
wrapper.worker.sleep.assert_called_once_with(1)
assert result is True
@patch.object(DiffusionWorker, "__init__", return_value=None)
def test_wake_up_delegation(self, mock_worker_init, mock_od_config):
"""Test that wake_up() delegates to worker.wake_up()."""
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
wrapper.worker.wake_up = Mock(return_value=True)
result = wrapper.wake_up(tags=["weights"])
wrapper.worker.wake_up.assert_called_once_with(["weights"])
assert result is True
@patch.object(DiffusionWorker, "__init__", return_value=None)
def test_shutdown_delegation(self, mock_worker_init, mock_od_config):
"""Test that shutdown() delegates to worker.shutdown()."""
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
wrapper.worker.shutdown = Mock(return_value=None)
result = wrapper.shutdown()
wrapper.worker.shutdown.assert_called_once()
assert result is None
# -------------------------------------------------------------------------
# Tests: execute_method
# -------------------------------------------------------------------------
class TestWorkerWrapperBaseExecuteMethod:
"""Test WorkerWrapperBase.execute_method functionality."""
@patch.object(DiffusionWorker, "__init__", return_value=None)
def test_execute_method_success(self, mock_worker_init, mock_od_config):
"""Test execute_method successfully calls worker method."""
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
wrapper.worker.test_method = Mock(return_value="method_result")
result = wrapper.execute_method("test_method", "arg1", kwarg1="value1")
wrapper.worker.test_method.assert_called_once_with("arg1", kwarg1="value1")
assert result == "method_result"
@patch.object(DiffusionWorker, "__init__", return_value=None)
def test_execute_method_with_no_args(self, mock_worker_init, mock_od_config):
"""Test execute_method with no arguments."""
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
wrapper.worker.no_args_method = Mock(return_value="no_args_result")
result = wrapper.execute_method("no_args_method")
wrapper.worker.no_args_method.assert_called_once_with()
assert result == "no_args_result"
@patch.object(DiffusionWorker, "__init__", return_value=None)
def test_execute_method_error(self, mock_worker_init, mock_od_config):
"""Test execute_method raises exception on error."""
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
wrapper.worker.error_method = Mock(side_effect=RuntimeError("Test error"))
with pytest.raises(RuntimeError, match="Test error"):
wrapper.execute_method("error_method")
@patch.object(DiffusionWorker, "__init__", return_value=None)
def test_execute_method_invalid_type(self, mock_worker_init, mock_od_config):
"""Test execute_method with invalid method type."""
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
with pytest.raises(AssertionError, match="Method must be str"):
wrapper.execute_method(b"bytes_method")
# -------------------------------------------------------------------------
# Tests: __getattr__ delegation
# -------------------------------------------------------------------------
class TestWorkerWrapperBaseGetAttr:
"""Test WorkerWrapperBase.__getattr__ delegation."""
@patch.object(DiffusionWorker, "__init__", return_value=None)
def test_getattr_delegation(self, mock_worker_init, mock_od_config):
"""Test __getattr__ delegates to worker attributes."""
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
wrapper.worker.custom_attribute = "test_value"
assert wrapper.custom_attribute == "test_value"
@patch.object(DiffusionWorker, "__init__", return_value=None)
def test_getattr_method_access(self, mock_worker_init, mock_od_config):
"""Test __getattr__ delegates to worker methods."""
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
wrapper.worker.custom_method = Mock(return_value="method_result")
result = wrapper.custom_method()
wrapper.worker.custom_method.assert_called_once()
assert result == "method_result"
@patch.object(DiffusionWorker, "__init__", return_value=None)
def test_getattr_missing_attribute(self, mock_worker_init, mock_od_config):
"""Test __getattr__ raises AttributeError for missing attributes."""
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
with pytest.raises(AttributeError):
_ = wrapper.nonexistent_attribute
# -------------------------------------------------------------------------
# Tests: Edge Cases
# -------------------------------------------------------------------------
class TestWorkerWrapperBaseEdgeCases:
"""Test WorkerWrapperBase edge cases and special scenarios."""
@patch.object(DiffusionWorker, "__init__", return_value=None)
def test_extension_conflict_warning(self, mock_worker_init, mock_od_config, caplog):
"""Test a warning is logged when an extension conflicts with worker."""
class ConflictExtension:
def load_model(self):
return "extension_load_model"
with patch.object(DiffusionWorker, "load_model"):
wrapper = WorkerWrapperBase(
gpu_id=0,
od_config=mock_od_config,
base_worker_class=DiffusionWorker,
worker_extension_cls=ConflictExtension,
)
assert wrapper.worker is not None
@patch.object(DiffusionWorker, "__init__", return_value=None)
def test_multiple_extensions_same_class(self, mock_worker_init, mock_od_config):
"""Test that applying same extension twice doesn't duplicate it."""
class TestExtension:
def custom_method(self):
return "extension"
with patch.object(DiffusionWorker, "__init__", return_value=None):
wrapper1 = WorkerWrapperBase(
gpu_id=0,
od_config=mock_od_config,
base_worker_class=DiffusionWorker,
worker_extension_cls=TestExtension,
)
wrapper2 = WorkerWrapperBase(
gpu_id=0,
od_config=mock_od_config,
base_worker_class=DiffusionWorker,
worker_extension_cls=TestExtension,
)
assert hasattr(wrapper1.worker, "custom_method")
assert hasattr(wrapper2.worker, "custom_method")
# -------------------------------------------------------------------------
# Tests: CustomPipelineWorkerExtension
# -------------------------------------------------------------------------
class TestCustomPipelineWorkerExtension:
"""Test CustomPipelineWorkerExtension functionality."""
@patch("torch.cuda.empty_cache")
@patch("gc.collect")
@patch.object(DiffusionWorker, "__init__", return_value=None)
def test_re_init_pipeline_basic(self, mock_worker_init, mock_gc_collect, mock_empty_cache, mock_od_config):
"""Test basic re_init_pipeline functionality."""
wrapper = WorkerWrapperBase(
gpu_id=0,
od_config=mock_od_config,
base_worker_class=DiffusionWorker,
worker_extension_cls=CustomPipelineWorkerExtension,
)
# Setup mock model_runner and pipeline
mock_model_runner = Mock()
mock_pipeline = Mock()
mock_model_runner.pipeline = mock_pipeline
wrapper.worker.model_runner = mock_model_runner
wrapper.worker.init_lora_manager = Mock()
wrapper.worker.load_model = Mock()
custom_args = {"pipeline_class": "tests.diffusion.test_worker_wrapper_base.MockCustomPipeline"}
# Call re_init_pipeline
wrapper.worker.re_init_pipeline(custom_args)
# Verify load_model was called with correct arguments
wrapper.worker.load_model.assert_called_once_with(
load_format="custom_pipeline",
custom_pipeline_name="tests.diffusion.test_worker_wrapper_base.MockCustomPipeline",
)
wrapper.worker.init_lora_manager.assert_called_once()
@patch("torch.cuda.empty_cache")
@patch("gc.collect")
@patch.object(DiffusionWorker, "__init__", return_value=None)
def test_re_init_pipeline_cleanup(self, mock_worker_init, mock_gc_collect, mock_empty_cache, mock_od_config):
"""Test that re_init_pipeline properly cleans up old pipeline."""
wrapper = WorkerWrapperBase(
gpu_id=0,
od_config=mock_od_config,
base_worker_class=DiffusionWorker,
worker_extension_cls=CustomPipelineWorkerExtension,
)
# Setup mock model_runner with pipeline
mock_model_runner = Mock()
mock_pipeline = Mock()
mock_model_runner.pipeline = mock_pipeline
wrapper.worker.model_runner = mock_model_runner
wrapper.worker.init_lora_manager = Mock()
wrapper.worker.load_model = Mock()
custom_args = {"pipeline_class": "tests.diffusion.test_worker_wrapper_base.MockCustomPipeline"}
# Call re_init_pipeline
wrapper.worker.re_init_pipeline(custom_args)
# Verify cleanup was performed
mock_gc_collect.assert_called_once()
mock_empty_cache.assert_called_once()
@patch("torch.cuda.empty_cache")
@patch("gc.collect")
@patch.object(DiffusionWorker, "__init__", return_value=None)
def test_re_init_pipeline_none_pipeline(self, mock_worker_init, mock_gc_collect, mock_empty_cache, mock_od_config):
"""Test re_init_pipeline when pipeline is None."""
wrapper = WorkerWrapperBase(
gpu_id=0,
od_config=mock_od_config,
base_worker_class=DiffusionWorker,
worker_extension_cls=CustomPipelineWorkerExtension,
)
# Setup mock model_runner with None pipeline
mock_model_runner = Mock()
mock_model_runner.pipeline = None
wrapper.worker.model_runner = mock_model_runner
wrapper.worker.init_lora_manager = Mock()
wrapper.worker.load_model = Mock()
custom_args = {"pipeline_class": "tests.diffusion.test_worker_wrapper_base.MockCustomPipeline"}
# Should not raise an error
wrapper.worker.re_init_pipeline(custom_args)
# Verify load_model was still called
wrapper.worker.load_model.assert_called_once()
@patch.object(DiffusionWorker, "__init__", return_value=None)
def test_custom_pipeline_args_initialization(self, mock_worker_init, mock_od_config):
"""Test initialization with custom_pipeline_args calls re_init_pipeline."""
custom_args = {"pipeline_class": "tests.diffusion.test_worker_wrapper_base.MockCustomPipeline"}
with patch.object(DiffusionWorker, "__init__", return_value=None):
with patch.object(WorkerWrapperBase, "_prepare_worker_class") as mock_prepare:
# Create a mock worker class with re_init_pipeline
mock_worker_class = Mock()
mock_worker_instance = Mock()
mock_worker_instance.re_init_pipeline = Mock()
mock_worker_class.return_value = mock_worker_instance
mock_prepare.return_value = mock_worker_class
_ = WorkerWrapperBase(
gpu_id=0,
od_config=mock_od_config,
base_worker_class=DiffusionWorker,
custom_pipeline_args=custom_args,
)
# Verify re_init_pipeline was called with custom_pipeline_args
mock_worker_instance.re_init_pipeline.assert_called_once_with(custom_args)
@patch.object(DiffusionWorker, "__init__", return_value=None)
def test_custom_pipeline_with_explicit_extension(self, mock_worker_init, mock_od_config):
"""Test that explicit worker_extension_cls is preserved when custom_pipeline_args is provided."""
class CustomExtension:
def re_init_pipeline(self, custom_pipeline_args: dict[str, Any]):
return "custom_re_init_pipeline"
def custom_extension_method(self):
return "custom_extension_method"
custom_args = {"pipeline_class": "tests.diffusion.test_worker_wrapper_base.MockCustomPipeline"}
with patch.object(DiffusionWorker, "__init__", return_value=None):
wrapper = WorkerWrapperBase(
gpu_id=0,
od_config=mock_od_config,
base_worker_class=DiffusionWorker,
worker_extension_cls=CustomExtension,
custom_pipeline_args=custom_args,
)
# Should still have the explicitly provided extension
assert CustomExtension in wrapper.worker.__class__.__bases__
assert hasattr(wrapper.worker, "custom_extension_method")
@patch("torch.cuda.empty_cache")
@patch("gc.collect")
@patch.object(DiffusionWorker, "__init__", return_value=None)
def test_re_init_pipeline_multiple_calls(self, mock_worker_init, mock_gc_collect, mock_empty_cache, mock_od_config):
"""Test calling re_init_pipeline multiple times."""
wrapper = WorkerWrapperBase(
gpu_id=0,
od_config=mock_od_config,
base_worker_class=DiffusionWorker,
worker_extension_cls=CustomPipelineWorkerExtension,
)
# Setup mock model_runner
mock_model_runner = Mock()
mock_pipeline1 = Mock()
mock_pipeline2 = Mock()
mock_model_runner.pipeline = mock_pipeline1
wrapper.worker.model_runner = mock_model_runner
wrapper.worker.init_lora_manager = Mock()
wrapper.worker.load_model = Mock()
# First call
custom_args1 = {"pipeline_class": "tests.diffusion.test_worker_wrapper_base.MockCustomPipeline"}
wrapper.worker.re_init_pipeline(custom_args1)
# Update pipeline for second call
mock_model_runner.pipeline = mock_pipeline2
# Second call
custom_args2 = {"pipeline_class": "tests.diffusion.test_worker_wrapper_base.MockCustomPipeline"}
wrapper.worker.re_init_pipeline(custom_args2)
# Verify load_model was called twice with different pipelines
assert wrapper.worker.load_model.call_count == 2
assert wrapper.worker.init_lora_manager.call_count == 2
@@ -0,0 +1,182 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import threading
from collections import deque
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
import torch
from vllm.v1.request import RequestStatus
from vllm_omni.distributed.omni_connectors.transfer_adapter.base import OmniTransferAdapterBase
from vllm_omni.distributed.omni_connectors.transfer_adapter.chunk_transfer_adapter import (
OmniChunkTransferAdapter,
)
from vllm_omni.distributed.omni_connectors.utils.config import ConnectorSpec
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class DummyWaitingQueue(list):
def prepend_requests(self, requests):
self[:0] = list(requests)
def add_request(self, request):
self.append(request)
def _req(req_id: str, status: RequestStatus, external_req_id: str | None = None):
return SimpleNamespace(
request_id=req_id,
external_req_id=external_req_id or req_id,
status=status,
prompt_token_ids=[],
num_computed_tokens=0,
additional_information=None,
)
@pytest.fixture
def build_adapter(monkeypatch):
def _build(*, stage_id: int = 1, model_mode: str = "ar", max_num_seqs: int = 2):
connector = MagicMock()
connector.stage_id = stage_id
connector.get.return_value = None
connector.put.return_value = (True, 1, {})
def _fake_base_init(self, config):
self.config = config
self._pending_load_reqs = {}
self._finished_load_reqs = set()
self._pending_save_reqs = {}
self._finished_save_reqs = set()
self.stop_event = threading.Event()
self.lock = threading.Lock()
monkeypatch.setattr(OmniTransferAdapterBase, "__init__", _fake_base_init)
monkeypatch.setattr(
OmniChunkTransferAdapter,
"create_connector",
classmethod(lambda cls, _model_config: connector),
)
model_config = SimpleNamespace(worker_type=model_mode)
scheduler_config = SimpleNamespace(max_num_seqs=max_num_seqs)
adapter = OmniChunkTransferAdapter(
SimpleNamespace(model_config=model_config, scheduler_config=scheduler_config)
)
return adapter, connector
return _build
@pytest.mark.parametrize(
("raw_cfg", "expected_name", "expected_extra"),
[
(None, "SharedMemoryConnector", {}),
(SimpleNamespace(name="YuanrongConnector", extra={"k": "v"}), "YuanrongConnector", {"k": "v"}),
],
)
def test_create_connector_config_parsing(monkeypatch, raw_cfg, expected_name, expected_extra):
captured = {}
def _fake_create(spec):
captured["spec"] = spec
return "ok"
monkeypatch.setattr(
"vllm_omni.distributed.omni_connectors.transfer_adapter.chunk_transfer_adapter"
".OmniConnectorFactory.create_connector",
_fake_create,
)
model_config = SimpleNamespace(stage_connector_config=raw_cfg) if raw_cfg is not None else SimpleNamespace()
connector = OmniChunkTransferAdapter.create_connector(model_config)
assert connector == "ok"
assert isinstance(captured["spec"], ConnectorSpec)
assert captured["spec"].name == expected_name
assert captured["spec"].extra == expected_extra
def test_load_poll(build_adapter):
adapter, connector = build_adapter(stage_id=2, model_mode="ar")
request = _req("req-1", RequestStatus.WAITING, external_req_id="external-1")
adapter.load_async(request)
payload = {"code_predictor_codes": [[1]], "hidden_states": torch.tensor([[2.0]]), "finished": True}
connector.get.return_value = (payload, 16)
adapter._poll_single_request("req-1")
connector.get.assert_called_once_with("1", "2", "external-1_1_0")
assert request.additional_information == payload
assert adapter.get_req_chunk["req-1"] == 1
assert "req-1" in adapter._finished_load_reqs
assert "req-1" in adapter.finished_requests
assert "req-1" not in adapter._pending_load_reqs
def test_save_async(build_adapter):
adapter, _ = build_adapter(stage_id=1)
request = SimpleNamespace(external_req_id="external-1")
adapter.custom_process_next_stage_input_func = lambda **kwargs: {"x": [1], "finished": False}
adapter.save_async(pooling_output=None, request=request)
adapter.custom_process_next_stage_input_func = lambda **kwargs: {}
adapter.save_async(pooling_output=None, request=request)
assert adapter.put_req_chunk["external-1"] == 1
queued = adapter._pending_save_reqs["external-1"]
assert len(queued) == 1
assert queued[0]["put_key"] == "external-1_1_0"
def test_update_request_payload(build_adapter):
adapter, _ = build_adapter()
adapter._update_request_payload("ext", {"h": torch.tensor([[1.0]]), "codes": [1], "finished": False})
merged = adapter._update_request_payload("ext", {"h": torch.tensor([[2.0]]), "codes": [2], "finished": True})
assert torch.equal(merged["h"], torch.tensor([[1.0], [2.0]]))
assert merged["codes"] == [1, 2]
assert merged["finished"] is True
def test_process_and_restore_queues(build_adapter):
adapter, _ = build_adapter(stage_id=1, max_num_seqs=8)
waiting_req = _req("w1", RequestStatus.WAITING)
running_req = _req("r1", RequestStatus.RUNNING)
waiting_queue = DummyWaitingQueue([waiting_req])
running_queue = [running_req]
adapter.process_pending_chunks(waiting_queue, running_queue)
assert waiting_req.status == RequestStatus.WAITING_FOR_CHUNK
assert running_req.status == RequestStatus.WAITING_FOR_CHUNK
assert waiting_queue == []
assert running_queue == []
adapter.restore_queues(waiting_queue, running_queue)
assert waiting_queue == [waiting_req]
assert running_queue == [running_req]
assert adapter.waiting_for_chunk_waiting_requests == deque()
assert adapter.waiting_for_chunk_running_requests == deque()
def test_postprocess_scheduler_output(build_adapter):
adapter, _ = build_adapter()
adapter.requests_with_ready_chunks = {"new-ready", "cached-ready", "leftover"}
scheduler_output = SimpleNamespace(
scheduled_new_reqs=[SimpleNamespace(req_id="new-ready")],
scheduled_cached_reqs=SimpleNamespace(req_ids=["cached-ready", "missing"]),
)
requests = {"cached-ready": SimpleNamespace(additional_information={"k": "v"})}
adapter.postprocess_scheduler_output(scheduler_output, requests)
cached_info = scheduler_output.scheduled_cached_reqs.additional_information
assert cached_info["cached-ready"] == {"k": "v"}
assert cached_info["missing"] is None
assert adapter.requests_with_ready_chunks == {"leftover"}
@@ -17,13 +17,15 @@ if str(REPO_ROOT) not in sys.path:
from vllm_omni import Omni
# Models to test and expected saved memory in MB, correspondingly
MODELS_SAVED_MEMORY_MB = {"riverclouds/qwen_image_random": 4500}
MODELS_SAVED_MEMORY_MB = {
"riverclouds/qwen_image_random": 4500,
# "Wan-AI/Wan2.2-T2V-A14B-Diffusers": 45000,
}
def run_inference(
model_name: str,
layerwise_offload: bool = False,
num_gpu_layers: int = 1,
num_inference_steps: int = 3,
) -> float:
# For now, only support on GPU, so apply torch.cuda operations here
@@ -36,7 +38,6 @@ def run_inference(
m = Omni(
model=model_name,
enable_layerwise_offload=layerwise_offload,
layerwise_num_gpu_layers=num_gpu_layers,
boundary_ratio=0.875,
flow_shift=5.0,
)
@@ -83,16 +84,12 @@ def test_layerwise_offload_diffusion_model(model_name: str):
cleanup_dist_env_and_memory()
# Run with layerwise offloading (1 layer on device)
layerwise_offload_peak_memory = run_inference(model_name, layerwise_offload=True, num_gpu_layers=1)
layerwise_offload_peak_memory = run_inference(model_name, layerwise_offload=True)
cleanup_dist_env_and_memory()
# Run with 2 layers on device
layerwise_offload_two_layers_peak = run_inference(model_name, layerwise_offload=True, num_gpu_layers=2)
except Exception:
pytest.fail("Inference failed")
print(f"Layerwise offload peak memory (1 GPU layer): {layerwise_offload_peak_memory} MB")
print(f"Layerwise offload peak memory (2 GPU layers): {layerwise_offload_two_layers_peak} MB")
print(f"No offload peak memory: {no_offload_peak_memory} MB")
# Verify that layerwise offloading significantly reduces memory usage
@@ -101,10 +98,3 @@ def test_layerwise_offload_diffusion_model(model_name: str):
f"Layerwise offload peak memory {layerwise_offload_peak_memory} MB "
f"should be significantly less than no offload peak memory {no_offload_peak_memory} MB"
)
# Verify that 2 GPU layers uses more memory than 1 GPU layer
# But not excessively more (should be a reasonable increase)
assert layerwise_offload_peak_memory < layerwise_offload_two_layers_peak, (
f"1 GPU layer peak {layerwise_offload_peak_memory} MB should be < "
f"2 GPU layers peak {layerwise_offload_two_layers_peak} MB"
)
@@ -34,10 +34,17 @@ elif current_omni_platform.is_rocm():
@pytest.mark.core_model
@pytest.mark.advanced_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):
def test_diffusion_model(model_name: str, run_level):
if run_level == "core_model" and model_name != "riverclouds/qwen_image_random":
pytest.skip()
if run_level == "advanced_model" and model_name == "riverclouds/qwen_image_random":
pytest.skip()
m = None
try:
m = Omni(model=model_name)
@@ -154,7 +154,7 @@ def _run_zimage_generate(
cleanup_dist_env_and_memory()
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.diffusion
@pytest.mark.parallel
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards={"cuda": 4, "rocm": 2})
@@ -145,7 +145,7 @@ def _basic_payload() -> dict:
}
@pytest.mark.core_model
@pytest.mark.advanced_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:
@@ -10,7 +10,7 @@ stage_args:
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
gpu_memory_utilization: 0.6
enforce_eager: true
enforce_eager: false
trust_remote_code: true
engine_output_type: latent # Output hidden states for talker
distributed_executor_backend: "mp"
+1 -1
View File
@@ -184,7 +184,7 @@ def calculate_metrics(
total_input += input_requests[i].prompt_len
tpot = 0
if output_len > 1:
latency_minus_ttft = outputs[i].latency - outputs[i].ttft
latency_minus_ttft = outputs[i].text_latency - outputs[i].ttft
tpot = latency_minus_ttft / (output_len - 1)
tpots.append(tpot)
# Note: if output_len <= 1, we regard tpot as 0 for goodput
+4 -2
View File
@@ -72,6 +72,7 @@ class MixRequestFuncOutput(RequestFuncOutput):
audio_duration: float = 0.0
audio_frames: int = 0
audio_rtf: float = 0.0
text_latency: float = 0.0
async def async_request_openai_chat_omni_completions(
@@ -149,6 +150,7 @@ async def async_request_openai_chat_omni_completions(
output.itl.append(timestamp - most_recent_timestamp)
generated_text += content or ""
most_recent_timestamp = timestamp
output.text_latency = timestamp - st
elif modality == "audio":
if output.audio_ttfp == 0.0:
output.audio_ttfp = timestamp - st
@@ -162,8 +164,8 @@ async def async_request_openai_chat_omni_completions(
else:
generated_audio = generated_audio + seg
elif usage := data.get("usage"):
output.output_tokens = usage.get("completion_tokens")
if metrics := data.get("metrics"):
output.output_tokens = metrics.get("num_tokens_out", 0)
output.latency = timestamp - st
output.generated_text = generated_text
+2
View File
@@ -28,6 +28,7 @@ class OmniModelConfig(ModelConfig):
(default: "thinker")
model_arch: Model architecture name
(default: "Qwen2_5OmniForConditionalGeneration")
worker_type: Model Type, e.g., "ar" or "generation"
engine_output_type: Optional output type specification for the engine.
Used to route outputs to appropriate processors (e.g., "image",
"audio", "latents"). If None, output type is inferred.
@@ -46,6 +47,7 @@ class OmniModelConfig(ModelConfig):
async_chunk: bool = False
model_stage: str = "thinker"
model_arch: str = "Qwen2_5OmniForConditionalGeneration"
worker_type: str | None = None
engine_output_type: str | None = None
hf_config_name: str | None = None
custom_process_next_stage_input_func: str | None = None
+24 -29
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import importlib
from collections import defaultdict
from dataclasses import asdict, dataclass
from time import time
@@ -20,9 +19,9 @@ from vllm.v1.request import Request, RequestStatus
from vllm.v1.spec_decode.metrics import SpecDecodingStats
from vllm_omni.core.sched.output import OmniSchedulerOutput
from vllm_omni.distributed.omni_connectors.adapter import get_chunk, put_chunk
from vllm_omni.distributed.omni_connectors.factory import OmniConnectorFactory
from vllm_omni.distributed.omni_connectors.utils.config import ConnectorSpec
from vllm_omni.distributed.omni_connectors.transfer_adapter.chunk_transfer_adapter import (
OmniChunkTransferAdapter,
)
logger = init_logger(__name__)
@@ -65,24 +64,9 @@ class OmniARScheduler(VLLMScheduler):
# Track requests that have already triggered prefill transfer to avoid duplicates
self.transfer_triggered_requests: set[str] = set()
model_config = self.vllm_config.model_config
self.omni_connector = None
if model_config.async_chunk:
connector_config = model_config.stage_connector_config
connector_specs = ConnectorSpec(
name=connector_config.get("name", "SharedMemoryConnector"),
extra=connector_config.get("extra", {}),
)
self.omni_connector = OmniConnectorFactory.create_connector(connector_specs)
custom_process_next_stage_input_func = getattr(
self.vllm_config.model_config, "custom_process_next_stage_input_func", None
)
if custom_process_next_stage_input_func:
module_path, func_name = custom_process_next_stage_input_func.rsplit(".", 1)
module = importlib.import_module(module_path)
self.custom_process_next_stage_input_func = getattr(module, func_name)
self.stage_id = getattr(self.vllm_config.model_config, "stage_id", None)
self.chunk_transfer_adapter = None
if getattr(model_config, "async_chunk", False):
self.chunk_transfer_adapter = OmniChunkTransferAdapter(self.vllm_config)
def _get_kv_transfer_criteria(self) -> dict | None:
# Note: vllm_config is available in Scheduler after super().__init__
@@ -152,7 +136,15 @@ class OmniARScheduler(VLLMScheduler):
return False
def schedule(self) -> SchedulerOutput: # type: ignore[override]
scheduler_output = super().schedule()
if self.chunk_transfer_adapter:
self.chunk_transfer_adapter.process_pending_chunks(self.waiting, self.running)
try:
scheduler_output = super().schedule()
finally:
if self.chunk_transfer_adapter:
# Add request waiting for chunk to the waiting and running queue
self.chunk_transfer_adapter.restore_queues(self.waiting, self.running)
try:
# Late import to avoid circulars in some launch modes
from .output import OmniNewRequestData
@@ -181,9 +173,8 @@ class OmniARScheduler(VLLMScheduler):
new_list.append(omni_nr)
scheduler_output.scheduled_new_reqs = new_list # type: ignore[assignment]
if self.omni_connector is not None:
get_chunk(self.omni_connector, scheduler_output)
if self.chunk_transfer_adapter:
self.chunk_transfer_adapter.postprocess_scheduler_output(scheduler_output, self.requests)
# Add information about requests needing KV cache transfer
finished_reqs = self.get_finished_requests_needing_kv_transfer()
except Exception:
@@ -312,6 +303,11 @@ class OmniARScheduler(VLLMScheduler):
kv_transfer_params = self._free_request(request)
if status_before_stop == RequestStatus.RUNNING:
stopped_running_reqs.add(request)
elif status_before_stop == RequestStatus.WAITING_FOR_CHUNK:
# In async chunk mode, request may be in either queue.
# Remove from both to avoid stale queue entries.
stopped_running_reqs.add(request)
stopped_preempted_reqs.add(request)
else:
stopped_preempted_reqs.add(request)
@@ -356,9 +352,8 @@ class OmniARScheduler(VLLMScheduler):
num_nans_in_logits=request.num_nans_in_logits,
)
)
if self.omni_connector is not None:
custom_process_next_stage_input_func = self.custom_process_next_stage_input_func
put_chunk(self.omni_connector, pooler_output, request, custom_process_next_stage_input_func)
if self.chunk_transfer_adapter is not None:
self.chunk_transfer_adapter.save_async(pooler_output, request)
else:
# Invariant: EngineCore returns no partial prefill outputs.
assert not prompt_logprobs_tensors
@@ -16,9 +16,9 @@ from vllm.v1.request import Request, RequestStatus
from vllm.v1.spec_decode.metrics import SpecDecodingStats
from vllm_omni.core.sched.output import OmniCachedRequestData, OmniNewRequestData
from vllm_omni.distributed.omni_connectors.adapter import get_chunk_for_generation
from vllm_omni.distributed.omni_connectors.factory import OmniConnectorFactory
from vllm_omni.distributed.omni_connectors.utils.config import ConnectorSpec
from vllm_omni.distributed.omni_connectors.transfer_adapter.chunk_transfer_adapter import (
OmniChunkTransferAdapter,
)
from vllm_omni.outputs import OmniModelRunnerOutput
@@ -26,15 +26,9 @@ class OmniGenerationScheduler(VLLMScheduler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
model_config = self.vllm_config.model_config
self.omni_connector = None
if model_config.async_chunk:
connector_config = model_config.stage_connector_config
connector_specs = ConnectorSpec(
name=connector_config.get("name", "SharedMemoryConnector"),
extra=connector_config.get("extra", {}),
)
self.omni_connector = OmniConnectorFactory.create_connector(connector_specs)
self.stage_id = getattr(self.vllm_config.model_config, "stage_id", None)
self.chunk_transfer_adapter = None
if getattr(model_config, "async_chunk", False):
self.chunk_transfer_adapter = OmniChunkTransferAdapter(self.vllm_config)
def schedule(self) -> SchedulerOutput:
"""Diffusion fast path:
@@ -59,18 +53,18 @@ class OmniGenerationScheduler(VLLMScheduler):
# Temporary queue: preserve waiting order, do not disturb non-diffusion requests
skipped_waiting_requests = create_request_queue(self.policy)
req_index = 0
if self.chunk_transfer_adapter:
self.chunk_transfer_adapter.process_pending_chunks(self.waiting, self.running)
# OMNI: Track requests that are already finished (e.g., marked by connector)
# These should be removed from running and not scheduled
already_finished_reqs: set[Request] = set()
while req_index < len(self.running) and token_budget > 0:
request = self.running[req_index]
if self.omni_connector is not None:
get_chunk_for_generation(self.omni_connector, request)
# OMNI: Skip requests that are not in self.requests
# This can happen when connector marks request as finished and it's removed from requests
if request.request_id not in self.requests or (
self.omni_connector is None and request.status == RequestStatus.FINISHED_STOPPED
self.chunk_transfer_adapter is None and request.status == RequestStatus.FINISHED_STOPPED
):
already_finished_reqs.add(request)
req_index += 1
@@ -107,12 +101,9 @@ class OmniGenerationScheduler(VLLMScheduler):
# independent of pooling_params)
while self.waiting and token_budget > 0 and len(self.running) < self.max_num_running_reqs:
request = self.waiting.peek_request()
if self.omni_connector is not None:
get_chunk_for_generation(self.omni_connector, request)
# OMNI: Skip requests that are not in self.requests
if request.request_id not in self.requests or (
self.omni_connector is None and request.status == RequestStatus.FINISHED_STOPPED
self.chunk_transfer_adapter is None and request.status == RequestStatus.FINISHED_STOPPED
):
# Pop the finished request from waiting queue and don't schedule it
self.waiting.pop_request()
@@ -154,7 +145,11 @@ class OmniGenerationScheduler(VLLMScheduler):
# If fast path scheduled none, fall back to the original scheduling
if not num_scheduled_tokens:
return super().schedule()
res = super().schedule()
if self.chunk_transfer_adapter:
self.chunk_transfer_adapter.restore_queues(self.waiting, self.running)
self.chunk_transfer_adapter.postprocess_scheduler_output(res)
return res
# Compute common prefix blocks (aligned with v1)
num_common_prefix_blocks = [0] * len(self.kv_cache_config.kv_cache_groups)
@@ -254,6 +249,11 @@ class OmniGenerationScheduler(VLLMScheduler):
new_list.append(omni_nr)
scheduler_output.scheduled_new_reqs = new_list # type: ignore[assignment]
if self.chunk_transfer_adapter:
self.chunk_transfer_adapter.restore_queues(self.waiting, self.running)
self.chunk_transfer_adapter.postprocess_scheduler_output(scheduler_output)
except Exception:
# If anything goes wrong, leave the original output unchanged
init_logger(__name__).exception("Failed to wrap scheduled_new_reqs with OmniNewRequestData")
@@ -361,7 +361,7 @@ class OmniGenerationScheduler(VLLMScheduler):
# Diffusion request: completes in one step; mark finished and free resources
if request.status == RequestStatus.FINISHED_STOPPED or (
self.omni_connector is None and request.num_computed_tokens >= request.num_prompt_tokens
self.chunk_transfer_adapter is None and request.num_computed_tokens >= request.num_prompt_tokens
):
request.status = RequestStatus.FINISHED_STOPPED
# Optional: set a stop_reason for front-end clarity
@@ -377,6 +377,11 @@ class OmniGenerationScheduler(VLLMScheduler):
kv_transfer_params = self._free_request(request)
if status_before_stop == RequestStatus.RUNNING:
stopped_running_reqs.add(request)
elif status_before_stop == RequestStatus.WAITING_FOR_CHUNK:
# In async chunk mode, request may be in either queue.
# Remove from both to avoid stale queue entries.
stopped_running_reqs.add(request)
stopped_preempted_reqs.add(request)
else:
stopped_preempted_reqs.add(request)
+9 -3
View File
@@ -298,11 +298,8 @@ class OmniDiffusionConfig:
# - Text encoders run on GPU while DiT is on CPU
# - DiT runs on GPU while encoders are on CPU
enable_cpu_offload: bool = False
# Layer-wise offloading (block-level offloading) parameters
enable_layerwise_offload: bool = False
# Number of transformer blocks ready for computation to keep on GPU
layerwise_num_gpu_layers: int = 1
use_fsdp_inference: bool = False
pin_cpu_memory: bool = True # Use pinned memory for faster transfers when offloading
@@ -335,6 +332,15 @@ class OmniDiffusionConfig:
# TODO: do not hard code
master_port: int | None = None
# Worker extension class for custom functionality
worker_extension_cls: str | None = None
# Custom pipeline arguments for custom pipelines
custom_pipeline_args: dict[str, Any] | None = None
# Diffusion model loading format
diffusion_load_format: str = "default" # "default", "custom_pipeline", "dummy"
# http server endpoint config, would be ignored in local mode
host: str | None = None
port: int | None = None
@@ -77,6 +77,10 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
mp.set_start_method("spawn", force=True)
processes = []
# Extract worker_extension_cls and custom_pipeline_args from od_config
worker_extension_cls = od_config.worker_extension_cls
custom_pipeline_args = getattr(od_config, "custom_pipeline_args", None)
# Launch all worker processes
scheduler_pipe_readers = []
scheduler_pipe_writers = []
@@ -91,6 +95,8 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
od_config,
writer,
broadcast_handle,
worker_extension_cls,
custom_pipeline_args,
),
name=f"DiffusionWorker-{i}",
daemon=True,
@@ -22,6 +22,7 @@ from vllm.model_executor.model_loader.weight_utils import (
maybe_download_from_modelscope,
safetensors_weights_iterator,
)
from vllm.utils.import_utils import resolve_obj_by_qualname
from vllm.utils.torch_utils import set_default_torch_dtype
from vllm_omni.diffusion.data import OmniDiffusionConfig
@@ -208,13 +209,22 @@ class DiffusersPipelineLoader:
allow_patterns_overrides=None,
)
def load_model(self, od_config: OmniDiffusionConfig, load_device: str) -> nn.Module:
def load_model(
self,
od_config: OmniDiffusionConfig,
load_device: str,
load_format: str = "default",
custom_pipeline_name: str | None = None,
) -> nn.Module:
"""Load a model with the given configurations."""
target_device = torch.device(load_device)
with set_default_torch_dtype(od_config.dtype):
with target_device:
model = initialize_model(od_config)
if load_format == "default":
model = initialize_model(od_config)
elif load_format == "custom_pipeline":
model_cls = resolve_obj_by_qualname(custom_pipeline_name)
model = model_cls(od_config=od_config)
logger.debug("Loading weights on %s ...", load_device)
# Quantization does not happen in `load_weights` but after it
self.load_weights(model)
@@ -10,7 +10,6 @@
import math
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any
import numpy as np
import torch
@@ -1251,6 +1250,39 @@ class Bagel(nn.Module):
def prepare_vae_latent(self, curr_kvlens, curr_rope, image_sizes, new_token_ids):
return self.prepare_input(curr_kvlens, curr_rope, image_sizes, new_token_ids)
def prepare_vae_latent_cfg(self, curr_kvlens, curr_rope, image_sizes):
packed_position_ids, packed_indexes, packed_key_value_indexes = list(), list(), list()
query_curr = curr = 0
for (H, W), curr_kvlen, curr_position_id in zip(image_sizes, curr_kvlens, curr_rope):
packed_key_value_indexes.extend(range(curr, curr + curr_kvlen))
curr += curr_kvlen
packed_indexes.append(curr)
curr += 1
query_curr += 1
h, w = H // self.latent_downsample, W // self.latent_downsample
num_image_tokens = h * w
packed_indexes.extend(range(curr, curr + num_image_tokens))
curr += num_image_tokens
query_curr += num_image_tokens
packed_indexes.append(curr)
curr += 1
query_curr += 1
packed_position_ids.extend([curr_position_id] * (num_image_tokens + 2))
generation_input = {
"cfg_packed_position_ids": torch.tensor(packed_position_ids, dtype=torch.long),
"cfg_key_values_lens": torch.tensor(curr_kvlens, dtype=torch.int),
"cfg_packed_query_indexes": torch.tensor(packed_indexes, dtype=torch.long),
"cfg_packed_key_value_indexes": torch.tensor(packed_key_value_indexes, dtype=torch.long),
}
return generation_input
def generate_image(
self,
packed_text_ids: torch.LongTensor,
@@ -1266,11 +1298,25 @@ class Bagel(nn.Module):
packed_key_value_indexes: torch.LongTensor,
num_timesteps: int = 24,
timestep_shift: float = 1.0,
cfg_renorm_min: float = 0.0,
cfg_renorm_type: str = "global",
cfg_interval: tuple[float, float] = [0, 1],
# cfg_text
cfg_text_scale: float = 1.0,
cfg_text_packed_query_indexes: torch.LongTensor | None = None,
cfg_text_packed_position_ids: torch.LongTensor | None = None,
cfg_text_past_key_values: NaiveCache | None = None,
cfg_text_key_values_lens: torch.IntTensor | None = None,
cfg_text_packed_key_value_indexes: torch.LongTensor | None = None,
# cfg_img
cfg_img_scale: float = 1.0,
cfg_img_packed_query_indexes: torch.LongTensor | None = None,
cfg_img_packed_position_ids: torch.LongTensor | None = None,
cfg_img_past_key_values: NaiveCache | None = None,
cfg_img_key_values_lens: torch.IntTensor | None = None,
cfg_img_packed_key_value_indexes: torch.LongTensor | None = None,
cfg_type: str = "parallel",
):
model_pred_cache_dic, model_pred_current = None, None
model_pred_text_cache_dic, model_pred_text_current = None, None
model_pred_img_cache_dic, model_pred_img_current = None, None
x_t = packed_init_noises
timesteps = torch.linspace(1, 0, num_timesteps, device=x_t.device)
@@ -1280,6 +1326,12 @@ class Bagel(nn.Module):
for i, t in enumerate(timesteps):
timestep = torch.tensor([t] * x_t.shape[0], device=x_t.device)
if t > cfg_interval[0] and t <= cfg_interval[1]:
cfg_text_scale_ = cfg_text_scale
cfg_img_scale_ = cfg_img_scale
else:
cfg_text_scale_ = 1.0
cfg_img_scale_ = 1.0
v_t = self._forward_flow(
x_t=x_t,
timestep=timestep,
@@ -1293,17 +1345,26 @@ class Bagel(nn.Module):
key_values_lens=key_values_lens,
past_key_values=past_key_values,
packed_key_value_indexes=packed_key_value_indexes,
# cache
model_pred_cache_dic=model_pred_cache_dic,
model_pred_current=model_pred_current,
model_pred_text_cache_dic=model_pred_text_cache_dic,
model_pred_text_current=model_pred_text_current,
model_pred_img_cache_dic=model_pred_img_cache_dic,
model_pred_img_current=model_pred_img_current,
cfg_renorm_min=cfg_renorm_min,
cfg_renorm_type=cfg_renorm_type,
# cfg_text
cfg_text_scale=cfg_text_scale_,
cfg_text_packed_position_ids=cfg_text_packed_position_ids,
cfg_text_packed_query_indexes=cfg_text_packed_query_indexes,
cfg_text_key_values_lens=cfg_text_key_values_lens,
cfg_text_past_key_values=cfg_text_past_key_values,
cfg_text_packed_key_value_indexes=cfg_text_packed_key_value_indexes,
# cfg_img
cfg_img_scale=cfg_img_scale_,
cfg_img_packed_position_ids=cfg_img_packed_position_ids,
cfg_img_packed_query_indexes=cfg_img_packed_query_indexes,
cfg_img_key_values_lens=cfg_img_key_values_lens,
cfg_img_past_key_values=cfg_img_past_key_values,
cfg_img_packed_key_value_indexes=cfg_img_packed_key_value_indexes,
cfg_type=cfg_type,
)
x_t = x_t - v_t.to(x_t.device) * dts[i] # velocity pointing from data to noise
unpacked_latent = x_t.split((packed_seqlens - 2).tolist())
return unpacked_latent
@@ -1321,13 +1382,23 @@ class Bagel(nn.Module):
key_values_lens: torch.IntTensor,
past_key_values: NaiveCache,
packed_key_value_indexes: torch.LongTensor,
# cache
model_pred_cache_dic: dict[str, Any] | None = None,
model_pred_current: int | None = None,
model_pred_text_cache_dic: dict[str, Any] | None = None,
model_pred_text_current: int | None = None,
model_pred_img_cache_dic: dict[str, Any] | None = None,
model_pred_img_current: int | None = None,
cfg_renorm_min: float = 0.0,
cfg_renorm_type: str = "global",
# cfg_text
cfg_text_scale: float = 1.0,
cfg_text_packed_position_ids: torch.LongTensor | None = None,
cfg_text_packed_query_indexes: torch.LongTensor | None = None,
cfg_text_key_values_lens: torch.Tensor | None = None,
cfg_text_past_key_values: NaiveCache | None = None,
cfg_text_packed_key_value_indexes: torch.LongTensor | None = None,
# cfg_img
cfg_img_scale: float = 1.0,
cfg_img_packed_position_ids: torch.LongTensor | None = None,
cfg_img_packed_query_indexes: torch.LongTensor | None = None,
cfg_img_key_values_lens: torch.Tensor | None = None,
cfg_img_past_key_values: NaiveCache | None = None,
cfg_img_packed_key_value_indexes: torch.LongTensor | None = None,
cfg_type: str = "parallel",
):
packed_text_embedding = self.language_model.model.embed_tokens(packed_text_ids)
packed_sequence = packed_text_embedding.new_zeros((sum(packed_seqlens), self.hidden_size))
@@ -1364,4 +1435,70 @@ class Bagel(nn.Module):
v_t = self.llm2vae(output.packed_query_sequence)
v_t = v_t[packed_vae_token_indexes]
if cfg_text_scale > 1.0:
cfg_text_output = self.language_model.forward(
packed_query_sequence=packed_sequence,
query_lens=packed_seqlens,
packed_query_position_ids=cfg_text_packed_position_ids,
packed_query_indexes=cfg_text_packed_query_indexes,
past_key_values=cfg_text_past_key_values,
key_values_lens=cfg_text_key_values_lens,
packed_key_value_indexes=cfg_text_packed_key_value_indexes,
update_past_key_values=False,
is_causal=False,
**extra_inputs,
)
cfg_text_v_t = self.llm2vae(cfg_text_output.packed_query_sequence)
cfg_text_v_t = cfg_text_v_t[packed_vae_token_indexes]
if cfg_img_scale > 1.0:
cfg_img_output = self.language_model.forward(
packed_query_sequence=packed_sequence,
query_lens=packed_seqlens,
packed_query_position_ids=cfg_img_packed_position_ids,
packed_query_indexes=cfg_img_packed_query_indexes,
past_key_values=cfg_img_past_key_values,
key_values_lens=cfg_img_key_values_lens,
packed_key_value_indexes=cfg_img_packed_key_value_indexes,
update_past_key_values=False,
is_causal=False,
**extra_inputs,
)
cfg_img_v_t = self.llm2vae(cfg_img_output.packed_query_sequence)
cfg_img_v_t = cfg_img_v_t[packed_vae_token_indexes]
if cfg_text_scale > 1.0:
if cfg_renorm_type == "text_channel":
v_t_text_ = cfg_text_v_t + cfg_text_scale * (v_t - cfg_text_v_t)
norm_v_t = torch.norm(v_t, dim=-1, keepdim=True)
norm_v_t_text_ = torch.norm(v_t_text_, dim=-1, keepdim=True)
scale = (norm_v_t / (norm_v_t_text_ + 1e-8)).clamp(min=cfg_renorm_min, max=1.0)
v_t_text = v_t_text_ * scale
if cfg_img_scale > 1.0:
v_t = cfg_img_v_t + cfg_img_scale * (v_t_text - cfg_img_v_t)
else:
v_t = v_t_text
else:
v_t_text_ = cfg_text_v_t + cfg_text_scale * (v_t - cfg_text_v_t)
if cfg_img_scale > 1.0:
v_t_ = cfg_img_v_t + cfg_img_scale * (v_t_text_ - cfg_img_v_t)
else:
v_t_ = v_t_text_
# NOTE norm is computed over all dimensions, thus currently only supports batch_size = 1 with navit
if cfg_renorm_type == "global":
norm_v_t = torch.norm(v_t)
norm_v_t_ = torch.norm(v_t_)
elif cfg_renorm_type == "channel":
norm_v_t = torch.norm(v_t, dim=-1, keepdim=True)
norm_v_t_ = torch.norm(v_t_, dim=-1, keepdim=True)
else:
raise NotImplementedError(f"{cfg_renorm_type} is not supported")
scale = (norm_v_t / (norm_v_t_ + 1e-8)).clamp(min=cfg_renorm_min, max=1.0)
v_t = v_t_ * scale
else:
# No CFG
pass
return v_t
@@ -9,6 +9,7 @@ from __future__ import annotations
import json
import os
from collections.abc import Iterable
from copy import deepcopy
from dataclasses import dataclass
from math import isqrt
@@ -37,7 +38,12 @@ logger = init_logger(__name__)
@dataclass
class BagelGenParams:
num_timesteps: int = 50
timestep_shift: float = 1.0
timestep_shift: float = 3.0
cfg_text_scale: float = 4.0
cfg_img_scale: float = 1.5
cfg_interval: tuple = (0.4, 1.0)
cfg_renorm_min: float = 0.0
cfg_renorm_type: str = "global"
def add_special_tokens(tokenizer):
@@ -296,10 +302,15 @@ class BagelPipeline(nn.Module):
)
image_shape = (height, width)
# Map request params to Bagel gen params (defaults follow Bagel inferencer)
extra_args = getattr(req.sampling_params, "extra_args", {}) or {}
cfg_text_scale = extra_args.get("cfg_text_scale", 4.0)
cfg_img_scale = extra_args.get("cfg_img_scale", 1.5)
gen_params = BagelGenParams(
num_timesteps=int(req.sampling_params.num_inference_steps or 50),
timestep_shift=3.0,
cfg_text_scale=cfg_text_scale,
cfg_img_scale=cfg_img_scale,
)
gen_context = {
@@ -307,20 +318,26 @@ class BagelPipeline(nn.Module):
"ropes": [0],
"past_key_values": NaiveCache(self.bagel.config.llm_config.num_hidden_layers),
}
cfg_text_context = deepcopy(gen_context)
cfg_img_context = deepcopy(gen_context)
# Add text prompt (prefill) on gen context.
# [Omni] Check for injected KV Cache from remote transfer
injected_kv = req.sampling_params.past_key_values
if injected_kv is not None:
logger.info("Using injected KV Cache (direct)")
gen_context["past_key_values"] = injected_kv
# User requested: kv_lens and ropes set to [gen_context["past_key_values"].key_cache[0].shape[0]]
# Assuming injected_kv is compatible and has key_cache[0]
seq_len = injected_kv.key_cache[0].shape[0]
gen_context["kv_lens"] = [seq_len]
gen_context["ropes"] = [seq_len]
# Disable CFG: single KV cache cannot support 3-branch CFG
logger.warning("CFG is disabled when using injected KV Cache")
gen_params = BagelGenParams(
num_timesteps=gen_params.num_timesteps,
timestep_shift=gen_params.timestep_shift,
cfg_text_scale=1.0,
cfg_img_scale=1.0,
)
else:
image_input = (
None if isinstance(first_prompt, str) else (first_prompt.get("multi_modal_data") or {}).get("image")
@@ -335,9 +352,35 @@ class BagelPipeline(nn.Module):
if self.image_processor and self.vae:
def vit_transforms(img):
# SigLIP processor returns dict with pixel_values; we want the tensor
return self.image_processor(images=img, return_tensors="pt").pixel_values[0]
stride = self.bagel.latent_downsample
max_img_size = int(self.bagel.max_latent_size * stride)
def _resize_to_stride(img):
if img.mode != "RGB":
img = img.convert("RGB")
w, h = img.size
# Scale down if longest edge exceeds max
scale = min(max_img_size / max(w, h), 1.0)
# Scale up if shortest edge is too small (min 256)
min_img_size = min(256, max_img_size)
scale = max(scale, min_img_size / min(w, h))
new_w = max(stride, int(round(w * scale / stride) * stride))
new_h = max(stride, int(round(h * scale / stride) * stride))
# Clamp to max
new_w = min(new_w, max_img_size)
new_h = min(new_h, max_img_size)
if new_w != w or new_h != h:
img = img.resize((new_w, new_h), Image.BICUBIC)
return img
image_input = [_resize_to_stride(img) for img in image_input]
resized_w, resized_h = image_input[0].size
image_shape = (resized_h, resized_w)
logger.info(f"img2img: resized image to {resized_w}x{resized_h}")
def vae_transforms(img):
if img.mode != "RGB":
img = img.convert("RGB")
@@ -345,7 +388,7 @@ class BagelPipeline(nn.Module):
arr = torch.from_numpy(np.array(img)).float() / 127.5 - 1.0
return arr.permute(2, 0, 1)
# 1. Update VAE
# Update gen_context with image (VAE + ViT)
gen_input_vae, newlens_vae, new_rope_vae = self.bagel.prepare_vae_images(
curr_kvlens=gen_context["kv_lens"],
curr_rope=gen_context["ropes"],
@@ -353,12 +396,9 @@ class BagelPipeline(nn.Module):
transforms=vae_transforms,
new_token_ids=self.new_token_ids,
)
for k, v in gen_input_vae.items():
if torch.is_tensor(v):
gen_input_vae[k] = v.to(self.device)
# VAE needs bfloat16 to match model strings usually, specifically encode
with torch.autocast(
device_type=self.device.type,
enabled=self.device.type != "cpu",
@@ -370,7 +410,6 @@ class BagelPipeline(nn.Module):
gen_context["kv_lens"] = newlens_vae
gen_context["ropes"] = new_rope_vae
# 2. Update ViT
gen_input_img, newlens_img, new_rope_img = self.bagel.prepare_vit_images(
curr_kvlens=gen_context["kv_lens"],
curr_rope=gen_context["ropes"],
@@ -378,11 +417,9 @@ class BagelPipeline(nn.Module):
transforms=vit_transforms,
new_token_ids=self.new_token_ids,
)
for k, v in gen_input_img.items():
if torch.is_tensor(v):
gen_input_img[k] = v.to(self.device)
with torch.autocast(
device_type=self.device.type,
enabled=self.device.type != "cpu",
@@ -393,6 +430,10 @@ class BagelPipeline(nn.Module):
)
gen_context["kv_lens"] = newlens_img
gen_context["ropes"] = new_rope_img
cfg_text_context = deepcopy(gen_context)
# Update gen_context with text prompt
generation_input, newlens, new_rope = self.bagel.prepare_prompts(
curr_kvlens=gen_context["kv_lens"],
curr_rope=gen_context["ropes"],
@@ -424,12 +465,56 @@ class BagelPipeline(nn.Module):
gen_context["kv_lens"] = newlens
gen_context["ropes"] = new_rope
# cfg_text_context: update with negative prompt (no text condition)
neg_prompt = extra_args.get("negative_prompt", "")
neg_input, neg_newlens, neg_rope = self.bagel.prepare_prompts(
curr_kvlens=cfg_text_context["kv_lens"],
curr_rope=cfg_text_context["ropes"],
prompts=[neg_prompt],
tokenizer=self.tokenizer,
new_token_ids=self.new_token_ids,
)
for k, v in neg_input.items():
if torch.is_tensor(v):
neg_input[k] = v.to(self.device)
with torch.autocast(
device_type=self.device.type,
enabled=self.device.type != "cpu",
dtype=self.od_config.dtype,
):
cfg_text_context["past_key_values"] = self.bagel.forward_cache_update_text(
cfg_text_context["past_key_values"], **neg_input
)
cfg_text_context["kv_lens"] = neg_newlens
cfg_text_context["ropes"] = neg_rope
# cfg_img_context: update with text prompt (no image condition)
cfg_img_generation_input, cfg_img_newlens, cfg_img_new_rope = self.bagel.prepare_prompts(
curr_kvlens=cfg_img_context["kv_lens"],
curr_rope=cfg_img_context["ropes"],
prompts=[prompt],
tokenizer=self.tokenizer,
new_token_ids=self.new_token_ids,
)
for k, v in cfg_img_generation_input.items():
if torch.is_tensor(v):
cfg_img_generation_input[k] = v.to(self.device)
with torch.autocast(
device_type=self.device.type,
enabled=self.device.type != "cpu",
dtype=self.od_config.dtype,
):
cfg_img_context["past_key_values"] = self.bagel.forward_cache_update_text(
cfg_img_context["past_key_values"], **cfg_img_generation_input
)
cfg_img_context["kv_lens"] = cfg_img_newlens
cfg_img_context["ropes"] = cfg_img_new_rope
if req.sampling_params.seed is not None:
torch.manual_seed(req.sampling_params.seed)
if self.device.type == "cuda":
torch.cuda.manual_seed(req.sampling_params.seed)
# Prepare latent query and run flow
generation_input = self.bagel.prepare_vae_latent(
curr_kvlens=gen_context["kv_lens"],
curr_rope=gen_context["ropes"],
@@ -462,6 +547,25 @@ class BagelPipeline(nn.Module):
if torch.is_tensor(v):
generation_input[k] = v.to(self.device)
# text cfg
generation_input_cfg_text = self.bagel.prepare_vae_latent_cfg(
curr_kvlens=cfg_text_context["kv_lens"],
curr_rope=cfg_text_context["ropes"],
image_sizes=[image_shape],
)
# img cfg
generation_input_cfg_img = self.bagel.prepare_vae_latent_cfg(
curr_kvlens=cfg_img_context["kv_lens"],
curr_rope=cfg_img_context["ropes"],
image_sizes=[image_shape],
)
for k, v in generation_input_cfg_text.items():
if torch.is_tensor(v):
generation_input_cfg_text[k] = v.to(self.device)
for k, v in generation_input_cfg_img.items():
if torch.is_tensor(v):
generation_input_cfg_img[k] = v.to(self.device)
with torch.autocast(
device_type=self.device.type,
enabled=self.device.type != "cpu",
@@ -469,12 +573,26 @@ class BagelPipeline(nn.Module):
):
latents = self.bagel.generate_image(
past_key_values=gen_context["past_key_values"],
cfg_text_past_key_values=cfg_text_context["past_key_values"],
cfg_img_past_key_values=cfg_img_context["past_key_values"],
num_timesteps=gen_params.num_timesteps,
timestep_shift=gen_params.timestep_shift,
cfg_text_scale=gen_params.cfg_text_scale,
cfg_img_scale=gen_params.cfg_img_scale,
cfg_interval=gen_params.cfg_interval,
cfg_renorm_min=gen_params.cfg_renorm_min,
cfg_renorm_type=gen_params.cfg_renorm_type,
**generation_input,
cfg_text_packed_position_ids=generation_input_cfg_text["cfg_packed_position_ids"],
cfg_text_packed_query_indexes=generation_input_cfg_text["cfg_packed_query_indexes"],
cfg_text_key_values_lens=generation_input_cfg_text["cfg_key_values_lens"],
cfg_text_packed_key_value_indexes=generation_input_cfg_text["cfg_packed_key_value_indexes"],
cfg_img_packed_position_ids=generation_input_cfg_img["cfg_packed_position_ids"],
cfg_img_packed_query_indexes=generation_input_cfg_img["cfg_packed_query_indexes"],
cfg_img_key_values_lens=generation_input_cfg_img["cfg_key_values_lens"],
cfg_img_packed_key_value_indexes=generation_input_cfg_img["cfg_packed_key_value_indexes"],
)
# Decode first sample
img = self._decode_image_from_latent(self.bagel, self.vae, latents[0], image_shape)
return DiffusionOutput(output=img)
@@ -2,14 +2,20 @@ from collections.abc import Iterable
import torch
import torch.nn as nn
from diffusers.models.attention import FeedForward
import torch.nn.functional as F
# TODO replace this with vLLM implementation
from diffusers.models.embeddings import CombinedTimestepTextProjEmbeddings, PatchEmbed
from diffusers.models.modeling_outputs import Transformer2DModelOutput
from diffusers.models.normalization import AdaLayerNormContinuous, AdaLayerNormZero, RMSNorm, SD35AdaLayerNormZeroX
from diffusers.models.normalization import AdaLayerNormContinuous, AdaLayerNormZero, SD35AdaLayerNormZeroX
from vllm.logger import init_logger
from vllm.model_executor.layers.linear import QKVParallelLinear, ReplicatedLinear
from vllm.model_executor.layers.layernorm import RMSNorm
from vllm.model_executor.layers.linear import (
ColumnParallelLinear,
QKVParallelLinear,
ReplicatedLinear,
RowParallelLinear,
)
from vllm.model_executor.model_loader.weight_utils import default_weight_loader
from vllm_omni.diffusion.attention.layer import Attention
@@ -18,6 +24,54 @@ from vllm_omni.diffusion.data import OmniDiffusionConfig
logger = init_logger(__name__)
class GELU(nn.Module):
def __init__(self, dim_in: int, dim_out: int, approximate: str = "none", bias: bool = True):
super().__init__()
self.proj = ColumnParallelLinear(dim_in, dim_out, bias=bias)
self.approximate = approximate
def forward(self, hidden_states):
hidden_states, _ = self.proj(hidden_states)
hidden_states = F.gelu(hidden_states, approximate=self.approximate)
return hidden_states
class FeedForward(nn.Module):
def __init__(
self,
dim: int,
dim_out: int | None = None,
mult: int = 4,
dropout: float = 0.0,
activation_fn: str = "gelu-approximate",
final_dropout: bool = False,
inner_dim=None,
bias: bool = True,
):
super().__init__()
if inner_dim is None:
inner_dim = int(dim * mult)
dim_out = dim_out if dim_out is not None else dim
if activation_fn == "gelu-approximate":
act_fn = GELU(dim, inner_dim, approximate="tanh", bias=bias)
else:
raise ValueError(f"Unsupported activation function type: {activation_fn}")
self.net = nn.ModuleList([])
self.net.append(act_fn)
self.net.append(nn.Dropout(dropout))
self.net.append(RowParallelLinear(inner_dim, dim_out, bias=bias))
if final_dropout:
self.net.append(nn.Dropout(dropout))
def forward(self, hidden_states: torch.Tensor, *args, **kwargs) -> torch.Tensor:
for layer in self.net:
output = layer(hidden_states)
hidden_states = output[0] if isinstance(output, tuple) else output
return hidden_states
class SD3PatchEmbed(nn.Module):
"""
2D Image to Patch Embedding with support for SD3.
@@ -77,7 +131,6 @@ class SD3CrossAttention(nn.Module):
hidden_size=dim,
head_size=self.head_dim,
total_num_heads=num_heads,
disable_tp=True,
)
self.norm_q = RMSNorm(head_dim, eps=eps) if qk_norm else nn.Identity()
self.norm_k = RMSNorm(head_dim, eps=eps) if qk_norm else nn.Identity()
@@ -88,17 +141,18 @@ class SD3CrossAttention(nn.Module):
added_kv_proj_dim,
head_size=self.inner_kv_dim // self.num_heads,
total_num_heads=self.num_heads,
disable_tp=True,
)
else:
self.add_kv_proj = None
if not context_pre_only:
self.to_add_out = ReplicatedLinear(self.inner_dim, self.dim, bias=out_bias)
self.to_add_out = RowParallelLinear(self.inner_dim, self.dim, bias=out_bias)
else:
self.to_add_out = None
if not pre_only:
self.to_out = nn.ModuleList([])
self.to_out.append(ReplicatedLinear(self.inner_dim, self.dim, bias=out_bias))
self.to_out.append(RowParallelLinear(self.inner_dim, self.dim, bias=out_bias))
else:
self.to_out = None
@@ -106,7 +160,7 @@ class SD3CrossAttention(nn.Module):
self.norm_added_k = RMSNorm(head_dim, eps=eps) if qk_norm else nn.Identity()
self.attn = Attention(
num_heads=num_heads,
num_heads=self.to_qkv.num_heads,
head_size=self.head_dim,
softmax_scale=1.0 / (self.head_dim**0.5),
causal=False,
@@ -118,13 +172,15 @@ class SD3CrossAttention(nn.Module):
encoder_hidden_states: torch.Tensor | None = None,
):
# Compute QKV for image stream (sample projections)
qkv, _ = self.to_qkv(hidden_states)
qkv = self.to_qkv(hidden_states)
qkv = qkv[0]
img_query, img_key, img_value = qkv.chunk(3, dim=-1)
# Reshape for multi-head attention
img_query = img_query.unflatten(-1, (self.num_heads, -1))
img_key = img_key.unflatten(-1, (self.num_heads, -1))
img_value = img_value.unflatten(-1, (self.num_heads, -1))
local_num_heads = self.to_qkv.num_heads
img_query = img_query.unflatten(-1, (local_num_heads, -1))
img_key = img_key.unflatten(-1, (local_num_heads, -1))
img_value = img_value.unflatten(-1, (local_num_heads, -1))
# Apply QK normalization
img_query = self.norm_q(img_query)
@@ -132,12 +188,13 @@ class SD3CrossAttention(nn.Module):
if encoder_hidden_states is not None:
# Compute QKV for text stream (context projections)
qkv, _ = self.add_kv_proj(encoder_hidden_states)
txt_query, txt_key, txt_value = qkv.chunk(3, dim=-1)
qkv_add = self.add_kv_proj(encoder_hidden_states)
qkv_add = qkv_add[0]
txt_query, txt_key, txt_value = qkv_add.chunk(3, dim=-1)
txt_query = txt_query.unflatten(-1, (self.num_heads, -1))
txt_key = txt_key.unflatten(-1, (self.num_heads, -1))
txt_value = txt_value.unflatten(-1, (self.num_heads, -1))
txt_query = txt_query.unflatten(-1, (local_num_heads, -1))
txt_key = txt_key.unflatten(-1, (local_num_heads, -1))
txt_value = txt_value.unflatten(-1, (local_num_heads, -1))
txt_query = self.norm_added_q(txt_query)
txt_key = self.norm_added_k(txt_key)
@@ -265,6 +322,8 @@ class SD3TransformerBlock(nn.Module):
encoder_hidden_states: torch.FloatTensor,
temb: torch.FloatTensor,
) -> tuple[torch.Tensor, torch.Tensor]:
if isinstance(encoder_hidden_states, tuple):
encoder_hidden_states = encoder_hidden_states[0]
if self.use_dual_attention:
norm_hidden_states, gate_msa, shift_mlp, scale_mlp, gate_mlp, norm_hidden_states2, gate_msa2 = self.norm1(
hidden_states, emb=temb
@@ -363,7 +422,7 @@ class SD3Transformer2DModel(nn.Module):
self.time_text_embed = CombinedTimestepTextProjEmbeddings(
embedding_dim=self.inner_dim, pooled_projection_dim=self.pooled_projection_dim
)
self.context_embedder = nn.Linear(self.joint_attention_dim, self.caption_projection_dim)
self.context_embedder = ReplicatedLinear(self.joint_attention_dim, self.caption_projection_dim)
self.transformer_blocks = nn.ModuleList(
[
@@ -380,7 +439,9 @@ class SD3Transformer2DModel(nn.Module):
)
self.norm_out = AdaLayerNormContinuous(self.inner_dim, self.inner_dim, elementwise_affine=False, eps=1e-6)
self.proj_out = nn.Linear(self.inner_dim, self.patch_size * self.patch_size * self.out_channels, bias=True)
self.proj_out = ReplicatedLinear(
self.inner_dim, self.patch_size * self.patch_size * self.out_channels, bias=True
)
def forward(
self,
@@ -427,6 +488,9 @@ class SD3Transformer2DModel(nn.Module):
hidden_states = self.norm_out(hidden_states, temb)
hidden_states = self.proj_out(hidden_states)
if isinstance(hidden_states, (tuple, list)):
hidden_states = hidden_states[0]
# unpatchify
patch_size = self.patch_size
height = height // patch_size
-535
View File
@@ -1,535 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""CPU offloading utilities for diffusion models.
This module provides mutual-exclusion CPU offloading between DiT and encoders.
When enable_cpu_offload is enabled:
- Text encoders run on GPU while DiT is on CPU
- DiT runs on GPU while encoders are offloaded to CPU
This allows running large models on limited GPU memory.
"""
from __future__ import annotations
from functools import partial
from itertools import chain
from typing import TYPE_CHECKING, Any
import torch
from torch import nn
from vllm.logger import init_logger
from vllm_omni.platforms import current_omni_platform
if TYPE_CHECKING:
from vllm_omni.diffusion.data import OmniDiffusionConfig
logger = init_logger(__name__)
class SequentialOffloader:
"""Sequential offloader: DiT and encoders take turns on GPU.
Uses PyTorch's forward pre-hooks to automatically swap models:
- Before encoder runs: move DiT modules to CPU, move encoder to GPU
- Before DiT runs: move encoders to CPU, move active DiT to GPU
This ensures only one large model group is on GPU at a time.
"""
def __init__(
self,
dits: list[nn.Module],
encoders: list[nn.Module],
device: torch.device,
pin_memory: bool = True,
):
assert all(isinstance(m, nn.Module) for m in dits), "All dits must be nn.Module"
assert all(isinstance(m, nn.Module) for m in encoders), "All encoders must be nn.Module"
self.dits = dits
self.encoders = encoders
self.device = device
self.pin_memory = pin_memory
self._handles: list = []
def _to_cpu(self, module: nn.Module) -> None:
"""Move module to CPU with optional memory pinning."""
# Skip if already on CPU
try:
param = next(module.parameters())
if param.device.type == "cpu":
return
except StopIteration:
return
previous_device = param.device
module.to("cpu", non_blocking=True)
# Release allocator blocks when tensors leave the GPU.
if previous_device.type != "cpu":
torch.cuda.empty_cache()
if self.pin_memory:
for p in module.parameters():
if p.data.device.type == "cpu" and not p.data.is_pinned():
p.data = p.data.pin_memory()
def _to_gpu(self, module: nn.Module) -> None:
"""Move module to GPU."""
# Skip if already on target device
try:
if next(module.parameters()).device == self.device:
return
except StopIteration:
return
module.to(self.device, non_blocking=True)
def _dit_pre_hook(self, module: nn.Module, args: tuple) -> None:
"""Before DiT forward: offload encoders, load DiT."""
for enc in self.encoders:
self._to_cpu(enc)
self._to_gpu(module)
current_omni_platform.synchronize()
logger.debug("Swapped: encoders -> CPU, DiT -> GPU")
def _encoder_pre_hook(self, module: nn.Module, args: tuple) -> None:
"""Before encoder forward: offload DiT, load encoder."""
for dit_mod in self.dits:
self._to_cpu(dit_mod)
self._to_gpu(module)
current_omni_platform.synchronize()
logger.debug("Swapped: DiT -> CPU, encoder -> GPU")
def register(self) -> None:
"""Register forward pre-hooks on DiT and encoders."""
# Hook on each DiT-like module
for dit_mod in self.dits:
h = dit_mod.register_forward_pre_hook(self._dit_pre_hook)
self._handles.append(h)
logger.debug("Registered offload hook for %s", dit_mod.__class__.__name__)
# Hook on each encoder
for enc in self.encoders:
h = enc.register_forward_pre_hook(self._encoder_pre_hook)
self._handles.append(h)
logger.debug("Registered offload hook for %s", enc.__class__.__name__)
def remove(self) -> None:
"""Remove all hooks."""
for h in self._handles:
h.remove()
self._handles = []
class LayerwiseOffloader:
"""Layer-wise CPU offloading for transformer blocks.
Keeps only a sliding window of layers (blocks), by default a single layer, on GPU,
prefetching the next block while the current block computes to approach compute - memcpy overlap.
Unused blocks are freed on GPU.
Based on implementations from:
https://github.com/sgl-project/sglang/blob/v0.5.8/python/sglang/multimodal_gen/runtime/utils/layerwise_offload.py
"""
def __init__(
self,
blocks: list[nn.Module],
device: torch.device,
pin_memory: bool = True,
num_gpu_layers: int = 1,
):
assert all(isinstance(m, nn.Module) for m in blocks), "All transformer blocks must be torch.nn.Module"
assert current_omni_platform.is_cuda(), "Layerwise offloading is only supported on cuda devices for now"
self.blocks = blocks
self.device = device
self.pin_memory = pin_memory
self.num_gpu_layers = num_gpu_layers
self.num_blocks = len(self.blocks)
if self.num_blocks == 0:
raise ValueError("LayerwiseOffloader requires at least one block, but found 0.")
if not (1 <= self.num_gpu_layers <= self.num_blocks):
raise ValueError(f"Invalid num_gpu_layers {self.num_gpu_layers} with {self.num_blocks} blocks")
self._pre_hook_handles: list = []
self._post_hook_handles: list = []
self._copy_stream = torch.cuda.Stream()
# Per-layer synchronization primitive: set after H2D copy completes.
self._prefetch_done: list[torch.cuda.Event | None] = [None] * self.num_blocks
# Simple state to avoid redundant work.
self._resident: list[bool] = [False] * self.num_blocks
# Pre-allocate gpu tensors
# layer-id -> {dtype -> flattened aggregated cpu tensor}
self.layer_cpu_weights: list[dict[torch.dtype, torch.Tensor]] = []
self.layer_metadata: list[dict[torch.dtype, list[dict[str, Any]]]] = []
self.block_parameters: dict[int, dict[str, nn.Parameter]] = {}
self.block_buffers: dict[int, dict[str, torch.Tensor]] = {}
for layer_idx, block in enumerate(self.blocks):
self.block_parameters[layer_idx] = dict(block.named_parameters())
self.block_buffers[layer_idx] = dict(block.named_buffers())
dtype_cpu_flattened_weights, dtype_metadata = self._to_cpu(
self.block_parameters[layer_idx], self.block_buffers[layer_idx]
)
self.layer_cpu_weights.append(dtype_cpu_flattened_weights)
self.layer_metadata.append(dtype_metadata)
if self.num_blocks != len(self.layer_cpu_weights):
logger.error(
f"Inconsistent block layers happened: # of blocks: {self.num_blocks}; "
f"# of layer cpu weights: {len(self.layer_cpu_weights)}"
)
# Register pre and post forward hooks on each of the blocks
self.register_block_hooks()
# Pre-fetch the first layer
# For subsequent requests, the first layer/block will be pre-fetched
# during the last layer compute of the previous request.
self.prefetch_layer(0, non_blocking=False)
def _to_cpu(
self, params: dict[str, nn.Parameter], bufs: dict[str, torch.Tensor]
) -> tuple[dict[torch.dtype, torch.Tensor], dict[torch.dtype, list[dict[str, Any]]]]:
"""Move block parameters and buffers to CPU, flattening by dtype.
Consolidates parameters and buffers into contiguous CPU tensors grouped by dtype
for GPU transfers. Replaces original tensors with empty placeholders.
Returns:
Tuple of
flattened CPU tensors by dtype,
metadata for reconstruction by dtype
"""
dtype_grouped_weights: dict[torch.dtype, dict[str, torch.Tensor]] = {}
dtype_cpu_flattened_weights: dict[torch.dtype, torch.Tensor] = {}
# order does matter
dtype_metadata: dict[torch.dtype, list[dict[str, Any]]] = {}
for name, param_or_buf in chain(params.items(), bufs.items()):
dtype = param_or_buf.dtype
if dtype not in dtype_grouped_weights:
dtype_grouped_weights[dtype] = {}
dtype_grouped_weights[dtype][name] = param_or_buf
for dtype, name2weights in dtype_grouped_weights.items():
# total # of parameters + buffers
total_numel = sum(t.numel() for _, t in name2weights.items())
cpu_tensor = torch.empty(total_numel, dtype=dtype, device="cpu", pin_memory=self.pin_memory)
current_offset = 0
for name, param_or_buf in name2weights.items():
numel = param_or_buf.numel()
cpu_tensor[current_offset : current_offset + numel].copy_(param_or_buf.flatten())
if dtype not in dtype_metadata:
dtype_metadata[dtype] = []
dtype_metadata[dtype].append(
{
"name": name,
"offset": current_offset,
"numel": numel,
"shape": param_or_buf.shape,
}
)
param_or_buf.data = torch.empty((), device=self.device, dtype=dtype)
current_offset += numel
dtype_cpu_flattened_weights[dtype] = cpu_tensor
return dtype_cpu_flattened_weights, dtype_metadata
def register_block_hooks(self) -> None:
"""Register forward hooks on blocks for prefetching and offloading."""
def _pre_hook(module: nn.Module, args: tuple, *, layer_idx: int) -> None:
# For the last block / layer, prefetch layer 0 (the first layer)
next_id = (layer_idx + 1) % self.num_blocks
self.prefetch_layer(next_id, non_blocking=True)
def _post_hook(module: nn.Module, args: tuple, output: tuple, *, layer_idx: int) -> None:
self.offload_layer(layer_idx)
self._resident[layer_idx] = False
self._prefetch_done[layer_idx] = None
for i, layer in enumerate(self.blocks):
pre_hook_fn = partial(_pre_hook, layer_idx=i)
handle = layer.register_forward_pre_hook(pre_hook_fn)
self._pre_hook_handles.append(handle)
post_hook_fn = partial(_post_hook, layer_idx=i)
handle = layer.register_forward_hook(post_hook_fn)
self._post_hook_handles.append(handle)
@torch.compiler.disable
def prefetch_layer(self, layer_idx: int, non_blocking: bool = True) -> None:
"""Copy layer weights from CPU -> GPU.
Pre-fetch target layer in an asynchronous way with compute - memory copy overlap,
with non_blocking set to True.
"""
if layer_idx >= self.num_blocks or layer_idx < 0:
logger.warning(f"Invalid layer id specified: {layer_idx}")
return
self._copy_stream.wait_stream(torch.cuda.current_stream())
layers_to_fetch = [(layer_idx + i) % self.num_blocks for i in range(self.num_gpu_layers)]
for idx in layers_to_fetch:
if self._resident[idx]:
continue
layer_params = self.block_parameters[idx]
layer_bufs = self.block_buffers[idx]
evt = torch.cuda.Event()
gpu_weights: dict[torch.dtype, torch.Tensor] = {}
with torch.cuda.stream(self._copy_stream):
for dtype, cpu_weight in self.layer_cpu_weights[idx].items():
gpu_weight = torch.empty(cpu_weight.shape, dtype=dtype, device=self.device)
gpu_weight.copy_(cpu_weight, non_blocking=non_blocking)
gpu_weights[dtype] = gpu_weight
evt.record(self._copy_stream)
for dtype in self.layer_metadata[idx]:
ordered_metadata: list[dict[str, Any]] = self.layer_metadata[idx][dtype]
gpu_weight = gpu_weights[dtype]
for metadata in ordered_metadata:
target_name = metadata["name"]
target_param_or_buf = (
layer_params[target_name] if target_name in layer_params else layer_bufs[target_name]
)
target_param_or_buf.data = gpu_weight[
metadata["offset"] : metadata["offset"] + metadata["numel"]
].view(metadata["shape"])
self._prefetch_done[idx] = evt
self._resident[idx] = True
@torch.compiler.disable
def offload_layer(self, layer_idx: int) -> None:
"""Free GPU memory for layer by replacing tensors with empty placeholders."""
if layer_idx >= self.num_blocks or layer_idx < 0:
logger.warning(f"Invalid layer id specified: {layer_idx}")
return
if not self._resident[layer_idx]:
logger.warning(f"{layer_idx} is not residing on GPU")
return
evt = self._prefetch_done[layer_idx]
if evt is not None:
torch.cuda.current_stream().wait_event(evt)
# free GPU residency
for _, param in self.block_parameters[layer_idx].items():
param.data = torch.empty((), device=self.device, dtype=param.dtype)
for _, buf in self.block_buffers[layer_idx].items():
buf.data = torch.empty((), device=self.device, dtype=buf.dtype)
def remove_all_hooks(self) -> None:
"""Remove all hooks."""
for h in self._pre_hook_handles:
h.remove()
for h in self._post_hook_handles:
h.remove()
self._pre_hook_handles.clear()
self._post_hook_handles.clear()
@staticmethod
def get_blocks_attr_name(model: nn.Module) -> str | None:
"""Retrieve blocks attribute name from provided DiT model"""
return getattr(model.__class__, "_layerwise_offload_blocks_attr", None)
@staticmethod
def get_blocks_from_dit(model: nn.Module) -> list[nn.Module]:
"""
Retrieve a list of blocks from provided DiT model. Blocks attribute name
are found by `_layerwise_offload_blocks_attr` set to DiT models. For example,
```
class WanTransformer3DModel(nn.Module):
_layerwise_offload_blocks_attr = "blocks"
```
"""
blocks_attr_name = LayerwiseOffloader.get_blocks_attr_name(model)
if blocks_attr_name is None:
logger.warning(
f"No _layerwise_offload_blocks_attr defined for {model.__class__.__name__}, "
"skipping layerwise offloading"
)
return []
_blocks = getattr(model, blocks_attr_name, None)
if _blocks is None:
logger.warning(
f"Blocks (layers) '{blocks_attr_name}' not found on {model.__class__.__name__}, "
"skipping layerwise offloading"
)
return []
return list(_blocks)
def apply_offload_hooks(
model: nn.Module,
od_config: OmniDiffusionConfig,
*,
device: torch.device | None = None,
) -> None:
"""Apply mutual-exclusion offload hooks based on config.
When enable_cpu_offload is enabled, DiT and encoders swap GPU access:
- Encoders (text_encoder, text_encoder_2, text_encoder_3, image_encoder)
run on GPU while DiT is on CPU
- DiT runs on GPU while encoders are on CPU
Args:
model: Diffusion pipeline model
od_config: OmniDiffusionConfig with offload settings
"""
enable_cpu_offload = getattr(od_config, "enable_cpu_offload", False)
enable_layerwise_offload = getattr(od_config, "enable_layerwise_offload", False)
pin_cpu_memory = getattr(od_config, "pin_cpu_memory", True)
if not enable_cpu_offload and not enable_layerwise_offload:
return
if enable_cpu_offload and enable_layerwise_offload:
# NOTE: Model-wise and layerwise cpu offloading are not supported together at this moment,
# consider layerwise offloading has higher priority than model-wise offloading
enable_cpu_offload = False
logger.info(
"Model-wise and layer-wise CPU offloading are not supported together at this moment. "
"Automatically disabled model-wise offloading."
)
# For now, model-wise and layer-wise (block-wise) offloading
# are functioning as expected when cuda device is available
if not current_omni_platform.is_cuda() or current_omni_platform.get_device_count() < 1:
logger.info("CPU Offloading requires cuda devices available. Skipping for now...")
return
# Find DiT/transformer modules
dit_modules: list[nn.Module] = []
dit_names: list[str] = []
candidate_attrs = ["transformer", "transformer_2", "dit"]
for attr in candidate_attrs:
if not hasattr(model, attr):
continue
module_obj = getattr(model, attr)
if module_obj is None:
continue
assert isinstance(module_obj, nn.Module), f"Expected {attr} to be nn.Module, got {type(module_obj)!r}"
if module_obj in dit_modules:
continue
dit_modules.append(module_obj)
dit_names.append(attr)
if not dit_modules:
logger.warning("enable_cpu_offload enabled but no transformer/dit/unet found")
return
if device is None:
try:
device = next(dit_modules[0].parameters()).device
except StopIteration:
try:
device = current_omni_platform.get_torch_device()
except (NotImplementedError, AttributeError):
logger.error("Fail to get device of pipeline. Skipping applying offloading hooks")
return
# Collect all encoders
encoders: list[nn.Module] = []
encoder_names: list[str] = []
for attr in ["text_encoder", "text_encoder_2", "text_encoder_3", "image_encoder"]:
if hasattr(model, attr) and getattr(model, attr) is not None:
encoders.append(getattr(model, attr))
encoder_names.append(attr)
if not encoders and enable_cpu_offload:
logger.warning("enable_cpu_offload enabled but no encoders found")
return
for enc in encoders:
enc.to(device)
# Collect VAE
for name in ["vae"]:
module = getattr(model, name, None)
if module is None:
continue
try:
module.to(device, non_blocking=True)
except Exception as exc:
logger.debug("Failed to move %s to GPU: %s", name, exc)
if enable_cpu_offload:
# Initial state: keep DiT modules on CPU (encoders typically run first)
for dit_mod in dit_modules:
dit_mod.to("cpu")
torch.cuda.empty_cache()
if pin_cpu_memory:
for dit_mod in dit_modules:
for p in dit_mod.parameters():
if p.data.device.type == "cpu" and not p.data.is_pinned():
p.data = p.data.pin_memory()
# Register sequential offload hooks
SequentialOffloader(dit_modules, encoders, device, pin_cpu_memory).register()
logger.info(
"CPU offload enabled: %s <-> %s (mutual exclusion)",
", ".join(dit_names),
", ".join(encoder_names),
)
elif enable_layerwise_offload:
logger.info(f"Applying offloading hooks on {dit_names}")
for i, dit_module in enumerate(dit_modules):
logger.info(f"Applying hook on {dit_names[i]} ({dit_module.__class__.__name__})")
blocks_attr_name = LayerwiseOffloader.get_blocks_attr_name(dit_module)
blocks = LayerwiseOffloader.get_blocks_from_dit(dit_module)
if not blocks_attr_name or not blocks:
logger.warning(
"Target layers (blocks) are not found. "
f"Skipping offloading on {dit_names[i]} ({dit_module.__class__.__name__})"
)
continue
# move modules other than blocks to gpu and keep them on gpu
for name, m in dit_module.named_children():
# Skip the blocks module (layers to be offloaded)
if name == blocks_attr_name:
logger.debug(f"Skipped module {name}")
continue
m.to(device)
logger.debug(f"Moved {name} to device {device}")
# set to the module (transformer)
offloader = LayerwiseOffloader(blocks, device, pin_cpu_memory, od_config.layerwise_num_gpu_layers)
setattr(dit_module, "_layerwise_offloader", offloader)
logger.info(
f"Layerwise offloading enabled on {len(blocks)} layers (blocks), "
f"with {od_config.layerwise_num_gpu_layers} kept on device"
)
+77
View File
@@ -0,0 +1,77 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import torch
from vllm.logger import init_logger
from vllm_omni.diffusion.data import OmniDiffusionConfig
from vllm_omni.platforms import current_omni_platform
from .base import OffloadBackend, OffloadConfig, OffloadStrategy
from .layerwise_backend import LayerWiseOffloadBackend
from .sequential_backend import (
ModelLevelOffloadBackend,
apply_sequential_offload,
remove_sequential_offload,
)
logger = init_logger(__name__)
__all__ = [
"OffloadBackend",
"OffloadConfig",
"OffloadStrategy",
"LayerWiseOffloadBackend",
"ModelLevelOffloadBackend",
"apply_sequential_offload",
"remove_sequential_offload",
"get_offload_backend",
]
def get_offload_backend(
od_config: OmniDiffusionConfig,
device: torch.device | None = None,
) -> OffloadBackend | None:
"""Create appropriate offload backend based on configuration.
Args:
od_config: OmniDiffusionConfig with offload settings
device: Target device (auto-detected if None)
Returns:
OffloadBackend instance or None if offloading disabled
Example:
>>> backend = get_offload_backend(od_config, device=torch.device("cuda:0"))
>>> if backend:
... backend.enable(pipeline)
"""
# Extract and validate configuration
config = OffloadConfig.from_od_config(od_config)
# Return None if no offloading requested
if config.strategy == OffloadStrategy.NONE:
return None
# Validate platform (CUDA required for now)
if not current_omni_platform.is_cuda() or current_omni_platform.get_device_count() < 1:
logger.warning("CPU offloading requires CUDA devices. Skipping offloading.")
return None
# Detect device if not provided
if device is None:
try:
device = current_omni_platform.get_torch_device()
except (NotImplementedError, AttributeError) as exc:
logger.error("Failed to detect device: %s. Skipping offloading.", exc)
return None
# Create appropriate backend
if config.strategy == OffloadStrategy.MODEL_LEVEL:
return ModelLevelOffloadBackend(config, device)
elif config.strategy == OffloadStrategy.LAYER_WISE:
return LayerWiseOffloadBackend(config, device)
else:
logger.error("Unknown offload strategy: %s", config.strategy)
return None
+94
View File
@@ -0,0 +1,94 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
import torch
from torch import nn
from vllm.logger import init_logger
from vllm_omni.diffusion.data import OmniDiffusionConfig
logger = init_logger(__name__)
class OffloadStrategy(Enum):
NONE = "none"
MODEL_LEVEL = "model_level" # Sequential offloading between DiT and encoders
LAYER_WISE = "layer_wise" # Block-level
@dataclass
class OffloadConfig:
strategy: OffloadStrategy
pin_cpu_memory: bool = True
@classmethod
def from_od_config(cls, od_config: OmniDiffusionConfig) -> "OffloadConfig":
"""Extract and validate offload settings from OmniDiffusionConfig.
For now, enforces mutual exclusion between model-level and layer-wise offloading.
Layer-wise takes priority if both are enabled.
Args:
od_config: OmniDiffusionConfig with offload settings
Returns:
OffloadConfig with validated settings
"""
enable_cpu_offload = getattr(od_config, "enable_cpu_offload", False)
enable_layerwise_offload = getattr(od_config, "enable_layerwise_offload", False)
pin_cpu_memory = getattr(od_config, "pin_cpu_memory", True)
# Determine strategy (mutual exclusion, layer-wise takes priority)
if enable_layerwise_offload:
strategy = OffloadStrategy.LAYER_WISE
if enable_cpu_offload:
logger.info(
"Both model-level and layer-wise offloading enabled. "
"Layer-wise takes priority, disabling model-level offloading."
)
elif enable_cpu_offload:
strategy = OffloadStrategy.MODEL_LEVEL
else:
strategy = OffloadStrategy.NONE
return cls(
strategy=strategy,
pin_cpu_memory=pin_cpu_memory,
)
class OffloadBackend(ABC):
"""Base class for CPU offload backends"""
def __init__(self, config: OffloadConfig, device: torch.device):
self.config = config
self.device = device
self.enabled = False
@abstractmethod
def enable(self, pipeline: nn.Module) -> None:
"""Enable offloading on the pipeline.
Discovers modules, moves them to appropriate devices, and
registers forward hooks for swapping/prefetching.
Args:
pipeline: Diffusion pipeline model (e.g., Wan22Pipeline)
"""
raise NotImplementedError
@abstractmethod
def disable(self) -> None:
"""Disable offloading and cleanup resources.
Removes all registered hooks. Does NOT move modules back to
original devices (caller responsible for that).
"""
raise NotImplementedError
def is_enabled(self) -> bool:
return self.enabled
@@ -0,0 +1,362 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from itertools import chain
from typing import Any
import torch
from torch import nn
from vllm.logger import init_logger
from vllm_omni.diffusion.hooks import HookRegistry, ModelHook
from vllm_omni.platforms import current_omni_platform
from .base import OffloadBackend, OffloadConfig
from .module_collector import ModuleDiscovery
logger = init_logger(__name__)
class LayerwiseOffloadHook(ModelHook):
"""Hook for layerwise (transformer-block-wise) CPU offloading.
The hook instance retains parameters for both the current registered block
module and those for the next block, as well as flattened CPU tensors which
record the parameters of the current block module, so that these parameters
could be re-materialized on device in an overlapping way.
This hook should be registered to each of the transformer blocks in DiT
module(s) of the target pipeline.
Based on implementations from:
https://github.com/sgl-project/sglang/blob/v0.5.8/python/sglang/multimodal_gen/runtime/utils/layerwise_offload.py
"""
_HOOK_NAME = "layerwise_offload"
def __init__(
self,
next_block: nn.Module,
device: torch.device,
stream: torch.cuda.Stream | None = None,
pin_memory: bool = True,
):
assert isinstance(next_block, nn.Module), "transformer block must be type `torch.nn.Module`"
assert current_omni_platform.is_cuda(), "Layerwise offloading is only supported on cuda devices for now"
self.next_block = next_block
self.device = device
self.copy_stream = stream or torch.cuda.current_stream()
self.pin_memory = pin_memory
# Per-block synchronization primitive: set after H2D copy completes.
self._prefetch_done: torch.cuda.Event | None = None
self.next_block_parameters: dict[str, nn.Parameter] = {}
self.next_block_buffers: dict[str, torch.Tensor] = {}
self.dtype_cpu_flattened_weights: dict[torch.dtype, torch.Tensor] = {}
self.dtype_metadata: dict[torch.dtype, list[dict[str, Any]]] = {}
def initialize_hook(self, module: nn.Module) -> nn.Module:
# This all happen during the hook instance being registered to hook registry;
# the input module is kept intact
module = super().initialize_hook(module)
self.block_parameters: dict[str, nn.Parameter] = dict(module.named_parameters())
self.block_buffers: dict[str, torch.Tensor] = dict(module.named_buffers())
self.next_block_parameters: dict[str, nn.Parameter] = dict(self.next_block.named_parameters())
self.next_block_buffers: dict[str, torch.Tensor] = dict(self.next_block.named_buffers())
# Pre-allocate gpu tensors in a flattened way
self.dtype_cpu_flattened_weights, self.dtype_metadata = LayerwiseOffloadHook._to_cpu(
self.next_block_parameters, self.next_block_buffers, self.device, self.pin_memory
)
return module
@staticmethod
def _to_cpu(
params: dict[str, nn.Parameter],
bufs: dict[str, torch.Tensor],
device: torch.device,
pin_memory: bool = True,
) -> tuple[dict[torch.dtype, torch.Tensor], dict[torch.dtype, list[dict[str, Any]]]]:
"""Helper method to move block parameters and buffers to CPU, flattening by dtype.
Consolidates parameters and buffers into contiguous CPU tensors grouped by dtype
for GPU transfers. Replaces original tensors with empty placeholders.
Returns:
Tuple of
flattened CPU tensors by dtype,
metadata for reconstruction by dtype
"""
dtype_grouped_weights: dict[torch.dtype, dict[str, torch.Tensor]] = {}
dtype_cpu_flattened_weights: dict[torch.dtype, torch.Tensor] = {}
# NOTE: order does matter
dtype_metadata: dict[torch.dtype, list[dict[str, Any]]] = {}
for name, param_or_buf in chain(params.items(), bufs.items()):
dtype = param_or_buf.dtype
if dtype not in dtype_grouped_weights:
dtype_grouped_weights[dtype] = {}
dtype_grouped_weights[dtype][name] = param_or_buf
for dtype, name2weights in dtype_grouped_weights.items():
# total # of parameters + buffers
total_numel = sum(t.numel() for _, t in name2weights.items())
cpu_tensor = torch.empty(total_numel, dtype=dtype, device="cpu", pin_memory=pin_memory)
current_offset = 0
for name, param_or_buf in name2weights.items():
numel = param_or_buf.numel()
cpu_tensor[current_offset : current_offset + numel].copy_(param_or_buf.flatten())
if dtype not in dtype_metadata:
dtype_metadata[dtype] = []
dtype_metadata[dtype].append(
{
"name": name,
"offset": current_offset,
"numel": numel,
"shape": param_or_buf.shape,
}
)
param_or_buf.data = torch.empty((), device=device, dtype=dtype)
current_offset += numel
dtype_cpu_flattened_weights[dtype] = cpu_tensor
return dtype_cpu_flattened_weights, dtype_metadata
@torch.compiler.disable
def prefetch_layer(self, non_blocking: bool = True) -> None:
"""Copy layer weights from CPU -> GPU.
Pre-fetch target block in an asynchronous way with compute - memory copy overlap,
with non_blocking set to True.
"""
self.copy_stream.wait_stream(torch.cuda.current_stream())
layer_params = self.next_block_parameters
layer_bufs = self.next_block_buffers
evt = torch.cuda.Event()
gpu_weights: dict[torch.dtype, torch.Tensor] = {}
with torch.cuda.stream(self.copy_stream):
for dtype, cpu_weight in self.dtype_cpu_flattened_weights.items():
gpu_weight = torch.empty(cpu_weight.shape, dtype=dtype, device=self.device)
gpu_weight.copy_(cpu_weight, non_blocking=non_blocking)
gpu_weights[dtype] = gpu_weight
evt.record(self.copy_stream)
for dtype, ordered_metadata in self.dtype_metadata.items():
# ordered_metadata: list[dict[str, Any]]
gpu_weight = gpu_weights[dtype]
for metadata in ordered_metadata:
target_name = metadata["name"]
target_param_or_buf = (
layer_params[target_name] if target_name in layer_params else layer_bufs[target_name]
)
target_param_or_buf.data = gpu_weight[metadata["offset"] : metadata["offset"] + metadata["numel"]].view(
metadata["shape"]
)
self._prefetch_done = evt
@torch.compiler.disable
def offload_layer(self) -> None:
"""Free GPU memory for layer by replacing tensors with empty placeholders.
This function does not actually offload weights from GPU back to CPU.
"""
evt = self._prefetch_done
if evt is not None:
torch.cuda.current_stream().wait_event(evt)
self._prefetch_done = None
# free GPU residency
for _, param in self.block_parameters.items():
param.data = torch.empty((), device=self.device, dtype=param.dtype)
for _, buf in self.block_buffers.items():
buf.data = torch.empty((), device=self.device, dtype=buf.dtype)
def pre_forward(self, module: nn.Module, *args: Any, **kwargs: Any) -> tuple[tuple, dict]:
self.prefetch_layer(non_blocking=True)
return args, kwargs
def post_forward(self, module: nn.Module, output: Any) -> Any:
self.offload_layer()
return output
def apply_block_hook(
module: nn.Module,
next_block: nn.Module,
device: torch.device,
stream: torch.cuda.Stream | None = None,
pin_memory: bool = True,
) -> LayerwiseOffloadHook:
registry = HookRegistry.get_or_create(module)
hook = LayerwiseOffloadHook(next_block, device, stream, pin_memory)
registry.register_hook(LayerwiseOffloadHook._HOOK_NAME, hook)
return hook
def remove_block_hook(module: nn.Module) -> None:
registry: HookRegistry | None = getattr(module, "_hook_registry", None)
if registry is not None:
registry.remove_hook(LayerwiseOffloadHook._HOOK_NAME)
logger.debug("Removed offload hook from %s", module.__class__.__name__)
class LayerWiseOffloadBackend(OffloadBackend):
"""Layer-wise (block-level) offloading backend.
Implements sliding window offloading where only a small number of transformer
blocks reside on GPU at a time. Blocks are prefetched asynchronously while
previous blocks compute, and freed after use.
"""
def __init__(self, config: OffloadConfig, device: torch.device):
super().__init__(config, device)
self.copy_stream = torch.cuda.Stream()
self._blocks: list[list[nn.Module]] = []
def enable(self, pipeline: nn.Module) -> None:
if self.enabled:
logger.warning("LayerWiseOffloadBackend already enabled")
return
modules = ModuleDiscovery.discover(pipeline)
if not modules.dits:
logger.warning("No DiT/transformer modules found, skipping layer-wise offloading")
return
# Move encoders to GPU (they stay resident)
for enc in modules.encoders:
enc.to(self.device)
# Move VAE to GPU if available
if modules.vae is not None:
try:
modules.vae.to(self.device, non_blocking=True)
except Exception as exc:
logger.debug("Failed to move VAE to GPU: %s", exc)
logger.info("Applying layer-wise offloading on %s", modules.dit_names)
# Apply block-wise offloading hook for each of the blocks in DiT model(s)
# Note that there might exist multiple DiT models in specific pipelines
for i, dit_module in enumerate(modules.dits):
dit_name = modules.dit_names[i]
logger.info(f"Applying hooks on {dit_name} ({dit_module.__class__.__name__})")
blocks_attr_name = LayerWiseOffloadBackend.get_blocks_attr_name(dit_module)
blocks = LayerWiseOffloadBackend.get_blocks_from_dit(dit_module)
if not blocks_attr_name or not blocks:
logger.warning(
"Target layers (blocks) not found. Skipping offloading on %s (%s)",
dit_name,
dit_module.__class__.__name__,
)
dit_module.to(self.device)
continue
num_blocks = len(blocks)
if num_blocks <= 1:
logger.warning(
"#Target layers (blocks) <= 1. Skipping offloading on %s (%s)",
dit_name,
dit_module.__class__.__name__,
)
dit_module.to(self.device)
continue
# Move non-block modules to GPU (they stay resident)
for name, m in dit_module.named_children():
if name == blocks_attr_name:
logger.debug(f"Skipped blocks module {name}")
continue
m.to(self.device)
logger.debug(f"Moved {name} to device {self.device}")
# Pre-fetch the first layer by manually calling the hook function on the last layer;
# For subsequent requests, the first layer/block will be pre-fetched
# during the last layer compute of the previous request.
last_block, first_block = blocks[-1], blocks[0]
hook = apply_block_hook(last_block, first_block, self.device, self.copy_stream, self.config.pin_cpu_memory)
hook.prefetch_layer(non_blocking=False)
# Register hook for each of blocks
for i, block in enumerate(blocks[:-1]):
next_block = blocks[(i + 1) % num_blocks]
apply_block_hook(block, next_block, self.device, self.copy_stream, self.config.pin_cpu_memory)
logger.info(f"Layer-wise offloading enabled on {num_blocks} layers (blocks)")
# Track hooked blocks for cleanup
self._blocks.append(blocks)
if len(self._blocks) > 0 and len(self._blocks[0]) > 0:
self.enabled = True
def disable(self) -> None:
if not self.enabled:
return
for blocks in self._blocks:
for block in blocks:
remove_block_hook(block)
self._blocks.clear()
self.enabled = False
logger.info("Layer-wise offloading disabled")
@staticmethod
def get_blocks_attr_name(model: nn.Module) -> str | None:
"""Retrieve blocks attribute name from provided DiT model"""
return getattr(model.__class__, "_layerwise_offload_blocks_attr", None)
@staticmethod
def set_blocks_attr_name(model: nn.Module, name: str) -> None:
if not hasattr(model.__class__, "_layerwise_offload_blocks_attr"):
setattr(model.__class__, "_layerwise_offload_blocks_attr", name)
@staticmethod
def get_blocks_from_dit(model: nn.Module) -> list[nn.Module]:
"""
Retrieve a list of blocks from provided DiT model. Blocks attribute name
are found by `_layerwise_offload_blocks_attr` set to DiT models. For example,
```
class WanTransformer3DModel(nn.Module):
_layerwise_offload_blocks_attr = "blocks"
```
"""
blocks_attr_name = LayerWiseOffloadBackend.get_blocks_attr_name(model)
if blocks_attr_name is None:
logger.warning(
f"No _layerwise_offload_blocks_attr defined for {model.__class__.__name__}, "
"skipping layerwise offloading"
)
return []
_blocks = getattr(model, blocks_attr_name, None)
if _blocks is None:
logger.warning(
f"Blocks (layers) '{blocks_attr_name}' not found on {model.__class__.__name__}, "
"skipping layerwise offloading"
)
return []
return list(_blocks)
@@ -0,0 +1,80 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from dataclasses import dataclass
from torch import nn
from vllm.logger import init_logger
logger = init_logger(__name__)
@dataclass
class PipelineModules:
dits: list[nn.Module]
dit_names: list[str]
encoders: list[nn.Module]
encoder_names: list[str]
vae: nn.Module | None = None
class ModuleDiscovery:
"""Discovers pipeline components for offloading"""
DIT_ATTRS = ["transformer", "transformer_2", "dit", "language_model", "transformer_blocks"]
ENCODER_ATTRS = ["text_encoder", "text_encoder_2", "text_encoder_3", "image_encoder"]
VAE_ATTRS = ["vae"]
@staticmethod
def discover(pipeline: nn.Module) -> PipelineModules:
"""Discover DiT, encoder, and VAE modules from pipeline.
Args:
pipeline: Diffusion pipeline model
Returns:
PipelineModules with lists of discovered modules and names
"""
# Collect DiT/transformer modules
dit_modules: list[nn.Module] = []
dit_names: list[str] = []
for attr in ModuleDiscovery.DIT_ATTRS:
if not hasattr(pipeline, attr):
continue
module_obj = getattr(pipeline, attr)
if module_obj is None:
continue
if not isinstance(module_obj, nn.Module):
logger.warning(f"Expected {attr} to be nn.Module, got {type(module_obj)!r}")
continue
if module_obj in dit_modules:
continue
dit_modules.append(module_obj)
dit_names.append(attr)
# Collect all encoders
encoders: list[nn.Module] = []
encoder_names: list[str] = []
for attr in ModuleDiscovery.ENCODER_ATTRS:
if hasattr(pipeline, attr) and getattr(pipeline, attr) is not None:
encoders.append(getattr(pipeline, attr))
encoder_names.append(attr)
# Collect VAE
vae = None
for attr in ModuleDiscovery.VAE_ATTRS:
module = getattr(pipeline, attr, None)
if module is not None:
vae = module
break
return PipelineModules(
dits=dit_modules,
dit_names=dit_names,
encoders=encoders,
encoder_names=encoder_names,
vae=vae,
)
@@ -0,0 +1,227 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import torch
from torch import nn
from vllm.logger import init_logger
from vllm_omni.diffusion.hooks import HookRegistry, ModelHook
from vllm_omni.platforms import current_omni_platform
from .base import OffloadBackend, OffloadConfig
from .module_collector import ModuleDiscovery
logger = init_logger(__name__)
class SequentialOffloadHook(ModelHook):
"""Hook for sequential offloading with mutual exclusion on encoder and DiT modules.
To be used as a model-level (or "component-level") of CPU offloading method;
When a module's forward is called, this hook offloads target modules to CPU
and loads the current module to GPU.
"""
_HOOK_NAME = "sequential_offload"
def __init__(
self,
offload_targets: list[nn.Module],
device: torch.device,
pin_memory: bool = True,
):
# Modules to offload to CPU before this module runs
self.offload_targets = offload_targets
self.device = device
self.pin_memory = pin_memory
def _to_cpu(self, module: nn.Module) -> None:
"""Move module to CPU."""
try:
param = next(module.parameters())
except StopIteration:
return
previous_device = param.device
# Skip if already on CPU
if previous_device.type == "cpu":
return
module.to("cpu")
torch.cuda.empty_cache()
if self.pin_memory:
for p in module.parameters():
if p.data.device.type == "cpu" and not p.data.is_pinned():
p.data = p.data.pin_memory()
def _to_gpu(self, module: nn.Module) -> None:
"""Move module to GPU."""
try:
# Skip if already on target device
if next(module.parameters()).device == self.device:
return
except StopIteration:
return
module.to(self.device)
def pre_forward(self, module: nn.Module, *args, **kwargs) -> tuple[tuple, dict]:
# Offload target modules to CPU
for target in self.offload_targets:
self._to_cpu(target)
# Load current module to GPU
self._to_gpu(module)
current_omni_platform.synchronize()
logger.debug(
"Swapped: %s -> CPU, %s -> GPU",
[t.__class__.__name__ for t in self.offload_targets],
module.__class__.__name__,
)
return args, kwargs
def apply_sequential_offload(
dit_modules: list[nn.Module],
encoder_modules: list[nn.Module],
device: torch.device,
pin_memory: bool = True,
) -> None:
"""Apply sequential offloading hooks to DiT and encoder modules.
Registers hooks on modules to implement mutual-exclusion GPU allocation.
- Before DiT runs, encoders are offloaded to CPU.
- Before encoders run, DiT is offloaded to CPU.
Args:
dit_modules: DiT/transformer modules to register hooks on
encoder_modules: Encoder modules to register hooks on
device: Target GPU device for loading
pin_memory: Whether to pin CPU memory for faster transfers
Example:
>>> apply_sequential_offload(
... dit_modules=[pipeline.transformer],
... encoder_modules=[pipeline.text_encoder, pipeline.vae],
... device=torch.device("cuda:0"),
... )
>>> # Modules of pipeline now automatically swap between CPU and GPU
"""
# Register hooks on DiT modules (offload encoders when DiT runs)
for dit_mod in dit_modules:
registry = HookRegistry.get_or_create(dit_mod)
hook = SequentialOffloadHook(
offload_targets=encoder_modules,
device=device,
pin_memory=pin_memory,
)
registry.register_hook(SequentialOffloadHook._HOOK_NAME, hook)
logger.debug("Registered offload hook for %s", dit_mod.__class__.__name__)
# Register hooks on encoders (offload DiTs when encoder runs)
for enc in encoder_modules:
registry = HookRegistry.get_or_create(enc)
hook = SequentialOffloadHook(
offload_targets=dit_modules,
device=device,
pin_memory=pin_memory,
)
registry.register_hook(SequentialOffloadHook._HOOK_NAME, hook)
logger.debug("Registered offload hook for %s", enc.__class__.__name__)
def remove_sequential_offload(modules: list[nn.Module]) -> None:
"""Remove sequential offloading hooks from modules.
Args:
modules: Modules to remove hooks from
Example:
>>> all_modules = [*dit_modules, *encoder_modules]
>>> remove_sequential_offload(all_modules)
"""
for module in modules:
registry: HookRegistry | None = getattr(module, "_hook_registry", None)
if registry is not None:
registry.remove_hook(SequentialOffloadHook._HOOK_NAME)
logger.debug("Removed offload hook from %s", module.__class__.__name__)
class ModelLevelOffloadBackend(OffloadBackend):
"""Model-level (sequential) offloading backend.
Uses SequentialOffloadHook registered via HookRegistry for automatic module swapping.
"""
def __init__(self, config: OffloadConfig, device: torch.device):
super().__init__(config, device)
self._offload_modules: list[nn.Module] = [] # Track modules with hooks
def enable(self, pipeline: nn.Module) -> None:
if self.enabled:
logger.warning("ModelLevelOffloadBackend already enabled")
return
modules = ModuleDiscovery.discover(pipeline)
if not modules.dits:
logger.warning("No DiT/transformer modules found, skipping model-level offloading")
return
if not modules.encoders:
logger.warning("No encoder modules found, skipping model-level offloading")
return
# Move encoders to GPU
for enc in modules.encoders:
enc.to(self.device)
# Move VAE to GPU if available
if modules.vae is not None:
try:
modules.vae.to(self.device, non_blocking=True)
except Exception as exc:
logger.debug("Failed to move VAE to GPU: %s", exc)
# Initial state: keep DiT modules on CPU (encoders typically run first)
# TODO: This part seems to be unnecessary, remove it after testing
# for dit_mod in modules.dits:
# dit_mod.to("cpu")
# torch.cuda.empty_cache()
# if self.config.pin_cpu_memory:
# for dit_mod in modules.dits:
# for p in dit_mod.parameters():
# if p.data.device.type == "cpu" and not p.data.is_pinned():
# p.data = p.data.pin_memory()
# Apply sequential offloading hooks
apply_sequential_offload(
dit_modules=modules.dits,
encoder_modules=modules.encoders,
device=self.device,
pin_memory=self.config.pin_cpu_memory,
)
# Track modules for cleanup
self._offload_modules = [*modules.dits, *modules.encoders]
self.enabled = True
logger.info(
"Model-level offloading enabled: %s <-> %s (mutual exclusion)",
", ".join(modules.dit_names),
", ".join(modules.encoder_names),
)
def disable(self) -> None:
if not self.enabled:
return
remove_sequential_offload(self._offload_modules)
self._offload_modules.clear()
self.enabled = False
logger.info("Model-level offloading disabled")
@@ -26,7 +26,7 @@ from vllm_omni.diffusion.compile import regionally_compile
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.forward_context import set_forward_context
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
from vllm_omni.diffusion.offload import apply_offload_hooks
from vllm_omni.diffusion.offloader import get_offload_backend
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.distributed.omni_connectors.kv_transfer_manager import OmniKVTransferManager
from vllm_omni.platforms import current_omni_platform
@@ -62,6 +62,7 @@ class DiffusionModelRunner:
self.device = device
self.pipeline = None
self.cache_backend = None
self.offload_backend = None
# Initialize KV cache manager for connector management
self.kv_transfer_manager = OmniKVTransferManager.from_od_config(od_config)
@@ -69,6 +70,8 @@ class DiffusionModelRunner:
def load_model(
self,
memory_pool_context_fn: callable | None = None,
load_format: str | None = None,
custom_pipeline_name: str | None = None,
) -> None:
"""
Load the diffusion model, apply compilation and offloading.
@@ -76,7 +79,17 @@ class DiffusionModelRunner:
Args:
memory_pool_context_fn: Optional function that returns a context manager
for memory pool allocation (used for sleep mode).
load_format: Format for loading model weights. Supported formats:
- "default" (default): Automatically detect and use the default format based on configuration
- "custom_pipeline": Init model from a custom pipeline class specified by `custom_pipeline_name`
- "dummy": Skip actual weight loading, useful for testing and custom pipelines that
don't require default weights.
custom_pipeline_name: Optional custom pipeline class name to use.
"""
if load_format == "dummy":
return
load_device = (
"cpu" if self.od_config.enable_cpu_offload or self.od_config.enable_layerwise_offload else str(self.device)
)
@@ -96,6 +109,8 @@ class DiffusionModelRunner:
self.pipeline = model_loader.load_model(
od_config=self.od_config,
load_device=load_device,
load_format=load_format,
custom_pipeline_name=custom_pipeline_name,
)
time_after_load = time.perf_counter()
@@ -107,8 +122,10 @@ class DiffusionModelRunner:
logger.info("Model runner: Model loaded successfully.")
# Apply CPU offloading
if self.od_config.enable_cpu_offload or self.od_config.enable_layerwise_offload:
apply_offload_hooks(self.pipeline, self.od_config, device=self.device)
self.offload_backend = get_offload_backend(self.od_config, device=self.device)
if self.offload_backend is not None:
logger.info(f" Enabling offloader backend: {self.offload_backend.__class__.__name__}")
self.offload_backend.enable(self.pipeline)
# Apply torch.compile if not in eager mode
if not self.od_config.enforce_eager:
+302 -25
View File
@@ -1,5 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Diffusion Worker for vLLM-Omni.
@@ -7,15 +8,19 @@ Handles GPU infrastructure initialization and delegates model operations
to DiffusionModelRunner.
"""
import gc
import multiprocessing as mp
import os
from collections.abc import Iterable
from contextlib import AbstractContextManager, nullcontext
from typing import Any
import torch
import zmq
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.import_utils import resolve_obj_by_qualname
from vllm.utils.mem_utils import GiB_bytes
from vllm.v1.worker.workspace import init_workspace_manager
@@ -35,6 +40,7 @@ from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner
from vllm_omni.lora.request import LoRARequest
from vllm_omni.platforms import current_omni_platform
from vllm_omni.worker.gpu_memory_utils import get_process_gpu_memory
logger = init_logger(__name__)
@@ -67,6 +73,15 @@ class DiffusionWorker:
self._sleep_saved_buffers: dict[str, torch.Tensor] = {}
self.lora_manager: DiffusionLoRAManager | None = None
self.init_device()
# Create model runner
self.model_runner = DiffusionModelRunner(
vllm_config=self.vllm_config,
od_config=self.od_config,
device=self.device,
)
self.load_model(load_format=self.od_config.diffusion_load_format)
self.init_lora_manager()
logger.info(f"Worker {self.rank}: Initialization complete.")
def init_device(self) -> None:
"""Initialize the device and distributed environment."""
@@ -110,16 +125,30 @@ class DiffusionWorker:
)
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,
)
def load_model(self, load_format: str = "default", custom_pipeline_name: str | None = None) -> None:
"""Load the diffusion model using DiffusionModelRunner."""
with (
set_forward_context(vllm_config=self.vllm_config, omni_diffusion_config=self.od_config),
set_current_vllm_config(self.vllm_config),
):
self.model_runner.load_model(
memory_pool_context_fn=self._maybe_get_memory_pool_context,
load_format=load_format,
custom_pipeline_name=custom_pipeline_name,
)
process_memory = get_process_gpu_memory(self.local_rank)
if process_memory is not None:
logger.info(
"Worker %d: Process-scoped GPU memory after model loading: %.2f GiB.",
self.rank,
process_memory / GiB_bytes,
)
assert self.model_runner.pipeline is not None
def init_lora_manager(self) -> None:
"""Initialize the LoRA manager for this worker."""
if self.model_runner.pipeline is None:
return
self.lora_manager = DiffusionLoRAManager(
pipeline=self.model_runner.pipeline,
device=self.device,
@@ -128,7 +157,6 @@ class DiffusionWorker:
lora_path=self.od_config.lora_path,
lora_scale=self.od_config.lora_scale,
)
logger.info(f"Worker {self.rank}: Initialization complete.")
def generate(self, request: OmniDiffusionRequest) -> DiffusionOutput:
"""Generate output for the given requests."""
@@ -182,7 +210,10 @@ class DiffusionWorker:
"""
from vllm.device_allocator.cumem import CuMemAllocator
free_bytes_before_sleep = current_omni_platform.get_free_memory()
process_memory_before_sleep = get_process_gpu_memory(self.local_rank)
free_bytes_before_sleep = None
if process_memory_before_sleep is None:
free_bytes_before_sleep = current_omni_platform.get_free_memory()
# Save the buffers before level 2 sleep
if level == 2 and self.model_runner is not None:
@@ -191,14 +222,23 @@ class DiffusionWorker:
allocator = CuMemAllocator.get_instance()
allocator.sleep(offload_tags=("weights",) if level == 1 else tuple())
free_bytes_after_sleep = current_omni_platform.get_free_memory()
device_id = self.device.index if self.device.index is not None else 0
total = current_omni_platform.get_device_total_memory(device_id)
freed_bytes = free_bytes_after_sleep - free_bytes_before_sleep
used_bytes = total - free_bytes_after_sleep
process_memory_after_sleep = get_process_gpu_memory(self.local_rank)
if process_memory_before_sleep is not None and process_memory_after_sleep is not None:
freed_bytes = process_memory_before_sleep - process_memory_after_sleep
used_bytes = process_memory_after_sleep
accounting_scope = "process-scoped"
else:
free_bytes_after_sleep = current_omni_platform.get_free_memory()
assert free_bytes_before_sleep is not None
device_id = self.device.index if self.device.index is not None else 0
total = current_omni_platform.get_device_total_memory(device_id)
freed_bytes = free_bytes_after_sleep - free_bytes_before_sleep
used_bytes = total - free_bytes_after_sleep
accounting_scope = "device-scoped fallback"
assert freed_bytes >= 0, "Memory usage increased after sleeping."
logger.info(
"Sleep mode freed %.2f GiB memory, %.2f GiB memory is still in use.",
"Sleep mode (%s) freed %.2f GiB memory, %.2f GiB memory is still in use.",
accounting_scope,
freed_bytes / GiB_bytes,
used_bytes / GiB_bytes,
)
@@ -247,6 +287,32 @@ class DiffusionWorker:
destroy_distributed_env()
class CustomPipelineWorkerExtension:
def re_init_pipeline(self, custom_pipeline_args: dict[str, Any]) -> None:
"""
Re-initialize the pipeline with custom arguments.
Args:
custom_pipeline_args: Dictionary of arguments for custom pipeline initialization
"""
# Clean up old pipeline
if self.model_runner.pipeline is not None:
del self.model_runner.pipeline
gc.collect()
torch.cuda.empty_cache()
# Get custom pipeline class name
custom_pipeline_name = custom_pipeline_args["pipeline_class"]
# Use the DiffusionWorker's load_model method which handles the forward context
self.load_model(
load_format="custom_pipeline",
custom_pipeline_name=custom_pipeline_name,
)
self.init_lora_manager()
class WorkerProc:
"""Wrapper that runs one Worker in a separate process."""
@@ -255,8 +321,11 @@ class WorkerProc:
od_config: OmniDiffusionConfig,
gpu_id: int,
broadcast_handle,
worker_extension_cls: str | None = None,
custom_pipeline_args: dict[str, Any] | None = None,
):
self.od_config = od_config
self.gpu_id = gpu_id
# Inter-process Communication
self.context = zmq.Context(io_threads=2)
@@ -274,17 +343,26 @@ class WorkerProc:
logger.info(f"Worker {gpu_id} created result MessageQueue")
assert od_config.master_port is not None
self.worker = self._create_worker(gpu_id, od_config)
self.gpu_id = gpu_id
# Create worker using WorkerWrapperBase for extension support
self.worker = self._create_worker(gpu_id, od_config, worker_extension_cls, custom_pipeline_args)
self._running = True
def _create_worker(self, gpu_id: int, od_config: OmniDiffusionConfig) -> DiffusionWorker:
def _create_worker(
self,
gpu_id: int,
od_config: OmniDiffusionConfig,
worker_extension_cls: str | None,
custom_pipeline_args: dict[str, Any] | None = None,
) -> DiffusionWorker:
"""Create a worker instance. Override in subclasses for different worker types."""
return DiffusionWorker(
local_rank=gpu_id,
rank=gpu_id,
wrapper = WorkerWrapperBase(
gpu_id=gpu_id,
od_config=od_config,
worker_extension_cls=worker_extension_cls,
custom_pipeline_args=custom_pipeline_args,
)
return wrapper
def return_result(self, output: DiffusionOutput):
"""Reply to client, only on rank 0."""
@@ -310,11 +388,8 @@ class WorkerProc:
return None, False
try:
if isinstance(method, str):
func = getattr(self.worker, method)
result = func(*args, **kwargs)
else:
result = method(self.worker, *args, **kwargs)
# Use execute_method from WorkerWrapperBase for consistent method resolution
result = self.worker.execute_method(method, *args, **kwargs)
return result, should_reply
except Exception as e:
logger.error(f"Error executing RPC: {e}", exc_info=True)
@@ -385,6 +460,8 @@ class WorkerProc:
od_config: OmniDiffusionConfig,
pipe_writer: mp.connection.Connection,
broadcast_handle,
worker_extension_cls: str | None = None,
custom_pipeline_args: dict[str, Any] | None = None,
) -> None:
"""Worker initialization and execution loops."""
from vllm_omni.plugins import load_omni_general_plugins
@@ -394,6 +471,8 @@ class WorkerProc:
od_config,
gpu_id=rank,
broadcast_handle=broadcast_handle,
worker_extension_cls=worker_extension_cls,
custom_pipeline_args=custom_pipeline_args,
)
logger.info(f"Worker {rank}: Scheduler loop started.")
pipe_writer.send(
@@ -404,3 +483,201 @@ class WorkerProc:
)
worker_proc.worker_busy_loop()
logger.info(f"Worker {rank}: Shutdown complete.")
class WorkerWrapperBase:
"""
Wrapper base class that creates DiffusionWorker with optional worker_extension_cls support.
This enables dynamic inheritance for DiffusionWorker to extend with custom functionality.
"""
def __init__(
self,
gpu_id: int,
od_config: OmniDiffusionConfig,
base_worker_class: type = DiffusionWorker,
worker_extension_cls: str | None = None,
custom_pipeline_args: dict[str, Any] | None = None,
):
"""
Initialize WorkerWrapperBase with support for worker extensions.
Args:
gpu_id: GPU device ID
od_config: OmniDiffusionConfig configuration
worker_extension_cls: Optional qualified name of worker extension class
custom_pipeline_args: Optional arguments for custom pipeline initialization
"""
self.gpu_id = gpu_id
self.od_config = od_config
self.base_worker_class = base_worker_class
self.worker_extension_cls = worker_extension_cls
self.custom_pipeline_args = custom_pipeline_args
# Prepare worker class with extension support
worker_class = self._prepare_worker_class()
# Create the actual worker instance
self.worker = worker_class(
local_rank=gpu_id,
rank=gpu_id,
od_config=od_config,
)
# Re-initialize pipeline with custom pipeline if provided
if self.custom_pipeline_args is not None:
self.worker.re_init_pipeline(self.custom_pipeline_args)
def _prepare_worker_class(self) -> type:
"""
Prepare the worker class with optional extension.
Dynamically extends GPUWorker with worker_extension_cls if provided.
Returns:
The worker class (potentially extended)
"""
worker_class = self.base_worker_class
# If custom_pipeline_args is provided, use CustomPipelineWorkerExtension
if self.custom_pipeline_args is not None:
# Set worker_extension_cls to CustomPipelineWorkerExtension if not already set
if self.worker_extension_cls is None:
self.worker_extension_cls = CustomPipelineWorkerExtension
if self.worker_extension_cls:
if isinstance(self.worker_extension_cls, str):
worker_extension_cls = resolve_obj_by_qualname(self.worker_extension_cls)
else:
worker_extension_cls = self.worker_extension_cls
extended_calls = []
if worker_extension_cls not in worker_class.__bases__:
# Check for conflicts between worker and extension
for attr in dir(worker_extension_cls):
if attr.startswith("__"):
continue
if hasattr(worker_class, attr):
logger.warning(
f"Worker class {worker_class} already has attribute "
f"{attr}, which may conflict with worker extension "
f"class {worker_extension_cls}."
)
if callable(getattr(worker_extension_cls, attr)):
extended_calls.append(attr)
# Dynamically inherit the worker extension class
class_name = f"{worker_class.__name__}With{worker_extension_cls.__name__}"
worker_class = type(class_name, (worker_extension_cls, worker_class), {})
logger.info(
"Created extended worker class %s from %s for extended calls %s",
class_name,
worker_extension_cls,
extended_calls,
)
return worker_class
def generate(self, requests: list[OmniDiffusionRequest]) -> DiffusionOutput:
"""
Generate output for the given requests.
Args:
requests: List of diffusion requests
Returns:
DiffusionOutput with generated results
"""
return self.worker.generate(requests)
def execute_model(self, reqs: list[OmniDiffusionRequest], od_config: OmniDiffusionConfig) -> DiffusionOutput:
"""
Execute a forward pass.
Args:
reqs: List of diffusion requests
od_config: OmniDiffusionConfig configuration
Returns:
DiffusionOutput with generated results
"""
return self.worker.execute_model(reqs, od_config)
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
"""
Load model weights.
Args:
weights: Iterable of (name, tensor) tuples
Returns:
Set of loaded weight names
"""
return self.worker.load_weights(weights)
def sleep(self, level: int = 1) -> bool:
"""
Put the worker to sleep. The worker should not process any requests.
The caller should guarantee that no requests are being processed
during the sleep period, before `wake_up` is called.
Args:
level: The sleep level. Level 1 sleep will offload the model
weights and discard the kv cache.
Currently only support level 1.
Returns:
True on success
"""
return self.worker.sleep(level)
def wake_up(self, tags: list[str] | None = None) -> bool:
"""
Wake up the worker from sleep mode. See the sleep function
method for more details.
Args:
tags: An optional list of tags to reallocate the worker memory
for specific memory allocations. Values must be in
`("weights")`. If None, all memory is reallocated.
wake_up should be called with all tags (or None) before the
worker is used again.
Returns:
True on success
"""
return self.worker.wake_up(tags)
def shutdown(self) -> None:
"""Shutdown the worker and cleanup resources."""
return self.worker.shutdown()
def execute_method(self, method: str | bytes, *args, **kwargs) -> Any:
"""
Execute a method on the worker.
Args:
method: Method name (str) or serialized callable (bytes)
Returns:
Result of the method execution (type depends on the method)
Raises:
Exception: If method execution fails
"""
try:
# Method resolution order:
# 1. If method is defined in this class, it will be called directly
# 2. Otherwise, since we define `__getattr__` and redirect attribute
# query to `self.worker`, the method will be called on the worker
assert isinstance(method, str), "Method must be str"
func = getattr(self.worker, method)
return func(*args, **kwargs)
except Exception as e:
msg = f"Error executing method {method!r}. This might cause issues in distributed execution."
logger.exception(msg)
raise e
def __getattr__(self, attr: str):
"""Delegate attribute access to the wrapped worker."""
return getattr(self.worker, attr)
@@ -5,14 +5,7 @@
import time
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
import torch
from vllm.v1.core.sched.output import SchedulerOutput
from vllm.v1.request import Request, RequestStatus
if TYPE_CHECKING:
from .connectors.base import OmniConnectorBase
from typing import Any
from vllm_omni.entrypoints.stage_utils import OmniStageTaskType
from vllm_omni.metrics import OrchestratorAggregator
@@ -180,178 +173,6 @@ def try_recv_via_connector(
return None, None
def update_request_payload(connector: "OmniConnectorBase", req_id: str, payload_data: dict[str, Any]) -> dict[str, Any]:
"""Update the payload data for a request in the connector.
Args:
connector: OmniConnectorBase instance
req_id: Request ID to update
payload_data: New payload data to store
"""
origin_payload = connector.request_payload[req_id]
for key, value in payload_data.items():
if key == "finished":
continue
elif isinstance(value, torch.Tensor) and key in origin_payload:
payload_data[key] = torch.cat([origin_payload[key], value], dim=0)
elif isinstance(value, list) and key in origin_payload:
payload_data[key] = origin_payload[key] + value
connector.request_payload[req_id] = payload_data
return payload_data
def get_chunk(
connector: "OmniConnectorBase",
scheduler_output: SchedulerOutput,
) -> None:
"""Retrieve a chunk of pooling output.
Args:
connector: OmniConnectorBase instance
scheduler_output: Partial scheduler output dictionary
Returns:
None: This function modifies scheduler_output in place
"""
stage_id = connector.stage_id
if stage_id == 0:
return
target_stage_id = stage_id - 1
# Handle new requests
for new_req_data in scheduler_output.scheduled_new_reqs:
connector.request_ids_mapping[new_req_data.req_id] = new_req_data.external_req_id
req_id = new_req_data.external_req_id
chunk_id = connector.get_requests[req_id]
connector_get_key = f"{req_id}_{target_stage_id}_{chunk_id}"
payload_data = get_through_connector(connector, target_stage_id, stage_id, req_id, connector_get_key)
if payload_data:
new_req_data.additional_information = payload_data
connector.request_payload[req_id] = payload_data
if payload_data.get("finished"):
connector.finished_requests.add(req_id)
# Handle cached/running requests
cached_reqs = scheduler_output.scheduled_cached_reqs
if not hasattr(cached_reqs, "additional_information"):
cached_reqs.additional_information = {}
for i, cached_req_id in enumerate(cached_reqs.req_ids):
req_id = connector.request_ids_mapping.get(cached_req_id, cached_req_id)
if req_id in connector.finished_requests:
continue
chunk_id = connector.get_requests[req_id]
connector_get_key = f"{req_id}_{target_stage_id}_{chunk_id}"
payload_data = get_through_connector(connector, target_stage_id, stage_id, req_id, connector_get_key)
if payload_data:
payload_data = update_request_payload(connector, req_id, payload_data)
cached_reqs.additional_information[cached_req_id] = payload_data
if payload_data.get("finished"):
connector.finished_requests.add(req_id)
def get_through_connector(connector, target_stage_id, stage_id, req_id, connector_get_key):
# TODO: add correct check mechanism for the payload_data
max_wait = 300
for _ in range(max_wait):
result = connector.get(
from_stage=str(target_stage_id),
to_stage=str(stage_id),
get_key=connector_get_key,
)
payload_data = None
if result:
payload_data, size = result
if payload_data:
connector.request_prompt_token_ids[req_id] = payload_data.get("thinker_input_ids", [])
connector.get_requests[req_id] += 1
logger.debug("[Stage-%d] Received one chunk for request %s", stage_id, connector_get_key)
break
time.sleep(0.01)
return payload_data
def get_chunk_for_generation(
connector: "OmniConnectorBase",
request: Request,
) -> None:
"""Retrieve a chunk of pooling output.
Args:
connector: OmniConnectorBase instance
request: Request object
Returns:
None: This function modifies request in place
"""
stage_id = connector.stage_id
target_stage_id = stage_id - 1
request_id = request.external_req_id
if request_id in connector.finished_requests:
return
chunk_id = connector.get_requests[request_id]
connector_get_key = f"{request_id}_{target_stage_id}_{chunk_id}"
payload_data = get_through_connector(connector, target_stage_id, stage_id, request_id, connector_get_key)
if not payload_data:
return
if payload_data.get("finished"):
connector.finished_requests.add(request_id)
request.status = RequestStatus.FINISHED_STOPPED
request.prompt_token_ids = payload_data.get("code_predictor_codes", [])
request.num_computed_tokens = 0
def put_chunk(
connector: "OmniConnectorBase",
pooling_output: dict[str, Any],
request: Request,
custom_process_input_func: Callable[[dict[str, Any], Request], dict[str, Any] | None] | None = None,
) -> None:
"""Store a chunk of pooling output.
Args:
connector: OmniConnectorBase instance
pooling_output: Partial pooling output dictionary
request: Request object
custom_process_input_func: Optional custom function to process input
Returns:
None: This function sends data via connector
"""
stage_id = connector.stage_id
next_stage_id = stage_id + 1
request_id = request.external_req_id
chunk_id = connector.put_requests[request_id]
connector_put_key = f"{request_id}_{stage_id}_{chunk_id}"
payload_data = None
# TODO: add default process_input_func to handle the payload_data ?
if custom_process_input_func:
try:
payload_data = custom_process_input_func(
connector=connector,
pooling_output=pooling_output,
request=request,
)
except Exception as e:
logger.error(f"Failed to use custom_process_input_func for payload extraction: {e}")
if not payload_data:
return
success, size, metadata = connector.put(
from_stage=str(stage_id), to_stage=str(next_stage_id), put_key=connector_put_key, data=payload_data
)
if success:
connector.put_requests[request_id] += 1
logger.debug("[Stage-%d] Sent %s", stage_id, connector_put_key)
def compute_talker_prompt_ids_length(prompt_ids: list[int]) -> int:
"""Compute the length of the talker prompt ids.
@@ -4,7 +4,6 @@
import fcntl
import os
import time
from collections import defaultdict
from typing import Any
from vllm_omni.entrypoints.stage_utils import shm_read_bytes, shm_write_bytes
@@ -25,14 +24,6 @@ class SharedMemoryConnector(OmniConnectorBase):
self.config = config
self.stage_id = config.get("stage_id", -1)
self.device = config.get("device", "cuda:0")
self.put_requests: dict[str, int] = defaultdict(int)
self.get_requests: dict[str, int] = defaultdict(int)
self.finished_requests: set[str] = set()
self.request_payload = {}
self.request_prompt_token_ids: dict[str, list[int]] = defaultdict(list)
self.code_prompt_token_ids: dict[str, list[list[int]]] = defaultdict(list)
self.request_ids_mapping: dict[str, str] = {}
# Default threshold matches legacy behavior (64KB)
self.threshold = int(config.get("shm_threshold_bytes", 65536))
self._metrics = {
"puts": 0,
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
@@ -0,0 +1,115 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import threading
import time
from typing import Any
from ..utils.logging import get_connector_logger
logger = get_connector_logger(__name__)
class OmniTransferAdapterBase:
"""Base class for managing data transfer via OmniConnector.
This class handles the core loop logic and connector interactions, but
leaves the specific data processing (chunks, KV cache, etc.) to subclasses.
"""
def __init__(self, config: Any):
self.config = config
if not hasattr(self, "connector"):
self.connector = None
# Requests that are waiting to be polled
self._pending_load_reqs = {}
# Requests that have successfully retrieved data
self._finished_load_reqs = set()
# Requests that are waiting to be saved
self._pending_save_reqs = {}
# Requests that have successfully saved data
self._finished_save_reqs = set()
self.stop_event = threading.Event()
self.lock = threading.Lock()
self.recv_thread = threading.Thread(target=self.recv_loop, daemon=True)
self.recv_thread.start()
self.save_thread = threading.Thread(target=self.save_loop, daemon=True)
self.save_thread.start()
@classmethod
def create_connector(cls, model_config: Any):
raise NotImplementedError
def recv_loop(self):
"""Loop to poll for incoming data."""
while not self.stop_event.is_set():
# Iterate over a snapshot of pending requests
with self.lock:
pending_reqs_ids = list(self._pending_load_reqs.keys())
for req_id in pending_reqs_ids:
try:
self._poll_single_request(req_id)
except Exception as e:
logger.warning(f"Error receiving data for {req_id}: {e}")
time.sleep(0.001)
def save_loop(self):
"""Loop to send outgoing data."""
while not self.stop_event.is_set():
task = None
with self.lock:
pending_save_reqs_ids = list(self._pending_save_reqs.keys())
for req_id in pending_save_reqs_ids:
if self._pending_save_reqs[req_id]:
task = self._pending_save_reqs[req_id].popleft()
if not self._pending_save_reqs[req_id]:
del self._pending_save_reqs[req_id]
break
if task:
try:
self._send_single_request(task)
except Exception as e:
logger.error(f"Error saving data for {task.get('request_id')}: {e}")
else:
time.sleep(0.001)
def _poll_single_request(self, *args, **kwargs):
"""Poll connector for a single request task.
Subclasses should implement request-specific receive behavior."""
raise NotImplementedError
def _send_single_request(self, *args, **kwargs):
"""Send one pending save request task to the connector.
Subclasses should implement task-specific handling logic."""
raise NotImplementedError
def load_async(self, *args, **kwargs):
"""Register a request to load data. To be implemented by subclasses."""
raise NotImplementedError
def save_async(self, *args, **kwargs):
"""Submit data to be saved. To be implemented by subclasses."""
raise NotImplementedError
def load(self, *args, **kwargs):
"""Load request data from connector synchronously. To be implemented by subclasses."""
raise NotImplementedError
def save(self, *args, **kwargs):
"""Save data to connector synchronously. To be implemented by subclasses."""
raise NotImplementedError
def get_finished_requests(self):
"""Get finished loaded or saved requests"""
raise NotImplementedError
def shutdown(self):
"""Stop background loops and close the connector."""
raise NotImplementedError
@@ -0,0 +1,339 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import importlib
from collections import defaultdict, deque
from typing import Any
import torch
from vllm.v1.request import Request, RequestStatus
from ..factory import OmniConnectorFactory
from ..utils.config import ConnectorSpec
from ..utils.logging import get_connector_logger
from .base import OmniTransferAdapterBase
logger = get_connector_logger(__name__)
class OmniChunkTransferAdapter(OmniTransferAdapterBase):
"""Chunk-level transfer adapter for Omni connector pipelines.
This class coordinates per-request chunk exchange between adjacent stages,
and implements asynchronous get/put of chunks via background threads.
It tracks per-request chunk indices for put/get, and accumulates
payloads across chunks (concatenating tensors/lists in AR mode). It also
caches prompt token ids and additional information for scheduler use.
Scheduler integration is handled via WAITING_FOR_CHUNK transitions:
requests are moved to waiting for chunk deque while polling, then restored
to waiting/running queues once a chunk arrives. The requests will finish
loading chunk util detecting the payload "finished" flag.
The base class owns background recv/save loops; load/save only enqueue
work and return immediately.
"""
def __init__(self, vllm_config: Any):
model_config = vllm_config.model_config
self.scheduler_max_num_seqs = vllm_config.scheduler_config.max_num_seqs
self.connector = self.create_connector(model_config)
super().__init__(model_config)
self.model_mode = getattr(model_config, "worker_type", "ar")
# State specific to Chunk management
self.custom_process_next_stage_input_func = None
custom_process_next_stage_input_func = getattr(model_config, "custom_process_next_stage_input_func", None)
if custom_process_next_stage_input_func:
module_path, func_name = custom_process_next_stage_input_func.rsplit(".", 1)
module = importlib.import_module(module_path)
self.custom_process_next_stage_input_func = getattr(module, func_name)
# mapping for request id and chunk id
self.put_req_chunk: dict[str, int] = defaultdict(int)
self.get_req_chunk: dict[str, int] = defaultdict(int)
self.finished_requests: set[str] = set()
self.request_payload = {}
self.code_prompt_token_ids: dict[str, list[list[int]]] = defaultdict(list)
self.request_ids_mapping: dict[str, str] = {}
self.waiting_for_chunk_waiting_requests: deque[Any] = deque()
self.waiting_for_chunk_running_requests: deque[Any] = deque()
self.requests_with_ready_chunks = set()
@classmethod
def create_connector(cls, model_config: Any):
connector_config = getattr(model_config, "stage_connector_config", None)
if connector_config is None:
connector_config = {}
elif not isinstance(connector_config, dict):
connector_config = {
"name": getattr(connector_config, "name", None),
"extra": getattr(connector_config, "extra", {}),
}
connector_specs = ConnectorSpec(
name=connector_config.get("name", "SharedMemoryConnector"),
extra=connector_config.get("extra", {}),
)
return OmniConnectorFactory.create_connector(connector_specs)
def load_async(self, request: Request):
"""Register a request for asynchronous chunk retrieval.
This method does not read from the connector directly. It records
request metadata and enqueues the request id for the background
receive loop to poll.
Stage-0 has no upstream producer, so this call is a no-op there.
Args:
request: The request object needing data.
"""
stage_id = self.connector.stage_id
request_id = request.request_id
self.request_ids_mapping[request_id] = request.external_req_id
if stage_id == 0:
return
if not hasattr(request, "additional_information"):
request.additional_information = None
with self.lock:
self._pending_load_reqs[request_id] = request
def save_async(self, pooling_output: torch.Tensor | None = None, request: Request | None = None):
"""Build and enqueue one chunk for asynchronous sending.
Payload extraction is executed in the caller thread via
``custom_process_next_stage_input_func``
Args:
pooling_output: Partial pooling output dictionary
request: Request object
"""
stage_id = self.connector.stage_id
next_stage_id = stage_id + 1
request_id = request.external_req_id
chunk_id = self.put_req_chunk[request_id]
# Process payload in main thread to avoid race conditions on request state
payload_data = None
if self.custom_process_next_stage_input_func:
try:
payload_data = self.custom_process_next_stage_input_func(
transfer_manager=self,
pooling_output=pooling_output,
request=request,
)
except Exception as e:
logger.error(f"Failed to use custom_process_input_func for payload extraction: {e}")
if not payload_data:
return
# Increment chunk_id
self.put_req_chunk[request_id] += 1
connector_put_key = f"{request_id}_{stage_id}_{chunk_id}"
task = {
"stage_id": stage_id,
"next_stage_id": next_stage_id,
"put_key": connector_put_key,
"data": payload_data,
"request_id": request_id,
}
with self.lock:
if request_id not in self._pending_save_reqs:
self._pending_save_reqs[request_id] = deque()
self._pending_save_reqs[request_id].append(task)
def _poll_single_request(self, req_id: str):
stage_id = self.connector.stage_id
target_stage_id = stage_id - 1
chunk_id = self.get_req_chunk[req_id]
external_req_id = self.request_ids_mapping.get(req_id, req_id)
connector_get_key = f"{external_req_id}_{target_stage_id}_{chunk_id}"
# Use timeout=0 for non-blocking poll
result = self.connector.get(
str(target_stage_id),
str(stage_id),
connector_get_key,
)
if result is None:
return
payload_data, size = result
if payload_data:
# Update connector state
self.get_req_chunk[req_id] += 1
req = self._pending_load_reqs[req_id]
if self.model_mode == "ar":
self._update_request_payload(external_req_id, payload_data)
req.additional_information = payload_data
if payload_data.get("finished"):
self.finished_requests.add(req_id)
else:
if payload_data.get("finished"):
self.finished_requests.add(req_id)
req.status = RequestStatus.FINISHED_STOPPED
req.prompt_token_ids = payload_data.get("code_predictor_codes", [])
req.num_computed_tokens = 0
# Mark as finished for consumption
with self.lock:
self._finished_load_reqs.add(req_id)
if req_id in self._pending_load_reqs:
del self._pending_load_reqs[req_id]
logger.debug(f"[Stage-{stage_id}] Received one chunk for key {connector_get_key}")
def _update_request_payload(self, req_id: str, payload_data: dict[str, Any]) -> dict[str, Any]:
"""Update the payload data for a request in the connector.
Args:
connector: OmniConnectorBase instance
req_id: Request ID to update
payload_data: New payload data to store
"""
if req_id not in self.request_payload:
self.request_payload[req_id] = payload_data
return payload_data
origin_payload = self.request_payload[req_id]
for key, value in payload_data.items():
if key == "finished":
continue
elif isinstance(value, torch.Tensor) and key in origin_payload:
payload_data[key] = torch.cat([origin_payload[key], value], dim=0)
elif isinstance(value, list) and key in origin_payload:
payload_data[key] = origin_payload[key] + value
self.request_payload[req_id] = payload_data
return payload_data
def _send_single_request(self, task: dict):
connector_put_key = task["put_key"]
stage_id = task["stage_id"]
next_stage_id = task["next_stage_id"]
payload_data = task["data"]
success, size, metadata = self.connector.put(
from_stage=str(stage_id),
to_stage=str(next_stage_id),
put_key=connector_put_key,
data=payload_data,
)
if success:
logger.debug(f"[Stage-{stage_id}] Sent {connector_put_key}")
########################################################################
# Schedule Helper
########################################################################
def process_pending_chunks(
self,
waiting_queue: Any,
running_queue: list[Request],
) -> None:
"""
Process pending chunks for waiting and running queues.
"""
if self.connector.stage_id == 0:
return
finished_load_reqs = self.get_finished_requests()
self._process_chunk_queue(
waiting_queue, self.waiting_for_chunk_waiting_requests, RequestStatus.WAITING, finished_load_reqs
)
self._process_chunk_queue(
running_queue, self.waiting_for_chunk_running_requests, RequestStatus.RUNNING, finished_load_reqs
)
while len(running_queue) > self.scheduler_max_num_seqs:
request = running_queue.pop()
waiting_queue.prepend_requests([request])
def restore_queues(self, waiting_queue: Any, running_queue: list[Request]) -> None:
"""
Restore requests waiting for chunk to the waiting and running queues.
"""
# Add request waiting for chunk to the waiting and running queue
for request in self.waiting_for_chunk_waiting_requests:
waiting_queue.add_request(request)
self.waiting_for_chunk_waiting_requests = deque()
if self.waiting_for_chunk_running_requests:
running_queue.extend(self.waiting_for_chunk_running_requests)
self.waiting_for_chunk_running_requests = deque()
def postprocess_scheduler_output(
self,
scheduler_output: Any,
requests: dict[str, Request] | None = None,
) -> None:
"""
Add additional info for cached requests and
clean up ready chunks from scheduler output.
"""
if requests is not None:
self.attach_cached_additional_information(scheduler_output, requests)
self._clear_chunk_ready(scheduler_output)
@staticmethod
def attach_cached_additional_information(scheduler_output: Any, requests: dict[str, Request]) -> None:
cached_reqs = getattr(scheduler_output, "scheduled_cached_reqs", None)
if not cached_reqs:
return
if not hasattr(cached_reqs, "additional_information"):
cached_reqs.additional_information = {}
for req_id in cached_reqs.req_ids:
request = requests.get(req_id) if req_id else None
additional_info = getattr(request, "additional_information", None) if request else None
cached_reqs.additional_information[req_id] = additional_info
def _process_chunk_queue(
self,
queue: Any,
waiting_for_chunk_list: deque[Any],
target_status: RequestStatus,
finished_load_reqs: set[str],
) -> None:
queue_snapshot = list(queue)
for request in queue_snapshot:
if request.status != RequestStatus.WAITING_FOR_CHUNK:
if request.request_id in self.requests_with_ready_chunks:
# Requests that have loaded chunk from last round
# of schedule, but have not scheduled
continue
if request.request_id in self.finished_requests:
request.additional_information = {}
continue
# Requests that waiting for chunk
self.load_async(request)
request.status = RequestStatus.WAITING_FOR_CHUNK
else:
if request.request_id in finished_load_reqs:
request.status = target_status
self.requests_with_ready_chunks.add(request.request_id)
continue
queue.remove(request)
waiting_for_chunk_list.append(request)
def _clear_chunk_ready(self, scheduler_output: Any) -> None:
if scheduler_output.scheduled_new_reqs:
for req_data in scheduler_output.scheduled_new_reqs:
if req_data.req_id in self.requests_with_ready_chunks:
self.requests_with_ready_chunks.remove(req_data.req_id)
if scheduler_output.scheduled_cached_reqs:
for req_id in scheduler_output.scheduled_cached_reqs.req_ids:
if req_id in self.requests_with_ready_chunks:
self.requests_with_ready_chunks.remove(req_id)
def get_finished_requests(self):
with self.lock:
finished_load = set(self._finished_load_reqs)
self._finished_load_reqs = set()
return finished_load
+4
View File
@@ -62,6 +62,7 @@ class OmniEngineArgs(EngineArgs):
If None, default processing is used.
stage_connector_spec: Extra configuration for stage connector
async_chunk: If set to True, perform async chunk
worker_type: Model Type, e.g., "ar" or "generation"
"""
stage_id: int = 0
@@ -73,6 +74,7 @@ class OmniEngineArgs(EngineArgs):
stage_connector_spec: dict[str, Any] = field(default_factory=dict)
async_chunk: bool = False
omni_kv_config: dict | None = None
worker_type: str | None = None
def __post_init__(self) -> None:
load_omni_general_plugins()
@@ -217,6 +219,7 @@ class AsyncOmniEngineArgs(AsyncEngineArgs):
Used to route outputs to appropriate processors (e.g., "image",
"audio", "latents"). If None, output type is inferred.
stage_connector_spec: Extra configuration for stage connector
worker_type: Model Type, e.g., "ar" or "generation"
"""
stage_id: int = 0
@@ -228,6 +231,7 @@ class AsyncOmniEngineArgs(AsyncEngineArgs):
stage_connector_spec: dict[str, Any] = field(default_factory=dict)
async_chunk: bool = False
omni_kv_config: dict | None = None
worker_type: str | None = None
def __post_init__(self) -> None:
load_omni_general_plugins()
+2 -1
View File
@@ -162,8 +162,9 @@ class AsyncOmni(OmniBase):
"enable_cache_dit_summary": kwargs.get("enable_cache_dit_summary", False),
"enable_cpu_offload": kwargs.get("enable_cpu_offload", False),
"enable_layerwise_offload": kwargs.get("enable_layerwise_offload", False),
"layerwise_num_gpu_layers": kwargs.get("layerwise_num_gpu_layers", False),
"enforce_eager": kwargs.get("enforce_eager", False),
"diffusion_load_format": kwargs.get("diffusion_load_format", "default"),
"custom_pipeline_args": kwargs.get("custom_pipeline_args", None),
},
"final_output": True,
"final_output_type": "image",
-6
View File
@@ -204,12 +204,6 @@ class OmniServeCommand(CLISubcommand):
action="store_true",
help="Enable layerwise (blockwise) offloading on DiT modules.",
)
omni_config_group.add_argument(
"--layerwise-num-gpu-layers",
type=int,
default=1,
help="Number of layers (blocks) to keep on GPU during generation.",
)
# Video model parameters (e.g., Wan2.2) - engine-level
omni_config_group.add_argument(
+10 -6
View File
@@ -67,16 +67,20 @@ def _sequential_init_lock(engine_args: dict[str, Any], stage_init_timeout: int =
"""
from vllm_omni.worker.gpu_memory_utils import is_process_scoped_memory_available
if is_process_scoped_memory_available() and detect_pid_host():
logger.debug(
nvml_available = is_process_scoped_memory_available()
pid_host = detect_pid_host()
if nvml_available and pid_host:
logger.info(
"NVML process-scoped memory available and PID host is available — concurrent init is safe, skipping locks"
)
yield
return
else:
logger.debug(
"NVML unavailable or PID host is not available (usually inside a container, "
"--pid=host is not set in docker run command) — using sequential init locks"
logger.info(
"Using sequential init locks (nvml_available=%s, pid_host=%s)",
nvml_available,
pid_host,
)
from vllm_omni.platforms import current_omni_platform
@@ -201,7 +205,7 @@ def _sequential_init_lock(engine_args: dict[str, Any], stage_init_timeout: int =
def _resolve_worker_cls(engine_args: dict[str, Any]) -> None:
worker_type = engine_args.pop("worker_type", None)
worker_type = engine_args.get("worker_type", None)
if not worker_type:
return
if engine_args.get("worker_cls"):
+31 -26
View File
@@ -306,37 +306,42 @@ def in_container() -> bool:
return any(m in cg for m in markers)
def has_pid_host() -> bool | None:
"""
def _nvml_pid_matches() -> bool | None:
"""Check if os.getpid() matches what NVML would report via NSpid.
If /proc/self/status has a single NSpid entry, the container PID equals
the host PID, so NVML process-scoped memory tracking works correctly.
Returns:
True -> very likely running with --pid=host (host PID namespace)
False -> very likely isolated PID namespace (default)
True -> PID namespaces match (single NSpid entry)
False -> PID namespaces differ (multiple NSpid entries)
None -> cannot determine
"""
# Strong signal: in host pid namespace, PID 2 is usually kthreadd
comm2 = _read_text("/proc/2/comm")
if comm2 is not None:
comm2 = comm2.strip()
if comm2 == "kthreadd":
return True
# If PID 2 exists and is NOT kthreadd, we're almost certainly not in host pid ns
return False
# Fallback: check for other low-numbered kernel threads (best-effort)
for pid, name in [(3, "rcu_gp"), (4, "rcu_par_gp"), (10, "ksoftirqd/0")]:
comm = _read_text(f"/proc/{pid}/comm")
if comm is not None:
if comm.strip() == name:
return True
else:
return False
return False
status = _read_text("/proc/self/status")
if status is None:
return None
for line in status.splitlines():
if line.startswith("NSpid:"):
pids = line.split()[1:]
# Single PID means container PID == host PID
return len(pids) == 1
return None
def detect_pid_host() -> bool:
ic = in_container()
if not ic:
"""Check if os.getpid() matches the PID that NVML will report.
NVML always reports host-namespace PIDs. If this process lives in a
different PID namespace the match will fail and process-scoped memory
tracking won't work. We detect this via the NSpid field in
/proc/self/status: a single entry means host PID == container PID.
"""
if not in_container():
return True
return has_pid_host()
result = _nvml_pid_matches()
if result is not None:
return result
# Cannot determine — assume mismatch to be safe.
return False
@@ -17,7 +17,7 @@ stage_args:
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
gpu_memory_utilization: 0.9
enforce_eager: true
enforce_eager: false
trust_remote_code: true
engine_output_type: latent # Output hidden states for talker
distributed_executor_backend: "mp"
@@ -48,7 +48,7 @@ stage_args:
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
gpu_memory_utilization: 0.6
enforce_eager: true
enforce_eager: false
trust_remote_code: true
engine_output_type: latent # Output codec codes for code2wav
enable_prefix_caching: false
@@ -86,7 +86,7 @@ stage_args:
engine_output_type: audio # Final output: audio waveform
gpu_memory_utilization: 0.1
distributed_executor_backend: "mp"
max_num_batched_tokens: 10000
max_num_batched_tokens: 51200 # [TODO] if max_num_batch_tokens < max_batch_size * 800, there will be precision problem.
hf_config_name: thinker_config
engine_input_source: [1]
final_output: true
@@ -84,7 +84,7 @@ def _validate_stage_inputs(stage_list, engine_input_source):
def thinker2talker_async_chunk(
connector: Any,
transfer_manager: Any,
pooling_output: dict[str, Any],
request: OmniEngineCoreRequest,
) -> list[dict[str, Any]]:
@@ -96,7 +96,7 @@ def thinker2talker_async_chunk(
"""
request_id = request.external_req_id
chunk_id = connector.put_requests[request_id]
chunk_id = transfer_manager.put_req_chunk[request_id]
if chunk_id == 0:
all_token_ids = request.all_token_ids # prefill + decode
prompt_token_ids = request.prompt_token_ids
@@ -114,12 +114,12 @@ def thinker2talker_async_chunk(
"tts_pad_embed": pooling_output.get("tts_pad_embed").detach().cpu(),
"finished": torch.tensor(request.is_finished(), dtype=torch.bool),
}
if connector.request_payload.get(request_id) is None:
if transfer_manager.request_payload.get(request_id) is None:
if not request.is_finished():
connector.request_payload[request_id] = talker_additional_info
transfer_manager.request_payload[request_id] = talker_additional_info
return None
else:
save_payload = connector.request_payload.pop(request_id)
save_payload = transfer_manager.request_payload.pop(request_id)
talker_additional_info["thinker_embeddings"] = torch.cat(
(save_payload.get("thinker_embeddings"), talker_additional_info.get("thinker_embeddings")), dim=0
)
@@ -206,7 +206,7 @@ def thinker2talker(
def talker2code2wav_async_chunk(
connector: Any,
transfer_manager: Any,
pooling_output: dict[str, Any],
request: OmniEngineCoreRequest,
):
@@ -241,8 +241,8 @@ def talker2code2wav_async_chunk(
request_id = request.external_req_id
chunk_size = left_context_size = 25
connector.code_prompt_token_ids[request_id].append(codec_codes)
length = len(connector.code_prompt_token_ids[request_id])
transfer_manager.code_prompt_token_ids[request_id].append(codec_codes)
length = len(transfer_manager.code_prompt_token_ids[request_id])
chunk_length = length % chunk_size
if chunk_length != 0 and not request.is_finished():
return None
@@ -252,7 +252,10 @@ def talker2code2wav_async_chunk(
info = {
"code_predictor_codes": (
torch.tensor(connector.code_prompt_token_ids[request_id][-end_index:]).transpose(0, 1).reshape(-1).tolist()
torch.tensor(transfer_manager.code_prompt_token_ids[request_id][-end_index:])
.transpose(0, 1)
.reshape(-1)
.tolist()
),
"finished": torch.tensor(request.is_finished(), dtype=torch.bool),
}
+8
View File
@@ -1,5 +1,6 @@
import sys
from aenum import extend_enum
from vllm.inputs.data import TokensPrompt as _OriginalTokensPrompt
from vllm.model_executor.layers.rotary_embedding import (
MRotaryEmbedding as _OriginalMRotaryEmbedding,
@@ -8,6 +9,7 @@ from vllm.v1.engine import EngineCoreOutput as _OriginalEngineCoreOutput
from vllm.v1.engine import EngineCoreOutputs as _OriginalEngineCoreOutputs
from vllm.v1.engine import EngineCoreRequest as _OriginalEngineCoreRequest
from vllm.v1.request import Request as _OriginalRequest
from vllm.v1.request import RequestStatus
import vllm_omni.logger # noqa: F401
from vllm_omni.engine import OmniEngineCoreOutput, OmniEngineCoreOutputs, OmniEngineCoreRequest
@@ -15,6 +17,12 @@ from vllm_omni.inputs.data import OmniTokensPrompt
from vllm_omni.model_executor.layers.rotary_embedding import OmniMRotaryEmbedding
from vllm_omni.request import OmniRequest
# Extend RequestStatus enum with omni-specific statuses
if not hasattr(RequestStatus, "WAITING_FOR_CHUNK"):
# The value - 1 is intentionally chosen to ensure it is treated
# as a non-finished state and remains compatible with existing comparisons.
extend_enum(RequestStatus, "WAITING_FOR_CHUNK", -1)
for module_name, module in sys.modules.items():
# only do patch on module of vllm, pass others
if "vllm" not in module_name: