vLLM · 官方博客

vLLM-Omni 中服务多阶段 Qwen3-Omni 的经验与教训

Experience and Lessons Learned from Serving Multi-Stage Qwen3-Omni in vLLM-Omni

二〇二六年七月三日 · 英文原文

vLLM-Omni 将 Qwen3-Omni 的多模态理解与语音生成拆分为 Thinker、Talker、Code2Wav 三阶段 pipeline 进行服务。通过阶段级 batching、CUDA Graphs、async chunk 交接、async output、stage replicas 和 hot-path cleanup 等优化,在 Seed-TTS en 数据集上使用 Qwen3-Omni-30B-A3B-Instruct 模型、并发度 64 的 benchmark 中,请求吞吐量从 2.2 req/s 提升至 11.7 req/s,平均音频 TTFP 从 5884 ms 降至 632 ms,平均音频 RTF 从 1.15 降至 0.47。

Qwen3-Omni 将多模态理解与语音生成结合在一起。本文解释了 vLLM-Omni 如何将其作为分阶段 pipeline 进行服务,并针对在线工作负载优化每个阶段。

TL;DR

vLLM-Omni 的 Qwen3-Omni 服务栈包括:

快速开始

当使用 --omni 服务模型时,默认的 Qwen3-Omni 部署配置会自动解析:

vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct \
  --omni \
  --port 8091

如需显式配置,请传递分阶段部署配置:

vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct \
  --omni \
  --port 8091 \
  --deploy-config vllm_omni/deploy/qwen3_omni_moe.yaml

捆绑的配置包含一个 platforms: 部分。vLLM-Omni 会检测运行时后端(CUDA、NPU、ROCm 或 XPU)并自动合并匹配的增量——无需额外的 CLI 标志。相同的启动命令可跨硬件工作。

请求应使用 /v1/chat/completions。在请求体中设置 modalities 以声明输出类型——例如,仅文本使用 ["text"],或文本加语音使用 ["text", "audio"]

有关部署选项、async-chunk 设置和多副本布局,请参阅 Qwen3-Omni 在线服务指南

Qwen3-Omni 服务模型

纯文本 LLM 服务是一个循环:prefill、decode、detokenize。Qwen3-Omni 在多模态推理之后增加了两个语音阶段,每个阶段具有不同的计算特性:

Thinker   -> 多模态理解 + 文本生成
Talker    -> 隐藏状态和 embedding 到 RVQ 编解码器码
Code2Wav  -> 编解码器码到波形音频

图 1:vLLM-Omni 中的 Qwen3-Omni 服务是一个分阶段的数据流:Thinker 产生文本和隐藏状态,Talker 产生编解码器码,Code2Wav 重建音频。

图 1:vLLM-Omni 中的 Qwen3-Omni 服务是一个分阶段的数据流:Thinker 产生文本和隐藏状态,Talker 产生编解码器码,Code2Wav 重建音频。

优化概览

Qwen3-Omni pipeline 的不同部分会遇到不同的瓶颈。vLLM-Omni 不应用单一的固定方案;每个优化都针对特定的阶段或交接。下面的逐步讲解依次介绍每个优化——按照我们验证的顺序——并依次回答三个问题:为什么问题存在,为什么有效(机制如何消除该问题),以及你获得了什么(可衡量的收益)。

