[Feat]: Offline inference supports async_chunk (#1415)

Signed-off-by: Sy03 <1370724210@qq.com>
Signed-off-by: linyueqian <linyueqian@outlook.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
Co-authored-by: linyueqian <linyueqian@outlook.com>
This commit is contained in:
authored and GitHub committed 2026-03-09 11:33:38 +08:00
1 parent fb717a4071
commit d6871db6a3
4 files changed
+708

No files matched your search

@@ -64,6 +64,51 @@ If media file paths are not provided, the script will use default assets. Suppor
- `multi_audios`: Multiple audio inputs
- `mixed_modalities`: Combination of video, image, and audio inputs
### Async-chunk (offline)
For true stage-level concurrency -- where downstream stages (Talker, Code2Wav)
start **before** the upstream stage (Thinker) finishes -- use the async_chunk
example. This requires:
1. A stage config YAML with ``async_chunk: true`` (e.g.
``qwen3_omni_moe_async_chunk.yaml``).
2. Hardware that matches the config (e.g. 2x H100 for the default 3-stage
config).
The async_chunk example uses ``AsyncOmni`` instead of the synchronous ``Omni``
class, which enables the async orchestrator to receive stage-0 intermediate
outputs and trigger downstream stages early. Chunk data flows directly between
stage workers via the in-worker ``OmniChunkTransferAdapter`` / connector,
**not** through the orchestrator.
#### Single prompt
```bash
cd examples/offline_inference/qwen3_omni
bash run_single_prompt_async_chunk.sh
```
#### Multiple prompts with concurrency control
```bash
bash run_multiple_prompts_async_chunk.sh --max-in-flight 4
```
#### Text-only output (skip audio generation)
```bash
python end2end_async_chunk.py --query-type text --modalities text
```
#### Custom stage config
```bash
python end2end_async_chunk.py \
--query-type use_audio \
--stage-configs-path /path/to/your_async_chunk.yaml
```
> **Note**: The synchronous ``end2end.py`` (using ``Omni``) is still the
> recommended entry point for non-async-chunk workflows. Only use the
> async_chunk example when you need the stage-level concurrency semantics
> described in PR #962 / #1151.
### FAQ
If you encounter error about backend of librosa, try to install ffmpeg with command below.
@@ -0,0 +1,614 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Offline inference with async_chunk enabled via AsyncOmni.
This script uses AsyncOmni (the async orchestrator) to run offline inference
with async_chunk semantics: downstream stages (Talker, Code2Wav) start
*before* upstream stages finish, consuming chunks as they arrive via
the in-worker OmniChunkTransferAdapter / connector.
Compared to the synchronous ``end2end.py`` (which uses ``Omni``), this
entry point achieves true stage-level concurrency -- stage-1/2 are
actively processing while stage-0 is still generating.
Usage
-----
python end2end_async_chunk.py --query-type use_audio \
--stage-configs-path <path-to-async-chunk-yaml>
See ``--help`` for all options.
"""
import asyncio
import logging
import os
import time
import uuid
from typing import NamedTuple
import numpy as np
import soundfile as sf
import torch
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
import librosa
from PIL import Image
from vllm import SamplingParams
from vllm.assets.audio import AudioAsset
from vllm.assets.image import ImageAsset
from vllm.assets.video import VideoAsset, video_to_ndarrays
from vllm.multimodal.image import convert_image_mode
from vllm.utils.argparse_utils import FlexibleArgumentParser
from vllm_omni.entrypoints.async_omni import AsyncOmni
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Query builders (reuse the patterns from end2end.py)
# ---------------------------------------------------------------------------
default_system = (
"You are Qwen, a virtual human developed by the Qwen Team, Alibaba "
"Group, capable of perceiving auditory and visual inputs, as well as "
"generating text and speech."
)
class QueryResult(NamedTuple):
inputs: dict
limit_mm_per_prompt: dict[str, int]
def get_text_query(question: str = None) -> QueryResult:
if question is None:
question = "Explain the system architecture for a scalable audio generation pipeline. Answer in 15 words."
prompt = (
f"<|im_start|>system\n{default_system}<|im_end|>\n"
"<|im_start|>user\n"
f"{question}<|im_end|>\n"
"<|im_start|>assistant\n"
)
return QueryResult(inputs={"prompt": prompt}, limit_mm_per_prompt={})
def get_audio_query(
question: str = None,
audio_path: str | None = None,
sampling_rate: int = 16000,
) -> QueryResult:
if question is None:
question = "What is the content of this audio?"
prompt = (
f"<|im_start|>system\n{default_system}<|im_end|>\n"
"<|im_start|>user\n<|audio_start|><|audio_pad|><|audio_end|>"
f"{question}<|im_end|>\n"
"<|im_start|>assistant\n"
)
if audio_path:
if not os.path.exists(audio_path):
raise FileNotFoundError(f"Audio file not found: {audio_path}")
audio_signal, sr = librosa.load(audio_path, sr=sampling_rate)
audio_data = (audio_signal.astype(np.float32), sr)
else:
audio_data = AudioAsset("mary_had_lamb").audio_and_sample_rate
return QueryResult(
inputs={
"prompt": prompt,
"multi_modal_data": {"audio": audio_data},
},
limit_mm_per_prompt={"audio": 1},
)
def get_image_query(question: str = None, image_path: str | None = None) -> QueryResult:
if question is None:
question = "What is the content of this image?"
prompt = (
f"<|im_start|>system\n{default_system}<|im_end|>\n"
"<|im_start|>user\n<|vision_start|><|image_pad|><|vision_end|>"
f"{question}<|im_end|>\n"
"<|im_start|>assistant\n"
)
if image_path:
if not os.path.exists(image_path):
raise FileNotFoundError(f"Image file not found: {image_path}")
pil_image = Image.open(image_path)
image_data = convert_image_mode(pil_image, "RGB")
else:
image_data = convert_image_mode(ImageAsset("cherry_blossom").pil_image, "RGB")
return QueryResult(
inputs={
"prompt": prompt,
"multi_modal_data": {"image": image_data},
},
limit_mm_per_prompt={"image": 1},
)
def get_video_query(
question: str = None,
video_path: str | None = None,
num_frames: int = 16,
) -> QueryResult:
if question is None:
question = "Why is this video funny?"
prompt = (
f"<|im_start|>system\n{default_system}<|im_end|>\n"
"<|im_start|>user\n<|vision_start|><|video_pad|><|vision_end|>"
f"{question}<|im_end|>\n"
"<|im_start|>assistant\n"
)
if video_path:
if not os.path.exists(video_path):
raise FileNotFoundError(f"Video file not found: {video_path}")
video_frames = video_to_ndarrays(video_path, num_frames=num_frames)
else:
video_frames = VideoAsset(name="baby_reading", num_frames=num_frames).np_ndarrays
return QueryResult(
inputs={
"prompt": prompt,
"multi_modal_data": {"video": video_frames},
},
limit_mm_per_prompt={"video": 1},
)
query_map = {
"text": get_text_query,
"use_audio": get_audio_query,
"use_image": get_image_query,
"use_video": get_video_query,
}
# ---------------------------------------------------------------------------
# Core async routine
# ---------------------------------------------------------------------------
def clone_prompt_for_request(template: dict) -> dict:
"""Shallow-clone prompt dict so concurrent requests own independent containers."""
cloned = dict(template)
for key in ("multi_modal_data", "mm_processor_kwargs", "additional_information"):
value = template.get(key)
if isinstance(value, dict):
cloned[key] = dict(value)
elif isinstance(value, list):
cloned[key] = list(value)
return cloned
def _default_async_chunk_stage_configs_path() -> str | None:
"""Best-effort default stage config for running Qwen3-Omni with async_chunk.
When this example is executed from within the repository, we resolve the
default YAML path relative to this file. When installed elsewhere, the
file may not exist and callers should pass --stage-configs-path explicitly.
"""
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))
candidate = os.path.join(
repo_root,
"vllm_omni",
"model_executor",
"stage_configs",
"qwen3_omni_moe_async_chunk.yaml",
)
return candidate if os.path.exists(candidate) else None
async def run_single_request(
async_omni: AsyncOmni,
prompt: dict,
request_id: str,
sampling_params_list: list[SamplingParams] | None,
output_dir: str,
output_modalities: list[str] | None = None,
stream_audio_to_disk: bool = False,
) -> dict:
"""Run one request through AsyncOmni and collect outputs.
Returns a dict with timing information and saved file paths.
"""
t_start = time.perf_counter()
text_parts: list[str] = []
audio_chunks: list[torch.Tensor] = []
audio_sr: int | None = None
first_audio_ts: float | None = None
audio_list_consumed: int = 0
audio_last_tensor: torch.Tensor | None = None
stage_0_first_output_ts: float | None = None
samplerate = 24000
wav_file = os.path.join(output_dir, f"output_{request_id}.wav")
sf_writer: sf.SoundFile | None = None
audio_samples_written: int = 0
try:
async for omni_output in async_omni.generate(
prompt=prompt,
request_id=request_id,
sampling_params_list=sampling_params_list,
output_modalities=output_modalities,
):
if not isinstance(omni_output.request_output, list):
outputs_list = [omni_output.request_output]
else:
outputs_list = omni_output.request_output
for output in outputs_list:
if omni_output.final_output_type == "text":
if stage_0_first_output_ts is None:
stage_0_first_output_ts = time.perf_counter()
text_output = output.outputs[0].text
if output.finished:
text_parts.append(text_output)
elif omni_output.final_output_type == "audio":
mm_out = output.outputs[0].multimodal_output
if mm_out and "audio" in mm_out:
if first_audio_ts is None:
first_audio_ts = time.perf_counter()
if audio_sr is None and "sr" in mm_out:
sr_val = mm_out["sr"]
audio_sr = sr_val.item() if hasattr(sr_val, "item") else int(sr_val)
samplerate = audio_sr
audio_data = mm_out["audio"]
if isinstance(audio_data, list):
new_chunks = audio_data[audio_list_consumed:]
audio_list_consumed = len(audio_data)
elif isinstance(audio_data, torch.Tensor):
new_chunks = [audio_data]
audio_last_tensor = audio_data
else:
new_chunks = []
if stream_audio_to_disk and new_chunks:
if sf_writer is None:
sf_writer = sf.SoundFile(
wav_file,
mode="w",
samplerate=samplerate,
channels=1,
subtype="FLOAT",
)
for chunk in new_chunks:
chunk_np = chunk.float().detach().cpu().numpy().flatten()
sf_writer.write(chunk_np)
audio_samples_written += len(chunk_np)
else:
audio_chunks.extend(new_chunks)
finally:
if sf_writer is not None:
sf_writer.close()
t_end = time.perf_counter()
result = {
"request_id": request_id,
"e2e_latency_s": t_end - t_start,
"saved_files": [],
}
# Save text output
if text_parts:
text_file = os.path.join(output_dir, f"{request_id}.txt")
with open(text_file, "w", encoding="utf-8") as f:
f.write("\n".join(text_parts))
result["saved_files"].append(text_file)
print(
f"[Request {request_id}] Text saved to {text_file} "
f"(stage-0 first output at {stage_0_first_output_ts - t_start:.3f}s)"
)
# Save audio output
if stream_audio_to_disk and audio_samples_written > 0:
result["saved_files"].append(wav_file)
result["audio_duration_s"] = audio_samples_written / samplerate
result["num_audio_chunks"] = audio_list_consumed
ttfa = (first_audio_ts - t_start) if first_audio_ts else None
result["time_to_first_audio_s"] = ttfa
ttfa_str = f"{ttfa:.3f}s" if ttfa is not None else "N/A"
print(
f"[Request {request_id}] Audio streamed to {wav_file} "
f"(duration={result['audio_duration_s']:.2f}s, "
f"TTFA={ttfa_str}, "
f"e2e={result['e2e_latency_s']:.3f}s)"
)
elif audio_chunks or audio_last_tensor is not None:
if audio_chunks:
if len(audio_chunks) > 1:
audio_tensor = torch.cat(audio_chunks, dim=-1)
else:
audio_tensor = audio_chunks[0]
else:
audio_tensor = audio_last_tensor
audio_numpy = audio_tensor.float().detach().cpu().numpy()
if audio_numpy.ndim > 1:
audio_numpy = audio_numpy.flatten()
sf.write(wav_file, audio_numpy, samplerate=samplerate, format="WAV")
result["saved_files"].append(wav_file)
result["audio_duration_s"] = len(audio_numpy) / samplerate
result["num_audio_chunks"] = len(audio_chunks)
ttfa = (first_audio_ts - t_start) if first_audio_ts else None
result["time_to_first_audio_s"] = ttfa
ttfa_str = f"{ttfa:.3f}s" if ttfa is not None else "N/A"
print(
f"[Request {request_id}] Audio saved to {wav_file} "
f"({len(audio_chunks)} chunks, "
f"duration={result['audio_duration_s']:.2f}s, "
f"TTFA={ttfa_str}, "
f"e2e={result['e2e_latency_s']:.3f}s)"
)
return result
async def run_all(args):
"""Main async entry: build prompts, create AsyncOmni, run requests."""
# Build query
query_func = query_map[args.query_type]
if args.query_type == "use_video":
query_result = query_func(
video_path=getattr(args, "video_path", None),
num_frames=getattr(args, "num_frames", 16),
)
elif args.query_type == "use_image":
query_result = query_func(image_path=getattr(args, "image_path", None))
elif args.query_type == "use_audio":
query_result = query_func(
audio_path=getattr(args, "audio_path", None),
sampling_rate=getattr(args, "sampling_rate", 16000),
)
else:
query_result = query_func()
# Build prompt list
if args.txt_prompts is not None:
assert args.query_type == "text", "txt-prompts is only supported for text query type"
with open(args.txt_prompts, encoding="utf-8") as f:
lines = [ln.strip() for ln in f if ln.strip()]
prompts = [get_text_query(ln).inputs for ln in lines]
print(f"[Info] Loaded {len(prompts)} prompts from {args.txt_prompts}")
else:
prompts = [clone_prompt_for_request(query_result.inputs) for _ in range(args.num_prompts)]
# Inject output modalities if specified
output_modalities = None
if args.modalities is not None:
output_modalities = args.modalities.split(",")
for prompt in prompts:
prompt["modalities"] = output_modalities
# Create AsyncOmni
print(f"[Info] Creating AsyncOmni with stage_configs_path={args.stage_configs_path}")
async_omni = None
try:
async_omni = AsyncOmni(
model=args.model,
stage_configs_path=args.stage_configs_path,
log_stats=args.log_stats,
stage_init_timeout=args.stage_init_timeout,
)
# Use default sampling params from stage config (they are pre-configured
# in the YAML for each stage).
sampling_params_list = None
output_dir = args.output_dir
os.makedirs(output_dir, exist_ok=True)
# Run requests with concurrency control
semaphore = asyncio.Semaphore(args.max_in_flight)
request_timeout = getattr(args, "request_timeout_s", None)
stream_audio = getattr(args, "stream_audio_to_disk", False)
async def _run_one(idx: int, prompt: dict):
async with semaphore:
request_id = f"req_{idx}_{uuid.uuid4().hex[:8]}"
coro = run_single_request(
async_omni=async_omni,
prompt=prompt,
request_id=request_id,
sampling_params_list=sampling_params_list,
output_dir=output_dir,
output_modalities=output_modalities,
stream_audio_to_disk=stream_audio,
)
if request_timeout and request_timeout > 0:
return await asyncio.wait_for(coro, timeout=request_timeout)
return await coro
wall_start = time.perf_counter()
tasks = [_run_one(i, p) for i, p in enumerate(prompts)]
all_results = await asyncio.gather(*tasks, return_exceptions=True)
wall_end = time.perf_counter()
# Print summary
print("\n" + "=" * 60)
print("Summary")
print("=" * 60)
success_count = 0
total_audio_dur = 0.0
for r in all_results:
if isinstance(r, Exception):
print(f" [ERROR] {type(r).__name__}: {r}")
else:
success_count += 1
total_audio_dur += r.get("audio_duration_s", 0.0)
print(f" [{r['request_id']}] e2e={r['e2e_latency_s']:.3f}s files={r['saved_files']}")
wall_time = wall_end - wall_start
print(f"\nTotal: {success_count}/{len(prompts)} succeeded")
print(f"Wall time: {wall_time:.3f}s")
if total_audio_dur > 0:
print(f"Total audio duration: {total_audio_dur:.2f}s")
print(f"Real-time factor: {total_audio_dur / wall_time:.2f}x")
print("=" * 60)
finally:
if async_omni is not None:
async_omni.shutdown()
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args():
parser = FlexibleArgumentParser(
description=(
"Offline inference with async_chunk enabled via AsyncOmni. "
"Downstream stages start before upstream stages finish, "
"achieving true stage-level concurrency."
)
)
parser.add_argument(
"--model",
type=str,
default="Qwen/Qwen3-Omni-30B-A3B-Instruct",
help="Model name or path.",
)
parser.add_argument(
"--query-type",
"-q",
type=str,
default="use_audio",
choices=query_map.keys(),
help="Query type.",
)
parser.add_argument(
"--stage-configs-path",
type=str,
default=_default_async_chunk_stage_configs_path(),
help=(
"Path to an async_chunk stage config YAML. "
"If not set, uses the model's default config "
"(make sure it has async_chunk: true)."
),
)
parser.add_argument(
"--log-stats",
action="store_true",
default=False,
help="Enable writing detailed statistics.",
)
parser.add_argument(
"--stage-init-timeout",
type=int,
default=300,
help="Timeout for initializing a single stage (seconds).",
)
parser.add_argument(
"--output-dir",
type=str,
default="output_audio_async_chunk",
help="Directory to save output files.",
)
parser.add_argument(
"--num-prompts",
type=int,
default=1,
help="Number of prompts to generate (duplicated from query).",
)
parser.add_argument(
"--txt-prompts",
type=str,
default=None,
help="Path to a .txt file with one prompt per line.",
)
parser.add_argument(
"--max-in-flight",
type=int,
default=1,
help="Maximum concurrent requests (default: 1).",
)
parser.add_argument(
"--request-timeout-s",
type=float,
default=None,
help=(
"Per-request timeout in seconds. When set, a request that "
"exceeds this duration is cancelled and reported as an error. "
"Default: None (no timeout)."
),
)
parser.add_argument(
"--batch-timeout-s",
type=float,
default=None,
help=(
"Global timeout for the entire batch in seconds. When set, "
"the whole run_all() is cancelled if it exceeds this duration. "
"Default: None (no global timeout)."
),
)
parser.add_argument(
"--stream-audio-to-disk",
action="store_true",
default=False,
help=(
"Write audio chunks to WAV incrementally instead of "
"accumulating in memory. Useful for very long audio or "
"high --max-in-flight to reduce memory footprint."
),
)
parser.add_argument(
"--modalities",
type=str,
default=None,
help="Comma-separated output modalities filter (e.g. 'text', 'audio', 'text,audio').",
)
parser.add_argument(
"--audio-path",
"-a",
type=str,
default=None,
help="Path to local audio file.",
)
parser.add_argument(
"--image-path",
"-i",
type=str,
default=None,
help="Path to local image file.",
)
parser.add_argument(
"--video-path",
"-v",
type=str,
default=None,
help="Path to local video file.",
)
parser.add_argument(
"--num-frames",
type=int,
default=16,
help="Number of frames to extract from video.",
)
parser.add_argument(
"--sampling-rate",
type=int,
default=16000,
help="Sampling rate for audio loading.",
)
return parser.parse_args()
if __name__ == "__main__":
args = parse_args()
async def _main():
batch_timeout = getattr(args, "batch_timeout_s", None)
if batch_timeout and batch_timeout > 0:
await asyncio.wait_for(run_all(args), timeout=batch_timeout)
else:
await run_all(args)
try:
asyncio.run(_main())
except asyncio.TimeoutError:
print(
f"\n[TIMEOUT] Batch exceeded --batch-timeout-s="
f"{args.batch_timeout_s}s. AsyncOmni shutdown was handled "
f"by finally block."
)
except KeyboardInterrupt:
print("\nInterrupted by user. AsyncOmni shutdown was handled by finally block.")
@@ -0,0 +1,23 @@
#!/bin/bash
# Run multiple Qwen3-Omni requests with async_chunk enabled.
#
# Uses AsyncOmni with --max-in-flight to control request-level
# concurrency (each request still gets true stage-level concurrency
# via async_chunk).
#
# Usage:
# bash run_multiple_prompts_async_chunk.sh
# bash run_multiple_prompts_async_chunk.sh --max-in-flight 4
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
python "${SCRIPT_DIR}/end2end_async_chunk.py" \
--query-type text \
--txt-prompts "${SCRIPT_DIR}/text_prompts_10.txt" \
--stage-configs-path "${REPO_ROOT}/vllm_omni/model_executor/stage_configs/qwen3_omni_moe_async_chunk.yaml" \
--output-dir output_audio_async_chunk \
--max-in-flight 2 \
"$@"
@@ -0,0 +1,26 @@
#!/bin/bash
# Run a single Qwen3-Omni request with async_chunk enabled.
#
# This uses AsyncOmni (async orchestrator) so that downstream stages
# (Talker, Code2Wav) start *before* stage-0 (Thinker) finishes,
# achieving true stage-level concurrency via chunk-level streaming.
#
# Prerequisites:
# - An async_chunk stage config YAML (e.g. qwen3_omni_moe_async_chunk.yaml)
# - Hardware matching the config (e.g. 2x H100 for the default 3-stage config)
#
# Usage:
# bash run_single_prompt_async_chunk.sh
# bash run_single_prompt_async_chunk.sh --query-type text --modalities text
# bash run_single_prompt_async_chunk.sh --stage-configs-path /path/to/custom.yaml
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
python "${SCRIPT_DIR}/end2end_async_chunk.py" \
--query-type use_audio \
--stage-configs-path "${REPO_ROOT}/vllm_omni/model_executor/stage_configs/qwen3_omni_moe_async_chunk.yaml" \
--output-dir output_audio_async_chunk \
"$@"