Skip to main content

07 - 性能瓶颈与优化案例

回到 07 篇开头那个现场:并发拉到 64,吞吐不涨、延迟翻倍、GPU 利用率 40%。

那时候只能给出「三种计算合不来」这个方向性的解释。现在架构讲完了,可以真的把它拆开看 —— 那 910 毫秒到底花在了哪一段上。

这一篇是五个真实案例,都来自 SGLang-Omni 仓库里可核对的记录。每个案例走同一条路:看到了什么现象 → 怎么定位 → 改了什么 → 收益多少 → 代价是什么

选这五个是因为它们凑齐了多模态服务瓶颈的典型形态 —— 注意没有一个是「算力不够」:

#瓶颈的真身表面看起来像
1排队算力不够(就是《多模态推理》06 篇那个拐点)
2发指令太慢GPU 慢(其实算得飞快,每次只算 5 ms 就得等主机发下一条)
3串行等待步数太多(其实是主机整理上一步结果时 GPU 干等)
4藏在缓存键里的一次同步无(它自己不慢,但把前两项优化全堵死了)
5攒了批却没真批批不够大(其实收集了 4 个请求然后一个一个算)
第一个案例里有一组不好看的数字

同一个模型、同一台机器,拿另一套服务栈做对照,对照组在每个并发档都更快,最多快 3.27 倍

这组数字是 SGLang-Omni 自己写在自己仓库里的。后面会讲为什么这么写反而是对的。

一、案例一:ASR 的并发拐点

症状:Qwen3-ASR-1.7B 在 H100 上,吞吐随并发上升到 32 就停住了,并发 64 时吞吐不再涨(103 req/s 对 108 req/s),延迟均值却从 295 ms 翻到 610 ms,还开始丢 1%~4% 的请求。

第一个判断:GPU 利用率在所有并发档都只有 33%~54%。GPU 从来不是约束。这一条决定了后面所有的排查方向 —— 不去看 kernel 选择、不去调注意力后端,而是去看主机侧。

阶段分解是决定性的证据:

每请求阶段耗时分解(SeedTTS 英文集,单位毫秒,条长按比例)请求构建构建到入队入队到被调度首次前向(编码器+prefill)解码尾段并发 1总 91 ms并发 8总 196 ms并发 16总 259 ms并发 32总 472 ms并发 64910 ms读法:红色那段从并发 32 的 5.9 ms 跳到并发 64 的 412.8 ms,整个拐点就是它。原因是运行请求上限 32、构建积压上限 16,64 深的闭环直接溢出。青色那段(解码尾段)从 55 ms 一路涨到 393 ms,同时 GPU 利用率在下降 —— 这是主机侧每步派发开销随批增大而变重的典型特征,不是 GPU 算不过来。琥珀色那段每次准入都要花 28~46 ms,而它跑在调度器线程和默认流上:每接纳一个新请求,整个运行中的解码批都要停这么久。
三个瓶颈的优先级由这张图直接给出:先解准入上限(红),再压主机侧派发(青),最后把编码器挪出调度器线程(琥珀)。注意琥珀那段虽然绝对值不大,但它的破坏性是「阻塞所有人」,跟前两个的性质不同。

同一个团队在 ASR 上还给了另一份更细的分解 —— 按音频时长和并发交叉切:

ASR 推理时间按阶段分解:横轴为编码器、LLM 预填充、自回归解码三段占比,分 5 秒、60 秒、20 分钟三种音频长度与并发 1、4、16 三档
出处:sglang-omni docs/_static/image/moss-td-profiling.svg。读法:音频越长,解码越不重要 —— 20 分钟音频在并发 1 时 95% 的时间花在编码器和预填充上,而 5 秒音频在并发 16 时解码占到 32%。这意味着「ASR 该优化哪一段」没有统一答案,取决于你的音频时长分布。

对应的优化落点也是官方画好的:

ASR 推理优化总览:编码器挂 LRU 缓存与 CUDA Graph,分块预填充建 KV,自回归解码挂 CUDA Graph、异步解码与流式输出
出处:sglang-omni docs/_static/image/moss-td-optimization.svg。三段各挂各的优化:编码器靠缓存与图捕获,预填充靠分块,解码靠图捕获加一步前瞻。