技术 目标阶段/路径 解决的问题 主要收益
阶段分解 Thinker → Talker → Code2Wav 三个计算特性差异很大的阶段共享一个循环,因此单一的 batching/graph/设备策略会让最慢的子路径限制其余部分——限制吞吐量并阻止按阶段调优和扩展 每个阶段独立的运行时策略
AR + Code2Wav batching Talker MTP 路径,Code2Wav async chunks 单请求微工作在高并发下导致 SM 在启动之间空闲,限制 GPU 占用率和 req/s 更高的占用率和 req/s
CUDA Graph Thinker / Talker / Code2Wav decode 路径 每个 decode 步骤重复的 CPU 端 kernel 调度增加了 TPOT 并使音频 RTF 高于实时 更低的 TPOT 和音频 RTF;扫描中吞吐量提升约 4 倍
Async chunk Thinker→Talker, Talker→Code2Wav 全负载阶段屏障强制 Code2Wav 等待完整的 Talker 负载,延迟首音频并增加音频 TTFP 流水线化交接;最大的音频 TTFP 降低
Async omni output Thinker connector payloads 同步负载构建阻塞了 Thinker 在 chunk 之间的 decode worker,浪费 GPU 时间在分配上并降低吞吐量 吞吐量恢复,无音频 TTFP 回归
Stage replicas Talker, Code2Wav Talker 和 Code2Wav 饱和并排队,而 Thinker 仍有余量,在负载下成为尾部瓶颈 仅在瓶颈阶段进行水平扩展
Hot-path cleanup Talker code predictor, connector payloads 每步的 Python、分配和同步开销在长话语中累积,增加 E2EL 和音频 TTFP 更低的每步延迟;与上述所有层叠加

我们通过 Seed-TTS enQwen3-Omni-30B-A3B-Instruct10/160/320/640 个 prompt,并发度 1/16/32/645 次预热,三块可见 GPU 映射为 0/1/2)上的受控 benchmark 扫描验证了每一层。每个配置都使用隔离的部署配置重启服务器,并在前一行之上添加一个优化。BatchAsync output 将每个阶段固定在一块 GPU 上(Thinker / Talker / Code2Wav 分别在 GPU 0 / 1 / 2 上,各一个副本);Stage replicas 行将 Thinker 保留在 GPU 0 上,并在 GPU 1 和 2 上运行 2× Talker + 2× Code2Wav。下表总结了并发度 64 的情况;验证结果 图表显示了所有四个并发度级别。

步骤 添加的配置 Talker / Code2Wav 副本数 Req/s 平均音频 TTFP 平均音频 RTF
Baseline Batch 1 / 1 2.2 5884 ms 1.15
+ CUDA Graph 在 Thinker、Talker、Code2Wav 上捕获 Graph 1 / 1 8.6 (+299%) 2790 ms (−53%) 0.59 (−49%)
+ Async chunk Async-chunk 阶段交接 1 / 1 9.3 (+8%) 655 ms (−77%) 0.63
+ Async output Async omni output 路径 1 / 1 11.3 (+22%) 631 ms (−4%) 0.47 (−25%)
+ Stage replicas 2× Talker + 2× Code2Wav 2 / 2 11.7 (+4%) 632 ms 0.47

图 2:Qwen3-Omni 的性能来自于同时优化分阶段数据流、阶段运行时和 decode 热路径。

图 2:Qwen3-Omni 的性能来自于同时优化分阶段数据流、阶段运行时和 decode 热路径。

优化栈,逐阶段分析

每个优化都建立在之前的基础上,因此每个步骤中报告的数字都假设其上的所有层都已启用。

1. 阶段分解与 Batching:Baseline

为什么。 Qwen3-Omni 不是一个同质的 decode 循环:Thinker 进行多模态 AR 文本生成,Talker 运行编解码器预测器 AR 路径,Code2Wav 运行并行声码器 decode。将这三个差异很大的工作负载折叠到单个服务路径中,会强制它们使用相同的 batching 策略、graph 策略和设备布局——并让最慢的子路径限制其余部分。分离阶段消除了这种耦合,但它暴露了第二个问题:语音路径仍然将其大部分 GPU 时间花在单请求微工作上。每个 Talker decode 步骤是一个短的 code-predictor 前向传播,每个 Code2Wav chunk 是一个小的 vocoder 前向传播,因此在并发度 64 时,一次一个请求地运行它们会使 SM 在启动之间空闲,并且永远无法摊销固定的每步成本。

为什么有效。 这依次解决了两个问题。首先,阶段分解打破了耦合:阶段边界成为一等服务对象,connector 定义了每个边界上传递的内容(隐藏状态、embedding、编解码器码、chunk 元数据),调度器可以在每个阶段的独立关键路径上调度、批处理和 graph 化——因此没有单一策略被强加给所有三个阶段,最慢的子路径不再限制其余部分。其次,每阶段 batching 弥补了空闲 SM 的差距:将并发请求收集到一个 Talker MTP 调用和一个 Code2Wav 前向传播中,填满了单请求微工作留下的空闲 SM,并将固定的每步成本分摊到整个 batch 上。

