Merge remote-tracking branch 'remotes/origin/main' into dev/rebase-v0170

Signed-off-by: Taichang Zhou <tzhouam@connect.ust.hk>
This commit is contained in:
Taichang Zhou committed 2026-03-09 23:40:32 +08:00
commit 0940c5a05e
91 files changed
+5235 -729

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
steps:
- label: ":docker: Build image"
key: image-build
commands:
- "aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/q9t5s3a7"
- "docker build --progress=plain --file docker/Dockerfile.ci -t vllm-omni-ci ."
- "docker tag vllm-omni-ci public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT"
- "docker push public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT"
agents:
queue: "cpu_queue_premerge"
# L2 Test
- label: "Upload Ready Pipeline"
depends_on: image-build
key: upload-ready-pipeline
commands:
- buildkite-agent pipeline upload .buildkite/test-ready.yml
agents:
queue: "cpu_queue_premerge"
# L3 Test
- label: "Upload Merge Pipeline"
depends_on: image-build
key: upload-merge-pipeline
commands:
- buildkite-agent pipeline upload .buildkite/test-merge.yml
agents:
queue: "cpu_queue_premerge"
# L4 Test
- label: "Upload Nightly Pipeline"
depends_on: image-build
key: upload-nightly-pipeline
if: build.env("NIGHTLY") == "1"
commands:
- buildkite-agent pipeline upload .buildkite/test-nightly.yml
agents:
queue: "cpu_queue_premerge"
+1
View File
@@ -68,6 +68,7 @@ steps:
if: build.env("NIGHTLY") == "1"
commands:
- |
huggingface-cli download Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice
export VLLM_WORKER_MULTIPROC_METHOD=spawn
pytest -s -v tests/e2e/online_serving/test_qwen3_tts.py::TestQwen3TTSNoAsyncChunk
agents:
+2 -1
View File
@@ -214,9 +214,10 @@ steps:
commands:
- |
timeout 20m bash -c '
huggingface-cli download Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
pytest -s -v tests/e2e/online_serving/test_qwen3_tts.py
pytest -s -v tests/e2e/online_serving/test_qwen3_tts.py -k "not NoAsyncChunk"
'
agents:
queue: "gpu_4_queue"
+1 -1
View File
@@ -9,7 +9,7 @@ Easy, fast, and cheap omni-modality model serving for everyone
</h3>
<p align="center">
| <a href="https://vllm-omni.readthedocs.io/en/latest/"><b>Documentation</b></a> | <a href="https://discuss.vllm.ai"><b>User Forum</b></a> | <a href="https://slack.vllm.ai"><b>Developer Slack</b></a> | <a href="docs/assets/WeChat.jpg"><b>WeChat</b></a> | <a href="https://arxiv.org/abs/2602.02204"><b>Paper</b></a> | <a href="https://docs.google.com/presentation/d/1qv4qMW1rKAqDREMXiUDLIgqqHQe7TDPj/edit?usp=sharing&ouid=110473603432222024453&rtpof=true&sd=true"><b>Slides</b></a> |
| <a href="https://vllm-omni.readthedocs.io/en/latest/"><b>Documentation</b></a> | <a href="https://discuss.vllm.ai"><b>User Forum</b></a> | <a href="https://slack.vllm.ai"><b>Developer Slack</b></a> | <a href="docs/assets/WeChat.jpg"><b>WeChat</b></a> | <a href="https://arxiv.org/abs/2602.02204"><b>Paper</b></a> | <a href="https://drive.google.com/file/d/12k_DOo2xoEROvd4xQxYNuEi6C2GECoRx/view?usp=drive_link"><b>Slides</b></a> |
</p>
+1 -1
View File
@@ -7,7 +7,7 @@ COPY . .
# Install system dependencies
RUN apt-get update && \
apt-get install -y ffmpeg git sox libsox-fmt-all jq && \
apt-get install -y espeak-ng ffmpeg git sox libsox-fmt-all jq && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
+1 -1
View File
@@ -19,7 +19,7 @@ WORKDIR ${COMMON_WORKDIR}
# Step 1: Setup - Install system dependencies
RUN apt-get update && \
apt-get install -y ffmpeg git sox libsox-fmt-all jq && \
apt-get install -y espeak-ng ffmpeg git sox libsox-fmt-all jq && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
+1
View File
@@ -14,6 +14,7 @@ RUN wget -O- https://apt.repos.intel.com/intel-gpg-keys/GPG-PUB-KEY-INTEL-SW-PRO
RUN apt clean && apt-get update -y && \
apt-get install -y --no-install-recommends --fix-missing \
curl \
espeak-ng \
ffmpeg \
git \
libsndfile1 \
+1 -34
View File
@@ -69,20 +69,8 @@ With `--log-stats` enabled, the server will output detailed metrics logs after e
| rx_decode_time_ms | 111.865 | 31.706 |
| in_flight_time_ms | 2.015 | 2.819 |
### DiffusionStats
| Field | Value |
|---------------------------------|------------|
| batch_id | 1 |
| batch_size | 1 |
| diffusion_engine_exec_time_ms | 16,363.419 |
| diffusion_engine_total_time_ms | 16,234.849 |
| image_num | 1.000 |
| num_inference_steps | 50.000 |
| postprocess_time_ms | 67.685 |
| preprocess_time_ms | 60.106 |
| resolution | 640.000 |
| stage_gen_time_ms | 16,363.714 |
These logs include:
- **Overall summary**: total requests, wall time, average tokens/sec, etc.
@@ -92,9 +80,6 @@ With `--log-stats` enabled, the server will output detailed metrics logs after e
- **Transfer table**: data transfer and timing for each edge.
- **DiffusionStats**:preprocessing time, model execution time, postprocessing time, generated images, resolution, and inference steps.
You can use these logs to monitor system health, debug performance, and analyze request-level metrics as described above.
@@ -145,11 +130,6 @@ For **online inference** (serving mode), the summary is always per-request. `e2e
| `image_num` | Number of images generated (for diffusion/image stages). |
| `resolution` | Image resolution (for diffusion/image stages). |
| `postprocess_time_ms` | Diffusion/image: post-processing time in ms. |
| `preprocess_time_ms` | Diffusion/image: pre-processing time in ms (before model execution). |
| `diffusion_engine_exec_time_ms` | Diffusion/image: model execution time in ms, excluding pre-processing and post-processing. |
| `diffusion_engine_total_time_ms` | Diffusion/image: total diffusion engine time in ms, including pre-processing, model execution, and post-processing. |
| `num_inference_steps` | Number of inference steps for diffusion generation. |
---
@@ -163,8 +143,6 @@ For **online inference** (serving mode), the summary is always per-request. `e2e
| `in_flight_time_ms` | In-flight time in ms. |
### Expectation of the Numbers (Verification)
**Formulas:**
@@ -193,14 +171,3 @@ For each edge:
- 1->2: tx_time_ms (**18.790**) + rx_decode_time_ms (**31.706**) + in_flight_time_ms (**2.819**) = **53.315**
192.581 + 53.315 = **245.896** = transfers_total_time_ms, which matches the calculation (difference is due to rounding)
**diffusion_engine_total_time_ms**
- preprocess_time_ms: Time spent on input preprocessing
- diffusion_engine_exec_time_ms: Pure model execution time
- postprocess_time_ms: Time spent on output postprocessing
- diffusion_engine_total_time_ms: Total time spent in the diffusion engine
so `diffusion_engine_total_time_ms` = `diffusion_engine_exec_time_ms` + `preprocess_time_ms` + `postprocess_time_ms`
= **16,363.419** + **60.106** + **67.685**
= **16,491.210**
+1 -1
View File
@@ -115,7 +115,7 @@ python image_edit.py \
--image cherry_blossom.jpg \
--prompt "Let this mascot dance under the moon, surrounded by floating stars" \
--output output_image_edit.png \
--num_inference_steps 10
--num-inference-steps 10
```
## Advanced Usage
+95 -1
View File
@@ -104,7 +104,7 @@ Content-Type: application/json
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `ref_audio` | string | null | Reference audio (URL or base64 data URL) |
| `ref_audio` | string | null | Reference audio (HTTP URL, base64 data URL, or `file://` URI with `--allowed-local-media-path`) |
| `ref_text` | string | null | Transcript of reference audio |
| `x_vector_only_mode` | bool | null | Use speaker embedding only (no ICL) |
@@ -125,6 +125,81 @@ Lists available voices for the loaded model.
"voices": ["aiden", "dylan", "eric", "ono_anna", "ryan", "serena", "sohee", "uncle_fu", "vivian"]
}
```
```
POST /v1/audio/voices
Content-Type: multipart/form-data
```
Upload a new voice sample for voice cloning in Base task TTS requests.
**Form Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `audio_sample` | file | Yes | Audio file (max 10MB, supported formats: wav, mp3, flac, ogg, aac, webm, mp4) |
| `consent` | string | Yes | Consent recording ID |
| `name` | string | Yes | Name for the new voice |
**Response Example:**
```json
{
"success": true,
"voice": {
"name": "custom_voice_1",
"consent": "user_consent_id",
"created_at": 1738660000,
"mime_type": "audio/wav",
"file_size": 1024000
}
}
```
**Usage Example:**
```bash
curl -X POST http://localhost:8091/v1/audio/voices \
-F "audio_sample=@/path/to/voice_sample.wav" \
-F "consent=user_consent_id" \
-F "name=custom_voice_1"
```
```bash
DELETE /v1/audio/voices/{name}
```
Delete an uploaded voice sample.
**Path Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | Yes | Name of the voice to delete |
**Response Example:**
```json
{
"success": true,
"message": "Voice 'custom_voice_1' deleted successfully"
}
```
**Error Response (404 Not Found):**
```json
{
"success": false,
"error": "Voice 'unknown_voice' not found"
}
```
**Usage Example:**
```bash
curl -X DELETE http://localhost:8091/v1/audio/voices/custom_voice_1
```
## Examples
@@ -185,6 +260,25 @@ curl -X POST http://localhost:8091/v1/audio/speech \
}' --output cloned.wav
```
upload voice
```bash
curl -X POST http://localhost:8091/v1/audio/voices \
-F "audio_sample=@/path/to/voice_sample.wav" \
-F "consent=user_consent_id" \
-F "name=custom_voice_1"
```
use upload voice
```bash
curl -X POST http://localhost:8091/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
"input": "Hello, this is a cloned voice",
"task_type": "Base",
"voice": "custom_voice_1"
}' --output cloned.wav
```
## Supported Models
| Model | Task Type | Description |
@@ -136,9 +136,9 @@ python examples/offline_inference/text_to_image/text_to_image.py \
--height 768 \
--width 1360 \
--seed 42 \
--cfg_scale 4.0 \
--num_images_per_prompt 1 \
--num_inference_steps 4 \
--cfg-scale 4.0 \
--num-images-per-prompt 1 \
--num-inference-steps 4 \
--output outputs/flux2_klein_4b.png
```
@@ -152,9 +152,9 @@ python examples/offline_inference/text_to_image/text_to_image.py \
--height 768 \
--width 1360 \
--seed 42 \
--cfg_scale 4.0 \
--num_images_per_prompt 1 \
--num_inference_steps 4 \
--cfg-scale 4.0 \
--num-images-per-prompt 1 \
--num-inference-steps 4 \
--output outputs/flux2_klein_4b_gguf.png
```
@@ -11,7 +11,7 @@ Qwen3 TTS provides multiple task variants for speech generation:
- **CustomVoice**: Generate speech with a known speaker identity (speaker ID) and optional instruction.
- **VoiceDesign**: Generate speech from text plus a descriptive instruction that designs a new voice.
- **Base**: Voice cloning using a reference audio + reference transcript, with optional mode selection.
- **Base**: Voice cloning using a reference audio + reference transcript, with optional mode selection. The `ref_audio` field accepts a local file path, HTTP URL, or base64 data URL.
## Setup
Please refer to the [stage configuration documentation](https://docs.vllm.ai/projects/vllm-omni/en/latest/configuration/stage_configs/) to configure memory allocation appropriately for your hardware setup.
@@ -112,7 +112,7 @@ The Python client supports the following command-line arguments:
- `--voice`: Speaker/voice name (default: `vivian`). Options: `vivian`, `ryan`, `aiden`, etc.
- `--language`: Language. Options: `Auto`, `Chinese`, `English`, `Japanese`, `Korean`, `German`, `French`, `Russian`, `Portuguese`, `Spanish`, `Italian`
- `--instructions`: Voice style/emotion instructions
- `--ref-audio`: Reference audio file path or URL for voice cloning (Base task)
- `--ref-audio`: Reference audio file path or URL for voice cloning (Base task). Local paths are automatically base64-encoded by the client before sending to the server.
- `--ref-text`: Reference audio transcript for voice cloning (Base task)
- `--response-format`: Audio output format (default: `wav`). Options: `wav`, `mp3`, `flac`, `pcm`, `aac`, `opus`
- `--output` (or `-o`): Output audio file path (default: `tts_output.wav`)
@@ -220,7 +220,7 @@ Lists available voices for the loaded model:
"task_type": "CustomVoice",
"language": "Auto",
"instructions": "Optional style instructions",
"ref_audio": "URL or base64 for voice cloning",
"ref_audio": "HTTP URL, base64 data URL, or file:// URI for voice cloning",
"ref_text": "Reference audio transcript",
"x_vector_only_mode": false,
"max_new_tokens": 2048
@@ -261,7 +261,7 @@ Returns binary audio data with appropriate `Content-Type` header (e.g., `audio/w
| Parameter | Type | Required | Description |
| -------------------- | ------ | -------- | -------------------------------------------- |
| `ref_audio` | string | **Yes** | Reference audio (URL or base64 data URL) |
| `ref_audio` | string | **Yes** | Reference audio (HTTP URL, base64 data URL, or `file://` URI with `--allowed-local-media-path`) |
| `ref_text` | string | No | Transcript of reference audio (for ICL mode) |
| `x_vector_only_mode` | bool | No | Use speaker embedding only (no ICL) |
+69 -97
View File
@@ -1,8 +1,7 @@
import argparse
import os
from typing import cast
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniPromptType
from vllm_omni.inputs.data import OmniPromptType
def parse_args():
@@ -58,6 +57,7 @@ def parse_args():
choices=[1, 2, 3],
help="CFG parallel size: 1=batched (single GPU), 2=parallel with 2 branches (text CFG only), 3=parallel (3 GPUs).",
)
parser.add_argument("--seed", type=int, default=None, help="Random seed for generation.")
args = parser.parse_args()
return args
@@ -88,108 +88,80 @@ def main():
from PIL import Image
if args.modality == "img2img":
from vllm_omni.entrypoints.omni_diffusion import OmniDiffusion
from vllm_omni.entrypoints.omni import Omni
print(f"[Info] Running in {args.modality} mode (Stage 1 only, cfg_parallel_size={args.cfg_parallel_size})")
omni_kwargs = {}
if args.stage_configs_path:
omni_kwargs["stage_configs_path"] = args.stage_configs_path
client = OmniDiffusion(
model=model_name,
parallel_config={"cfg_parallel_size": args.cfg_parallel_size},
)
omni_kwargs.update(
{
"log_stats": args.log_stats,
"init_sleep_seconds": args.init_sleep_seconds,
"batch_timeout": args.batch_timeout,
"init_timeout": args.init_timeout,
"shm_threshold_bytes": args.shm_threshold_bytes,
"worker_backend": args.worker_backend,
"ray_address": args.ray_address,
}
)
if args.image_path:
if os.path.exists(args.image_path):
loaded_image = Image.open(args.image_path).convert("RGB")
prompts = [
{
"prompt": cast(str, p),
"multi_modal_data": {"image": loaded_image},
}
for p in prompts
]
else:
print(f"[Warning] Image path {args.image_path} does not exist.")
omni = Omni(model=model_name, **omni_kwargs)
result = client.generate(
prompts,
OmniDiffusionSamplingParams(
seed=52,
need_kv_receive=False,
num_inference_steps=args.steps,
extra_args={
"cfg_text_scale": args.cfg_text_scale,
"cfg_img_scale": args.cfg_img_scale,
},
),
)
# Ensure result is a list for iteration
if not isinstance(result, list):
omni_outputs = [result]
else:
omni_outputs = result
else:
from vllm_omni.entrypoints.omni import Omni
omni_kwargs = {}
if args.stage_configs_path:
omni_kwargs["stage_configs_path"] = args.stage_configs_path
omni_kwargs.update(
{
"log_stats": args.log_stats,
"init_sleep_seconds": args.init_sleep_seconds,
"batch_timeout": args.batch_timeout,
"init_timeout": args.init_timeout,
"shm_threshold_bytes": args.shm_threshold_bytes,
"worker_backend": args.worker_backend,
"ray_address": args.ray_address,
formatted_prompts = []
for p in prompts:
if args.modality == "img2img":
if not args.image_path or not os.path.exists(args.image_path):
raise ValueError(f"img2img requires --image-path pointing to an existing file, got: {args.image_path}")
loaded_image = Image.open(args.image_path).convert("RGB")
final_prompt_text = f"<|fim_middle|><|im_start|>{p}<|im_end|>"
prompt_dict = {
"prompt": final_prompt_text,
"multi_modal_data": {"img2img": loaded_image},
"modalities": ["img2img"],
}
)
omni = Omni(model=model_name, **omni_kwargs)
formatted_prompts = []
for p in args.prompts:
if args.modality == "img2text":
if args.image_path:
loaded_image = Image.open(args.image_path).convert("RGB")
final_prompt_text = f"<|im_start|>user\n<|image_pad|>\n{p}<|im_end|>\n<|im_start|>assistant\n"
prompt_dict = {
"prompt": final_prompt_text,
"multi_modal_data": {"image": loaded_image},
"modalities": ["text"],
}
formatted_prompts.append(prompt_dict)
elif args.modality == "text2text":
final_prompt_text = f"<|im_start|>user\n{p}<|im_end|>\n<|im_start|>assistant\n"
prompt_dict = {"prompt": final_prompt_text, "modalities": ["text"]}
formatted_prompts.append(prompt_dict)
else:
# text2img
final_prompt_text = f"<|im_start|>{p}<|im_end|>"
prompt_dict = {"prompt": final_prompt_text, "modalities": ["image"]}
if args.negative_prompt is not None:
prompt_dict["negative_prompt"] = args.negative_prompt
formatted_prompts.append(prompt_dict)
params_list = omni.default_sampling_params_list
if args.modality == "text2img":
params_list[0].max_tokens = 1 # type: ignore # The first stage is a SamplingParam (vllm)
if len(params_list) > 1:
diffusion_params = params_list[1]
diffusion_params.num_inference_steps = args.steps # type: ignore
extra = {
"cfg_text_scale": args.cfg_text_scale,
"cfg_img_scale": args.cfg_img_scale,
if args.negative_prompt is not None:
prompt_dict["negative_prompt"] = args.negative_prompt
formatted_prompts.append(prompt_dict)
elif args.modality == "img2text":
if args.image_path:
loaded_image = Image.open(args.image_path).convert("RGB")
final_prompt_text = f"<|im_start|>user\n<|image_pad|>\n{p}<|im_end|>\n<|im_start|>assistant\n"
prompt_dict = {
"prompt": final_prompt_text,
"multi_modal_data": {"image": loaded_image},
"modalities": ["text"],
}
if args.negative_prompt is not None:
extra["negative_prompt"] = args.negative_prompt
diffusion_params.extra_args = extra # type: ignore
formatted_prompts.append(prompt_dict)
elif args.modality == "text2text":
final_prompt_text = f"<|im_start|>user\n{p}<|im_end|>\n<|im_start|>assistant\n"
prompt_dict = {"prompt": final_prompt_text, "modalities": ["text"]}
formatted_prompts.append(prompt_dict)
else:
final_prompt_text = f"<|im_start|>{p}<|im_end|>"
prompt_dict = {"prompt": final_prompt_text, "modalities": ["image"]}
if args.negative_prompt is not None:
prompt_dict["negative_prompt"] = args.negative_prompt
formatted_prompts.append(prompt_dict)
omni_outputs = list(omni.generate(prompts=formatted_prompts, sampling_params_list=params_list))
params_list = omni.default_sampling_params_list
if args.modality in ("text2img", "img2img"):
params_list[0].max_tokens = 1 # type: ignore
if len(params_list) > 1:
diffusion_params = params_list[1]
diffusion_params.num_inference_steps = args.steps # type: ignore
diffusion_params.cfg_parallel_size = args.cfg_parallel_size # type: ignore
if args.seed is not None:
diffusion_params.seed = args.seed # type: ignore
extra = {
"cfg_text_scale": args.cfg_text_scale,
"cfg_img_scale": args.cfg_img_scale,
}
if args.negative_prompt is not None:
extra["negative_prompt"] = args.negative_prompt
diffusion_params.extra_args = extra # type: ignore
omni_outputs = list(omni.generate(prompts=formatted_prompts, sampling_params_list=params_list))
for i, req_output in enumerate(omni_outputs):
images = getattr(req_output, "images", None)
@@ -29,8 +29,8 @@ Accelerated with cache_dit:
python image_edit.py \
--image input.png \
--prompt "Edit description" \
--cache_backend cache_dit \
--cache_dit_enable_taylorseer
--cache-backend cache_dit \
--cache-dit-enable-taylorseer
Layered RGBA output:
python image_edit.py \
@@ -66,40 +66,39 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--model", default="Qwen/Qwen-Image-Edit", help="Model name or local path.")
parser.add_argument("--image", type=str, nargs="+", required=True, help="Input image file(s).")
parser.add_argument("--prompt", type=str, required=True, help="Edit description prompt.")
parser.add_argument("--negative_prompt", type=str, default=None)
parser.add_argument("--negative-prompt", type=str, default=None)
parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--cfg_scale", type=float, default=4.0)
parser.add_argument("--guidance_scale", type=float, default=1.0)
parser.add_argument("--cfg-scale", type=float, default=4.0)
parser.add_argument("--guidance-scale", type=float, default=1.0)
parser.add_argument("--output", type=str, default="output.png")
parser.add_argument("--num_outputs_per_prompt", type=int, default=1)
parser.add_argument("--num_inference_steps", type=int, default=50)
parser.add_argument("--cache_backend", type=str, default=None, choices=["cache_dit", "tea_cache"])
parser.add_argument("--ulysses_degree", type=int, default=1)
parser.add_argument("--ring_degree", type=int, default=1)
parser.add_argument("--tensor_parallel_size", type=int, default=1)
parser.add_argument("--num-outputs-per-prompt", type=int, default=1)
parser.add_argument("--num-inference-steps", type=int, default=50)
parser.add_argument("--cache-backend", type=str, default=None, choices=["cache_dit", "tea_cache"])
parser.add_argument("--ulysses-degree", type=int, default=1)
parser.add_argument("--ring-degree", type=int, default=1)
parser.add_argument("--tensor-parallel-size", type=int, default=1)
parser.add_argument("--layers", type=int, default=4)
parser.add_argument("--resolution", type=int, default=640)
parser.add_argument("--color-format", type=str, default="RGB")
# Acceleration + Optimization Options
parser.add_argument("--cache_dit_fn_compute_blocks", type=int, default=1)
parser.add_argument("--cache_dit_bn_compute_blocks", type=int, default=0)
parser.add_argument("--cache_dit_max_warmup_steps", type=int, default=4)
parser.add_argument("--cache_dit_residual_diff_threshold", type=float, default=0.24)
parser.add_argument("--cache_dit_max_continuous_cached_steps", type=int, default=3)
parser.add_argument("--cache_dit_enable_taylorseer", action="store_true", default=False)
parser.add_argument("--cache_dit_taylorseer_order", type=int, default=1)
parser.add_argument("--cache-dit-fn-compute-blocks", type=int, default=1)
parser.add_argument("--cache-dit-bn-compute-blocks", type=int, default=0)
parser.add_argument("--cache-dit-max-warmup-steps", type=int, default=4)
parser.add_argument("--cache-dit-residual-diff-threshold", type=float, default=0.24)
parser.add_argument("--cache-dit-max-continuous-cached-steps", type=int, default=3)
parser.add_argument("--cache-dit-enable-taylorseer", action="store_true", default=False)
parser.add_argument("--cache-dit-taylorseer-order", type=int, default=1)
parser.add_argument(
"--cache_dit_scm_steps_mask_policy", type=str, default=None, choices=[None, "slow", "medium", "fast", "ultra"]
"--cache-dit-scm-steps-mask-policy", type=str, default=None, choices=[None, "slow", "medium", "fast", "ultra"]
)
parser.add_argument("--cache_dit_scm_steps_policy", type=str, default="dynamic", choices=["dynamic", "static"])
parser.add_argument("--tea_cache_rel_l1_thresh", type=float, default=0.2)
parser.add_argument("--cfg_parallel_size", type=int, default=1, choices=[1, 2])
parser.add_argument("--enforce_eager", action="store_true")
parser.add_argument("--vae_use_slicing", action="store_true")
parser.add_argument("--vae_use_tiling", action="store_true")
parser.add_argument("--cache-dit-scm-steps-policy", type=str, default="dynamic", choices=["dynamic", "static"])
parser.add_argument("--tea-cache-rel-l1-thresh", type=float, default=0.2)
parser.add_argument("--cfg-parallel-size", type=int, default=1, choices=[1, 2])
parser.add_argument("--enforce-eager", action="store_true")
parser.add_argument("--vae-use-slicing", action="store_true")
parser.add_argument("--vae-use-tiling", action="store_true")
parser.add_argument("--enable-cpu-offload", action="store_true")
parser.add_argument("--log-stats", "--log_stats", dest="log_stats", action="store_true", default=False)
return parser.parse_args()
@@ -156,7 +155,6 @@ async def main():
cache_config=cache_config,
parallel_config=parallel_config,
enforce_eager=args.enforce_eager,
log_stats=args.log_stats,
enable_cpu_offload=args.enable_cpu_offload,
diffusion_load_format="dummy",
custom_pipeline_args={"pipeline_class": "custom_pipeline.CustomPipeline"},
@@ -3,4 +3,4 @@ python image_edit.py \
--image cherry_blossom.jpg \
--prompt "Let this mascot dance under the moon, surrounded by floating stars and poetic bubbles such as 'Be Kind'" \
--output output_image_edit.png \
--num_inference_steps 10
--num-inference-steps 10
@@ -320,11 +320,6 @@ def parse_args() -> argparse.Namespace:
action="store_true",
help="Enable layerwise (blockwise) offloading on DiT modules.",
)
parser.add_argument(
"--log-stats",
action="store_true",
help="Enable vLLM-Omni statistics logging.",
)
return parser.parse_args()
@@ -388,7 +383,6 @@ def main():
parallel_config=parallel_config,
enforce_eager=args.enforce_eager,
enable_cpu_offload=args.enable_cpu_offload,
log_stats=args.log_stats,
)
print("Pipeline loaded")
@@ -22,7 +22,7 @@ Usage:
python image_to_video.py --model /path/to/LTX-2 \
--model-class-name LTX2ImageToVideoPipeline \
--image input.jpg --prompt "A cinematic dolly shot of a boat" \
--num-frames 121 --num_inference_steps 40 --guidance_scale 4.0 \
--num-frames 121 --num-inference-steps 40 --guidance-scale 4.0 \
--frame-rate 24 --fps 24 --output ltx2_i2v.mp4
"""
@@ -176,11 +176,6 @@ def parse_args() -> argparse.Namespace:
"Default 1 means pure sharding (no replication). "
),
)
parser.add_argument(
"--log-stats",
action="store_true",
help="Enable vLLM-Omni statistics logging.",
)
return parser.parse_args()
@@ -283,7 +278,6 @@ def main():
enable_cpu_offload=args.enable_cpu_offload,
parallel_config=parallel_config,
enforce_eager=args.enforce_eager,
log_stats=args.log_stats,
model_class_name=model_class_name,
cache_backend=args.cache_backend,
cache_config=cache_config,
@@ -64,6 +64,51 @@ If media file paths are not provided, the script will use default assets. Suppor
- `multi_audios`: Multiple audio inputs
- `mixed_modalities`: Combination of video, image, and audio inputs
### Async-chunk (offline)
For true stage-level concurrency -- where downstream stages (Talker, Code2Wav)
start **before** the upstream stage (Thinker) finishes -- use the async_chunk
example. This requires:
1. A stage config YAML with ``async_chunk: true`` (e.g.
``qwen3_omni_moe_async_chunk.yaml``).
2. Hardware that matches the config (e.g. 2x H100 for the default 3-stage
config).
The async_chunk example uses ``AsyncOmni`` instead of the synchronous ``Omni``
class, which enables the async orchestrator to receive stage-0 intermediate
outputs and trigger downstream stages early. Chunk data flows directly between
stage workers via the in-worker ``OmniChunkTransferAdapter`` / connector,
**not** through the orchestrator.
#### Single prompt
```bash
cd examples/offline_inference/qwen3_omni
bash run_single_prompt_async_chunk.sh
```
#### Multiple prompts with concurrency control
```bash
bash run_multiple_prompts_async_chunk.sh --max-in-flight 4
```
#### Text-only output (skip audio generation)
```bash
python end2end_async_chunk.py --query-type text --modalities text
```
#### Custom stage config
```bash
python end2end_async_chunk.py \
--query-type use_audio \
--stage-configs-path /path/to/your_async_chunk.yaml
```
> **Note**: The synchronous ``end2end.py`` (using ``Omni``) is still the
> recommended entry point for non-async-chunk workflows. Only use the
> async_chunk example when you need the stage-level concurrency semantics
> described in PR #962 / #1151.
### FAQ
If you encounter error about backend of librosa, try to install ffmpeg with command below.
@@ -0,0 +1,614 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Offline inference with async_chunk enabled via AsyncOmni.
This script uses AsyncOmni (the async orchestrator) to run offline inference
with async_chunk semantics: downstream stages (Talker, Code2Wav) start
*before* upstream stages finish, consuming chunks as they arrive via
the in-worker OmniChunkTransferAdapter / connector.
Compared to the synchronous ``end2end.py`` (which uses ``Omni``), this
entry point achieves true stage-level concurrency -- stage-1/2 are
actively processing while stage-0 is still generating.
Usage
-----
python end2end_async_chunk.py --query-type use_audio \
--stage-configs-path <path-to-async-chunk-yaml>
See ``--help`` for all options.
"""
import asyncio
import logging
import os
import time
import uuid
from typing import NamedTuple
import numpy as np
import soundfile as sf
import torch
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
import librosa
from PIL import Image
from vllm import SamplingParams
from vllm.assets.audio import AudioAsset
from vllm.assets.image import ImageAsset
from vllm.assets.video import VideoAsset, video_to_ndarrays
from vllm.multimodal.image import convert_image_mode
from vllm.utils.argparse_utils import FlexibleArgumentParser
from vllm_omni.entrypoints.async_omni import AsyncOmni
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Query builders (reuse the patterns from end2end.py)
# ---------------------------------------------------------------------------
default_system = (
"You are Qwen, a virtual human developed by the Qwen Team, Alibaba "
"Group, capable of perceiving auditory and visual inputs, as well as "
"generating text and speech."
)
class QueryResult(NamedTuple):
inputs: dict
limit_mm_per_prompt: dict[str, int]
def get_text_query(question: str = None) -> QueryResult:
if question is None:
question = "Explain the system architecture for a scalable audio generation pipeline. Answer in 15 words."
prompt = (
f"<|im_start|>system\n{default_system}<|im_end|>\n"
"<|im_start|>user\n"
f"{question}<|im_end|>\n"
"<|im_start|>assistant\n"
)
return QueryResult(inputs={"prompt": prompt}, limit_mm_per_prompt={})
def get_audio_query(
question: str = None,
audio_path: str | None = None,
sampling_rate: int = 16000,
) -> QueryResult:
if question is None:
question = "What is the content of this audio?"
prompt = (
f"<|im_start|>system\n{default_system}<|im_end|>\n"
"<|im_start|>user\n<|audio_start|><|audio_pad|><|audio_end|>"
f"{question}<|im_end|>\n"
"<|im_start|>assistant\n"
)
if audio_path:
if not os.path.exists(audio_path):
raise FileNotFoundError(f"Audio file not found: {audio_path}")
audio_signal, sr = librosa.load(audio_path, sr=sampling_rate)
audio_data = (audio_signal.astype(np.float32), sr)
else:
audio_data = AudioAsset("mary_had_lamb").audio_and_sample_rate
return QueryResult(
inputs={
"prompt": prompt,
"multi_modal_data": {"audio": audio_data},
},
limit_mm_per_prompt={"audio": 1},
)
def get_image_query(question: str = None, image_path: str | None = None) -> QueryResult:
if question is None:
question = "What is the content of this image?"
prompt = (
f"<|im_start|>system\n{default_system}<|im_end|>\n"
"<|im_start|>user\n<|vision_start|><|image_pad|><|vision_end|>"
f"{question}<|im_end|>\n"
"<|im_start|>assistant\n"
)
if image_path:
if not os.path.exists(image_path):
raise FileNotFoundError(f"Image file not found: {image_path}")
pil_image = Image.open(image_path)
image_data = convert_image_mode(pil_image, "RGB")
else:
image_data = convert_image_mode(ImageAsset("cherry_blossom").pil_image, "RGB")
return QueryResult(
inputs={
"prompt": prompt,
"multi_modal_data": {"image": image_data},
},
limit_mm_per_prompt={"image": 1},
)
def get_video_query(
question: str = None,
video_path: str | None = None,
num_frames: int = 16,
) -> QueryResult:
if question is None:
question = "Why is this video funny?"
prompt = (
f"<|im_start|>system\n{default_system}<|im_end|>\n"
"<|im_start|>user\n<|vision_start|><|video_pad|><|vision_end|>"
f"{question}<|im_end|>\n"
"<|im_start|>assistant\n"
)
if video_path:
if not os.path.exists(video_path):
raise FileNotFoundError(f"Video file not found: {video_path}")
video_frames = video_to_ndarrays(video_path, num_frames=num_frames)
else:
video_frames = VideoAsset(name="baby_reading", num_frames=num_frames).np_ndarrays
return QueryResult(
inputs={
"prompt": prompt,
"multi_modal_data": {"video": video_frames},
},
limit_mm_per_prompt={"video": 1},
)
query_map = {
"text": get_text_query,
"use_audio": get_audio_query,
"use_image": get_image_query,
"use_video": get_video_query,
}
# ---------------------------------------------------------------------------
# Core async routine
# ---------------------------------------------------------------------------
def clone_prompt_for_request(template: dict) -> dict:
"""Shallow-clone prompt dict so concurrent requests own independent containers."""
cloned = dict(template)
for key in ("multi_modal_data", "mm_processor_kwargs", "additional_information"):
value = template.get(key)
if isinstance(value, dict):
cloned[key] = dict(value)
elif isinstance(value, list):
cloned[key] = list(value)
return cloned
def _default_async_chunk_stage_configs_path() -> str | None:
"""Best-effort default stage config for running Qwen3-Omni with async_chunk.
When this example is executed from within the repository, we resolve the
default YAML path relative to this file. When installed elsewhere, the
file may not exist and callers should pass --stage-configs-path explicitly.
"""
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))
candidate = os.path.join(
repo_root,
"vllm_omni",
"model_executor",
"stage_configs",
"qwen3_omni_moe_async_chunk.yaml",
)
return candidate if os.path.exists(candidate) else None
async def run_single_request(
async_omni: AsyncOmni,
prompt: dict,
request_id: str,
sampling_params_list: list[SamplingParams] | None,
output_dir: str,
output_modalities: list[str] | None = None,
stream_audio_to_disk: bool = False,
) -> dict:
"""Run one request through AsyncOmni and collect outputs.
Returns a dict with timing information and saved file paths.
"""
t_start = time.perf_counter()
text_parts: list[str] = []
audio_chunks: list[torch.Tensor] = []
audio_sr: int | None = None
first_audio_ts: float | None = None
audio_list_consumed: int = 0
audio_last_tensor: torch.Tensor | None = None
stage_0_first_output_ts: float | None = None
samplerate = 24000
wav_file = os.path.join(output_dir, f"output_{request_id}.wav")
sf_writer: sf.SoundFile | None = None
audio_samples_written: int = 0
try:
async for omni_output in async_omni.generate(
prompt=prompt,
request_id=request_id,
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
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)
finally:
if sf_writer is not None:
sf_writer.close()
t_end = time.perf_counter()
result = {
"request_id": request_id,
"e2e_latency_s": t_end - t_start,
"saved_files": [],
}
# Save text output
if text_parts:
text_file = os.path.join(output_dir, f"{request_id}.txt")
with open(text_file, "w", encoding="utf-8") as f:
f.write("\n".join(text_parts))
result["saved_files"].append(text_file)
print(
f"[Request {request_id}] Text saved to {text_file} "
f"(stage-0 first output at {stage_0_first_output_ts - t_start:.3f}s)"
)
# Save audio output
if stream_audio_to_disk and audio_samples_written > 0:
result["saved_files"].append(wav_file)
result["audio_duration_s"] = audio_samples_written / samplerate
result["num_audio_chunks"] = audio_list_consumed
ttfa = (first_audio_ts - t_start) if first_audio_ts else None
result["time_to_first_audio_s"] = ttfa
ttfa_str = f"{ttfa:.3f}s" if ttfa is not None else "N/A"
print(
f"[Request {request_id}] Audio streamed to {wav_file} "
f"(duration={result['audio_duration_s']:.2f}s, "
f"TTFA={ttfa_str}, "
f"e2e={result['e2e_latency_s']:.3f}s)"
)
elif audio_chunks or audio_last_tensor is not None:
if audio_chunks:
if len(audio_chunks) > 1:
audio_tensor = torch.cat(audio_chunks, dim=-1)
else:
audio_tensor = audio_chunks[0]
else:
audio_tensor = audio_last_tensor
audio_numpy = audio_tensor.float().detach().cpu().numpy()
if audio_numpy.ndim > 1:
audio_numpy = audio_numpy.flatten()
sf.write(wav_file, audio_numpy, samplerate=samplerate, format="WAV")
result["saved_files"].append(wav_file)
result["audio_duration_s"] = len(audio_numpy) / samplerate
result["num_audio_chunks"] = len(audio_chunks)
ttfa = (first_audio_ts - t_start) if first_audio_ts else None
result["time_to_first_audio_s"] = ttfa
ttfa_str = f"{ttfa:.3f}s" if ttfa is not None else "N/A"
print(
f"[Request {request_id}] Audio saved to {wav_file} "
f"({len(audio_chunks)} chunks, "
f"duration={result['audio_duration_s']:.2f}s, "
f"TTFA={ttfa_str}, "
f"e2e={result['e2e_latency_s']:.3f}s)"
)
return result
async def run_all(args):
"""Main async entry: build prompts, create AsyncOmni, run requests."""
# Build query
query_func = query_map[args.query_type]
if args.query_type == "use_video":
query_result = query_func(
video_path=getattr(args, "video_path", None),
num_frames=getattr(args, "num_frames", 16),
)
elif args.query_type == "use_image":
query_result = query_func(image_path=getattr(args, "image_path", None))
elif args.query_type == "use_audio":
query_result = query_func(
audio_path=getattr(args, "audio_path", None),
sampling_rate=getattr(args, "sampling_rate", 16000),
)
else:
query_result = query_func()
# Build prompt list
if args.txt_prompts is not None:
assert args.query_type == "text", "txt-prompts is only supported for text query type"
with open(args.txt_prompts, encoding="utf-8") as f:
lines = [ln.strip() for ln in f if ln.strip()]
prompts = [get_text_query(ln).inputs for ln in lines]
print(f"[Info] Loaded {len(prompts)} prompts from {args.txt_prompts}")
else:
prompts = [clone_prompt_for_request(query_result.inputs) for _ in range(args.num_prompts)]
# Inject output modalities if specified
output_modalities = None
if args.modalities is not None:
output_modalities = args.modalities.split(",")
for prompt in prompts:
prompt["modalities"] = output_modalities
# Create AsyncOmni
print(f"[Info] Creating AsyncOmni with stage_configs_path={args.stage_configs_path}")
async_omni = None
try:
async_omni = AsyncOmni(
model=args.model,
stage_configs_path=args.stage_configs_path,
log_stats=args.log_stats,
stage_init_timeout=args.stage_init_timeout,
)
# Use default sampling params from stage config (they are pre-configured
# in the YAML for each stage).
sampling_params_list = None
output_dir = args.output_dir
os.makedirs(output_dir, exist_ok=True)
# Run requests with concurrency control
semaphore = asyncio.Semaphore(args.max_in_flight)
request_timeout = getattr(args, "request_timeout_s", None)
stream_audio = getattr(args, "stream_audio_to_disk", False)
async def _run_one(idx: int, prompt: dict):
async with semaphore:
request_id = f"req_{idx}_{uuid.uuid4().hex[:8]}"
coro = run_single_request(
async_omni=async_omni,
prompt=prompt,
request_id=request_id,
sampling_params_list=sampling_params_list,
output_dir=output_dir,
output_modalities=output_modalities,
stream_audio_to_disk=stream_audio,
)
if request_timeout and request_timeout > 0:
return await asyncio.wait_for(coro, timeout=request_timeout)
return await coro
wall_start = time.perf_counter()
tasks = [_run_one(i, p) for i, p in enumerate(prompts)]
all_results = await asyncio.gather(*tasks, return_exceptions=True)
wall_end = time.perf_counter()
# Print summary
print("\n" + "=" * 60)
print("Summary")
print("=" * 60)
success_count = 0
total_audio_dur = 0.0
for r in all_results:
if isinstance(r, Exception):
print(f" [ERROR] {type(r).__name__}: {r}")
else:
success_count += 1
total_audio_dur += r.get("audio_duration_s", 0.0)
print(f" [{r['request_id']}] e2e={r['e2e_latency_s']:.3f}s files={r['saved_files']}")
wall_time = wall_end - wall_start
print(f"\nTotal: {success_count}/{len(prompts)} succeeded")
print(f"Wall time: {wall_time:.3f}s")
if total_audio_dur > 0:
print(f"Total audio duration: {total_audio_dur:.2f}s")
print(f"Real-time factor: {total_audio_dur / wall_time:.2f}x")
print("=" * 60)
finally:
if async_omni is not None:
async_omni.shutdown()
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args():
parser = FlexibleArgumentParser(
description=(
"Offline inference with async_chunk enabled via AsyncOmni. "
"Downstream stages start before upstream stages finish, "
"achieving true stage-level concurrency."
)
)
parser.add_argument(
"--model",
type=str,
default="Qwen/Qwen3-Omni-30B-A3B-Instruct",
help="Model name or path.",
)
parser.add_argument(
"--query-type",
"-q",
type=str,
default="use_audio",
choices=query_map.keys(),
help="Query type.",
)
parser.add_argument(
"--stage-configs-path",
type=str,
default=_default_async_chunk_stage_configs_path(),
help=(
"Path to an async_chunk stage config YAML. "
"If not set, uses the model's default config "
"(make sure it has async_chunk: true)."
),
)
parser.add_argument(
"--log-stats",
action="store_true",
default=False,
help="Enable writing detailed statistics.",
)
parser.add_argument(
"--stage-init-timeout",
type=int,
default=300,
help="Timeout for initializing a single stage (seconds).",
)
parser.add_argument(
"--output-dir",
type=str,
default="output_audio_async_chunk",
help="Directory to save output files.",
)
parser.add_argument(
"--num-prompts",
type=int,
default=1,
help="Number of prompts to generate (duplicated from query).",
)
parser.add_argument(
"--txt-prompts",
type=str,
default=None,
help="Path to a .txt file with one prompt per line.",
)
parser.add_argument(
"--max-in-flight",
type=int,
default=1,
help="Maximum concurrent requests (default: 1).",
)
parser.add_argument(
"--request-timeout-s",
type=float,
default=None,
help=(
"Per-request timeout in seconds. When set, a request that "
"exceeds this duration is cancelled and reported as an error. "
"Default: None (no timeout)."
),
)
parser.add_argument(
"--batch-timeout-s",
type=float,
default=None,
help=(
"Global timeout for the entire batch in seconds. When set, "
"the whole run_all() is cancelled if it exceeds this duration. "
"Default: None (no global timeout)."
),
)
parser.add_argument(
"--stream-audio-to-disk",
action="store_true",
default=False,
help=(
"Write audio chunks to WAV incrementally instead of "
"accumulating in memory. Useful for very long audio or "
"high --max-in-flight to reduce memory footprint."
),
)
parser.add_argument(
"--modalities",
type=str,
default=None,
help="Comma-separated output modalities filter (e.g. 'text', 'audio', 'text,audio').",
)
parser.add_argument(
"--audio-path",
"-a",
type=str,
default=None,
help="Path to local audio file.",
)
parser.add_argument(
"--image-path",
"-i",
type=str,
default=None,
help="Path to local image file.",
)
parser.add_argument(
"--video-path",
"-v",
type=str,
default=None,
help="Path to local video file.",
)
parser.add_argument(
"--num-frames",
type=int,
default=16,
help="Number of frames to extract from video.",
)
parser.add_argument(
"--sampling-rate",
type=int,
default=16000,
help="Sampling rate for audio loading.",
)
return parser.parse_args()
if __name__ == "__main__":
args = parse_args()
async def _main():
batch_timeout = getattr(args, "batch_timeout_s", None)
if batch_timeout and batch_timeout > 0:
await asyncio.wait_for(run_all(args), timeout=batch_timeout)
else:
await run_all(args)
try:
asyncio.run(_main())
except asyncio.TimeoutError:
print(
f"\n[TIMEOUT] Batch exceeded --batch-timeout-s="
f"{args.batch_timeout_s}s. AsyncOmni shutdown was handled "
f"by finally block."
)
except KeyboardInterrupt:
print("\nInterrupted by user. AsyncOmni shutdown was handled by finally block.")
@@ -0,0 +1,23 @@
#!/bin/bash
# Run multiple Qwen3-Omni requests with async_chunk enabled.
#
# Uses AsyncOmni with --max-in-flight to control request-level
# concurrency (each request still gets true stage-level concurrency
# via async_chunk).
#
# Usage:
# bash run_multiple_prompts_async_chunk.sh
# bash run_multiple_prompts_async_chunk.sh --max-in-flight 4
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
python "${SCRIPT_DIR}/end2end_async_chunk.py" \
--query-type text \
--txt-prompts "${SCRIPT_DIR}/text_prompts_10.txt" \
--stage-configs-path "${REPO_ROOT}/vllm_omni/model_executor/stage_configs/qwen3_omni_moe_async_chunk.yaml" \
--output-dir output_audio_async_chunk \
--max-in-flight 2 \
"$@"
@@ -0,0 +1,26 @@
#!/bin/bash
# Run a single Qwen3-Omni request with async_chunk enabled.
#
# This uses AsyncOmni (async orchestrator) so that downstream stages
# (Talker, Code2Wav) start *before* stage-0 (Thinker) finishes,
# achieving true stage-level concurrency via chunk-level streaming.
#
# Prerequisites:
# - An async_chunk stage config YAML (e.g. qwen3_omni_moe_async_chunk.yaml)
# - Hardware matching the config (e.g. 2x H100 for the default 3-stage config)
#
# Usage:
# bash run_single_prompt_async_chunk.sh
# bash run_single_prompt_async_chunk.sh --query-type text --modalities text
# bash run_single_prompt_async_chunk.sh --stage-configs-path /path/to/custom.yaml
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
python "${SCRIPT_DIR}/end2end_async_chunk.py" \
--query-type use_audio \
--stage-configs-path "${REPO_ROOT}/vllm_omni/model_executor/stage_configs/qwen3_omni_moe_async_chunk.yaml" \
--output-dir output_audio_async_chunk \
"$@"
@@ -79,10 +79,16 @@ def get_custom_voice_query(use_batch_sample: bool = False) -> QueryResult:
task_type = "CustomVoice"
model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
if use_batch_sample:
texts = ["其实我真的有发现,我是一个特别善于观察别人情绪的人。", "She said she would be here by noon."]
instructs = ["", "Very happy."]
languages = ["Chinese", "English"]
speakers = ["Vivian", "Ryan"]
texts = [
"其实我真的有发现,我是一个特别善于观察别人情绪的人。",
"She said she would be here by noon.",
"I like you very much.",
"Really, you do?",
"Yes, absolutely.",
]
instructs = ["", "Very happy.", "Very happy.", "Very happy.", "Very happy."]
languages = ["Chinese", "English", "English", "English", "English"]
speakers = ["Vivian", "Ryan", "Ryan", "Ryan", "Ryan"]
inputs = []
for text, instruct, language, speaker in zip(texts, instructs, languages, speakers):
additional_information = {
@@ -94,11 +94,6 @@ def parse_args() -> argparse.Namespace:
action="store_true",
help="Enable cache-dit summary logging after diffusion forward passes.",
)
parser.add_argument(
"--log-stats",
action="store_true",
help="Enable vLLM-Omni statistics logging.",
)
parser.add_argument(
"--ulysses-degree",
type=int,
@@ -304,7 +299,6 @@ def main():
"parallel_config": parallel_config,
"enforce_eager": args.enforce_eager,
"enable_cpu_offload": args.enable_cpu_offload,
"log_stats": args.log_stats,
**lora_args,
**quant_kwargs,
}
@@ -127,11 +127,6 @@ def parse_args() -> argparse.Namespace:
default=1,
help="Number of GPUs used for VAE patch/tile parallelism (decode).",
)
parser.add_argument(
"--log-stats",
action="store_true",
help="Enable vLLM-Omni statistics logging.",
)
return parser.parse_args()
@@ -181,7 +176,6 @@ def main():
enable_cpu_offload=args.enable_cpu_offload,
parallel_config=parallel_config,
enforce_eager=args.enforce_eager,
log_stats=args.log_stats,
cache_backend=args.cache_backend,
cache_config=cache_config,
)
+53 -14
View File
@@ -184,6 +184,59 @@ sudo apt install ffmpeg
## API Reference
### Voices Endpoint
#### GET /v1/audio/voices
List all available voices/speakers from the loaded model, including both built-in model voices and uploaded custom voices.
**Response Example:**
```json
{
"voices": ["vivian", "ryan", "custom_voice_1"],
"uploaded_voices": [
{
"name": "custom_voice_1",
"consent": "user_consent_id",
"created_at": 1738660000,
"file_size": 1024000,
"mime_type": "audio/wav"
}
]
}
```
#### POST /v1/audio/voices
Upload a new voice sample for voice cloning in Base task TTS requests.
**Form Parameters:**
- `audio_sample` (required): Audio file (max 10MB, supported formats: wav, mp3, flac, ogg, aac, webm, mp4)
- `consent` (required): Consent recording ID
- `name` (required): Name for the new voice
**Response Example:**
```json
{
"success": true,
"voice": {
"name": "custom_voice_1",
"consent": "user_consent_id",
"created_at": 1738660000,
"mime_type": "audio/wav",
"file_size": 1024000
}
}
```
**Usage Example:**
```bash
curl -X POST http://localhost:8000/v1/audio/voices \
-F "audio_sample=@/path/to/voice_sample.wav" \
-F "consent=user_consent_id" \
-F "name=custom_voice_1"
```
### Endpoint
```
@@ -193,20 +246,6 @@ Content-Type: application/json
This endpoint follows the [OpenAI Audio Speech API](https://platform.openai.com/docs/api-reference/audio/createSpeech) format with additional Qwen3-TTS parameters.
### Voices Endpoint
```
GET /v1/audio/voices
```
Lists available voices for the loaded model:
```json
{
"voices": ["aiden", "dylan", "eric", "one_anna", "ryan", "serena", "sohee", "uncle_fu", "vivian"]
}
```
### Request Body
```json
+1
View File
@@ -51,6 +51,7 @@ dev = [
"mooncake-transfer-engine==0.3.8.post1",
"av", # for ComfyUI tests
"openpyxl>=3.0.0", # for nightly CI
"pyttsx3>=2.99"
]
docs = [
+200 -94
View File
@@ -12,6 +12,7 @@ if "VLLM_TARGET_DEVICE" not in os.environ:
import concurrent.futures
import gc
import multiprocessing
import socket
import subprocess
import sys
@@ -217,102 +218,193 @@ def generate_synthetic_audio(
sample_rate: int = 48000, # Default use 48000Hz.
save_to_file: bool = False,
) -> dict[str, Any]:
""" "Generate synthetic audio with rain."""
"""
Generate TTS speech with pyttsx3 and return base64 string.
"""
import tempfile
import pyttsx3
import soundfile as sf
# Initialize audio data array
num_samples = int(sample_rate * duration)
def _pick_voice(engine: pyttsx3.Engine) -> str | None:
voices = engine.getProperty("voices")
if not voices:
return None
preferred_tokens = (
"natural",
"jenny",
"sonia",
"susan",
"zira",
"aria",
"hazel",
"samantha",
"ava",
"allison",
"female",
"woman",
"english-us",
"en-us",
"english",
)
discouraged_tokens = (
"espeak",
"robot",
"mbrola",
"microsoft david",
"male",
"man",
)
best_voice = voices[0]
best_score = float("-inf")
for voice in voices:
voice_text = f"{getattr(voice, 'id', '')} {getattr(voice, 'name', '')}".lower()
voice_languages = " ".join(
lang.decode(errors="ignore") if isinstance(lang, bytes) else str(lang)
for lang in getattr(voice, "languages", [])
).lower()
combined_text = f"{voice_text} {voice_languages}"
score = 0
for idx, token in enumerate(preferred_tokens):
if token in combined_text:
score += 20 - idx
for token in discouraged_tokens:
if token in combined_text:
score -= 10
if "english" in combined_text or "en_" in combined_text or "en-" in combined_text:
score += 4
if "en-us" in combined_text or "english-us" in combined_text:
score += 4
if score > best_score:
best_score = score
best_voice = voice
return best_voice.id
def _resample_audio(audio: np.ndarray, src_sr: int, dst_sr: int) -> np.ndarray:
if src_sr == dst_sr or len(audio) == 0:
return audio.astype(np.float32)
src_len = audio.shape[0]
dst_len = max(1, int(round(src_len * float(dst_sr) / float(src_sr))))
src_idx = np.arange(src_len, dtype=np.float32)
dst_idx = np.linspace(0, src_len - 1, dst_len, dtype=np.float32)
resampled_channels: list[np.ndarray] = []
for ch in range(audio.shape[1]):
resampled_channels.append(np.interp(dst_idx, src_idx, audio[:, ch]).astype(np.float32))
return np.stack(resampled_channels, axis=1)
def _match_channels(audio: np.ndarray, target_channels: int) -> np.ndarray:
current_channels = audio.shape[1]
if current_channels == target_channels:
return audio.astype(np.float32)
if target_channels == 1:
return np.mean(audio, axis=1, keepdims=True, dtype=np.float32)
if current_channels == 1:
return np.repeat(audio, target_channels, axis=1).astype(np.float32)
collapsed = np.mean(audio, axis=1, keepdims=True, dtype=np.float32)
return np.repeat(collapsed, target_channels, axis=1).astype(np.float32)
def _trim_silence(audio: np.ndarray, threshold: float = 0.01) -> np.ndarray:
if len(audio) == 0:
return audio
energy = np.max(np.abs(audio), axis=1)
voiced = np.where(energy > threshold)[0]
if len(voiced) == 0:
return audio
start = max(0, int(voiced[0]) - int(sample_rate * 0.02))
end = min(len(audio), int(voiced[-1]) + int(sample_rate * 0.04) + 1)
return audio[start:end]
def _enhance_speech(audio: np.ndarray) -> np.ndarray:
if len(audio) == 0:
return audio.astype(np.float32)
enhanced = audio.astype(np.float32).copy()
enhanced -= np.mean(enhanced, axis=0, keepdims=True, dtype=np.float32)
if len(enhanced) > 1:
preemphasis = enhanced.copy()
preemphasis[1:] = enhanced[1:] - 0.94 * enhanced[:-1]
enhanced = 0.7 * enhanced + 0.3 * preemphasis
# Mild dynamic-range compression for ASR/TTS robustness.
enhanced = np.sign(enhanced) * np.sqrt(np.abs(enhanced))
# Light fade to avoid clicks after trimming/repeating.
fade = min(len(enhanced) // 4, max(1, int(sample_rate * 0.01)))
if fade > 1:
ramp_in = np.linspace(0.0, 1.0, fade, dtype=np.float32)
ramp_out = np.linspace(1.0, 0.0, fade, dtype=np.float32)
enhanced[:fade] *= ramp_in[:, None]
enhanced[-fade:] *= ramp_out[:, None]
peak = float(np.max(np.abs(enhanced)))
if peak > 1e-8:
enhanced = enhanced / peak * 0.95
return enhanced.astype(np.float32)
phrase_text = "test"
num_samples = int(sample_rate * max(1, duration))
audio_data = np.zeros((num_samples, num_channels), dtype=np.float32)
# Configure parameters based on rain intensity
drop_density = 10 # Number of raindrops per second
drop_volume = 0.15 # Volume of individual raindrops
background_volume = 0.02 # Volume of background rain noise
engine = pyttsx3.init()
engine.setProperty("rate", 112)
engine.setProperty("volume", 1.0)
selected_voice = _pick_voice(engine)
if selected_voice is not None:
engine.setProperty("voice", selected_voice)
# Pink noise sounds more natural than white noise for rain
white_noise = np.random.randn(num_samples)
pink_noise = np.convolve(white_noise, np.ones(8) / 8, mode="same")
pink_noise = pink_noise / np.max(np.abs(pink_noise)) if np.max(np.abs(pink_noise)) > 0 else pink_noise
bg_noise = pink_noise * background_volume
temp_wav = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
temp_wav.close()
# Add background noise to all channels
for ch in range(num_channels):
audio_data[:, ch] += bg_noise
try:
engine.save_to_file(phrase_text, temp_wav.name)
engine.runAndWait()
engine.stop()
# Total number of raindrops = density × duration × channels for stereo effect
total_drops = int(drop_density * duration * num_channels)
ready = False
for _ in range(50):
if os.path.exists(temp_wav.name) and os.path.getsize(temp_wav.name) > 44:
ready = True
break
time.sleep(0.1)
for _ in range(total_drops):
# Random timing for raindrop
drop_time = random.uniform(0, duration)
if not ready:
raise RuntimeError("pyttsx3 did not produce a WAV file in time.")
# Random duration of raindrop sound (0.01-0.05 seconds)
drop_duration = random.uniform(0.01, 0.05)
tts_audio, tts_sr = sf.read(temp_wav.name, dtype="float32", always_2d=True)
finally:
if os.path.exists(temp_wav.name):
os.unlink(temp_wav.name)
# Random frequency gives variation in raindrop pitch
drop_freq = random.uniform(500, 5000) # Hz
if len(tts_audio) == 0:
raise RuntimeError("pyttsx3 produced an empty WAV file.")
# Random channel selection for stereo positioning
channel = random.randint(0, num_channels - 1)
tts_audio = _resample_audio(tts_audio, tts_sr, sample_rate)
tts_audio = _match_channels(tts_audio, num_channels)
tts_audio = _trim_silence(tts_audio, threshold=0.012)
tts_audio = _enhance_speech(tts_audio)
# Calculate sample positions for this raindrop
start_sample = int(drop_time * sample_rate)
drop_samples = int(drop_duration * sample_rate)
end_sample = min(start_sample + drop_samples, num_samples)
lead_silence = min(int(sample_rate * 0.02), num_samples // 8)
pause_samples = int(sample_rate * 0.18)
start = lead_silence
phrase_len = tts_audio.shape[0]
if start_sample < end_sample:
# Generate the raindrop sound
num_drop_samples = end_sample - start_sample
t = np.arange(num_drop_samples) / sample_rate
while start < num_samples:
take = min(phrase_len, num_samples - start)
audio_data[start : start + take] = tts_audio[:take]
start += phrase_len + pause_samples
# Basic sine wave for raindrop sound
drop_sound = drop_volume * np.sin(2 * math.pi * drop_freq * t)
# Apply envelope for natural attack and decay
envelope = np.ones(num_drop_samples)
attack_samples = int(num_drop_samples * 0.1) # 10% of samples for attack
decay_samples = num_drop_samples - attack_samples
if attack_samples > 0:
# Linear attack: volume increases from 0 to 1
envelope[:attack_samples] = np.linspace(0, 1, attack_samples)
if decay_samples > 0:
# Exponential decay for natural sound fade
decay = np.exp(-8 * t[attack_samples:] / drop_duration)
envelope[attack_samples:] = decay
# Apply envelope to raindrop sound
drop_sound *= envelope
# Add raindrop sound to selected channel
audio_data[start_sample:end_sample, channel] += drop_sound
# Step 3: Add simple reverb effect for realism
# Reverb simulates sound reflections in environment
if duration > 2:
# Single delay reverb (100ms delay)
delay_samples = int(0.1 * sample_rate)
if delay_samples < num_samples:
for ch in range(num_channels):
delayed = np.zeros(num_samples)
delayed[delay_samples:] = audio_data[:-delay_samples, ch] * 0.3
audio_data[:, ch] += delayed
# Step 4: Normalize audio to prevent clipping
# Find maximum amplitude and scale to 80% of maximum volume
max_amp = np.max(np.abs(audio_data))
max_amp = float(np.max(np.abs(audio_data)))
if max_amp > 0:
audio_data = audio_data / max_amp * 0.8
audio_data = audio_data / max_amp * 0.95
audio_array = audio_data.copy()
result = {
"np_array": audio_array,
audio_bytes: bytes | None = None
output_path: str | None = None
result: dict[str, Any] = {
"np_array": audio_data.copy(),
}
# Handle file saving
audio_bytes = None
if save_to_file:
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
output_path = f"audio_{num_channels}ch_{timestamp}.wav"
@@ -617,24 +709,38 @@ def convert_audio_to_text(audio_data):
return text
def convert_audio_file_to_text(output_path):
def _whisper_transcribe_in_current_process(output_path: str) -> str:
import whisper
model = whisper.load_model("small")
text = model.transcribe(
output_path,
temperature=0.0,
word_timestamps=True,
condition_on_previous_text=False,
)["text"]
del model
if torch.cuda.is_available():
device = "cuda" if torch.cuda.is_available() else "cpu"
model = whisper.load_model("small", device=device)
try:
text = model.transcribe(
output_path,
temperature=0.0,
word_timestamps=True,
condition_on_previous_text=False,
)["text"]
finally:
# Sync GPU so in-flight ops finish before we free the model; otherwise
# freed memory may not show up until those ops complete.
if torch is not None and torch.cuda.is_available():
torch.cuda.synchronize()
del model
gc.collect()
torch.cuda.empty_cache()
if text:
return text
else:
return ""
if torch is not None and torch.cuda.is_available():
torch.cuda.empty_cache()
return text or ""
def convert_audio_file_to_text(output_path: str) -> str:
"""Convert an audio file to text in an isolated subprocess."""
# Import locally to avoid impacting test module import time.
ctx = multiprocessing.get_context("spawn")
with concurrent.futures.ProcessPoolExecutor(max_workers=1, mp_context=ctx) as executor:
future = executor.submit(_whisper_transcribe_in_current_process, output_path)
return future.result()
def merge_base64_and_convert_to_text(base64_list):
@@ -1105,7 +1211,7 @@ def assert_omni_response(response: OmniResponse, request_config: dict[str, Any],
assert response.success, "The request failed."
e2e_latency = response.e2e_latency
if e2e_latency is not None:
print(f"the avg e2e is: {e2e_latency}")
print(f"the avg e2e latency is: {e2e_latency}")
modalities = request_config.get("modalities", ["text", "audio"])
+162 -1
View File
@@ -156,13 +156,22 @@ class TestDiffusionParallelConfigHSDP:
def test_hsdp_cannot_use_with_tp(self):
"""Test that HSDP and Tensor Parallelism cannot be used together."""
with pytest.raises(ValueError, match="cannot be used with Tensor Parallelism"):
with pytest.raises(ValueError, match="cannot be used with TP or DP"):
DiffusionParallelConfig(
tensor_parallel_size=2,
use_hsdp=True,
hsdp_shard_size=4,
)
def test_hsdp_cannot_use_with_dp(self):
"""Test that HSDP and Data Parallelism cannot be used together."""
with pytest.raises(ValueError, match="cannot be used with TP or DP"):
DiffusionParallelConfig(
data_parallel_size=2,
use_hsdp=True,
hsdp_shard_size=4,
)
def test_from_dict_with_hsdp(self):
"""Test creating config from dict with HSDP settings."""
config = DiffusionParallelConfig.from_dict(
@@ -177,6 +186,158 @@ class TestDiffusionParallelConfigHSDP:
assert config.hsdp_shard_size == 2 # auto: 4 // 2
class TestStandaloneHSDPDetection:
"""Tests for standalone HSDP detection and dit_parallel_size calculation.
These tests verify the logic used in initialize_model_parallel() to detect
standalone HSDP mode and calculate effective parallel sizes.
Standalone HSDP is when all non-HSDP parallelism dimensions are 1.
"""
@staticmethod
def compute_standalone_hsdp_params(
data_parallel_size: int = 1,
cfg_parallel_size: int = 1,
sequence_parallel_size: int = 1,
pipeline_parallel_size: int = 1,
tensor_parallel_size: int = 1,
fully_shard_degree: int = 1,
hsdp_replicate_size: int = 1,
) -> dict:
"""Compute standalone HSDP detection parameters.
This mirrors the logic in initialize_model_parallel().
"""
dit_parallel_size = (
data_parallel_size
* cfg_parallel_size
* sequence_parallel_size
* pipeline_parallel_size
* tensor_parallel_size
)
# Check for standalone HSDP: all non-HSDP parallelism dimensions are 1
is_standalone_hsdp = dit_parallel_size == 1 and fully_shard_degree > 1
# For standalone HSDP: use (fully_shard_degree * hsdp_replicate_size)
if is_standalone_hsdp:
effective_dit_parallel_size = fully_shard_degree * hsdp_replicate_size
else:
effective_dit_parallel_size = dit_parallel_size
effective_dp_size = (fully_shard_degree * hsdp_replicate_size) if is_standalone_hsdp else data_parallel_size
return {
"original_dit_parallel_size": dit_parallel_size,
"is_standalone_hsdp": is_standalone_hsdp,
"effective_dit_parallel_size": effective_dit_parallel_size,
"effective_dp_size": effective_dp_size,
}
def test_standalone_hsdp_basic(self):
"""Test basic standalone HSDP detection (shard_size=4, replicate=1)."""
result = self.compute_standalone_hsdp_params(
fully_shard_degree=4,
hsdp_replicate_size=1,
)
assert result["original_dit_parallel_size"] == 1
assert result["is_standalone_hsdp"] is True
assert result["effective_dit_parallel_size"] == 4
assert result["effective_dp_size"] == 4
def test_standalone_hsdp_with_replicate(self):
"""Test standalone HSDP with replication (shard_size=4, replicate=2)."""
result = self.compute_standalone_hsdp_params(
fully_shard_degree=4,
hsdp_replicate_size=2,
)
assert result["original_dit_parallel_size"] == 1
assert result["is_standalone_hsdp"] is True
assert result["effective_dit_parallel_size"] == 8 # 4 * 2
assert result["effective_dp_size"] == 8
def test_combined_hsdp_sp_not_standalone(self):
"""Test HSDP combined with SP is NOT detected as standalone.
This is a regression test for the bug where the condition
`dit_parallel_size == fully_shard_degree` incorrectly matched
combined modes like SP=4 + HSDP=4.
"""
result = self.compute_standalone_hsdp_params(
sequence_parallel_size=4,
fully_shard_degree=4,
hsdp_replicate_size=1,
)
assert result["original_dit_parallel_size"] == 4
assert result["is_standalone_hsdp"] is False
# Should NOT override dp_size for combined mode
assert result["effective_dp_size"] == 1 # original data_parallel_size
def test_combined_hsdp_cfg_not_standalone(self):
"""Test HSDP combined with CFG is NOT detected as standalone."""
result = self.compute_standalone_hsdp_params(
cfg_parallel_size=2,
fully_shard_degree=4,
hsdp_replicate_size=1,
)
assert result["original_dit_parallel_size"] == 2
assert result["is_standalone_hsdp"] is False
assert result["effective_dp_size"] == 1
def test_combined_hsdp_pp_not_standalone(self):
"""Test HSDP combined with PP is NOT detected as standalone."""
result = self.compute_standalone_hsdp_params(
pipeline_parallel_size=2,
fully_shard_degree=4,
hsdp_replicate_size=1,
)
assert result["original_dit_parallel_size"] == 2
assert result["is_standalone_hsdp"] is False
assert result["effective_dp_size"] == 1
def test_no_hsdp_not_standalone(self):
"""Test that no HSDP (fully_shard_degree=1) is NOT standalone."""
result = self.compute_standalone_hsdp_params(
fully_shard_degree=1,
)
assert result["original_dit_parallel_size"] == 1
assert result["is_standalone_hsdp"] is False
assert result["effective_dp_size"] == 1
def test_combined_multiple_parallelism_not_standalone(self):
"""Test HSDP combined with multiple parallelism is NOT standalone."""
result = self.compute_standalone_hsdp_params(
sequence_parallel_size=2,
cfg_parallel_size=2,
fully_shard_degree=4,
hsdp_replicate_size=1,
)
assert result["original_dit_parallel_size"] == 4 # 2 * 2
assert result["is_standalone_hsdp"] is False
assert result["effective_dp_size"] == 1
def test_standalone_hsdp_large_shard(self):
"""Test standalone HSDP with large shard size."""
result = self.compute_standalone_hsdp_params(
fully_shard_degree=8,
hsdp_replicate_size=1,
)
assert result["is_standalone_hsdp"] is True
assert result["effective_dit_parallel_size"] == 8
assert result["effective_dp_size"] == 8
def test_standalone_hsdp_large_replicate(self):
"""Test standalone HSDP with large replicate size."""
result = self.compute_standalone_hsdp_params(
fully_shard_degree=4,
hsdp_replicate_size=4,
)
assert result["is_standalone_hsdp"] is True
assert result["effective_dit_parallel_size"] == 16 # 4 * 4
assert result["effective_dp_size"] == 16
class TestHSDPShardConditions:
"""Tests for _hsdp_shard_conditions matching logic."""
@@ -96,6 +96,7 @@ class TestWorkerWrapperBaseInitialization:
local_rank=0,
rank=0,
od_config=mock_od_config,
skip_load_model=False,
)
@@ -6,6 +6,7 @@ from vllm_omni.distributed.omni_connectors.kv_transfer_manager import (
OmniKVCacheConfig,
OmniKVTransferManager,
)
from vllm_omni.distributed.omni_connectors.utils.kv_utils import normalize_layer_kv
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.cache]
@@ -192,8 +193,7 @@ def test_normalize_layer_kv_rejects_invalid_inputs(kv_config, common_constants,
else:
layer_kv = (torch.randn(2, block_size, num_heads, head_dim), "not-a-tensor")
manager = OmniKVTransferManager(kv_config)
normalized = manager._normalize_layer_kv(layer_kv, req_id=req_id, layer_idx=0)
normalized = normalize_layer_kv(layer_kv, req_id=req_id, layer_idx=0)
assert normalized is None
@@ -13,7 +13,7 @@ stage_args:
model_arch: BagelForConditionalGeneration
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
gpu_memory_utilization: 0.35
gpu_memory_utilization: 0.45
enforce_eager: true
trust_remote_code: true
engine_output_type: text
@@ -46,7 +46,7 @@ stage_args:
max_batch_size: 1
engine_args:
model_stage: dit
gpu_memory_utilization: 0.55
gpu_memory_utilization: 0.45
enforce_eager: true
trust_remote_code: true
engine_output_type: image
@@ -13,7 +13,7 @@ stage_args:
model_arch: BagelForConditionalGeneration
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
gpu_memory_utilization: 0.35
gpu_memory_utilization: 0.45
enforce_eager: true
trust_remote_code: true
engine_output_type: text
@@ -45,7 +45,7 @@ stage_args:
max_batch_size: 1
engine_args:
model_stage: dit
gpu_memory_utilization: 0.55
gpu_memory_utilization: 0.45
enforce_eager: true
trust_remote_code: true
engine_output_type: image
@@ -21,6 +21,7 @@ stage_args:
trust_remote_code: true
engine_output_type: latent
enable_prefix_caching: false
mm_processor_cache_gb: 0
is_comprehension: true
final_output: true
final_output_type: text
@@ -1,3 +1,4 @@
import gc
import sys
from pathlib import Path
@@ -20,11 +21,18 @@ models = ["riverclouds/qwen_image_random"]
def inference(model_name: str, offload: bool = True):
gc.collect()
current_omni_platform.empty_cache()
device_index = torch.cuda.current_device()
torch.cuda.reset_peak_memory_stats(device=device_index)
monitor = GPUMemoryMonitor(device_index=device_index, interval=0.02)
monitor.start()
m = Omni(model=model_name, enable_cpu_offload=offload)
m = Omni(
model=model_name,
# TODO: we might want to add overlapped feature e2e tests
# cache_backend="cache_dit",
enable_cpu_offload=offload,
)
torch.cuda.reset_peak_memory_stats(device=device_index)
height = 256
width = 256
@@ -42,6 +50,10 @@ def inference(model_name: str, offload: bool = True):
peak = monitor.peak_used_mb
monitor.stop()
del m
gc.collect()
current_omni_platform.empty_cache()
return peak
@@ -51,9 +63,9 @@ def inference(model_name: str, offload: bool = True):
@pytest.mark.parametrize("model_name", models)
def test_cpu_offload_diffusion_model(model_name: str):
try:
no_offload_peak_memory = inference(model_name, offload=False)
cleanup_dist_env_and_memory()
offload_peak_memory = inference(model_name, offload=True)
cleanup_dist_env_and_memory()
no_offload_peak_memory = inference(model_name, offload=False)
except Exception:
pytest.fail("Inference failed")
print(f"Offload peak memory: {offload_peak_memory} MB")
@@ -38,6 +38,8 @@ def run_inference(
m = Omni(
model=model_name,
enable_layerwise_offload=layerwise_offload,
# TODO: we might want to add overlapped feature e2e tests
# cache_backend="cache_dit",
boundary_ratio=0.875,
flow_shift=5.0,
)
@@ -10,12 +10,29 @@ from tests.conftest import (
generate_synthetic_audio,
generate_synthetic_image,
generate_synthetic_video,
modify_stage_config,
)
from tests.utils import hardware_test
from vllm_omni.platforms import current_omni_platform
models = ["Qwen/Qwen2.5-Omni-7B"]
def get_cuda_graph_config():
path = modify_stage_config(
str(Path(__file__).parent.parent / "stage_configs" / "qwen2_5_omni_ci.yaml"),
updates={
"stage_args": {
0: {
"engine_args.enforce_eager": "true",
},
1: {"engine_args.enforce_eager": "true"},
},
},
)
return path
# CI stage config optimized for 24GB GPU (L4/RTX3090) or NPU
if current_omni_platform.is_npu():
stage_config = str(Path(__file__).parent / "stage_configs" / "npu" / "qwen2_5_omni_ci.yaml")
@@ -23,7 +40,7 @@ elif current_omni_platform.is_rocm():
# ROCm stage config optimized for MI325 GPU
stage_config = str(Path(__file__).parent.parent / "stage_configs" / "rocm" / "qwen2_5_omni_ci.yaml")
else:
stage_config = str(Path(__file__).parent.parent / "stage_configs" / "qwen2_5_omni_ci.yaml")
stage_config = get_cuda_graph_config()
# Create parameter combinations for model and stage config
test_params = [(model, stage_config) for model in models]
+18 -1
View File
@@ -13,18 +13,35 @@ import pytest
from tests.conftest import (
generate_synthetic_video,
modify_stage_config,
)
from tests.utils import hardware_test
from vllm_omni.platforms import current_omni_platform
models = ["Qwen/Qwen3-Omni-30B-A3B-Instruct"]
def get_cuda_graph_config():
path = modify_stage_config(
str(Path(__file__).parent.parent / "stage_configs" / "qwen3_omni_ci.yaml"),
updates={
"stage_args": {
0: {
"engine_args.enforce_eager": "true",
},
1: {"engine_args.enforce_eager": "true"},
},
},
)
return path
# CI stage config for 2xH100-80G GPUs or AMD GPU MI325
if current_omni_platform.is_rocm():
# ROCm stage config optimized for MI325 GPU
stage_configs = [str(Path(__file__).parent.parent / "stage_configs" / "rocm" / "qwen3_omni_ci.yaml")]
else:
stage_configs = [str(Path(__file__).parent.parent / "stage_configs" / "qwen3_omni_ci.yaml")]
stage_configs = [get_cuda_graph_config()]
# Create parameter combinations for model and stage config
test_params = [(model, stage_config) for model in models for stage_config in stage_configs]
+3 -2
View File
@@ -114,7 +114,7 @@ def test_mix_to_text_audio_001(omni_server, openai_client) -> None:
"messages": messages,
"stream": True,
"key_words": {
"audio": ["water", "chirping", "crackling", "rain"],
"audio": ["test"],
"image": ["square", "quadrate"],
},
}
@@ -126,13 +126,14 @@ def test_mix_to_text_audio_001(omni_server, openai_client) -> None:
@pytest.mark.advanced_model
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_text_to_text_001(omni_server, openai_client) -> None:
"""
Test text input processing and text/audio output generation via OpenAI API.
Deploy Setting: default yaml
Input Modal: text
Output Modal: text + audio
Output Modal: text
Datasets: few requests
"""
messages = dummy_messages_from_mix_data(system_prompt=get_system_prompt(), content_text=get_prompt())
@@ -22,7 +22,7 @@ from tests.utils import hardware_test
models = ["Qwen/Qwen3-Omni-30B-A3B-Instruct"]
AUDIO_KEY = ["water", "chirping", "crackling", "rain"]
AUDIO_KEY = ["test"]
IMAGE_KEY = ["square", "quadrate"]
VIDEO_KEY = ["sphere", "globe", "circle", "round", "ball"]
@@ -90,12 +90,19 @@ def get_max_batch_size(size_type="few"):
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_text_to_audio_001(omni_server, openai_client) -> None:
"""
Input Modal: text
Output Modal: audio
Input Setting: stream=True
Datasets: single request
"""
messages = dummy_messages_from_mix_data(system_prompt=get_system_prompt(), content_text=get_prompt())
request_config = {
"model": omni_server.model,
"messages": messages,
"modalities": ["audio"],
"stream": True,
"key_words": {"text": ["beijing"]},
}
@@ -107,6 +114,12 @@ def test_text_to_audio_001(omni_server, openai_client) -> None:
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_text_to_text_audio_001(omni_server, openai_client) -> None:
"""
Input Modal: text
Output Modal: text, audio
Input Setting: stream=False
Datasets: few requests
"""
messages = dummy_messages_from_mix_data(system_prompt=get_system_prompt(), content_text=get_prompt())
request_config = {
@@ -115,7 +128,7 @@ def test_text_to_text_audio_001(omni_server, openai_client) -> None:
"key_words": {"text": ["beijing"]},
}
openai_client.send_request(request_config)
openai_client.send_request(request_config, request_num=get_max_batch_size())
@pytest.mark.advanced_model
@@ -123,6 +136,12 @@ def test_text_to_text_audio_001(omni_server, openai_client) -> None:
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_image_to_text_001(omni_server, openai_client) -> None:
"""
Input Modal: image
Output Modal: text
Input Setting: stream=True
Datasets: single request
"""
image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(224, 224)['base64']}"
messages = dummy_messages_from_mix_data(image_data_url=image_data_url)
@@ -130,6 +149,7 @@ def test_image_to_text_001(omni_server, openai_client) -> None:
"model": omni_server.model,
"messages": messages,
"modalities": ["text"],
"stream": True,
"key_words": {"image": IMAGE_KEY},
}
@@ -141,6 +161,12 @@ def test_image_to_text_001(omni_server, openai_client) -> None:
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_image_to_audio_001(omni_server, openai_client) -> None:
"""
Input Modal: image
Output Modal: audio
Input Setting: stream=False
Datasets: single request
"""
image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(224, 224)['base64']}"
messages = dummy_messages_from_mix_data(image_data_url=image_data_url)
@@ -159,6 +185,12 @@ def test_image_to_audio_001(omni_server, openai_client) -> None:
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_image_to_text_audio_001(omni_server, openai_client) -> None:
"""
Input Modal: image
Output Modal: text, audio
Input Setting: stream=False
Datasets: few requests
"""
image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(1280, 720)['base64']}"
messages = dummy_messages_from_mix_data(image_data_url=image_data_url)
@@ -177,6 +209,12 @@ def test_image_to_text_audio_001(omni_server, openai_client) -> None:
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_video_to_text_001(omni_server, openai_client) -> None:
"""
Input Modal: video
Output Modal: text
Input Setting: stream=False
Datasets: single request
"""
video_data_url = f"data:video/mp4;base64,{generate_synthetic_video(224, 224, 300)['base64']}"
messages = dummy_messages_from_mix_data(video_data_url=video_data_url)
@@ -195,6 +233,12 @@ def test_video_to_text_001(omni_server, openai_client) -> None:
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_video_to_audio_001(omni_server, openai_client) -> None:
"""
Input Modal: video
Output Modal: audio
Input Setting: stream=False
Datasets: single request
"""
video_data_url = f"data:video/mp4;base64,{generate_synthetic_video(224, 224, 300)['base64']}"
messages = dummy_messages_from_mix_data(video_data_url=video_data_url)
@@ -213,6 +257,12 @@ def test_video_to_audio_001(omni_server, openai_client) -> None:
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_video_to_text_audio_001(omni_server, openai_client) -> None:
"""
Input Modal: video
Output Modal: text, audio
Input Setting: stream=False
Datasets: few requests
"""
video_data_url = f"data:video/mp4;base64,{generate_synthetic_video(224, 224, 300)['base64']}"
messages = dummy_messages_from_mix_data(video_data_url=video_data_url)
@@ -231,6 +281,12 @@ def test_video_to_text_audio_001(omni_server, openai_client) -> None:
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_text_audio_to_text_audio_001(omni_server, openai_client) -> None:
"""
Input Modal: text, audio
Output Modal: text, audio
Input Setting: stream=False
Datasets: single request
"""
audio_data_url = f"data:audio/wav;base64,{generate_synthetic_audio(5, 1)['base64']}"
messages = dummy_messages_from_mix_data(
audio_data_url=audio_data_url, system_prompt=get_system_prompt(), content_text=get_prompt("text_audio")
@@ -239,7 +295,6 @@ def test_text_audio_to_text_audio_001(omni_server, openai_client) -> None:
request_config = {
"model": omni_server.model,
"messages": messages,
"modalities": ["text"],
"key_words": {"audio": AUDIO_KEY},
}
@@ -251,6 +306,12 @@ def test_text_audio_to_text_audio_001(omni_server, openai_client) -> None:
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_text_image_to_text_audio_001(omni_server, openai_client) -> None:
"""
Input Modal: text, image
Output Modal: text, audio
Input Setting: stream=False
Datasets: single request
"""
image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(224, 224)['base64']}"
messages = dummy_messages_from_mix_data(
@@ -260,19 +321,23 @@ def test_text_image_to_text_audio_001(omni_server, openai_client) -> None:
request_config = {
"model": omni_server.model,
"messages": messages,
"modalities": ["audio"],
"key_words": {"image": IMAGE_KEY},
}
openai_client.send_request(request_config)
@pytest.mark.skip(reason="There is a known issue with oom error.")
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_text_video_to_text_audio_001(omni_server, openai_client) -> None:
"""
Input Modal: text, video
Output Modal: text, audio
Input Setting: stream=True
Datasets: single requests
"""
video_data_url = f"data:video/mp4;base64,{generate_synthetic_video(1280, 720, 30)['base64']}"
messages = dummy_messages_from_mix_data(
@@ -282,10 +347,11 @@ def test_text_video_to_text_audio_001(omni_server, openai_client) -> None:
request_config = {
"model": omni_server.model,
"messages": messages,
"stream": True,
"key_words": {"video": VIDEO_KEY},
}
openai_client.send_request(request_config, request_num=get_max_batch_size())
openai_client.send_request(request_config)
@pytest.mark.skip(reason="There is a known issue with shape mismatch error.")
@@ -294,6 +360,12 @@ def test_text_video_to_text_audio_001(omni_server, openai_client) -> None:
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_mix_to_text_audio_001(omni_server, openai_client) -> None:
"""
Input Modal: text, audio, image, video
Output Modal: text, audio
Input Setting: stream=True
Datasets: few requests
"""
video_data_url = f"data:video/mp4;base64,{generate_synthetic_video(224, 224, 300)['base64']}"
image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(224, 224)['base64']}"
audio_data_url = f"data:audio/wav;base64,{generate_synthetic_audio(5, 1)['base64']}"
@@ -18,6 +18,7 @@ stage_args:
trust_remote_code: true
engine_output_type: latent
enable_prefix_caching: false
mm_processor_cache_gb: 0
is_comprehension: true
final_output: true
final_output_type: text
@@ -22,6 +22,7 @@ stage_args:
max_num_batched_tokens: 32768
max_model_len: 32768
enable_prefix_caching: false
mm_processor_cache_gb: 0
hf_config_name: thinker_config
tensor_parallel_size: 1
load_format: dummy
@@ -22,6 +22,7 @@ stage_args:
trust_remote_code: true
engine_output_type: latent
enable_prefix_caching: false
mm_processor_cache_gb: 0
is_comprehension: true
final_output: true
final_output_type: text
@@ -20,6 +20,7 @@ stage_args:
engine_output_type: latent # Output hidden states for talker
distributed_executor_backend: "mp"
enable_prefix_caching: false
mm_processor_cache_gb: 0
hf_config_name: thinker_config
tensor_parallel_size: 1
load_format: dummy
@@ -1,11 +1,15 @@
# tests/entrypoints/openai/test_serving_speech.py
import logging
import os
from inspect import Signature, signature
from pathlib import Path
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
import torch
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException, UploadFile
from fastapi.params import File, Form
from fastapi.testclient import TestClient
from pydantic import ValidationError
from pytest_mock import MockerFixture
@@ -206,10 +210,52 @@ def test_app(mocker: MockerFixture):
# Add list_voices endpoint
async def list_voices():
speakers = sorted(speech_server.supported_speakers) if speech_server.supported_speakers else []
return {"voices": speakers}
uploaded_voices = []
if hasattr(speech_server, "uploaded_speakers"):
for voice_name, info in speech_server.uploaded_speakers.items():
uploaded_voices.append(
{
"name": info.get("name", voice_name),
"consent": info.get("consent", ""),
"created_at": info.get("created_at", 0),
"file_size": info.get("file_size", 0),
"mime_type": info.get("mime_type", ""),
}
)
return {"voices": speakers, "uploaded_voices": uploaded_voices}
app.add_api_route("/v1/audio/voices", list_voices, methods=["GET"])
# Add upload_voice endpoint
async def upload_voice(audio_sample: UploadFile = File(...), consent: str = Form(...), name: str = Form(...)):
try:
result = await speech_server.upload_voice(audio_sample, consent, name)
return {"success": True, "voice": result}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.exception(f"Failed to upload voice: {e}")
raise HTTPException(status_code=500, detail=f"Failed to upload voice: {str(e)}")
app.add_api_route("/v1/audio/voices", upload_voice, methods=["POST"])
# Add delete_voice endpoint
async def delete_voice(name: str):
try:
success = await speech_server.delete_voice(name)
if not success:
raise HTTPException(status_code=404, detail=f"Voice '{name}' not found")
return {"success": True, "message": f"Voice '{name}' deleted successfully"}
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.exception(f"Failed to delete voice '{name}': {e}")
raise HTTPException(status_code=500, detail=f"Failed to delete voice: {str(e)}")
app.add_api_route("/v1/audio/voices/{name}", delete_voice, methods=["DELETE"])
return app
@@ -284,6 +330,149 @@ class TestSpeechAPI:
assert response.status_code == 200
assert "voices" in response.json()
def test_upload_voice_success(self, client, tmp_path):
"""Test successful voice upload."""
# Create a mock audio file
audio_content = b"fake audio content" * 1000 # ~17KB
files = {
"audio_sample": ("test.wav", audio_content, "audio/wav"),
}
data = {
"consent": "user_consent_123",
"name": "test_voice",
}
response = client.post("/v1/audio/voices", files=files, data=data)
assert response.status_code == 200
result = response.json()
assert result["success"] is True
assert "voice" in result
voice_info = result["voice"]
assert voice_info["name"] == "test_voice"
assert voice_info["consent"] == "user_consent_123"
assert "created_at" in voice_info
assert voice_info["mime_type"] == "audio/wav"
assert voice_info["file_size"] == len(audio_content)
response = client.delete("/v1/audio/voices/test_voice")
def test_upload_voice_file_too_large(self, client):
"""Test voice upload with file exceeding size limit."""
# Create a file larger than 10MB
audio_content = b"x" * (11 * 1024 * 1024) # 11MB
files = {
"audio_sample": ("test.wav", audio_content, "audio/wav"),
}
data = {
"consent": "user_consent_123",
"name": "test_voice",
}
response = client.post("/v1/audio/voices", files=files, data=data)
assert response.status_code == 400
result = response.json()
assert "detail" in result
assert "10MB" in result["detail"]
def test_upload_voice_invalid_mime_type(self, client):
"""Test voice upload with invalid MIME type."""
audio_content = b"fake audio content"
files = {
"audio_sample": ("test.txt", audio_content, "text/plain"),
}
data = {
"consent": "user_consent_123",
"name": "test_voice",
}
response = client.post("/v1/audio/voices", files=files, data=data)
assert response.status_code == 400
result = response.json()
assert "detail" in result
assert "MIME type" in result["detail"]
def test_upload_voice_name_collision(self, client):
"""Test voice upload with duplicate name."""
# First upload
audio_content = b"fake audio content"
files = {
"audio_sample": ("test.wav", audio_content, "audio/wav"),
}
data = {
"consent": "user_consent_123",
"name": "test_voice",
}
response = client.post("/v1/audio/voices", files=files, data=data)
assert response.status_code == 200
# Second upload with same name
response = client.post("/v1/audio/voices", files=files, data=data)
assert response.status_code == 400
result = response.json()
assert "detail" in result
assert "already exists" in result["detail"]
response = client.delete("/v1/audio/voices/test_voice")
def test_upload_voice_missing_parameters(self, client):
"""Test voice upload with missing required parameters."""
audio_content = b"fake audio content"
files = {
"audio_sample": ("test.wav", audio_content, "audio/wav"),
}
# Missing consent
data = {"name": "test_voice5"}
response = client.post("/v1/audio/voices", files=files, data=data)
assert response.status_code == 422 # Validation error
# Missing name
data = {"consent": "user_consent_123"}
response = client.post("/v1/audio/voices", files=files, data=data)
assert response.status_code == 422 # Validation error
# Missing file
data = {
"consent": "user_consent_123",
"name": "test_voice6",
}
response = client.post("/v1/audio/voices", data=data)
assert response.status_code == 422 # Validation error
def test_delete_voice_success(self, client):
"""Test successful voice deletion."""
# First upload a voice
audio_content = b"fake audio content"
files = {
"audio_sample": ("test.wav", audio_content, "audio/wav"),
}
data = {
"consent": "user_consent_123",
"name": "test_voice7",
}
response = client.post("/v1/audio/voices", files=files, data=data)
assert response.status_code == 200
# Then delete it
response = client.delete("/v1/audio/voices/test_voice7")
assert response.status_code == 200
result = response.json()
assert result["success"] is True
assert "deleted successfully" in result["message"]
# Verify it's gone by trying to delete again
response = client.delete("/v1/audio/voices/test_voice7")
assert response.status_code == 404
result = response.json()
assert "not found" in result["detail"]
def test_delete_voice_not_found(self, client):
"""Test deleting a non-existent voice."""
response = client.delete("/v1/audio/voices/nonexistent")
assert response.status_code == 404
result = response.json()
assert "not found" in result["detail"]
class TestTTSMethods:
"""Unit tests for TTS validation and parameter building."""
@@ -438,6 +627,121 @@ class TestTTSMethods:
# Verify speakers are normalized to lowercase
assert server.supported_speakers == {"ryan", "vivian", "aiden"}
def test_build_tts_params_with_uploaded_voice(self, speech_server):
"""Test _build_tts_params auto-sets ref_audio for uploaded voices."""
# Mock an uploaded speaker
speech_server.uploaded_speakers = {
"custom_voice": {
"name": "custom_voice",
"file_path": "/tmp/voice_samples/custom_voice_consent_123.wav",
"mime_type": "audio/wav",
}
}
speech_server.supported_speakers = {"ryan", "vivian", "custom_voice"}
# Mock _get_uploaded_audio_data to return base64 data
with patch.object(speech_server, "_get_uploaded_audio_data") as mock_get_audio:
mock_get_audio.return_value = "data:audio/wav;base64,ZmFrZWF1ZGlv"
req = OpenAICreateSpeechRequest(input="Hello", voice="custom_voice", task_type="Base")
params = speech_server._build_tts_params(req)
# Verify ref_audio was auto-set
assert "ref_audio" in params
assert params["ref_audio"] == ["data:audio/wav;base64,ZmFrZWF1ZGlv"]
assert "x_vector_only_mode" in params
assert params["x_vector_only_mode"] == [True]
mock_get_audio.assert_called_once_with("custom_voice")
def test_build_tts_params_without_uploaded_voice(self, speech_server):
"""Test _build_tts_params does not auto-set ref_audio for non-uploaded voices."""
# No uploaded speakers
speech_server.uploaded_speakers = {}
speech_server.supported_speakers = {"ryan", "vivian"}
req = OpenAICreateSpeechRequest(input="Hello", voice="ryan", task_type="Base")
params = speech_server._build_tts_params(req)
# Verify ref_audio was NOT auto-set
assert "ref_audio" not in params
assert "x_vector_only_mode" not in params
def test_build_tts_params_with_explicit_ref_audio(self, speech_server):
"""Test _build_tts_params uses explicit ref_audio even for uploaded voices."""
# Mock an uploaded speaker
speech_server.uploaded_speakers = {
"custom_voice": {
"name": "custom_voice",
"file_path": "/tmp/voice_samples/custom_voice_consent_123.wav",
"mime_type": "audio/wav",
}
}
speech_server.supported_speakers = {"ryan", "vivian", "custom_voice"}
req = OpenAICreateSpeechRequest(
input="Hello", voice="custom_voice", task_type="Base", ref_audio="data:audio/wav;base64,ZXhwbGljaXQ="
)
params = speech_server._build_tts_params(req)
# _build_tts_params should NOT auto-set ref_audio when explicit ref_audio
# is provided (request.ref_audio is not None skips the auto-set branch).
# The explicit ref_audio is resolved later in create_speech() via
# _resolve_ref_audio(), not in _build_tts_params().
assert "ref_audio" not in params
# x_vector_only_mode should not be set when explicit ref_audio is provided
assert "x_vector_only_mode" not in params
def test_get_uploaded_audio_data(self, speech_server):
"""Test _get_uploaded_audio_data function."""
# Mock file operations
with (
patch("builtins.open", create=True) as mock_open,
patch("base64.b64encode") as mock_b64encode,
patch("pathlib.Path.exists") as mock_exists,
):
mock_exists.return_value = True
mock_b64encode.return_value = b"ZmFrZWF1ZGlv"
# Setup mock file
mock_file = MagicMock()
mock_file.read.return_value = b"fakeaudio"
mock_open.return_value.__enter__.return_value = mock_file
# Setup uploaded speaker
speech_server.uploaded_speakers = {
"test_voice": {"name": "test_voice", "file_path": "/tmp/test.wav", "mime_type": "audio/wav"}
}
result = speech_server._get_uploaded_audio_data("test_voice")
assert result == "data:audio/wav;base64,ZmFrZWF1ZGlv"
mock_open.assert_called_once_with(Path("/tmp/test.wav"), "rb")
mock_b64encode.assert_called_once_with(b"fakeaudio")
def test_get_uploaded_audio_data_missing_file(self, speech_server):
"""Test _get_uploaded_audio_data when file is missing."""
with patch("pathlib.Path.exists") as mock_exists:
mock_exists.return_value = False
# Setup uploaded speaker
speech_server.uploaded_speakers = {
"test_voice": {"name": "test_voice", "file_path": "/tmp/test.wav", "mime_type": "audio/wav"}
}
result = speech_server._get_uploaded_audio_data("test_voice")
assert result is None
def test_get_uploaded_audio_data_voice_not_found(self, speech_server):
"""Test _get_uploaded_audio_data when voice is not in uploaded_speakers."""
speech_server.uploaded_speakers = {}
result = speech_server._get_uploaded_audio_data("nonexistent")
assert result is None
def test_max_instructions_length_default(self, speech_server):
"""Test default max instructions length (500) when no config provided."""
# Fixture creates server with no CLI override and no TTS stage
@@ -544,6 +848,70 @@ class TestTTSMethods:
assert "max 10 characters" in error
class TestFileValidationFunctions:
"""Unit tests for file validation helper functions."""
def test_sanitize_filename(self):
"""Test _sanitize_filename function."""
from vllm_omni.entrypoints.openai.serving_speech import _sanitize_filename
# Test normal filenames
assert _sanitize_filename("test.wav") == "test.wav"
assert _sanitize_filename("test-file.mp3") == "test-file.mp3"
assert _sanitize_filename("test_file.flac") == "test_file.flac"
# Test path traversal attempts
assert _sanitize_filename("../../../etc/passwd") == "passwd"
assert _sanitize_filename("/absolute/path/file.wav") == "file.wav"
# Test special characters
assert _sanitize_filename("file with spaces.wav") == "file_with_spaces.wav"
assert _sanitize_filename("file&with&special&chars.wav") == "file_with_special_chars.wav"
assert _sanitize_filename("file@with#special$chars%.wav") == "file_with_special_chars_.wav"
# Test empty filename
assert _sanitize_filename("") == "file"
# Test very long filename
long_name = "a" * 300
sanitized = _sanitize_filename(long_name)
assert len(sanitized) == 255
assert sanitized.startswith("a")
def test_validate_path_within_directory(self, tmp_path):
"""Test _validate_path_within_directory function."""
from vllm_omni.entrypoints.openai.serving_speech import _validate_path_within_directory
# Create test directory structure
base_dir = tmp_path / "uploads"
base_dir.mkdir()
# Valid paths within directory
valid_file = base_dir / "test.wav"
valid_subdir_file = base_dir / "subdir" / "test.wav"
valid_subdir_file.parent.mkdir()
assert _validate_path_within_directory(valid_file, base_dir) is True
assert _validate_path_within_directory(valid_subdir_file, base_dir) is True
# Invalid paths outside directory
outside_file = tmp_path / "outside.wav"
assert _validate_path_within_directory(outside_file, base_dir) is False
# Test with symlink (should fail)
if hasattr(os, "symlink"):
link_target = tmp_path / "target.wav"
link_target.touch()
symlink = base_dir / "link.wav"
os.symlink(link_target, symlink)
# Symlinks to outside should be rejected
assert _validate_path_within_directory(symlink, base_dir) is False
# Test with non-existent file (should still validate path)
non_existent = base_dir / "nonexistent.wav"
assert _validate_path_within_directory(non_existent, base_dir) is True
class TestStreamingProtocolValidation:
"""Unit tests for the stream field validators in OpenAICreateSpeechRequest."""
@@ -11,12 +11,18 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
MODEL = "riverclouds/qwen_image_random"
def _noop_inline_engine(self, model, stage_config, kwargs):
self._inline_diffusion = False
self._inline_engine = None
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,
@@ -47,6 +53,7 @@ def test_default_cache_config_used_when_missing(monkeypatch):
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,
@@ -65,6 +72,7 @@ def test_default_stage_devices_from_sequence_parallel(monkeypatch):
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,
+1 -1
View File
@@ -85,7 +85,7 @@ stage_args:
engine_output_type: audio # Final output: audio waveform
gpu_memory_utilization: 0.1
distributed_executor_backend: "mp"
max_num_batched_tokens: 1000000
max_num_batched_tokens: 100000
hf_config_name: thinker_config
engine_input_source: [1]
custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav
+9 -4
View File
@@ -111,11 +111,12 @@ class DiffusionParallelConfig:
# 1. Standalone: when other parallelism is all 1, HSDP determines world_size
# 2. Combined: HSDP overlays on top of other parallelism
if self.use_hsdp:
if self.tensor_parallel_size > 1:
if self.tensor_parallel_size > 1 or self.data_parallel_size > 1:
raise ValueError(
"HSDP (use_hsdp=True) cannot be used with Tensor Parallelism "
f"(tensor_parallel_size={self.tensor_parallel_size}). "
"Set tensor_parallel_size=1 when using HSDP."
"HSDP (use_hsdp=True) cannot be used with TP or DP "
f"(tensor_parallel_size={self.tensor_parallel_size}, "
f"data_parallel_size={self.data_parallel_size}). "
"Set tensor_parallel_size=1 and data_parallel_size=1 when using HSDP."
)
if self.hsdp_shard_size == -1:
# Auto-calculate: use other_parallel_world_size as shard_size
@@ -618,6 +619,10 @@ class DiffusionOutput:
post_process_func: Callable[..., Any] | None = None
# Extra custom output data (e.g. latent trajectories, prompt embeds)
# passed through to OmniRequestOutput.custom_output
custom_output: dict[str, Any] = field(default_factory=dict)
# logged timings info, directly from Req.timings
# timings: Optional["RequestTimings"] = None
+23 -13
View File
@@ -67,18 +67,19 @@ class DiffusionEngine:
raise e
def step(self, request: OmniDiffusionRequest) -> list[OmniRequestOutput]:
diffusion_engine_start_time = time.time()
diffusion_engine_start_time = time.perf_counter()
# Apply pre-processing if available
preprocess_time = 0.0
if self.pre_process_func is not None:
preprocess_start_time = time.time()
preprocess_start_time = time.perf_counter()
request = self.pre_process_func(request)
preprocess_time = time.time() - preprocess_start_time
preprocess_time = time.perf_counter() - preprocess_start_time
logger.info(f"Pre-processing completed in {preprocess_time:.4f} seconds")
exec_start_time = time.time()
exec_start_time = time.perf_counter()
output = self.add_req_and_wait_for_response(request)
exec_total_time = time.time() - exec_start_time
exec_total_time = time.perf_counter() - exec_start_time
if output.error:
raise Exception(f"{output.error}")
@@ -97,15 +98,25 @@ class DiffusionEngine:
for i, prompt in enumerate(request.prompts)
]
postprocess_start_time = time.time()
postprocess_start_time = time.perf_counter()
outputs = self.post_process_func(output.output) if self.post_process_func is not None else output.output
audio_payload = None
if isinstance(outputs, dict):
audio_payload = outputs.get("audio")
outputs = outputs.get("video", outputs)
postprocess_time = time.time() - postprocess_start_time
postprocess_time = time.perf_counter() - postprocess_start_time
logger.info(f"Post-processing completed in {postprocess_time:.4f} seconds")
step_total_ms = (time.perf_counter() - diffusion_engine_start_time) * 1000
logger.info(
"DiffusionEngine.step breakdown: preprocess=%.2f ms, "
"add_req_and_wait=%.2f ms, postprocess=%.2f ms, total=%.2f ms",
preprocess_time * 1000,
exec_total_time * 1000,
postprocess_time * 1000,
step_total_ms,
)
# Convert to OmniRequestOutput format
# Ensure outputs is a list
if not isinstance(outputs, list):
@@ -113,19 +124,16 @@ class DiffusionEngine:
metrics = {
"preprocess_time_ms": preprocess_time * 1000,
"diffusion_engine_exec_time_ms": (time.time() - diffusion_engine_start_time) * 1000,
"diffusion_engine_exec_time_ms": (time.perf_counter() - diffusion_engine_start_time) * 1000,
"diffusion_engine_total_time_ms": exec_total_time * 1000,
"image_num": int(request.sampling_params.num_outputs_per_prompt),
"resolution": int(request.sampling_params.resolution),
"postprocess_time_ms": postprocess_time * 1000,
}
if self.pre_process_func is not None:
metrics["preprocessing_time_ms"] = preprocess_time * 1000
# Handle single request or multiple requests
metrics["postprocess_time_ms"] = postprocess_time * 1000
metrics["num_inference_steps"] = int(request.sampling_params.num_inference_steps)
if len(request.prompts) == 1:
# Single request: return single OmniRequestOutput
prompt = request.prompts[0]
@@ -155,6 +163,7 @@ class DiffusionEngine:
prompt=prompt,
metrics=metrics,
latents=output.trajectory_latents,
custom_output=output.custom_output or {},
multimodal_output=mm_output,
),
]
@@ -208,11 +217,12 @@ class DiffusionEngine:
prompt=prompt,
metrics=metrics,
latents=output.trajectory_latents,
custom_output=output.custom_output or {},
multimodal_output=mm_output,
),
)
return results
return results
@staticmethod
def make_engine(config: OmniDiffusionConfig) -> "DiffusionEngine":
@@ -669,6 +669,7 @@ def initialize_model_parallel(
tensor_parallel_size: int = 1,
pipeline_parallel_size: int = 1,
fully_shard_degree: int = 1,
hsdp_replicate_size: int = 1,
backend: str | None = None,
) -> None:
if backend is None:
@@ -740,6 +741,14 @@ def initialize_model_parallel(
data_parallel_size * cfg_parallel_size * sequence_parallel_size * pipeline_parallel_size * tensor_parallel_size
)
# Check for standalone HSDP: all non-HSDP parallelism dimensions are 1
is_standalone_hsdp = dit_parallel_size == 1 and fully_shard_degree > 1
# For standalone HSDP: use (fully_shard_degree * hsdp_replicate_size) as dit_parallel_size
# This ensures orthogonal rank generation works correctly for all HSDP workers
if is_standalone_hsdp:
dit_parallel_size = fully_shard_degree * hsdp_replicate_size
if world_size < dit_parallel_size:
raise RuntimeError(
f"world_size ({world_size}) is less than "
@@ -751,12 +760,16 @@ def initialize_model_parallel(
f"data_parallel_size ({data_parallel_size})"
)
# For standalone HSDP, use (fully_shard_degree * hsdp_replicate_size) as data_parallel_size
# so that RankGenerator.world_size matches the actual number of workers
effective_dp_size = (fully_shard_degree * hsdp_replicate_size) if is_standalone_hsdp else data_parallel_size
rank_generator: RankGenerator = RankGenerator(
tensor_parallel_size,
sequence_parallel_size,
pipeline_parallel_size,
cfg_parallel_size,
data_parallel_size,
effective_dp_size,
fs=fully_shard_degree,
order="tp-sp-pp-cfg-dp",
)
+8 -6
View File
@@ -107,7 +107,7 @@ class ModelHook:
The output of the forward pass.
"""
args, kwargs = self.pre_forward(module, *args, **kwargs)
output = module._original_forward(*args, **kwargs) # type: ignore[attr-defined]
output = module._omni_original_forward(*args, **kwargs) # type: ignore[attr-defined]
return self.post_forward(module, output)
def reset_state(self, module: nn.Module) -> nn.Module:
@@ -131,7 +131,7 @@ class _WrappedForward:
def __call__(self, *args: Any, **kwargs: Any):
registry: HookRegistry | None = getattr(self.module, "_hook_registry", None)
if registry is None or not registry._hooks:
return self.module._original_forward(*args, **kwargs)
return self.module._omni_original_forward(*args, **kwargs)
return registry.dispatch(*args, **kwargs)
@@ -162,8 +162,10 @@ class HookRegistry:
setattr(module, "_hook_registry", registry)
# Wrap module.forward once so hooks can intercept calls.
if not hasattr(module, "_original_forward"):
module._original_forward = module.forward # type: ignore[attr-defined]
# NOTE: Use `_omni_original_forward` to avoid collision with cache-dit's
# `_original_forward` attribute on the same module.
if not hasattr(module, "_omni_original_forward"):
module._omni_original_forward = module.forward # type: ignore[attr-defined]
module.forward = _WrappedForward(module) # type: ignore[assignment]
return registry
@@ -212,7 +214,7 @@ class HookRegistry:
The output of the forward pass.
"""
if not self._hooks:
return self.module._original_forward(*args, **kwargs) # type: ignore[attr-defined]
return self.module._omni_original_forward(*args, **kwargs) # type: ignore[attr-defined]
# For single hook case, call directly
if len(self._hooks) == 1:
@@ -228,7 +230,7 @@ class HookRegistry:
args, kwargs = hook.pre_forward(self.module, *args, **kwargs)
# Call original forward
output = self.module._original_forward(*args, **kwargs) # type: ignore[attr-defined]
output = self.module._omni_original_forward(*args, **kwargs) # type: ignore[attr-defined]
# Apply all post_forward hooks in reverse order
for _, hook in reversed(sorted_hooks):
+89
View File
@@ -0,0 +1,89 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""IPC utilities for transferring large tensors via POSIX shared memory.
Used by Hop1 (GPU worker <-> scheduler) to avoid pickling large video tensors
through the MessageQueue. Tensors above ``_SHM_TENSOR_THRESHOLD`` are copied
into a named shared-memory segment; only a lightweight metadata dict is
serialised through the queue.
"""
from __future__ import annotations
from typing import Any
import torch
from vllm_omni.diffusion.data import DiffusionOutput
_SHM_TENSOR_THRESHOLD = 1_000_000 # 1 MB
def _tensor_to_shm(tensor: torch.Tensor) -> dict[str, Any]:
"""Copy a tensor into POSIX shared memory and return a metadata handle.
The shared memory segment remains alive after this call (the local fd is
closed, but the segment persists until ``_tensor_from_shm`` unlinks it).
"""
from multiprocessing import shared_memory
import numpy as np
tensor = tensor.detach().cpu().contiguous()
arr = tensor.numpy()
nbytes = arr.nbytes
shm = shared_memory.SharedMemory(create=True, size=nbytes)
shm_arr = np.ndarray(arr.shape, dtype=arr.dtype, buffer=shm.buf[:nbytes])
np.copyto(shm_arr, arr)
handle = {
"__tensor_shm__": True,
"name": shm.name,
"shape": list(tensor.shape),
"torch_dtype": str(tensor.dtype),
"numpy_dtype": str(arr.dtype),
"nbytes": nbytes,
}
shm.close()
return handle
def _tensor_from_shm(handle: dict[str, Any]) -> torch.Tensor:
"""Reconstruct a tensor from a shared-memory handle and free the segment."""
from multiprocessing import shared_memory
import numpy as np
shm = shared_memory.SharedMemory(name=handle["name"])
try:
np_dtype = np.dtype(handle["numpy_dtype"])
arr = np.ndarray(handle["shape"], dtype=np_dtype, buffer=shm.buf[: handle["nbytes"]])
tensor = torch.from_numpy(arr.copy())
finally:
shm.close()
shm.unlink()
return tensor
def pack_diffusion_output_shm(output: DiffusionOutput) -> DiffusionOutput:
"""Replace large tensors in *output* with shared-memory handles.
The DiffusionOutput is modified **in-place** so that the (now lightweight)
object can be serialised cheaply through a MessageQueue.
"""
if output.output is not None and isinstance(output.output, torch.Tensor):
if output.output.nelement() * output.output.element_size() > _SHM_TENSOR_THRESHOLD:
output.output = _tensor_to_shm(output.output)
if output.trajectory_latents is not None and isinstance(output.trajectory_latents, torch.Tensor):
if output.trajectory_latents.nelement() * output.trajectory_latents.element_size() > _SHM_TENSOR_THRESHOLD:
output.trajectory_latents = _tensor_to_shm(output.trajectory_latents)
return output
def unpack_diffusion_output_shm(output: DiffusionOutput) -> DiffusionOutput:
"""Reconstruct tensors from shared-memory handles produced by ``pack_diffusion_output_shm``."""
if isinstance(output.output, dict) and output.output.get("__tensor_shm__"):
output.output = _tensor_from_shm(output.output)
if isinstance(output.trajectory_latents, dict) and output.trajectory_latents.get("__tensor_shm__"):
output.trajectory_latents = _tensor_from_shm(output.trajectory_latents)
return output
@@ -1526,6 +1526,11 @@ class Bagel(nn.Module):
f"Use cfg_parallel_size=3 to enable image CFG in parallel mode."
)
# Ensure all ranks start with the same x_t (initial noise may differ
# across ranks when no per-request seed is set).
x_t = x_t.contiguous()
cfg_group.broadcast(x_t, src=0)
# Select this rank's branch inputs
if cfg_rank == 0:
# Gen branch: use main inputs directly
@@ -1553,17 +1558,10 @@ class Bagel(nn.Module):
for i, t in enumerate(timesteps):
timestep = torch.tensor([t] * x_t.shape[0], device=x_t.device)
if t > cfg_interval[0] and t <= cfg_interval[1]:
cfg_text_scale_ = cfg_text_scale
cfg_img_scale_ = cfg_img_scale
else:
cfg_text_scale_ = 1.0
cfg_img_scale_ = 1.0
use_cfg_this_step = cfg_text_scale_ > 1.0
use_cfg_this_step = t > cfg_interval[0] and t <= cfg_interval[1] and cfg_text_scale > 1.0
if use_cfg_this_step:
# Each rank computes its branch's velocity
# CFG interval: each rank computes its own branch
local_v_t = self._forward_flow_single_branch(
x_t=x_t,
timestep=timestep,
@@ -1579,46 +1577,34 @@ class Bagel(nn.Module):
packed_key_value_indexes=branch_key_value_indexes,
)
# All-gather velocities from all CFG ranks
gathered = cfg_group.all_gather(local_v_t, separate_tensors=True)
# Rank 0 combines with CFG formula
if cfg_rank == 0:
v_t = gathered[0] # gen branch
cfg_text_v_t = gathered[1] # text_cfg branch
cfg_img_v_t = gathered[2] if (use_cfg_img and len(gathered) > 2) else None
v_t = self._combine_cfg(
v_t,
cfg_text_v_t,
cfg_img_v_t,
cfg_text_scale_,
cfg_img_scale_,
cfg_renorm_type,
cfg_renorm_min,
)
x_t = x_t - v_t.to(x_t.device) * dts[i]
v_t = self._combine_cfg(
gathered[0],
gathered[1],
gathered[2] if (use_cfg_img and len(gathered) > 2) else None,
cfg_text_scale,
cfg_img_scale,
cfg_renorm_type,
cfg_renorm_min,
)
else:
# Outside cfg_interval: only rank 0 computes (no CFG needed)
if cfg_rank == 0:
v_t = self._forward_flow_single_branch(
x_t=x_t,
timestep=timestep,
packed_vae_token_indexes=packed_vae_token_indexes,
packed_vae_position_ids=packed_vae_position_ids,
packed_text_ids=packed_text_ids,
packed_text_indexes=packed_text_indexes,
packed_indexes=packed_indexes,
packed_position_ids=packed_position_ids,
packed_seqlens=packed_seqlens,
key_values_lens=key_values_lens,
past_key_values=past_key_values,
packed_key_value_indexes=packed_key_value_indexes,
)
x_t = x_t - v_t.to(x_t.device) * dts[i]
# Outside CFG interval: all ranks compute with gen inputs, no comm
v_t = self._forward_flow_single_branch(
x_t=x_t,
timestep=timestep,
packed_vae_token_indexes=packed_vae_token_indexes,
packed_vae_position_ids=packed_vae_position_ids,
packed_text_ids=packed_text_ids,
packed_text_indexes=packed_text_indexes,
packed_indexes=packed_indexes,
packed_position_ids=packed_position_ids,
packed_seqlens=packed_seqlens,
key_values_lens=key_values_lens,
past_key_values=past_key_values,
packed_key_value_indexes=packed_key_value_indexes,
)
# Broadcast updated x_t from rank 0 to all ranks
x_t = x_t.contiguous()
cfg_group.broadcast(x_t, src=0)
x_t = x_t - v_t.to(x_t.device) * dts[i]
unpacked_latent = x_t.split((packed_seqlens - 2).tolist())
return unpacked_latent
@@ -332,6 +332,9 @@ class BagelPipeline(nn.Module):
else:
gen_context["ropes"] = [seq_len]
if req.sampling_params.kv_metadata and "image_shape" in req.sampling_params.kv_metadata:
image_shape = tuple(req.sampling_params.kv_metadata["image_shape"])
cfg_text_kv = getattr(req.sampling_params, "cfg_text_past_key_values", None)
if cfg_text_kv is not None:
logger.info("CFG enabled with multi-KV: using injected cfg_text KV Cache")
@@ -604,6 +604,7 @@ class HeliosPipeline(nn.Module, CFGParallelMixin, ProgressBarMixin):
use_zero_init=use_zero_init,
zero_steps=zero_steps,
device=device,
generator=generator,
)
if keep_first_frame and (
@@ -752,6 +753,7 @@ class HeliosPipeline(nn.Module, CFGParallelMixin, ProgressBarMixin):
use_zero_init: bool = True,
zero_steps: int = 1,
device: torch.device | None = None,
generator: torch.Generator | list[torch.Generator] | None = None,
) -> torch.Tensor:
"""Pyramid multi-stage denoising for one chunk."""
batch_size, num_channel, num_frames_lat, height, width = latents.shape
@@ -804,7 +806,9 @@ class HeliosPipeline(nn.Module, CFGParallelMixin, ProgressBarMixin):
alpha = 1 / (math.sqrt(1 + (1 / gamma)) * (1 - ori_sigma) + ori_sigma)
beta = alpha * (1 - ori_sigma) / math.sqrt(gamma)
noise = self.sample_block_noise(batch_size, num_channel, latents.shape[2], height, width, patch_size)
noise = self.sample_block_noise(
batch_size, num_channel, latents.shape[2], height, width, patch_size, generator=generator
)
noise = noise.to(device=device, dtype=transformer_dtype)
latents = alpha * latents + beta * noise
@@ -873,18 +877,23 @@ class HeliosPipeline(nn.Module, CFGParallelMixin, ProgressBarMixin):
return latents
def sample_block_noise(self, batch_size, channel, num_frames, height, width, patch_size=(1, 2, 2)):
def sample_block_noise(self, batch_size, channel, num_frames, height, width, patch_size=(1, 2, 2), generator=None):
gamma = self.scheduler.config.gamma
_, ph, pw = patch_size
block_size = ph * pw
cov = torch.eye(block_size) * (1 + gamma) - torch.ones(block_size, block_size) * gamma
dist = torch.distributions.MultivariateNormal(torch.zeros(block_size, device=cov.device), covariance_matrix=cov)
cov += torch.eye(block_size) * 1e-8
cov = cov.float() # Upcast to fp32 for numerical stability — cholesky is unreliable in fp16/bf16.
L = torch.linalg.cholesky(cov)
block_number = batch_size * channel * num_frames * (height // ph) * (width // pw)
z = torch.randn(block_number, block_size, generator=generator)
noise = z @ L.T
noise = dist.sample((block_number,))
noise = noise.view(batch_size, channel, num_frames, height // ph, width // pw, ph, pw)
noise = noise.permute(0, 1, 2, 3, 5, 4, 6).reshape(batch_size, channel, num_frames, height, width)
return noise
def predict_noise(self, **kwargs: Any) -> torch.Tensor:
@@ -78,7 +78,7 @@ class HeliosScheduler(SchedulerMixin, ConfigMixin):
num_train_timesteps = self.config.num_train_timesteps
shift = self.config.shift
alphas = np.linspace(1, 1 / num_train_timesteps, num_train_timesteps + 1)
alphas = np.linspace(1, 1 / num_train_timesteps, num_train_timesteps + 1, dtype=np.float32)
sigmas = 1.0 - alphas
sigmas = np.flip(shift * sigmas / (1 + (shift - 1) * sigmas))[:-1].copy()
sigmas = torch.from_numpy(sigmas)
@@ -133,11 +133,11 @@ class HeliosScheduler(SchedulerMixin, ConfigMixin):
timestep_ratio = self.timestep_ratios[i_s]
timestep_max = min(self.timesteps[int(timestep_ratio[0] * training_steps)], 999)
timestep_min = self.timesteps[min(int(timestep_ratio[1] * training_steps), training_steps - 1)]
timesteps = np.linspace(timestep_max, timestep_min, training_steps + 1)
timesteps = np.linspace(timestep_max, timestep_min, training_steps + 1, dtype=np.float32)
self.timesteps_per_stage[i_s] = (
timesteps[:-1] if isinstance(timesteps, torch.Tensor) else torch.from_numpy(timesteps[:-1])
)
stage_sigmas = np.linspace(0.999, 0, training_steps + 1)
stage_sigmas = np.linspace(0.999, 0, training_steps + 1, dtype=np.float32)
self.sigmas_per_stage[i_s] = torch.from_numpy(stage_sigmas[:-1])
@property
@@ -188,10 +188,11 @@ class HeliosScheduler(SchedulerMixin, ConfigMixin):
stage_timesteps[0].item(),
stage_timesteps[-1].item(),
num_inference_steps,
dtype=np.float32,
)
stage_sigmas = self.sigmas_per_stage[stage_index]
ratios = np.linspace(stage_sigmas[0].item(), stage_sigmas[-1].item(), num_inference_steps)
ratios = np.linspace(stage_sigmas[0].item(), stage_sigmas[-1].item(), num_inference_steps, dtype=np.float32)
sigmas = torch.from_numpy(ratios)
self.timesteps = torch.from_numpy(timesteps).to(device=device)
@@ -6,6 +6,7 @@ from __future__ import annotations
import json
import logging
import os
import time
from collections.abc import Iterable
from typing import Any, cast
@@ -21,7 +22,7 @@ from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import Dist
from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin
from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin, _is_rank_zero
from vllm_omni.diffusion.models.schedulers import FlowUniPCMultistepScheduler
from vllm_omni.diffusion.models.wan2_2.wan2_2_transformer import WanTransformer3DModel
from vllm_omni.diffusion.request import OmniDiffusionRequest
@@ -29,6 +30,7 @@ from vllm_omni.inputs.data import OmniTextPrompt
from vllm_omni.platforms import current_omni_platform
logger = logging.getLogger(__name__)
DEBUG_PERF = False
def retrieve_latents(
@@ -424,7 +426,11 @@ class Wan22Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin):
if generator is None and req.sampling_params.seed is not None:
generator = torch.Generator(device=device).manual_seed(req.sampling_params.seed)
# Encode prompts
if DEBUG_PERF:
# Sync GPU before timing to ensure accurate measurements
current_omni_platform.synchronize()
_t_pipeline_start = time.perf_counter()
_t_text_enc_start = _t_pipeline_start
if prompt_embeds is None:
prompt_embeds, negative_prompt_embeds = self.encode_prompt(
prompt=prompt,
@@ -443,6 +449,9 @@ class Wan22Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin):
raise ValueError(
"negative_prompt_embeds must be provided when prompt_embeds are given and guidance > 1."
)
if DEBUG_PERF:
current_omni_platform.synchronize()
_t_text_enc_ms = (time.perf_counter() - _t_text_enc_start) * 1000
# Timesteps
self.scheduler.set_timesteps(num_steps, device=device)
@@ -452,7 +461,8 @@ class Wan22Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin):
if boundary_ratio is not None:
boundary_timestep = boundary_ratio * self.scheduler.config.num_train_timesteps
# Handle I2V mode when expand_timesteps=True and image is provided
if DEBUG_PERF:
_t_latent_prep_start = time.perf_counter()
multi_modal_data = req.prompts[0].get("multi_modal_data", {}) if not isinstance(req.prompts[0], str) else None
raw_image = multi_modal_data.get("image", None) if multi_modal_data is not None else None
if isinstance(raw_image, list):
@@ -543,11 +553,15 @@ class Wan22Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin):
generator=generator,
latents=req.sampling_params.latents,
)
if DEBUG_PERF:
current_omni_platform.synchronize()
_t_latent_prep_ms = (time.perf_counter() - _t_latent_prep_start) * 1000
if attention_kwargs is None:
attention_kwargs = {}
# Denoising
if DEBUG_PERF:
_t_denoise_start = time.perf_counter()
with self.progress_bar(total=len(timesteps)) as pbar:
for t in timesteps:
self._current_timestep = t
@@ -638,12 +652,16 @@ class Wan22Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin):
if current_omni_platform.is_available():
current_omni_platform.empty_cache()
self._current_timestep = None
if DEBUG_PERF:
current_omni_platform.synchronize()
_t_denoise_ms = (time.perf_counter() - _t_denoise_start) * 1000
# For I2V mode: blend final latents with condition
if self.expand_timesteps and latent_condition is not None:
latents = (1 - first_frame_mask) * latent_condition + first_frame_mask * latents
# Decode
if DEBUG_PERF:
_t_decode_start = time.perf_counter()
if output_type == "latent":
output = latents
else:
@@ -659,6 +677,28 @@ class Wan22Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin):
latents = latents / latents_std + latents_mean
output = self.vae.decode(latents, return_dict=False)[0]
if DEBUG_PERF:
current_omni_platform.synchronize()
_t_decode_ms = (time.perf_counter() - _t_decode_start) * 1000
_t_pipeline_wall_ms = (time.perf_counter() - _t_pipeline_start) * 1000
_t_stages_sum = _t_text_enc_ms + _t_latent_prep_ms + _t_denoise_ms + _t_decode_ms
if _is_rank_zero():
logger.info(
"Pipeline stage timing summary: "
"TextEncoding=%.2f ms, LatentPreparation=%.2f ms, "
"Denoising=%.2f ms (%d steps), Decoding=%.2f ms, "
"StagesSum=%.2f ms, PipelineWall=%.2f ms, Unaccounted=%.2f ms",
_t_text_enc_ms,
_t_latent_prep_ms,
_t_denoise_ms,
len(timesteps),
_t_decode_ms,
_t_stages_sum,
_t_pipeline_wall_ms,
_t_pipeline_wall_ms - _t_stages_sum,
)
return DiffusionOutput(output=output)
def predict_noise(self, current_model: nn.Module | None = None, **kwargs: Any) -> torch.Tensor:
@@ -5,6 +5,7 @@ from __future__ import annotations
import logging
import os
import time
from collections.abc import Iterable
from typing import Any, cast
@@ -22,7 +23,7 @@ from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
from vllm_omni.diffusion.models.interface import SupportImageInput
from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin
from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin, _is_rank_zero
from vllm_omni.diffusion.models.schedulers import FlowUniPCMultistepScheduler
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import (
create_transformer_from_config,
@@ -34,6 +35,7 @@ from vllm_omni.inputs.data import OmniTextPrompt
from vllm_omni.platforms import current_omni_platform
logger = logging.getLogger(__name__)
DEBUG_PERF = False
def _load_model_index(model: str, local_files_only: bool) -> dict:
@@ -388,7 +390,12 @@ class Wan22I2VPipeline(nn.Module, SupportImageInput, CFGParallelMixin, ProgressB
if generator is None and req.sampling_params.seed is not None:
generator = torch.Generator(device=device).manual_seed(req.sampling_params.seed)
# Encode prompts
if DEBUG_PERF:
# Sync GPU before timing to ensure accurate measurements
current_omni_platform.synchronize()
_t_pipeline_start = time.perf_counter()
_t_text_enc_start = _t_pipeline_start
if prompt_embeds is None:
prompt_embeds, negative_prompt_embeds = self.encode_prompt(
prompt=prompt,
@@ -404,9 +411,14 @@ class Wan22I2VPipeline(nn.Module, SupportImageInput, CFGParallelMixin, ProgressB
if negative_prompt_embeds is not None:
negative_prompt_embeds = negative_prompt_embeds.to(device=device, dtype=dtype)
if DEBUG_PERF:
current_omni_platform.synchronize()
_t_text_enc_ms = (time.perf_counter() - _t_text_enc_start) * 1000
batch_size = prompt_embeds.shape[0]
# Encode image embeddings (for Wan2.1-style with CLIP)
if DEBUG_PERF:
_t_img_enc_start = time.perf_counter()
if self.has_image_encoder and self.transformer.config.image_dim is not None:
if image_embeds is None:
if last_image is None:
@@ -418,6 +430,10 @@ class Wan22I2VPipeline(nn.Module, SupportImageInput, CFGParallelMixin, ProgressB
else:
image_embeds = None
if DEBUG_PERF:
current_omni_platform.synchronize()
_t_img_enc_ms = (time.perf_counter() - _t_img_enc_start) * 1000
# Timesteps
self.scheduler.set_timesteps(num_steps, device=device)
timesteps = self.scheduler.timesteps
@@ -430,7 +446,8 @@ class Wan22I2VPipeline(nn.Module, SupportImageInput, CFGParallelMixin, ProgressB
# Prepare latents (use out_channels=16 for VAE latent, not in_channels=36)
num_channels_latents = self.transformer.config.out_channels
# Preprocess image for VAE
if DEBUG_PERF:
_t_latent_prep_start = time.perf_counter()
from diffusers.video_processor import VideoProcessor
video_processor = VideoProcessor(vae_scale_factor=self.vae_scale_factor_spatial)
@@ -465,10 +482,15 @@ class Wan22I2VPipeline(nn.Module, SupportImageInput, CFGParallelMixin, ProgressB
last_image=last_image_tensor,
)
if DEBUG_PERF:
current_omni_platform.synchronize()
_t_latent_prep_ms = (time.perf_counter() - _t_latent_prep_start) * 1000
if attention_kwargs is None:
attention_kwargs = {}
# Denoising loop
if DEBUG_PERF:
_t_denoise_start = time.perf_counter()
with self.progress_bar(total=len(timesteps)) as pbar:
for t in timesteps:
self._current_timestep = t
@@ -538,11 +560,17 @@ class Wan22I2VPipeline(nn.Module, SupportImageInput, CFGParallelMixin, ProgressB
current_omni_platform.empty_cache()
self._current_timestep = None
if DEBUG_PERF:
current_omni_platform.synchronize()
_t_denoise_ms = (time.perf_counter() - _t_denoise_start) * 1000
# For expand_timesteps mode, blend final latents with condition
if self.expand_timesteps:
latents = (1 - first_frame_mask) * condition + first_frame_mask * latents
# Decode
if DEBUG_PERF:
_t_decode_start = time.perf_counter()
if output_type == "latent":
output = latents
else:
@@ -558,6 +586,30 @@ class Wan22I2VPipeline(nn.Module, SupportImageInput, CFGParallelMixin, ProgressB
latents = latents / latents_std + latents_mean
output = self.vae.decode(latents, return_dict=False)[0]
if DEBUG_PERF:
current_omni_platform.synchronize()
_t_decode_ms = (time.perf_counter() - _t_decode_start) * 1000
_t_pipeline_wall_ms = (time.perf_counter() - _t_pipeline_start) * 1000
_t_stages_sum = _t_text_enc_ms + _t_img_enc_ms + _t_latent_prep_ms + _t_denoise_ms + _t_decode_ms
if _is_rank_zero():
logger.info(
"Pipeline stage timing summary: "
"TextEncoding=%.2f ms, ImageEncoding=%.2f ms, "
"LatentPreparation=%.2f ms, Denoising=%.2f ms (%d steps), "
"Decoding=%.2f ms, StagesSum=%.2f ms, PipelineWall=%.2f ms, "
"Unaccounted=%.2f ms",
_t_text_enc_ms,
_t_img_enc_ms,
_t_latent_prep_ms,
_t_denoise_ms,
len(timesteps),
_t_decode_ms,
_t_stages_sum,
_t_pipeline_wall_ms,
_t_pipeline_wall_ms - _t_stages_sum,
)
return DiffusionOutput(output=output)
def predict_noise(self, current_model: nn.Module | None = None, **kwargs: Any) -> torch.Tensor:
@@ -50,6 +50,9 @@ class LayerwiseOffloadHook(ModelHook):
# Per-block synchronization primitive: set after H2D copy completes.
self._prefetch_done: current_omni_platform.Event | None = None
# Backward link to the hook that is responsible for prefetching *this* block's weights
self._prev_hook: LayerwiseOffloadHook | None = None
self.next_block_parameters: dict[str, nn.Parameter] = {}
self.next_block_buffers: dict[str, torch.Tensor] = {}
self.dtype_cpu_flattened_weights: dict[torch.dtype, torch.Tensor] = {}
@@ -128,6 +131,14 @@ class LayerwiseOffloadHook(ModelHook):
return dtype_cpu_flattened_weights, dtype_metadata
@property
def is_materialized(self) -> bool:
"""Check whether this block's parameters hold real data on device."""
for param in self.block_parameters.values():
return param.data.dim() > 0
return True
@torch.compiler.disable
def prefetch_layer(self, non_blocking: bool = True) -> None:
"""Copy layer weights from CPU -> GPU.
@@ -185,6 +196,12 @@ class LayerwiseOffloadHook(ModelHook):
buf.data = torch.empty((), device=self.device, dtype=buf.dtype)
def pre_forward(self, module: nn.Module, *args: Any, **kwargs: Any) -> tuple[tuple, dict]:
# if the previous hook was skipped and the weights are not on device,
# (e.g. by cache-dit block caching), ask the previous hook to
# synchronously prefetch *this* block's weights before computation
if not self.is_materialized and self._prev_hook is not None:
self._prev_hook.prefetch_layer(non_blocking=False)
self.prefetch_layer(non_blocking=True)
return args, kwargs
@@ -293,13 +310,23 @@ class LayerWiseOffloadBackend(OffloadBackend):
# For subsequent requests, the first layer/block will be pre-fetched
# during the last layer compute of the previous request.
last_block, first_block = blocks[-1], blocks[0]
hook = apply_block_hook(last_block, first_block, self.device, self.copy_stream, self.config.pin_cpu_memory)
hook.prefetch_layer(non_blocking=False)
last_hook = apply_block_hook(
last_block, first_block, self.device, self.copy_stream, self.config.pin_cpu_memory
)
last_hook.prefetch_layer(non_blocking=False)
block_hooks: list[LayerwiseOffloadHook] = [last_hook]
# Register hook for each of blocks
for i, block in enumerate(blocks[:-1]):
next_block = blocks[(i + 1) % num_blocks]
apply_block_hook(block, next_block, self.device, self.copy_stream, self.config.pin_cpu_memory)
hook = apply_block_hook(block, next_block, self.device, self.copy_stream, self.config.pin_cpu_memory)
block_hooks.append(hook)
# NOTE(yuanheng-zhao): We make each hook gets a backward reference to the hook
# that is responsible for prefetching its block's weights. This is specifically a
# workaround for that arbitrary blocks are skipped by caching systems (e.g., cache-dit)
for i in range(len(block_hooks)):
block_hooks[i]._prev_hook = block_hooks[i - 1]
logger.info(f"Layer-wise offloading enabled on {num_blocks} layers (blocks)")
@@ -35,6 +35,23 @@ class SequentialOffloadHook(ModelHook):
self.device = device
self.pin_memory = pin_memory
@staticmethod
def _move_params(module: nn.Module, device: torch.device) -> None:
"""Move module parameters and buffers to device.
This cls method specifically prevents recursion device movement,
E.g., Cache-DiT CachedBlocks has attr `transformer` as a ref to original
transformer blocks, thus `module.to(device)` will fail for recursion calling,
refer to
https://github.com/vipshop/cache-dit/blob/v1.2.3/src/cache_dit/caching/cache_blocks/__init__.py#L83
"""
for p in module.parameters():
if p.data.device != device:
p.data = p.data.to(device, non_blocking=True)
for b in module.buffers():
if b.device != device:
b.data = b.data.to(device, non_blocking=True)
def _to_cpu(self, module: nn.Module) -> None:
"""Move module to CPU."""
try:
@@ -42,13 +59,12 @@ class SequentialOffloadHook(ModelHook):
except StopIteration:
return
previous_device = param.device
# Skip if already on CPU
if previous_device.type == "cpu":
if param.device.type == "cpu":
return
module.to("cpu")
torch.cuda.empty_cache()
self._move_params(module, torch.device("cpu"))
current_omni_platform.empty_cache()
if self.pin_memory:
for p in module.parameters():
@@ -64,7 +80,7 @@ class SequentialOffloadHook(ModelHook):
except StopIteration:
return
module.to(self.device)
self._move_params(module, self.device)
def pre_forward(self, module: nn.Module, *args, **kwargs) -> tuple[tuple, dict]:
# Offload target modules to CPU
@@ -186,19 +202,6 @@ class ModelLevelOffloadBackend(OffloadBackend):
except Exception as exc:
logger.debug("Failed to move VAE to GPU: %s", exc)
# Initial state: keep DiT modules on CPU (encoders typically run first)
# TODO: This part seems to be unnecessary, remove it after testing
# for dit_mod in modules.dits:
# dit_mod.to("cpu")
# torch.cuda.empty_cache()
# if self.config.pin_cpu_memory:
# for dit_mod in modules.dits:
# for p in dit_mod.parameters():
# if p.data.device.type == "cpu" and not p.data.is_pinned():
# p.data = p.data.pin_memory()
# Apply sequential offloading hooks
apply_sequential_offload(
dit_modules=modules.dits,
+8 -1
View File
@@ -8,6 +8,7 @@ from vllm.distributed.device_communicators.shm_broadcast import MessageQueue
from vllm.logger import init_logger
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.ipc import unpack_diffusion_output_shm
from vllm_omni.diffusion.request import OmniDiffusionRequest
logger = init_logger(__name__)
@@ -59,12 +60,18 @@ class Scheduler:
# Broadcast RPC request to all workers
self.mq.enqueue(rpc_request)
# Wait for result from Rank 0 (or whoever sends it)
# Wait for result from Rank 0 (or whoever sends it)
if self.result_mq is None:
raise RuntimeError("Result queue not initialized")
output = self.result_mq.dequeue()
try:
unpack_diffusion_output_shm(output)
except Exception as e:
logger.warning("SHM unpack failed (data may already be inline): %s", e)
# {"status": "error", "error": str(e)}
if isinstance(output, dict) and output.get("status") == "error":
raise RuntimeError("worker error")
+15 -2
View File
@@ -34,6 +34,7 @@ from vllm_omni.diffusion.distributed.parallel_state import (
initialize_model_parallel,
)
from vllm_omni.diffusion.forward_context import set_forward_context
from vllm_omni.diffusion.ipc import pack_diffusion_output_shm
from vllm_omni.diffusion.lora.manager import DiffusionLoRAManager
from vllm_omni.diffusion.profiler import CurrentProfiler
from vllm_omni.diffusion.request import OmniDiffusionRequest
@@ -63,6 +64,7 @@ class DiffusionWorker:
local_rank: int,
rank: int,
od_config: OmniDiffusionConfig,
skip_load_model: bool = False,
):
self.local_rank = local_rank
self.rank = rank
@@ -79,8 +81,9 @@ class DiffusionWorker:
od_config=self.od_config,
device=self.device,
)
self.load_model(load_format=self.od_config.diffusion_load_format)
self.init_lora_manager()
if not skip_load_model:
self.load_model(load_format=self.od_config.diffusion_load_format)
self.init_lora_manager()
logger.info(f"Worker {self.rank}: Initialization complete.")
def init_device(self) -> None:
@@ -123,6 +126,7 @@ class DiffusionWorker:
tensor_parallel_size=parallel_config.tensor_parallel_size,
pipeline_parallel_size=parallel_config.pipeline_parallel_size,
fully_shard_degree=parallel_config.hsdp_shard_size if parallel_config.use_hsdp else 1,
hsdp_replicate_size=parallel_config.hsdp_replicate_size if parallel_config.use_hsdp else 1,
)
init_workspace_manager(self.device)
@@ -373,6 +377,10 @@ class WorkerProc:
def return_result(self, output: DiffusionOutput):
"""Reply to client, only on rank 0."""
if self.result_mq is not None:
try:
pack_diffusion_output_shm(output)
except Exception as e:
logger.warning("SHM pack failed, falling back to raw enqueue: %s", e)
self.result_mq.enqueue(output)
def recv_message(self):
@@ -524,10 +532,15 @@ class WorkerWrapperBase:
worker_class = self._prepare_worker_class()
# Create the actual worker instance
# When custom_pipeline_args is provided, skip initial model loading
# since re_init_pipeline will handle it. This avoids allocating memory
# through CuMemAllocator twice, which causes assertion failures in
# sleep mode.
self.worker = worker_class(
local_rank=gpu_id,
rank=gpu_id,
od_config=od_config,
skip_load_model=(self.custom_pipeline_args is not None),
)
# Re-initialize pipeline with custom pipeline if provided
@@ -12,6 +12,7 @@ from vllm.logger import init_logger
from .factory import OmniConnectorFactory
from .utils.config import ConnectorSpec
from .utils.kv_utils import normalize_layer_kv
logger = init_logger(__name__)
@@ -200,8 +201,12 @@ class OmniKVTransferManager:
logger.warning(f"Request {req_id} has no block IDs, skipping")
continue
custom_metadata = data.get("custom_metadata")
# Extract KV cache from GPU blocks -> CPU tensors
kv_data = self._extract_kv_cache(req_id, block_ids, seq_len, kv_caches, block_size, cache_dtype)
kv_data = self._extract_kv_cache(
req_id, block_ids, seq_len, kv_caches, block_size, cache_dtype, custom_metadata
)
if kv_data:
# Resolve global request ID if available
transfer_req_id = request_id_resolver(req_id) if request_id_resolver else req_id
@@ -224,6 +229,7 @@ class OmniKVTransferManager:
kv_caches: list[LayerKV],
block_size: int,
cache_dtype: str,
custom_metadata: dict[str, Any] | None = None,
) -> KVCacheTransferData | None:
"""Extract KV cache from GPU blocks for a single request.
@@ -234,6 +240,7 @@ class OmniKVTransferManager:
kv_caches: List of KV cache (tensor or tuple) per layer
block_size: Size of each cache block
cache_dtype: Data type of the cache
custom_metadata: Optional custom metadata to include
Note: If key/value block counts differ, extraction uses only the overlapping
block range. Extra key/value blocks are ignored, so returned KV may be partial.
@@ -246,7 +253,7 @@ class OmniKVTransferManager:
value_cache: list[torch.Tensor | None] = [None] * num_layers
for layer_idx, layer_kv in enumerate(kv_caches):
kv_pair = self._normalize_layer_kv(layer_kv, req_id=req_id, layer_idx=layer_idx)
kv_pair = normalize_layer_kv(layer_kv, req_id=req_id, layer_idx=layer_idx)
if kv_pair is None:
continue
key_blocks, value_blocks = kv_pair
@@ -289,57 +296,10 @@ class OmniKVTransferManager:
"num_layers": num_layers,
"dtype": str(cache_dtype),
"seq_len": seq_len,
**(custom_metadata or {}),
},
)
def _normalize_layer_kv(
self,
layer_kv: LayerKV,
req_id: str,
layer_idx: int,
) -> tuple[torch.Tensor, torch.Tensor] | None:
"""Normalize one layer KV cache to a `(key_blocks, value_blocks)` tuple.
Args:
layer_kv: The raw KV cache (tensor or tuple) for the layer
req_id: Request ID for logging
layer_idx: Layer index for logging
Returns:
Tuple of (key_blocks, value_blocks) if valid, None otherwise
"""
if isinstance(layer_kv, torch.Tensor):
if layer_kv.ndim < 3 or layer_kv.shape[0] != 2:
logger.warning(
f"Layer {layer_idx} for request {req_id} has invalid stacked KV shape: "
f"expected [2, blocks, block_size, ...], got {tuple(layer_kv.shape)}"
)
return None
key_blocks = layer_kv[0]
value_blocks = layer_kv[1]
elif isinstance(layer_kv, tuple):
if len(layer_kv) != 2:
logger.warning(
f"Layer {layer_idx} for request {req_id} has KV pair length {len(layer_kv)} (expected 2)"
)
return None
key_blocks, value_blocks = layer_kv
if not isinstance(key_blocks, torch.Tensor) or not isinstance(value_blocks, torch.Tensor):
logger.warning(f"Layer {layer_idx} for request {req_id} has non-tensor KV pair entries")
return None
else:
logger.warning(f"Layer {layer_idx} for request {req_id} has unsupported KV type {type(layer_kv).__name__}")
return None
# ensure key/value blocks are at least 2D for block indexing
if key_blocks.ndim < 2 or value_blocks.ndim < 2:
logger.warning(
f"Layer {layer_idx} for request {req_id} has invalid KV block shape: "
f"got key={tuple(key_blocks.shape)} value={tuple(value_blocks.shape)}"
)
return None
return key_blocks, value_blocks
def _transfer_kv_cache(self, kv_data: KVCacheTransferData, transfer_req_id: str) -> None:
"""Transfer KV cache data to downstream stage via OmniConnector.
@@ -496,6 +456,8 @@ class OmniKVTransferManager:
if "metadata" in data:
req.kv_metadata = data["metadata"]
if hasattr(req, "sampling_params") and req.sampling_params is not None:
req.sampling_params.kv_metadata = data["metadata"]
# Legacy compatibility method
def receive_kv_cache(self, req: Any, target_device: torch.device | None = None) -> bool:
@@ -39,7 +39,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
self.scheduler_max_num_seqs = vllm_config.scheduler_config.max_num_seqs
self.connector = self.create_connector(model_config)
super().__init__(model_config)
self.model_mode = getattr(model_config, "worker_type", "ar")
self.model_mode = getattr(model_config, "worker_type", None) or "ar"
# State specific to Chunk management
self.custom_process_next_stage_input_func = None
custom_process_next_stage_input_func = getattr(model_config, "custom_process_next_stage_input_func", None)
@@ -0,0 +1,85 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Utility helpers for KV cache manipulation."""
import torch
from vllm.logger import init_logger
logger = init_logger(__name__)
LayerKV = torch.Tensor | tuple[torch.Tensor, torch.Tensor]
def normalize_layer_kv(
layer_kv: LayerKV,
*,
req_id: str = "",
layer_idx: int = -1,
) -> tuple[torch.Tensor, torch.Tensor] | None:
"""Normalize one layer KV cache to a ``(key_blocks, value_blocks)`` tuple.
In vLLM, different attention backends return paged-attention KV blocks
with different layouts. For example:
* **FlashAttention** (``vllm/v1/attention/backends/flash_attn.py``)
returns shape ``(2, num_blocks, block_size, num_kv_heads, head_size)``
– the key/value dimension is at **dim 0**.
* **FlashInfer** (``vllm/v1/attention/backends/flashinfer.py``)
returns shape ``(num_blocks, 2, block_size, num_kv_heads, head_size)``
– the key/value dimension is at **dim 1**.
This utility handles both layouts (and the tuple case) so that
downstream code can work with any backend.
Supported layouts:
* **Stacked tensor** ``[2, num_blocks, block_size, n_heads, head_dim]`` –
dim-0 selects key / value.
* **Stacked tensor** ``[num_blocks, 2, block_size, n_heads, head_dim]`` –
dim-1 selects key / value.
* **Tuple** ``(key_tensor, value_tensor)`` – returned as-is after
validation.
Args:
layer_kv: The raw KV cache (tensor or tuple) for the layer.
req_id: Request ID used only for diagnostic log messages.
layer_idx: Layer index used only for diagnostic log messages.
Returns:
``(key_blocks, value_blocks)`` if *layer_kv* is valid, ``None``
otherwise.
"""
if isinstance(layer_kv, torch.Tensor):
if layer_kv.ndim >= 3 and layer_kv.shape[0] == 2:
key_blocks = layer_kv[0]
value_blocks = layer_kv[1]
elif layer_kv.ndim >= 3 and layer_kv.shape[1] == 2:
key_blocks = layer_kv[:, 0]
value_blocks = layer_kv[:, 1]
else:
logger.warning(
f"Layer {layer_idx} for request {req_id} has invalid stacked KV shape: "
f"expected [2, ...] or [..., 2, ...] at dim 0/1, got {tuple(layer_kv.shape)}"
)
return None
elif isinstance(layer_kv, tuple):
if len(layer_kv) != 2:
logger.warning(f"Layer {layer_idx} for request {req_id} has KV pair length {len(layer_kv)} (expected 2)")
return None
key_blocks, value_blocks = layer_kv
if not isinstance(key_blocks, torch.Tensor) or not isinstance(value_blocks, torch.Tensor):
logger.warning(f"Layer {layer_idx} for request {req_id} has non-tensor KV pair entries")
return None
else:
logger.warning(f"Layer {layer_idx} for request {req_id} has unsupported KV type {type(layer_kv).__name__}")
return None
# ensure key/value blocks are at least 2D for block indexing
if key_blocks.ndim < 2 or value_blocks.ndim < 2:
logger.warning(
f"Layer {layer_idx} for request {req_id} has invalid KV block shape: "
f"got key={tuple(key_blocks.shape)} value={tuple(value_blocks.shape)}"
)
return None
return key_blocks, value_blocks
+234 -10
View File
@@ -4,8 +4,8 @@ import asyncio
import copy
import time
import weakref
from collections.abc import AsyncGenerator, Iterable, Sequence
from typing import Any
from collections.abc import AsyncGenerator, Callable, Iterable, Sequence
from typing import Any, TypeVar
from vllm.config import VllmConfig
from vllm.inputs.preprocess import InputPreprocessor
@@ -35,11 +35,20 @@ from vllm_omni.lora.request import LoRARequest
from vllm_omni.metrics import OrchestratorAggregator
from vllm_omni.outputs import OmniRequestOutput
_R = TypeVar("_R")
logger = init_logger(__name__)
def _weak_close_cleanup_async(stage_list, stage_in_queues, stage_out_queues, ray_pg, output_handler, zmq_ctx=None):
def _weak_close_cleanup_async(
stage_list, stage_in_queues, stage_out_queues, ray_pg, output_handler, zmq_ctx=None, inline_engine=None
):
"""Weak reference cleanup function for AsyncOmni instances."""
if inline_engine is not None:
try:
inline_engine.close()
except Exception as e:
logger.warning("Failed to close inline diffusion engine: %s", e)
if stage_list:
for q in stage_in_queues:
try:
@@ -105,10 +114,17 @@ class AsyncOmni(OmniBase):
self._pause_cond: asyncio.Condition = asyncio.Condition()
self._paused: bool = False
# Sleep mode tracking
self._is_sleeping: bool = False
# Request state tracking
self.request_states: dict[str, ClientRequestState] = {}
self.output_handler: asyncio.Task | None = None
# RPC results storage: {stage_id: {rpc_id: result}}
# Used to avoid race condition between output_handler and collective_rpc
self._rpc_results: dict[int, dict[str, dict[str, Any]]] = {}
super().__init__(model, **kwargs)
# Register weak reference cleanup (called on garbage collection)
@@ -121,6 +137,7 @@ class AsyncOmni(OmniBase):
self._ray_pg,
self.output_handler,
self._zmq_ctx,
getattr(self, "_inline_engine", None),
)
def _create_default_diffusion_stage_cfg(self, kwargs: dict[str, Any]) -> dict[str, Any]:
@@ -193,6 +210,8 @@ class AsyncOmni(OmniBase):
"enforce_eager": kwargs.get("enforce_eager", False),
"diffusion_load_format": kwargs.get("diffusion_load_format", "default"),
"custom_pipeline_args": kwargs.get("custom_pipeline_args", None),
"worker_extension_cls": kwargs.get("worker_extension_cls", None),
"enable_sleep_mode": kwargs.get("enable_sleep_mode", False),
"enable_multithread_weight_load": kwargs.get("enable_multithread_weight_load", True),
"num_weight_load_threads": kwargs.get("num_weight_load_threads", 4),
},
@@ -253,6 +272,26 @@ class AsyncOmni(OmniBase):
self.io_processor = None
self.model_config = None
def _setup_rpc_result_checkers(self) -> None:
"""Override base class to use async-friendly RPC result checkers.
In the async path the output_handler task drains the output queues
and stashes collective_rpc results into ``_rpc_results``. The
checker therefore only needs to look there (no queue draining).
"""
for stage in self.stage_list:
sid = stage.stage_id
def make_rpc_checker(stage_id: int):
def rpc_checker(rpc_id: str) -> dict[str, Any] | None:
if stage_id in self._rpc_results and rpc_id in self._rpc_results[stage_id]:
return self._rpc_results[stage_id].pop(rpc_id)
return None
return rpc_checker
stage._rpc_result_checker = make_rpc_checker(sid)
def shutdown(self):
"""Shutdown, cleaning up the background proc and IPC.
@@ -299,6 +338,11 @@ class AsyncOmni(OmniBase):
async with self._pause_cond:
await self._pause_cond.wait_for(lambda: not self._paused)
if self._inline_diffusion:
async for output in self._generate_inline(prompt, request_id, sampling_params_list, output_modalities):
yield output
return
logger.debug(f"[{self._name}] generate() called")
try:
# Start output handler on the first call to generate()
@@ -390,6 +434,97 @@ class AsyncOmni(OmniBase):
logger.info("[AsyncOrchestrator] Request %s aborted.", request_id)
raise
async def _generate_inline(
self,
prompt: OmniPromptType,
request_id: str,
sampling_params_list: Sequence[OmniSamplingParams] | None = None,
output_modalities: list[str] | None = None,
) -> AsyncGenerator[OmniRequestOutput, None]:
"""Generate using inline diffusion engine (no stage worker subprocess).
Eliminates Hop3 IPC overhead by running OmniDiffusion directly in the
orchestrator process. The blocking generate() call is offloaded to a
thread executor so the asyncio event loop remains responsive.
"""
_wall_start_ts = time.time()
if sampling_params_list is None:
sampling_params_list = self.default_sampling_params_list
sp0 = sampling_params_list[0]
stage = self.stage_list[0]
final_stage_id_for_e2e = 0
metrics = OrchestratorAggregator(
num_stages=1,
log_stats=self.log_stats,
wall_start_ts=_wall_start_ts,
final_stage_id_for_e2e=final_stage_id_for_e2e,
)
metrics.stage_first_ts[0] = time.time()
logger.info(
"[%s] Inline diffusion generate for request %s",
self._name,
request_id,
)
try:
loop = asyncio.get_running_loop()
results = await loop.run_in_executor(
None,
self._inline_engine.generate,
prompt,
sp0,
[request_id],
)
for result in results:
images = getattr(result, "images", None) or []
finished = getattr(result, "finished", True)
output_to_yield = OmniRequestOutput(
stage_id=0,
final_output_type=stage.final_output_type,
request_output=result,
images=images,
finished=finished,
)
metrics.stage_last_ts[0] = time.time()
yield output_to_yield
try:
metrics.on_finalize_request(
final_stage_id_for_e2e,
request_id,
_wall_start_ts,
)
metrics.build_and_log_summary()
except Exception as e:
logger.exception(
"[%s] Failed to finalize inline metrics: %s",
self._name,
e,
)
except (asyncio.CancelledError, GeneratorExit):
logger.info(
"[%s] Inline request %s cancelled.",
self._name,
request_id,
)
raise
except Exception as e:
logger.exception(
"[%s] Inline diffusion failed for request %s: %s",
self._name,
request_id,
e,
)
raise
async def _process_async_results(
self,
request_id: str,
@@ -401,7 +536,10 @@ class AsyncOmni(OmniBase):
) -> AsyncGenerator[OmniRequestOutput, None]:
all_stages_finished = {stage_id: False for stage_id in range(final_stage_id_for_e2e + 1)}
submit_flag = True
_loop_iter = 0
_last_progress_ts = time.time()
while not all(all_stages_finished.values()):
_loop_iter += 1
for stage_id, stage in enumerate(self.stage_list[: final_stage_id_for_e2e + 1]):
if all_stages_finished[stage_id]:
continue
@@ -410,6 +548,7 @@ class AsyncOmni(OmniBase):
except asyncio.QueueEmpty:
await asyncio.sleep(0.001)
continue
_last_progress_ts = time.time()
engine_outputs, finished, output_to_yield = self._process_single_result(
result,
stage,
@@ -418,11 +557,20 @@ class AsyncOmni(OmniBase):
)
if submit_flag and stage_id == 0:
submit_flag = False
prompt_token_ids = engine_outputs.prompt_token_ids
prompt_token_ids = getattr(engine_outputs, "prompt_token_ids", None)
if prompt_token_ids is None:
prompt_token_ids = []
engine_input = copy.deepcopy(prompt)
next_prompt_len = max(1, compute_talker_prompt_ids_length(prompt_token_ids))
try:
next_prompt_len = max(1, compute_talker_prompt_ids_length(prompt_token_ids))
except Exception:
raise
engine_input["prompt_token_ids"] = [0] * next_prompt_len
engine_input["multi_modal_data"] = engine_input["mm_processor_kwargs"] = None
for _mm_key in ("mm_kwargs", "mm_hashes", "mm_placeholders", "multi_modal_uuids"):
engine_input.pop(_mm_key, None)
if engine_input.get("type") == "multimodal":
engine_input["type"] = "token"
for i in range(1, len(self.stage_list)):
task = {
"request_id": request_id,
@@ -525,6 +673,7 @@ class AsyncOmni(OmniBase):
raise RuntimeError(result)
engine_outputs = _load(result, obj_key="engine_outputs", shm_key="engine_outputs_shm")
if isinstance(engine_outputs, list):
engine_outputs = engine_outputs[0]
@@ -542,12 +691,17 @@ class AsyncOmni(OmniBase):
images = engine_outputs.images
if stage.final_output_type == "image":
# Propagate custom_output from inner diffusion output
custom_output = {}
if isinstance(engine_outputs, OmniRequestOutput):
custom_output = engine_outputs.custom_output or {}
output_to_yield = OmniRequestOutput(
stage_id=stage_id,
final_output_type=stage.final_output_type,
request_output=engine_outputs,
images=images,
finished=finished,
_custom_output=custom_output,
)
else:
output_to_yield = OmniRequestOutput(
@@ -597,6 +751,15 @@ class AsyncOmni(OmniBase):
# so we wait for a short time and try again
await asyncio.sleep(0.05)
continue
# Handle collective_rpc results separately to avoid
# race condition with the polling in collective_rpc
if result.get("type") == "collective_rpc_result":
rpc_id = result.get("rpc_id")
if rpc_id:
if stage_id not in self._rpc_results:
self._rpc_results[stage_id] = {}
self._rpc_results[stage_id][rpc_id] = result
continue
req_id = result.get("request_id")
req_state = request_states.get(req_id)
if req_state is None:
@@ -632,7 +795,8 @@ class AsyncOmni(OmniBase):
@property
def is_running(self) -> bool:
# Is None before the loop is started.
if self._inline_diffusion:
return self._inline_engine is not None
return len(self._stage_in_queues) > 0
@property
@@ -656,6 +820,10 @@ class AsyncOmni(OmniBase):
return EngineDeadError()
async def abort(self, request_id: str | Iterable[str]) -> None:
if self._inline_diffusion:
if self._inline_engine is not None:
self._inline_engine.engine.abort(request_id)
return None
abort_task = {"type": OmniStageTaskType.ABORT, "request_id": request_id}
for stage in self.stage_list:
stage.submit(abort_task)
@@ -709,6 +877,8 @@ class AsyncOmni(OmniBase):
OMNI: Required by upstream OpenAIServingModels.__init__ which
accesses engine_client.renderer.
"""
if self.input_processor is None:
return None
return self.input_processor.renderer
async def do_log_stats(self) -> None:
@@ -723,19 +893,73 @@ class AsyncOmni(OmniBase):
async def reset_prefix_cache(self, reset_running_requests: bool = False) -> bool:
pass
async def collective_rpc(
self,
method: str | Callable[..., _R],
timeout: float | None = None,
args: tuple = (),
kwargs: dict[str, Any] | None = None,
) -> list[_R]:
"""Execute an RPC call on all stages asynchronously.
Args:
method: Name of the method to execute or callable
timeout: Optional timeout in seconds
args: Positional arguments for the method
kwargs: Keyword arguments for the method
Returns:
List of results from each stage
"""
self._run_output_handler()
# Run synchronous collective_rpc in thread pool to avoid blocking event loop
loop = asyncio.get_event_loop()
async def run_stage_rpc(stage: OmniStage) -> _R:
return await loop.run_in_executor(
None,
stage.collective_rpc,
method,
timeout,
args,
kwargs,
)
# Run all stages concurrently
results = await asyncio.gather(*[run_stage_rpc(stage) for stage in self.stage_list])
return list(results)
async def sleep(self, level: int = 1) -> None:
pass
self._is_sleeping = True
await self.collective_rpc(method="sleep", args=(level,))
async def wake_up(self, tags: list[str] | None = None) -> None:
pass
self._is_sleeping = False
await self.collective_rpc(method="wake_up", args=(tags,))
async def is_sleeping(self) -> bool:
"""Check whether the engine is sleeping"""
return False
return getattr(self, "_is_sleeping", False)
async def add_lora(self, lora_request: LoRARequest) -> bool:
"""Load a new LoRA adapter into the engine for future requests."""
return False
result = await self.collective_rpc(method="add_lora", args=(lora_request,))
return result[0][0]
async def remove_lora(self, adapter_id: int) -> bool:
"""Remove a LoRA adapter from the engine."""
result = await self.collective_rpc(method="remove_lora", args=(adapter_id,))
return result[0][0]
async def list_loras(self) -> list[int]:
"""List all loaded LoRA adapter IDs."""
result = await self.collective_rpc(method="list_loras")
return result[0][0]
async def pin_lora(self, adapter_id: int) -> bool:
"""Pin a LoRA adapter so it is not evicted from the cache."""
result = await self.collective_rpc(method="pin_lora", args=(adapter_id,))
return result[0][0]
async def encode(
self,
+198 -10
View File
@@ -9,7 +9,7 @@ import uuid
import weakref
from collections.abc import Callable, Generator, Sequence
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Literal, overload
from typing import Any, Literal, TypeVar, overload
import huggingface_hub
import msgspec.msgpack
@@ -40,18 +40,22 @@ from vllm_omni.entrypoints.omni_stage import OmniStage
from vllm_omni.entrypoints.stage_utils import SHUTDOWN_TASK, OmniStageTaskType
from vllm_omni.entrypoints.stage_utils import maybe_load_from_ipc as _load
from vllm_omni.entrypoints.utils import (
filter_dataclass_kwargs,
get_final_stage_id_for_e2e,
inject_omni_kv_config,
load_and_resolve_stage_configs,
)
from vllm_omni.entrypoints.zmq_utils import ZmqQueue
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniPromptType, OmniSamplingParams
from vllm_omni.lora.request import LoRARequest
from vllm_omni.metrics import OrchestratorAggregator, StageRequestStats
from vllm_omni.model_executor.model_loader.weight_utils import (
download_weights_from_hf_specific,
)
from vllm_omni.outputs import OmniRequestOutput
_R = TypeVar("_R")
logger = init_logger(__name__)
@@ -177,6 +181,13 @@ class OmniBase:
self._handshake_seen: set[int] = set() # Track which stage IDs have completed ZMQ handshake
self._single_stage_id: int | None = None # Optional: deploy only a specific stage ID
# Sleep mode tracking
self._is_sleeping: bool = False
# RPC results storage: {stage_id: {rpc_id: result}}
# Used by collective_rpc to retrieve results collected from the output queue
self._rpc_results: dict[int, dict[str, dict[str, Any]]] = {}
# Initialize stages - each stage will create appropriate instance based on stage_type
# Stage workers will automatically create OmniLLM or OmniDiffusion instances
# based on stage_type in YAML config (handled in omni_stage.py)
@@ -304,6 +315,9 @@ class OmniBase:
def _initialize_stages(self, model: str, kwargs: dict[str, Any]) -> None:
"""Initialize stage list management."""
self._inline_diffusion = False
self._inline_engine = None
stage_init_timeout = kwargs.get("stage_init_timeout", 20)
shm_threshold_bytes = kwargs.get("shm_threshold_bytes", 65536)
init_timeout = kwargs.get("init_timeout", 300)
@@ -351,6 +365,13 @@ class OmniBase:
self.output_modalities = [st.final_output_type for st in self.stage_list]
logger.info(f"[{self._name}] Loaded {len(self.stage_list)} stages")
# Phase 1 optimization: for a single diffusion stage in async mode,
# run the engine directly in the orchestrator process to eliminate
# the stage worker subprocess and its IPC serialization overhead.
if len(self.stage_list) == 1 and self.stage_list[0].stage_type == "diffusion" and self.is_async:
self._init_inline_diffusion_engine(model, self.stage_configs[0], kwargs)
return
if self.worker_backend == "ray":
self._queue_cls = get_ray_queue_class()
else:
@@ -362,6 +383,71 @@ class OmniBase:
self._start_stages(model)
# Wait for all stages to report readiness before seeding
self._wait_for_stages_ready(timeout=init_timeout)
# Set up RPC result checkers so that collective_rpc works
self._setup_rpc_result_checkers()
def _init_inline_diffusion_engine(
self,
model: str,
stage_config: Any,
kwargs: dict[str, Any],
) -> None:
"""Initialize diffusion engine directly in the orchestrator process.
For single-stage diffusion pipelines, this eliminates the stage worker
subprocess and the associated Hop3 IPC serialization overhead.
GPU workers for tensor parallelism are still spawned by the
DiffusionExecutor as separate processes.
"""
from vllm_omni.diffusion.data import OmniDiffusionConfig
from vllm_omni.entrypoints.omni_diffusion import OmniDiffusion
from vllm_omni.entrypoints.stage_utils import (
_to_dict,
load_func_from_config,
set_stage_devices,
)
stage_id = stage_config.stage_id
engine_args = _to_dict(stage_config.engine_args)
runtime_cfg = _to_dict(getattr(stage_config, "runtime", {}))
if os.environ.get("VLLM_WORKER_MULTIPROC_METHOD") != "spawn":
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
try:
from vllm_omni.platforms import current_omni_platform
device_type = current_omni_platform.device_type
set_stage_devices(stage_id, runtime_cfg.get("devices"), device_type=device_type)
except Exception as e:
logger.warning("Device setup for inline diffusion failed: %s", e)
engine_args = filter_dataclass_kwargs(OmniDiffusionConfig, engine_args)
engine_args.pop("model_stage", None)
engine_args.pop("model", None)
cfg_kv_collect_func = load_func_from_config(getattr(stage_config, "cfg_kv_collect_func", None))
self._inline_engine = OmniDiffusion(
model=model,
stage_id=stage_id,
engine_input_source=getattr(stage_config, "engine_input_source", []),
cfg_kv_collect_func=cfg_kv_collect_func,
**engine_args,
)
self._inline_diffusion = True
# These attributes are normally set by AsyncOmni._wait_for_stages_ready
# but we skip that for inline mode. Set them to None since there is no
# LLM stage to provide them.
self.input_processor = None
self.io_processor = None
self.model_config = None
logger.info(
"[%s] Inline diffusion mode active – stage worker subprocess bypassed",
self._name,
)
def _is_async_chunk_enable(self, stage_args: list) -> bool:
"""get async chunk flag"""
@@ -517,6 +603,117 @@ class OmniBase:
profiler = getattr(profiler_config, "profiler", None)
return profiler is not None
def _setup_rpc_result_checkers(self) -> None:
"""Set up RPC result checkers for all stages.
Each checker reads from the shared ``_rpc_results`` dict so that
``OmniStage.collective_rpc`` can retrieve results that were
collected by the orchestrator (or, in the sync path, by the
stage-level checker that drains the output queue).
Uses a weak reference to ``self`` to avoid a circular reference
(OmniBase → stage_list → OmniStage → closure → OmniBase) that
would prevent the instance from being freed by reference counting.
"""
weak_self = weakref.ref(self)
for stage in self.stage_list:
sid = stage.stage_id
def make_rpc_checker(stage_id: int):
def rpc_checker(rpc_id: str) -> dict[str, Any] | None:
_self = weak_self()
if _self is None:
return None
# First check the shared dict
if stage_id in _self._rpc_results and rpc_id in _self._rpc_results[stage_id]:
return _self._rpc_results[stage_id].pop(rpc_id)
# In the sync path there is no background output handler,
# so drain the output queue ourselves and stash any
# non-RPC results back.
out_q = _self._stage_out_queues[stage_id] if stage_id < len(_self._stage_out_queues) else None
if out_q is not None:
import queue as _queue
try:
while True:
item = out_q.get_nowait()
if isinstance(item, dict) and item.get("type") == "collective_rpc_result":
item_rpc_id = item.get("rpc_id")
if item_rpc_id == rpc_id:
return item
# Stash for another caller
if stage_id not in _self._rpc_results:
_self._rpc_results[stage_id] = {}
_self._rpc_results[stage_id][item_rpc_id] = item
else:
# Non-RPC item — put it back
out_q.put(item)
break
except _queue.Empty:
pass
return None
return rpc_checker
stage._rpc_result_checker = make_rpc_checker(sid)
def collective_rpc(
self,
method: str | Callable[..., _R],
timeout: float | None = None,
args: tuple = (),
kwargs: dict[str, Any] | None = None,
) -> list[_R]:
"""Execute an RPC call on all stage workers.
Args:
method: Name of the worker method to execute, or a callable.
timeout: Maximum time in seconds to wait for execution.
args: Positional arguments to pass to the worker method.
kwargs: Keyword arguments to pass to the worker method.
Returns:
A list containing the results from each stage.
"""
results: list[_R] = []
for stage in self.stage_list:
results.append(
stage.collective_rpc(
method=method,
timeout=timeout,
args=args,
kwargs=kwargs,
)
)
return results
def sleep(self, level: int = 1) -> None:
"""Put the engine to sleep to free up resources.
Args:
level: Sleep level (1 = light sleep, higher = deeper sleep).
"""
self._is_sleeping = True
self.collective_rpc(method="sleep", args=(level,))
def wake_up(self, tags: list[str] | None = None) -> None:
"""Wake the engine up from sleep.
Args:
tags: Optional list of tags to selectively wake components.
"""
self._is_sleeping = False
self.collective_rpc(method="wake_up", args=(tags,))
def is_sleeping(self) -> bool:
"""Check whether the engine is sleeping."""
return getattr(self, "_is_sleeping", False)
def add_lora(self, lora_request: LoRARequest) -> bool:
"""Load a new LoRA adapter into the engine for future requests."""
result = self.collective_rpc(method="add_lora", args=(lora_request,))
return result[0][0]
def start_profile(self, stages: list[int] | None = None) -> None:
"""Start profiling for specified stages.
@@ -1135,15 +1332,6 @@ class Omni(OmniBase):
final_output_type=stage.final_output_type, # type: ignore[attr-defined]
request_output=engine_outputs,
)
try:
if stage.final_output_type == "text" or metrics.log_stats:
output_to_yield.metrics = metrics.build_output_metrics(stage_id, req_id)
except Exception as e:
# Make metrics contract explicit on failure.
output_to_yield.metrics = {}
logger.exception(
f"[{self._name}] Failed to attach output metrics for req {req_id} at stage {stage_id}: {e}",
)
# Record audio generated frames (only when finished)
try:
+9 -1
View File
@@ -1,9 +1,11 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import time
import uuid
from collections.abc import Sequence
from vllm.logger import init_logger
from vllm.transformers_utils.config import get_hf_file_to_dict
from vllm_omni.diffusion.data import OmniDiffusionConfig, TransformerConfig
@@ -12,6 +14,8 @@ from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniPromptType
from vllm_omni.outputs import OmniRequestOutput
logger = init_logger(__name__)
class OmniDiffusion:
"""
@@ -108,6 +112,7 @@ class OmniDiffusion:
sampling_params: OmniDiffusionSamplingParams,
request_ids: list[str] = [],
) -> list[OmniRequestOutput]:
_t0 = time.perf_counter()
if isinstance(prompts, (str, dict)):
prompts = [prompts]
else:
@@ -118,7 +123,10 @@ class OmniDiffusion:
request_ids.extend(f"{i + len(request_ids)}_{uuid.uuid4()}" for i in range(len(prompts) - len(request_ids)))
request = OmniDiffusionRequest(prompts, sampling_params, request_ids)
return self._run_engine(request)
result = self._run_engine(request)
_t_ms = (time.perf_counter() - _t0) * 1000
logger.info("OmniDiffusion.generate total: %.2f ms", _t_ms)
return result
def _run_engine(self, request: OmniDiffusionRequest) -> list[OmniRequestOutput]:
return self.engine.step(request)
+179 -10
View File
@@ -15,10 +15,11 @@ import queue
import sys
import time
import traceback
from collections.abc import Sequence
import uuid
from collections.abc import Callable, Sequence
from contextlib import contextmanager
from dataclasses import fields
from typing import Any, Literal, cast
from typing import Any, Literal, TypeVar, cast
from vllm import PromptType, RequestOutput
from vllm.inputs import TextPrompt
@@ -60,6 +61,8 @@ from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniPromptType, O
from vllm_omni.metrics import count_tokens_from_outputs
from vllm_omni.outputs import OmniRequestOutput
_R = TypeVar("_R")
logger = init_logger(__name__)
@@ -302,6 +305,10 @@ class OmniStage:
self._proc: mp.Process | None = None
self._shm_threshold_bytes: int = 65536
self._stage_init_timeout: int = stage_init_timeout
# Callback used by the orchestrator's output handler to stash
# collective_rpc results so that ``collective_rpc`` can retrieve
# them without competing for the output queue.
self._rpc_result_checker: Callable[[str], dict | None] | None = None
def set_engine(self, engine: LLMEngine) -> None:
"""Set the LLM engine for this stage.
@@ -406,6 +413,71 @@ class OmniStage:
logger.error(f"[Stage-{self.stage_id}] Timeout waiting for profiler results.")
return {}
def collective_rpc(
self,
method: str | Callable[..., _R],
timeout: float | None = None,
args: tuple = (),
kwargs: dict[str, Any] | None = None,
) -> list[_R]:
"""Execute an RPC call on all workers via the stage engine.
Args:
method: Name of the worker method to execute, or a callable that
is serialized and sent to all workers to execute.
If the method is a callable, it should accept an additional
``self`` argument, in addition to the arguments passed in
``args`` and ``kwargs``. The ``self`` argument will be the
worker object.
timeout: Maximum time in seconds to wait for execution. Raises a
:class:`TimeoutError` on timeout. ``None`` means wait
indefinitely.
args: Positional arguments to pass to the worker method.
kwargs: Keyword arguments to pass to the worker method.
Returns:
A list containing the results from each worker.
Note:
It is recommended to use this API to only pass control messages,
and set up data-plane communication to pass data.
"""
assert self._in_q is not None and self._out_q is not None, "Queues must be attached before collective_rpc"
# Submit collective_rpc task to worker
rpc_id = str(uuid.uuid4())
self._in_q.put(
{
"type": OmniStageTaskType.COLLECTIVE_RPC,
"rpc_id": rpc_id,
"method": method,
"timeout": timeout,
"args": args,
"kwargs": kwargs,
}
)
start_time = time.time()
while True:
if timeout is not None and (time.time() - start_time) > timeout:
raise TimeoutError(f"collective_rpc timed out after {timeout} seconds")
# Check if result was already collected by the orchestrator's
# output handler (stored in a shared dict).
result = None
if self._rpc_result_checker is not None:
result = self._rpc_result_checker(rpc_id)
if result is not None:
if result.get("type") == "collective_rpc_result":
if result.get("rpc_id") == rpc_id:
if "error" in result:
raise RuntimeError(f"collective_rpc failed: {result['error']}")
return result["result"]
time.sleep(0.001) # Small sleep to avoid busy waiting
def init_stage_worker(
self,
model: str,
@@ -538,14 +610,24 @@ class OmniStage:
try:
self._in_q.put_nowait(SHUTDOWN_TASK)
except Exception as e:
logger.warning("Failed to send shutdown to in_q: %s", e)
# Queue may already be closed by the weak-ref cleanup
# (e.g. ZmqQueue socket closed) — this is expected.
logger.debug("Failed to send shutdown to in_q: %s", e)
close_fn = getattr(self._in_q, "close", None)
if callable(close_fn):
close_fn()
try:
close_fn()
except Exception:
pass
self._in_q = None
if self._out_q is not None:
close_fn = getattr(self._out_q, "close", None)
if callable(close_fn):
close_fn()
try:
close_fn()
except Exception:
pass
self._out_q = None
if hasattr(self, "_ray_actor") and self._ray_actor:
kill_ray_actor(self._ray_actor)
@@ -874,6 +956,40 @@ def _stage_worker(
out_q.put({"type": "profiler_result", "data": profiler_data})
continue
# Handle collective_rpc commands
if task_type == OmniStageTaskType.COLLECTIVE_RPC:
rpc_id = task.get("rpc_id")
rpc_method = task.get("method")
rpc_timeout = task.get("timeout")
rpc_args = task.get("args", ())
rpc_kwargs = task.get("kwargs") or {}
try:
rpc_result = stage_engine.collective_rpc(
method=rpc_method,
timeout=rpc_timeout,
args=rpc_args,
kwargs=rpc_kwargs,
)
out_q.put(
{
"type": "collective_rpc_result",
"rpc_id": rpc_id,
"stage_id": stage_id,
"result": rpc_result,
}
)
except Exception as e:
logger.exception("[Stage-%s] collective_rpc failed: %s", stage_id, e)
out_q.put(
{
"type": "collective_rpc_result",
"rpc_id": rpc_id,
"stage_id": stage_id,
"error": str(e),
}
)
continue
batch_tasks: list[dict[str, Any]] = [task]
tasks_failed_to_add_to_batch: list[dict[str, Any]] = []
start_time = _time.time()
@@ -1003,7 +1119,6 @@ def _stage_worker(
gen_outputs.extend(results)
_gen_t1 = _time.time()
_gen_ms = (_gen_t1 - _gen_t0) * 1000.0
logger.debug(f"Generate done: batch={len(batch_tasks)}, req_ids={batch_request_ids}, gen_ms={_gen_ms:.1f}")
# Group outputs per request id with fallback
req_to_outputs: dict[Any, list[Any]] = {rid: [] for rid in batch_request_ids}
@@ -1043,6 +1158,10 @@ def _stage_worker(
_metrics.stage_stats = None
try:
use_shm, payload = maybe_dump_to_shm(r_outputs, shm_threshold_bytes)
except Exception:
use_shm, payload = False, r_outputs
try:
if use_shm:
out_q.put(
{
@@ -1070,10 +1189,6 @@ def _stage_worker(
"metrics": _metrics,
}
)
logger.debug(
"Enqueued result for request %s to downstream",
rid,
)
except Exception as e:
logger.exception("Failed on batch %s: %s", batch_request_ids, e)
_tb = traceback.format_exc()
@@ -1402,6 +1517,54 @@ async def _stage_worker_async(
# Send result back to orchestrator for STOP command
if task_type == OmniStageTaskType.PROFILER_STOP:
out_q.put({"type": "profiler_result", "data": profiler_data})
elif task_type == OmniStageTaskType.COLLECTIVE_RPC:
rpc_id = task.get("rpc_id")
rpc_method = task.get("method")
rpc_timeout = task.get("timeout")
rpc_args = task.get("args", ())
rpc_kwargs = task.get("kwargs") or {}
try:
if stage_type == "diffusion":
# DiffusionEngine.collective_rpc is synchronous
loop = asyncio.get_event_loop()
rpc_result = await loop.run_in_executor(
None,
lambda: stage_engine.engine.collective_rpc(
method=rpc_method,
timeout=rpc_timeout,
args=rpc_args,
kwargs=rpc_kwargs,
),
)
else:
rpc_result = await cast(AsyncLLM, stage_engine).collective_rpc(
method=rpc_method,
timeout=rpc_timeout,
args=rpc_args,
kwargs=rpc_kwargs,
)
out_q.put(
{
"type": "collective_rpc_result",
"rpc_id": rpc_id,
"stage_id": stage_id,
"result": rpc_result,
}
)
except Exception as e:
logger.exception(
"[Stage-%s] collective_rpc failed: %s",
stage_id,
e,
)
out_q.put(
{
"type": "collective_rpc_result",
"rpc_id": rpc_id,
"stage_id": stage_id,
"error": str(e),
}
)
else:
asyncio.create_task(generation_single_request(task))
@@ -1555,10 +1718,16 @@ def output_strip(r_output: RequestOutput | OmniRequestOutput, final_output: bool
if mm_output is not None:
r_output.multimodal_output = {}
custom_out = getattr(r_output, "_custom_output", None)
if custom_out is not None:
r_output._custom_output = {}
outputs = getattr(r_output, "outputs", None)
if outputs is not None:
for out in outputs:
if getattr(out, "multimodal_output", None):
out.multimodal_output = {}
if getattr(out, "_custom_output", None):
out._custom_output = {}
return r_output
+106 -1
View File
@@ -882,8 +882,113 @@ async def list_voices(raw_request: Request):
if handler is None:
return base(raw_request).create_error_response(message="The model does not support Speech API")
# Get all speakers (both model built-in and uploaded)
speakers = sorted(handler.supported_speakers) if handler.supported_speakers else []
return JSONResponse(content={"voices": speakers})
# Get uploaded speakers details
uploaded_speakers = []
if hasattr(handler, "uploaded_speakers"):
for voice_name, info in handler.uploaded_speakers.items():
uploaded_speakers.append(
{
"name": info.get("name", voice_name),
"consent": info.get("consent", ""),
"created_at": info.get("created_at", 0),
"file_size": info.get("file_size", 0),
"mime_type": info.get("mime_type", ""),
}
)
return JSONResponse(content={"voices": speakers, "uploaded_voices": uploaded_speakers})
@router.post(
"/v1/audio/voices",
responses={
HTTPStatus.OK.value: {"model": dict},
HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse},
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
},
)
async def upload_voice(
raw_request: Request,
audio_sample: UploadFile = File(...),
consent: str = Form(...),
name: str = Form(...),
):
"""Upload a new voice sample for voice cloning.
Uploads an audio file that can be used as a reference for voice cloning
in Base task TTS requests. The voice can then be referenced by name
in subsequent TTS requests.
Args:
audio_sample: Audio file (max 10MB)
consent: Consent recording ID
name: Name for the new voice
raw_request: Raw FastAPI request
Returns:
JSON response with voice information
"""
handler = Omnispeech(raw_request)
if handler is None:
return base(raw_request).create_error_response(message="The model does not support Speech API")
try:
# Upload the voice
result = await handler.upload_voice(audio_sample, consent, name)
return JSONResponse(content={"success": True, "voice": result})
except ValueError as e:
return base(raw_request).create_error_response(message=str(e))
except Exception as e:
logger.exception(f"Failed to upload voice: {e}")
return base(raw_request).create_error_response(message=f"Failed to upload voice: {str(e)}")
@router.delete(
"/v1/audio/voices/{name}",
responses={
HTTPStatus.OK.value: {"model": dict},
HTTPStatus.NOT_FOUND.value: {"model": ErrorResponse},
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
},
)
async def delete_voice(name: str, raw_request: Request):
"""Delete an uploaded voice.
Deletes the voice sample and associated metadata. Also removes any
cached voice clone prompts for this voice.
Args:
name: Name of the voice to delete
raw_request: Raw FastAPI request
Returns:
JSON response indicating success or failure
"""
handler = Omnispeech(raw_request)
if handler is None:
return base(raw_request).create_error_response(message="The model does not support Speech API")
try:
# Delete the voice
success = await handler.delete_voice(name)
if not success:
return JSONResponse(
content={"success": False, "error": f"Voice '{name}' not found"},
status_code=HTTPStatus.NOT_FOUND.value,
)
return JSONResponse(content={"success": True, "message": f"Voice '{name}' deleted successfully"})
except ValueError as e:
return base(raw_request).create_error_response(message=str(e))
except Exception as e:
logger.exception(f"Failed to delete voice '{name}': {e}")
return base(raw_request).create_error_response(message=f"Failed to delete voice: {str(e)}")
# Health and Model endpoints for diffusion mode
@@ -0,0 +1,243 @@
"""
Metadata manager for voice samples and cache information.
Provides a unified interface for managing metadata.json with
concurrency safety and data consistency across multiple processes.
"""
import fcntl
import json
import logging
import os
import threading
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class MetadataManager:
"""
Manages metadata for uploaded speakers and cache information.
Features:
1. Single source of truth for metadata
2. Concurrency safety with threading locks
3. Atomic read-modify-write operations
4. Merge updates to preserve fields from different components
"""
def __init__(self, metadata_file: Path):
"""
Initialize the metadata manager.
Args:
metadata_file: Path to metadata.json file
"""
self.metadata_file = metadata_file
self._lock = threading.Lock() # For intra-process concurrency
self._metadata = self._load_from_disk()
# Create lock file for cross-process synchronization
self.lock_file = metadata_file.with_suffix(".lock")
self.lock_file.parent.mkdir(parents=True, exist_ok=True)
def _load_from_disk(self) -> dict[str, Any]:
"""Load metadata from disk."""
if not self.metadata_file.exists():
return {"uploaded_speakers": {}}
try:
with open(self.metadata_file) as f:
return json.load(f)
except Exception as e:
logger.error(f"Failed to load metadata from {self.metadata_file}: {e}")
return {"uploaded_speakers": {}}
def _save_to_disk(self, metadata: dict[str, Any]) -> bool:
"""Save metadata to disk."""
try:
self.metadata_file.parent.mkdir(parents=True, exist_ok=True)
tmp = self.metadata_file.with_suffix(".tmp")
with open(tmp, "w") as f:
json.dump(metadata, f, indent=2)
tmp.replace(self.metadata_file)
return True
except Exception as e:
logger.error(f"Failed to save metadata to {self.metadata_file}: {e}")
return False
# ================================
# Core fix: single flock overwrites RMW
# ================================
def _update_with_file_lock(
self, update_fn: Callable[[dict[str, Any]], dict[str, Any] | None]
) -> dict[str, Any] | None:
lock_fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR)
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX)
metadata = self._load_from_disk()
result = update_fn(metadata)
if result is None:
return None
if not self._save_to_disk(metadata):
return None
self._metadata = metadata
return result
finally:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
os.close(lock_fd)
def get_uploaded_speakers(self) -> dict[str, dict[str, Any]]:
"""Get all uploaded speakers."""
# Read directly from disk to ensure getting the latest data
metadata = self._load_from_disk()
return metadata.get("uploaded_speakers", {}).copy()
def get_speaker(self, speaker_key: str) -> dict[str, Any] | None:
"""Get specific speaker information."""
# Read directly from disk to ensure getting the latest data
metadata = self._load_from_disk()
speakers = metadata.get("uploaded_speakers", {})
return speakers.get(speaker_key, {}).copy() if speaker_key in speakers else None
def update_speaker(self, speaker_key: str, updates: dict[str, Any]) -> bool:
"""
Update speaker information with merge semantics.
Uses file locking for cross-process atomic operations.
"""
with self._lock:
def _update(metadata: dict[str, Any]):
speakers = metadata.setdefault("uploaded_speakers", {})
entry = speakers.get(speaker_key, {})
entry.update(updates)
speakers[speaker_key] = entry
return True
return self._update_with_file_lock(_update) is not None
def create_speaker(self, speaker_key: str, speaker_data: dict[str, Any]) -> bool:
"""
Create a new speaker entry.
Uses file locking for cross-process atomic operations.
"""
with self._lock:
def _create(metadata: dict[str, Any]):
speakers = metadata.setdefault("uploaded_speakers", {})
if speaker_key in speakers:
logger.warning(f"Speaker {speaker_key} already exists")
return None
speakers[speaker_key] = speaker_data
return True
return self._update_with_file_lock(_create) is not None
def update_cache_info(self, speaker_key: str, cache_file_path: Path, status: str = "ready") -> bool:
"""
Update cache information for a speaker.
"""
updates = {
"cache_status": status,
"cache_file": str(cache_file_path),
"cache_generated_at": time.time(),
}
return self.update_speaker(speaker_key, updates)
def delete_speaker(self, speaker_key: str) -> dict[str, Any] | None:
"""
Delete a speaker from metadata and clean up associated files.
Uses file locking for cross-process atomic operations.
Args:
speaker_key: Speaker name (lowercase)
base_dir: Base directory for file validation (optional)
Returns:
dict: Deleted speaker information if successful, None if speaker doesn't exist or error
"""
with self._lock:
def _delete(metadata: dict[str, Any]):
speakers = metadata.get("uploaded_speakers", {})
if speaker_key not in speakers:
logger.warning(f"Speaker {speaker_key} not found in metadata")
return None
speaker_info = speakers.pop(speaker_key)
# Clean up associated files
deleted_files = self._cleanup_speaker_files(speaker_info)
if deleted_files:
logger.info(f"Deleted {len(deleted_files)} files for speaker {speaker_key}: {deleted_files}")
return speaker_info
return self._update_with_file_lock(_delete)
def _cleanup_speaker_files(self, speaker_info: dict[str, Any]) -> list[str]:
"""
Clean up files associated with a speaker.
Args:
speaker_info: Speaker information dictionary
base_dir: Base directory for file validation (optional)
Returns:
list: List of successfully deleted file paths
"""
deleted_files = []
# Helper function to safely delete a file
def safe_delete(file_path_str: str, description: str) -> bool:
if not file_path_str:
return False
try:
file_path = Path(file_path_str)
# Check if file exists
if not file_path.exists():
logger.debug(f"{description} not found: {file_path}")
return False
# Delete the file
file_path.unlink()
logger.info(f"Deleted {description}: {file_path}")
deleted_files.append(str(file_path))
return True
except Exception as e:
logger.error(f"Failed to delete {description} {file_path_str}: {e}")
return False
# Delete audio file
audio_file = speaker_info.get("file_path")
if audio_file:
safe_delete(audio_file, "audio file")
# Delete cache file
cache_file = speaker_info.get("cache_file")
if cache_file:
safe_delete(cache_file, "cache file")
return deleted_files
def reload_from_disk(self) -> bool:
"""Force reload metadata from disk (useful for external changes)."""
with self._lock:
try:
self._metadata = self._load_from_disk()
return True
except Exception as e:
logger.error(f"Failed to reload metadata from disk: {e}")
return False
+311 -71
View File
@@ -1,24 +1,23 @@
import asyncio
import base64
import io
import ipaddress
import json
import math
import os
import socket
import re
import time
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from urllib.request import urlopen
import numpy as np
import soundfile as sf
from fastapi import Request
from fastapi import Request, UploadFile
from fastapi.responses import Response, StreamingResponse
from vllm.entrypoints.openai.engine.serving import OpenAIServing
from vllm.logger import init_logger
from vllm.multimodal.media import MediaConnector
from vllm.utils import random_uuid
from vllm_omni.entrypoints.openai.audio_utils_mixin import AudioMixin
from vllm_omni.entrypoints.openai.metadata_manager import MetadataManager
from vllm_omni.entrypoints.openai.protocol.audio import (
CreateAudio,
OpenAICreateSpeechRequest,
@@ -27,19 +26,6 @@ from vllm_omni.outputs import OmniRequestOutput
logger = init_logger(__name__)
_REF_AUDIO_TIMEOUT_S = 15
_REF_AUDIO_MAX_BYTES = 50 * 1024 * 1024 # 50 MB
_REF_AUDIO_BLOCKED_NETWORKS = [
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
]
# TTS Configuration (currently supports Qwen3-TTS)
_TTS_MODEL_STAGES: set[str] = {"qwen3_tts"}
_TTS_LANGUAGES: set[str] = {
@@ -60,9 +46,52 @@ _TTS_MAX_NEW_TOKENS_MIN = 1
_TTS_MAX_NEW_TOKENS_MAX = 4096
def _sanitize_filename(filename: str) -> str:
"""Sanitize filename to prevent path traversal attacks.
Only allows alphanumeric characters, underscores, hyphens, and dots.
Replaces any other characters with underscores.
"""
# Remove any path components
filename = os.path.basename(filename)
# Replace any non-alphanumeric, underscore, hyphen, or dot with underscore
sanitized = re.sub(r"[^a-zA-Z0-9_.\-]", "_", filename)
# Ensure filename is not empty
if not sanitized:
sanitized = "file"
# Limit length to prevent potential issues
if len(sanitized) > 255:
sanitized = sanitized[:255]
return sanitized
def _validate_path_within_directory(file_path: Path, directory: Path) -> bool:
"""Validate that file_path is within the specified directory.
Prevents path traversal attacks by ensuring the resolved path
is within the target directory.
"""
try:
# Resolve both paths to absolute paths
file_path_resolved = file_path.resolve()
directory_resolved = directory.resolve()
# Check if file_path is within directory
return directory_resolved in file_path_resolved.parents or directory_resolved == file_path_resolved
except Exception:
return False
class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Initialize uploaded speakers storage
speech_voice_samples_dir = os.environ.get("SPEECH_VOICE_SAMPLES", "/tmp/voice_samples")
self.uploaded_speakers_dir = Path(speech_voice_samples_dir)
self.uploaded_speakers_dir.mkdir(parents=True, exist_ok=True)
self.metadata_file = self.uploaded_speakers_dir / "metadata.json"
# Initialize metadata manager
self.metadata_manager = MetadataManager(self.metadata_file)
# Find and cache the TTS stage (if any) during initialization
self._tts_stage = self._find_tts_stage()
@@ -73,9 +102,16 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
# Load supported speakers
self.supported_speakers = self._load_supported_speakers()
logger.info(f"Loaded {len(self.supported_speakers)} supported speakers: {sorted(self.supported_speakers)}")
# Load uploaded speakers
self.uploaded_speakers = self.metadata_manager.get_uploaded_speakers()
# Merge supported speakers with uploaded speakers
self.supported_speakers.update(self.uploaded_speakers.keys())
self._tts_tokenizer = None
logger.info(f"Loaded {len(self.supported_speakers)} supported speakers: {sorted(self.supported_speakers)}")
logger.info(f"Loaded {len(self.uploaded_speakers)} uploaded speakers")
# Load speech tokenizer codec parameters for prompt length estimation
self._codec_frame_rate: float | None = self._load_codec_frame_rate()
@@ -223,6 +259,191 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
logger.warning("Failed to estimate TTS prompt length, using fallback 2048: %s", e)
return 2048
def _get_uploaded_audio_data(self, voice_name: str) -> str | None:
"""Get base64 encoded audio data for uploaded voice."""
voice_name_lower = voice_name.lower()
if voice_name_lower not in self.uploaded_speakers:
return None
speaker_info = self.uploaded_speakers[voice_name_lower]
file_path = Path(speaker_info["file_path"])
if not file_path.exists():
logger.warning(f"Audio file not found for voice {voice_name}: {file_path}")
return None
try:
# Read audio file
with open(file_path, "rb") as f:
audio_bytes = f.read()
# Encode to base64
audio_b64 = base64.b64encode(audio_bytes).decode("utf-8")
# Get MIME type from file extension
mime_type = speaker_info.get("mime_type", "audio/wav")
# Return as data URL
return f"data:{mime_type};base64,{audio_b64}"
except Exception as e:
logger.error(f"Could not read audio file for voice {voice_name}: {e}")
return None
async def upload_voice(self, audio_file: UploadFile, consent: str, name: str) -> dict:
"""Upload a new voice sample."""
# Validate file size (max 10MB)
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
audio_file.file.seek(0, 2) # Seek to end
file_size = audio_file.file.tell()
audio_file.file.seek(0) # Reset to beginning
if file_size > MAX_FILE_SIZE:
raise ValueError(f"File size exceeds maximum limit of 10MB. Got {file_size} bytes.")
# Detect MIME type from filename if content_type is generic
mime_type = audio_file.content_type
if mime_type == "application/octet-stream":
# Simple MIME type detection based on file extension
filename_lower = audio_file.filename.lower()
if filename_lower.endswith(".wav"):
mime_type = "audio/wav"
elif filename_lower.endswith((".mp3", ".mpeg")):
mime_type = "audio/mpeg"
elif filename_lower.endswith(".flac"):
mime_type = "audio/flac"
elif filename_lower.endswith(".ogg"):
mime_type = "audio/ogg"
elif filename_lower.endswith(".aac"):
mime_type = "audio/aac"
elif filename_lower.endswith(".webm"):
mime_type = "audio/webm"
elif filename_lower.endswith(".mp4"):
mime_type = "audio/mp4"
else:
mime_type = "audio/wav" # Default
# Validate MIME type
allowed_mime_types = {
"audio/mpeg",
"audio/wav",
"audio/x-wav",
"audio/ogg",
"audio/aac",
"audio/flac",
"audio/webm",
"audio/mp4",
}
if mime_type not in allowed_mime_types:
raise ValueError(f"Unsupported MIME type: {mime_type}. Allowed: {allowed_mime_types}")
# Normalize voice name
voice_name_lower = name.lower()
# Check if voice already exists
if voice_name_lower in self.uploaded_speakers:
raise ValueError(f"Voice '{name}' already exists")
# Sanitize name and consent to prevent path traversal
sanitized_name = _sanitize_filename(name)
sanitized_consent = _sanitize_filename(consent)
# Generate filename with sanitized inputs
timestamp = int(time.time())
file_suffix = Path(audio_file.filename).suffix
file_ext = file_suffix[1:] if file_suffix and len(file_suffix) > 1 else "wav"
# Sanitize file extension as well
sanitized_ext = _sanitize_filename(file_ext)
if not sanitized_ext or sanitized_ext == "file":
sanitized_ext = "wav"
filename = f"{sanitized_name}_{sanitized_consent}_{timestamp}.{sanitized_ext}"
file_path = self.uploaded_speakers_dir / filename
# Double-check that the path is within the upload directory
if not _validate_path_within_directory(file_path, self.uploaded_speakers_dir):
raise ValueError("Invalid file path: potential path traversal attack detected")
# Save audio file
try:
with open(file_path, "wb") as f:
content = await audio_file.read()
f.write(content)
except Exception as e:
raise ValueError(f"Failed to save audio file: {e}")
# Create speaker data
speaker_data = {
"name": name,
"consent": consent,
"file_path": str(file_path),
"created_at": timestamp,
"mime_type": mime_type,
"original_filename": audio_file.filename,
"file_size": file_size,
"cache_status": "pending", # The initial cache state is pending.
"cache_file": None, # The initial cache file is empty.
"cache_generated_at": None, # The initial cache generation time is empty.
}
# Save metadata using metadata manager (concurrency safe)
success = self.metadata_manager.create_speaker(voice_name_lower, speaker_data)
if not success:
# Clean up the saved file if metadata creation failed
try:
file_path.unlink()
except Exception:
pass
raise ValueError(f"Failed to create metadata for voice '{name}' (possibly already exists)")
# Update in-memory cache
self.uploaded_speakers[voice_name_lower] = speaker_data
self.supported_speakers.add(voice_name_lower)
logger.info(f"Uploaded new voice '{name}' with consent ID '{consent}'")
# Return voice information without exposing the server file path
return {
"name": name,
"consent": consent,
"created_at": timestamp,
"mime_type": mime_type,
"file_size": file_size,
}
async def delete_voice(self, name: str) -> bool:
"""
Delete an uploaded voice.
Args:
name: Voice name to delete
Returns:
bool: True if successful, False if voice doesn't exist
"""
voice_name_lower = name.lower()
# Check if voice exists in memory cache
if voice_name_lower not in self.uploaded_speakers:
logger.warning(f"Voice '{name}' not found in memory cache")
return False
# Delete from metadata manager with file cleanup
# Pass base_dir for path validation
deleted_info = self.metadata_manager.delete_speaker(voice_name_lower)
if not deleted_info:
logger.error(f"Failed to delete voice '{name}' from metadata")
return False
# Update in-memory cache
if voice_name_lower in self.uploaded_speakers:
del self.uploaded_speakers[voice_name_lower]
if voice_name_lower in self.supported_speakers:
self.supported_speakers.remove(voice_name_lower)
logger.info(f"Deleted voice '{name}' and associated files")
return True
def _is_tts_model(self) -> bool:
"""Check if the current model is a supported TTS model."""
stage_list = getattr(self.engine_client, "stage_list", None)
@@ -265,19 +486,48 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
# Validate Base task requirements
if task_type == "Base":
if request.ref_audio is None:
return "Base task requires 'ref_audio' for voice cloning"
# Validate ref_audio format
if not (request.ref_audio.startswith(("http://", "https://")) or request.ref_audio.startswith("data:")):
return "ref_audio must be a URL (http/https) or base64 data URL (data:...)"
# In-context voice cloning (default) requires non-empty ref_text.
# x_vector_only_mode skips in-context and only uses speaker embedding.
if not request.x_vector_only_mode:
if not request.ref_text or not request.ref_text.strip():
return (
"Base task requires non-empty 'ref_text' (transcript of "
"the reference audio) unless 'x_vector_only_mode' is enabled"
)
if request.voice is None:
if request.ref_audio is None:
return "Base task requires 'ref_audio' for voice cloning"
# Validate ref_audio format (include file:// from upstream)
if not (
request.ref_audio.startswith(("http://", "https://"))
or request.ref_audio.startswith("data:")
or request.ref_audio.startswith("file://")
):
return "ref_audio must be a URL (http/https), base64 data URL (data:...), or file URI (file://...)"
# In-context voice cloning (default) requires non-empty ref_text.
# x_vector_only_mode skips in-context and only uses speaker embedding.
if not request.x_vector_only_mode:
if not request.ref_text or not request.ref_text.strip():
return (
"Base task requires non-empty 'ref_text' (transcript of "
"the reference audio) unless 'x_vector_only_mode' is enabled"
)
else:
# voice is not None
voice_lower = request.voice.lower()
if voice_lower in self.uploaded_speakers:
# Check if audio file exists for uploaded speaker
speaker_info = self.uploaded_speakers[voice_lower]
file_path = Path(speaker_info["file_path"])
if not file_path.exists():
return f"Audio file for uploaded speaker '{request.voice}' not found on disk"
else:
# need ref_audio for built-in speaker
if request.ref_audio is None:
return (
f"Base task with built-in speaker '{request.voice}' requires 'ref_audio' for voice cloning"
)
# Validate ref_audio format for built-in speaker
if not (
request.ref_audio.startswith(("http://", "https://"))
or request.ref_audio.startswith("data:")
or request.ref_audio.startswith("file://")
):
return (
"ref_audio must be a URL (http/https), base64 data URL (data:...), or file URI (file://...)"
)
# Validate cross-parameter dependencies
if task_type != "Base":
@@ -303,44 +553,23 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
return None
@staticmethod
async def _resolve_ref_audio(ref_audio_str: str) -> tuple[list[float], int]:
"""Resolve ref_audio URL/base64 to (wav_samples, sample_rate)."""
parsed = urlparse(ref_audio_str)
async def _resolve_ref_audio(self, ref_audio_str: str) -> tuple[list[float], int]:
"""Resolve ref_audio to (wav_samples, sample_rate).
def _check_ssrf(url: str) -> None:
host = urlparse(url).hostname
if not host:
raise ValueError("ref_audio URL must include a hostname")
for info in socket.getaddrinfo(host, None):
ip_str = str(info[4][0]).split("%", 1)[0]
addr = ipaddress.ip_address(ip_str)
if any(addr in net for net in _REF_AUDIO_BLOCKED_NETWORKS):
raise ValueError(f"ref_audio URL resolves to blocked address: {addr}")
def _fetch_sync() -> tuple[np.ndarray, int]:
if parsed.scheme in ("http", "https"):
_check_ssrf(ref_audio_str)
with urlopen(ref_audio_str, timeout=_REF_AUDIO_TIMEOUT_S) as resp:
data = resp.read(_REF_AUDIO_MAX_BYTES + 1)
if len(data) > _REF_AUDIO_MAX_BYTES:
raise ValueError(f"ref_audio URL exceeds {_REF_AUDIO_MAX_BYTES} bytes")
buf = io.BytesIO(data)
elif ref_audio_str.startswith("data:"):
b64 = ref_audio_str
if "," in b64:
b64 = b64.split(",", 1)[1]
buf = io.BytesIO(base64.b64decode(b64))
else:
raise ValueError("ref_audio must be an http(s) URL or data: base64 URI")
audio, sr = sf.read(buf, dtype="float32", always_2d=False)
if isinstance(audio, np.ndarray) and audio.ndim > 1:
audio = np.mean(audio, axis=-1)
return np.asarray(audio, dtype=np.float32), int(sr)
loop = asyncio.get_running_loop()
wav_np, sr = await loop.run_in_executor(None, _fetch_sync)
return wav_np.tolist(), sr
Delegates to upstream vLLM's MediaConnector which handles http(s)
URLs, ``data:`` base64 URIs, and ``file:`` local paths (the latter
gated by ``--allowed-local-media-path``).
"""
model_config = self.model_config
connector = MediaConnector(
allowed_local_media_path=model_config.allowed_local_media_path,
allowed_media_domains=model_config.allowed_media_domains,
)
wav_np, sr = await connector.fetch_audio_async(ref_audio_str)
wav_np = np.asarray(wav_np, dtype=np.float32)
if wav_np.ndim > 1:
wav_np = np.mean(wav_np, axis=-1)
return wav_np.tolist(), int(sr)
async def _generate_pcm_chunks(self, generator, request_id: str):
"""Generate PCM audio chunks for streaming response.
@@ -447,6 +676,17 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
# Speaker (voice)
if request.voice is not None:
params["speaker"] = [request.voice]
# If voice is an uploaded speaker and no ref_audio provided, auto-set it
if request.voice.lower() in self.uploaded_speakers and request.ref_audio is None:
audio_data = self._get_uploaded_audio_data(request.voice)
if audio_data:
params["ref_audio"] = [audio_data]
params["x_vector_only_mode"] = [True]
logger.info(f"Auto-set ref_audio for uploaded voice: {request.voice}")
else:
raise ValueError(f"Audio file for uploaded voice '{request.voice}' is missing or corrupted")
elif params["task_type"][0] == "CustomVoice":
params["speaker"] = ["Vivian"] # Default for CustomVoice
@@ -141,6 +141,7 @@ class OmniOpenAIServingVideo:
)
result = await self._run_generation(prompt, gen_params, request_id, raw_request)
_t_encode_start = time.perf_counter()
videos = self._extract_video_outputs(result)
audios = self._extract_audio_outputs(result, expected_count=len(videos))
output_fps = fps or 24
@@ -161,6 +162,8 @@ class OmniOpenAIServingVideo:
)
for idx, video in enumerate(videos)
]
_t_encode_ms = (time.perf_counter() - _t_encode_start) * 1000
logger.info("Video response encoding (MP4+base64): %.2f ms", _t_encode_ms)
return VideoGenerationResponse(created=int(time.time()), data=video_data)
def _resolve_model_name(self, raw_request: Request | None) -> str | None:
+1
View File
@@ -36,6 +36,7 @@ class OmniStageTaskType(enum.Enum):
SHUTDOWN = "shutdown"
PROFILER_START = "profiler_start"
PROFILER_STOP = "profiler_stop"
COLLECTIVE_RPC = "collective_rpc"
SHUTDOWN_TASK = {"type": OmniStageTaskType.SHUTDOWN}
+23 -2
View File
@@ -1,7 +1,7 @@
import copy
import pprint
from dataclasses import asdict, dataclass, field
from typing import Any, TypeAlias
from typing import Any, TypeAlias, TypedDict
from vllm.inputs import PromptType
from vllm.sampling_params import SamplingParams
@@ -103,13 +103,34 @@ class OmniEmbedsPrompt(EmbedsPrompt):
additional_information: NotRequired[dict[str, Any]]
class OmniCustomPrompt(TypedDict, total=False):
"""Custom prompt type for diffusion pipelines with pre-tokenized inputs.
Allows users to pass pre-tokenized prompt IDs, attention masks, and extra
arguments directly, bypassing the tokenization stage in the pipeline.
Attributes:
prompt_ids: Pre-tokenized prompt token IDs (single or batched)
negative_prompt_ids: Pre-tokenized negative prompt token IDs
prompt_mask: Attention mask tensor for the prompt
negative_prompt_mask: Attention mask tensor for the negative prompt
extra_args: Additional pipeline-specific arguments
"""
prompt_ids: list[int] | list[list[int]]
negative_prompt_ids: list[int] | list[list[int]]
prompt_mask: torch.Tensor
negative_prompt_mask: torch.Tensor
extra_args: dict[str, Any]
# Must ensure that all additional prompt types are inherited from vLLM prompt types
# Because TypedDict doesn't support isinstance and are dict. Cannot distinguish them in runtime.
# Inheritance ensure that there are only additional fields but not removing fields--safe to route to LLM.generate()
OmniSingletonPrompt: TypeAlias = str | list[int] | OmniTextPrompt | OmniTokensPrompt | OmniEmbedsPrompt
"""Omni singleton prompt type extending vLLM's SingletonPrompt."""
OmniPromptType: TypeAlias = PromptType | OmniTextPrompt | OmniTokensPrompt | OmniEmbedsPrompt
OmniPromptType: TypeAlias = PromptType | OmniTextPrompt | OmniTokensPrompt | OmniEmbedsPrompt | OmniCustomPrompt
def token_inputs_omni(
+14 -50
View File
@@ -39,7 +39,7 @@ class StageRequestStats:
final_output_type: str | None = None
request_id: str | None = None
postprocess_time_ms: float = 0.0
diffusion_metrics: dict[str, float] = None
diffusion_metrics: dict[str, int] = None
audio_generated_frames: int = 0
@property
@@ -293,15 +293,23 @@ class OrchestratorAggregator:
return
# 4. Finished with output: assign text metrics if available
output_to_yield.metrics = self.build_output_metrics(stage_id, req_id)
output_to_yield.metrics = {}
stage_event = next(
(evt for evt in reversed(self.stage_events.get(req_id, [])) if evt.stage_id == stage_id),
None,
)
if stage_event is not None and stage_event.final_output_type == "text":
output_to_yield.metrics = {
"num_tokens_in": stage_event.num_tokens_in,
"num_tokens_out": stage_event.num_tokens_out,
"stage_id": stage_event.stage_id,
"final_output_type": stage_event.final_output_type,
}
# 5. Finished: record audio generated frames
self.record_audio_generated_frames(output_to_yield, stage_id, req_id)
except Exception:
if output_to_yield is not None:
# Make metrics contract explicit on failure.
output_to_yield.metrics = {}
logger.exception(
"Failed to process metrics for stage %s, req %s",
stage_id,
@@ -321,56 +329,12 @@ class OrchestratorAggregator:
stats.request_id = req_id
stats.final_output_type = final_output_type
stats.diffusion_metrics = (
{k: float(v) for k, v in self.diffusion_metrics.pop(req_id, {}).items()}
{k: int(v) for k, v in self.diffusion_metrics.pop(req_id, {}).items()}
if req_id in self.diffusion_metrics
else None
)
return stats
def _get_stage_event(self, stage_id: int, req_id: Any) -> StageRequestStats | None:
rid_key = str(req_id)
for evt in reversed(self.stage_events.get(rid_key, [])):
if evt.stage_id == stage_id:
return evt
return None
def _collect_diffusion_metrics(self, req_id: Any) -> dict[str, float]:
"""Aggregate diffusion metrics across all stages for a request."""
rid_key = str(req_id)
merged: dict[str, float] = {}
for evt in self.stage_events.get(rid_key, []):
if not evt.diffusion_metrics:
continue
for key, value in evt.diffusion_metrics.items():
merged[key] = merged.get(key, 0.0) + float(value)
return merged
def build_output_metrics(self, stage_id: int, req_id: Any) -> dict[str, Any]:
stage_event = self._get_stage_event(stage_id, req_id)
if stage_event is None:
return {}
merged: dict[str, Any] = {}
if stage_event.final_output_type == "text":
merged.update(
{
"num_tokens_in": stage_event.num_tokens_in,
"num_tokens_out": stage_event.num_tokens_out,
}
)
if self.log_stats:
diffusion_metrics = self._collect_diffusion_metrics(req_id)
if diffusion_metrics:
merged.update(diffusion_metrics)
if merged:
merged["stage_id"] = stage_event.stage_id
merged["final_output_type"] = stage_event.final_output_type
return merged
def on_stage_metrics(
self,
stage_id: int,
+5 -1
View File
@@ -1,4 +1,8 @@
from .bagel.bagel import OmniBagelForConditionalGeneration
from .qwen3_omni import Qwen3OmniMoeForConditionalGeneration
from .registry import OmniModelRegistry # noqa: F401
__all__ = ["Qwen3OmniMoeForConditionalGeneration"]
__all__ = [
"Qwen3OmniMoeForConditionalGeneration",
"OmniBagelForConditionalGeneration",
]
@@ -0,0 +1,3 @@
from .bagel import OmniBagelForConditionalGeneration
__all__ = ["OmniBagelForConditionalGeneration"]
File diff suppressed because it is too large. Load diff
@@ -340,6 +340,11 @@ class Qwen3OmniMoeThinkerProcessingInfo(Qwen2AudioProcessingInfo, Qwen2_5_VLProc
seq_len=seq_len,
mm_counts=mm_counts,
)
if audio_tokens is None:
feature_extractor = self.get_feature_extractor()
max_audio_samples = feature_extractor.chunk_length * feature_extractor.sampling_rate
max_audio_tokens = int(_get_feat_extract_output_lengths(torch.tensor([max_audio_samples])).item())
audio_tokens = {"audio": max_audio_tokens}
mm_max_tokens["audio"] = audio_tokens["audio"]
return mm_max_tokens
@@ -769,8 +774,7 @@ class Qwen3OmniMoeConditionalGenerationMixin(Qwen2_5OmniConditionalGenerationMix
feature_lens=audio_feature_lengths,
aftercnn_lens=audio_output_lengths,
)
# OMNI: audio_tower.forward() returns hidden_states tensor directly
audio_features = audio_outputs
audio_features = audio_outputs if isinstance(audio_outputs, torch.Tensor) else audio_outputs.last_hidden_state
return audio_features.split(audio_output_lengths.tolist())
@@ -13,11 +13,9 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import base64
import copy
import io
import urllib.request
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlparse
@@ -36,6 +34,7 @@ from vllm_omni.model_executor.models.output_templates import OmniOutput
from .configuration_qwen3_tts import Qwen3TTSConfig
from .modeling_qwen3_tts import Qwen3TTSForConditionalGeneration
from .processing_qwen3_tts import Qwen3TTSProcessor
from .voice_cache_manager import VoiceCacheManager, VoiceClonePromptItem
logger = init_logger(__name__)
@@ -60,21 +59,6 @@ AudioLike = (
MaybeList = Any | list[Any]
@dataclass
class VoiceClonePromptItem:
"""
Container for one sample's voice-clone prompt information that can be fed to the model.
Fields are aligned with `Qwen3TTSForConditionalGeneration.generate(..., voice_clone_prompt=...)`.
"""
ref_code: torch.Tensor | None # (T, Q) or (T,) depending on tokenizer 25Hz/12Hz
ref_spk_embedding: torch.Tensor # (D,)
x_vector_only_mode: bool
icl_mode: bool
ref_text: str | None = None
class Qwen3TTSModelForGeneration(nn.Module):
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
super().__init__()
@@ -130,6 +114,13 @@ class Qwen3TTSModelForGeneration(nn.Module):
except Exception:
logger.warning("Failed to enable CUDA Graph for decoder", exc_info=True)
@staticmethod
def extract_val(d, key, default):
val = d.get(key, default)
if isinstance(val, list):
return val[0] if len(val) > 0 else default
return val
def forward(
self,
input_ids: torch.Tensor | None = None,
@@ -139,7 +130,7 @@ class Qwen3TTSModelForGeneration(nn.Module):
**kwargs: Any,
) -> OmniOutput:
"""
Forward pass for TTS generation model.
Forward pass for TTS generation model (Patched for batched inference).
Args:
input_ids: Input token IDs (required for TTS generation)
@@ -151,46 +142,62 @@ class Qwen3TTSModelForGeneration(nn.Module):
Returns:
OmniOutput: Contains multimodal outputs with audio tensors
"""
runtime_info_list = kwargs.get("runtime_additional_information", [{}])
if not isinstance(runtime_info_list, list):
runtime_info_list = [runtime_info_list]
# Extract additional parameters from kwargs that the generation methods expect
# Initialize lists to accumulate batched inputs
texts = []
task_types = []
speakers = []
languages = []
instructs = []
merged_kwargs = {}
runtime_additional_information = kwargs.get("model_intermediate_buffer")
if runtime_additional_information is None:
runtime_additional_information = kwargs.get("runtime_additional_information", [{}])
if "runtime_additional_information" in kwargs and "model_intermediate_buffer" not in kwargs:
logger.warning_once("runtime_additional_information is deprecated, use model_intermediate_buffer")
if isinstance(runtime_additional_information, list) and len(runtime_additional_information) > 0:
runtime_additional_information = runtime_additional_information[0]
runtime_additional_information = copy.deepcopy(runtime_additional_information)
text = runtime_additional_information.pop("text", [""])[0]
# Extract task_type from kwargs, default to self.task_type
task_type = _normalize_task_type(runtime_additional_information.pop("task_type", [self.task_type])[0])
speaker = runtime_additional_information.pop("speaker", ["uncle_fu"])[0]
language = runtime_additional_information.pop("language", ["Auto"])[0]
instruct = runtime_additional_information.pop("instruct", [""])[0]
for key, value in runtime_additional_information.items():
if isinstance(value, list) and len(value) > 0:
runtime_additional_information[key] = value[0]
# Keys that the underlying model natively supports as lists for batched inference
batched_keys = {"ref_audio", "ref_text", "x_vector_only_mode", "voice_clone_prompt"}
# During profile/warmup runs, text is empty and no real inputs exist.
# Cap generation steps so the full pipeline executes (preserving
# KV-cache profiling behaviour) but exits quickly even if the model
# cannot converge from degenerate dummy inputs.
if not text:
for req_info in runtime_info_list:
texts.append(self.extract_val(req_info, "text", ""))
task_types.append(self.extract_val(req_info, "task_type", self.task_type))
speakers.append(self.extract_val(req_info, "speaker", "uncle_fu"))
languages.append(self.extract_val(req_info, "language", "Auto"))
instructs.append(self.extract_val(req_info, "instruct", ""))
for k, v in req_info.items():
if k not in ["text", "task_type", "speaker", "language", "instruct"]:
# Extract single value from list if wrapped
val = v[0] if isinstance(v, list) and len(v) > 0 else v
if k in batched_keys:
# Accumulate as list for batched generation
if k not in merged_kwargs:
merged_kwargs[k] = []
merged_kwargs[k].append(val)
else:
# For scalar params (e.g. max_new_tokens), take from the first request
if k not in merged_kwargs:
merged_kwargs[k] = val
# During profile/warmup runs, texts are empty.
if all(not t for t in texts):
logger.info("Profile run detected (empty text). Capping max_new_tokens to 2.")
runtime_additional_information["max_new_tokens"] = 2
merged_kwargs["max_new_tokens"] = 2
# Call the appropriate generation method based on task_type
# Assume uniform task type across the batch
if len(set(task_types)) > 1:
raise ValueError(f"Mixed task types not supported: {set(task_types)}")
task_type = task_types[0]
# Call the appropriate generation method based on task_type, passing lists
if task_type == "CustomVoice":
result = self.model.generate_custom_voice(
text, speaker=speaker, language=language, instruct=instruct, **runtime_additional_information
texts, speaker=speakers, language=languages, instruct=instructs, **merged_kwargs
)
elif task_type == "VoiceDesign":
result = self.model.generate_voice_design(
text, instruct=instruct, language=language, **runtime_additional_information
)
result = self.model.generate_voice_design(texts, instruct=instructs, language=languages, **merged_kwargs)
elif task_type == "Base":
result = self.model.generate_voice_clone(text, language=language, **runtime_additional_information)
result = self.model.generate_voice_clone(texts, language=languages, **merged_kwargs)
else:
raise ValueError(f"Invalid task type: {task_type}")
@@ -209,17 +216,20 @@ class Qwen3TTSModelForGeneration(nn.Module):
# Handle tuple format: (audio_tensors, sample_rate)
if isinstance(model_outputs, tuple) and len(model_outputs) == 2:
audio_tensors, sr = model_outputs
# audio_tensors is a list of numpy arrays, convert first one to tensor if needed
# audio_tensors is a list of numpy arrays, convert ALL to tensors
if isinstance(audio_tensors, list) and len(audio_tensors) > 0:
# Convert numpy array to tensor if needed
audio_tensor = audio_tensors[0]
if isinstance(audio_tensor, np.ndarray):
audio_tensor = torch.from_numpy(audio_tensor).float()
elif not isinstance(audio_tensor, torch.Tensor):
audio_tensor = torch.tensor(audio_tensor, dtype=torch.float32)
audio_tensor_list = []
for audio_tensor in audio_tensors:
if isinstance(audio_tensor, np.ndarray):
audio_tensor_list.append(torch.from_numpy(audio_tensor).float())
elif not isinstance(audio_tensor, torch.Tensor):
audio_tensor_list.append(torch.tensor(audio_tensor, dtype=torch.float32))
else:
audio_tensor_list.append(audio_tensor)
return OmniOutput(
text_hidden_states=None,
multimodal_outputs={"model_outputs": audio_tensor, "sr": torch.tensor(sr, dtype=torch.int)},
multimodal_outputs={"model_outputs": audio_tensor_list, "sr": torch.tensor(sr, dtype=torch.int)},
)
# If it's already a tensor, wrap it
@@ -348,6 +358,12 @@ class Qwen3TTSModel:
self.processor = processor
self.generate_defaults = generate_defaults or {}
# Initialize voice cache manager.
# Note: this creates its own MetadataManager for the same metadata.json
# used by serving_speech.py. Sharing is not possible across model/serving
# layers, but file locking in MetadataManager ensures correctness.
self.voice_cache_manager = VoiceCacheManager()
self.device = getattr(model, "device", None)
if self.device is None:
try:
@@ -766,6 +782,7 @@ class Qwen3TTSModel:
self,
text: str | list[str],
language: str | list[str] = None,
speaker: str | None = None, # New parameter: speaker name
ref_audio: AudioLike | list[AudioLike] | None = None,
ref_text: str | list[str | None] | None = None,
x_vector_only_mode: bool | list[bool] = False,
@@ -840,7 +857,27 @@ class Qwen3TTSModel:
self._validate_languages(languages)
if voice_clone_prompt is None:
# Cache logic: if speaker parameter is provided, try to load from cache
cache_loaded = False
cache_speaker = None
cache_audio_path = None
if speaker:
# Use VoiceCacheManager to load cached voice prompt, passing device parameter
cached_items = self.voice_cache_manager.load_cached_voice_prompt(speaker, device=str(self.device))
if cached_items is not None:
voice_clone_prompt = cached_items
cache_loaded = True
# If no cache, check if cache needs to be generated
if not cache_loaded:
audio_file_path = self.voice_cache_manager.get_speaker_audio_path(speaker)
if audio_file_path:
logger.info(f"Will generate cache for speaker: {speaker} (first use)")
cache_speaker = speaker
cache_audio_path = audio_file_path
if voice_clone_prompt is None and not cache_loaded:
if ref_audio is None:
# For profile run
sample_rate = int(self.model.speaker_encoder_sample_rate)
@@ -854,6 +891,28 @@ class Qwen3TTSModel:
prompt_items = self.create_voice_clone_prompt(
ref_audio=ref_audio, ref_text=ref_text, x_vector_only_mode=x_vector_only_mode
)
# If cache needs to be generated, save cache file
if cache_speaker and cache_audio_path:
try:
# Use VoiceCacheManager to save cache
success = self.voice_cache_manager.save_voice_cache(cache_speaker, cache_audio_path, prompt_items)
if success:
logger.info(f"Cache generated and saved for speaker: {cache_speaker}")
else:
logger.error(f"Failed to save cache for speaker: {cache_speaker}")
except Exception as e:
logger.error(f"Failed to save cache for speaker {cache_speaker}: {e}")
if len(prompt_items) == 1 and len(texts) > 1:
prompt_items = prompt_items * len(texts)
if len(prompt_items) != len(texts):
raise ValueError(f"Batch size mismatch: prompt={len(prompt_items)}, text={len(texts)}")
voice_clone_prompt_dict = self._prompt_items_to_voice_clone_prompt(prompt_items)
ref_texts_for_ids = [it.ref_text for it in prompt_items]
elif cache_loaded and isinstance(voice_clone_prompt, list):
# Use cached VoiceClonePromptItem
prompt_items = voice_clone_prompt
if len(prompt_items) == 1 and len(texts) > 1:
prompt_items = prompt_items * len(texts)
if len(prompt_items) != len(texts):
@@ -831,7 +831,9 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
def _is_url(self, s: str) -> bool:
try:
u = urlparse(s)
return u.scheme in ("http", "https") and bool(u.netloc)
if u.scheme in ("http", "https"):
return bool(u.netloc)
return u.scheme == "file"
except Exception:
return False
@@ -841,12 +843,19 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
return base64.b64decode(b64)
def _load_audio_to_np(self, x: str) -> tuple[np.ndarray, int]:
"""Load audio from local path or base64 data URI (no network I/O)."""
"""Load audio from local path, URL, or base64 data URI.
Uses upstream vLLM's MediaConnector for http(s) URLs and ``file:``
URIs, with unrestricted local access (offline inference is trusted).
"""
import librosa
if self._is_url(x):
raise ValueError("ref_audio URLs must be resolved by the serving layer before reaching the model worker.")
if self._is_probably_base64(x):
from vllm.multimodal.media import MediaConnector
connector = MediaConnector(allowed_local_media_path="/")
audio, sr = connector.fetch_audio(x)
elif self._is_probably_base64(x):
wav_bytes = self._decode_base64_to_wav_bytes(x)
with io.BytesIO(wav_bytes) as f:
audio, sr = sf.read(f, dtype="float32", always_2d=False)
@@ -0,0 +1,271 @@
# Copyright 2026 The Alibaba Qwen team.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import torch
from safetensors import safe_open
from safetensors.torch import save_file
from vllm.logger import init_logger
from vllm_omni.entrypoints.openai.metadata_manager import MetadataManager
logger = init_logger(__name__)
@dataclass
class VoiceClonePromptItem:
"""
Container for one sample's voice-clone prompt information that can be fed to the model.
Fields are aligned with `Qwen3TTSForConditionalGeneration.generate(..., voice_clone_prompt=...)`.
"""
ref_code: torch.Tensor | None # (T, Q) or (T,) depending on tokenizer 25Hz/12Hz
ref_spk_embedding: torch.Tensor # (D,)
x_vector_only_mode: bool
icl_mode: bool
ref_text: str | None = None
class VoiceCacheManager:
"""
Voice cache manager, responsible for managing custom voice cache functionality.
Main features:
1. Load uploaded speaker information from metadata.json
2. Manage voice clone prompt cache
3. Update cache status to metadata.json
Security properties:
- No pickle / torch.load
- Safetensors-only
- Cache path confined to voice samples directory
"""
def __init__(self, speech_voice_samples_dir: str | None = None, metadata_manager: MetadataManager | None = None):
"""
Initialize the voice cache manager.
Args:
speech_voice_samples_dir: Speech voice samples directory path,
if None, get from environment variable
metadata_manager: Optional MetadataManager instance for shared metadata access.
If not provided, will create its own (less efficient).
"""
self.speech_voice_samples_dir = speech_voice_samples_dir or os.environ.get(
"SPEECH_VOICE_SAMPLES", "/tmp/voice_samples"
)
# Initialize metadata manager
if metadata_manager is not None:
self.metadata_manager = metadata_manager
else:
metadata_file = Path(self.speech_voice_samples_dir) / "metadata.json"
self.metadata_manager = MetadataManager(metadata_file)
# ------------------------------------------------------------------
# Metadata helpers
# ------------------------------------------------------------------
def load_uploaded_speakers_from_metadata(self) -> dict[str, Any] | None:
"""Load uploaded speakers from metadata manager."""
try:
return self.metadata_manager.get_uploaded_speakers()
except Exception as e:
logger.warning(f"Failed to load uploaded speakers from metadata: {e}")
return None
def update_metadata_cache_info(self, speaker: str, cache_file_path: Path, status: str = "ready") -> bool:
"""
Update cache information using metadata manager.
Args:
speaker: Speaker name
cache_file_path: Cache file path
status: Cache status, default is "ready"
Returns:
bool: Whether the update was successful
"""
try:
speaker_key = speaker.lower()
return self.metadata_manager.update_cache_info(
speaker_key=speaker_key, cache_file_path=cache_file_path, status=status
)
except Exception as e:
logger.error(f"Failed to update metadata cache info: {e}")
return False
# ------------------------------------------------------------------
# Cache save (SAFE)
# ------------------------------------------------------------------
def save_voice_cache(
self,
speaker: str,
audio_file_path: Path,
prompt_items: list[VoiceClonePromptItem],
) -> bool:
"""
Save voice cache using safetensors (no pickle, no RCE).
"""
try:
cache_file_path = audio_file_path.with_suffix(".safetensors")
tensors: dict[str, torch.Tensor] = {}
metadata: dict[str, str] = {}
tensors["__len__"] = torch.tensor(len(prompt_items), dtype=torch.int64)
for i, item in enumerate(prompt_items):
prefix = f"item_{i}_"
tensors[prefix + "ref_spk_embedding"] = item.ref_spk_embedding.detach().cpu()
has_ref_code = item.ref_code is not None
tensors[prefix + "has_ref_code"] = torch.tensor(int(has_ref_code), dtype=torch.int8)
if has_ref_code:
tensors[prefix + "ref_code"] = item.ref_code.detach().cpu()
tensors[prefix + "x_vector_only_mode"] = torch.tensor(int(item.x_vector_only_mode), dtype=torch.int8)
tensors[prefix + "icl_mode"] = torch.tensor(int(item.icl_mode), dtype=torch.int8)
if item.ref_text is not None:
metadata[prefix + "ref_text"] = item.ref_text
save_file(tensors, str(cache_file_path), metadata=metadata)
return self.update_metadata_cache_info(
speaker=speaker,
cache_file_path=cache_file_path,
status="ready",
)
except Exception as e:
logger.error(f"Failed to save safetensors cache for speaker {speaker}: {e}")
self.update_metadata_cache_info(speaker, Path(""), "failed")
return False
# ------------------------------------------------------------------
# Cache load (SAFE)
# ------------------------------------------------------------------
def load_cached_voice_prompt(
self,
speaker: str,
device: str | None = None,
) -> list[VoiceClonePromptItem] | None:
"""
Load cached VoiceClonePromptItem list from safetensors.
"""
try:
uploaded_speakers = self.load_uploaded_speakers_from_metadata()
if not uploaded_speakers:
return None
speaker_key = speaker.lower()
if speaker_key not in uploaded_speakers:
return None
speaker_info = uploaded_speakers[speaker_key]
if speaker_info.get("cache_status") != "ready":
return None
cache_file_path = Path(speaker_info.get("cache_file", "")).resolve()
base_dir = Path(self.speech_voice_samples_dir).resolve()
# ---- Path confinement (critical security check)
if not str(cache_file_path).startswith(str(base_dir)):
logger.error(f"Illegal cache path outside base dir: {cache_file_path}")
return None
if not cache_file_path.exists():
return None
if cache_file_path.suffix != ".safetensors":
logger.error(f"Legacy or unsafe cache format rejected: {cache_file_path}")
return None
with safe_open(cache_file_path, framework="pt", device="cpu") as f:
meta = f.metadata()
num_items = int(f.get_tensor("__len__").item())
result: list[VoiceClonePromptItem] = []
for i in range(num_items):
prefix = f"item_{i}_"
has_ref_code = bool(f.get_tensor(prefix + "has_ref_code").item())
ref_code = f.get_tensor(prefix + "ref_code").to(device) if has_ref_code else None
ref_spk_embedding = f.get_tensor(prefix + "ref_spk_embedding").to(device)
x_vector_only_mode = bool(f.get_tensor(prefix + "x_vector_only_mode").item())
icl_mode = bool(f.get_tensor(prefix + "icl_mode").item())
ref_text = meta.get(prefix + "ref_text")
result.append(
VoiceClonePromptItem(
ref_code=ref_code,
ref_spk_embedding=ref_spk_embedding,
x_vector_only_mode=x_vector_only_mode,
icl_mode=icl_mode,
ref_text=ref_text,
)
)
logger.info(f"Safetensors cache loaded for speaker: {speaker}")
return result
except Exception as e:
logger.warning(f"Failed to load safetensors cache for speaker {speaker}: {e}")
return None
# ------------------------------------------------------------------
# Audio path helper
# ------------------------------------------------------------------
def get_speaker_audio_path(self, speaker: str) -> Path | None:
"""
Get speaker's audio file path.
Args:
speaker: Speaker name
Returns:
Optional[Path]: Audio file path, returns None if speaker doesn't exist
"""
uploaded_speakers = self.load_uploaded_speakers_from_metadata()
if not uploaded_speakers:
return None
speaker_key = speaker.lower()
if speaker_key not in uploaded_speakers:
return None
audio_file_path = Path(uploaded_speakers[speaker_key]["file_path"])
if audio_file_path.exists():
return audio_file_path
logger.warning(f"Audio file not found for speaker {speaker}: {audio_file_path}")
return None
@@ -119,6 +119,11 @@ _OMNI_MODELS = {
"glm_image_ar",
"GlmImageForConditionalGeneration",
),
"OmniBagelForConditionalGeneration": (
"bagel",
"bagel",
"OmniBagelForConditionalGeneration",
),
"HunyuanImage3ForCausalMM": (
"hunyuan_image3",
"hunyuan_image3",
@@ -6,15 +6,14 @@ stage_args:
prompt_expand_func: vllm_omni.model_executor.stage_input_processors.bagel.expand_cfg_prompts
runtime:
devices: "0"
# 2 = 1 user prompt + 1 CFG companion (text-unconditional).
# For multi-prompt batches this should scale as batch_size × 2.
max_batch_size: 2
# 3 = 1 user prompt + 2 CFG companions (text-unconditional + image-unconditional).
max_batch_size: 3
engine_args:
model_stage: thinker
model_arch: BagelForConditionalGeneration
model_arch: OmniBagelForConditionalGeneration
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
gpu_memory_utilization: 0.35
gpu_memory_utilization: 0.45
enforce_eager: true
trust_remote_code: true
engine_output_type: text
@@ -46,7 +45,7 @@ stage_args:
max_batch_size: 1
engine_args:
model_stage: dit
gpu_memory_utilization: 0.55
gpu_memory_utilization: 0.45
enforce_eager: true
trust_remote_code: true
engine_output_type: image
@@ -11,7 +11,7 @@ stage_args:
model_arch: BagelForConditionalGeneration
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
gpu_memory_utilization: 0.35
gpu_memory_utilization: 0.45
enforce_eager: true
trust_remote_code: true
engine_output_type: text
@@ -45,7 +45,7 @@ stage_args:
max_batch_size: 1
engine_args:
model_stage: dit
gpu_memory_utilization: 0.55
gpu_memory_utilization: 0.45
enforce_eager: true
trust_remote_code: true
engine_output_type: image
@@ -4,7 +4,7 @@ stage_args:
stage_type: llm
runtime:
devices: "0"
max_batch_size: 1
max_batch_size: 10
engine_args:
model_stage: qwen3_tts
model_arch: Qwen3TTSTalkerForConditionalGeneration
@@ -20,6 +20,7 @@ from typing import Any
logger = logging.getLogger(__name__)
CFG_TEXT_SUFFIX = "__cfg_text"
CFG_IMG_SUFFIX = "__cfg_img"
@dataclass
@@ -73,7 +74,37 @@ def expand_cfg_prompts(
),
]
# img2img: more complex (3 distinct caches). Reserve for future.
if "img2img" in modalities:
IMG2IMG_PLACEHOLDER = "<|fim_middle|>"
cfg_text_dict: dict[str, Any] = {
"prompt": f"{IMG2IMG_PLACEHOLDER}{neg_prompt}",
"modalities": ["img2img"],
}
mm_data = prompt.get("multi_modal_data")
if mm_data:
cfg_text_dict["multi_modal_data"] = mm_data
original_text = prompt.get("prompt", "")
cfg_img_text = original_text.replace(IMG2IMG_PLACEHOLDER, "")
cfg_img_dict: dict[str, Any] = {
"prompt": cfg_img_text,
"modalities": ["img2img"],
}
return [
ExpandedPrompt(
prompt=cfg_text_dict,
role="cfg_text",
request_id_suffix=CFG_TEXT_SUFFIX,
),
ExpandedPrompt(
prompt=cfg_img_dict,
role="cfg_img",
request_id_suffix=CFG_IMG_SUFFIX,
),
]
return []
+24
View File
@@ -60,6 +60,7 @@ class OmniRequestOutput:
latents: torch.Tensor | None = None
metrics: dict[str, Any] = field(default_factory=dict)
_multimodal_output: dict[str, Any] = field(default_factory=dict)
_custom_output: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_pipeline(
@@ -95,6 +96,7 @@ class OmniRequestOutput:
metrics: dict[str, Any] | None = None,
latents: torch.Tensor | None = None,
multimodal_output: dict[str, Any] | None = None,
custom_output: dict[str, Any] | None = None,
final_output_type: str = "image",
) -> "OmniRequestOutput":
"""Create output from diffusion model.
@@ -105,6 +107,8 @@ class OmniRequestOutput:
prompt: The prompt used
metrics: Generation metrics
latents: Optional latent tensors
multimodal_output: Optional multimodal output dict
custom_output: Optional custom output dict (e.g. latent trajectories, prompt embeds)
Returns:
OmniRequestOutput configured for diffusion mode
@@ -117,6 +121,7 @@ class OmniRequestOutput:
latents=latents,
metrics=metrics or {},
_multimodal_output=multimodal_output or {},
_custom_output=custom_output or {},
finished=True,
)
@@ -140,6 +145,23 @@ class OmniRequestOutput:
return mm
return self._multimodal_output
@property
def custom_output(self) -> dict[str, Any]:
"""Return custom output data from diffusion pipelines.
For diffusion outputs, returns the local _custom_output field.
For pipeline outputs with an inner OmniRequestOutput, forwards
the custom_output from the inner request output.
"""
if self.request_output is not None:
if isinstance(self.request_output, OmniRequestOutput):
return self.request_output._custom_output
return self._custom_output
@custom_output.setter
def custom_output(self, value: dict[str, Any]) -> None:
self._custom_output = value
@property
def num_images(self) -> int:
"""Return the number of generated images."""
@@ -248,7 +270,9 @@ class OmniRequestOutput:
f"images={images_repr}",
f"prompt={self.prompt!r}",
f"latents={self.latents}",
f"metrics={self.metrics}",
f"multimodal_output={self._multimodal_output}",
f"custom_output={self._custom_output}",
]
return f"OmniRequestOutput({', '.join(parts)})"
+16
View File
@@ -1,6 +1,8 @@
import sys
from functools import cached_property as _cached_property
from aenum import extend_enum
from vllm.config import ModelConfig as _ModelConfig
from vllm.inputs.data import TokensPrompt as _OriginalTokensPrompt
from vllm.model_executor.layers.rotary_embedding import (
MRotaryEmbedding as _OriginalMRotaryEmbedding,
@@ -43,6 +45,20 @@ except ImportError:
# GlmImageTextConfig not available, skip patching
pass
# Patch ModelConfig.is_mm_prefix_lm to include Bagel (bidirectional attention
# for multimodal prefix positions, same as Gemma3/Molmo2/PaliGemma).
_orig_is_mm_prefix_lm = _ModelConfig.__dict__["is_mm_prefix_lm"].func
@_cached_property
def _patched_is_mm_prefix_lm(self) -> bool:
return _orig_is_mm_prefix_lm(self) or getattr(self.hf_config, "model_type", None) == "bagel"
_patched_is_mm_prefix_lm.__set_name__(_ModelConfig, "is_mm_prefix_lm")
_ModelConfig.is_mm_prefix_lm = _patched_is_mm_prefix_lm
# Extend RequestStatus enum with omni-specific statuses
if not hasattr(RequestStatus, "WAITING_FOR_CHUNK"):
# The value - 1 is intentionally chosen to ensure it is treated
+21 -1
View File
@@ -98,9 +98,25 @@ class GPUARModelRunner(OmniGPUModelRunner):
if self.execute_model_state is not None:
raise RuntimeError("State error: sample_tokens() must be called after execute_model() returns None.")
if not getattr(self, "_warmup_state_cleared", False):
self._warmup_state_cleared = True
if hasattr(self.model, "_clear_warmup_state"):
self.model._clear_warmup_state()
# [Omni] Handle KV transfer BEFORE updating states (which removes finished requests)
finished_reqs = getattr(scheduler_output, "finished_requests_needing_kv_transfer", {})
if finished_reqs and hasattr(self.model, "get_kv_transfer_metadata"):
for req_id, data in finished_reqs.items():
try:
model_meta = self.model.get_kv_transfer_metadata(req_id)
if model_meta:
existing = data.get("custom_metadata") or {}
existing.update(model_meta)
data["custom_metadata"] = existing
except Exception as e:
logger.warning(f"Failed to get custom metadata from model for {req_id}: {e}")
self.kv_extracted_req_ids = self.kv_transfer_manager.handle_finished_requests_kv_transfer(
finished_reqs=getattr(scheduler_output, "finished_requests_needing_kv_transfer", {}),
finished_reqs=finished_reqs,
kv_caches=self.kv_caches,
block_size=self.cache_config.block_size,
cache_dtype=str(self.cache_config.cache_dtype),
@@ -290,6 +306,10 @@ class GPUARModelRunner(OmniGPUModelRunner):
sampler=self.sampler,
)
# [Omni] Map pending ropes metadata to req_ids.
if hasattr(self.model, "flush_pending_metadata"):
self.model.flush_pending_metadata(list(req_ids))
with record_function_or_nullcontext("gpu_model_runner: postprocess"):
if self.use_aux_hidden_state_outputs:
# True when EAGLE 3 is used.