Merge remote-tracking branch 'origin/main' into dev/vllm-align

Signed-off-by: tzhouam <tzhouam@connect.ust.hk>
This commit is contained in:
tzhouam committed 2026-03-19 02:06:26 +00:00
commit 342a209924
148 files changed
+7862 -9492

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# vllm-omni Intel bootstrap
# Uses static pipeline-intel.yaml for Intel XPU tests
# Based on: bootstrap-amd-omni.sh
set -euo pipefail
if [[ -z "${DOCS_ONLY_DISABLE:-}" ]]; then
DOCS_ONLY_DISABLE=0
fi
upload_pipeline() {
echo "--- 🛠 Preparing Intel pipeline"
buildkite-agent pipeline upload .buildkite/pipeline-intel.yaml
}
get_diff() {
$(git add .)
echo $(git diff --name-only --diff-filter=ACMDR $(git merge-base origin/main HEAD))
}
get_diff_main() {
$(git add .)
echo $(git diff --name-only --diff-filter=ACMDR HEAD~1)
}
file_diff=$(get_diff)
if [[ $BUILDKITE_BRANCH == "main" ]]; then
file_diff=$(get_diff_main)
fi
# ----------------------------------------------------------------------
# Early exit: skip pipeline if all changed files are under docs/
# ----------------------------------------------------------------------
if [[ "${DOCS_ONLY_DISABLE}" != "1" ]] && [[ -n "${file_diff:-}" ]]; then
docs_only=1
while IFS= read -r f; do
[[ -z "$f" ]] && continue
if [[ "$f" != docs/* ]]; then
docs_only=0
break
fi
done < <(printf '%s\n' "$file_diff" | tr ' ' '\n' | tr -d '\r')
if [[ "$docs_only" -eq 1 ]]; then
buildkite-agent annotate ":memo: CI skipped — docs only" --style "info"
exit 0
fi
fi
upload_pipeline
+16
View File
@@ -0,0 +1,16 @@
# .buildkite/pipeline-intel.yaml
steps:
- group: "INTEL Tests"
steps:
- label: "Intel: XPU Test"
command: "bash .buildkite/scripts/hardware_ci/run-xpu-test.sh"
agents:
queue: "intel-gpu-omni"
env:
DOCKER_BUILDKIT: "1"
# Buildkite will automatically replace this with the actual commit hash
VLLM_IMAGE_TAG: "${BUILDKITE_COMMIT}"
VLLM_VERSION: "v0.17.0"
priority: 100
timeout_in_minutes: 60
soft_fail: true
+1
View File
@@ -101,6 +101,7 @@ steps:
- export VLLM_LOGGING_LEVEL=DEBUG
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- export VLLM_TEST_CLEAN_GPU_MEMORY="1"
- export VLLM_ROCM_USE_AITER=0
- pytest -s -v tests/e2e/offline_inference/test_qwen3_omni.py
- pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py
+12 -19
View File
@@ -74,28 +74,21 @@ body:
If relevant, add a minimal example so that we can reproduce the error by running the code. It is very important for the snippet to be as succinct (minimal) as possible, so please take time to trim down any irrelevant code to help us debug efficiently. We are going to copy-paste your code and we expect to get the same result as you did: avoid any external data, and include the relevant imports, etc. For example:
```python
from vllm_omni import OmniLLM, create_ar_stage_config, create_dit_stage_config
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm import SamplingParams
# Create stage configurations
ar_config = create_ar_stage_config(
stage_id=0,
model_path="Qwen/Qwen3-0.6B",
input_modalities=["text"],
output_modalities=["text"]
omni = Omni(
model="Qwen/Qwen-Image",
stage_configs_path="/path/to/stage_configs.yaml",
)
dit_config = create_dit_stage_config(
stage_id=1,
model_path="stabilityai/stable-diffusion-2-1",
input_modalities=["text"],
output_modalities=["image"]
)
# Initialize OmniLLM
omni = OmniLLM([ar_config, dit_config])
# Generate
outputs = omni.generate(prompt="A scenic watercolor painting of a lighthouse at sunset")
prompts = [{"prompt": "A scenic watercolor painting of a lighthouse at sunset"}]
sampling_params_list = [
SamplingParams(max_tokens=1),
OmniDiffusionSamplingParams(num_outputs_per_prompt=1),
]
outputs = omni.generate(prompts=prompts, sampling_params_list=sampling_params_list)
```
If the code is too long (hopefully, it isn't), feel free to put it in a public gist and link it in the issue: https://gist.github.com.
+3 -3
View File
@@ -56,9 +56,9 @@ What it does:
- Runs `examples/offline_inference/qwen3_omni/end2end.py` with `--log-stats`.
- Uses `benchmarks/build_dataset/top100.txt` and writes to:
- Logs: `benchmarks/qwen3-omni/vllm_omni/logs/`
- `omni_llm_pipeline_text.orchestrator.stats.jsonl` — per-stage latency stats.
- `omni_llm_pipeline_text.overall.stats.jsonl` — end-to-end latency/TPS.
- `omni_llm_pipeline_text.stage{0,1,2}.log` — per-stage detailed logs/errors.
- `omni_pipeline_text.orchestrator.stats.jsonl` — per-stage latency stats.
- `omni_pipeline_text.overall.stats.jsonl` — end-to-end latency/TPS.
- `omni_pipeline_text.stage{0,1,2}.log` — per-stage detailed logs/errors.
- Outputs: `benchmarks/qwen3-omni/vllm_omni/outputs/` — ~100 text and `.wav` files.
Key checks:
@@ -26,12 +26,12 @@ else
--log-stats \
--log-dir $log_dir
echo "Logs and outputs are saved in ${log_dir} and ${outputs_dir} respectively:"
echo " - omni_llm_pipeline_text run dir/base name"
echo " - omni_llm_pipeline_text.orchestrator.stats.jsonl orchestrator-stage latency stats"
echo " - omni_llm_pipeline_text.overall.stats.jsonl overall latency/TPS stats"
echo " - omni_llm_pipeline_text.stage0.log per-stage detailed logs"
echo " - omni_llm_pipeline_text.stage1.log"
echo " - omni_llm_pipeline_text.stage2.log"
echo " - omni_pipeline_text run dir/base name"
echo " - omni_pipeline_text.orchestrator.stats.jsonl orchestrator-stage latency stats"
echo " - omni_pipeline_text.overall.stats.jsonl overall latency/TPS stats"
echo " - omni_pipeline_text.stage0.log per-stage detailed logs"
echo " - omni_pipeline_text.stage1.log"
echo " - omni_pipeline_text.stage2.log"
echo "Key checks: overall.stats.jsonl for end-to-end latency/TPS; orchestrator.stats.jsonl for stable per-stage latency; stage*.log for errors or long tails."
echo " - outputs/ Generated txt and wav files, there should be 100 text and wav files generated respectively"
fi
@@ -10,8 +10,6 @@ stage_args:
engine_args:
model_stage: qwen3_tts
model_arch: Qwen3TTSTalkerForConditionalGeneration
hf_overrides:
architectures: [Qwen3TTSTalkerForConditionalGeneration]
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
enforce_eager: false
@@ -43,8 +41,6 @@ stage_args:
engine_args:
model_stage: code2wav
model_arch: Qwen3TTSCode2Wav
hf_overrides:
architectures: [Qwen3TTSCode2Wav]
worker_type: generation
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
enforce_eager: true
@@ -11,8 +11,6 @@ stage_args:
engine_args:
model_stage: qwen3_tts
model_arch: Qwen3TTSTalkerForConditionalGeneration
hf_overrides:
architectures: [Qwen3TTSTalkerForConditionalGeneration]
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
enforce_eager: false
@@ -44,8 +42,6 @@ stage_args:
engine_args:
model_stage: code2wav
model_arch: Qwen3TTSCode2Wav
hf_overrides:
architectures: [Qwen3TTSCode2Wav]
worker_type: generation
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
enforce_eager: true
+1 -9
View File
@@ -6,19 +6,13 @@ Main entry points for vLLM-Omni inference and serving.
- [vllm_omni.entrypoints.async_omni.AsyncOmni][]
- [vllm_omni.entrypoints.async_omni_diffusion.AsyncOmniDiffusion][]
- [vllm_omni.entrypoints.async_omni_llm.AsyncOmniLLM][]
- [vllm_omni.entrypoints.cli.benchmark.base.OmniBenchmarkSubcommandBase][]
- [vllm_omni.entrypoints.cli.benchmark.main.OmniBenchmarkSubcommand][]
- [vllm_omni.entrypoints.cli.benchmark.serve.OmniBenchmarkServingSubcommand][]
- [vllm_omni.entrypoints.cli.serve.OmniServeCommand][]
- [vllm_omni.entrypoints.client_request_state.ClientRequestState][]
- [vllm_omni.entrypoints.omni.Omni][]
- [vllm_omni.entrypoints.omni.OmniBase][]
- [vllm_omni.entrypoints.omni_diffusion.OmniDiffusion][]
- [vllm_omni.entrypoints.omni_llm.OmniLLM][]
- [vllm_omni.entrypoints.omni_stage.OmniStage][]
- [vllm_omni.entrypoints.stage_utils.OmniStageTaskType][]
- [vllm_omni.entrypoints.zmq_utils.ZmqQueue][]
- [vllm_omni.entrypoints.omni_base.OmniBase][]
## Inputs
@@ -48,9 +42,7 @@ Engine classes for offline and online inference.
- [vllm_omni.engine.OmniEngineCoreOutputs][]
- [vllm_omni.engine.OmniEngineCoreRequest][]
- [vllm_omni.engine.PromptEmbedsPayload][]
- [vllm_omni.engine.arg_utils.AsyncOmniEngineArgs][]
- [vllm_omni.engine.arg_utils.OmniEngineArgs][]
- [vllm_omni.engine.input_processor.OmniInputProcessor][]
- [vllm_omni.engine.output_processor.MultimodalOutputProcessor][]
- [vllm_omni.engine.output_processor.OmniRequestState][]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 97 KiB

+2 -2
View File
@@ -18,7 +18,7 @@ If users want to modify some part of it. The custom stage_configs file can be in
For offline (Assume necessary dependencies have ben imported):
```python
model_name = "Qwen/Qwen2.5-Omni-7B"
omni_llm = Omni(model=model_name, stage_configs_path="/path/to/custom_stage_configs.yaml")
omni = Omni(model=model_name, stage_configs_path="/path/to/custom_stage_configs.yaml")
```
For online serving:
@@ -30,7 +30,7 @@ vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 --stage-configs-path /path/to
Below is a specific example of stage_configs.yaml in Qwen2.5-omni.
```python
# stage config for running qwen2.5-omni with architecture of OmniLLM.
# stage config for running qwen2.5-omni with AsyncOmniEngine + Orchestrator runtime.
stage_args:
- stage_id: 0 # mark the unique id for each stage
runtime: # The disaggregated configuration
@@ -1,4 +1,4 @@
# stage config for running qwen2.5-omni with architecture of OmniLLM.
# stage config for running qwen2.5-omni with AsyncOmniEngine + Orchestrator runtime.
stage_args:
- stage_id: 0
runtime:
+1 -1
View File
@@ -107,7 +107,7 @@ Only specific types of PRs will be reviewed. The PR title is prefixed appropriat
- `[CI/Build]` for build or continuous integration improvements.
- `[Doc]` for documentation fixes and improvements.
- `[Model]` for adding a new model or improving an existing model. Model name should appear in the title.
- `[Frontend]` For changes on the vLLM-Omni frontend (e.g., OpenAI API server, `OmniLLM` class, etc.)
- `[Frontend]` For changes on the vLLM-Omni frontend (e.g., OpenAI API server, `Omni`/`AsyncOmni`, etc.)
- `[Kernel]` for changes affecting CUDA kernels or other compute kernels.
- `[Core]` for changes in the core vLLM-Omni logic (e.g., `OmniProcessor`, `OmniARScheduler`, etc.)
- `[Hardware][Vendor]` for hardware-specific changes. Vendor name should appear in the prefix, such as [Ascend] for Ascend NPUs.
-5
View File
@@ -168,11 +168,6 @@ vllm_omni/ tests/
│ └── 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)
+1 -8
View File
@@ -24,8 +24,6 @@ End-to-end tests verify the complete functionality of a system or component. For
- **`tests/e2e/online_serving/`**: Tests for online serving scenarios (e.g., API server tests)
**Example:** The test file for `vllm_omni/entrypoints/omni_llm.py` should be located at `tests/entrypoints/test_omni_llm.py`.
## Test Directory Structure
The ideal directory structure mirrors the source code organization. Legend: `✅` = test exists, `⬜` = suggested to add.
@@ -75,11 +73,6 @@ vllm_omni/ tests/
│ └── 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)
@@ -170,7 +163,7 @@ vllm_omni/ tests/
### Naming Conventions
- **Unit Tests**: Use `test_<module_name>.py` format. Example: `omni_llm.py` → `test_omni_llm.py`
- **Unit Tests**: Use `test_<module_name>.py` format. Example: `stage_utils.py` → `test_stage_utils.py`
- **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`
+24 -32
View File
@@ -330,54 +330,46 @@ Stage transitions are the mechanism by which outputs from one stage are converte
### Where Stage Transitions Are Called
Stage transitions happen automatically in the orchestrator (`OmniLLM` class) during the generation loop. Here's the detailed flow:
Stage transitions happen automatically in the runtime orchestrator. Here's the detailed flow:
1. **Location**: `vllm_omni/entrypoints/omni_llm.py` in the `_run_generation()` method
1. **Location**: `vllm_omni/engine/orchestrator.py` in `_forward_to_next_stage()`
2. **Trigger**: When a stage completes processing and produces outputs
3. **Execution Flow**:
```python
# In omni_llm.py, _run_generation() method (around line 345-460)
# In orchestrator.py
next_stage_id = stage_id + 1
next_client = self.stage_clients[next_stage_id]
params = req_state.sampling_params_list[next_stage_id]
# Main orchestrator loop polls each stage for completed requests
for stage_id, stage in enumerate(self.stage_list):
result = stage.try_collect() # Get completed request
if result is None:
continue
# Save current stage outputs so stage_input_processors can consume them.
self.stage_clients[stage_id].set_engine_outputs([output])
# Store outputs from this stage
engine_outputs = _load(result, obj_key="engine_outputs", shm_key="engine_outputs_shm")
stage.set_engine_outputs(engine_outputs)
# THIS IS WHERE STAGE TRANSITION HAPPENS
next_inputs = next_client.process_engine_inputs(
stage_list=self.stage_clients,
prompt=req_state.prompt,
)
# Check if there's a next stage to forward to
next_stage_id = stage_id + 1
if next_stage_id < len(self.stage_list):
next_stage: OmniStage = self.stage_list[next_stage_id]
# THIS IS WHERE STAGE TRANSITION HAPPENS
next_inputs = next_stage.process_engine_inputs(
self.stage_list,
[request_id_to_prompt[req_id]]
)
# Submit to next stage
task = {
"type": OmniStageTaskType.GENERATE,
"request_id": req_id,
"engine_inputs": next_inputs[0],
"sampling_params": sampling_params_list[next_stage_id],
}
next_stage.submit(task)
# Build and submit request(s) to the next stage.
for next_input in next_inputs:
request = build_engine_core_request_from_tokens(
request_id=req_id,
prompt=next_input,
params=params,
model_config=self.stage_vllm_configs[next_stage_id].model_config,
)
await next_client.add_request_async(request)
```
### How Stage Transitions Work
The stage transition process follows these steps:
1. **Stage Completion**: When a stage finishes processing a request, it stores outputs via `stage.set_engine_outputs(engine_outputs)`
1. **Stage Completion**: When a stage finishes processing a request, the orchestrator stores outputs via `stage_client.set_engine_outputs(...)`
2. **Transition Detection**: The orchestrator checks if there's a next stage and calls `process_engine_inputs()` on it
3. **Input Processing**: The `process_engine_inputs()` method in `OmniStage` (`omni_stage.py`) handles the transition:
3. **Input Processing**: The stage input processor configured in stage YAML (under `vllm_omni/model_executor/stage_input_processors/`) handles the transition:
```python
def process_engine_inputs(
self, stage_list: list[Any], prompt: OmniTokensPrompt | TextPrompt = None
+11 -9
View File
@@ -23,30 +23,32 @@ export VLLM_PROFILER_MAX_ITERS=1
The profiler is default to function across all stages. But It is highly recommended to profile specific stages by passing the stages list, preventing from producing too large trace files:
```python
# Profile all stages
omni_llm.start_profile()
omni.start_profile()
# Only profile Stage 1
omni_llm.start_profile(stages=[1])
omni.start_profile(stages=[1])
```
```python
# Stage 0 (Thinker) and Stage 2 (Audio Decoder) for qwen omni
omni_llm.start_profile(stages=[0, 2])
omni.start_profile(stages=[0, 2])
```
**Python Usage**: Wrap your generation logic with `start_profile()` and `stop_profile()`.
```python
from vllm_omni import omni_llm
from vllm_omni.entrypoints.omni import Omni
omni = Omni(model="Qwen/Qwen3-Omni-30B-A3B-Instruct")
profiler_enabled = bool(os.getenv("VLLM_TORCH_PROFILER_DIR"))
# 1. Start profiling if enabled
if profiler_enabled:
omni_llm.start_profile(stages=[0])
omni.start_profile(stages=[0])
# Initialize generator
omni_generator = omni_llm.generate(prompts, sampling_params_list, py_generator=args.py_generator)
omni_generator = omni.generate(prompts, sampling_params_list, py_generator=args.py_generator)
total_requests = len(prompts)
processed_count = 0
@@ -57,21 +59,21 @@ for stage_outputs in omni_generator:
# ... [Output processing logic for text/audio would go here] ...
# Update count to track when to stop profiling
processed_count += len(stage_outputs.request_output)
processed_count += 1
# 2. Check if all requests are done to stop the profiler safely
if profiler_enabled and processed_count >= total_requests:
print(f"[Info] Processed {processed_count}/{total_requests}. Stopping profiler inside active loop...")
# Stop the profiler while workers are still active
omni_llm.stop_profile()
omni.stop_profile()
# Wait for traces to flush to disk
print("[Info] Waiting 30s for workers to write trace files to disk...")
time.sleep(30)
print("[Info] Trace export wait time finished.")
omni_llm.close()
omni.close()
```
+5 -5
View File
@@ -67,12 +67,12 @@ According to analysis for current popular open-source models, most of them have
| Component | Description |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| **OmniRouter** | provide an intelligent router for Omni-modality requests dispatch |
| **EntryPoints** | define the APIs for offline/online serving (APIServer, Omni/AsyncOmni) and provide the OmniStage abstraction for different AR/DiT stages |
| **EntryPoints** | define the APIs for offline/online serving (APIServer, Omni/AsyncOmni), while `AsyncOmniEngine` and `Orchestrator` coordinate multi-stage AR/DiT execution |
| **AR** | adapted for omni-modality models while inheriting efficient features from vLLM, such as cache management |
| **Diffusion** | natively implemented and optimized using acceleration components |
| **OmniConnector** | supports fully disaggregation based on E/P/D/G (Encoding/Processing/Decoding/Generation) disaggregation across stages |
Disaggregated stages are managed through configuration, such as in the Qwen3-Omni example, where stages like Thinker, Talker, and Code2wav are defined as separate OmniStage instances with specific resources and input/output type.
Disaggregated stages are managed through stage configuration. In Qwen3-Omni, Thinker/Talker/Code2wav are declared as separate configured stages, and runtime routing is handled by `Orchestrator` over `StageEngineCoreClient` / `StageDiffusionClient`.
## Main features
@@ -127,10 +127,10 @@ Taking **Qwen3-Omni** as an example:
The **Omni** class provides a Python interface for offline batched inference. Users initialize the Omni class with a Hugging Face model name and use the generate method, passing inputs that include both text prompts and multi-modal data:
```
# Create an omni_lm with HF model name.
# Create an omni runtime with HF model name.
from vllm_omni.entrypoints.omni import Omni
omni_lm = Omni(model="Qwen/Qwen3-Omni-30B-A3B-Instruct")
omni = Omni(model="Qwen/Qwen3-Omni-30B-A3B-Instruct")
# Example prompts.
om_inputs = {"prompt": prompt,
@@ -140,7 +140,7 @@ om_inputs = {"prompt": prompt,
}}
# Generate texts and audio from the multi-modality inputs.
outputs = omni_lm.generate(om_inputs, sampling_params_list)
outputs = omni.generate(om_inputs, sampling_params_list)
```
## Online Serving
+19 -46
View File
@@ -85,9 +85,6 @@ classDiagram
class InputProcessor {
+process_inputs() EngineCoreRequest
}
class OmniInputProcessor {
+process_inputs() OmniEngineCoreRequest
}
class VLLMOutputProcessor {
+process_outputs() OutputProcessorOutput
@@ -96,7 +93,6 @@ classDiagram
+process_outputs() OutputProcessorOutput
+_route_and_normalize()
}
InputProcessor <|-- OmniInputProcessor
VLLMOutputProcessor <|-- MultimodalOutputProcessor
```
@@ -105,7 +101,7 @@ classDiagram
- **Scheduler**: `OmniARScheduler` extends `vllm.v1.core.sched.scheduler.Scheduler` to enrich scheduled requests with omni-specific payloads
- **Worker**: `GPUARWorker` extends `vllm.v1.worker.gpu_worker.Worker` to initialize AR-specific model runners
- **ModelRunner**: `GPUARModelRunner` extends `OmniGPUModelRunner` → `vllm.v1.worker.gpu_model_runner.GPUModelRunner` to expose hidden states and handle multimodal outputs
- **InputProcessor**: `OmniInputProcessor` extends `vllm.v1.engine.input_processor.InputProcessor` to serialize prompt embeddings and additional information
- **InputProcessor**: Stage-0 uses upstream `vllm.v1.engine.input_processor.InputProcessor`; `AsyncOmniEngine` then restores omni-specific payloads (for example `additional_information` and `prompt_embeds`) when building `OmniEngineCoreRequest`
- **OutputProcessor**: `MultimodalOutputProcessor` extends `vllm.v1.engine.output_processor.OutputProcessor` to route and accumulate multimodal outputs
## 3. Scheduler Design
@@ -118,7 +114,7 @@ The following diagram illustrates the request flow through the AR module compone
```mermaid
flowchart TD
A[OmniInputProcessor] -->|OmniEngineCoreRequest| B[OmniARScheduler]
A[InputProcessor stage-0 in AsyncOmniEngine] -->|EngineCoreRequest then upgraded to OmniEngineCoreRequest| B[OmniARScheduler]
B -->|schedule: OmniNewRequestData| C[GPUARWorker]
C -->|SchedulerOutput| D[GPUARModelRunner]
D -->|execute_model: None| E[Model Forward Pass]
@@ -297,15 +293,17 @@ The input/output processing pipeline handles serialization, routing, and accumul
```mermaid
sequenceDiagram
participant Client
participant OmniInputProcessor
participant AsyncOmniEngine
participant InputProcessor
participant Scheduler
participant ModelRunner
participant MultimodalOutputProcessor
participant Client
Client->>OmniInputProcessor: prompt + prompt_embeds + additional_info
OmniInputProcessor->>OmniInputProcessor: Serialize tensors to payloads
OmniInputProcessor->>Scheduler: OmniEngineCoreRequest (with payloads)
Client->>AsyncOmniEngine: prompt + prompt_embeds + additional_info
AsyncOmniEngine->>InputProcessor: process_inputs()
InputProcessor->>Scheduler: EngineCoreRequest
AsyncOmniEngine->>AsyncOmniEngine: _upgrade_to_omni_request() + serialize_additional_information()
Scheduler->>ModelRunner: OmniNewRequestData (with payloads)
ModelRunner->>ModelRunner: Decode payloads → CPU tensors
ModelRunner->>ModelRunner: Overlay prompt_embeds on inputs_embeds
@@ -318,43 +316,18 @@ sequenceDiagram
MultimodalOutputProcessor->>Client: RequestOutput (with multimodal_output)
```
### OmniInputProcessor
### Stage-0 Input Processing
`OmniInputProcessor` extends the base input processor to serialize prompt embeddings and additional information for inter-stage transfer.
#### Payload Serialization
Converts PyTorch tensors to serialized payloads:
Stage-0 now uses upstream `InputProcessor` directly, and `AsyncOmniEngine` upgrades the request to `OmniEngineCoreRequest` while restoring omni-specific payloads.
```python
def process_inputs(self, ...) -> OmniEngineCoreRequest:
# Serialize prompt_embeds
if "prompt_embeds" in decoder_inputs:
pe_cpu = decoder_inputs["prompt_embeds"].detach().to("cpu").contiguous()
prompt_embeds_payload = PromptEmbedsPayload(
data=pe_cpu.numpy().tobytes(),
shape=[seq_len, hidden_size],
dtype=dtype_str,
)
# Serialize additional_information
if "additional_information" in decoder_inputs:
entries = {}
for key, value in raw_info.items():
if isinstance(value, torch.Tensor):
entry = AdditionalInformationEntry(
tensor_data=value.numpy().tobytes(),
tensor_shape=list(value.shape),
tensor_dtype=dtype_str,
)
entries[key] = entry
additional_information_payload = AdditionalInformationPayload(entries=entries)
return OmniEngineCoreRequest(
# ... standard fields ...
prompt_embeds=prompt_embeds_payload,
additional_information=additional_information_payload,
)
request = self.input_processor.process_inputs(
request_id=request_id,
prompt=prompt,
params=params,
supported_tasks=self.supported_tasks,
)
request = _upgrade_to_omni_request(request, prompt)
```
### MultimodalOutputProcessor
@@ -400,13 +373,13 @@ The AR module of vLLM-Omni extends vLLM through strategic inheritance and minima
### Key Design Patterns
1. **Inheritance over composition**: Extends vLLM classes to preserve compatibility with existing scheduling, batching, and execution mechanisms
2. **Payload serialization**: Uses `PromptEmbedsPayload` and `AdditionalInformationPayload` for efficient inter-stage data transfer
2. **Payload serialization**: Uses serialized `additional_information` payloads together with prompt-embedding handoff for efficient inter-stage transfer
3. **Two-phase execution**: Maintains vLLM's execute/sample separation for AR models while supporting single-phase execution for generation models
4. **Multimodal routing**: Routes outputs by `output_type` and accumulates tensors incrementally to support streaming
### Differences from vLLM
- **Payload support**: Serialized prompt embeddings and additional information enable direct transfer between pipeline stages
- **Payload support**: Serialized additional information and prompt embeddings enable direct transfer between pipeline stages
- **Multimodal handling**: Extended input/output processors support images, audio, and other modalities alongside text
- **Hidden state exposure**: AR model runners expose per-request hidden states via `pooler_output` for downstream consumption
- **Generation scheduler**: Fast-path scheduling for basic heterogeneous architectures that complete in one step
@@ -0,0 +1,203 @@
# AsyncOmni Architecture (Qwen3-Omni Example)
## 1. System Architecture
```text
• ┌─────────────────────────────────────────────────────────────────────────────────┐
│ API Layer │
│ ┌─────────────────────────────────────┐ ┌──────────────────────────────────┐ │
│ │ AsyncOmni (EngineClient) │ │ Omni │ │
│ │ • generate() / abort() / shutdown() │ │ • generate() │ │
│ │ • _final_output_handler() │ │ | │
│ └─────────────────────────────────────┘ └──────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────────────────────┤
│ Engine Layer (Proxy) │
│ ┌───────────────────────────────────────────────────────────────────────────┐ │
│ │ AsyncOmniEngine │ │
│ │ • _bootstrap_orchestrator() & _initialize_stages() │ │
│ │ • add_request() / add_request_async() -> input_processor.process_inputs() │ │
│ │ • try_get_output() / try_get_output_async() │ │
│ └───────────────────┬─────────────────────────────────▲─────────────────────┘ │
│ request_queue (janus.Queue) output_queue (janus.Queue) │
├──────────────────────┼─────────────────────────────────┼────────────────────────┤
│ ▼ Orchestration Layer │ │
│ ┌───────────────────────────────────────────────────────────────────────────┐ │
│ │ Orchestrator [background thread] │ │
│ │ • _request_handler() │ │
│ │ - stage_client.add_request_async() & _prewarm_async_chunk_stages() │ │
│ │ • _orchestration_output_handler() │ │
│ │ - _process_stage_outputs() -> output_processors[i].process_outputs() │ │
│ │ - _route_output() & _forward_to_next_stage() │ │
│ └──────────┬─────────────────────────┬────────────────────────┬─────────────┘ │
├─────────────┼─────────────────────────┼────────────────────────┼────────────────┤
│ │ Communication Layer │ │
│ ┌───────────────────────┐ ┌───────────────────────┐ ┌───────────────────────┐ │
│ │ StageEngineCoreClient │ │ StageEngineCoreClient │ │ StageDiffusionClient │ │
│ │ • ZMQ ROUTER / PULL │ │ • ZMQ ROUTER / PULL │ │ • ZMQ ROUTER / PULL │ │
│ │ • Msgpack codec │ │ • Msgpack codec │ │ • Msgpack codec │ │
│ └──────────┬────────────┘ └──────────┬────────────┘ └──────────┬────────────┘ │
│ ▼ ZMQ IPC ▼ ZMQ IPC ▼ ZMQ IPC │
├─────────────────────────────────────────────────────────────────────────────────┤
│ Execution Layer │
│ ┌───────────────────────┐ ┌───────────────────────┐ ┌───────────────────────┐ │
│ │ StageCoreProc │ │ StageCoreProc │ │ DiffusionEngine │ │
│ │ [background process] │ │ [background process] │ │ [background process] │ │
│ └───────────────────────┘ └───────────────────────┘ └───────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────┘
```
## 2. Execution Flow (Arrow Steps, one generate request)
```text
[1] App
-> AsyncOmni.generate(prompt, request_id)
[2] AsyncOmni
-> _final_output_handler() (started on first request)
-> AsyncOmniEngine.add_request(stage_id=0, ...)
[3] AsyncOmniEngine.add_request
-> (if stage-0 is llm and input is not EngineCoreRequest)
InputProcessor.process_inputs()
OutputProcessor[0].add_request()
-> request_queue.put(add_request_msg)
[4] Orchestrator._request_handler
-> _handle_add_request(msg)
-> stage_clients[0].add_request_async(...)
[5] Orchestrator._orchestration_loop (loop)
-> poll stage output
- llm stage: await get_output_async()
- diffusion stage: get_diffusion_output_async()
-> (llm stage) output_processors[i].process_outputs(...)
-> _route_output(...)
-> if finished and not final_stage and non-async-chunk:
_forward_to_next_stage(...)
-> next_stage.add_request_async(...)
-> output_queue.put(output)
[6] AsyncOmni._final_output_loop (background coroutine)
-> AsyncOmniEngine.try_get_output_async()
-> route by request_id to ClientRequestState.queue
[7] AsyncOmni._process_orchestrator_results
-> read from ClientRequestState.queue
-> _process_single_result(...)
-> yield OmniRequestOutput
[8] Exit condition
-> receive result["finished"] == True
-> generate() ends
```
## 3. Runtime Sequence (one generate request)
```mermaid
sequenceDiagram
participant APP as App
participant AO as AsyncOmni
participant ENG as AsyncOmniEngine
participant ORCH as Orchestrator
participant S0 as Stage-0 Client
participant SN as Next Stage Client
APP->>AO: generate
AO->>AO: start output_handler once
AO->>ENG: add_request(stage_id=0, ...)
ENG->>ENG: input_processor.process_inputs()
ENG->>ORCH: request_queue.put(add_request)
ORCH->>ORCH: _handle_add_request
ORCH->>S0: add_request_async
loop poll route forward
ORCH->>S0: get_output_async / get_diffusion_output_async
ORCH->>ORCH: _route_output
alt need forward to next stage
ORCH->>SN: add_request_async
end
ORCH-->>ENG: output_queue.put
end
AO->>ENG: try_get_output_async
ENG-->>AO: message
AO-->>APP: yield OmniRequestOutput
```
## 4. Comparison
Previous topology (reference):
```text
┌────────────────────────────────────────────────────────────────────────────┐
│ Main Process │
│ ┌──────────────────────┐ ┌────────────────────────────────────────────┐ │
│ │ generate() │ │ final_output_handler() │ │
│ └──────────────────────┘ └────────────────────────────────────────────┘ │
└──────────┬─────────────────────────┬─────────────────────────┬─────────────┘
mp.Queue (in_q/out_q) mp.Queue (in_q/out_q) mp.Queue (in_q/out_q)
▼▲ ▼▲ ▼▲
┌───────────────────────┐ ┌───────────────────────┐ ┌──────────────────────┐
│ Worker Proc-0 │ │ Worker Proc-1 │ │ Worker Proc-2 │
│ (Thinker LLM) │ │ (Talker LLM) │ │ (Vocoder) │
│ ┌────────────────┐ │ │ ┌────────────────┐ │ │ ┌────────────────┐ │
│ │_stage_worker │ │ │ │_stage_worker │ │ │ │_stage_worker │ │
│ │_async() │ │ │ │_async() │ │ │ │_async() │ │
│ └────────────────┘ │ │ └────────────────┘ │ │ └────────────────┘ │
│ ┌────────────────┐ │ │ ┌────────────────┐ │ │ ┌────────────────┐ │
│ │output_handler()│ │ │ │output_handler()│ │ │ │output_handler()│ │
│ └────────────────┘ │ │ └────────────────┘ │ │ └────────────────┘ │
└──────────┬────────────┘ └──────────┬────────────┘ └──────────┬───────────┘
ZMQ ▼ ▲ ZMQ ZMQ ▼ ▲ ZMQ ZMQ ▼ ▲ ZMQ
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ EngineCore Proc-0 │ │ EngineCore Proc-1 │ │ EngineCore Proc-2 │
│ (Thinker) │ │ (Talker) │ │ (Vocoder) │
└──────────────────────┘ └──────────────────────┘ └──────────────────────┘
```
Current topology:
```text
┌────────────────────────────────────────────────────────────────────────────┐
│ Main Process │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Main Thread │ │
│ │ ┌──────────────────────┐ ┌─────────────────────────────────────┐ │ │
│ │ │ generate() │ │ final_output_handler() │ │ │
│ │ └──────────────────────┘ └─────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ janus.Queue (request_queue) ▼ ▲ janus.Queue (output_queue) │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Orchestrator Thread │ │
│ │ ┌──────────────────────┐ ┌──────────────────────────────────────┐ │ │
│ │ │ _request_handler() │ │ _orchestration_output_handler() │ │ │
│ │ └──────────────────────┘ └──────────────────────────────────────┘ │ │
│ │ ┌────────────────────────────────────────────────────────────────┐ │ │
│ │ │ _orchestration_loop(): poll/process/route outputs for all stages│ │ │
│ │ └────────────────────────────────────────────────────────────────┘ │ │
│ └───────┬─────────────────────────┬─────────────────────────┬──────────┘ │
└──────────┬─────────────────────────┬─────────────────────────┬─────────────┘
ZMQ ▼ ▲ ZMQ ZMQ ▼ ▲ ZMQ ZMQ ▼ ▲ ZMQ
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ EngineCore Proc-0 │ │ EngineCore Proc-1 │ │ EngineCore Proc-2 │
│ (Thinker) │ │ (Talker) │ │ (Vocoder) │
└──────────────────────┘ └──────────────────────┘ └──────────────────────┘
```
Test scripts:
```bash
# enter offline inference folder.
cd examples/offline_inference/qwen2_5_omni
python end2end.py --output-dir output_audio --query-type use_mixed_modalities
cd ../qwen3_omni
python end2end.py --output-dir output_audio --query-type text --async-chunk --enable-stats
cd ../bagel
python end2end.py --prompts "A cute cat"
cd ../text_to_image
python text_to_image.py --prompt "a cup of coffee on the table" --output output.png
```
+1 -1
View File
@@ -97,7 +97,7 @@ outputs = omni.generate(
)
# Access custom trajectory data
output = outputs[0].request_output[0]
output = outputs[0].request_output
print(f"Trajectory timesteps shape: {output.metrics['trajectory_timesteps'].shape}")
print(f"Trajectory latents shape: {output.latents.shape}")
```
+2 -2
View File
@@ -42,7 +42,7 @@ if __name__ == "__main__":
omni = Omni(model="Tongyi-MAI/Z-Image-Turbo")
prompt = "a cup of coffee on the table"
outputs = omni.generate(prompt)
images = outputs[0].request_output[0].images
images = outputs[0].request_output.images
images[0].save("coffee.png")
```
@@ -70,7 +70,7 @@ if __name__ == "__main__":
]
omni_outputs = omni.generate(prompts)
for i_prompt, prompt_output in enumerate(omni_outputs):
this_request_output = prompt_output.request_output[0]
this_request_output = prompt_output.request_output
this_images = this_request_output.images
for i_image, image in enumerate(this_images):
image.save(f"p{i_prompt}-img{i_image}.jpg")
+15 -3
View File
@@ -22,10 +22,16 @@
CLI (examples/offline_inference/text_to_image/text_to_image.py)
|
v
Omni (vllm_omni/entrypoints/omni.py)
Omni (vllm_omni/entrypoints/__init__.py)
|
v
OmniStage (diffusion)
AsyncOmniEngine
|
v
Orchestrator
|
v
StageDiffusionClient
|
v
DiffusionWorker
@@ -64,7 +70,13 @@ _generate_with_async_omni
AsyncOmni
|
v
DiffusionEngine
AsyncOmniEngine
|
v
Orchestrator
|
v
StageDiffusionClient
|
v
OmniRequestOutput
@@ -19,7 +19,7 @@ if __name__ == "__main__":
omni = Omni(model="Qwen/Qwen-Image")
prompt = "a cup of coffee on the table"
outputs = omni.generate(prompt)
images = outputs[0].request_output[0].images
images = outputs[0].request_output.images
images[0].save("coffee.png")
```
@@ -37,7 +37,7 @@ if __name__ == "__main__":
]
outputs = omni.generate(prompts)
for i, output in enumerate(outputs):
image = output.request_output[0].images[0].save(f"{i}.jpg")
image = output.request_output.images[0].save(f"{i}.jpg")
```
!!! info
@@ -72,7 +72,7 @@ if __name__ == "__main__":
}
])
for i, output in enumerate(outputs):
image = output.request_output[0].images[0].save(f"{i}.jpg")
image = output.request_output.images[0].save(f"{i}.jpg")
```
## Local CLI Usage
+10 -18
View File
@@ -34,7 +34,7 @@ def parse_args():
help="Path to input image for img2img.",
)
# OmniLLM init args
# Omni runtime init args
parser.add_argument("--log-stats", action="store_true", default=False)
parser.add_argument("--init-sleep-seconds", type=int, default=20)
parser.add_argument("--batch-timeout", type=int, default=5)
@@ -163,25 +163,17 @@ def main():
omni_outputs = list(omni.generate(prompts=formatted_prompts, sampling_params_list=params_list))
for i, req_output in enumerate(omni_outputs):
img_idx = 0
for req_output in omni_outputs:
images = getattr(req_output, "images", None)
if not images and hasattr(req_output, "output"):
if isinstance(req_output.output, list):
images = req_output.output
else:
images = [req_output.output]
if not images:
continue
if images:
for j, img in enumerate(images):
img.save(f"output_{i}_{j}.png")
if hasattr(req_output, "request_output") and req_output.request_output:
for stage_out in req_output.request_output:
if hasattr(stage_out, "images") and stage_out.images:
for k, img in enumerate(stage_out.images):
save_path = f"output_{i}_stage_{getattr(stage_out, 'stage_id', '?')}_{k}.png"
img.save(save_path)
print(f"[Info] Saved stage output image to {save_path}")
for j, img in enumerate(images):
save_path = f"output_{img_idx}_{j}.png"
img.save(save_path)
print(f"[Info] Saved image to {save_path}")
img_idx += 1
print(omni_outputs)
@@ -167,27 +167,26 @@ def run_e2e():
print(f"Received {len(outputs)} outputs.")
for i, output in enumerate(outputs):
try:
ro_list = output.request_output or []
if not ro_list:
ro = output.request_output
if ro is None:
print("No request_output found.")
continue
for ro in ro_list:
# Multimodal output may be attached to RequestOutput or CompletionOutput.
mm = getattr(ro, "multimodal_output", None)
if not mm and ro.outputs:
mm = getattr(ro.outputs[0], "multimodal_output", None)
# Multimodal output may be attached to RequestOutput or CompletionOutput.
mm = getattr(ro, "multimodal_output", None)
if not mm and ro.outputs:
mm = getattr(ro.outputs[0], "multimodal_output", None)
if mm:
print(f"Multimodal output keys: {mm.keys()}")
if "audio" in mm:
audio_out = mm["audio"]
print(f"Generated Audio Shape: {audio_out.shape}")
out_path = f"output_{i}.wav"
sf.write(out_path, audio_out.cpu().numpy().squeeze(), 22050)
print(f"Saved audio to {out_path}")
else:
print("No multimodal output found.")
if mm:
print(f"Multimodal output keys: {mm.keys()}")
if "audio" in mm:
audio_out = mm["audio"]
print(f"Generated Audio Shape: {audio_out.shape}")
out_path = f"output_{i}.wav"
sf.write(out_path, audio_out.cpu().numpy().squeeze(), 22050)
print(f"Saved audio to {out_path}")
else:
print("No multimodal output found.")
except Exception as e:
print(f"Error inspecting output: {e}")
omni.close()
@@ -209,7 +209,7 @@ async def main():
if not outputs:
raise ValueError("No output produced from omni.generate()")
first_out = outputs[0].request_output[0]
first_out = outputs[0].request_output
req_out: OmniRequestOutput = first_out
# Verify trajectory data (from custom pipeline)
@@ -129,8 +129,14 @@ def main(args):
t_start = time.perf_counter()
for stage_outputs in omni.generate(inputs):
for output in stage_outputs.request_output:
_save_wav(output_dir, output.request_id, output.outputs[0].multimodal_output)
request_output = stage_outputs.request_output
if request_output is None or not request_output.outputs:
continue
_save_wav(
output_dir,
request_output.request_id,
request_output.outputs[0].multimodal_output,
)
t_end = time.perf_counter()
logger.info("Total inference time: %.1f ms", (t_end - t_start) * 1000)
+20 -21
View File
@@ -310,36 +310,35 @@ def main(args: argparse.Namespace) -> None:
output_count = 0
for stage_outputs in omni.generate(prompts, sampling_params_list, py_generator=True):
output = stage_outputs.request_output
if stage_outputs.final_output_type == "image":
for output in stage_outputs.request_output:
request_id = output.request_id
request_id = output.request_id
# Get generated images
images = output.images if hasattr(output, "images") else []
if not images and hasattr(output, "multimodal_output"):
images = output.multimodal_output.get("images", [])
# Get generated images
images = output.images if hasattr(output, "images") else []
if not images and hasattr(output, "multimodal_output"):
images = output.multimodal_output.get("images", [])
# Save each generated image
for idx, img in enumerate(images):
if args.num_prompts == 1 and len(images) == 1:
output_path = args.output
else:
base, ext = os.path.splitext(args.output)
output_path = f"{base}_{request_id}_{idx}{ext}"
# Save each generated image
for idx, img in enumerate(images):
if args.num_prompts == 1 and len(images) == 1:
output_path = args.output
else:
base, ext = os.path.splitext(args.output)
output_path = f"{base}_{request_id}_{idx}{ext}"
if isinstance(img, Image.Image):
save_image(img, output_path)
else:
print(f"Warning: Unexpected image type for request {request_id}: {type(img)}")
if isinstance(img, Image.Image):
save_image(img, output_path)
else:
print(f"Warning: Unexpected image type for request {request_id}: {type(img)}")
output_count += 1
output_count += 1
elif stage_outputs.final_output_type == "text":
# AR stage output (intermediate, for debugging)
if args.verbose:
for output in stage_outputs.request_output:
print(f"AR output for request {output.request_id}:")
print(f" Token count: {len(output.outputs[0].token_ids)}")
print(f"AR output for request {output.request_id}:")
print(f" Token count: {len(output.outputs[0].token_ids)}")
gen_time = time.time() - gen_start_time
print(f"\nGeneration completed in {gen_time:.2f}s")
+5 -6
View File
@@ -293,12 +293,11 @@ def main():
)
if hasattr(first_item, "is_pipeline_output") and first_item.is_pipeline_output:
if isinstance(first_item.request_output, list) and len(first_item.request_output) > 0:
inner_output = first_item.request_output[0]
if isinstance(inner_output, OmniRequestOutput) and hasattr(inner_output, "images"):
frames = inner_output.images[0] if inner_output.images else None
if frames is None:
raise ValueError("No video frames found in output.")
inner_output = first_item.request_output
if isinstance(inner_output, OmniRequestOutput) and hasattr(inner_output, "images"):
frames = inner_output.images[0] if inner_output.images else None
if frames is None:
raise ValueError("No video frames found in output.")
elif hasattr(first_item, "images") and first_item.images:
frames = first_item.images
else:
@@ -9,7 +9,7 @@ from PIL import Image
from vllm_omni.entrypoints.omni import Omni
"""
The tencent/HunyuanImage-3.0-Instruct base model is built on the Hunyuan v1 architecture, specifically the tencent/Hunyuan-A13B-Instruct model. It utilizes two tokenizer delimiter templates:
The tencent/HunyuanImage-3.0-Instruct base model uses the tencent/Hunyuan-A13B-Instruct backbone. It utilizes two tokenizer delimiter templates:
1) Pretrained template (default for gen_text mode), which concatenates system, image
tokens, and user question WITHOUT role delimiters:
@@ -70,8 +70,8 @@ def main(args: argparse.Namespace) -> None:
prompts = [prompt_dict]
omni_outputs = omni.generate(prompts=prompts)
prompt_text = omni_outputs[0].request_output[0].prompt
generated_text = omni_outputs[0].request_output[0].outputs[0].text
prompt_text = omni_outputs[0].request_output.prompt
generated_text = omni_outputs[0].request_output.outputs[0].text
print(f"Prompt: {prompt_text}")
print(f"Text: {generated_text}")
@@ -463,12 +463,12 @@ def main():
raise ValueError("No output generated from omni.generate()")
# Extract images from OmniRequestOutput
# omni.generate() returns list[OmniRequestOutput], extract images from request_output[0].images
# omni.generate() returns list[OmniRequestOutput], extract images from request_output.images
first_output = outputs[0]
if not hasattr(first_output, "request_output") or not first_output.request_output:
raise ValueError("No request_output found in OmniRequestOutput")
req_out = first_output.request_output[0]
req_out = first_output.request_output
if not isinstance(req_out, OmniRequestOutput) or not hasattr(req_out, "images"):
raise ValueError("Invalid request_output structure or missing 'images' key")
@@ -338,8 +338,6 @@ def main():
audio = frames.multimodal_output["audio"]
if frames.is_pipeline_output and frames.request_output is not None:
inner_output = frames.request_output
if isinstance(inner_output, list):
inner_output = inner_output[0] if inner_output else None
if isinstance(inner_output, OmniRequestOutput):
if inner_output.multimodal_output and "audio" in inner_output.multimodal_output:
audio = inner_output.multimodal_output["audio"]
@@ -99,14 +99,12 @@ def main() -> None:
lines: list[str] = []
for stage_outputs in outputs:
req_outputs = getattr(stage_outputs, "request_output", stage_outputs)
req_outputs = req_outputs if isinstance(req_outputs, list) else [req_outputs]
for ro in req_outputs:
text = ro.outputs[0].text if getattr(ro, "outputs", None) else str(ro)
lines.append(f"request_id: {getattr(ro, 'request_id', 'unknown')}\n")
lines.append("answer:\n")
lines.append(text.strip() + "\n")
lines.append("\n")
ro = getattr(stage_outputs, "request_output", stage_outputs)
text = ro.outputs[0].text if getattr(ro, "outputs", None) else str(ro)
lines.append(f"request_id: {getattr(ro, 'request_id', 'unknown')}\n")
lines.append("answer:\n")
lines.append(text.strip() + "\n")
lines.append("\n")
print("\n".join(lines))
@@ -148,19 +148,16 @@ def _collect_images(outputs: list) -> list[torch.Tensor]:
"""Extract all image tensors produced by the final (DiT) stage."""
images: list[torch.Tensor] = []
for out in outputs:
ro_list = getattr(out, "request_output", out)
if not isinstance(ro_list, list):
ro_list = [ro_list]
for ro_item in ro_list:
for completion in getattr(ro_item, "outputs", None) or []:
mm = getattr(completion, "multimodal_output", None)
if not isinstance(mm, dict) or "image" not in mm:
raise RuntimeError(f"Missing image in multimodal output: {mm}")
payload = mm["image"]
for tensor in payload if isinstance(payload, list) else [payload]:
if not isinstance(tensor, torch.Tensor):
raise TypeError(f"Expected image tensor, got {type(tensor)}")
images.append(tensor)
ro_item = getattr(out, "request_output", out)
for completion in getattr(ro_item, "outputs", None) or []:
mm = getattr(completion, "multimodal_output", None)
if not isinstance(mm, dict) or "image" not in mm:
raise RuntimeError(f"Missing image in multimodal output: {mm}")
payload = mm["image"]
for tensor in payload if isinstance(payload, list) else [payload]:
if not isinstance(tensor, torch.Tensor):
raise TypeError(f"Expected image tensor, got {type(tensor)}")
images.append(tensor)
return images
@@ -180,11 +180,11 @@ def main(args):
# Get the query function and call it with appropriate parameters
query_func = query_map[args.query_type]
omni_llm = Omni(
omni = Omni(
model=model_name,
stage_configs_path=args.stage_configs_path,
log_stats=args.enable_stats,
log_file=("omni_llm_pipeline.log" if args.enable_stats else None),
log_file=("omni_pipeline.log" if args.enable_stats else None),
init_sleep_seconds=args.init_sleep_seconds,
batch_timeout=args.batch_timeout,
init_timeout=args.init_timeout,
@@ -290,7 +290,7 @@ def main(args):
prompts = [copy.deepcopy(query_result) for _ in range(args.num_prompts)]
print("prompts", prompts)
omni_outputs = omni_llm.generate(prompts, sampling_params_list)
omni_outputs = omni.generate(prompts, sampling_params_list)
output_dir = args.output_dir if getattr(args, "output_dir", None) else args.output_wav
if args.query_type is not None:
@@ -298,45 +298,44 @@ def main(args):
os.makedirs(output_dir, exist_ok=True)
for stage_outputs in omni_outputs:
output = stage_outputs.request_output
if stage_outputs.final_output_type == "text":
for output in stage_outputs.request_output:
request_id = output.request_id
text_output = output.outputs[0].text
# Save aligned text file per request
prompt_text = output.prompt
out_txt = os.path.join(output_dir, f"{request_id}.txt")
lines = []
lines.append("Prompt:\n")
lines.append(str(prompt_text) + "\n")
lines.append("vllm_text_output:\n")
lines.append(str(text_output).strip() + "\n")
try:
with open(out_txt, "w", encoding="utf-8") as f:
print("lines", lines)
f.writelines(lines)
except Exception as e:
print(f"[Warn] Failed writing text file {out_txt}: {e}")
print(f"Request ID: {request_id}, Text saved to {out_txt}\n")
request_id = output.request_id
text_output = output.outputs[0].text
# Save aligned text file per request
prompt_text = output.prompt
out_txt = os.path.join(output_dir, f"{request_id}.txt")
lines = []
lines.append("Prompt:\n")
lines.append(str(prompt_text) + "\n")
lines.append("vllm_text_output:\n")
lines.append(str(text_output).strip() + "\n")
try:
with open(out_txt, "w", encoding="utf-8") as f:
print("lines", lines)
f.writelines(lines)
except Exception as e:
print(f"[Warn] Failed writing text file {out_txt}: {e}")
print(f"Request ID: {request_id}, Text saved to {out_txt}\n")
elif stage_outputs.final_output_type == "audio":
for output in stage_outputs.request_output:
request_id = output.request_id
audio_tensor = output.outputs[0].multimodal_output.get("audio")
request_id = output.request_id
audio_tensor = output.outputs[0].multimodal_output.get("audio")
if audio_tensor is None:
continue
if audio_tensor is None:
continue
output_wav = os.path.join(output_dir, f"{request_id}.wav")
output_wav = os.path.join(output_dir, f"{request_id}.wav")
# Convert to numpy array and ensure correct format
audio_numpy = audio_tensor.float().detach().cpu().numpy()
# Convert to numpy array and ensure correct format
audio_numpy = audio_tensor.float().detach().cpu().numpy()
# Ensure audio is 1D (flatten if needed)
if audio_numpy.ndim > 1:
audio_numpy = audio_numpy.flatten()
# Ensure audio is 1D (flatten if needed)
if audio_numpy.ndim > 1:
audio_numpy = audio_numpy.flatten()
# Save audio file with explicit WAV format
sf.write(output_wav, audio_numpy, samplerate=24000, format="WAV")
print(f"Request ID: {request_id}, Audio saved to {output_wav}")
# Save audio file with explicit WAV format
sf.write(output_wav, audio_numpy, samplerate=24000, format="WAV")
print(f"Request ID: {request_id}, Audio saved to {output_wav}")
def parse_args():
@@ -320,7 +320,7 @@ def main(args):
query_result = query_func(audio_path=audio_path, sampling_rate=sampling_rate)
else:
query_result = query_func()
omni_llm = Omni(
omni = Omni(
model=model_name,
log_stats=args.log_stats,
stage_init_timeout=args.stage_init_timeout,
@@ -378,9 +378,11 @@ def main(args):
prompt["modalities"] = output_modalities
profiler_enabled = bool(os.getenv("VLLM_TORCH_PROFILER_DIR"))
if profiler_enabled:
omni_llm.start_profile(stages=[0])
omni_generator = omni_llm.generate(prompts, sampling_params_list, py_generator=args.py_generator)
if profiler_enabled and hasattr(omni, "start_profile"):
omni.start_profile(stages=[0])
elif profiler_enabled:
print("[Warn] VLLM_TORCH_PROFILER_DIR is set, but current engine does not support profiler controls.")
omni_generator = omni.generate(prompts, sampling_params_list, py_generator=args.py_generator)
# Determine output directory: prefer --output-dir; fallback to --output-wav
output_dir = args.output_dir if getattr(args, "output_dir", None) else args.output_wav
@@ -389,43 +391,42 @@ def main(args):
total_requests = len(prompts)
processed_count = 0
for stage_outputs in omni_generator:
output = stage_outputs.request_output
if stage_outputs.final_output_type == "text":
for output in stage_outputs.request_output:
request_id = output.request_id
text_output = output.outputs[0].text
# Save aligned text file per request
prompt_text = output.prompt
out_txt = os.path.join(output_dir, f"{request_id}.txt")
lines = []
lines.append("Prompt:\n")
lines.append(str(prompt_text) + "\n")
lines.append("vllm_text_output:\n")
lines.append(str(text_output).strip() + "\n")
try:
with open(out_txt, "w", encoding="utf-8") as f:
f.writelines(lines)
except Exception as e:
print(f"[Warn] Failed writing text file {out_txt}: {e}")
print(f"Request ID: {request_id}, Text saved to {out_txt}")
request_id = output.request_id
text_output = output.outputs[0].text
# Save aligned text file per request
prompt_text = output.prompt
out_txt = os.path.join(output_dir, f"{request_id}.txt")
lines = []
lines.append("Prompt:\n")
lines.append(str(prompt_text) + "\n")
lines.append("vllm_text_output:\n")
lines.append(str(text_output).strip() + "\n")
try:
with open(out_txt, "w", encoding="utf-8") as f:
f.writelines(lines)
except Exception as e:
print(f"[Warn] Failed writing text file {out_txt}: {e}")
print(f"Request ID: {request_id}, Text saved to {out_txt}")
elif stage_outputs.final_output_type == "audio":
for output in stage_outputs.request_output:
request_id = output.request_id
audio_tensor = output.outputs[0].multimodal_output["audio"]
output_wav = os.path.join(output_dir, f"output_{request_id}.wav")
sf.write(output_wav, audio_tensor.detach().cpu().numpy(), samplerate=24000)
print(f"Request ID: {request_id}, Saved audio to {output_wav}")
request_id = output.request_id
audio_tensor = output.outputs[0].multimodal_output["audio"]
output_wav = os.path.join(output_dir, f"output_{request_id}.wav")
sf.write(output_wav, audio_tensor.detach().cpu().numpy(), samplerate=24000)
print(f"Request ID: {request_id}, Saved audio to {output_wav}")
processed_count += len(stage_outputs.request_output)
if profiler_enabled and processed_count >= total_requests:
processed_count += 1
if profiler_enabled and hasattr(omni, "stop_profile") and processed_count >= total_requests:
print(f"[Info] Processed {processed_count}/{total_requests}. Stopping profiler inside active loop...")
# Stop the profiler while workers are still alive
omni_llm.stop_profile()
omni.stop_profile()
print("[Info] Waiting 30s for workers to write massive trace files to disk...")
time.sleep(30)
print("[Info] Trace export wait finished.")
omni_llm.close()
omni.close()
def parse_args():
@@ -294,7 +294,7 @@ def main(args):
else:
query_result = query_func()
omni_llm = Omni(
omni = Omni(
model=model_name,
stage_configs_path=args.stage_configs_path,
log_stats=args.log_stats,
@@ -354,8 +354,8 @@ def main(args):
profiler_enabled = bool(os.getenv("VLLM_TORCH_PROFILER_DIR"))
if profiler_enabled:
omni_llm.start_profile(stages=[0])
omni_generator = omni_llm.generate(prompts, sampling_params_list, py_generator=args.py_generator)
omni.start_profile(stages=[0])
omni_generator = omni.generate(prompts, sampling_params_list, py_generator=args.py_generator)
# Determine output directory: prefer --output-dir; fallback to --output-wav
output_dir = args.output_dir if getattr(args, "output_dir", None) else args.output_wav
os.makedirs(output_dir, exist_ok=True)
@@ -366,51 +366,50 @@ def main(args):
print(f"query type: {args.query_type}")
for stage_outputs in omni_generator:
output = stage_outputs.request_output
if stage_outputs.final_output_type == "text":
for output in stage_outputs.request_output:
request_id = output.request_id
text_output = output.outputs[0].text
# Save aligned text file per request
prompt_text = output.prompt
out_txt = os.path.join(output_dir, f"{request_id}.txt")
lines = []
lines.append("Prompt:\n")
lines.append(str(prompt_text) + "\n")
lines.append("vllm_text_output:\n")
lines.append(str(text_output).strip() + "\n")
try:
with open(out_txt, "w", encoding="utf-8") as f:
f.writelines(lines)
except Exception as e:
print(f"[Warn] Failed writing text file {out_txt}: {e}")
print(f"Request ID: {request_id}, Text saved to {out_txt}")
request_id = output.request_id
text_output = output.outputs[0].text
# Save aligned text file per request
prompt_text = output.prompt
out_txt = os.path.join(output_dir, f"{request_id}.txt")
lines = []
lines.append("Prompt:\n")
lines.append(str(prompt_text) + "\n")
lines.append("vllm_text_output:\n")
lines.append(str(text_output).strip() + "\n")
try:
with open(out_txt, "w", encoding="utf-8") as f:
f.writelines(lines)
except Exception as e:
print(f"[Warn] Failed writing text file {out_txt}: {e}")
print(f"Request ID: {request_id}, Text saved to {out_txt}")
elif stage_outputs.final_output_type == "audio":
for output in stage_outputs.request_output:
request_id = output.request_id
audio_tensor = output.outputs[0].multimodal_output["audio"]
output_wav = os.path.join(output_dir, f"output_{request_id}.wav")
request_id = output.request_id
audio_tensor = output.outputs[0].multimodal_output["audio"]
output_wav = os.path.join(output_dir, f"output_{request_id}.wav")
# Convert to numpy array and ensure correct format
audio_numpy = audio_tensor.float().detach().cpu().numpy()
# Convert to numpy array and ensure correct format
audio_numpy = audio_tensor.float().detach().cpu().numpy()
# Ensure audio is 1D (flatten if needed)
if audio_numpy.ndim > 1:
audio_numpy = audio_numpy.flatten()
# Ensure audio is 1D (flatten if needed)
if audio_numpy.ndim > 1:
audio_numpy = audio_numpy.flatten()
# Save audio file with explicit WAV format
sf.write(output_wav, audio_numpy, samplerate=24000, format="WAV")
print(f"Request ID: {request_id}, Saved audio to {output_wav}")
# Save audio file with explicit WAV format
sf.write(output_wav, audio_numpy, samplerate=24000, format="WAV")
print(f"Request ID: {request_id}, Saved audio to {output_wav}")
processed_count += len(stage_outputs.request_output)
processed_count += 1
if profiler_enabled and processed_count >= total_requests:
print(f"[Info] Processed {processed_count}/{total_requests}. Stopping profiler inside active loop...")
# Stop the profiler while workers are still alive
omni_llm.stop_profile()
omni.stop_profile()
print("[Info] Waiting 30s for workers to write trace files to disk...")
time.sleep(30)
print("[Info] Trace export wait time finished.")
omni_llm.close()
omni.close()
def parse_args():
@@ -231,52 +231,47 @@ async def run_single_request(
sampling_params_list=sampling_params_list,
output_modalities=output_modalities,
):
if not isinstance(omni_output.request_output, list):
outputs_list = [omni_output.request_output]
else:
outputs_list = omni_output.request_output
output = omni_output.request_output
if omni_output.final_output_type == "text":
if stage_0_first_output_ts is None:
stage_0_first_output_ts = time.perf_counter()
text_output = output.outputs[0].text
if output.finished:
text_parts.append(text_output)
elif omni_output.final_output_type == "audio":
mm_out = output.outputs[0].multimodal_output
if mm_out and "audio" in mm_out:
if first_audio_ts is None:
first_audio_ts = time.perf_counter()
if audio_sr is None and "sr" in mm_out:
sr_val = mm_out["sr"]
audio_sr = sr_val.item() if hasattr(sr_val, "item") else int(sr_val)
samplerate = audio_sr
audio_data = mm_out["audio"]
if isinstance(audio_data, list):
new_chunks = audio_data[audio_list_consumed:]
audio_list_consumed = len(audio_data)
elif isinstance(audio_data, torch.Tensor):
new_chunks = [audio_data]
audio_last_tensor = audio_data
else:
new_chunks = []
for output in outputs_list:
if omni_output.final_output_type == "text":
if stage_0_first_output_ts is None:
stage_0_first_output_ts = time.perf_counter()
text_output = output.outputs[0].text
if output.finished:
text_parts.append(text_output)
elif omni_output.final_output_type == "audio":
mm_out = output.outputs[0].multimodal_output
if mm_out and "audio" in mm_out:
if first_audio_ts is None:
first_audio_ts = time.perf_counter()
if audio_sr is None and "sr" in mm_out:
sr_val = mm_out["sr"]
audio_sr = sr_val.item() if hasattr(sr_val, "item") else int(sr_val)
samplerate = audio_sr
audio_data = mm_out["audio"]
if isinstance(audio_data, list):
new_chunks = audio_data[audio_list_consumed:]
audio_list_consumed = len(audio_data)
elif isinstance(audio_data, torch.Tensor):
new_chunks = [audio_data]
audio_last_tensor = audio_data
else:
new_chunks = []
if stream_audio_to_disk and new_chunks:
if sf_writer is None:
sf_writer = sf.SoundFile(
wav_file,
mode="w",
samplerate=samplerate,
channels=1,
subtype="FLOAT",
)
for chunk in new_chunks:
chunk_np = chunk.float().detach().cpu().numpy().flatten()
sf_writer.write(chunk_np)
audio_samples_written += len(chunk_np)
else:
audio_chunks.extend(new_chunks)
if stream_audio_to_disk and new_chunks:
if sf_writer is None:
sf_writer = sf.SoundFile(
wav_file,
mode="w",
samplerate=samplerate,
channels=1,
subtype="FLOAT",
)
for chunk in new_chunks:
chunk_np = chunk.float().detach().cpu().numpy().flatten()
sf_writer.write(chunk_np)
audio_samples_written += len(chunk_np)
else:
audio_chunks.extend(new_chunks)
finally:
if sf_writer is not None:
sf_writer.close()
@@ -51,16 +51,68 @@ def _estimate_prompt_len(
tok = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True, padding_side="left")
cfg = Qwen3TTSConfig.from_pretrained(model_name, trust_remote_code=True)
_cache[model_name] = (tok, getattr(cfg, "talker_config", None))
tok, tcfg = _cache[model_name]
# Load speech tokenizer (codec encoder) for exact ref_code_len.
speech_tok = None
try:
import os
from transformers.utils import cached_file
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_tokenizer import Qwen3TTSTokenizer
st_cfg_path = cached_file(model_name, "speech_tokenizer/config.json")
if st_cfg_path:
speech_tok = Qwen3TTSTokenizer.from_pretrained(
os.path.dirname(st_cfg_path), torch_dtype=torch.bfloat16
)
logger.info("Loaded speech tokenizer for exact ref_code_len estimation")
except Exception as e:
logger.debug("Could not load speech tokenizer: %s", e)
_cache[model_name] = (tok, getattr(cfg, "talker_config", None), speech_tok)
tok, tcfg, speech_tok = _cache[model_name]
task_type = (additional_information.get("task_type") or ["CustomVoice"])[0]
def _estimate_ref_code_len(ref_audio: object) -> int | None:
"""Encode ref_audio with the actual codec to get exact frame count."""
if not isinstance(ref_audio, (str, list)):
return None
audio_path = ref_audio[0] if isinstance(ref_audio, list) else ref_audio
if not isinstance(audio_path, str) or not audio_path.strip():
return None
try:
from vllm.multimodal.media import MediaConnector
connector = MediaConnector(allowed_local_media_path="/")
audio, sr = connector.fetch_audio(audio_path)
import numpy as np
wav_np = np.asarray(audio, dtype=np.float32)
if speech_tok is not None:
enc = speech_tok.encode(wav_np, sr=int(sr), return_dict=True)
ref_code = getattr(enc, "audio_codes", None)
if isinstance(ref_code, list):
ref_code = ref_code[0] if ref_code else None
if ref_code is not None and hasattr(ref_code, "shape"):
shape = ref_code.shape
return int(shape[0]) if len(shape) == 2 else int(shape[1]) if len(shape) == 3 else None
# Fallback: estimate from duration
codec_hz = getattr(tcfg, "codec_frame_rate", None) or 12
return int(len(audio) / sr * codec_hz)
except Exception:
return None
return Qwen3TTSTalkerForConditionalGeneration.estimate_prompt_len_from_additional_information(
additional_information=additional_information,
task_type=task_type,
tokenize_prompt=lambda t: tok(t, padding=False)["input_ids"],
codec_language_id=getattr(tcfg, "codec_language_id", None),
spk_is_dialect=getattr(tcfg, "spk_is_dialect", None),
estimate_ref_code_len=_estimate_ref_code_len,
)
except Exception as exc:
logger.warning("Failed to estimate prompt length, using fallback 2048: %s", exc)
@@ -325,8 +377,8 @@ def main(args):
for batch_start in range(0, len(inputs), batch_size):
batch = inputs[batch_start : batch_start + batch_size]
for stage_outputs in omni.generate(batch):
for output in stage_outputs.request_output:
_save_wav(output_dir, output.request_id, output.outputs[0].multimodal_output)
output = stage_outputs.request_output
_save_wav(output_dir, output.request_id, output.outputs[0].multimodal_output)
async def main_streaming(args):
@@ -177,7 +177,7 @@ def main():
output = outputs[0]
if not hasattr(output, "request_output") or not output.request_output:
raise ValueError("No request_output found in OmniRequestOutput")
request_output = output.request_output[0]
request_output = output.request_output
if not hasattr(request_output, "multimodal_output"):
raise ValueError("No multimodal_output found in request_output")
@@ -16,7 +16,7 @@ if __name__ == "__main__":
omni = Omni(model="Qwen/Qwen-Image")
prompt = "a cup of coffee on the table"
outputs = omni.generate(prompt)
images = outputs[0].request_output[0].images
images = outputs[0].request_output.images
images[0].save("coffee.png")
```
@@ -34,7 +34,7 @@ if __name__ == "__main__":
]
outputs = omni.generate(prompts)
for i, output in enumerate(outputs):
image = output.request_output[0].images[0].save(f"{i}.jpg")
image = output.request_output.images[0].save(f"{i}.jpg")
```
!!! info
@@ -69,7 +69,7 @@ if __name__ == "__main__":
}
])
for i, output in enumerate(outputs):
image = output.request_output[0].images[0].save(f"{i}.jpg")
image = output.request_output.images[0].save(f"{i}.jpg")
```
## Local CLI Usage
@@ -112,7 +112,7 @@ def build_demo(args: argparse.Namespace) -> gr.Blocks:
)
images_outputs = []
for output in outputs:
req_out = output.request_output[0]
req_out = output.request_output
if not isinstance(req_out, OmniRequestOutput) or not hasattr(req_out, "images"):
raise ValueError("Invalid request_output structure or missing 'images' key")
images = req_out.images
@@ -14,7 +14,6 @@ from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.lora.request import LoRARequest
from vllm_omni.lora.utils import stable_lora_int_id
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.platforms import current_omni_platform
@@ -400,20 +399,18 @@ def main():
else:
print("[Profiler] No valid profiling data returned.")
# Extract images from OmniRequestOutput
# omni.generate() returns list[OmniRequestOutput], extract images from the first output
# omni.generate() returns list[OmniRequestOutput]
if not outputs or len(outputs) == 0:
raise ValueError("No output generated from omni.generate()")
logger.info(f"Outputs: {outputs}")
# Extract images from request_output[0]['images']
first_output = outputs[0]
if not hasattr(first_output, "request_output") or not first_output.request_output:
raise ValueError("No request_output found in OmniRequestOutput")
req_out = first_output.request_output[0]
if not isinstance(req_out, OmniRequestOutput) or not hasattr(req_out, "images"):
raise ValueError("Invalid request_output structure or missing 'images' key")
req_out = first_output.request_output
if not hasattr(req_out, "images"):
raise ValueError("Invalid request_output structure or missing 'images'.")
images = req_out.images
if not images:
@@ -240,8 +240,6 @@ def main():
audio = frames.multimodal_output["audio"]
if frames.is_pipeline_output and frames.request_output is not None:
inner_output = frames.request_output
if isinstance(inner_output, list):
inner_output = inner_output[0] if inner_output else None
if isinstance(inner_output, OmniRequestOutput):
if inner_output.multimodal_output and "audio" in inner_output.multimodal_output:
audio = inner_output.multimodal_output["audio"]
+1
View File
@@ -17,3 +17,4 @@ einops>=0.8.1
prettytable>=3.8.0
aenum==3.1.16
pyzmq>=25.0.0
janus>=1.0.0
+59 -13
View File
@@ -11,6 +11,7 @@ import time
import traceback
from collections.abc import Iterable, Sequence
from enum import StrEnum, auto
from types import SimpleNamespace
from typing import Any, NamedTuple
from unittest.mock import AsyncMock, MagicMock, patch
@@ -31,6 +32,7 @@ from vllm import SamplingParams
from vllm.outputs import CompletionOutput, RequestOutput
from vllm.utils.argparse_utils import FlexibleArgumentParser
from vllm_omni.entrypoints.async_omni import AsyncOmni as RealAsyncOmni
from vllm_omni.entrypoints.cli.serve import OmniServeCommand
from vllm_omni.inputs.data import OmniSamplingParams
from vllm_omni.outputs import OmniRequestOutput
@@ -43,7 +45,7 @@ class ServerCase(NamedTuple):
served_model: str
stage_list: list
stage_configs: list[dict]
stage_configs: list[Any]
outputs: list[OmniRequestOutput]
@@ -262,6 +264,40 @@ def _assert_model_param_values(received: OmniSamplingParams, expected: dict):
)
def _make_stage_config(
stage_type: str,
*,
is_comprehension: bool = False,
model_stage: str | None = None,
):
engine_args = SimpleNamespace()
if model_stage is not None:
engine_args.model_stage = model_stage
return SimpleNamespace(
stage_type=stage_type,
is_comprehension=is_comprehension,
engine_args=engine_args,
)
def _stage_type(stage: Any) -> str | None:
return getattr(stage, "stage_type", None)
def _build_output_modalities(stage_configs: list[Any]) -> list[str]:
modalities: list[str] = []
for stage in stage_configs:
final_output = getattr(stage, "final_output", False)
final_output_type = getattr(stage, "final_output_type", None)
if final_output and isinstance(final_output_type, str):
modalities.append(final_output_type)
if modalities:
return modalities
if any(_stage_type(stage) == "diffusion" for stage in stage_configs):
return ["image"]
return ["text", "audio"]
def _build_mock_outputs(outputs: Iterable[OmniRequestOutput], sampling_case: SamplingCase, server_case: ServerCase):
async def _mock_generate(*args, **kwargs):
received_sampling_params_list: Sequence[OmniSamplingParams] | None = (
@@ -367,18 +403,28 @@ def mock_async_omni(server_case: ServerCase, sampling_case: SamplingCase):
new=_mock_preprocess_chat,
),
):
mock_instance = AsyncMock()
mock_instance = AsyncMock(spec=RealAsyncOmni)
mock_instance.generate = _build_mock_outputs(server_case.outputs, sampling_case, server_case)
mock_instance.stage_list = server_case.stage_list
mock_instance.stage_configs = server_case.stage_configs
mock_instance.output_modalities = _build_output_modalities(server_case.stage_configs)
mock_instance.default_sampling_params_list = [
SamplingParams() if stage.get("stage_type") != "diffusion" else MagicMock()
SamplingParams() if _stage_type(stage) != "diffusion" else MagicMock()
for stage in server_case.stage_configs
]
mock_instance.errored = False
mock_instance.dead_error = RuntimeError("Mock engine error")
mock_instance.model_config = MagicMock(max_model_len=4096, io_processor_plugin=None)
mock_instance.model_config = MagicMock(
max_model_len=4096,
io_processor_plugin=None,
allowed_local_media_path=None,
allowed_media_domains=None,
)
# Mimic Qwen3-TTS talker speaker config so CustomVoice validation passes.
mock_instance.model_config.hf_config = MagicMock()
mock_instance.model_config.hf_config.talker_config = MagicMock()
mock_instance.model_config.hf_config.talker_config.speaker_id = {"Vivian": 0}
mock_instance.io_processor = MagicMock()
mock_instance.input_processor = MagicMock()
mock_instance.shutdown = MagicMock()
@@ -444,7 +490,7 @@ def api_server(unused_tcp_port_factory, server_case: ServerCase, mock_async_omni
ServerCase(
served_model="Tongyi-MAI/Z-Image-Turbo",
stage_list=["diffusion"],
stage_configs=[{"stage_type": "diffusion"}],
stage_configs=[_make_stage_config("diffusion")],
outputs=[_build_diffusion_image_output_for_images_endpoint()],
),
"Tongyi-MAI/Z-Image-Turbo",
@@ -455,7 +501,7 @@ def api_server(unused_tcp_port_factory, server_case: ServerCase, mock_async_omni
ServerCase(
served_model="ByteDance-Seed/BAGEL-7B-MoT",
stage_list=["diffusion"],
stage_configs=[{"stage_type": "diffusion"}],
stage_configs=[_make_stage_config("diffusion")],
outputs=[_build_diffusion_image_output_for_chat_endpoint()],
),
"ByteDance-Seed/BAGEL-7B-MoT",
@@ -466,7 +512,7 @@ def api_server(unused_tcp_port_factory, server_case: ServerCase, mock_async_omni
ServerCase(
served_model="Qwen/Qwen-Image-Edit",
stage_list=["diffusion"],
stage_configs=[{"stage_type": "diffusion"}],
stage_configs=[_make_stage_config("diffusion")],
outputs=[_build_diffusion_image_output_for_images_endpoint()],
),
"Qwen/Qwen-Image-Edit",
@@ -477,7 +523,7 @@ def api_server(unused_tcp_port_factory, server_case: ServerCase, mock_async_omni
ServerCase(
served_model="ByteDance-Seed/BAGEL-7B-MoT",
stage_list=["diffusion"],
stage_configs=[{"stage_type": "diffusion"}],
stage_configs=[_make_stage_config("diffusion")],
outputs=[_build_diffusion_image_output_for_chat_endpoint()],
),
"ByteDance-Seed/BAGEL-7B-MoT",
@@ -542,9 +588,9 @@ async def test_image_generation_node(api_server: str, model: str, image_input: b
MagicMock(is_comprehension=False, model_stage="llm"),
],
stage_configs=[
{"stage_type": "llm"},
{"stage_type": "llm"},
{"stage_type": "llm"},
_make_stage_config("llm", is_comprehension=True, model_stage="thinker"),
_make_stage_config("llm", is_comprehension=False, model_stage="talker"),
_make_stage_config("llm", is_comprehension=False, model_stage="code2wav"),
],
outputs=[_build_audio_chat_output(), _build_text_output("Understanding response")],
),
@@ -599,7 +645,7 @@ async def test_understanding_node(api_server: str, sampling_case: SamplingCase):
ServerCase(
served_model="Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
stage_list=["llm"],
stage_configs=[{"stage_type": "llm"}],
stage_configs=[_make_stage_config("llm", model_stage="qwen3_tts")],
outputs=[_build_audio_speech_output()],
),
VLLMOmniTTS,
@@ -617,7 +663,7 @@ async def test_understanding_node(api_server: str, sampling_case: SamplingCase):
ServerCase(
served_model="Qwen/Qwen3-TTS-12Hz-1.7B-Base",
stage_list=["llm"],
stage_configs=[{"stage_type": "llm"}],
stage_configs=[_make_stage_config("llm", model_stage="qwen3_tts")],
outputs=[_build_audio_speech_output()],
),
VLLMOmniVoiceClone,
+5 -3
View File
@@ -1733,7 +1733,9 @@ class OmniRunner:
Returns:
List of SamplingParams with default decoding for each stage
"""
return [st.default_sampling_params for st in self.omni.stage_list]
if not hasattr(self.omni, "default_sampling_params_list"):
raise AttributeError("Omni.default_sampling_params_list is not available")
return list(self.omni.default_sampling_params_list)
def get_omni_inputs(
self,
@@ -1991,9 +1993,9 @@ class OmniRunnerHandler:
audio_content = None
for stage_output in outputs:
if getattr(stage_output, "final_output_type", None) == "text":
text_content = stage_output.request_output[0].outputs[0].text
text_content = stage_output.request_output.outputs[0].text
if getattr(stage_output, "final_output_type", None) == "audio":
audio_content = stage_output.request_output[0].outputs[0].multimodal_output["audio"]
audio_content = stage_output.request_output.outputs[0].multimodal_output["audio"]
result.audio_content = audio_content
result.text_content = text_content
+47
View File
@@ -16,6 +16,7 @@ from unittest.mock import Mock, patch
import pytest
from vllm_omni.diffusion.cache.cache_dit_backend import (
CUSTOM_DIT_ENABLERS,
CacheDiTBackend,
)
from vllm_omni.diffusion.cache.selector import get_cache_backend
@@ -122,6 +123,52 @@ class TestCacheDiTBackend:
assert "cache_config" in call_args[1]
assert backend._last_num_inference_steps == 100
def test_hunyuan_custom_enabler_registered(self):
"""Test HunyuanImage3 custom cache-dit enabler is registered."""
assert "HunyuanImage3Pipeline" in CUSTOM_DIT_ENABLERS
@patch("vllm_omni.diffusion.cache.cache_dit_backend.BlockAdapter")
@patch("vllm_omni.diffusion.cache.cache_dit_backend.cache_dit")
def test_enable_hunyuan_pipeline_uses_model_transformer(self, mock_cache_dit, mock_block_adapter):
"""Test HunyuanImage3 custom enabler uses pipeline.model for cache enable/refresh."""
mock_pipeline = Mock()
mock_pipeline.__class__.__name__ = "HunyuanImage3Pipeline"
mock_pipeline.model = Mock()
mock_pipeline.model.layers = Mock()
mock_cache_dit.enable_cache = Mock()
mock_cache_dit.refresh_context = Mock()
backend = CacheDiTBackend({"Fn_compute_blocks": 2})
backend.enable(mock_pipeline)
assert backend.enabled is True
assert backend._refresh_func is not None
mock_block_adapter.assert_called_once()
adapter_kwargs = mock_block_adapter.call_args.kwargs
assert adapter_kwargs["transformer"] is mock_pipeline.model
assert adapter_kwargs["blocks"] is mock_pipeline.model.layers
assert adapter_kwargs["forward_pattern"] == adapter_kwargs["forward_pattern"].__class__.Pattern_4
assert len(adapter_kwargs["params_modifiers"]) == 1
mock_cache_dit.enable_cache.assert_called_once()
backend.refresh(mock_pipeline, num_inference_steps=12)
mock_cache_dit.refresh_context.assert_called_once()
call_args = mock_cache_dit.refresh_context.call_args
assert call_args[0][0] is mock_pipeline.model
assert call_args[1]["num_inference_steps"] == 12
def test_enable_hunyuan_pipeline_requires_model_layers(self):
"""Test HunyuanImage3 enabler fails with a formatted pipeline class name."""
mock_pipeline = Mock()
mock_pipeline.__class__.__name__ = "HunyuanImage3Pipeline"
mock_pipeline.model = Mock(spec=[])
backend = CacheDiTBackend({"Fn_compute_blocks": 2})
with pytest.raises(ValueError, match="HunyuanImage3Pipeline"):
backend.enable(mock_pipeline)
class TestTeaCacheBackend:
"""Test TeaCacheBackend implementation."""
@@ -70,6 +70,7 @@ def mock_dist():
patch.object(dist, "get_world_size", return_value=2),
patch.object(dist, "get_rank", return_value=0),
patch.object(dist, "is_initialized", return_value=True),
patch.object(dist, "all_reduce", return_value=None),
patch.object(dist, "gather", return_value=None),
patch.object(dist, "broadcast", return_value=None),
):
@@ -54,6 +54,8 @@ def build_adapter(monkeypatch, mocker: MockerFixture):
self._pending_save_reqs = deque()
self._finished_save_reqs = set()
self.stop_event = threading.Event()
self._recv_cond = threading.Condition()
self._save_cond = threading.Condition()
monkeypatch.setattr(OmniTransferAdapterBase, "__init__", _fake_base_init)
monkeypatch.setattr(
@@ -1,4 +1,4 @@
# stage config for running qwen2.5-omni with architecture of OmniLLM.
# stage config for running qwen2.5-omni for multi-stage omni runtime.
# This config is optimized for CI e2e tests.
stage_args:
@@ -98,9 +98,9 @@ def _extract_generated_image(omni_outputs: list) -> Image.Image | None:
if images := getattr(req_output, "images", None):
return images[0]
if hasattr(req_output, "request_output") and req_output.request_output:
for stage_out in req_output.request_output:
if hasattr(stage_out, "images") and stage_out.images:
return stage_out.images[0]
stage_out = req_output.request_output
if hasattr(stage_out, "images") and stage_out.images:
return stage_out.images[0]
return None
@@ -14,6 +14,9 @@ Equivalent to running:
"""
import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "1"
import signal
import socket
import subprocess
@@ -96,9 +99,9 @@ def _extract_generated_image(omni_outputs: list) -> Image.Image | None:
if images := getattr(req_output, "images", None):
return images[0]
if hasattr(req_output, "request_output") and req_output.request_output:
for stage_out in req_output.request_output:
if hasattr(stage_out, "images") and stage_out.images:
return stage_out.images[0]
stage_out = req_output.request_output
if hasattr(stage_out, "images") and stage_out.images:
return stage_out.images[0]
return None
@@ -72,13 +72,13 @@ def test_cache_dit(model_name: str):
num_outputs_per_prompt=1, # Single output for speed
),
)
# Extract images from request_output[0]['images']
# Extract images from request_output['images']
first_output = outputs[0]
assert first_output.final_output_type == "image"
if not hasattr(first_output, "request_output") or not first_output.request_output:
raise ValueError("No request_output found in OmniRequestOutput")
req_out = first_output.request_output[0]
req_out = first_output.request_output
if not isinstance(req_out, OmniRequestOutput) or not hasattr(req_out, "images"):
raise ValueError("Invalid request_output structure or missing 'images' key")
@@ -37,7 +37,7 @@ def test_diffusion_model(model_name: str, tmp_path: Path):
if not hasattr(first_output, "request_output") or not first_output.request_output:
raise ValueError("No request_output found in OmniRequestOutput")
req_out = first_output.request_output[0]
req_out = first_output.request_output
if not isinstance(req_out, OmniRequestOutput) or not hasattr(req_out, "images"):
raise ValueError("Invalid request_output structure or missing 'images' key")
return req_out.images
@@ -129,7 +129,7 @@ def _run_inference(
elapsed_ms = (time.time() - start) * 1000
return InferenceResult(
images=outputs[0].request_output[0].images,
images=outputs[0].images,
elapsed_ms=elapsed_ms,
)
finally:
@@ -130,7 +130,7 @@ def _run_inference(
elapsed_ms = (time.time() - start) * 1000
return InferenceResult(
images=outputs[0].request_output[0].images,
images=outputs[0].request_output.images,
elapsed_ms=elapsed_ms,
)
finally:
@@ -57,7 +57,7 @@ def test_stable_audio_model(model_name: str):
assert first_output.final_output_type == "image"
assert hasattr(first_output, "request_output") and first_output.request_output
req_out = first_output.request_output[0]
req_out = first_output.request_output
assert isinstance(req_out, OmniRequestOutput)
assert req_out.final_output_type == "audio"
assert hasattr(req_out, "multimodal_output") and req_out.multimodal_output
@@ -28,7 +28,7 @@ if current_omni_platform.is_npu():
models = ["Tongyi-MAI/Z-Image-Turbo", "Qwen/Qwen-Image"]
elif current_omni_platform.is_rocm():
# TODO: When ROCm support is ready, remove this branch.
# vLLM V0.11.0 has issues running riverclouds/qwen_image_random
# Current upstream vLLM has issues running riverclouds/qwen_image_random
# on ROCm
models = ["Tongyi-MAI/Z-Image-Turbo"]
@@ -62,13 +62,13 @@ def test_diffusion_model(model_name: str, run_level):
num_outputs_per_prompt=2,
),
)
# Extract images from request_output[0]['images']
# Extract images from request_output['images']
first_output = outputs[0]
assert first_output.final_output_type == "image"
if not hasattr(first_output, "request_output") or not first_output.request_output:
raise ValueError("No request_output found in OmniRequestOutput")
req_out = first_output.request_output[0]
req_out = first_output.request_output
if not isinstance(req_out, OmniRequestOutput) or not hasattr(req_out, "images"):
raise ValueError("Invalid request_output structure or missing 'images' key")
@@ -51,7 +51,7 @@ def test_video_diffusion_model(model_name: str):
if not hasattr(first_output, "request_output") or not first_output.request_output:
raise ValueError("No request_output found in OmniRequestOutput")
req_out = first_output.request_output[0]
req_out = first_output.request_output
if not isinstance(req_out, OmniRequestOutput) or not hasattr(req_out, "images"):
raise ValueError("Invalid request_output structure or missing 'images' key")
+2 -2
View File
@@ -68,13 +68,13 @@ def test_teacache(model_name: str):
num_outputs_per_prompt=1, # Single output for speed
),
)
# Extract images from request_output[0]['images']
# Extract images from request_output['images']
first_output = outputs[0]
assert first_output.final_output_type == "image"
if not hasattr(first_output, "request_output") or not first_output.request_output:
raise ValueError("No request_output found in OmniRequestOutput")
req_out = first_output.request_output[0]
req_out = first_output.request_output
if not isinstance(req_out, OmniRequestOutput) or not hasattr(req_out, "images"):
raise ValueError("Invalid request_output structure or missing 'images' key")
@@ -72,6 +72,7 @@ def is_nextstep_model(model_name: str) -> bool:
def model_run(model_configs, tp, out_height, out_width, out_frames, using_tile, vae_patch_parallel_size=1):
m = None
try:
parallel_config = DiffusionParallelConfig(
tensor_parallel_size=tp,
@@ -105,7 +106,7 @@ def model_run(model_configs, tp, out_height, out_width, out_frames, using_tile,
)
end = time.perf_counter()
first_output = outputs[0]
req_out = first_output.request_output[0]
req_out = first_output.request_output
frames = req_out.images[0]
if isinstance(frames, torch.Tensor):
frames = frames.detach().cpu().numpy()
@@ -115,7 +116,8 @@ def model_run(model_configs, tp, out_height, out_width, out_frames, using_tile,
cost = (end - start) * 1000
return frames, cost
finally:
m.close()
if m is not None:
m.close()
cleanup_dist_env_and_memory()
@@ -68,7 +68,7 @@ def _extract_single_image(outputs) -> Image.Image:
if not hasattr(first_output, "request_output") or not first_output.request_output:
raise ValueError("No request_output found in OmniRequestOutput")
req_out = first_output.request_output[0]
req_out = first_output.request_output
if not isinstance(req_out, OmniRequestOutput) or not hasattr(req_out, "images"):
raise ValueError("Invalid request_output structure or missing 'images' key")
@@ -17,6 +17,7 @@ import tempfile
from pathlib import Path
import httpx
import numpy as np
import pytest
from tests.conftest import (
@@ -99,6 +100,34 @@ def assert_not_silence(pcm_bytes: bytes):
MIN_AUDIO_BYTES = 10000
MIN_HNR_DB = 1.2 # Clean voice clone > 1.2 dB; distorted < 1.0 dB
def compute_hnr_db(pcm_samples: np.ndarray, sr: int = 24000) -> float:
"""Compute mean Harmonic-to-Noise Ratio (dB) for speech quality.
Clean cloned speech has HNR > 1.2 dB; distorted speech (e.g. lost
ref_code decoder context) drops below 1.0 dB.
"""
frame_len = int(0.03 * sr) # 30ms frames
hop = frame_len // 2
hnr_values: list[float] = []
for start in range(0, len(pcm_samples) - frame_len, hop):
frame = pcm_samples[start : start + frame_len]
if np.max(np.abs(frame)) < 0.01:
continue
ac = np.correlate(frame, frame, mode="full")[len(frame) - 1 :]
ac = ac / (ac[0] + 1e-10)
min_lag = int(sr / 400)
max_lag = min(int(sr / 80), len(ac))
if min_lag >= max_lag:
continue
peak = float(np.max(ac[min_lag:max_lag]))
if 0 < peak < 1:
hnr_values.append(10 * np.log10(peak / (1 - peak + 1e-10)))
return float(np.mean(hnr_values)) if hnr_values else 0.0
class TestQwen3TTSBaseVoiceClone:
@@ -165,3 +194,30 @@ class TestQwen3TTSBaseVoiceClone:
assert response.headers.get("content-type") == "audio/wav"
assert verify_wav_audio(response.content), "Response is not valid WAV"
assert len(response.content) > MIN_AUDIO_BYTES
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=4)
def test_base_voice_clone_no_distortion(self, omni_server) -> None:
"""Audio must not be distorted (regression for ref_code context loss).
When the decoder loses ref_code speaker context on later streaming
chunks, HNR drops below 1.0 dB. Clean voice clone should be > 1.2 dB.
See https://github.com/vllm-project/vllm-omni/issues/1944
"""
response = make_base_speech_request(
host=omni_server.host,
port=omni_server.port,
response_format="pcm",
)
assert response.status_code == 200, f"Request failed: {response.text}"
assert len(response.content) > MIN_AUDIO_BYTES
pcm_samples = np.frombuffer(response.content, dtype=np.int16).astype(np.float32) / 32768.0
hnr = compute_hnr_db(pcm_samples)
print(f"Voice clone HNR: {hnr:.2f} dB (threshold: {MIN_HNR_DB} dB)")
assert hnr >= MIN_HNR_DB, (
f"Audio distortion detected: HNR={hnr:.2f} dB < {MIN_HNR_DB} dB. "
"Voice clone decoder may be losing ref_code speaker context on later chunks."
)
+1 -1
View File
@@ -1,4 +1,4 @@
# stage config for running qwen2.5-omni with architecture of OmniLLM.
# stage config for running qwen2.5-omni for multi-stage omni runtime.
# The following config has been verified on 2x 24GB GPU (L4/RTX3090/RTX4090).
# This config is optimized for CI e2e tests.
@@ -1,4 +1,4 @@
# stage config for running qwen2.5-omni with architecture of OmniLLM.
# stage config for running qwen2.5-omni for multi-stage omni runtime.
# The following config has been verified on 2x 24GB GPU (L4/RTX3090/RTX4090).
# This config is optimized for CI e2e tests.
@@ -1,9 +1,9 @@
# stage config for running qwen2.5-omni with architecture of OmniLLM.
# stage config for running qwen2.5-omni for multi-stage omni runtime.
# The following config is verified with 2 * Intel Arc Pro B60 XPU.
stage_args:
- stage_id: 0
stage_type: llm # Use llm stage type to launch OmniLLM
stage_type: llm # Use llm stage type for AR stages
runtime:
process: true # Run this stage in a separate process
devices: "0" # Visible devices for this stage
@@ -35,7 +35,7 @@ stage_args:
detokenize: True
repetition_penalty: 1.1
- stage_id: 1
stage_type: llm # Use llm stage type to launch OmniLLM
stage_type: llm # Use llm stage type for AR stages
runtime:
process: true
devices: "1"
@@ -67,7 +67,7 @@ stage_args:
stop_token_ids: [8294]
- stage_id: 2
stage_type: llm # Use llm stage type to launch OmniLLM
stage_type: llm # Use llm stage type for AR stages
runtime:
process: true
devices: "2"
@@ -6,7 +6,7 @@
# The following config is verified with 8 * Intel Arc Pro B60 XPU.
stage_args:
- stage_id: 0
stage_type: llm # Use llm stage type to launch OmniLLM
stage_type: llm # Use llm stage type for AR stages
runtime:
devices: "0,1,2,3"
max_batch_size: 1
@@ -41,7 +41,7 @@ stage_args:
repetition_penalty: 1.05
- stage_id: 1
stage_type: llm # Use llm stage type to launch OmniLLM
stage_type: llm # Use llm stage type for AR stages
runtime:
devices: "4"
max_batch_size: 1
@@ -75,7 +75,7 @@ stage_args:
stop_token_ids: [2150]
- stage_id: 2
stage_type: llm # Use llm stage type to launch OmniLLM
stage_type: llm # Use llm stage type for AR stages
runtime:
devices: "5"
max_batch_size: 1
@@ -0,0 +1,63 @@
from unittest.mock import Mock
import pytest
from vllm.sampling_params import SamplingParams
from vllm.v1.engine import EngineCoreRequest
from vllm_omni.engine import OmniEngineCoreRequest
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _make_engine_core_request() -> EngineCoreRequest:
return EngineCoreRequest(
request_id="req-1",
prompt_token_ids=[1, 1, 1],
mm_features=None,
sampling_params=SamplingParams(max_tokens=8),
pooling_params=None,
arrival_time=0.0,
lora_request=None,
cache_salt=None,
data_parallel_rank=None,
)
def test_build_add_request_message_preserves_additional_information():
engine = object.__new__(AsyncOmniEngine)
params = SamplingParams(max_tokens=8)
engine.default_sampling_params_list = [params]
engine.stage_metadata = [{"stage_type": "llm"}]
engine.supported_tasks = ("speech",)
input_processor = Mock()
input_processor.process_inputs.return_value = _make_engine_core_request()
engine.input_processor = input_processor
output_processor = Mock()
engine.output_processors = [output_processor]
prompt = {
"prompt_token_ids": [1, 1, 1],
"additional_information": {
"text": ["hello world"],
"speaker": ["vivian"],
},
}
msg = engine._build_add_request_message(
request_id="req-1",
prompt=prompt,
sampling_params_list=[params],
final_stage_id=0,
arrival_time=0.0,
)
request = msg["prompt"]
assert isinstance(request, OmniEngineCoreRequest)
assert request.external_req_id == "req-1"
assert request.additional_information is not None
assert request.additional_information.entries["text"].list_data == ["hello world"]
assert request.additional_information.entries["speaker"].list_data == ["vivian"]
output_processor.add_request.assert_called_once()
@@ -10,6 +10,7 @@ OpenAI-compatible async text-to-image generation API endpoints in api_server.py.
import base64
import io
from argparse import Namespace
from types import SimpleNamespace
import pytest
from fastapi.testclient import TestClient
@@ -115,7 +116,10 @@ class FakeAsyncOmni:
"""Fake AsyncOmni that yields a single diffusion output."""
def __init__(self):
self.stage_list = ["llm", "diffusion"]
self.stage_configs = [
SimpleNamespace(stage_type="llm"),
SimpleNamespace(stage_type="diffusion"),
]
self.default_sampling_params_list = [SamplingParams(temperature=0.1), OmniDiffusionSamplingParams()]
self.captured_sampling_params_list = None
self.captured_prompt = None
@@ -129,21 +133,25 @@ class FakeAsyncOmni:
@pytest.fixture
def mock_async_diffusion(mocker: MockerFixture):
"""Mock AsyncOmniDiffusion instance that returns fake images"""
mock = mocker.Mock()
mock.is_running = True # For health endpoint
mock.check_health = mocker.AsyncMock() # For LLM mode health check
"""Mock diffusion engine that matches the current async-generator API."""
async def generate(**kwargs):
# Return n PIL images wrapped in result object
n = kwargs["sampling_params_list"][0].num_outputs_per_prompt
mock.captured_sampling_params_list = kwargs["sampling_params_list"]
mock.captured_prompt = kwargs["prompt"]
images = [Image.new("RGB", (64, 64), color="blue") for _ in range(n)]
return MockGenerationResult(images)
class MockAsyncDiffusion:
def __init__(self) -> None:
self.is_running = True
self.check_health = mocker.AsyncMock()
self.captured_sampling_params_list = None
self.captured_prompt = None
self.generate_calls = 0
mock.generate = mocker.AsyncMock(side_effect=generate)
return mock
async def generate(self, **kwargs):
self.generate_calls += 1
n = kwargs["sampling_params_list"][0].num_outputs_per_prompt
self.captured_sampling_params_list = kwargs["sampling_params_list"]
self.captured_prompt = kwargs["prompt"]
images = [Image.new("RGB", (64, 64), color="blue") for _ in range(n)]
yield MockGenerationResult(images)
return MockAsyncDiffusion()
@pytest.fixture
@@ -159,7 +167,7 @@ def test_client(mock_async_diffusion):
# Set up app state with diffusion engine
app.state.engine_client = mock_async_diffusion
app.state.diffusion_engine = mock_async_diffusion # Also set for health endpoint
app.state.stage_configs = [{"stage_type": "diffusion"}]
app.state.stage_configs = [SimpleNamespace(stage_type="diffusion")]
app.state.diffusion_model_name = "Qwen/Qwen-Image" # For models endpoint
app.state.args = Namespace(
default_sampling_params='{"0": {"num_inference_steps":4, "guidance_scale":7.5}}',
@@ -180,7 +188,32 @@ def async_omni_test_client():
app.include_router(router)
app.state.engine_client = FakeAsyncOmni()
app.state.stage_configs = [{"stage_type": "llm"}, {"stage_type": "diffusion"}]
app.state.stage_configs = [
SimpleNamespace(stage_type="llm"),
SimpleNamespace(stage_type="diffusion"),
]
app.state.args = Namespace(
default_sampling_params='{"1": {"num_inference_steps":4, "guidance_scale":7.5}}',
max_generated_image_size=4096, # 64*64
)
return TestClient(app)
@pytest.fixture
def async_omni_stage_configs_only_client():
"""Create test client with refactored AsyncOmni compatibility surface only."""
from fastapi import FastAPI
from vllm_omni.entrypoints.openai.api_server import router
app = FastAPI()
app.include_router(router)
engine = FakeAsyncOmni()
assert not hasattr(engine, "stage_list")
app.state.engine_client = engine
# Intentionally do not populate app.state.stage_configs. Refactored
# AsyncOmni exposes stage_configs on the engine instance.
app.state.args = Namespace(
default_sampling_params='{"1": {"num_inference_steps":4, "guidance_scale":7.5}}',
max_generated_image_size=4096, # 64*64
@@ -291,6 +324,45 @@ def test_generate_images_async_omni_sampling_params(async_omni_test_client):
assert captured[1].seed == 7
def test_generate_images_async_omni_stage_configs_only(async_omni_stage_configs_only_client):
"""Regression: image generation accepts refactored AsyncOmni without stage_list."""
response = async_omni_stage_configs_only_client.post(
"/v1/images/generations",
json={
"prompt": "a castle",
"n": 1,
"size": "256x256",
"seed": 11,
},
)
assert response.status_code == 200
data = response.json()
assert len(data["data"]) == 1
engine = async_omni_stage_configs_only_client.app.state.engine_client
captured = engine.captured_sampling_params_list
assert captured is not None
assert len(captured) == 2
assert captured[1].seed == 11
def test_image_edits_async_omni_stage_configs_only(async_omni_stage_configs_only_client):
"""Regression: image edits accepts refactored AsyncOmni without stage_list."""
img_bytes = make_test_image_bytes((16, 16))
response = async_omni_stage_configs_only_client.post(
"/v1/images/edits",
files=[("image", img_bytes)],
data={
"prompt": "edit me",
"size": "auto",
},
)
assert response.status_code == 200
engine = async_omni_stage_configs_only_client.app.state.engine_client
captured = engine.captured_sampling_params_list
assert captured is not None
assert len(captured) == 2
def test_generate_multiple_images(test_client):
"""Test generating multiple images"""
response = test_client.post(
@@ -538,13 +610,12 @@ def test_parameters_passed_through(test_client, mock_async_diffusion):
)
assert response.status_code == 200
# Ensure generate() was called exactly once
mock_async_diffusion.generate.assert_awaited_once()
call_kwargs = mock_async_diffusion.generate.call_args[1]["sampling_params_list"][0]
assert call_kwargs.num_inference_steps == 100
assert call_kwargs.guidance_scale == 7.5
assert call_kwargs.true_cfg_scale == 3.0
assert call_kwargs.seed == 42
assert mock_async_diffusion.generate_calls == 1
captured = mock_async_diffusion.captured_sampling_params_list[0]
assert captured.num_inference_steps == 100
assert captured.guidance_scale == 7.5
assert captured.true_cfg_scale == 3.0
assert captured.seed == 42
def test_model_field_omitted_works(test_client):
@@ -63,9 +63,9 @@ def mock_engine_client(
default_other_params,
mocker: MockerFixture,
):
"""Create mock engine client with stage_list and default_sampling_params_list."""
"""Create mock engine client with stage_configs and default_sampling_params_list."""
engine_client = mocker.MagicMock()
engine_client.stage_list = [mock_comprehension_stage, mock_other_stage]
engine_client.stage_configs = [mock_comprehension_stage, mock_other_stage]
engine_client.default_sampling_params_list = [
default_comprehension_params,
default_other_params,
@@ -311,7 +311,7 @@ def test_get_comprehension_stage_index_finds_second_stage(mocker: MockerFixture)
comprehension.is_comprehension = True
instance.engine_client = mocker.MagicMock()
instance.engine_client.stage_list = [other, comprehension]
instance.engine_client.stage_configs = [other, comprehension]
assert instance._get_comprehension_stage_index() == 1
@@ -328,7 +328,7 @@ def test_get_comprehension_stage_index_raises_when_not_found(mocker: MockerFixtu
stage2.is_comprehension = False
instance.engine_client = mocker.MagicMock()
instance.engine_client.stage_list = [stage1, stage2]
instance.engine_client.stage_configs = [stage1, stage2]
with pytest.raises(ValueError, match="No comprehension stage"):
instance._get_comprehension_stage_index()
@@ -487,7 +487,7 @@ class TestTTSMethods:
def speech_server(self, mocker: MockerFixture):
mock_engine_client = mocker.MagicMock()
mock_engine_client.errored = False
mock_engine_client.stage_list = None
mock_engine_client.stage_configs = []
mock_engine_client.tts_max_instructions_length = None
mock_models = mocker.MagicMock()
mock_models.is_base_model.return_value = True
@@ -499,7 +499,7 @@ class TestTTSMethods:
def test_is_tts_detection_no_stage(self, speech_server):
"""Test TTS model detection when no TTS stage exists."""
# Fixture creates server with stage_list = None -> _is_tts should be False
# Fixture creates server with stage_configs = [] -> _is_tts should be False
assert speech_server._is_tts is False
assert speech_server._tts_stage is None
@@ -511,9 +511,9 @@ class TestTTSMethods:
# Create a TTS stage
mock_stage = mocker.MagicMock()
mock_stage.model_stage = "qwen3_tts"
mock_stage.engine_args.model_stage = "qwen3_tts"
mock_stage.tts_args = {}
mock_engine_client.stage_list = [mock_stage]
mock_engine_client.stage_configs = [mock_stage]
mock_models = mocker.MagicMock()
mock_models.is_base_model.return_value = True
@@ -614,7 +614,7 @@ class TestTTSMethods:
"""Test _load_supported_speakers."""
mock_engine_client = mocker.MagicMock()
mock_engine_client.errored = False
mock_engine_client.stage_list = None
mock_engine_client.stage_configs = []
# Mock talker_config with mixed-case speaker names
mock_talker_config = mocker.MagicMock()
@@ -757,7 +757,7 @@ class TestTTSMethods:
"""Test CLI override (stored in engine_client) takes highest priority."""
mock_engine_client = mocker.MagicMock()
mock_engine_client.errored = False
mock_engine_client.stage_list = None
mock_engine_client.stage_configs = []
# CLI override is stored in engine_client
mock_engine_client.tts_max_instructions_length = 1000
mock_models = mocker.MagicMock()
@@ -781,9 +781,9 @@ class TestTTSMethods:
# Mock stage with tts_args
mock_stage = mocker.MagicMock()
mock_stage.model_stage = "qwen3_tts"
mock_stage.engine_args.model_stage = "qwen3_tts"
mock_stage.tts_args = {"max_instructions_length": 750}
mock_engine_client.stage_list = [mock_stage]
mock_engine_client.stage_configs = [mock_stage]
server = OmniOpenAIServingSpeech(
engine_client=mock_engine_client,
@@ -804,9 +804,9 @@ class TestTTSMethods:
# Mock stage with tts_args
mock_stage = mocker.MagicMock()
mock_stage.model_stage = "qwen3_tts"
mock_stage.engine_args.model_stage = "qwen3_tts"
mock_stage.tts_args = {"max_instructions_length": 750}
mock_engine_client.stage_list = [mock_stage]
mock_engine_client.stage_configs = [mock_stage]
server = OmniOpenAIServingSpeech(
engine_client=mock_engine_client,
@@ -820,7 +820,7 @@ class TestTTSMethods:
"""Test instructions length validation uses cached _max_instructions_length."""
mock_engine_client = mocker.MagicMock()
mock_engine_client.errored = False
mock_engine_client.stage_list = None
mock_engine_client.stage_configs = []
# CLI override with max length of 10 characters
mock_engine_client.tts_max_instructions_length = 10
mock_models = mocker.MagicMock()
@@ -1034,15 +1034,12 @@ class TestAsyncOmniSupportedTasks:
@pytest.mark.asyncio
async def test_tts_only_no_generate_task(self):
"""TTS-only models (audio output, no text) should not include 'generate'."""
from unittest.mock import MagicMock
from types import SimpleNamespace
from vllm_omni.entrypoints.async_omni import AsyncOmni
omni = AsyncOmni.__new__(AsyncOmni)
omni.output_modalities = [None, "audio"]
stage = MagicMock()
stage.is_comprehension = False
omni.stage_list = [stage]
omni.engine = SimpleNamespace(supported_tasks=("speech",))
tasks = await omni.get_supported_tasks()
assert "generate" not in tasks
assert "speech" in tasks
@@ -1050,15 +1047,12 @@ class TestAsyncOmniSupportedTasks:
@pytest.mark.asyncio
async def test_omni_model_includes_generate(self):
"""Models with text output (e.g. Qwen3-Omni) should include 'generate'."""
from unittest.mock import MagicMock
from types import SimpleNamespace
from vllm_omni.entrypoints.async_omni import AsyncOmni
omni = AsyncOmni.__new__(AsyncOmni)
omni.output_modalities = ["text", None, "audio"]
stage = MagicMock()
stage.is_comprehension = True
omni.stage_list = [stage]
omni.engine = SimpleNamespace(supported_tasks=("generate", "speech"))
tasks = await omni.get_supported_tasks()
assert "generate" in tasks
@@ -44,7 +44,7 @@ class MockVideoResult:
class FakeAsyncOmni:
def __init__(self):
self.stage_list = ["diffusion"]
self.stage_configs = [SimpleNamespace(stage_type="diffusion")]
self.captured_prompt = None
self.captured_sampling_params_list = None
+65
View File
@@ -0,0 +1,65 @@
from types import SimpleNamespace
import pytest
from vllm.entrypoints.openai.models.protocol import BaseModelPath
from vllm.entrypoints.openai.models.serving import OpenAIServingModels
from vllm_omni.entrypoints.async_omni import AsyncOmni
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@pytest.mark.asyncio
async def test_get_supported_tasks_returns_engine_supported_tasks():
omni = object.__new__(AsyncOmni)
omni.engine = SimpleNamespace(supported_tasks=("generate", "speech"))
supported_tasks = await omni.get_supported_tasks()
assert supported_tasks == ("generate", "speech")
def test_model_config_and_vllm_config_forward_from_comprehension_stage():
model_config = SimpleNamespace(model="Qwen/Qwen3-TTS")
vllm_config = SimpleNamespace(model_config=model_config)
renderer = SimpleNamespace(name="renderer")
input_processor = SimpleNamespace(renderer=renderer)
io_processor = SimpleNamespace(name="io-processor")
omni = object.__new__(AsyncOmni)
omni.engine = SimpleNamespace(
stage_clients=[SimpleNamespace(is_comprehension=False), SimpleNamespace(is_comprehension=True)],
stage_vllm_configs=[None, vllm_config],
)
omni.input_processor = input_processor
omni.io_processor = io_processor
assert omni.vllm_config is vllm_config
assert omni.model_config is model_config
assert omni.renderer is renderer
assert omni.input_processor is input_processor
assert omni.io_processor is io_processor
def test_openai_serving_models_can_consume_async_omni_compat_attrs():
model_config = SimpleNamespace(model="Qwen/Qwen3-TTS", max_model_len=32768)
vllm_config = SimpleNamespace(model_config=model_config)
renderer = SimpleNamespace(name="renderer")
input_processor = SimpleNamespace(renderer=renderer)
io_processor = SimpleNamespace(name="io-processor")
omni = object.__new__(AsyncOmni)
omni.engine = SimpleNamespace(
stage_clients=[SimpleNamespace(is_comprehension=True)],
stage_vllm_configs=[vllm_config],
)
omni.input_processor = input_processor
omni.io_processor = io_processor
serving_models = OpenAIServingModels(
engine_client=omni,
base_model_paths=[BaseModelPath(name="tts-model", model_path="Qwen/Qwen3-TTS")],
)
assert serving_models.model_config is model_config
assert serving_models.renderer is renderer
assert serving_models.io_processor is io_processor
assert serving_models.input_processor is input_processor
@@ -3,87 +3,51 @@
import pytest
from vllm_omni.entrypoints import utils as utils_module
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
MODEL = "riverclouds/qwen_image_random"
def test_default_stage_config_includes_cache_backend():
"""Ensure cache knobs survive the default diffusion-stage builder."""
stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg(
{
"cache_backend": "cache_dit",
"cache_config": '{"Fn_compute_blocks": 2}',
"vae_use_slicing": True,
"ulysses_degree": 2,
}
)[0]
engine_args = stage_cfg["engine_args"]
assert stage_cfg["stage_type"] == "diffusion"
assert engine_args["cache_backend"] == "cache_dit"
assert engine_args["cache_config"]["Fn_compute_blocks"] == 2
assert engine_args["vae_use_slicing"] is True
assert engine_args["parallel_config"].ulysses_degree == 2
assert engine_args["model_stage"] == "diffusion"
def _noop_inline_engine(self, model, stage_config, kwargs):
self._inline_diffusion = False
self._inline_engine = None
def test_default_cache_config_used_when_missing():
"""Ensure default cache_config is synthesized when only backend is given."""
stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg(
{
"cache_backend": "cache_dit",
}
)[0]
def test_default_stage_config_includes_cache_backend(monkeypatch):
"""Ensure cache_backend/cache_config are preserved in default diffusion stage."""
monkeypatch.setattr(utils_module, "load_stage_configs_from_model", lambda model, base_engine_args=None: [])
monkeypatch.setattr(utils_module, "resolve_model_config_path", lambda model: None)
monkeypatch.setattr(AsyncOmni, "_start_stages", lambda self, model: None)
monkeypatch.setattr(AsyncOmni, "_wait_for_stages_ready", lambda self, timeout=0: None)
monkeypatch.setattr(AsyncOmni, "_init_inline_diffusion_engine", _noop_inline_engine)
omni = AsyncOmni(
model=MODEL,
cache_backend="cache_dit",
cache_config='{"Fn_compute_blocks": 2}',
vae_use_slicing=True,
ulysses_degree=2,
)
stage_cfg = omni.stage_configs[0]
engine_args = stage_cfg.engine_args
assert engine_args.get("cache_backend") == "cache_dit"
cache_config = engine_args.get("cache_config")
assert cache_config["Fn_compute_blocks"] == 2
assert engine_args.get("vae_use_slicing") is True
parallel_config = engine_args.get("parallel_config")
if hasattr(parallel_config, "get"):
ulysses_degree = parallel_config.get("ulysses_degree")
else:
ulysses_degree = getattr(parallel_config, "ulysses_degree", None)
assert ulysses_degree == 2
def test_default_cache_config_used_when_missing(monkeypatch):
"""Ensure default cache_config is applied when cache_backend is set."""
monkeypatch.setattr(utils_module, "load_stage_configs_from_model", lambda model, base_engine_args=None: [])
monkeypatch.setattr(utils_module, "resolve_model_config_path", lambda model: None)
monkeypatch.setattr(AsyncOmni, "_start_stages", lambda self, model: None)
monkeypatch.setattr(AsyncOmni, "_wait_for_stages_ready", lambda self, timeout=0: None)
monkeypatch.setattr(AsyncOmni, "_init_inline_diffusion_engine", _noop_inline_engine)
omni = AsyncOmni(
model=MODEL,
cache_backend="cache_dit",
)
engine_args = omni.stage_configs[0].engine_args
cache_config = engine_args.get("cache_config")
cache_config = stage_cfg["engine_args"]["cache_config"]
assert cache_config is not None
assert cache_config["Fn_compute_blocks"] == 1
def test_default_stage_devices_from_sequence_parallel(monkeypatch):
"""Ensure devices list reflects sequence parallel size when no parallel_config is provided."""
monkeypatch.setattr(utils_module, "load_stage_configs_from_model", lambda model, base_engine_args=None: [])
monkeypatch.setattr(utils_module, "resolve_model_config_path", lambda model: None)
monkeypatch.setattr(AsyncOmni, "_start_stages", lambda self, model: None)
monkeypatch.setattr(AsyncOmni, "_wait_for_stages_ready", lambda self, timeout=0: None)
monkeypatch.setattr(AsyncOmni, "_init_inline_diffusion_engine", _noop_inline_engine)
def test_default_stage_devices_from_sequence_parallel():
"""Ensure runtime devices reflect computed diffusion world size."""
stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg(
{
"ulysses_degree": 2,
"ring_degree": 2,
}
)[0]
omni = AsyncOmni(
model=MODEL,
ulysses_degree=2,
ring_degree=2,
)
stage_cfg = omni.stage_configs[0]
runtime = stage_cfg.runtime
if hasattr(runtime, "get"):
devices = runtime.get("devices")
else:
devices = getattr(runtime, "devices", None)
assert devices == "0,1,2,3"
assert stage_cfg["runtime"]["devices"] == "0,1,2,3"
File diff suppressed because it is too large. Load diff
@@ -1,63 +0,0 @@
import pytest
from vllm_omni.inputs.preprocess import OmniInputPreprocessor
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _make_preprocessor(monkeypatch):
preprocessor = object.__new__(OmniInputPreprocessor)
monkeypatch.setattr(preprocessor, "_truncate_inputs", lambda tokens, tokenization_kwargs=None: tokens)
monkeypatch.setattr(
preprocessor,
"_process_multimodal",
lambda *args, **kwargs: {"prompt_token_ids": [1, 2, 3]},
)
monkeypatch.setattr(preprocessor, "_tokenize_prompt", lambda prompt_text, tokenization_kwargs=None: [9, 8, 7])
return preprocessor
def test_process_tokens_keeps_additional_information(monkeypatch):
preprocessor = _make_preprocessor(monkeypatch)
parsed = {
"prompt_token_ids": [1, 2, 3],
"prompt_embeds": "embeds",
"additional_information": {"task": ["tts"], "lang": ["auto"]},
}
inputs = OmniInputPreprocessor._process_tokens(preprocessor, parsed)
assert inputs["prompt_token_ids"] == [1, 2, 3]
assert inputs["prompt_embeds"] == "embeds"
assert inputs["additional_information"] == {"task": ["tts"], "lang": ["auto"]}
def test_process_text_keeps_additional_information(monkeypatch):
preprocessor = _make_preprocessor(monkeypatch)
parsed = {
"prompt": "hello",
"prompt_embeds": "embeds",
"additional_information": {"speaker": ["alice"]},
}
inputs = OmniInputPreprocessor._process_text(preprocessor, parsed)
assert inputs["prompt_token_ids"] == [9, 8, 7]
assert inputs["prompt_embeds"] == "embeds"
assert inputs["additional_information"] == {"speaker": ["alice"]}
def test_process_text_multimodal_skips_empty_payloads(monkeypatch):
preprocessor = _make_preprocessor(monkeypatch)
parsed = {
"prompt": "hello",
"multi_modal_data": {"image": "fake"},
"prompt_embeds": None,
"additional_information": None,
}
inputs = OmniInputPreprocessor._process_text(preprocessor, parsed)
assert inputs["prompt_token_ids"] == [1, 2, 3]
assert "prompt_embeds" not in inputs
assert "additional_information" not in inputs
File diff suppressed because it is too large. Load diff
@@ -1,31 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
from vllm_omni.entrypoints.omni_stage import _build_od_config
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_build_od_config_includes_diffusion_fields():
engine_args = {
"cache_backend": "cache_dit",
"cache_config": {"Fn_compute_blocks": 2},
"vae_use_slicing": True,
}
od_config = _build_od_config(engine_args, model="dummy-model")
assert od_config["model"] == "dummy-model"
assert od_config["cache_backend"] == "cache_dit"
assert od_config["cache_config"]["Fn_compute_blocks"] == 2
assert od_config["vae_use_slicing"] is True
def test_build_od_config_respects_explicit_config():
engine_args = {
"od_config": {"cache_backend": "tea_cache"},
"cache_backend": "cache_dit",
}
od_config = _build_od_config(engine_args, model="dummy-model")
assert od_config == {"cache_backend": "tea_cache"}
@@ -227,12 +227,14 @@ def test_first_streaming_chunk_prepends_ref_code_context():
assert len(payload["code_predictor_codes"]) == _Q * 12
def test_ref_code_context_only_applies_to_first_streaming_chunk():
def test_ref_code_context_applies_to_all_streaming_chunks():
"""ref_code is prepended as decoder context on every chunk, not just the first."""
tm = _tm()
rid = "r-ref2"
tm.code_prompt_token_ids[rid] = [_FRAME[:] for _ in range(20)]
tm.put_req_chunk[rid] = 1
ref_code = torch.tensor([[9, 9, 9, 9], [8, 8, 8, 8]], dtype=torch.long)
tm.request_payload[rid] = ref_code
payload = talker2code2wav_async_chunk(
transfer_manager=tm,
@@ -242,8 +244,9 @@ def test_ref_code_context_only_applies_to_first_streaming_chunk():
)
assert payload is not None
assert payload["left_context_size"] == 10
assert len(payload["code_predictor_codes"]) == _Q * 20
# ref_code (2 frames) prepended as left context on second chunk too
assert payload["left_context_size"] == 10 + 2
assert len(payload["code_predictor_codes"]) == _Q * (20 + 2)
def test_ref_code_context_can_be_buffered_before_first_emit():
@@ -276,9 +279,10 @@ def test_ref_code_context_can_be_buffered_before_first_emit():
)
assert payload is not None
# ref_code (2 frames) is kept (not popped) for subsequent chunks
assert payload["left_context_size"] == 2
assert len(payload["code_predictor_codes"]) == _Q * 12
assert rid not in tm.request_payload
assert rid in tm.request_payload
def test_non_async_processor_prepends_ref_code_and_sets_trim_context():
@@ -317,3 +321,27 @@ def test_non_async_processor_prepends_ref_code_and_sets_trim_context():
4,
8,
]
def test_non_async_processor_filters_out_of_range_codec_values():
"""Frames with values >= codebook_size (e.g. stop_token_id=2150) are filtered."""
ref_code = torch.tensor([[9, 9, 9, 9]], dtype=torch.long)
audio_codes = torch.tensor(
[
[0, 0, 0, 0], # zero-padded (filtered)
[1, 2, 3, 4], # valid
[2150, 0, 0, 0], # stop token (filtered)
[5, 6, 7, 8], # valid
],
dtype=torch.long,
)
output = SimpleNamespace(multimodal_output={"audio_codes": audio_codes, "ref_code": ref_code})
stage = SimpleNamespace(engine_outputs=[SimpleNamespace(outputs=[output])])
prompts = talker2code2wav(stage_list=[stage], engine_input_source=[0])
assert len(prompts) == 1
prompt = prompts[0]
# Only ref_code (1 frame) + 2 valid frames = 3 frames * 4 quantizers = 12 codes
assert len(prompt["prompt_token_ids"]) == 4 * 3
assert prompt["additional_information"] == {"left_context_size": [1]}
+12 -6
View File
@@ -50,11 +50,14 @@ def create_unique_server_params(configs: list[dict[str, Any]]) -> list[tuple[str
for config in configs:
test_name = config["test_name"]
model = config["server_params"]["model"]
stage_config_name = config["server_params"]["stage_config_name"]
stage_config_path = str(Path(__file__).parent.parent / "stage_configs" / stage_config_name)
delete = config["server_params"].get("delete", None)
update = config["server_params"].get("update", None)
stage_config_path = modify_stage(stage_config_path, update, delete)
stage_config_name = config["server_params"].get("stage_config_name")
if stage_config_name:
stage_config_path = str(Path(__file__).parent.parent / "stage_configs" / stage_config_name)
delete = config["server_params"].get("delete", None)
update = config["server_params"].get("update", None)
stage_config_path = modify_stage(stage_config_path, update, delete)
else:
stage_config_path = None
server_param = (test_name, model, stage_config_path)
if server_param not in seen:
@@ -98,7 +101,10 @@ def omni_server(request):
print(f"Starting OmniServer with test: {test_name}, model: {model}")
with OmniServer(model, ["--stage-configs-path", stage_config_path, "--stage-init-timeout", "120"]) as server:
server_args = ["--stage-init-timeout", "120"]
if stage_config_path:
server_args = ["--stage-configs-path", stage_config_path] + server_args
with OmniServer(model, server_args) as server:
server.test_name = test_name
print("OmniServer started successfully")
yield server
+3 -3
View File
@@ -7,7 +7,7 @@
async_chunk: false
stage_args:
- stage_id: 0
stage_type: llm # Use llm stage type to launch OmniLLM
stage_type: llm # Use llm stage type for AR stages
runtime:
devices: "0"
max_batch_size: 64
@@ -38,7 +38,7 @@ stage_args:
repetition_penalty: 1.05
- stage_id: 1
stage_type: llm # Use llm stage type to launch OmniLLM
stage_type: llm # Use llm stage type for AR stages
runtime:
devices: "1"
max_batch_size: 64
@@ -69,7 +69,7 @@ stage_args:
stop_token_ids: [2150]
- stage_id: 2
stage_type: llm # Use llm stage type to launch OmniLLM
stage_type: llm # Use llm stage type for AR stages
runtime:
devices: "1"
max_batch_size: 64
+1 -4
View File
@@ -7,14 +7,13 @@ async_chunk: true
stage_args:
- stage_id: 0
stage_type: llm
is_comprehension: true
runtime:
devices: "0"
max_batch_size: 4
engine_args:
model_stage: qwen3_tts
model_arch: Qwen3TTSTalkerForConditionalGeneration
hf_overrides:
architectures: [Qwen3TTSTalkerForConditionalGeneration]
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
enforce_eager: false
@@ -46,8 +45,6 @@ stage_args:
engine_args:
model_stage: code2wav
model_arch: Qwen3TTSCode2Wav
hf_overrides:
architectures: [Qwen3TTSCode2Wav]
worker_type: generation
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
enforce_eager: true
+1 -2
View File
@@ -152,8 +152,7 @@
{
"test_name": "test_qwen3_tts",
"server_params": {
"model": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
"stage_config_name": "qwen3_tts.yaml"
"model": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
},
"benchmark_params": [
{
+353 -42
View File
@@ -22,6 +22,49 @@ LOGGER = logging.getLogger(__name__)
GREY_BLOCK_FILL = PatternFill(start_color="D3D3D3", fill_type="solid")
# Diffusion sheet columns (Qwen-Image diffusion benchmark).
DIFFUSION_BENCHMARK_COLUMNS: tuple[str, ...] = (
"duration",
"completed_requests",
"failed_requests",
"throughput_qps",
"latency_mean",
"latency_median",
"latency_p50",
"latency_p99",
"peak_memory_mb_max",
"peak_memory_mb_mean",
"peak_memory_mb_median",
"slo_attainment_rate",
)
DIFFUSION_NUMERIC_FORMAT_COLUMNS: tuple[str, ...] = DIFFUSION_BENCHMARK_COLUMNS
DIFFUSION_SUMMARY_COLUMNS: tuple[str, ...] = (
"date",
"test_name",
"model",
"backend",
"dataset",
"task",
"completed_requests",
"failed_requests",
"duration",
"throughput_qps",
"latency_mean",
"latency_median",
"latency_p50",
"latency_p99",
"peak_memory_mb_max",
"peak_memory_mb_mean",
"peak_memory_mb_median",
"slo_attainment_rate",
"commit_sha",
"build_id",
"build_url",
"source_file",
)
# Benchmark metric columns: grey the latest row's cell when value changed vs previous date.
BENCHMARK_COLUMNS: tuple[str, ...] = (
"num_prompts",
@@ -53,10 +96,29 @@ BENCHMARK_COLUMNS: tuple[str, ...] = (
NUMERIC_FORMAT_COLUMNS: tuple[str, ...] = tuple(
c for c in BENCHMARK_COLUMNS if c not in ("request_rate", "max_concurrency")
)
DATASET_NAME_ALLOWED = ("random", "random-mm")
_COLUMNS_FILENAME = "nightly_perf_summary_columns.txt"
_RESULT_JSON_PREFIX = "result_test_"
_DIFFUSION_JSON_PREFIX = "diffusion_perf_"
DEFAULT_INPUT_DIR = os.getenv("DEFAULT_INPUT_DIR") if os.getenv("DEFAULT_INPUT_DIR") else "tests"
DEFAULT_OUTPUT_DIR = os.getenv("DEFAULT_OUTPUT_DIR") if os.getenv("DEFAULT_OUTPUT_DIR") else "tests"
DEFAULT_DIFFUSION_INPUT_DIR = os.getenv("DIFFUSION_BENCHMARK_DIR")
# Read omni/diffusion benchmarks from DEFAULT_INPUT_DIR, if DEFAULT_DIFFUSION_INPUT_DIR is not set.
def _omni_group_key(record: dict[str, Any]) -> tuple[Any, ...]:
return (
record.get("model_id") or "",
record.get("test_name") or "",
record.get("dataset_name") or "",
record.get("max_concurrency") if record.get("max_concurrency") is not None else 0,
record.get("num_prompts") if record.get("num_prompts") is not None else 0,
)
def _diffusion_group_key(record: dict[str, Any]) -> tuple[Any, ...]:
return (record.get("test_name") or "",)
def _load_summary_columns(script_dir: str) -> list[str]:
@@ -68,6 +130,8 @@ def _load_summary_columns(script_dir: str) -> list[str]:
"backend",
"model_id",
"tokenizer_id",
"test_name",
"dataset_name",
"num_prompts",
"request_rate",
"burstiness",
@@ -106,6 +170,33 @@ def _load_summary_columns(script_dir: str) -> list[str]:
return columns if columns else default
def _ensure_omni_summary_columns(summary_columns: list[str]) -> list[str]:
"""Ensure omni summary contains required columns, even when a custom columns file exists."""
required = ("test_name", "dataset_name", "source_file")
existing = set(summary_columns)
if all(c in existing for c in required):
return summary_columns
out = list(summary_columns)
insert_after = "tokenizer_id"
if "test_name" not in existing:
if insert_after in existing:
idx = out.index(insert_after) + 1
out.insert(idx, "test_name")
else:
out.append("test_name")
existing = set(out)
if "dataset_name" not in existing:
if "test_name" in out:
idx = out.index("test_name") + 1
out.insert(idx, "dataset_name")
else:
out.append("dataset_name")
if "source_file" not in set(out):
out.append("source_file")
return out
def _vllm_omni_root() -> str:
"""Resolve vllm-omni repo root: directory that contains a 'tests' subdir (and usually 'tools')."""
path = os.path.dirname(os.path.abspath(__file__))
@@ -122,6 +213,11 @@ def _default_input_dir() -> str:
return os.path.join(root, DEFAULT_INPUT_DIR)
def _default_diffusion_input_dir(input_dir: str) -> str:
"""Default diffusion input dir: DIFFUSION_BENCHMARK_DIR if set, else fall back to omni input dir."""
return DEFAULT_DIFFUSION_INPUT_DIR if DEFAULT_DIFFUSION_INPUT_DIR else input_dir
def _default_output_file() -> str:
"""Default: vllm-omni root / DEFAULT_OUTPUT_DIR / nightly_perf_<timestamp>.xlsx."""
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
@@ -139,12 +235,23 @@ def parse_args() -> argparse.Namespace:
default=_default_input_dir(),
help="Directory containing performance JSON files; default is <vllm-omni-root>/DEFAULT_INPUT_DIR.",
)
parser.add_argument(
"--diffusion-input-dir",
type=str,
default=None,
help=(
"Directory containing diffusion_perf_*.json files; default is "
"DIFFUSION_BENCHMARK_DIR, fallback to --input-dir."
),
)
parser.add_argument(
"--output-file",
type=str,
default=_default_output_file(),
help="Output path of the Excel report; \
default is <vllm-omni-root>/DEFAULT_OUTPUT_DIR/nightly_perf_<timestamp>.xlsx.",
help=(
"Output path of the Excel report; default is "
"<vllm-omni-root>/DEFAULT_OUTPUT_DIR/nightly_perf_<timestamp>.xlsx."
),
)
parser.add_argument(
"--commit-sha",
@@ -183,11 +290,54 @@ def _load_json_file(path: str) -> dict[str, Any] | None:
return data
def _iter_json_records(input_dir: str) -> Iterable[dict[str, Any]]:
"""Iterate over JSON files in the input directory and yield normalized records.
commit_sha/build_id/build_url are not set here; they are applied later only to
rows with the latest date (see _apply_build_metadata_to_latest_only).
def _parse_from_filename(filename: str) -> dict[str, Any]:
"""Parse test-related metadata from a result JSON filename.
Expected pattern (after prefix/suffix stripped):
<test_name>_<dataset_name>_<max_concurrency>_<num_prompts>_<timestamp>
"""
name, ext = os.path.splitext(filename)
if ext != ".json" or not name.startswith(_RESULT_JSON_PREFIX):
return {}
core = name[len(_RESULT_JSON_PREFIX) :]
parts = core.split("_")
if len(parts) < 5:
LOGGER.warning("filename '%s' does not match expected pattern, skip parsing test metadata", filename)
return {}
timestamp = parts[-1]
num_prompts_str = parts[-2]
max_concurrency_str = parts[-3]
dataset_name = parts[-4]
test_name = "_".join(parts[:-4]) if parts[:-4] else ""
parsed: dict[str, Any] = {}
if len(timestamp) >= 15:
parsed["date"] = timestamp
if dataset_name in DATASET_NAME_ALLOWED:
parsed["dataset_name"] = dataset_name
try:
parsed["num_prompts"] = int(num_prompts_str)
except (TypeError, ValueError):
pass
try:
parsed["max_concurrency"] = int(max_concurrency_str)
except (TypeError, ValueError):
pass
if test_name:
parsed["test_name"] = test_name
return parsed
def _iter_omni_json_records(input_dir: str) -> Iterable[dict[str, Any]]:
"""Iterate over result_test_*.json files and yield normalized omni records."""
if not os.path.isdir(input_dir):
LOGGER.warning("input dir '%s' does not exist or is not a directory", input_dir)
return
@@ -195,6 +345,8 @@ def _iter_json_records(input_dir: str) -> Iterable[dict[str, Any]]:
for entry in sorted(os.listdir(input_dir)):
if not entry.endswith(".json"):
continue
if not entry.startswith(_RESULT_JSON_PREFIX):
continue
full_path = os.path.join(input_dir, entry)
if not os.path.isfile(full_path):
continue
@@ -204,17 +356,89 @@ def _iter_json_records(input_dir: str) -> Iterable[dict[str, Any]]:
continue
record: dict[str, Any] = dict(data)
record.setdefault("date", datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S"))
filename_meta = _parse_from_filename(os.path.basename(full_path))
if "date" not in record or not record["date"]:
if "date" in filename_meta:
record["date"] = filename_meta["date"]
else:
record["date"] = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
if "num_prompts" not in record or record["num_prompts"] is None:
if "num_prompts" in filename_meta:
record["num_prompts"] = filename_meta["num_prompts"]
if "max_concurrency" not in record or record["max_concurrency"] is None:
if "max_concurrency" in filename_meta:
record["max_concurrency"] = filename_meta["max_concurrency"]
if "test_name" not in record or not record.get("test_name"):
if "test_name" in filename_meta:
record["test_name"] = filename_meta["test_name"]
if "dataset_name" not in record or not record.get("dataset_name"):
if "dataset_name" in filename_meta:
record["dataset_name"] = filename_meta["dataset_name"]
record["source_file"] = os.path.basename(full_path)
yield record
def _parse_diffusion_from_filename(filename: str) -> dict[str, Any]:
"""Parse diffusion test_name/date from filename: diffusion_perf_<test_name>_<YYYYMMDD-HHMMSS>.json"""
name, ext = os.path.splitext(filename)
if ext != ".json" or not name.startswith(_DIFFUSION_JSON_PREFIX):
return {}
core = name[len(_DIFFUSION_JSON_PREFIX) :]
parts = core.split("_")
if len(parts) < 2:
return {}
timestamp = parts[-1]
test_name = "_".join(parts[:-1]) if parts[:-1] else ""
parsed: dict[str, Any] = {}
if len(timestamp) >= 15:
parsed["date"] = timestamp
if test_name:
parsed["test_name"] = test_name
return parsed
def _iter_diffusion_json_records(input_dir: str) -> Iterable[dict[str, Any]]:
"""Iterate over diffusion_perf_*.json files and yield normalized diffusion records."""
if not os.path.isdir(input_dir):
LOGGER.warning("diffusion input dir '%s' does not exist or is not a directory", input_dir)
return
for entry in sorted(os.listdir(input_dir)):
if not entry.endswith(".json"):
continue
if not entry.startswith(_DIFFUSION_JSON_PREFIX):
continue
full_path = os.path.join(input_dir, entry)
if not os.path.isfile(full_path):
continue
data = _load_json_file(full_path)
if data is None:
continue
record: dict[str, Any] = dict(data)
filename_meta = _parse_diffusion_from_filename(os.path.basename(full_path))
if "date" not in record or not record.get("date"):
record["date"] = filename_meta.get("date") or datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
if "test_name" not in record or not record.get("test_name"):
if "test_name" in filename_meta:
record["test_name"] = filename_meta["test_name"]
record["source_file"] = os.path.basename(full_path)
yield record
def _collect_records(input_dir: str) -> list[dict[str, Any]]:
"""Collect all JSON records into a list."""
records: list[dict[str, Any]] = []
for record in _iter_json_records(input_dir):
records.append(record)
return records
return list(_iter_omni_json_records(input_dir))
def _collect_diffusion_records(diffusion_input_dir: str) -> list[dict[str, Any]]:
return list(_iter_diffusion_json_records(diffusion_input_dir))
def _apply_build_metadata_to_latest_only(
@@ -241,9 +465,17 @@ def _apply_build_metadata_to_latest_only(
def _sort_records_for_summary(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Sort so that same model_id is grouped, newest date first within each group."""
"""Sort so that same test configuration is grouped, newest date first within each group."""
by_date_desc = sorted(records, key=lambda r: (r.get("date") or ""), reverse=True)
return sorted(by_date_desc, key=lambda r: (r.get("model_id") or ""))
return sorted(
by_date_desc,
key=_omni_group_key,
)
def _sort_diffusion_records_for_summary(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
by_date_desc = sorted(records, key=lambda r: (r.get("date") or ""), reverse=True)
return sorted(by_date_desc, key=_diffusion_group_key)
def _values_differ(a: Any, b: Any) -> bool:
@@ -261,21 +493,22 @@ def _values_differ(a: Any, b: Any) -> bool:
return a != b
def _apply_benchmark_change_highlight(
def _apply_change_highlight(
ws,
summary_columns: Sequence[str],
columns: Sequence[str],
records: Sequence[dict[str, Any]],
benchmark_columns: Sequence[str],
group_key_fn,
) -> None:
"""Grey cells in the latest row of each model when a benchmark metric changed vs previous date."""
"""Grey cells in the latest row of each group when a metric changed vs previous row in the same group."""
if not records:
return
col_to_index = {c: i + 1 for i, c in enumerate(summary_columns)}
# Walk by model_id blocks (records already sorted by model_id, date desc).
col_to_index = {c: i + 1 for i, c in enumerate(columns)}
i = 0
while i < len(records):
model_id = records[i].get("model_id")
group_key = group_key_fn(records[i])
block_start = i
while i < len(records) and records[i].get("model_id") == model_id:
while i < len(records) and group_key_fn(records[i]) == group_key:
i += 1
block_end = i
newest_idx = block_start
@@ -283,7 +516,7 @@ def _apply_benchmark_change_highlight(
if prev_idx is None:
continue
excel_row = newest_idx + 2
for col in BENCHMARK_COLUMNS:
for col in benchmark_columns:
if col not in col_to_index:
continue
cur_val = records[newest_idx].get(col)
@@ -343,18 +576,20 @@ def _write_sheet(
ws.append(row_values)
def _format_benchmark_columns(
def _format_numeric_columns(
ws,
columns: Sequence[str],
numeric_columns: Sequence[str],
num_data_rows: int,
width: int,
) -> None:
"""Set number format and column width for numeric benchmark columns so values display (no ####)."""
numeric_set = set(NUMERIC_FORMAT_COLUMNS)
"""Set number format and column width for numeric columns so values display (no ####)."""
numeric_set = set(numeric_columns)
for c, col_name in enumerate(columns):
if col_name not in numeric_set:
continue
col_letter = get_column_letter(c + 1)
ws.column_dimensions[col_letter].width = 14
ws.column_dimensions[col_letter].width = width
for r in range(2, 2 + num_data_rows):
cell = ws.cell(row=r, column=c + 1)
if cell.value is not None and isinstance(cell.value, (int, float)):
@@ -367,6 +602,35 @@ def _format_benchmark_columns(
pass
def _set_column_width(ws, columns: Sequence[str], col_name: str, width: int) -> None:
"""Set a single column width by column name if present."""
try:
idx = list(columns).index(col_name)
except ValueError:
return
ws.column_dimensions[get_column_letter(idx + 1)].width = width
def _apply_column_widths(ws, columns: Sequence[str], widths: dict[str, int]) -> None:
for name, width in widths.items():
_set_column_width(ws, columns, name, width)
_OMNI_SUMMARY_WIDTHS = {
"endpoint_type": 10,
"backend": 10,
"model_id": 20,
"tokenizer_id": 20,
"test_name": 20,
"dataset_name": 10,
}
_DIFFUSION_SUMMARY_WIDTHS = {
"test_name": 30,
"model": 15,
}
def _ensure_parent_dir(path: str) -> None:
"""Ensure that the parent directory of the output file exists."""
parent = os.path.dirname(os.path.abspath(path))
@@ -377,34 +641,79 @@ def _ensure_parent_dir(path: str) -> None:
def generate_excel_report(
input_dir: str,
diffusion_input_dir: str,
output_file: str,
commit_sha: str | None,
build_id: str | None,
build_url: str | None,
) -> None:
"""Main logic: load JSON records and generate an Excel report."""
"""Main logic: load JSON records and generate an Excel report with multiple sheets."""
script_dir = os.path.dirname(os.path.abspath(__file__))
summary_columns = _load_summary_columns(script_dir)
omni_summary_columns = _ensure_omni_summary_columns(_load_summary_columns(script_dir))
records = _collect_records(input_dir)
if not records:
LOGGER.warning("no valid json records found under '%s'", input_dir)
omni_records = _collect_records(input_dir)
diffusion_records = _collect_diffusion_records(diffusion_input_dir)
sorted_records = _sort_records_for_summary(records)
_apply_build_metadata_to_latest_only(sorted_records, commit_sha, build_id, build_url)
if not omni_records:
LOGGER.warning("no valid omni json records found under '%s'", input_dir)
if not diffusion_records:
LOGGER.warning("no valid diffusion json records found under '%s'", diffusion_input_dir)
omni_sorted = _sort_records_for_summary(omni_records)
diffusion_sorted = _sort_diffusion_records_for_summary(diffusion_records)
_apply_build_metadata_to_latest_only(omni_sorted, commit_sha, build_id, build_url)
_apply_build_metadata_to_latest_only(diffusion_sorted, commit_sha, build_id, build_url)
wb = Workbook()
ws_summary = wb.active
ws_summary.title = "summary"
ws_omni_summary = wb.active
ws_omni_summary.title = "omni_summary"
_write_sheet(ws_omni_summary, omni_summary_columns, omni_sorted, numeric_columns=NUMERIC_FORMAT_COLUMNS)
_format_numeric_columns(
ws_omni_summary,
omni_summary_columns,
NUMERIC_FORMAT_COLUMNS,
len(omni_sorted),
width=14,
)
_apply_column_widths(ws_omni_summary, omni_summary_columns, _OMNI_SUMMARY_WIDTHS)
_apply_change_highlight(
ws_omni_summary,
omni_summary_columns,
omni_sorted,
BENCHMARK_COLUMNS,
_omni_group_key,
)
_write_sheet(ws_summary, summary_columns, sorted_records, numeric_columns=NUMERIC_FORMAT_COLUMNS)
_format_benchmark_columns(ws_summary, summary_columns, len(sorted_records))
_apply_benchmark_change_highlight(ws_summary, summary_columns, sorted_records)
ws_diff_summary = wb.create_sheet(title="diffusion_summary")
_write_sheet(
ws_diff_summary, DIFFUSION_SUMMARY_COLUMNS, diffusion_sorted, numeric_columns=DIFFUSION_NUMERIC_FORMAT_COLUMNS
)
_format_numeric_columns(
ws_diff_summary,
DIFFUSION_SUMMARY_COLUMNS,
DIFFUSION_NUMERIC_FORMAT_COLUMNS,
len(diffusion_sorted),
width=16,
)
_apply_column_widths(ws_diff_summary, DIFFUSION_SUMMARY_COLUMNS, _DIFFUSION_SUMMARY_WIDTHS)
_apply_change_highlight(
ws_diff_summary,
DIFFUSION_SUMMARY_COLUMNS,
diffusion_sorted,
DIFFUSION_BENCHMARK_COLUMNS,
_diffusion_group_key,
)
if sorted_records:
raw_columns = _build_raw_columns(sorted_records, summary_columns)
ws_raw = wb.create_sheet(title="raw")
_write_sheet(ws_raw, raw_columns, sorted_records)
if omni_sorted:
omni_raw_columns = _build_raw_columns(omni_sorted, omni_summary_columns)
ws_omni_raw = wb.create_sheet(title="omni_raw")
_write_sheet(ws_omni_raw, omni_raw_columns, omni_sorted)
if diffusion_sorted:
diffusion_raw_columns = _build_raw_columns(diffusion_sorted, DIFFUSION_SUMMARY_COLUMNS)
ws_diff_raw = wb.create_sheet(title="diffusion_raw")
_write_sheet(ws_diff_raw, diffusion_raw_columns, diffusion_sorted)
_ensure_parent_dir(output_file)
wb.save(output_file)
@@ -423,9 +732,11 @@ def main() -> None:
commit_sha = args.commit_sha or os.getenv("BUILDKITE_COMMIT")
build_id = args.build_id or os.getenv("BUILDKITE_BUILD_ID")
build_url = args.build_url or os.getenv("BUILDKITE_BUILD_URL")
diffusion_input_dir = args.diffusion_input_dir or _default_diffusion_input_dir(args.input_dir)
generate_excel_report(
input_dir=args.input_dir,
diffusion_input_dir=diffusion_input_dir,
output_file=args.output_file,
commit_sha=commit_sha,
build_id=build_id,
File diff suppressed because it is too large. Load diff
-1
View File
@@ -16,7 +16,6 @@ import regex as re
# alternatives like msgpack or pydantic that are already in use in vLLM. Only
# add to this list if absolutely necessary and after careful security review.
ALLOWED_FILES = {
"vllm_omni/entrypoints/omni_llm.py",
"tests/e2e/offline_inference/utils.py",
"tests/utils.py",
"vllm_omni/diffusion/distributed/group_coordinator.py",
+1 -4
View File
@@ -24,10 +24,7 @@ except ModuleNotFoundError as exc: # pragma: no cover - optional dependency
from vllm_omni.transformers_utils import configs as _configs # noqa: F401, E402
from .config import OmniModelConfig
from .entrypoints.async_omni import AsyncOmni
# Main entry points
from .entrypoints.omni import Omni
from .entrypoints import AsyncOmni, Omni
from .version import __version__, __version_tuple__ # isort:skip
+115 -21
View File
@@ -49,10 +49,11 @@ class StageType(str, Enum):
@dataclass
class StageConfig:
"""Per-stage configuration — pipeline-structure fields only.
"""Per-stage configuration from pipeline YAML.
Engine params (gpu_memory_utilization, tp_size, etc.) come from CLI,
NOT from this class.
Topology fields (stage_id, input_sources, etc.) define the DAG.
Engine and runtime defaults come from the YAML; CLI overrides take
precedence via ``runtime_overrides``.
"""
# Identity
@@ -71,6 +72,14 @@ class StageConfig:
hf_config_name: str | None = None
is_comprehension: bool = False
# Per-stage engine args from pipeline YAML (defaults)
yaml_engine_args: dict[str, Any] = field(default_factory=dict)
# Per-stage runtime config from pipeline YAML (devices, max_batch_size)
yaml_runtime: dict[str, Any] = field(default_factory=dict)
# Pass-through fields from pipeline YAML (default_sampling_params,
# output_connectors, input_connectors, tts_args, etc.)
yaml_extras: dict[str, Any] = field(default_factory=dict)
# Runtime overrides (populated from CLI, not from pipeline YAML)
runtime_overrides: dict[str, Any] = field(default_factory=dict)
@@ -80,11 +89,11 @@ class StageConfig:
Returns:
OmegaConf DictConfig with stage configuration in legacy format.
"""
# Build engine_args dict with required fields
engine_args: dict[str, Any] = {
"model_stage": self.model_stage,
}
# Start with YAML engine_args defaults
engine_args: dict[str, Any] = dict(self.yaml_engine_args)
# Overlay topology-level fields
engine_args["model_stage"] = self.model_stage
if self.worker_type:
engine_args["worker_type"] = self.worker_type
if self.scheduler_cls:
@@ -92,19 +101,22 @@ class StageConfig:
if self.hf_config_name:
engine_args["hf_config_name"] = self.hf_config_name
# Apply runtime overrides (CLI args)
# CLI overrides take precedence over YAML defaults
for key, value in self.runtime_overrides.items():
if key not in ("devices", "max_batch_size"):
engine_args[key] = value
# Build runtime config
runtime: dict[str, Any] = {
"process": True,
"max_batch_size": self.runtime_overrides.get("max_batch_size", 1),
}
# Build runtime config from YAML defaults + CLI overrides
runtime: dict[str, Any] = dict(self.yaml_runtime)
runtime.setdefault("process", True)
runtime.setdefault("max_batch_size", self.runtime_overrides.get("max_batch_size", 1))
if "devices" in self.runtime_overrides:
runtime["devices"] = self.runtime_overrides["devices"]
# Inject max_num_seqs from max_batch_size (legacy compat)
max_batch_size = int(runtime.get("max_batch_size", 1) or 1)
engine_args.setdefault("max_num_seqs", max_batch_size)
# Build full config dict
config_dict: dict[str, Any] = {
"stage_id": self.stage_id,
@@ -120,6 +132,10 @@ class StageConfig:
if self.custom_process_input_func:
config_dict["custom_process_input_func"] = self.custom_process_input_func
# Pass through extra YAML fields (default_sampling_params,
# output_connectors, input_connectors, tts_args, etc.)
config_dict.update(self.yaml_extras)
return create_config(config_dict)
@@ -249,6 +265,15 @@ class StageConfigFactory:
if errors:
logger.warning(f"Pipeline validation warnings for {model}: {errors}")
# Inject pipeline-wide async_chunk into ALL stages' engine_args.
# The legacy loader (load_stage_configs_from_yaml) sets async_chunk
# on every stage so that build_engine_args_dict() can inject the
# stage_connector_spec. AsyncOmniEngine.__init__ also reads it
# from stage_configs[0].engine_args.async_chunk.
if pipeline.async_chunk:
for stage in pipeline.stages:
stage.yaml_engine_args.setdefault("async_chunk", True)
# Apply CLI overrides
result: list[StageConfig] = []
for stage in pipeline.stages:
@@ -348,6 +373,25 @@ class StageConfigFactory:
return cls._parse_pipeline_yaml(pipeline_path, model_type)
# Keys consumed as explicit StageConfig fields — everything else is
# passed through via yaml_extras.
_KNOWN_STAGE_KEYS: set[str] = {
"stage_id",
"model_stage",
"stage_type",
"input_sources",
"engine_input_source",
"custom_process_input_func",
"final_output",
"final_output_type",
"worker_type",
"scheduler_cls",
"hf_config_name",
"is_comprehension",
"engine_args",
"runtime",
}
@classmethod
def _parse_pipeline_yaml(cls, path: Path, model_type: str) -> ModelPipeline:
"""Parse a pipeline YAML file.
@@ -375,27 +419,65 @@ class StageConfigFactory:
input_sources = []
input_sources = list(input_sources)
# Extract per-stage engine_args and runtime dicts
raw_ea = stage_data.get("engine_args", None)
yaml_engine_args = to_dict(raw_ea) if raw_ea is not None else {}
raw_rt = stage_data.get("runtime", None)
yaml_runtime = to_dict(raw_rt) if raw_rt is not None else {}
# Topology-level fields that also live inside engine_args in legacy
# YAMLs (worker_type, scheduler_cls, etc.) — read from both places.
worker_type = stage_data.get("worker_type", None) or yaml_engine_args.pop("worker_type", None)
scheduler_cls = stage_data.get("scheduler_cls", None) or yaml_engine_args.pop("scheduler_cls", None)
hf_config_name = stage_data.get("hf_config_name", None) or yaml_engine_args.pop("hf_config_name", None)
model_stage = getattr(stage_data, "model_stage", None) or yaml_engine_args.pop("model_stage", None)
# Collect pass-through fields (default_sampling_params,
# output_connectors, input_connectors, tts_args, etc.)
yaml_extras: dict[str, Any] = {}
for key in stage_data:
if key not in cls._KNOWN_STAGE_KEYS:
val = stage_data[key]
try:
yaml_extras[key] = to_dict(val)
except ValueError:
yaml_extras[key] = val
stage = StageConfig(
stage_id=stage_data.stage_id,
model_stage=stage_data.model_stage,
model_stage=model_stage or "",
stage_type=stage_type,
input_sources=input_sources,
custom_process_input_func=stage_data.get("custom_process_input_func", None),
final_output=stage_data.get("final_output", False),
final_output_type=stage_data.get("final_output_type", None),
worker_type=stage_data.get("worker_type", None),
scheduler_cls=stage_data.get("scheduler_cls", None),
hf_config_name=stage_data.get("hf_config_name", None),
worker_type=worker_type,
scheduler_cls=scheduler_cls,
hf_config_name=hf_config_name,
is_comprehension=stage_data.get("is_comprehension", False),
yaml_engine_args=yaml_engine_args,
yaml_runtime=yaml_runtime,
yaml_extras=yaml_extras,
)
stages.append(stage)
# Get pipeline-wide flags
async_chunk = config_data.get("async_chunk", False)
# Get optional connector config
connectors = to_dict(config_data.connectors) if hasattr(config_data, "connectors") else None
edges = to_dict(config_data.edges) if hasattr(config_data, "edges") else None
# Get optional connector config — check both top-level and nested
# under ``runtime`` (legacy stage_configs format).
connectors = None
edges = None
if hasattr(config_data, "connectors"):
connectors = to_dict(config_data.connectors)
if hasattr(config_data, "edges"):
edges = to_dict(config_data.edges)
if hasattr(config_data, "runtime") and config_data.runtime is not None:
top_runtime = config_data.runtime
if connectors is None and hasattr(top_runtime, "connectors"):
connectors = to_dict(top_runtime.connectors)
if edges is None and hasattr(top_runtime, "edges"):
edges = to_dict(top_runtime.edges)
return ModelPipeline(
model_type=getattr(config_data, "model_type", model_type),
@@ -421,9 +503,21 @@ class StageConfigFactory:
hf_config = get_config(model, trust_remote_code=trust_remote_code)
return hf_config.model_type, hf_config
except Exception:
pass
# Fallback: read config.json directly for custom model types that
# are not registered with transformers (e.g. qwen3_tts).
try:
from vllm.transformers_utils.config import get_hf_file_to_dict
config_dict = get_hf_file_to_dict("config.json", model, revision=None)
if config_dict and "model_type" in config_dict:
return config_dict["model_type"], None
except Exception as e:
logger.debug(f"Failed to auto-detect model type for {model}: {e}")
return None, None
return None, None
# Keys that should never be forwarded as engine overrides (internal /
# orchestrator-only knobs, complex objects, etc.).
+71
View File
@@ -606,6 +606,76 @@ def enable_cache_for_dit(pipeline: Any, cache_config: Any) -> Callable[[int], No
return refresh_cache_context
def enable_cache_for_hunyuan_image3(pipeline: Any, cache_config: Any) -> Callable[[int], None]:
"""Enable cache-dit for HunyuanImage3 pipeline.
HunyuanImage3 stores its main transformer stack at ``pipeline.model`` with
decoder blocks in ``pipeline.model.layers``.
"""
db_cache_config = _build_db_cache_config(cache_config)
calibrator_config = None
if cache_config.enable_taylorseer:
taylorseer_order = cache_config.taylorseer_order
calibrator_config = TaylorSeerCalibratorConfig(taylorseer_order=taylorseer_order)
logger.info(f"TaylorSeer enabled with order={taylorseer_order}")
transformer = getattr(pipeline, "model", None)
if transformer is None or not hasattr(transformer, "layers"):
raise ValueError(
f"HunyuanImage3 cache-dit enabler expects pipeline.model.layers, got pipeline={pipeline.__class__.__name__}"
)
logger.info(
f"Enabling cache-dit on HunyuanImage3 model: "
f"Fn={db_cache_config.Fn_compute_blocks}, "
f"Bn={db_cache_config.Bn_compute_blocks}, "
f"W={db_cache_config.max_warmup_steps}, "
)
# HunyuanImage3 decoder layers are single-stream `hidden_states` blocks
# (`HunyuanImage3DecoderLayer.forward(hidden_states, ...)`) while caller
# still reads outputs as tuple (`layer_outputs[0]`). Pattern_4 matches this:
# hidden-only input with hidden-first tuple output.
modifier = ParamsModifier(
cache_config=db_cache_config,
calibrator_config=calibrator_config,
)
cache_dit.enable_cache(
BlockAdapter(
transformer=transformer,
blocks=transformer.layers,
forward_pattern=ForwardPattern.Pattern_4,
params_modifiers=[modifier],
check_forward_pattern=False,
),
cache_config=db_cache_config,
calibrator_config=calibrator_config,
)
def refresh_cache_context(pipeline: Any, num_inference_steps: int, verbose: bool = True) -> None:
transformer = getattr(pipeline, "model", None)
if transformer is None:
raise ValueError("HunyuanImage3 cache-dit refresh expects pipeline.model to exist.")
if cache_config.scm_steps_mask_policy is None:
cache_dit.refresh_context(transformer, num_inference_steps=num_inference_steps, verbose=verbose)
else:
cache_dit.refresh_context(
transformer,
cache_config=DBCacheConfig().reset(
num_inference_steps=num_inference_steps,
steps_computation_mask=cache_dit.steps_mask(
mask_policy=cache_config.scm_steps_mask_policy,
total_steps=num_inference_steps,
),
steps_computation_policy=cache_config.scm_steps_policy,
),
verbose=verbose,
)
return refresh_cache_context
class BagelCachedContextManager(CachedContextManager):
"""
Custom CachedContextManager for Bagel that safely handles NaiveCache objects
@@ -1064,6 +1134,7 @@ CUSTOM_DIT_ENABLERS.update(
"Wan22Pipeline": enable_cache_for_wan22,
"Wan22I2VPipeline": enable_cache_for_wan22,
"Wan22TI2VPipeline": enable_cache_for_wan22,
"HunyuanImage3Pipeline": enable_cache_for_hunyuan_image3,
"FluxPipeline": enable_cache_for_flux,
"Flux2KleinPipeline": enable_cache_for_flux2_klein,
"LongCatImagePipeline": enable_cache_for_longcat_image,
+13 -1
View File
@@ -8,6 +8,7 @@ from typing import Any
import numpy as np
import PIL.Image
import torch
from vllm.logger import init_logger
from vllm_omni.diffusion.data import OmniDiffusionConfig
@@ -106,8 +107,19 @@ class DiffusionEngine:
for i, prompt in enumerate(request.prompts)
]
# When CPU offload is enabled, move output to CPU before
# post-processing to avoid device OOM — model weights may still
# reside on the device and leave no headroom for intermediates.
output_data = output.output
if (
self.od_config.enable_cpu_offload
and isinstance(output_data, torch.Tensor)
and output_data.device.type != "cpu"
):
output_data = output_data.cpu()
postprocess_start_time = time.perf_counter()
outputs = self.post_process_func(output.output) if self.post_process_func is not None else output.output
outputs = self.post_process_func(output_data) if self.post_process_func is not None else output_data
audio_payload = None
if isinstance(outputs, dict):
audio_payload = outputs.get("audio")
@@ -0,0 +1,146 @@
"""Stage Diffusion Client for vLLM-Omni multi-stage runtime.
Wraps AsyncOmniDiffusion to expose the same interface the Orchestrator
expects from any stage client.
"""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING, Any
from vllm.logger import init_logger
from vllm_omni.engine.stage_init_utils import StageMetadata
from vllm_omni.entrypoints.async_omni_diffusion import AsyncOmniDiffusion
from vllm_omni.outputs import OmniRequestOutput
if TYPE_CHECKING:
from vllm_omni.diffusion.data import OmniDiffusionConfig
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniPromptType
logger = init_logger(__name__)
class StageDiffusionClient:
"""Wraps AsyncOmniDiffusion for use inside the Orchestrator.
Exposes the same attributes and async methods the Orchestrator
uses on StageEngineCoreClient, but routes execution through
DiffusionEngine instead of vLLM EngineCore.
"""
stage_type: str = "diffusion"
def __init__(
self,
model: str,
od_config: OmniDiffusionConfig,
metadata: StageMetadata,
) -> None:
self.stage_id = metadata.stage_id
self.final_output = metadata.final_output
self.final_output_type = metadata.final_output_type
self.default_sampling_params = metadata.default_sampling_params
self.custom_process_input_func = metadata.custom_process_input_func
self.engine_input_source = metadata.engine_input_source
self._engine = AsyncOmniDiffusion(model=model, od_config=od_config)
self._output_queue: asyncio.Queue[OmniRequestOutput] = asyncio.Queue()
self._tasks: dict[str, asyncio.Task] = {}
logger.info("[StageDiffusionClient] Stage-%s initialized", self.stage_id)
async def add_request_async(
self,
request_id: str,
prompt: OmniPromptType,
sampling_params: OmniDiffusionSamplingParams,
) -> None:
task = asyncio.create_task(
self._run(request_id, prompt, sampling_params),
name=f"diffusion-{request_id}",
)
self._tasks[request_id] = task
async def _run(
self,
request_id: str,
prompt: OmniPromptType,
sampling_params: OmniDiffusionSamplingParams,
) -> None:
try:
result = await self._engine.generate(prompt, sampling_params, request_id)
await self._output_queue.put(result)
except Exception as e:
logger.exception(
"[StageDiffusionClient] Stage-%s req=%s failed: %s",
self.stage_id,
request_id,
e,
)
finally:
self._tasks.pop(request_id, None)
def get_diffusion_output_async(self) -> OmniRequestOutput | None:
try:
return self._output_queue.get_nowait()
except asyncio.QueueEmpty:
return None
async def abort_requests_async(self, request_ids: list[str]) -> None:
for rid in request_ids:
task = self._tasks.pop(rid, None)
if task:
task.cancel()
async def collective_rpc_async(
self,
method: str,
timeout: float | None = None,
args: tuple[Any, ...] = (),
kwargs: dict[str, Any] | None = None,
) -> Any:
"""Best-effort control RPC shim for diffusion stages.
TODO(AsyncOmni): add dedicated wrappers on AsyncOmniDiffusion for the
remaining control APIs instead of reaching into its underlying engine.
"""
kwargs = kwargs or {}
if method in {"add_lora", "remove_lora", "list_loras", "pin_lora", "start_profile", "stop_profile"}:
target = getattr(self._engine, method, None)
if target is None:
return {
"supported": False,
"todo": True,
"reason": f"AsyncOmniDiffusion.{method} is not implemented",
}
result = target(*args, **kwargs)
if timeout is not None:
return await asyncio.wait_for(result, timeout=timeout)
return await result
if method in {"sleep", "wake_up"}:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
self._engine._executor,
self._engine.engine.collective_rpc,
method,
timeout,
args,
kwargs,
None,
)
return {
"supported": False,
"todo": True,
"reason": f"Diffusion stage collective_rpc method {method} is not implemented yet",
}
def shutdown(self) -> None:
for task in self._tasks.values():
task.cancel()
self._tasks.clear()
self._engine.close()
@@ -1,13 +1,9 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# temporary for compatibility with vllm_omni.entrypoints.omni_stage.py
# and vllm_omni.entrypoints.omni_llm.py
import time
from collections.abc import Callable
from typing import Any
from vllm_omni.entrypoints.stage_utils import OmniStageTaskType
from vllm_omni.metrics import OrchestratorAggregator
from .utils.logging import get_connector_logger
@@ -66,7 +62,7 @@ def try_send_via_connector(
if success:
# Send lightweight notification via queue
notify_payload = {
"type": OmniStageTaskType.GENERATE,
"type": "generate",
"request_id": req_id,
"sampling_params": sampling_params,
"from_connector": True,
@@ -2,7 +2,6 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import threading
import time
from collections import deque
from typing import Any
@@ -33,6 +32,8 @@ class OmniTransferAdapterBase:
self._finished_save_reqs = set()
self.stop_event = threading.Event()
self._recv_cond = threading.Condition()
self._save_cond = threading.Condition()
self.recv_thread = threading.Thread(target=self.recv_loop, daemon=True)
self.recv_thread.start()
@@ -45,22 +46,37 @@ class OmniTransferAdapterBase:
raise NotImplementedError
def recv_loop(self):
"""Loop to poll for incoming data."""
"""Loop to poll for incoming data.
Process each pending request exactly once per pass. When no request
made progress, back off 1 ms instead of tight-spinning on failed
shm_open syscalls (which can burn a full CPU core).
"""
while not self.stop_event.is_set():
# Iterate over a snapshot of pending requests
while self._pending_load_reqs:
n = len(self._pending_load_reqs)
any_success = False
for _ in range(n):
if not self._pending_load_reqs:
break
request = self._pending_load_reqs.popleft()
request_id = request.request_id
self.request_ids_mapping[request_id] = request.external_req_id
try:
is_success = self._poll_single_request(request)
if not is_success:
if is_success:
any_success = True
else:
self._pending_load_reqs.append(request)
except Exception as e:
self._pending_load_reqs.append(request)
logger.warning(f"Error receiving data for {request_id}: {e}")
time.sleep(0.001)
# Timeout is the fallback for lock-free append/notify races.
with self._recv_cond:
if not self._pending_load_reqs and not self.stop_event.is_set():
self._recv_cond.wait(timeout=0.1)
elif not any_success and not self.stop_event.is_set():
self._recv_cond.wait(timeout=0.001)
def save_loop(self):
"""Loop to send outgoing data."""
@@ -71,7 +87,10 @@ class OmniTransferAdapterBase:
self._send_single_request(task)
except Exception as e:
logger.warning(f"Error saving data for {task.get('request_id')}: {e}")
time.sleep(0.001)
with self._save_cond:
if not self._pending_save_reqs and not self.stop_event.is_set():
self._save_cond.wait(timeout=0.1)
def _poll_single_request(self, *args, **kwargs):
"""Poll connector for a single request task.
@@ -105,4 +124,13 @@ class OmniTransferAdapterBase:
def shutdown(self):
"""Stop background loops and close the connector."""
raise NotImplementedError
self.stop_event.set()
with self._recv_cond:
self._recv_cond.notify_all()
with self._save_cond:
self._save_cond.notify_all()
if self.connector is not None:
try:
self.connector.close()
except Exception:
pass
@@ -95,6 +95,8 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
if not hasattr(request, "additional_information"):
request.additional_information = None
self._pending_load_reqs.append(request)
with self._recv_cond:
self._recv_cond.notify()
def save_async(
self,
@@ -116,6 +118,8 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
"is_finished": request.is_finished(),
}
self._pending_save_reqs.append(task)
with self._save_cond:
self._save_cond.notify()
def _poll_single_request(self, request: Request):
stage_id = self.connector.stage_id
@@ -302,6 +306,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
)
while len(running_queue) > self.scheduler_max_num_seqs:
request = running_queue.pop()
request.status = RequestStatus.PREEMPTED
waiting_queue.prepend_requests([request])
def restore_queues(self, waiting_queue: Any, running_queue: list[Request]) -> None:
+10 -164
View File
@@ -1,8 +1,10 @@
import argparse
import dataclasses
from dataclasses import dataclass, field
from typing import Any
import vllm.envs as envs
from vllm.engine.arg_utils import AsyncEngineArgs, EngineArgs
from vllm.engine.arg_utils import EngineArgs
from vllm.logger import init_logger
from vllm.transformers_utils.gguf_utils import is_gguf
@@ -86,6 +88,12 @@ class OmniEngineArgs(EngineArgs):
load_omni_general_plugins()
super().__post_init__()
@classmethod
def from_cli_args(cls, args: argparse.Namespace) -> "OmniEngineArgs":
attrs = [attr.name for attr in dataclasses.fields(cls)]
engine_args = cls(**{attr: getattr(args, attr) for attr in attrs if hasattr(args, attr)})
return engine_args
def _ensure_omni_models_registered(self):
if hasattr(self, "_omni_models_registered"):
return True
@@ -115,170 +123,8 @@ class OmniEngineArgs(EngineArgs):
self._ensure_omni_models_registered()
# Keep compatibility when async args are constructed from partial payloads.
limit_mm_per_prompt = getattr(self, "limit_mm_per_prompt", {})
language_model_only = getattr(self, "language_model_only", False)
enable_mm_embeds = getattr(self, "enable_mm_embeds", False)
interleave_mm_strings = getattr(self, "interleave_mm_strings", False)
media_io_kwargs = getattr(self, "media_io_kwargs", {})
skip_mm_profiling = getattr(self, "skip_mm_profiling", False)
mm_processor_kwargs = getattr(self, "mm_processor_kwargs", None)
mm_processor_cache_gb = getattr(self, "mm_processor_cache_gb", 4)
mm_processor_cache_type = getattr(self, "mm_processor_cache_type", None)
mm_shm_cache_max_object_size_mb = getattr(self, "mm_shm_cache_max_object_size_mb", 128)
mm_encoder_only = getattr(self, "mm_encoder_only", False)
mm_encoder_tp_mode = getattr(self, "mm_encoder_tp_mode", "weights")
mm_encoder_attn_backend = getattr(self, "mm_encoder_attn_backend", None)
video_pruning_rate = getattr(self, "video_pruning_rate", 0.0)
# Build stage_connector_config from stage_connector_spec
stage_connector_config = {
"name": self.stage_connector_spec.get("name", "SharedMemoryConnector"),
"extra": self.stage_connector_spec.get("extra", {}).copy(),
}
stage_connector_config["extra"]["stage_id"] = self.stage_id
# Create OmniModelConfig directly from engine args
# Note: We pass the actual init parameters matching vLLM's EngineArgs.create_model_config()
omni_config = OmniModelConfig(
# Base ModelConfig fields (matching vLLM's EngineArgs.create_model_config)
model=self.model,
model_weights=self.model_weights,
hf_config_path=self.hf_config_path,
runner=self.runner,
convert=self.convert,
tokenizer=self.tokenizer,
tokenizer_mode=self.tokenizer_mode,
trust_remote_code=self.trust_remote_code,
allowed_local_media_path=self.allowed_local_media_path,
allowed_media_domains=self.allowed_media_domains,
dtype=self.dtype,
seed=self.seed,
revision=self.revision,
code_revision=self.code_revision,
hf_token=self.hf_token,
hf_overrides=self.hf_overrides,
tokenizer_revision=self.tokenizer_revision,
max_model_len=self.max_model_len,
quantization=self.quantization,
allow_deprecated_quantization=self.allow_deprecated_quantization,
enforce_eager=self.enforce_eager,
enable_return_routed_experts=self.enable_return_routed_experts,
max_logprobs=self.max_logprobs,
logprobs_mode=self.logprobs_mode,
disable_sliding_window=self.disable_sliding_window,
disable_cascade_attn=self.disable_cascade_attn,
skip_tokenizer_init=self.skip_tokenizer_init,
enable_prompt_embeds=self.enable_prompt_embeds,
served_model_name=self.served_model_name,
language_model_only=language_model_only,
limit_mm_per_prompt=limit_mm_per_prompt,
enable_mm_embeds=enable_mm_embeds,
interleave_mm_strings=interleave_mm_strings,
media_io_kwargs=media_io_kwargs,
skip_mm_profiling=skip_mm_profiling,
config_format=self.config_format,
mm_processor_kwargs=mm_processor_kwargs,
mm_processor_cache_gb=mm_processor_cache_gb,
mm_processor_cache_type=mm_processor_cache_type,
mm_shm_cache_max_object_size_mb=mm_shm_cache_max_object_size_mb,
mm_encoder_only=mm_encoder_only,
mm_encoder_tp_mode=mm_encoder_tp_mode,
mm_encoder_attn_backend=mm_encoder_attn_backend,
pooler_config=self.pooler_config,
generation_config=self.generation_config,
override_generation_config=self.override_generation_config,
enable_sleep_mode=self.enable_sleep_mode,
model_impl=self.model_impl,
override_attention_dtype=self.override_attention_dtype,
logits_processors=self.logits_processors,
video_pruning_rate=video_pruning_rate,
io_processor_plugin=self.io_processor_plugin,
# Omni-specific fields
stage_id=self.stage_id,
async_chunk=self.async_chunk,
model_stage=self.model_stage,
model_arch=self.model_arch,
worker_type=self.worker_type,
engine_output_type=self.engine_output_type,
hf_config_name=self.hf_config_name,
custom_process_next_stage_input_func=self.custom_process_next_stage_input_func,
stage_connector_config=stage_connector_config,
omni_kv_config=self.omni_kv_config,
task_type=self.task_type,
)
omni_config.hf_config.architectures = omni_config.architectures
return omni_config
@dataclass
class AsyncOmniEngineArgs(AsyncEngineArgs):
"""Async engine arguments for omni models, extending base AsyncEngineArgs.
Adds omni-specific configuration fields for multi-stage pipeline
processing and output type specification in async contexts.
Args:
stage_id: Identifier for the stage in a multi-stage pipeline (default: 0)
model_stage: Stage type identifier, e.g., "thinker" or "talker"
(default: "thinker")
model_arch: Model architecture name
(default: "Qwen2_5OmniForConditionalGeneration")
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.
stage_connector_spec: Extra configuration for stage connector
worker_type: Model Type, e.g., "ar" or "generation"
task_type: Default task type for TTS models (CustomVoice, VoiceDesign, or Base).
If not specified, will be inferred from model path.
"""
stage_id: int = 0
model_stage: str = "thinker"
model_arch: str | None = None
engine_output_type: str | None = None
hf_config_name: str | None = None
custom_process_next_stage_input_func: str | None = None
stage_connector_spec: dict[str, Any] = field(default_factory=dict)
async_chunk: bool = False
omni_kv_config: dict | None = None
quantization_config: Any | None = None
worker_type: str | None = None
task_type: str | None = None
def __post_init__(self) -> None:
load_omni_general_plugins()
super().__post_init__()
def _ensure_omni_models_registered(self):
if hasattr(self, "_omni_models_registered"):
return True
register_omni_models_to_vllm()
self._omni_models_registered = True
return True
def create_model_config(self) -> OmniModelConfig:
"""Create an OmniModelConfig from these engine arguments.
Returns:
OmniModelConfig instance with all configuration fields set
"""
# GGUF files need a specific model loader path in vLLM.
if is_gguf(self.model):
self.quantization = self.load_format = "gguf"
if not envs.VLLM_ENABLE_V1_MULTIPROCESSING:
logger.warning(
"The global random seed is set to %d. Since "
"VLLM_ENABLE_V1_MULTIPROCESSING is set to False, this may "
"affect the random state of the Python process that "
"launched vLLM.",
self.seed,
)
# register omni models to avoid model not found error
self._ensure_omni_models_registered()
# Keep compatibility when async args are constructed from partial payloads.
limit_mm_per_prompt = getattr(self, "limit_mm_per_prompt", {})
language_model_only = getattr(self, "language_model_only", False)
limit_mm_per_prompt = getattr(self, "limit_mm_per_prompt", {})
enable_mm_embeds = getattr(self, "enable_mm_embeds", False)
interleave_mm_strings = getattr(self, "interleave_mm_strings", False)
media_io_kwargs = getattr(self, "media_io_kwargs", {})
File diff suppressed because it is too large. Load diff
-301
View File
@@ -1,301 +0,0 @@
import time
from collections.abc import Mapping
from typing import Any
import numpy as np
import torch
from vllm.config import VllmConfig
from vllm.inputs import ProcessorInputs, PromptType
from vllm.inputs.parse import split_enc_dec_inputs
from vllm.logger import init_logger
from vllm.multimodal import MULTIMODAL_REGISTRY, MultiModalRegistry
from vllm.multimodal.inputs import MultiModalFeatureSpec
from vllm.multimodal.utils import argsort_mm_positions
from vllm.platforms import current_platform
from vllm.pooling_params import PoolingParams
from vllm.renderers import BaseRenderer
from vllm.sampling_params import SamplingParams
from vllm.tasks import SupportedTask
from vllm.utils import length_from_prompt_token_ids_or_embeds
from vllm.utils.jsontree import json_iter_leaves
from vllm.v1.engine.input_processor import InputProcessor
from vllm_omni.engine import (
AdditionalInformationEntry,
AdditionalInformationPayload,
OmniEngineCoreRequest,
PromptEmbedsPayload,
)
from vllm_omni.inputs.preprocess import OmniInputPreprocessor
from vllm_omni.lora.request import LoRARequest
logger = init_logger(__name__)
_OMNI_EXTRA_KEYS = (
"additional_information",
"prompt_embeds",
"negative_prompt",
"negative_prompt_embeds",
)
def reinject_omni_fields(
results: list[ProcessorInputs],
original_prompts: list[dict],
) -> None:
"""Re-inject omni-specific fields that the upstream renderer discards.
The upstream renderer's ``process_for_engine`` creates new dicts that only
copy standard vLLM fields (prompt_token_ids, multi_modal_data, …).
Omni-specific fields such as ``additional_information`` and
``prompt_embeds`` are silently dropped. This helper copies them back from
the *original* parsed prompts into the renderer outputs so they survive
into ``OmniInputProcessor.process_inputs()``.
"""
for result, orig in zip(results, original_prompts):
if not isinstance(orig, dict):
continue
for key in _OMNI_EXTRA_KEYS:
val = orig.get(key)
if val is not None and key not in result:
result[key] = val
class OmniInputProcessor(InputProcessor):
"""Processor for omni models, handling multimodal inputs and embeddings.
Extends the base vLLM Processor with support for processing prompt
embeddings and additional information payloads, enabling direct transfer
of pre-computed embeddings between pipeline stages.
Args:
vllm_config: Global vLLM configuration
mm_registry: Multi-modal registry for processing multimodal inputs
"""
@staticmethod
def _dtype_to_name(dtype: torch.dtype) -> str:
"""Convert torch dtype to string representation.
Args:
dtype: PyTorch dtype to convert
Returns:
String representation of the dtype (e.g., "float32", "int64")
"""
mapping = {
torch.float32: "float32",
torch.float: "float32",
torch.float16: "float16",
torch.half: "float16",
torch.bfloat16: "bfloat16",
torch.float64: "float64",
torch.double: "float64",
torch.int64: "int64",
torch.long: "int64",
torch.int32: "int32",
torch.int: "int32",
torch.int16: "int16",
torch.short: "int16",
torch.int8: "int8",
torch.uint8: "uint8",
torch.bool: "bool",
}
return mapping.get(dtype, str(dtype).replace("torch.", ""))
def __init__(
self,
vllm_config: VllmConfig,
renderer: BaseRenderer | None = None,
*,
mm_registry: MultiModalRegistry = MULTIMODAL_REGISTRY,
):
super().__init__(vllm_config, renderer=renderer, mm_registry=mm_registry)
self.input_preprocessor = OmniInputPreprocessor(
vllm_config=vllm_config,
renderer=self.renderer,
mm_registry=mm_registry,
)
def process_inputs(
self,
request_id: str,
prompt: PromptType | ProcessorInputs,
params: SamplingParams | PoolingParams,
supported_tasks: tuple[SupportedTask, ...] = ("generate",),
arrival_time: float | None = None,
lora_request: LoRARequest | None = None,
tokenization_kwargs: dict[str, Any] | None = None,
trace_headers: Mapping[str, str] | None = None,
priority: int = 0,
data_parallel_rank: int | None = None,
resumable: bool = False,
) -> OmniEngineCoreRequest:
"""Process input prompt into an engine core request.
Converts a prompt (text, tokens, or multimodal) into an
OmniEngineCoreRequest that can be processed by the engine.
Handles prompt embeddings and additional information payloads
for direct transfer between stages.
Args:
request_id: Unique identifier for this request
prompt: Input prompt (text, token IDs, embeddings, or multimodal)
params: Sampling or pooling parameters for generation
supported_tasks: Tuple of supported tasks for validation
arrival_time: Optional arrival timestamp (defaults to current time)
lora_request: Optional LoRA adapter request
tokenization_kwargs: Optional additional tokenization arguments
trace_headers: Optional tracing headers for observability
priority: Request priority (higher values processed first)
data_parallel_rank: Optional data parallel rank for distributed
inference
resumable: Whether the request supports streaming input
Returns:
OmniEngineCoreRequest ready for the engine
Raises:
ValueError: If data_parallel_rank is out of range or prompt_embeds
has incorrect shape
"""
self._validate_params(params, supported_tasks)
self._validate_lora(lora_request)
parallel_config = self.vllm_config.parallel_config
dp_size = parallel_config.data_parallel_size
dp_local_size = parallel_config.data_parallel_size_local
num_ranks = dp_local_size if parallel_config.local_engines_only else dp_size
if data_parallel_rank is not None and not (0 <= data_parallel_rank < num_ranks):
raise ValueError(f"data_parallel_rank {data_parallel_rank} is out of range [0, {num_ranks}).")
# Short-circuit for prompts already processed by the renderer
# (they carry a "type" key). Raw prompts must still go through the
# omni preprocessor which preserves additional_information, etc.
if isinstance(prompt, dict) and "type" in prompt:
if arrival_time is None:
arrival_time = prompt.get("arrival_time", time.time())
processed_inputs: ProcessorInputs = prompt # type: ignore[assignment]
else:
if arrival_time is None:
arrival_time = time.time()
processed_inputs = self.input_preprocessor.preprocess(
prompt,
tokenization_kwargs=tokenization_kwargs,
)
current_platform.validate_request(processed_inputs, params)
encoder_inputs, decoder_inputs = split_enc_dec_inputs(processed_inputs)
self._validate_model_inputs(encoder_inputs, decoder_inputs)
# Normalize decoder prompt access across TypedDict variants.
if decoder_inputs["type"] == "embeds":
prompt_token_ids = None
prompt_embeds = decoder_inputs["prompt_embeds"]
else:
prompt_token_ids = decoder_inputs["prompt_token_ids"]
prompt_embeds = decoder_inputs.get("prompt_embeds")
sampling_params = None
pooling_params = None
if isinstance(params, SamplingParams):
# TODO: can we avoid cloning here in multiproc case?
sampling_params = params.clone()
# If unset max tokens, then generate up to the max_model_len.
if sampling_params.max_tokens is None:
seq_len = length_from_prompt_token_ids_or_embeds(prompt_token_ids, prompt_embeds)
sampling_params.max_tokens = self.model_config.max_model_len - seq_len
sampling_params.update_from_generation_config(
self.generation_config_fields,
self.renderer.get_eos_token_id(),
)
if self.tokenizer is not None:
sampling_params.update_from_tokenizer(self.tokenizer)
else:
pooling_params = params.clone()
# Multimodal related.
mm_features: list[MultiModalFeatureSpec] | None = None
if decoder_inputs["type"] == "multimodal":
decoder_mm_inputs = decoder_inputs["mm_kwargs"]
decoder_mm_positions = decoder_inputs["mm_placeholders"]
decoder_mm_hashes = decoder_inputs["mm_hashes"]
if not all(isinstance(leaf, str) for leaf in json_iter_leaves(decoder_mm_hashes)):
raise ValueError(
f"mm_hashes must contain only strings, got: {decoder_mm_hashes}. "
"This is likely due to an incorrect custom implementation of "
"MultiModalProcessor.apply method."
)
# Merge and flatten multimodal placeholders, hashes and inputs
# from dictionaries to lists, and sort them by each item's position
# in the input sequence.
sorted_mm_idxs = argsort_mm_positions(decoder_mm_positions)
mm_features = []
for modality, idx in sorted_mm_idxs:
base_mm_hash = decoder_mm_hashes[modality][idx]
mm_features.append(
MultiModalFeatureSpec(
data=decoder_mm_inputs[modality][idx],
modality=modality,
identifier=self._get_mm_identifier(base_mm_hash, lora_request),
mm_position=decoder_mm_positions[modality][idx],
mm_hash=base_mm_hash,
)
)
# Compatibility: decode serialized prompt embeds if provided.
if isinstance(prompt_embeds, PromptEmbedsPayload):
prompt_embeds = self._decode_prompt_embeds(prompt_embeds)
additional_information_payload: AdditionalInformationPayload | None = None
raw_info: dict[str, Any] | AdditionalInformationPayload | None = decoder_inputs.get("additional_information")
if isinstance(raw_info, AdditionalInformationPayload):
additional_information_payload = raw_info
elif raw_info is not None:
entries: dict[str, AdditionalInformationEntry] = {}
for key, value in raw_info.items():
if isinstance(value, torch.Tensor):
v_cpu = value.detach().to("cpu").contiguous()
dtype_str = self._dtype_to_name(v_cpu.dtype)
data_bytes = v_cpu.numpy().tobytes()
entry = AdditionalInformationEntry(
tensor_data=data_bytes,
tensor_shape=[int(x) for x in list(v_cpu.shape)],
tensor_dtype=dtype_str,
)
elif isinstance(value, list):
entry = AdditionalInformationEntry(list_data=value)
else:
raise ValueError("additional_information values must be Tensor or list")
entries[key] = entry
additional_information_payload = AdditionalInformationPayload(entries=entries)
return OmniEngineCoreRequest(
request_id=request_id,
prompt_token_ids=prompt_token_ids,
mm_features=mm_features,
sampling_params=sampling_params,
pooling_params=pooling_params,
arrival_time=arrival_time,
lora_request=lora_request,
cache_salt=decoder_inputs.get("cache_salt"),
priority=priority,
data_parallel_rank=data_parallel_rank,
trace_headers=trace_headers,
prompt_embeds=prompt_embeds,
additional_information=additional_information_payload,
resumable=resumable,
)
@staticmethod
def _decode_prompt_embeds(payload: PromptEmbedsPayload) -> torch.Tensor:
dtype = getattr(np, payload.dtype)
arr = np.frombuffer(payload.data, dtype=dtype)
arr = arr.reshape(payload.shape)
return torch.from_numpy(arr)
+812
View File
@@ -0,0 +1,812 @@
"""
Orchestrator for vLLM-Omni multi-stage runtime.
Runs inside a background thread with its own asyncio event loop.
Owns all StageEngineCoreClient instances, input/output processors,
and handles stage-to-stage transfer logic.
"""
from __future__ import annotations
import asyncio
import copy
import time as _time
from dataclasses import dataclass, field
from typing import Any
import janus
import torch
from vllm.config import ModelConfig
from vllm.logger import init_logger
from vllm.outputs import RequestOutput
from vllm.pooling_params import PoolingParams
from vllm.sampling_params import SamplingParams
from vllm.v1.engine import EngineCoreOutputs
from vllm_omni.distributed.omni_connectors.adapter import compute_talker_prompt_ids_length
from vllm_omni.engine import (
OmniEngineCoreRequest,
)
from vllm_omni.engine.serialization import serialize_additional_information
from vllm_omni.metrics.stats import StageRequestStats as StageRequestMetrics
from vllm_omni.metrics.stats import StageStats
from vllm_omni.metrics.utils import count_tokens_from_outputs
logger = init_logger(__name__)
def build_engine_core_request_from_tokens(
request_id: str,
prompt: dict[str, Any],
params: SamplingParams | PoolingParams,
arrival_time: float | None = None,
model_config: ModelConfig | None = None,
) -> OmniEngineCoreRequest:
"""Build an OmniEngineCoreRequest directly from an OmniTokensPrompt.
Lightweight alternative to the full InputProcessor pipeline - skips
tokenization, multimodal preprocessing, LoRA validation, and platform
validation. Intended for stage 1+ where the upstream stage has already
produced token IDs and optional embeddings.
"""
if arrival_time is None:
arrival_time = _time.time()
prompt_token_ids = prompt["prompt_token_ids"]
# Clone params and set max_tokens if needed
sampling_params = None
pooling_params = None
if isinstance(params, SamplingParams):
sampling_params = params.clone()
if sampling_params.max_tokens is None and model_config is not None:
sampling_params.max_tokens = model_config.max_model_len - len(prompt_token_ids)
else:
pooling_params = params.clone()
prompt_embeds: torch.Tensor | None = prompt.get("prompt_embeds")
# Serialize additional_information if present
additional_info_payload = serialize_additional_information(
prompt.get("additional_information"),
log_prefix=f"build_engine_core_request_from_tokens req={request_id}",
)
return OmniEngineCoreRequest(
request_id=request_id,
prompt_token_ids=prompt_token_ids,
mm_features=None,
sampling_params=sampling_params,
pooling_params=pooling_params,
arrival_time=arrival_time,
lora_request=None,
cache_salt=None,
data_parallel_rank=None,
prompt_embeds=prompt_embeds,
additional_information=additional_info_payload,
)
# ============================================================
# Orchestrator internals (run inside the background thread)
# ============================================================
@dataclass
class OrchestratorRequestState:
"""Per-request bookkeeping inside the Orchestrator."""
request_id: str
prompt: Any = None
sampling_params_list: list[Any] = field(default_factory=list)
final_stage_id: int = -1
# Metrics: timestamp when request was submitted to each stage
stage_submit_ts: dict[int, float] = field(default_factory=dict)
class Orchestrator:
"""Runs inside a background thread's asyncio event loop.
Owns all StageEngineCoreClient instances, input/output processors,
and handles stage-to-stage transfer logic.
"""
def __init__(
self,
request_async_queue: janus.AsyncQueue[dict[str, Any]],
output_async_queue: janus.AsyncQueue[dict[str, Any]],
rpc_async_queue: janus.AsyncQueue[dict[str, Any]],
stage_clients: list[Any],
output_processors: list[Any],
stage_vllm_configs: list[Any],
*,
async_chunk: bool = False,
) -> None:
self.request_async_queue = request_async_queue
self.output_async_queue = output_async_queue
self.rpc_async_queue = rpc_async_queue
self.num_stages = len(stage_clients)
self.async_chunk = bool(async_chunk)
self.stage_clients: list[Any] = stage_clients
self.output_processors: list[Any] = output_processors
self.stage_vllm_configs: list[Any] = stage_vllm_configs
# Per-request state
self.request_states: dict[str, OrchestratorRequestState] = {}
# CFG companion tracking
self._companion_map: dict[str, dict[str, str]] = {}
self._companion_to_parent: dict[str, str] = {}
self._companion_ids: set[str] = set()
self._companion_done: dict[str, set[str]] = {}
self._deferred_parents: dict[str, dict[str, Any]] = {}
# Per-stage metrics accumulators.
self._batch_seq: list[int] = [0] * self.num_stages
self._agg_total_tokens: list[int] = [0] * self.num_stages
self._agg_total_gen_time_ms: list[float] = [0.0] * self.num_stages
# Shutdown coordination
self._shutdown_event = asyncio.Event()
self._stages_shutdown = False
async def run(self) -> None:
"""Main entry point for the Orchestrator event loop."""
logger.info("[Orchestrator] Starting event loop")
request_task = asyncio.create_task(self._request_handler(), name="orchestrator-request-handler")
output_task = asyncio.create_task(
self._orchestration_output_handler(),
name="orchestrator-stage-output-handler",
)
try:
# Run both tasks concurrently; if either fails the other is cancelled.
await asyncio.gather(request_task, output_task)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("[Orchestrator] Fatal error in orchestrator tasks")
raise
finally:
self._shutdown_event.set()
for t in (request_task, output_task):
if not t.done():
t.cancel()
try:
await asyncio.gather(request_task, output_task, return_exceptions=True)
except Exception:
pass
self._shutdown_stages()
# Cancel any remaining tasks spawned by wait_for / gather so
# the event loop can close cleanly without "pending task" errors.
loop = asyncio.get_running_loop()
pending = [t for t in asyncio.all_tasks(loop) if t is not asyncio.current_task() and not t.done()]
for t in pending:
t.cancel()
if pending:
await asyncio.gather(*pending, return_exceptions=True)
async def _request_handler(self) -> None:
"""Read messages from the main thread via request_async_queue."""
while True:
msg = await self.request_async_queue.get()
msg_type = msg.get("type")
if msg_type == "add_request":
await self._handle_add_request(msg)
elif msg_type == "add_companion_request":
await self._handle_add_companion(msg)
elif msg_type == "abort":
await self._handle_abort(msg)
elif msg_type == "collective_rpc":
await self._handle_collective_rpc(msg)
elif msg_type == "shutdown":
logger.info("[Orchestrator] Received shutdown signal")
self._shutdown_event.set()
self._shutdown_stages()
break
else:
logger.warning(f"[Orchestrator] Unknown message type: {msg_type}")
async def _orchestration_output_handler(self) -> None:
"""Poll all stages, handle transfers, send final outputs to main."""
try:
await self._orchestration_loop()
except asyncio.CancelledError:
logger.debug("[Orchestrator] _orchestration_output_handler cancelled")
return
async def _orchestration_loop(self) -> None:
"""Inner loop for _orchestration_output_handler (clean cancellation).
Control flow: poll raw → process through output processor → route.
"""
while not self._shutdown_event.is_set():
idle = True
for stage_id in range(self.num_stages):
if self._shutdown_event.is_set():
return
# 1) Diffusion stage: poll non-blocking queue
# TODO (Peiqi): the output of diffusion stage is OmniRequestOutput,
# which is different from EngineCoreOutputs (LLM stages). We may want to unify
# the output format in the future to simplify the processing logic in Orchestrator.
stage_client = self.stage_clients[stage_id]
if stage_client.stage_type == "diffusion":
output = stage_client.get_diffusion_output_async()
if output is not None:
idle = False
req_state = self.request_states.get(output.request_id)
if req_state is not None:
stage_metrics = self._build_stage_metrics(stage_id, output.request_id, [output], req_state)
await self._route_output(stage_id, output, req_state, stage_metrics)
continue
# 1) Poll raw outputs from the stage
try:
raw_outputs = await asyncio.wait_for(self._poll_stage_raw(stage_id), timeout=0.001)
except asyncio.TimeoutError:
continue
except asyncio.CancelledError:
raise
except Exception:
if self._shutdown_event.is_set():
return
logger.exception(
"[Orchestrator] _poll_stage_raw failed for stage-%s",
stage_id,
)
raise
if raw_outputs is None:
continue
idle = False
# 2) Process raw outputs through the output processor
request_outputs = await self._process_stage_outputs(stage_id, raw_outputs)
# 3) Route each processed output
for output in request_outputs:
req_state = self.request_states.get(output.request_id)
if req_state is None:
logger.warning(
"[Orchestrator] Dropping output for unknown req %s at stage-%s (known reqs: %s)",
output.request_id,
stage_id,
list(self.request_states.keys()),
)
continue
stage_metrics = None
if output.finished:
stage_metrics = self._build_stage_metrics(
stage_id,
output.request_id,
[output],
req_state,
)
await self._route_output(stage_id, output, req_state, stage_metrics)
if idle:
await asyncio.sleep(0.001)
else:
await asyncio.sleep(0)
async def _route_output(
self,
stage_id: int,
output: Any,
req_state: OrchestratorRequestState,
stage_metrics: Any,
) -> None:
"""Route a processed output: send to main thread and/or forward to next stage."""
req_id = output.request_id
finished = output.finished
submit_ts = req_state.stage_submit_ts.get(stage_id)
stage_client = self.stage_clients[stage_id]
# CFG companion handling: companions don't produce user-visible output
# and don't forward to the next stage directly.
if finished and req_id in self._companion_ids:
parent_id = self._companion_to_parent.get(req_id)
if parent_id is not None:
self._companion_done.setdefault(parent_id, set()).add(req_id)
logger.debug(
"[Orchestrator] CFG companion %s done (parent=%s)",
req_id,
parent_id,
)
# Check if parent is waiting and all companions are done
if parent_id in self._deferred_parents and self._all_companions_done(parent_id):
deferred = self._deferred_parents.pop(parent_id)
parent_state = self.request_states.get(parent_id)
if parent_state is not None:
await self._forward_to_next_stage(
parent_id,
deferred["stage_id"],
deferred["output"],
parent_state,
)
self.request_states.pop(req_id, None)
return
if stage_client.final_output:
await self.output_async_queue.put(
{
"type": "output",
"request_id": req_id,
"stage_id": stage_id,
"engine_outputs": output,
"metrics": stage_metrics,
"finished": finished and stage_id == req_state.final_stage_id,
"stage_submit_ts": submit_ts,
}
)
elif stage_metrics is not None:
await self.output_async_queue.put(
{
"type": "stage_metrics",
"request_id": req_id,
"stage_id": stage_id,
"metrics": stage_metrics,
"stage_submit_ts": submit_ts,
}
)
if finished and stage_id < req_state.final_stage_id and not self.async_chunk:
# If this parent has CFG companions, defer forwarding until all done
if req_id in self._companion_map and not self._all_companions_done(req_id):
self._deferred_parents[req_id] = {
"stage_id": stage_id,
"output": output,
}
logger.debug(
"[Orchestrator] Parent %s deferred, waiting for CFG companions",
req_id,
)
else:
await self._forward_to_next_stage(req_id, stage_id, output, req_state)
if finished and stage_id == req_state.final_stage_id:
self._cleanup_companion_state(req_id)
self.request_states.pop(req_id, None)
def _cleanup_companion_state(self, parent_id: str) -> None:
"""Remove all companion tracking state for a completed parent."""
role_map = self._companion_map.pop(parent_id, {})
for cid in role_map.values():
self._companion_ids.discard(cid)
self._companion_to_parent.pop(cid, None)
self._companion_done.pop(parent_id, None)
self._deferred_parents.pop(parent_id, None)
def _all_companions_done(self, parent_id: str) -> bool:
"""Check whether all CFG companions for a parent request have finished."""
role_map = self._companion_map.get(parent_id, {})
if not role_map:
return True
done_set = self._companion_done.get(parent_id, set())
return all(cid in done_set for cid in role_map.values())
def _build_stage_metrics(
self,
stage_id: int,
req_id: str,
request_outputs: list[RequestOutput],
req_state: OrchestratorRequestState,
) -> StageRequestMetrics:
"""Build StageRequestMetrics for a finished request at a stage.
Reuses StageRequestMetrics so OrchestratorMetrics and downstream
metric handlers can consume a stable schema.
"""
now = _time.time()
submit_ts = req_state.stage_submit_ts.get(stage_id, now)
stage_gen_time_ms = (now - submit_ts) * 1000.0
num_tokens_out = count_tokens_from_outputs(request_outputs)
num_tokens_in = 0
if stage_id == 0:
for ro in request_outputs:
ptids = getattr(ro, "prompt_token_ids", None)
if ptids is not None:
num_tokens_in += len(ptids)
# Monotonic batch counter per stage.
self._batch_seq[stage_id] += 1
batch_id = self._batch_seq[stage_id]
# Accumulate for running-average stage_stats
self._agg_total_tokens[stage_id] += num_tokens_out
self._agg_total_gen_time_ms[stage_id] += stage_gen_time_ms
return StageRequestMetrics(
num_tokens_in=num_tokens_in,
num_tokens_out=num_tokens_out,
stage_gen_time_ms=stage_gen_time_ms,
batch_id=batch_id,
batch_size=1,
rx_decode_time_ms=0.0,
rx_transfer_bytes=0,
rx_in_flight_time_ms=0.0,
stage_stats=StageStats(
total_token=self._agg_total_tokens[stage_id],
total_gen_time_ms=self._agg_total_gen_time_ms[stage_id],
),
)
async def _forward_to_next_stage(
self,
req_id: str,
stage_id: int,
output: Any,
req_state: OrchestratorRequestState,
) -> None:
"""Forward output from current stage to the next stage.
Handles the full pipeline: set outputs on current stage, compute
next-stage inputs, build lightweight requests, and submit them.
"""
next_stage_id = stage_id + 1
next_client = self.stage_clients[next_stage_id]
params = req_state.sampling_params_list[next_stage_id]
if next_client.stage_type == "diffusion":
self.stage_clients[stage_id].set_engine_outputs([output])
if next_client.custom_process_input_func is not None:
diffusion_prompt = next_client.custom_process_input_func(
self.stage_clients,
next_client.engine_input_source,
req_state.prompt,
False,
)
if isinstance(diffusion_prompt, list):
diffusion_prompt = diffusion_prompt[0]
else:
diffusion_prompt = req_state.prompt
# Attach CFG companion KV request IDs so the diffusion model
# runner can fetch companion KV caches alongside the primary one.
cfg_ids = self._companion_map.get(req_id)
if cfg_ids:
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
if isinstance(params, OmniDiffusionSamplingParams):
params = copy.deepcopy(params)
params.cfg_kv_request_ids = cfg_ids
logger.info(
"[Orchestrator] Attaching cfg_kv_request_ids=%s to req %s",
cfg_ids,
req_id,
)
await next_client.add_request_async(req_id, diffusion_prompt, params)
req_state.stage_submit_ts[next_stage_id] = _time.time()
return
self.stage_clients[stage_id].set_engine_outputs([output])
# Process inputs for next stage
try:
next_inputs = next_client.process_engine_inputs(
stage_list=self.stage_clients,
prompt=req_state.prompt,
)
except Exception:
logger.exception(
"[Orchestrator] req=%s process_engine_inputs FAILED for stage-%s",
req_id,
next_stage_id,
)
raise
# Build and submit requests for each input
for next_input in next_inputs:
request = build_engine_core_request_from_tokens(
request_id=req_id,
prompt=next_input,
params=params,
model_config=self.stage_vllm_configs[next_stage_id].model_config,
)
# TODO: Here we directly use the req id to assign.
request.external_req_id = request.request_id
self.output_processors[next_stage_id].add_request(
request=request,
prompt=None,
parent_req=None,
request_index=0,
queue=None,
)
await next_client.add_request_async(request)
# Record submit timestamp for the next stage
req_state.stage_submit_ts[next_stage_id] = _time.time()
async def _poll_stage_raw(self, stage_id: int) -> EngineCoreOutputs | None:
"""Pull raw EngineCoreOutputs from a stage client without processing.
Returns the raw outputs object, or None when there is nothing
to consume.
"""
outputs = await self.stage_clients[stage_id].get_output_async()
if not outputs.outputs:
return None
return outputs
async def _process_stage_outputs(self, stage_id: int, raw_outputs: EngineCoreOutputs) -> list[RequestOutput]:
"""Run the output processor on raw outputs, returning RequestOutputs.
Also handles abort forwarding and scheduler stats updates.
"""
processor = self.output_processors[stage_id]
processed = processor.process_outputs(
raw_outputs.outputs,
raw_outputs.timestamp,
None,
)
if processed.reqs_to_abort:
await self.stage_clients[stage_id].abort_requests_async(processed.reqs_to_abort)
if raw_outputs.scheduler_stats is not None:
processor.update_scheduler_stats(raw_outputs.scheduler_stats)
return processed.request_outputs
async def _handle_add_request(self, msg: dict[str, Any]) -> None:
"""Handle an add_request message from the main thread."""
stage_id = 0
request_id = msg["request_id"]
prompt = msg["prompt"]
original_prompt = msg.get("original_prompt", prompt)
sampling_params_list = msg["sampling_params_list"]
if not sampling_params_list:
raise ValueError(f"Missing sampling params for stage 0. Got {len(sampling_params_list)} stage params.")
params = sampling_params_list[0]
final_stage_id = msg["final_stage_id"]
logger.info(
"[Orchestrator] _handle_add_request: stage=%s req=%s "
"prompt_type=%s original_prompt_type=%s final_stage=%s "
"num_sampling_params=%d",
stage_id,
request_id,
type(prompt).__name__,
type(original_prompt).__name__,
final_stage_id,
len(sampling_params_list),
)
# Track request state - use original_prompt so downstream stages
# (e.g. thinker2talker) can access the raw dict with multi_modal_data.
req_state = OrchestratorRequestState(
request_id=request_id,
prompt=original_prompt,
sampling_params_list=sampling_params_list,
final_stage_id=final_stage_id,
)
req_state.stage_submit_ts[stage_id] = _time.time()
self.request_states[request_id] = req_state
# Stage-0 prompt is already a fully-formed OmniEngineCoreRequest
# (pre-processed by AsyncOmniEngine.add_request, output processor
# already registered there) - submit directly.
request = prompt
stage_client = self.stage_clients[stage_id]
if stage_client.stage_type == "diffusion":
await stage_client.add_request_async(request_id, prompt, params)
else:
await stage_client.add_request_async(request)
if self.async_chunk and stage_id == 0 and final_stage_id > 0:
await self._prewarm_async_chunk_stages(request_id, request, req_state)
async def _prewarm_async_chunk_stages(
self,
request_id: str,
stage0_request: Any,
req_state: OrchestratorRequestState,
) -> None:
"""Pre-submit downstream stages for async-chunk mode.
In async-chunk mode, stages exchange data through connectors/chunk adapters,
so downstream stages should be armed once at request start instead of waiting
for stage-finished forwarding.
"""
if req_state.final_stage_id <= 0:
return
prompt_token_ids = getattr(stage0_request, "prompt_token_ids", None)
if prompt_token_ids is None:
logger.warning(
"[Orchestrator] async_chunk prewarm skipped for req=%s: stage0 prompt_token_ids missing",
request_id,
)
return
# Pre-arm stage-1+ with placeholder prompt IDs.
try:
next_prompt_len = max(1, compute_talker_prompt_ids_length(prompt_token_ids))
except Exception:
next_prompt_len = max(1, len(prompt_token_ids))
original_prompt = req_state.prompt
if isinstance(original_prompt, dict):
base_input = copy.deepcopy(original_prompt)
else:
base_input = {}
base_input["prompt_token_ids"] = [0] * next_prompt_len
base_input["multi_modal_data"] = None
base_input["mm_processor_kwargs"] = None
for next_stage_id in range(1, req_state.final_stage_id + 1):
next_client = self.stage_clients[next_stage_id]
params = req_state.sampling_params_list[next_stage_id]
if next_client.stage_type == "diffusion":
await next_client.add_request_async(request_id, req_state.prompt, params)
req_state.stage_submit_ts[next_stage_id] = _time.time()
continue
request = build_engine_core_request_from_tokens(
request_id=request_id,
prompt=base_input,
params=params,
model_config=self.stage_vllm_configs[next_stage_id].model_config,
)
request.external_req_id = request.request_id
self.output_processors[next_stage_id].add_request(
request=request,
prompt=None,
parent_req=None,
request_index=0,
queue=None,
)
await next_client.add_request_async(request)
req_state.stage_submit_ts[next_stage_id] = _time.time()
async def _handle_add_companion(self, msg: dict[str, Any]) -> None:
"""Handle an add_companion_request message: submit companion to stage 0."""
companion_id = msg["companion_id"]
parent_id = msg["parent_id"]
role = msg["role"]
companion_prompt = msg["prompt"]
sampling_params_list = msg["sampling_params_list"]
# Register companion mapping
if parent_id not in self._companion_map:
self._companion_map[parent_id] = {}
self._companion_map[parent_id][role] = companion_id
self._companion_ids.add(companion_id)
self._companion_to_parent[companion_id] = parent_id
self._companion_done.setdefault(parent_id, set())
companion_state = OrchestratorRequestState(
request_id=companion_id,
prompt=companion_prompt,
sampling_params_list=sampling_params_list,
final_stage_id=0,
)
companion_state.stage_submit_ts[0] = _time.time()
self.request_states[companion_id] = companion_state
request = companion_prompt # Already a processed OmniEngineCoreRequest
stage_client = self.stage_clients[0]
await stage_client.add_request_async(request)
logger.info(
"[Orchestrator] CFG companion submitted: %s (role=%s, parent=%s)",
companion_id,
role,
parent_id,
)
async def _handle_abort(self, msg: dict[str, Any]) -> None:
"""Handle an abort message from the main thread."""
request_ids = msg["request_ids"]
# Also abort any CFG companions for aborted parents
companion_ids_to_abort: list[str] = []
for req_id in request_ids:
role_map = self._companion_map.pop(req_id, {})
for cid in role_map.values():
companion_ids_to_abort.append(cid)
self._companion_ids.discard(cid)
self._companion_to_parent.pop(cid, None)
self.request_states.pop(cid, None)
self._companion_done.pop(req_id, None)
self._deferred_parents.pop(req_id, None)
all_ids_to_abort = list(request_ids) + companion_ids_to_abort
for stage_id in range(self.num_stages):
await self.stage_clients[stage_id].abort_requests_async(all_ids_to_abort)
for req_id in request_ids:
self.request_states.pop(req_id, None)
logger.info("[Orchestrator] Aborted request(s) %s", request_ids)
async def _handle_collective_rpc(self, msg: dict[str, Any]) -> None:
"""Handle a control-plane RPC request from the main thread.
TODO(AsyncOmni): parallelize stage dispatch if control latency becomes
noticeable. The current sequential fanout keeps the first version simple
and deterministic.
"""
rpc_id = msg["rpc_id"]
method = msg["method"]
timeout = msg.get("timeout")
args = tuple(msg.get("args", ()))
kwargs = dict(msg.get("kwargs") or {})
requested_stage_ids = msg.get("stage_ids")
stage_ids = list(range(self.num_stages)) if requested_stage_ids is None else list(requested_stage_ids)
results: list[Any] = []
for stage_id in stage_ids:
if stage_id < 0 or stage_id >= self.num_stages:
results.append(
{
"supported": False,
"todo": True,
"error": f"Invalid stage id {stage_id}",
}
)
continue
stage_client = self.stage_clients[stage_id]
try:
if hasattr(stage_client, "collective_rpc_async"):
stage_result = await stage_client.collective_rpc_async(
method=method,
timeout=timeout,
args=args,
kwargs=kwargs,
)
else:
stage_result = {
"supported": False,
"todo": True,
"reason": (f"{stage_client.__class__.__name__}.collective_rpc_async is not implemented yet"),
}
except Exception as exc:
logger.exception(
"[Orchestrator] collective_rpc failed: stage=%s method=%s",
stage_id,
method,
)
stage_result = {
"supported": False,
"error": str(exc),
}
results.append(stage_result)
await self.rpc_async_queue.put(
{
"type": "collective_rpc_result",
"rpc_id": rpc_id,
"method": method,
"stage_ids": stage_ids,
"results": results,
}
)
def _shutdown_stages(self) -> None:
"""Shutdown all stage clients."""
if self._stages_shutdown:
return
self._stages_shutdown = True
logger.info("[Orchestrator] Shutting down all stages")
for stage_id, stage_client in enumerate(self.stage_clients):
try:
stage_client.shutdown()
logger.info(f"[Orchestrator] Stage {stage_id} shut down")
except Exception as e:
logger.warning(f"[Orchestrator] Failed to shutdown stage {stage_id}: {e}")
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff. Show more