你获得了什么。 显式的阶段让 vLLM-Omni 将每个组件视为独立的运行时——单独的 max_num_seqs、采样参数、connector、graph/eager 策略和可选的副本——这是下面每个优化的先决条件。这个经过批处理和阶段分解的配置是 Batch baseline,后续每一行都建立在此之上。

2. CUDA Graph:每阶段 Decode 捕获

为什么。 Batching 提高了占用率,但每个 decode 步骤仍然付出了重复的 CPU 端 kernel 调度代价。Qwen3-Omni 运行三个 decode 密集型阶段;仅 Talker 每个话语就可能执行数百个短步骤,并且每个步骤之前都从 Python 重新启动相同的稳定算子序列。在并发度 64 时,即使经过 batching,这种启动开销也主导了 TPOT 并使音频 RTF 保持在实时以上。

为什么有效。 CUDA Graph 消除了主导 TPOT 的每步 kernel 调度:它一次捕获一个固定的算子序列,并以最少的 CPU 工作重放它。每个阶段有不同的捕获点,但原理相同:decode 形状被分桶为稳定的 (batch, seq, frames) 配置,因此运行时在预热时记录 graph 并在热路径上重用它。

图 3:每个阶段在不同的点捕获 graph。Thinker 和 Talker 在 vLLM 的外部 graph 下 decode;Talker 的内部 code predictor 使用 torch.compile 而不是第二个 graph;Code2Wav 使用内部的 CUDAGraphDecoderWrapper。

图 3:每个阶段在不同的点捕获 graph。Thinker 和 Talker 在 vLLM 的外部 graph 下 decode;Talker 的内部 code predictor 使用 torch.compile 而不是第二个 graph;Code2Wav 使用内部的 CUDAGraphDecoderWrapper。

阶段 0 — Thinker:vLLM 外部 decode graph

Thinker 是一个自回归多模态阶段(LLM_AR)。当 enforce_eager 为 false 时,它在 decode 路径上使用 vLLM 的标准 CUDA Graph 捕获——与纯文本 LLM 服务相同的机制。这消除了在长 Thinker 生成过程中重复的 CPU 端 kernel 调度。

阶段 1 — Talker:外部 decode graph + 编译的 code predictor

enforce_eager: false 时,Talker 阶段也通过 vLLM 的外部 CUDA Graph 路径运行。每个 Talker decode 步骤还会调用 code predictor——一个短的重新 prefill transformer,用于发射 RVQ 编解码器码。该内部路径被单独优化:

当显式启用时,可选的 prefix-graph 桶(connector 配置中的 code_predictor_prefix_graphs)可以捕获额外的稳定 predictor 形状。

阶段 2 — Code2Wav:内部 vocoder graph

Code2Wav 是一个生成阶段(LLM_GENERATION),而不是 AR 循环。它的 graph 路径是一个内部 CUDAGraphDecoderWrapper,而不是 vLLM 的外部包装器:

# 在阶段 enforce_eager 为 false 时的权重加载期间启用
self.code2wav.enable_cudagraph(
    codec_chunk_frames=chunk_frames,
    codec_left_context_frames=left_frames,
)

来自 connector 配置的形状分桶。 在预热之前,包装器从阶段 connector 配置中读取 codec_chunk_framescodec_left_context_frames。捕获枚举了 async-chunk 和全负载 decode 在运行时将命中的 (batch, num_quantizers, frames) 桶——包括来自 initial_codec_chunk_frames 的较小第一个 chunk。

Vocoder 预热。 precompute_snake_caches() 在 graph 捕获之前运行,以便 SnakeBeta 激活不会在捕获的 decode 循环内部支付重复的设置开销。

Chunk 调度。 在 async-chunk 模式下,chunked_decode_streaming 将稳定的 chunk 委托给 _cudagraph_wrapper.chunked_decode_with_cudagraph;当形状匹配捕获的桶时,全负载路径使用包装器的批处理 decode 入口点。

