diff --git a/.buildkite/release-pipeline.yaml b/.buildkite/release-pipeline.yaml new file mode 100644 index 000000000..edd98fafd --- /dev/null +++ b/.buildkite/release-pipeline.yaml @@ -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" diff --git a/.buildkite/test-nightly.yml b/.buildkite/test-nightly.yml index 8f141576d..001c46645 100644 --- a/.buildkite/test-nightly.yml +++ b/.buildkite/test-nightly.yml @@ -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: diff --git a/.buildkite/test-ready.yml b/.buildkite/test-ready.yml index 6c1b458b8..092d6f11a 100644 --- a/.buildkite/test-ready.yml +++ b/.buildkite/test-ready.yml @@ -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" diff --git a/README.md b/README.md index d20a90e12..df5d093b1 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Easy, fast, and cheap omni-modality model serving for everyone

-| Documentation | User Forum | Developer Slack | WeChat | Paper | Slides | +| Documentation | User Forum | Developer Slack | WeChat | Paper | Slides |

diff --git a/docker/Dockerfile.ci b/docker/Dockerfile.ci index 1a6889663..cb80828eb 100644 --- a/docker/Dockerfile.ci +++ b/docker/Dockerfile.ci @@ -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/* diff --git a/docker/Dockerfile.rocm b/docker/Dockerfile.rocm index e9db9ee49..3e0b60650 100644 --- a/docker/Dockerfile.rocm +++ b/docker/Dockerfile.rocm @@ -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/* diff --git a/docker/Dockerfile.xpu b/docker/Dockerfile.xpu index 3d59f9b04..1595165e8 100644 --- a/docker/Dockerfile.xpu +++ b/docker/Dockerfile.xpu @@ -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 \ diff --git a/docs/contributing/metrics.md b/docs/contributing/metrics.md index bc0f13efd..92dcd92cc 100644 --- a/docs/contributing/metrics.md +++ b/docs/contributing/metrics.md @@ -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** diff --git a/docs/features/custom_pipeline.md b/docs/features/custom_pipeline.md index be46b5b14..aee5cfa6b 100644 --- a/docs/features/custom_pipeline.md +++ b/docs/features/custom_pipeline.md @@ -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 diff --git a/docs/serving/speech_api.md b/docs/serving/speech_api.md index b22aee84c..ca5f7a2ac 100644 --- a/docs/serving/speech_api.md +++ b/docs/serving/speech_api.md @@ -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 | diff --git a/docs/user_guide/diffusion/quantization/gguf.md b/docs/user_guide/diffusion/quantization/gguf.md index 025180e18..9b9cb6fc7 100644 --- a/docs/user_guide/diffusion/quantization/gguf.md +++ b/docs/user_guide/diffusion/quantization/gguf.md @@ -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 ``` diff --git a/docs/user_guide/examples/offline_inference/qwen3_tts.md b/docs/user_guide/examples/offline_inference/qwen3_tts.md index 5c3b044d7..afadd39f4 100644 --- a/docs/user_guide/examples/offline_inference/qwen3_tts.md +++ b/docs/user_guide/examples/offline_inference/qwen3_tts.md @@ -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. diff --git a/docs/user_guide/examples/online_serving/qwen3_tts.md b/docs/user_guide/examples/online_serving/qwen3_tts.md index 77a3947b5..e3de00515 100644 --- a/docs/user_guide/examples/online_serving/qwen3_tts.md +++ b/docs/user_guide/examples/online_serving/qwen3_tts.md @@ -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) | diff --git a/examples/offline_inference/bagel/end2end.py b/examples/offline_inference/bagel/end2end.py index 584e463e1..cffe44fc3 100644 --- a/examples/offline_inference/bagel/end2end.py +++ b/examples/offline_inference/bagel/end2end.py @@ -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) diff --git a/examples/offline_inference/custom_pipeline/image_to_image/image_edit.py b/examples/offline_inference/custom_pipeline/image_to_image/image_edit.py index 6af06fa19..a9b3c32f8 100644 --- a/examples/offline_inference/custom_pipeline/image_to_image/image_edit.py +++ b/examples/offline_inference/custom_pipeline/image_to_image/image_edit.py @@ -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"}, diff --git a/examples/offline_inference/custom_pipeline/image_to_image/run.sh b/examples/offline_inference/custom_pipeline/image_to_image/run.sh index 1d2706e25..d62efae28 100644 --- a/examples/offline_inference/custom_pipeline/image_to_image/run.sh +++ b/examples/offline_inference/custom_pipeline/image_to_image/run.sh @@ -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 diff --git a/examples/offline_inference/image_to_image/image_edit.py b/examples/offline_inference/image_to_image/image_edit.py index 305a6d54a..81fe24e21 100644 --- a/examples/offline_inference/image_to_image/image_edit.py +++ b/examples/offline_inference/image_to_image/image_edit.py @@ -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") diff --git a/examples/offline_inference/image_to_video/image_to_video.py b/examples/offline_inference/image_to_video/image_to_video.py index 58da640d9..7b0e1bb2e 100644 --- a/examples/offline_inference/image_to_video/image_to_video.py +++ b/examples/offline_inference/image_to_video/image_to_video.py @@ -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, diff --git a/examples/offline_inference/qwen3_omni/README.md b/examples/offline_inference/qwen3_omni/README.md index b7eee8f74..b3e859253 100644 --- a/examples/offline_inference/qwen3_omni/README.md +++ b/examples/offline_inference/qwen3_omni/README.md @@ -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. diff --git a/examples/offline_inference/qwen3_omni/end2end_async_chunk.py b/examples/offline_inference/qwen3_omni/end2end_async_chunk.py new file mode 100644 index 000000000..965595578 --- /dev/null +++ b/examples/offline_inference/qwen3_omni/end2end_async_chunk.py @@ -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 + +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.") diff --git a/examples/offline_inference/qwen3_omni/run_multiple_prompts_async_chunk.sh b/examples/offline_inference/qwen3_omni/run_multiple_prompts_async_chunk.sh new file mode 100755 index 000000000..809054867 --- /dev/null +++ b/examples/offline_inference/qwen3_omni/run_multiple_prompts_async_chunk.sh @@ -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 \ + "$@" diff --git a/examples/offline_inference/qwen3_omni/run_single_prompt_async_chunk.sh b/examples/offline_inference/qwen3_omni/run_single_prompt_async_chunk.sh new file mode 100755 index 000000000..918c7ee4f --- /dev/null +++ b/examples/offline_inference/qwen3_omni/run_single_prompt_async_chunk.sh @@ -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 \ + "$@" diff --git a/examples/offline_inference/qwen3_tts/end2end.py b/examples/offline_inference/qwen3_tts/end2end.py index 98303533a..f60365032 100644 --- a/examples/offline_inference/qwen3_tts/end2end.py +++ b/examples/offline_inference/qwen3_tts/end2end.py @@ -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 = { diff --git a/examples/offline_inference/text_to_image/text_to_image.py b/examples/offline_inference/text_to_image/text_to_image.py index 15cbbbf72..382971606 100644 --- a/examples/offline_inference/text_to_image/text_to_image.py +++ b/examples/offline_inference/text_to_image/text_to_image.py @@ -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, } diff --git a/examples/offline_inference/text_to_video/text_to_video.py b/examples/offline_inference/text_to_video/text_to_video.py index 857530045..c1efd342e 100644 --- a/examples/offline_inference/text_to_video/text_to_video.py +++ b/examples/offline_inference/text_to_video/text_to_video.py @@ -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, ) diff --git a/examples/online_serving/qwen3_tts/README.md b/examples/online_serving/qwen3_tts/README.md index 2046c6508..5088a4ca6 100644 --- a/examples/online_serving/qwen3_tts/README.md +++ b/examples/online_serving/qwen3_tts/README.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 99194c648..5bcfab7d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/tests/conftest.py b/tests/conftest.py index c3ff10246..c775ae349 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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"]) diff --git a/tests/diffusion/distributed/test_hsdp.py b/tests/diffusion/distributed/test_hsdp.py index 3309155e3..6fcaf1e81 100644 --- a/tests/diffusion/distributed/test_hsdp.py +++ b/tests/diffusion/distributed/test_hsdp.py @@ -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.""" diff --git a/tests/diffusion/test_worker_wrapper_base.py b/tests/diffusion/test_worker_wrapper_base.py index fd0be6c98..986bad9a8 100644 --- a/tests/diffusion/test_worker_wrapper_base.py +++ b/tests/diffusion/test_worker_wrapper_base.py @@ -96,6 +96,7 @@ class TestWorkerWrapperBaseInitialization: local_rank=0, rank=0, od_config=mock_od_config, + skip_load_model=False, ) diff --git a/tests/distributed/omni_connectors/test_kv_flow.py b/tests/distributed/omni_connectors/test_kv_flow.py index 1f3d4acab..b12fc013b 100644 --- a/tests/distributed/omni_connectors/test_kv_flow.py +++ b/tests/distributed/omni_connectors/test_kv_flow.py @@ -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 diff --git a/tests/e2e/offline_inference/stage_configs/bagel_mooncake_ci.yaml b/tests/e2e/offline_inference/stage_configs/bagel_mooncake_ci.yaml index 79e825d5d..dbb93344b 100644 --- a/tests/e2e/offline_inference/stage_configs/bagel_mooncake_ci.yaml +++ b/tests/e2e/offline_inference/stage_configs/bagel_mooncake_ci.yaml @@ -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 diff --git a/tests/e2e/offline_inference/stage_configs/bagel_sharedmemory_ci.yaml b/tests/e2e/offline_inference/stage_configs/bagel_sharedmemory_ci.yaml index eeefab236..686600f72 100644 --- a/tests/e2e/offline_inference/stage_configs/bagel_sharedmemory_ci.yaml +++ b/tests/e2e/offline_inference/stage_configs/bagel_sharedmemory_ci.yaml @@ -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 diff --git a/tests/e2e/offline_inference/stage_configs/npu/qwen2_5_omni_ci.yaml b/tests/e2e/offline_inference/stage_configs/npu/qwen2_5_omni_ci.yaml index d6b04258a..87707e919 100644 --- a/tests/e2e/offline_inference/stage_configs/npu/qwen2_5_omni_ci.yaml +++ b/tests/e2e/offline_inference/stage_configs/npu/qwen2_5_omni_ci.yaml @@ -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 diff --git a/tests/e2e/offline_inference/test_diffusion_cpu_offload.py b/tests/e2e/offline_inference/test_diffusion_cpu_offload.py index bf36b78ff..6e5f449c1 100644 --- a/tests/e2e/offline_inference/test_diffusion_cpu_offload.py +++ b/tests/e2e/offline_inference/test_diffusion_cpu_offload.py @@ -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") diff --git a/tests/e2e/offline_inference/test_diffusion_layerwise_offload.py b/tests/e2e/offline_inference/test_diffusion_layerwise_offload.py index 998e6232e..beac14099 100644 --- a/tests/e2e/offline_inference/test_diffusion_layerwise_offload.py +++ b/tests/e2e/offline_inference/test_diffusion_layerwise_offload.py @@ -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, ) diff --git a/tests/e2e/offline_inference/test_qwen2_5_omni.py b/tests/e2e/offline_inference/test_qwen2_5_omni.py index c5bb65842..d3c20580c 100644 --- a/tests/e2e/offline_inference/test_qwen2_5_omni.py +++ b/tests/e2e/offline_inference/test_qwen2_5_omni.py @@ -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] diff --git a/tests/e2e/offline_inference/test_qwen3_omni.py b/tests/e2e/offline_inference/test_qwen3_omni.py index 9f76f911d..8c57becef 100644 --- a/tests/e2e/offline_inference/test_qwen3_omni.py +++ b/tests/e2e/offline_inference/test_qwen3_omni.py @@ -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] diff --git a/tests/e2e/online_serving/test_qwen3_omni.py b/tests/e2e/online_serving/test_qwen3_omni.py index 74eaa9c6b..fb9986592 100644 --- a/tests/e2e/online_serving/test_qwen3_omni.py +++ b/tests/e2e/online_serving/test_qwen3_omni.py @@ -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()) diff --git a/tests/e2e/online_serving/test_qwen3_omni_expansion.py b/tests/e2e/online_serving/test_qwen3_omni_expansion.py index e2f77af73..fd7e0704c 100644 --- a/tests/e2e/online_serving/test_qwen3_omni_expansion.py +++ b/tests/e2e/online_serving/test_qwen3_omni_expansion.py @@ -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']}" diff --git a/tests/e2e/stage_configs/qwen2_5_omni_thinker_ci.yaml b/tests/e2e/stage_configs/qwen2_5_omni_thinker_ci.yaml index 3ebf914e8..23eacafdf 100644 --- a/tests/e2e/stage_configs/qwen2_5_omni_thinker_ci.yaml +++ b/tests/e2e/stage_configs/qwen2_5_omni_thinker_ci.yaml @@ -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 diff --git a/tests/e2e/stage_configs/qwen3_omni_ci.yaml b/tests/e2e/stage_configs/qwen3_omni_ci.yaml index 45e7fcab8..6f1293f96 100644 --- a/tests/e2e/stage_configs/qwen3_omni_ci.yaml +++ b/tests/e2e/stage_configs/qwen3_omni_ci.yaml @@ -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 diff --git a/tests/e2e/stage_configs/rocm/qwen2_5_omni_ci.yaml b/tests/e2e/stage_configs/rocm/qwen2_5_omni_ci.yaml index ec815ad1f..d51f7a5c8 100644 --- a/tests/e2e/stage_configs/rocm/qwen2_5_omni_ci.yaml +++ b/tests/e2e/stage_configs/rocm/qwen2_5_omni_ci.yaml @@ -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 diff --git a/tests/e2e/stage_configs/rocm/qwen3_omni_ci.yaml b/tests/e2e/stage_configs/rocm/qwen3_omni_ci.yaml index 477e6e59f..23e75aae7 100644 --- a/tests/e2e/stage_configs/rocm/qwen3_omni_ci.yaml +++ b/tests/e2e/stage_configs/rocm/qwen3_omni_ci.yaml @@ -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 diff --git a/tests/entrypoints/openai_api/test_serving_speech.py b/tests/entrypoints/openai_api/test_serving_speech.py index 10dbd3a3b..8b7177365 100644 --- a/tests/entrypoints/openai_api/test_serving_speech.py +++ b/tests/entrypoints/openai_api/test_serving_speech.py @@ -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.""" diff --git a/tests/entrypoints/test_async_omni_diffusion_config.py b/tests/entrypoints/test_async_omni_diffusion_config.py index ce443d5a5..ed205032e 100644 --- a/tests/entrypoints/test_async_omni_diffusion_config.py +++ b/tests/entrypoints/test_async_omni_diffusion_config.py @@ -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, diff --git a/tests/perf/stage_configs/qwen3_omni.yaml b/tests/perf/stage_configs/qwen3_omni.yaml index 802f8dd24..01ca7d9fb 100644 --- a/tests/perf/stage_configs/qwen3_omni.yaml +++ b/tests/perf/stage_configs/qwen3_omni.yaml @@ -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 diff --git a/vllm_omni/diffusion/data.py b/vllm_omni/diffusion/data.py index da68adbdb..6a1e44472 100644 --- a/vllm_omni/diffusion/data.py +++ b/vllm_omni/diffusion/data.py @@ -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 diff --git a/vllm_omni/diffusion/diffusion_engine.py b/vllm_omni/diffusion/diffusion_engine.py index e5ace3b28..abd668466 100644 --- a/vllm_omni/diffusion/diffusion_engine.py +++ b/vllm_omni/diffusion/diffusion_engine.py @@ -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": diff --git a/vllm_omni/diffusion/distributed/parallel_state.py b/vllm_omni/diffusion/distributed/parallel_state.py index fbcfe0651..4c09fd7a0 100644 --- a/vllm_omni/diffusion/distributed/parallel_state.py +++ b/vllm_omni/diffusion/distributed/parallel_state.py @@ -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", ) diff --git a/vllm_omni/diffusion/hooks/base.py b/vllm_omni/diffusion/hooks/base.py index 8b330a19f..3c975408c 100644 --- a/vllm_omni/diffusion/hooks/base.py +++ b/vllm_omni/diffusion/hooks/base.py @@ -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): diff --git a/vllm_omni/diffusion/ipc.py b/vllm_omni/diffusion/ipc.py new file mode 100644 index 000000000..cac406ef4 --- /dev/null +++ b/vllm_omni/diffusion/ipc.py @@ -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 diff --git a/vllm_omni/diffusion/models/bagel/bagel_transformer.py b/vllm_omni/diffusion/models/bagel/bagel_transformer.py index e715bdaec..09144e05a 100644 --- a/vllm_omni/diffusion/models/bagel/bagel_transformer.py +++ b/vllm_omni/diffusion/models/bagel/bagel_transformer.py @@ -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 diff --git a/vllm_omni/diffusion/models/bagel/pipeline_bagel.py b/vllm_omni/diffusion/models/bagel/pipeline_bagel.py index 46ce4b6bd..9ed3481d7 100644 --- a/vllm_omni/diffusion/models/bagel/pipeline_bagel.py +++ b/vllm_omni/diffusion/models/bagel/pipeline_bagel.py @@ -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") diff --git a/vllm_omni/diffusion/models/helios/pipeline_helios.py b/vllm_omni/diffusion/models/helios/pipeline_helios.py index 1a33a5eea..ba15a62b0 100644 --- a/vllm_omni/diffusion/models/helios/pipeline_helios.py +++ b/vllm_omni/diffusion/models/helios/pipeline_helios.py @@ -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: diff --git a/vllm_omni/diffusion/models/helios/scheduling_helios.py b/vllm_omni/diffusion/models/helios/scheduling_helios.py index 847dea2d0..741e906e8 100644 --- a/vllm_omni/diffusion/models/helios/scheduling_helios.py +++ b/vllm_omni/diffusion/models/helios/scheduling_helios.py @@ -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) diff --git a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py index 1de801c10..9a0037a6a 100644 --- a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py +++ b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py @@ -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: diff --git a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_i2v.py b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_i2v.py index b673cd0fa..fe15a24f5 100644 --- a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_i2v.py +++ b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_i2v.py @@ -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: diff --git a/vllm_omni/diffusion/offloader/layerwise_backend.py b/vllm_omni/diffusion/offloader/layerwise_backend.py index c1841dadf..5b66ae5ee 100644 --- a/vllm_omni/diffusion/offloader/layerwise_backend.py +++ b/vllm_omni/diffusion/offloader/layerwise_backend.py @@ -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)") diff --git a/vllm_omni/diffusion/offloader/sequential_backend.py b/vllm_omni/diffusion/offloader/sequential_backend.py index 945cfc413..ebf85624f 100644 --- a/vllm_omni/diffusion/offloader/sequential_backend.py +++ b/vllm_omni/diffusion/offloader/sequential_backend.py @@ -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, diff --git a/vllm_omni/diffusion/scheduler.py b/vllm_omni/diffusion/scheduler.py index c30d907aa..5f1d01c12 100644 --- a/vllm_omni/diffusion/scheduler.py +++ b/vllm_omni/diffusion/scheduler.py @@ -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") diff --git a/vllm_omni/diffusion/worker/diffusion_worker.py b/vllm_omni/diffusion/worker/diffusion_worker.py index d4389f4d2..19ba5f31c 100644 --- a/vllm_omni/diffusion/worker/diffusion_worker.py +++ b/vllm_omni/diffusion/worker/diffusion_worker.py @@ -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 diff --git a/vllm_omni/distributed/omni_connectors/kv_transfer_manager.py b/vllm_omni/distributed/omni_connectors/kv_transfer_manager.py index 21f09c951..2806f7315 100644 --- a/vllm_omni/distributed/omni_connectors/kv_transfer_manager.py +++ b/vllm_omni/distributed/omni_connectors/kv_transfer_manager.py @@ -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: diff --git a/vllm_omni/distributed/omni_connectors/transfer_adapter/chunk_transfer_adapter.py b/vllm_omni/distributed/omni_connectors/transfer_adapter/chunk_transfer_adapter.py index c7289d8b8..db8d5dbaa 100644 --- a/vllm_omni/distributed/omni_connectors/transfer_adapter/chunk_transfer_adapter.py +++ b/vllm_omni/distributed/omni_connectors/transfer_adapter/chunk_transfer_adapter.py @@ -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) diff --git a/vllm_omni/distributed/omni_connectors/utils/kv_utils.py b/vllm_omni/distributed/omni_connectors/utils/kv_utils.py new file mode 100644 index 000000000..2cb48a8b3 --- /dev/null +++ b/vllm_omni/distributed/omni_connectors/utils/kv_utils.py @@ -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 diff --git a/vllm_omni/entrypoints/async_omni.py b/vllm_omni/entrypoints/async_omni.py index b663430dc..6a4b88dbf 100644 --- a/vllm_omni/entrypoints/async_omni.py +++ b/vllm_omni/entrypoints/async_omni.py @@ -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, diff --git a/vllm_omni/entrypoints/omni.py b/vllm_omni/entrypoints/omni.py index dddcb12b5..c414b972b 100644 --- a/vllm_omni/entrypoints/omni.py +++ b/vllm_omni/entrypoints/omni.py @@ -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: diff --git a/vllm_omni/entrypoints/omni_diffusion.py b/vllm_omni/entrypoints/omni_diffusion.py index 9937726e5..59ddf2eeb 100644 --- a/vllm_omni/entrypoints/omni_diffusion.py +++ b/vllm_omni/entrypoints/omni_diffusion.py @@ -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) diff --git a/vllm_omni/entrypoints/omni_stage.py b/vllm_omni/entrypoints/omni_stage.py index 4114ef3fb..b0f94e426 100644 --- a/vllm_omni/entrypoints/omni_stage.py +++ b/vllm_omni/entrypoints/omni_stage.py @@ -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 diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py index 969531b08..6db97dd2d 100644 --- a/vllm_omni/entrypoints/openai/api_server.py +++ b/vllm_omni/entrypoints/openai/api_server.py @@ -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 diff --git a/vllm_omni/entrypoints/openai/metadata_manager.py b/vllm_omni/entrypoints/openai/metadata_manager.py new file mode 100644 index 000000000..4077aa23b --- /dev/null +++ b/vllm_omni/entrypoints/openai/metadata_manager.py @@ -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 diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index 8c292cc7a..de9c559dd 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -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 diff --git a/vllm_omni/entrypoints/openai/serving_video.py b/vllm_omni/entrypoints/openai/serving_video.py index f73df5ea4..b663eb52f 100644 --- a/vllm_omni/entrypoints/openai/serving_video.py +++ b/vllm_omni/entrypoints/openai/serving_video.py @@ -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: diff --git a/vllm_omni/entrypoints/stage_utils.py b/vllm_omni/entrypoints/stage_utils.py index 110bf48cd..de4a71719 100644 --- a/vllm_omni/entrypoints/stage_utils.py +++ b/vllm_omni/entrypoints/stage_utils.py @@ -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} diff --git a/vllm_omni/inputs/data.py b/vllm_omni/inputs/data.py index 16d690bf3..c0f10af2b 100644 --- a/vllm_omni/inputs/data.py +++ b/vllm_omni/inputs/data.py @@ -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( diff --git a/vllm_omni/metrics/stats.py b/vllm_omni/metrics/stats.py index 068b4a30b..46fec6fcc 100644 --- a/vllm_omni/metrics/stats.py +++ b/vllm_omni/metrics/stats.py @@ -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, diff --git a/vllm_omni/model_executor/models/__init__.py b/vllm_omni/model_executor/models/__init__.py index 0b2629b4a..68074bbb9 100644 --- a/vllm_omni/model_executor/models/__init__.py +++ b/vllm_omni/model_executor/models/__init__.py @@ -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", +] diff --git a/vllm_omni/model_executor/models/bagel/__init__.py b/vllm_omni/model_executor/models/bagel/__init__.py new file mode 100644 index 000000000..11e4c6667 --- /dev/null +++ b/vllm_omni/model_executor/models/bagel/__init__.py @@ -0,0 +1,3 @@ +from .bagel import OmniBagelForConditionalGeneration + +__all__ = ["OmniBagelForConditionalGeneration"] diff --git a/vllm_omni/model_executor/models/bagel/bagel.py b/vllm_omni/model_executor/models/bagel/bagel.py new file mode 100644 index 000000000..24a1331f9 --- /dev/null +++ b/vllm_omni/model_executor/models/bagel/bagel.py @@ -0,0 +1,1016 @@ +from collections import deque +from collections.abc import Iterable, Mapping, Sequence +from math import isqrt +from typing import Any + +import torch +import torch.nn as nn +from transformers import BatchFeature +from vllm.config import VllmConfig +from vllm.config.multimodal import BaseDummyOptions +from vllm.model_executor.layers.layernorm import RMSNorm as VllmRMSNorm +from vllm.model_executor.layers.linear import ( + QKVParallelLinear, + RowParallelLinear, +) +from vllm.model_executor.model_loader.weight_utils import default_weight_loader +from vllm.model_executor.models.bagel import BagelForConditionalGeneration +from vllm.model_executor.models.interfaces import MultiModalEmbeddings +from vllm.model_executor.models.qwen2 import Qwen2DecoderLayer, Qwen2MLP +from vllm.model_executor.models.utils import AutoWeightsLoader +from vllm.multimodal import MULTIMODAL_REGISTRY +from vllm.multimodal.inputs import ( + MultiModalDataDict, + MultiModalFieldConfig, + MultiModalKwargsItems, +) +from vllm.multimodal.parse import ( + ImageEmbeddingItems, + ImageProcessorItems, + ModalityData, + ModalityDataItems, + MultiModalDataItems, + MultiModalDataParser, +) +from vllm.multimodal.processing import ( + BaseDummyInputsBuilder, + BaseMultiModalProcessor, + BaseProcessingInfo, + PromptReplacement, + PromptUpdateDetails, +) +from vllm.transformers_utils.processors.bagel import BagelProcessor + +from vllm_omni.diffusion.models.bagel.autoencoder import ( + AutoEncoderParams, + DiagonalGaussian, + Encoder, +) +from vllm_omni.diffusion.models.bagel.bagel_transformer import ( + PositionEmbedding, + TimestepEmbedder, +) +from vllm_omni.diffusion.models.bagel.pipeline_bagel import default_ae_params + + +class OmniBagelProcessor(BagelProcessor): + def __call__(self, text=None, images=None, **kwargs): + is_img2img = kwargs.pop("is_img2img", False) + + if is_img2img and images is not None: + image_kwargs = kwargs.copy() + image_kwargs["do_resize"] = False + image_kwargs["do_rescale"] = True + if "return_tensors" not in image_kwargs: + image_kwargs["return_tensors"] = "pt" + + pixel_values = self.image_processor(images, **image_kwargs) + + text_inputs = self.tokenizer(text, **kwargs) if text is not None else None + + if pixel_values is not None and text_inputs is not None: + combined = dict(text_inputs) + combined["pixel_values"] = pixel_values["pixel_values"] + return BatchFeature(combined) + elif pixel_values is not None: + return pixel_values + elif text_inputs is not None: + return BatchFeature(dict(text_inputs)) + else: + return BatchFeature({}) + + return super().__call__(text, images, **kwargs) + + +class OmniBagelProcessingInfo(BaseProcessingInfo): + def get_supported_mm_limits(self) -> Mapping[str, int | None]: + return {"image": 1, "img2img": 1} + + def get_hf_processor(self, **kwargs: object): + return self.ctx.get_hf_processor(OmniBagelProcessor, **kwargs) + + def get_hf_config(self): + config = super().get_hf_config() + if not getattr(self, "_latent_size_patched", False): + self._latent_size_patched = True + self._patch_max_latent_size(config) + return config + + def _patch_max_latent_size(self, config): + """Infer correct max_latent_size from the model's latent_pos_embed + weight, since the HF config value may be stale (e.g. 32 vs 64).""" + import json + from pathlib import Path + + model_name = self.ctx.model_config.model + try: + p = Path(model_name) + if p.is_dir(): + index_path = p / "model.safetensors.index.json" + else: + from huggingface_hub import hf_hub_download + + index_path = Path(hf_hub_download(model_name, "model.safetensors.index.json")) + + if not index_path.exists(): + return + + with open(index_path) as f: + index = json.load(f) + + shard = index.get("weight_map", {}).get("latent_pos_embed.pos_embed") + if not shard: + return + + from safetensors import safe_open + + with safe_open(str(index_path.parent / shard), framework="pt") as f: + if "latent_pos_embed.pos_embed" in f.keys(): + npos = f.get_slice("latent_pos_embed.pos_embed").get_shape()[0] + side = isqrt(npos) + if side * side == npos: + old = getattr(config, "max_latent_size", 32) + if old != side: + config.max_latent_size = side + except Exception: + pass + + def get_data_parser(self) -> "OmniBagelDataParser": + return OmniBagelDataParser( + expected_hidden_size=self._get_expected_hidden_size(), + ) + + +class OmniBagelDummyInputsBuilder(BaseDummyInputsBuilder[OmniBagelProcessingInfo]): + def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str: + dummy_text = "" + if "image" in mm_counts: + dummy_text += "<|image_pad|>" * mm_counts["image"] + if "img2img" in mm_counts: + dummy_text += "<|fim_middle|>" * mm_counts["img2img"] + return dummy_text + + def get_dummy_mm_data( + self, + seq_len: int, + mm_counts: Mapping[str, int], + mm_options: Mapping[str, BaseDummyOptions] | None = None, + ) -> MultiModalDataDict: + num_images = mm_counts.get("image", 0) + hf_config = self.info.get_hf_config() + vit_config = hf_config.vit_config + + image_size = vit_config.image_size + image_overrides = mm_options.get("image") if mm_options else None + + return { + "image": self._get_dummy_images( + width=image_size, + height=image_size, + num_images=num_images, + overrides=image_overrides, + ), + "img2img": self._get_dummy_images( + width=image_size, + height=image_size, + num_images=mm_counts.get("img2img", 0), + overrides=image_overrides, + ), + } + + +class Img2ImgProcessorItems(ImageProcessorItems): + def __init__(self, data): + super().__init__(data) + self.modality = "img2img" + + def get_processor_data(self): + return {"pixel_values_img2img": self.get_all()} + + +class OmniBagelDataParser(MultiModalDataParser): + def _parse_img2img_data(self, data: ModalityData) -> ModalityDataItems | None: + items = self._parse_image_data(data) + if items is None: + return None + return Img2ImgProcessorItems(items.data) + + def _get_subparsers(self): + parsers = super()._get_subparsers() + parsers["img2img"] = self._parse_img2img_data + return parsers + + +class OmniBagelMultiModalProcessor(BaseMultiModalProcessor[OmniBagelProcessingInfo]): + def _get_mm_fields_config(self, hf_inputs, hf_processor_mm_kwargs): + return { + "pixel_values": MultiModalFieldConfig.batched("image"), + "pixel_values_img2img": MultiModalFieldConfig.batched("img2img"), + } + + def _call_hf_processor( + self, + prompt: str, + mm_data: Mapping[str, object], + mm_kwargs: Mapping[str, object], + tok_kwargs: Mapping[str, object], + ) -> "BatchFeature": + has_image = "images" in mm_data + has_img2img = "pixel_values_img2img" in mm_data + + if has_image and has_img2img: + outputs = BatchFeature() + + img_data = dict(mm_data) + if "pixel_values_img2img" in img_data: + del img_data["pixel_values_img2img"] + kwargs_img = dict(mm_kwargs) + kwargs_img["is_img2img"] = False + out_img = super()._call_hf_processor(prompt, img_data, kwargs_img, tok_kwargs) + if "pixel_values" in out_img: + outputs["pixel_values"] = out_img["pixel_values"] + for k, v in out_img.items(): + if k != "pixel_values": + outputs[k] = v + + img2img_data = dict(mm_data) + if "images" in img2img_data: + del img2img_data["images"] + img2img_data["images"] = img2img_data.pop("pixel_values_img2img") + kwargs_img2img = dict(mm_kwargs) + kwargs_img2img["is_img2img"] = True + out_img2img = super()._call_hf_processor(prompt, img2img_data, kwargs_img2img, tok_kwargs) + if "pixel_values" in out_img2img: + outputs["pixel_values_img2img"] = out_img2img["pixel_values"] + for k, v in out_img2img.items(): + if k not in outputs: + outputs[k] = v + + return outputs + + elif has_img2img: + mm_data = dict(mm_data) + mm_data["images"] = mm_data.pop("pixel_values_img2img") + mm_kwargs = dict(mm_kwargs) + mm_kwargs["is_img2img"] = True + outputs = super()._call_hf_processor(prompt, mm_data, mm_kwargs, tok_kwargs) + if "pixel_values" in outputs: + outputs["pixel_values_img2img"] = outputs.pop("pixel_values") + return outputs + + return super()._call_hf_processor(prompt, mm_data, mm_kwargs, tok_kwargs) + + def _hf_processor_applies_updates( + self, + prompt_text: str, + mm_items: MultiModalDataItems, + hf_processor_mm_kwargs: Mapping[str, object], + tokenization_kwargs: Mapping[str, object], + ) -> bool: + return False + + def _get_prompt_updates( + self, + mm_items: MultiModalDataItems, + hf_processor_mm_kwargs: Mapping[str, Any], + out_mm_kwargs: MultiModalKwargsItems, + ) -> Sequence[PromptReplacement]: + hf_config = self.info.get_hf_config() + tokenizer = self.info.get_tokenizer() + + replacements: list[PromptReplacement] = [] + + image_token_id = tokenizer.get_vocab().get("<|image_pad|>") + if image_token_id is not None: + num_patches = hf_config.vit_max_num_patch_per_side**2 + + def get_image_replacement(item_idx: int): + return [image_token_id] * num_patches + + replacements.append( + PromptReplacement( + modality="image", + target=[image_token_id], + replacement=get_image_replacement, + ) + ) + + img2img_token_id = tokenizer.get_vocab().get("<|fim_middle|>") + if img2img_token_id is not None: + vit_config = hf_config.vit_config + image_size = vit_config.image_size + num_vit_patches = (image_size // vit_config.patch_size) ** 2 + + latent_patch_size = getattr(hf_config, "latent_patch_size", 2) + downsample = hf_config.vae_config.get("downsample", 8) + latent_downsample = downsample * latent_patch_size + + def get_img2img_replacement(item_idx: int): + h, w = image_size, image_size + if "img2img" in mm_items: + item = mm_items.get_items("img2img", (Img2ImgProcessorItems, ImageEmbeddingItems)) + if hasattr(item, "get_image_size"): + size = item.get_image_size(item_idx) + h, w = size.height, size.width + + max_latent_size = getattr(hf_config, "max_latent_size", 32) + max_img_size = int(max_latent_size * latent_downsample) + stride = latent_downsample + scale = min(max_img_size / max(h, w), 1.0) + min_img_size = min(256, max_img_size) + scale = max(scale, min_img_size / min(h, w)) + new_h = max(stride, int(round(h * scale / stride) * stride)) + new_w = max(stride, int(round(w * scale / stride) * stride)) + new_h = min(new_h, max_img_size) + new_w = min(new_w, max_img_size) + + num_vae_patches = (new_h // latent_downsample) * (new_w // latent_downsample) + num_vae_total = num_vae_patches + 2 + num_vit_total = num_vit_patches + 2 + # +1 separator between VAE and ViT blocks so that + # extract_embeds_range() produces two distinct mm_prefix_range + # entries, preventing VAE tokens from attending to ViT. + total = num_vae_total + 1 + num_vit_total + tokens = [img2img_token_id] * total + + embed_mask = [True] * num_vae_total + [False] + [True] * num_vit_total + return PromptUpdateDetails( + full=tokens, + is_embed=lambda _tok, _seq, _m=embed_mask: torch.tensor(_m, dtype=torch.bool), + ) + + replacements.append( + PromptReplacement( + modality="img2img", + target=[img2img_token_id], + replacement=get_img2img_replacement, + ) + ) + + return replacements + + +class VAEEncoder(nn.Module): + """Lightweight VAE encoder (no decoder) for embedding images in the AR stage.""" + + def __init__(self, params: AutoEncoderParams): + super().__init__() + self.encoder = Encoder( + resolution=params.resolution, + in_channels=params.in_channels, + ch=params.ch, + ch_mult=params.ch_mult, + num_res_blocks=params.num_res_blocks, + z_channels=params.z_channels, + ) + self.reg = DiagonalGaussian() + self.scale_factor = params.scale_factor + self.shift_factor = params.shift_factor + + def encode(self, x: torch.Tensor) -> torch.Tensor: + z = self.reg(self.encoder(x)) + z = self.scale_factor * (z - self.shift_factor) + return z + + +@MULTIMODAL_REGISTRY.register_processor( + OmniBagelMultiModalProcessor, + info=OmniBagelProcessingInfo, + dummy_inputs=OmniBagelDummyInputsBuilder, +) +class OmniBagelForConditionalGeneration(BagelForConditionalGeneration): + """ + Omni version of BagelForConditionalGeneration. + + Extends the base model with a VAE encoder so that img2img can embed + both VAE latents and ViT features within the AR stage, producing a + combined KV cache that is then transferred to the DiT stage. + + Position IDs are adjusted so that: + - VAE tokens all share position 0 + - ViT tokens all share position 1 + - Text tokens use sequential positions starting from 2 + This matches the position scheme used by the single-stage DiT pipeline, + ensuring the transferred KV cache + ropes are directly compatible with + the DiT's denoising loop. + """ + + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""): + super().__init__(vllm_config=vllm_config, prefix=prefix) + config = vllm_config.model_config.hf_config + self.latent_patch_size = getattr(config, "latent_patch_size", 2) + self.downsample = config.vae_config.get("downsample") + self.latent_downsample = self.downsample * self.latent_patch_size + self.max_latent_size = getattr(config, "max_latent_size", 32) + self.latent_channel = config.vae_config.get("z_channels") + + hidden_size = config.llm_config.hidden_size + patch_latent_dim = self.latent_patch_size**2 * self.latent_channel + self.vae = VAEEncoder(default_ae_params()) + self.vae2llm = nn.Linear(patch_latent_dim, hidden_size) + self.latent_pos_embed = PositionEmbedding(self.max_latent_size, hidden_size) + self.time_embedder = TimestepEmbedder(hidden_size) + + self._pending_img2img_info: list[tuple[int, int, int, int]] = [] + self._ropes_pending: list[dict[str, Any]] = [] + self._ropes_metadata: dict[str, dict[str, Any]] = {} + self._cfg_companion_queue: deque[tuple[tuple[int, int, int, int], int]] = deque() + + from transformers import AutoTokenizer + + tok_name = getattr(vllm_config.model_config, "tokenizer", None) or vllm_config.model_config.model + _tok = AutoTokenizer.from_pretrained(tok_name, trust_remote_code=True) + for t in ["<|vision_start|>", "<|vision_end|>"]: + if t not in _tok.get_vocab(): + _tok.add_tokens([t]) + self._start_of_image_id = int(_tok.convert_tokens_to_ids("<|vision_start|>")) + self._end_of_image_id = int(_tok.convert_tokens_to_ids("<|vision_end|>")) + + self._vae_token_mask: torch.Tensor | None = None + + self._install_mot_modules(config) + + def _install_mot_modules(self, config): + """Add generation-mode (MoT) weight modules to each Qwen2 decoder layer. + + The single-stage DiT routes VAE latent tokens through separate + ``qkv_proj_moe_gen / o_proj_moe_gen / mlp_moe_gen`` weight matrices + (``mode="gen"``). We replicate that structure here so the AR stage + produces the same KV cache values. + """ + llm_cfg = config.llm_config + hidden_size = llm_cfg.hidden_size + intermediate_size = llm_cfg.intermediate_size + num_heads = llm_cfg.num_attention_heads + num_kv_heads = llm_cfg.num_key_value_heads + head_dim = hidden_size // num_heads + rms_eps = llm_cfg.rms_norm_eps + + qwen2_model = self.language_model.model # Qwen2Model + + qwen2_model.norm_moe_gen = VllmRMSNorm(hidden_size, eps=rms_eps) + + for layer in qwen2_model.layers: + if not isinstance(layer, Qwen2DecoderLayer): + continue + attn = layer.self_attn + + attn.qkv_proj_moe_gen = QKVParallelLinear( + hidden_size, + head_dim, + num_heads, + num_kv_heads, + bias=True, + ) + attn.o_proj_moe_gen = RowParallelLinear( + num_heads * head_dim, + hidden_size, + bias=False, + ) + attn.q_norm_moe_gen = VllmRMSNorm(head_dim, eps=rms_eps) + attn.k_norm_moe_gen = VllmRMSNorm(head_dim, eps=rms_eps) + + layer.mlp_moe_gen = Qwen2MLP( + hidden_size=hidden_size, + intermediate_size=intermediate_size, + hidden_act=llm_cfg.hidden_act, + ) + layer.input_layernorm_moe_gen = VllmRMSNorm(hidden_size, eps=rms_eps) + layer.post_attention_layernorm_moe_gen = VllmRMSNorm(hidden_size, eps=rms_eps) + + def _resize_to_stride(self, pixel_values: torch.Tensor) -> torch.Tensor: + """Resize pixel values to stride-aligned dimensions + (matches DiT's ``_resize_images_to_stride``).""" + H, W = pixel_values.shape[2], pixel_values.shape[3] + stride = self.latent_downsample + max_img_size = int(self.max_latent_size * stride) + + scale = min(max_img_size / max(H, W), 1.0) + min_img_size = min(256, max_img_size) + scale = max(scale, min_img_size / min(H, W)) + new_H = max(stride, int(round(H * scale / stride) * stride)) + new_W = max(stride, int(round(W * scale / stride) * stride)) + new_H = min(new_H, max_img_size) + new_W = min(new_W, max_img_size) + + if new_H != H or new_W != W: + pixel_values = torch.nn.functional.interpolate( + pixel_values, size=(new_H, new_W), mode="bicubic", align_corners=False + ) + return pixel_values + + def _clear_warmup_state(self): + """Clear stale state accumulated during warmup/profiling runs.""" + self._ropes_pending.clear() + self._ropes_metadata.clear() + self._pending_img2img_info.clear() + self._cfg_companion_queue.clear() + self._vae_token_mask = None + + def get_kv_transfer_metadata(self, req_id: str) -> dict[str, Any] | None: + return self._ropes_metadata.pop(req_id, None) + + def flush_pending_metadata(self, req_ids: list[str]) -> None: + """Map pending metadata (batch order) to req_ids after forward().""" + pending = self._ropes_pending + self._ropes_pending = [] + for i, meta in enumerate(pending): + if i < len(req_ids): + self._ropes_metadata[req_ids[i]] = meta + + def _parse_and_validate_multimodal_inputs(self, **kwargs: object) -> dict: + mm_input_by_modality = {} + + if any(k in kwargs for k in ("pixel_values", "image_embeds")): + mm_input_by_modality["img2text"] = self._parse_and_validate_image_input(**kwargs) + + img2img_keys = {"pixel_values_img2img": "pixel_values", "image_embeds_img2img": "image_embeds"} + img2img_kwargs = {img2img_keys[k]: v for k, v in kwargs.items() if k in img2img_keys} + + if img2img_kwargs: + combined_kwargs = kwargs.copy() + combined_kwargs.update(img2img_kwargs) + mm_input_by_modality["img2img"] = self._parse_and_validate_image_input(**combined_kwargs) + + return mm_input_by_modality + + def embed_multimodal(self, **kwargs: object) -> MultiModalEmbeddings | None: + mm_input_by_modality = self._parse_and_validate_multimodal_inputs(**kwargs) + if not mm_input_by_modality: + return None + multimodal_embeddings: tuple[torch.Tensor, ...] = () + + for modality in mm_input_by_modality: + multimodal_input = mm_input_by_modality[modality] + if modality == "img2text": + image_embeddings = self._process_img2text_input(multimodal_input) + multimodal_embeddings += tuple(image_embeddings) + if modality == "img2img": + img2img_embeddings = self._process_img2img_input(multimodal_input) + multimodal_embeddings += tuple(img2img_embeddings) + return multimodal_embeddings + + def get_flattened_position_ids(self, img_h, img_w, patch_size, max_num_patches_per_side): + num_patches_h, num_patches_w = img_h // patch_size, img_w // patch_size + coords_h = torch.arange(0, num_patches_h) + coords_w = torch.arange(0, num_patches_w) + pos_ids = (coords_h[:, None] * max_num_patches_per_side + coords_w).flatten() + return pos_ids + + def _process_img2text_input(self, multimodal_input): + return self._process_image_input(multimodal_input) + + def _process_img2img_input(self, multimodal_input): + pixel_values = multimodal_input["pixel_values"] + if pixel_values.ndim == 5: + b, n, c, h, w = pixel_values.shape + pixel_values = pixel_values.reshape(b * n, c, h, w) + + num_images = pixel_values.shape[0] + image_size = self.config.vit_config.image_size + p = self.latent_patch_size + timestep = 0 + + if self._ropes_pending: + self._ropes_pending.clear() + + vit_pixel_values = torch.nn.functional.interpolate( + pixel_values, + size=(image_size, image_size), + mode="bicubic", + align_corners=False, + ) + + vit_embeddings_tuple = self._process_image_input({"pixel_values": vit_pixel_values}) + + marker_ids = torch.tensor( + [self._start_of_image_id, self._end_of_image_id], + device=pixel_values.device, + dtype=torch.long, + ) + marker_embeds = self.language_model.model.embed_tokens(marker_ids) + start_embed = marker_embeds[0:1] + end_embed = marker_embeds[1:2] + + results = [] + + for i in range(num_images): + single_pv = pixel_values[i : i + 1] + single_pv = self._resize_to_stride(single_pv) + H, W = single_pv.shape[2:] + + padded_latent = self.vae.encode(single_pv) + h = H // self.latent_downsample + w = W // self.latent_downsample + + latent = padded_latent[0][:, : h * p, : w * p] + latent = latent.reshape(self.latent_channel, h, p, w, p) + latent = torch.einsum("chpwq->hwpqc", latent).reshape(-1, p * p * self.latent_channel) + + vae_position_ids = self.get_flattened_position_ids( + H, + W, + self.latent_downsample, + max_num_patches_per_side=self.max_latent_size, + ) + pos_embed = self.latent_pos_embed([vae_position_ids]) + packed_timesteps = torch.tensor([timestep], device=padded_latent.device) + with torch.amp.autocast("cuda", dtype=torch.bfloat16): + timestep_embeds = self.time_embedder(packed_timesteps.to(padded_latent)) + vae_embeds = self.vae2llm(latent) + timestep_embeds + pos_embed + + vit_emb = vit_embeddings_tuple[i] if i < len(vit_embeddings_tuple) else vit_embeddings_tuple[0] + + se = start_embed.to(vae_embeds.dtype) + ee = end_embed.to(vae_embeds.dtype) + combined = torch.cat([se, vae_embeds, ee, se, vit_emb, ee], dim=0) + results.append(combined) + + num_vae = h * w + 2 # +2 for start/end markers + num_vit = vit_emb.shape[0] + 2 + info = (num_vae, num_vit, int(H), int(W)) + self._pending_img2img_info.append(info) + self._cfg_companion_queue.append((info, 2)) # cfg_text + cfg_img + + return tuple(results) + + def forward( + self, + input_ids: torch.Tensor | None, + positions: torch.Tensor, + intermediate_tensors=None, + inputs_embeds: torch.Tensor | None = None, + **kwargs: object, + ) -> torch.Tensor: + use_mot = False + seq_len = inputs_embeds.shape[0] if inputs_embeds is not None else positions.shape[0] + + if self._pending_img2img_info: + positions = self._adjust_positions_for_img2img(positions) + use_mot = True + + elif self._cfg_companion_queue: + cached, remaining = self._cfg_companion_queue[0] + remaining -= 1 + num_vae, num_vit, img_H, img_W = cached + num_img2img = num_vae + 1 + num_vit # +1 separator + seq_len = inputs_embeds.shape[0] if inputs_embeds is not None else positions.shape[0] + + if inputs_embeds is not None and seq_len >= num_img2img: + self._pending_img2img_info = [cached] + positions = self._adjust_positions_for_img2img(positions) + use_mot = True + else: + rope = int(positions[seq_len - 1].item()) + 1 + self._ropes_pending.append({"ropes": [rope]}) + + if remaining == 0: + self._cfg_companion_queue.popleft() + else: + self._cfg_companion_queue[0] = (cached, remaining) + + if use_mot: + return self._mot_forward(input_ids, positions, intermediate_tensors, inputs_embeds, **kwargs) + return super().forward(input_ids, positions, intermediate_tensors, inputs_embeds, **kwargs) + + def _adjust_positions_for_img2img(self, positions: torch.Tensor) -> torch.Tensor: + """Rewrite position IDs to match the single-stage DiT scheme: + VAE tokens -> position 0, separator -> position 0, + ViT tokens -> position 1, text -> 2, 3, ... + + Also computes ``self._vae_token_mask`` (bool tensor, True for actual + VAE latent patches that should use gen-mode weights) and pushes + per-request ropes + image_shape to the FIFO consumed by + ``get_kv_transfer_metadata``. + """ + info_list = self._pending_img2img_info + self._pending_img2img_info = [] + + if not info_list: + self._vae_token_mask = None + return positions + + boundaries = [0] + for i in range(1, len(positions)): + if positions[i] < positions[i - 1]: + boundaries.append(i) + boundaries.append(len(positions)) + + num_requests = len(boundaries) - 1 + new_positions = positions.clone() + vae_mask = torch.zeros(len(positions), dtype=torch.bool, device=positions.device) + + img2img_idx = 0 + for req_idx in range(num_requests): + start = boundaries[req_idx] + end = boundaries[req_idx + 1] + req_len = end - start + + if img2img_idx < len(info_list): + num_vae, num_vit, img_H, img_W = info_list[img2img_idx] + num_img2img = num_vae + 1 + num_vit # +1 separator + + if req_len >= num_img2img: + new_positions[start : start + num_vae] = 0 + new_positions[start + num_vae] = 0 # separator + vit_start = start + num_vae + 1 + new_positions[vit_start : vit_start + num_vit] = 1 + num_text = req_len - num_img2img + if num_text > 0: + text_start = start + num_img2img + new_positions[text_start:end] = torch.arange( + 2, 2 + num_text, device=positions.device, dtype=positions.dtype + ) + + # VAE gen-mode mask: only actual VAE patches (not markers) + vae_patches_start = start + 1 # skip start_marker + vae_patches_end = start + num_vae - 1 # before end_marker + if vae_patches_end > vae_patches_start: + vae_mask[vae_patches_start:vae_patches_end] = True + + rope = 2 + num_text + self._ropes_pending.append( + { + "ropes": [rope], + "image_shape": [img_H, img_W], + } + ) + img2img_idx += 1 + continue + + rope = int(new_positions[end - 1].item()) + 1 + self._ropes_pending.append({"ropes": [rope]}) + + self._vae_token_mask = vae_mask if vae_mask.any() else None + return new_positions + + # ------------------------------------------------------------------ + # MoT (Mixture-of-Transformers) forward path + # ------------------------------------------------------------------ + + def _mot_forward( + self, + input_ids: torch.Tensor | None, + positions: torch.Tensor, + intermediate_tensors, + inputs_embeds: torch.Tensor | None, + **kwargs, + ) -> torch.Tensor: + """Full forward pass with MoT routing for img2img requests. + + VAE latent patches are routed through ``*_moe_gen`` weight matrices + while all other tokens (markers, ViT, separator, text) use the + standard understanding-mode weights. + """ + qwen2_model = self.language_model.model # Qwen2Model + + if inputs_embeds is not None: + hidden_states = inputs_embeds + else: + hidden_states = qwen2_model.embed_input_ids(input_ids) + + residual = None + vae_mask = self._vae_token_mask + self._vae_token_mask = None # consumed + + for layer in qwen2_model.layers: + if not isinstance(layer, Qwen2DecoderLayer): + continue # skip PPMissingLayer (pipeline parallelism) + hidden_states, residual = self._mot_layer_forward( + layer, + positions, + hidden_states, + residual, + vae_mask, + ) + + # Final norm with MoT routing + if residual is not None: + hidden_states = hidden_states + residual + if vae_mask is not None and vae_mask.any(): + out = torch.empty_like(hidden_states) + non_vae = ~vae_mask + if non_vae.any(): + out[non_vae] = qwen2_model.norm(hidden_states[non_vae]) + out[vae_mask] = qwen2_model.norm_moe_gen(hidden_states[vae_mask]) + hidden_states = out + else: + hidden_states = qwen2_model.norm(hidden_states) + + return hidden_states + + def _mot_layer_forward( + self, + layer: Qwen2DecoderLayer, + positions: torch.Tensor, + hidden_states: torch.Tensor, + residual: torch.Tensor | None, + vae_mask: torch.Tensor | None, + ) -> tuple[torch.Tensor, torch.Tensor]: + """Single decoder-layer forward with MoT routing.""" + if vae_mask is None or not vae_mask.any(): + return layer(positions, hidden_states, residual) + + non_vae = ~vae_mask + + # ---- input layernorm (split) ---- + if residual is not None: + hidden_states = hidden_states + residual + residual = hidden_states + normed = torch.empty_like(hidden_states) + if non_vae.any(): + normed[non_vae] = layer.input_layernorm(hidden_states[non_vae]) + normed[vae_mask] = layer.input_layernorm_moe_gen(hidden_states[vae_mask]) + hidden_states = normed + + # ---- attention (split QKV / O projections) ---- + hidden_states = self._mot_attn_forward(layer.self_attn, positions, hidden_states, vae_mask) + + # ---- post-attention layernorm (split) ---- + hidden_states = hidden_states + residual + residual = hidden_states + normed = torch.empty_like(hidden_states) + if non_vae.any(): + normed[non_vae] = layer.post_attention_layernorm(hidden_states[non_vae]) + normed[vae_mask] = layer.post_attention_layernorm_moe_gen(hidden_states[vae_mask]) + hidden_states = normed + + # ---- MLP (split) ---- + mlp_out = torch.empty_like(hidden_states) + if non_vae.any(): + mlp_out[non_vae] = layer.mlp(hidden_states[non_vae]) + mlp_out[vae_mask] = layer.mlp_moe_gen(hidden_states[vae_mask]) + hidden_states = mlp_out + + return hidden_states, residual + + def _mot_attn_forward( + self, + attn, # Qwen2Attention + positions: torch.Tensor, + hidden_states: torch.Tensor, + vae_mask: torch.Tensor, + ) -> torch.Tensor: + """Attention forward with MoT routing for QKV and O projections.""" + non_vae = ~vae_mask + qkv_dim = attn.q_size + 2 * attn.kv_size + + # ---- QKV projection (split) ---- + qkv = torch.empty( + hidden_states.shape[0], + qkv_dim, + device=hidden_states.device, + dtype=hidden_states.dtype, + ) + if non_vae.any(): + qkv_und, _ = attn.qkv_proj(hidden_states[non_vae]) + qkv[non_vae] = qkv_und + qkv_gen, _ = attn.qkv_proj_moe_gen(hidden_states[vae_mask]) + qkv[vae_mask] = qkv_gen + + q, k, v = qkv.split([attn.q_size, attn.kv_size, attn.kv_size], dim=-1) + + # ---- QK normalization (split) ---- + if attn.qk_norm: + n_tok = q.shape[0] + q = q.view(n_tok, attn.num_heads, attn.head_dim) + k = k.view(n_tok, attn.num_kv_heads, attn.head_dim) + + q_out = torch.empty_like(q) + k_out = torch.empty_like(k) + if non_vae.any(): + q_out[non_vae] = attn.q_norm(q[non_vae]) + k_out[non_vae] = attn.k_norm(k[non_vae]) + q_out[vae_mask] = attn.q_norm_moe_gen(q[vae_mask]) + k_out[vae_mask] = attn.k_norm_moe_gen(k[vae_mask]) + + q = q_out.reshape(n_tok, attn.q_size) + k = k_out.reshape(n_tok, attn.kv_size) + + # ---- RoPE + attention (same for all tokens) ---- + q, k = attn.rotary_emb(positions, q, k) + attn_output = attn.attn(q, k, v) + + # ---- O projection (split) ---- + output = torch.empty( + hidden_states.shape[0], + attn.hidden_size, + device=hidden_states.device, + dtype=hidden_states.dtype, + ) + if non_vae.any(): + o_und, _ = attn.o_proj(attn_output[non_vae]) + output[non_vae] = o_und + o_gen, _ = attn.o_proj_moe_gen(attn_output[vae_mask]) + output[vae_mask] = o_gen + + return output + + # ------------------------------------------------------------------ + # Weight loading + # ------------------------------------------------------------------ + + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: + generation_keywords_to_skip = [ + "llm2vae", + "decoder.", + ] + + def _map_vae_weight_name(name: str) -> str: + if name.startswith("encoder."): + return "vae." + name + if name.startswith("reg."): + return "vae." + name + return name + + moe_gen_weights: list[tuple[str, torch.Tensor]] = [] + filtered_weights = [] + + for name, tensor in weights: + if any(skip in name for skip in generation_keywords_to_skip): + continue + + mapped_name = _map_vae_weight_name(name) + + if "moe_gen" in mapped_name: + moe_gen_weights.append((mapped_name, tensor)) + continue + + if "patch_embedding.weight" in mapped_name and tensor.ndim == 2: + out_channels = tensor.shape[0] + in_features = tensor.shape[1] + patch_size = self.config.vit_config.patch_size + in_channels = self.config.vit_config.num_channels + if in_features == in_channels * patch_size * patch_size: + tensor = tensor.reshape(out_channels, patch_size, patch_size, in_channels) + tensor = tensor.permute(0, 3, 1, 2).contiguous() + + if "latent_pos_embed.pos_embed" in mapped_name and tensor.ndim == 2: + npos, hdim = tensor.shape + current_param = self.latent_pos_embed.pos_embed + if current_param.shape != tensor.shape: + side = isqrt(int(npos)) + if side * side == int(npos) and hdim == current_param.shape[1]: + current_param.data = current_param.data.new_empty((npos, hdim)) + self.max_latent_size = int(side) + setattr(self.config, "max_latent_size", int(side)) + if hasattr(self.latent_pos_embed, "max_num_patch_per_side"): + self.latent_pos_embed.max_num_patch_per_side = int(side) + + filtered_weights.append((mapped_name, tensor)) + + loader = AutoWeightsLoader( + self, + skip_prefixes=["vit_pos_embed.pos_embed"], + ignore_unexpected_prefixes=["vae.", "latent_pos_embed.", "time_embedder.", "vae2llm."], + ) + loaded = loader.load_weights(filtered_weights, mapper=self.hf_to_vllm_mapper) + + loaded |= self._load_moe_gen_weights(moe_gen_weights) + + return loaded + + def _load_moe_gen_weights(self, weights: list[tuple[str, torch.Tensor]]) -> set[str]: + """Load generation-mode MoT weights with proper stacked-param mapping.""" + stacked_params = [ + ("qkv_proj_moe_gen", "q_proj_moe_gen", "q"), + ("qkv_proj_moe_gen", "k_proj_moe_gen", "k"), + ("qkv_proj_moe_gen", "v_proj_moe_gen", "v"), + ("mlp_moe_gen.gate_up_proj", "mlp_moe_gen.gate_proj", 0), + ("mlp_moe_gen.gate_up_proj", "mlp_moe_gen.up_proj", 1), + ] + + mapper = self.hf_to_vllm_mapper + prefix_map = getattr(mapper, "orig_to_new_prefix", {}) + + params_dict = dict(self.named_parameters()) + loaded: set[str] = set() + + for name, tensor in weights: + mapped = name + for orig, new in prefix_map.items(): + if mapped.startswith(orig): + mapped = new + mapped[len(orig) :] + break + + found_stacked = False + for param_name, weight_name, shard_id in stacked_params: + if weight_name not in mapped: + continue + mapped = mapped.replace(weight_name, param_name) + if mapped in params_dict: + param = params_dict[mapped] + weight_loader = getattr(param, "weight_loader", default_weight_loader) + weight_loader(param, tensor, shard_id) + loaded.add(mapped) + found_stacked = True + break + + if not found_stacked: + if mapped in params_dict: + param = params_dict[mapped] + weight_loader = getattr(param, "weight_loader", default_weight_loader) + weight_loader(param, tensor) + loaded.add(mapped) + + return loaded diff --git a/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni_moe_thinker.py b/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni_moe_thinker.py index 835dc6d6f..7dfb988eb 100644 --- a/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni_moe_thinker.py +++ b/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni_moe_thinker.py @@ -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()) diff --git a/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts.py b/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts.py index b70f2f3b3..b73ab33d7 100644 --- a/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts.py +++ b/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts.py @@ -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): diff --git a/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_talker.py b/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_talker.py index e80f1785f..a1f21a6a6 100644 --- a/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_talker.py +++ b/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_talker.py @@ -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) diff --git a/vllm_omni/model_executor/models/qwen3_tts/voice_cache_manager.py b/vllm_omni/model_executor/models/qwen3_tts/voice_cache_manager.py new file mode 100644 index 000000000..1e26a161d --- /dev/null +++ b/vllm_omni/model_executor/models/qwen3_tts/voice_cache_manager.py @@ -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 diff --git a/vllm_omni/model_executor/models/registry.py b/vllm_omni/model_executor/models/registry.py index 496dfe305..9bf2b4fdb 100644 --- a/vllm_omni/model_executor/models/registry.py +++ b/vllm_omni/model_executor/models/registry.py @@ -119,6 +119,11 @@ _OMNI_MODELS = { "glm_image_ar", "GlmImageForConditionalGeneration", ), + "OmniBagelForConditionalGeneration": ( + "bagel", + "bagel", + "OmniBagelForConditionalGeneration", + ), "HunyuanImage3ForCausalMM": ( "hunyuan_image3", "hunyuan_image3", diff --git a/vllm_omni/model_executor/stage_configs/bagel.yaml b/vllm_omni/model_executor/stage_configs/bagel.yaml index 23bbb441f..4bce816b4 100644 --- a/vllm_omni/model_executor/stage_configs/bagel.yaml +++ b/vllm_omni/model_executor/stage_configs/bagel.yaml @@ -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 diff --git a/vllm_omni/model_executor/stage_configs/bagel_multiconnector.yaml b/vllm_omni/model_executor/stage_configs/bagel_multiconnector.yaml index 862e51b83..93700b6f3 100644 --- a/vllm_omni/model_executor/stage_configs/bagel_multiconnector.yaml +++ b/vllm_omni/model_executor/stage_configs/bagel_multiconnector.yaml @@ -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 diff --git a/vllm_omni/model_executor/stage_configs/qwen3_tts.yaml b/vllm_omni/model_executor/stage_configs/qwen3_tts.yaml index 564f1160c..19502add8 100644 --- a/vllm_omni/model_executor/stage_configs/qwen3_tts.yaml +++ b/vllm_omni/model_executor/stage_configs/qwen3_tts.yaml @@ -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 diff --git a/vllm_omni/model_executor/stage_input_processors/bagel.py b/vllm_omni/model_executor/stage_input_processors/bagel.py index 80b28b230..d7055ff51 100644 --- a/vllm_omni/model_executor/stage_input_processors/bagel.py +++ b/vllm_omni/model_executor/stage_input_processors/bagel.py @@ -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 [] diff --git a/vllm_omni/outputs.py b/vllm_omni/outputs.py index af14dd0e6..6f035a81e 100644 --- a/vllm_omni/outputs.py +++ b/vllm_omni/outputs.py @@ -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)})" diff --git a/vllm_omni/patch.py b/vllm_omni/patch.py index 6daef1a23..c9dea609e 100644 --- a/vllm_omni/patch.py +++ b/vllm_omni/patch.py @@ -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 diff --git a/vllm_omni/worker/gpu_ar_model_runner.py b/vllm_omni/worker/gpu_ar_model_runner.py index 1ea56f724..d7d45031a 100644 --- a/vllm_omni/worker/gpu_ar_model_runner.py +++ b/vllm_omni/worker/gpu_ar_model_runner.py @@ -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.