诚实的部分:同一份数据里还有一组对照 —— 同一个 checkpoint、同一台机器上另一套固定的 OpenAI 兼容服务栈,两边都没调优、背靠背跑。结果是对照组在每个并发档都更快,并发 1 快 1.42 倍,并发 64 快 3.27 倍,而且 64 时一条不丢。这份文档把这组数字原样写在了自己的仓库里,并把差距拆成两部分:一部分是固定的每请求服务开销(并发 1 就存在),一部分随并发增长(准入上限和主机派发)。

这个做法值得单独说:性能文档最大的价值不是证明自己快,而是给出一个可复现的、承认差距的基线,让后续每一个优化都有可衡量的对象。后面四个案例的收益,都是相对这类基线量出来的。

二、案例二:流式声码器的有状态 CUDA Graph

症状:MOSS-TTS-Local 在并发 8 下,流式声码器占掉单请求墙钟时间的 58%~63%。

定位:每帧的 codec 解码算得很少但 kernel 数很多,是典型的启动开销主导。实测 T=5(占全部步数约 38% 的最常见形状)时,每步 65.8 ms 里绝大部分是启动而非计算。

手段:把这个解码步捕成 CUDA Graph 回放。难点在于这个解码是有状态的 —— 每个槽位有自己的因果偏移和注意力缓存,而 CUDA Graph 要求捕获期间地址稳定。前置条件是把注意力缓存的写入改成原地写(地址固定),这一改动本身是从另一个项目移植过来的 codec 修复。

结果:T=5 时每步 65.8 → 30.7 ms(2.14 倍);并发 8 下声码器耗时降 40%、端到端 p50 降 22%、吞吐 +16%~+29%;输出逐位一致,14 组样本最大差值 0

代价与取舍

  • 按 T 分桶捕获图要吃显存:实测选的那组常见 T 值加上限,覆盖约 87%、占 10.99 GB。
  • 拒绝了补零方案。补零能让图的数量少很多,但会破坏逐位一致 —— 团队选择了「多占显存但保证等价」。
  • 默认开启,但所有失败路径都退回 eager,可以用 cuda_graph: false 关掉。

三、案例三:一步前瞻的异步解码

症状:多码本 AR 循环里,每一步都要在 Python 侧收集 C 组码本结果,这段主机工作和 GPU 前向是串行的。

手段:把 ModelRunner.execute() 劈成两半 —— execute_launch 负责入队前向、在 GPU 上采样、把收集用的快照异步拷回主机的固定内存乒乓缓冲;execute_resolve 查 CUDA event 然后做主机侧收集。调度循环变成「先 launch(N),再 resolve(N−1)」。

同一段解码,两种排法同步GPU前向 1空闲前向 2空闲前向 3主机等待收集 1等待收集 2三步走完用了 5 个时隙,GPU 有 2 个时隙在空转一步前瞻GPU前向 1前向 2前向 3前向 4主机收集 1收集 2收集 3同样三步只用 3 个时隙,GPU 时间线被填满代价:多一份固定内存的乒乓缓冲,结果晚一步才可用批小于 2 时走原来的同步路径 —— 批为 1 时这个优化按设计就是打平的,没必要为它引入额外复杂度和风险。正确性验收:批 1 和批 4 各 100 组贪心采样,output_codes 关掉和打开逐位一致。实测收益(SeedTTS 英文全集,并发 16):吞吐 +12.7%,平均延迟 −16.1%,RTF p99 −39.1%。
「批 1 按设计打平」这个说法比「批 1 没有收益」准确得多:批 1 时主机收集的绝对时间很短,重叠不出什么东西,所以直接走快路径。给优化划出「它不适用的区间」并显式绕过,比让它在所有情况下都生效然后祈祷不出问题更稳。

四、案例四:把 radix 缓存键搬上 GPU

这个案例最能说明「多模态自回归的瓶颈藏在什么地方」—— 它堵住的不是算力,是另外两项优化的前提

先看改之前每解一帧发生了什么:

改之前 · 每一帧都要往主机跑一趟解出一帧13 通道,在显存里拷到主机GPU → CPU 同步blake2b 哈希在 CPU 上算拷回显存CPU → GPU每帧一次,一秒几十次这一次主机同步,同时堵死了 CUDA Graph 捕获 和 异步解码的一步前瞻真正的代价改之后 · 全程不离开显存解出一帧13 通道,在显存里int64 张量运算,13 步定系数多项式(Horner)乘 · 加 · 取余 · where,通道数是编译期常量键,仍在显存里没有任何往返图捕获与异步解码这才用得上提示词路径不动,仍用 blake2b —— 它一次请求只跑一次,不在解码热路径上,也永远不会进捕获区。这条边界是刻意划的。代价:换了哈希算法,「两边跑同一个 blake2b」的对拍前提没了,所以验证必须拆成两层,见下。
这张图的重点在中间那条琥珀色横条:那次主机同步本身只花几十微秒,但它把案例二(CUDA Graph)和案例三(一步前瞻)两条路同时堵死了。找瓶颈时,「它自己花多少时间」和「它挡住了什么」是两个问题,后者经常大得多。

这个案例最能说明「多模态 AR 的瓶颈藏在什么地方」。

症状:MOSS-TTS-Local 每解出一帧,就要往请求的 KV 链上追加一个 radix 缓存的 token id。原来的做法复用了提示词路径的辅助函数:把这一帧的行张量搬到主机(row.numpy().tobytes()),用 blake2b 哈希。

在提示词路径上这没问题 —— 一次请求跑一次,在任何图捕获区之外。但在生成路径上,它每个解码步都要跑一次,而且跑在刚刚由 GPU 产出的张量上。于是每帧一次 GPU→CPU 同步 + 主机哈希 + CPU→GPU 上传。这个同步既阻塞 CUDA Graph 捕获,也阻塞异步解码的一步前瞻,也就是把上面案例二和案例三两条路都堵死了。

为什么必须哈希整行:文本通道对每个续接帧都是同一个 assistant 槽位 id,只哈希它的话,两个提示词相同的请求会互相前缀命中到对方已生成的音频区。所以键必须覆盖完整的 13 通道行(文本 + 12 个 RVQ 码)。

手段:改成纯 int64 torch 运算的定系数多项式(Horner)哈希,通道数是静态的 13,for 循环在捕获期展开成固定的算子序列:

acc = 0
for channel in range(C): # C = 13,编译期常量,会被展开
acc = (acc * BASE + row[:, channel]) % MOD
key = acc % HASH_SPACE # 续接帧:折进普通 id 区间
key = audio_end_id # EOS 行:保留原始结束 id(torch.where 选择)

四个设计点,每一个都不能省:

  • MOD = 2³¹ − 1(梅森素数),BASE = 1000000007。两者都小于 2³¹,所以每一步 Horner 的中间值小于 2⁶²,稳稳落在有符号 int64 里 —— 算术是精确的,CPU 和 GPU 上逐位可复现,不依赖任何实现定义的溢出行为。这一点有一个对拍纯 Python 大整数参考实现的测试来钉住。
  • HASH_SPACE = 151643<|endoftext|> 开启特殊 id 段的位置。续接帧的键严格折在这之下,EOS 行保留真实的 audio_end id 落在特殊段里,这样 SGLang 的词表边界结束检查仍然能正常触发。
  • 只改生成路径,提示词路径原样保留 blake2b。边界是刻意划的:提示词哈希不在解码热路径上,也永远不会进捕获区。
  • 只用逐元素 int64 运算(乘、加、取余、where),没有 .cpu().item().tolist()、numpy 往返,没有数据依赖的控制流,没有动态形状。因此它不引入主机同步,是捕获安全的。

碰撞分析被单独写了一节,结论是:这个键只影响 radix 缓存的命中与否,永远不改变采样输出。折叠后的空间是 151643,n 个共存的生成行落在同一前缀上的期望冲突数约 n²/(2×151643) —— n=75 时小于 0.02。而且 Horner 是次序敏感的(通道换序会改键),单通道 ±1 会让键跳变一个 BASE 的幂次,结构相近的行不会聚簇。