你获得了什么。 在 benchmark 扫描中为所有三个阶段启用 CUDA Graph 将 req/s 从 2.2 提高到 8.6(+299%),将平均音频 TTFP 从 5884 ms 降低到 2790 ms,并将平均音频 RTF 从 1.15 降低到 0.59。大部分收益来自于共同消除 Thinker 文本生成、Talker 编解码器 decode 和 Code2Wav vocoder 前向传播中的启动开销。

3. Async Chunk:流水线化阶段间交接

为什么。 CUDA Graph 使每个阶段更快,但 pipeline 仍然是屏障同步的:Talker 必须等到 Thinker 完成才能开始,Code2Wav 必须等到 Talker 累积了完整负载才能发射音频。因此,首音频延迟跟踪了完整的 Thinker 生成加上完整的 Talker prefill——即使只需要几个编解码器帧来产生第一个可听 chunk。

为什么有效。 Async chunk 用流水线化的部分交接取代了全负载屏障。Thinker 增量地发射 embedding 行;Talker 累积编解码器帧并在 initial_codec_chunk_frames / codec_chunk_frames 边界上切片它们;异步调度器将 chunk 传输与阶段计算重叠,因此每个阶段在前一个阶段仍在解码时就开始工作——首音频在几个编解码器帧后即可准备就绪,而不是在完整的 Thinker 生成加上 Talker prefill 之后。

图 4:没有 async chunk,每个阶段等待前一个阶段的完整负载,因此首音频跟踪完整的 Thinker 生成加上 Talker prefill。Async chunk 重叠部分交接,因此 Code2Wav 在仅几个编解码器帧后就开始发射音频。

图 4:没有 async chunk,每个阶段等待前一个阶段的完整负载,因此首音频跟踪完整的 Thinker 生成加上 Talker prefill。Async chunk 重叠部分交接,因此 Code2Wav 在仅几个编解码器帧后就开始发射音频。

你获得了什么。 Async chunk 是扫描中最大的音频 TTFP 收益:平均音频 TTFP 从 2790 ms(CUDA Graph)下降到 655 ms

4. Async Output:非阻塞负载构建

为什么。 Async chunk 流水线化了 Thinker→Talker→Code2Wav,但同步负载构建仍然可能阻塞 decode worker。如果 Thinker 必须在每个 chunk 边界上完全组装每个 connector 负载——复制 embedding 和隐藏状态——然后才能开始下一个 decode 步骤,那么即使阶段交接已经是增量的,GPU 时间也会浪费在 Python 调度上。

为什么有效。 async_omni_output 将负载构建与阶段交接解耦:Thinker 将 decode 状态交给一个非阻塞的输出路径并立即返回处理下一个 token,而 connector 异步地组装和发送 chunks。

图 5:async output 前后的 decode 步骤间隙。使用同步负载构建,GPU 在 Talker 步骤之间空闲约 2.8 ms;将负载组装移出 decode 路径后,步骤紧密排列,将步骤间间隙缩小到约 41 µs。

图 5:async output 前后的 decode 步骤间隙。使用同步负载构建,GPU 在 Talker 步骤之间空闲约 2.8 ms;将负载组装移出 decode 路径后,步骤紧密排列,将步骤间间隙缩小到约 41 µs。

你获得了什么。 在并发度 64 的 async chunk 之上,async output 将平均音频 TTFP 保持在 631 ms 附近,同时将平均音频 RTF 从 0.63 降低到 0.47

5. Stage Replicas:扩展 Talker 和 Code2Wav

为什么。 这三个阶段在负载下不会同等饱和。对于每个请求,Thinker 生成一次文本,但 Talker 和 Code2Wav 随后运行数百个短的 decode 步骤和 vocoder 前向传播以将该文本渲染为音频——在语音侧有更多持续的小步骤工作。因此,随着并发度增加,Talker 和 Code2Wav 首先饱和:在并发度 64 时,任何一个的单个副本都会成为尾部瓶颈,而 Thinker 仍有余量。扩展整个 pipeline 可以清除它,但会浪费内存来复制大型多模态 Thinker。

为什么有效。 复制仅向饱和的阶段添加容量,而不是整个 pipeline。GPU 0 上的单个 Thinker 为分布在 GPU 1 和 2 上的 2× Talker 和 2× Code2Wav 副本提供数据:额外的副本吸收了语音侧积压的工作,而重型多模态阶段保持不重复。benchmark 部署配置通过以下方式启用此功能:

{
  "stage_overrides": {
    "1": {"num_replicas": 2, "devices": "1,2"},
    "2": {"num_replicas": 2, "devices": "1,2"}
  }
}

图 6:Async chunk 和 stage replicas 针对 pipeline 的语音生成侧,其中 Talker 和 Code2Wav 在并发负载下可能成为瓶颈。

图 6:Async chunk 和 stage replicas 针对 pipeline 的语音生成侧,其中 Talker 和 Code2Wav 在并发负载下可能成为瓶颈。

你获得了什么。 在 async output 之上添加副本,在并发度 64 时达到了 11.7 req/s——扫描中的最高吞吐量——同时将平均音频 TTFP 保持在 632 ms 附近,平均音频 RTF 保持在 0.47 附近。随着并发度增加,副本相对于单个 Talker/Code2Wav 的优势扩大,因为语音阶段是最先饱和的。

6. Hot-Path Cleanup:Talker Decode 和 Connector Payloads

为什么。 Batching、graph、async chunk 和副本消除了结构性的、框架级别的瓶颈——这些优化适用于大多数多模态服务 pipeline。剩下的是模型内部的问题:对 Talker decode 循环的分析仍然显示出一长串在每个步骤上重复的小成本,因此随话语长度而扩展——冗余的 connector 流量、构建负载时的每步 torch.cat 和 CPU 序列化、codec predictor 中的 Python 调度,以及下一个步骤立即需要在 GPU 上使用的 decode 状态的设备到主机读取。

为什么有效。 下面的每个修复都消除了其中一个重复成本——或削减了固定的每部署开销——而不改变音频输出:

仅 decode 的 connector 交接。 Chunk 0 仍然发送完整的 Thinker prefill;之后的每个 decode 步骤只发送新的 embed.decode 行,而不是重新传输完整的 prefill embedding 和隐藏状态张量。Connector 流量保持 O(1) 每步,而不是随 prompt 长度增长,并且每次交接避免了冗余的 CPU 序列化和跨阶段复制,这些在长话语的每个 Talker 步骤上都会重复。

单 GPU executor 默认值。 移除 distributed_executor_backend 的隐式 "mp" 默认值,允许单 GPU 部署使用 uni executor,并避免在每步延迟最重要的低并发路径上的多进程启动、IPC 和 worker 同步开销。

Connector 负载构建。 之前累积 Thinker 和 Talker 负载需要在每个 chunk 边界上支付重复的 torch.cat。按 token 传递 decode embedding 并修剪冗余组装消除了这种分配和复制工作。同一组更改还跳过了在请求的最终阶段已经是本地时构建下游 pooler/多模态 CPU 负载,避免了在不馈送另一个阶段的路径上的隐藏状态 D2H。

Talker code predictor 重写。 旧的路径通过 Hugging Face generate() 驱动非常短的 codec-predictor 序列,在每个 Talker 步骤上增加了 Python 调度、动态分配和 KV-cache 开销。重写使用带有 SDPA、原生 GQA、内联 top-k 采样、缓存模块引用和内部 transformer 上的 torch.compile 的重新 prefill。在 CUDA 上,这个编译路径位于 vLLM 的 Talker CUDA Graph 之下,而不是添加一个冲突的第二个 graph 层(参见 §2)。

GPU 驻留的 decode 状态和阶段本地快速路径。 中间张量如 hidden_states.lasthidden_states.trailing_textembed.tts_pad_projectedcodes.audio 保留在 model_intermediate_buffer 中,因此下一步在设备上重用它们,而不是强制进行设备到主机的往返,这会使每个 Talker 步骤序列化。Talker 和 Code2Wav 还跳过多模态 get_mrope_input_positions——它们只需要廉价的线性位置——并且 _store_value 在张量已经在 CPU 上时避免了冗余的 .to("cpu") 工作。