验证被拆成两层,这是本案例最值得学的部分。因为换了哈希算法,「两边都跑同一个 blake2b」这个对拍前提不成立了,所以:

  1. 输出层(逐位一致)audio_codes / output_ids 由采样产生,不依赖 radix 键的取值,因此改动前后必须逐位相同。用固定随机种子的门禁测试和图-eager 对拍哨兵来钉。
  2. 键层(算法性质):确定性、碰撞行为、EOS 保留、输出值域、dtype/device 跟随,全部作为 CPU 单元测试覆盖。

输出层保证行为不变,键层保证哈希自身的契约成立,两层互不依赖。 换掉一个「不影响输出、只影响缓存」的组件时,这个拆法是通用的。

五、案例五:声码器批解码的长度分桶

症状:Higgs 的声码器阶段虽然会收集最多 4 个请求,但仍然逐个串行解码。

手段:接上真正的 batch_compute_fn,利用 codec 原生的 [B, N, T] → [B, 1, L] 批解码。

关键约束不能把不等长的码本张量补零后堆在一起 —— 非因果的 DAC 解码器会把补零的短样本解坏。所以只能按完全相同的 T 分桶,同长的才堆。

结果:并发 16 下吞吐 +7.8%、p99 延迟 −20%,并发 1 中性,质量无回退。

为什么这里的批处理是干净的收益:声码器不像编码器那样跟 AR 引擎抢同一个瓶颈资源。编码器的批处理会挤占 AR 段的时间,而声码器在 AR 之后,AR 是瓶颈时它本来就在等 —— 在等待期间把活攒起来一起干,不占用任何本来能用来推进瓶颈的资源。判断一个批处理优化值不值,先看它加在瓶颈的上游还是下游。

六、五个案例的共同特征

五个案例讲完了,横过来看比竖着看更有价值 —— 它们的瓶颈类型、验收标准、以及留没留退路,呈现出很一致的模式。

案例真正的瓶颈验收标准留没留退路
ASR 并发拐点准入上限 + 主机派发吞吐曲线不再在 32 处折断基线可复现,含对照组
流式声码器图捕获kernel 启动开销逐位一致,14/14 最大差 0默认开,失败退 eager,可关
一步前瞻解码主机收集与前向串行逐位一致,批 1 与批 4 各 100 组批小于 2 走同步快路径
GPU radix 哈希每帧一次主机同步输出层逐位 + 键层单测,两层独立提示词路径保持原实现不动
声码器批解码攒了批却仍串行解质量无回退,吞吐与 p99按精确长度分桶,不补零
三条能带走的经验

先看 GPU 利用率再决定往哪查。 五个案例里四个的瓶颈在主机侧,一上来就调 kernel 一个都找不到。

「不改变输出」的优化,验收标准就该是逐位一致,而不是听感或指标持平 —— 后者会放过真 bug。

每个优化都要有关掉它的办法。 默认开启加失败自动回退,比「相信它不会出问题」现实得多。

七、选型

最后落到选型上:

场景建议理由
只有文本 LLMSGLang / vLLM 主线多阶段带来的全是成本
VLM(图进文出)主线 + 编码器缓存 + 编码器 DP结构是「编码器 + LLM」,单进程装得下
图像流量很大的 VLM主线 + EPD 分离编码器成为瓶颈时才值得拆
ASR(单段结构)主线或 SGLang-Omni 都可本质也是编码器 + LLM;选后者是为了跟 TTS 复用运维
TTS / 音乐生成SGLang-OmniAR 段与声码器的调度需求根本冲突,必须分阶段
Omni 语音对话SGLang-Omni多终点、跨阶段流式、分布式中止,单进程接不住
图像 / 视频生成SGLang diffusion 一侧批处理语义与 LLM 完全不同,跟 LLM 混部没有收益

一条通用判据:看这个请求里有几种「一步」的定义。只有一种,用主线;有两种以上且它们的调度需求冲突,才需要多阶段。

下一篇08 - 运行时性能排查方法论,把本篇这些案例背后的排查过程整理成一套可复用的流程。