数值精度护栏。 这些重写不得降低音频质量,因此 RMSNorm 方差和 RoPE 保持在 fp32(epilogue_fusion=False),并且每次调用的 embedding 缓冲区避免跨请求别名——上述加速在此约束之上运行,而不是与之对抗。

你获得了什么。 Hot-path cleanup 消除了随话语长度扩展的开销。在一个长上下文的单请求测试中,E2EL 从 21.28 秒下降到 7.37 秒,音频 TTFP 从 3197 毫秒下降到 1796 毫秒,音频 RTF 从 0.71 下降到 0.28。这些更改与上述层叠加,并作为 DFX 性能套件 baseline 出现,而不是作为单独的扫描行。

验证结果

下面的图表绘制了与优化概览中总结的相同的 benchmark 扫描——Batch、CUDA Graph、Async chunk、Async output 和 Stage replicas——跨越所有四个并发度级别(1/16/32/64),每个都从 Batch baseline 开始。

图 7:在 c=1(橙色)、c=16(紫色)、c=32(绿色)和 c=64(红色)时的请求吞吐量(req/s)。Stage replicas 在 c=64 时达到 11.7 req/s,在 c=32 时达到 6.8 req/s,高于 2.2 req/s(Batch at c=64)。

图 7:在 c=1(橙色)、c=16(紫色)、c=32(绿色)和 c=64(红色)时的请求吞吐量(req/s)。Stage replicas 在 c=64 时达到 11.7 req/s,在 c=32 时达到 6.8 req/s,高于 2.2 req/s(Batch at c=64)。

图 8:在 c=1、16、32 和 64 时的平均音频 RTF。Batch 在负载下处于或高于实时(c=64 时 RTF 高达 1.15);async output 和 replicas 将 c=32/c=64 的 RTF 保持在约 0.47 或以下。

图 8:在 c=1、16、32 和 64 时的平均音频 RTF。Batch 在负载下处于或高于实时(c=64 时 RTF 高达 1.15);async output 和 replicas 将 c=32/c=64 的 RTF 保持在约 0.47 或以下。

图 9:在 c=1、16、32 和 64 时的平均音频 TTFP(毫秒,对数刻度)。Async chunk 将 c=64 的 TTFP 从约 5884 ms(Batch)降低到约 655 ms。

图 9:在 c=1、16、32 和 64 时的平均音频 TTFP(毫秒,对数刻度)。Async chunk 将 c=64 的 TTFP 从约 5884 ms(Batch)降低到约 655 ms。

综合解读扫描结果

跨三个扫描,结论是一致的:每一层针对不同的瓶颈,并且它们共同产生复合效应。

结论是,没有单一层承担全部收益:CUDA Graph 和 async chunk 主导了延迟降低,而 async output 和 stage replicas 在并发度 32 和 64 时增加了吞吐量余量。将它们堆叠起来,将一个在负载下勉强维持的 pipeline 转变为一个具有充足实时余量的 pipeline。

致谢

我们感谢 vLLM-Omni 中的 Qwen3-Omni 贡献者,包括 Haiyan Wu、Taichang Zhou、Canlin Guo、Ruirui Yang、Ziming Huang、Wengang Zheng、Lianhao Xu、Han Gao、Junhong Liu、Samit Huang、Hao Chen、Alex Brooks、Chenguang Zheng、Peiqi Yin、Wenjing Chen、Nick Cao、Shunyang Li、Yong Yang、Divyansh Singhvi、Yueqian Lin、Dayu Qiu、Roger Wang 和 Hongsheng Liu,感谢他们的贡献和反馈。


参考文献

源码与配置

优化 Pull Requests

如果您对 Qwen3-Omni 服务或全模态推理感兴趣,请加入 vLLM Slack#vllm-omni 频道,或在 vLLM-Omni GitHub 上提交 issue。

译自 vLLM · 官方博客 · 录于 二〇二六年七月三日