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 选择、不去调注意力后端,而是去看主机侧。
阶段分解是决定性的证据:
同一个团队在 ASR 上还给了另一份更细的分解 —— 按音频时长和并发交叉切:
docs/_static/image/moss-td-profiling.svg。读法:音频越长,解码越不重要 —— 20 分钟音频在并发 1 时 95% 的时间花在编码器和预填充上,而 5 秒音频在并发 16 时解码占到 32%。这意味着「ASR 该优化哪一段」没有统一答案,取决于你的音频时长分布。对应的优化落点也是官方画好的:
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)」。
四、案例四:把 radix 缓存键搬上 GPU
这个案例最能说明「多模态自回归的瓶颈藏在什么地方」—— 它堵住的不是算力,是另外两项优化的前提。
先看改之前每解一帧发生了什么:
这个案例最能说明「多模态 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_endid 落在特殊段里,这样 SGLang 的词表边界结束检查仍然能正常触发。- 只改生成路径,提示词路径原样保留 blake2b。边界是刻意划的:提示词哈希不在解码热路径上,也永远不会进捕获区。
- 只用逐元素 int64 运算(乘、加、取余、
where),没有.cpu()、.item()、.tolist()、numpy 往返,没有数据依赖的控制流,没有动态形状。因此它不引入主机同步,是捕获安全的。
碰撞分析被单独写了一节,结论是:这个键只影响 radix 缓存的命中与否,永远不改变采样输出。折叠后的空间是 151643,n 个共存的生成行落在同一前缀上的期望冲突数约 n²/(2×151643) —— n=75 时小于 0.02。而且 Horner 是次序敏感的(通道换序会改键),单通道 ±1 会让键跳 变一个 BASE 的幂次,结构相近的行不会聚簇。
验证被拆成两层,这是本案例最值得学的部分。因为换了哈希算法,「两边都跑同一个 blake2b」这个对拍前提不成立了,所以:
- 输出层(逐位一致):
audio_codes/output_ids由采样产生,不依赖 radix 键的取值,因此改动前后必须逐位相同。用固定随机种子的门禁测试和图-eager 对拍哨兵来钉。 - 键层(算法性质):确定性、碰撞行为、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。
③ 每个优化都要有关掉它的办法。 默认开启加失败自动回退,比「相信它不会出问题」现实得多。
七、选型
最后落到选型上:
| 场景 | 建议 | 理由 |
|---|---|---|
| 只有文本 LLM | SGLang / vLLM 主线 | 多阶段带来的全是成本 |
| VLM(图进文出) | 主线 + 编码器缓存 + 编码器 DP | 结构是「编码器 + LLM」,单进程装得下 |
| 图像流量很大的 VLM | 主线 + EPD 分离 | 编码器成为瓶颈时才值得拆 |
| ASR(单段结构) | 主线或 SGLang-Omni 都可 | 本质也是编码器 + LLM;选后者是为了跟 TTS 复用运维 |
| TTS / 音乐生成 | SGLang-Omni | AR 段与声码器的调度需求根本冲突,必须分阶段 |
| Omni 语音对话 | SGLang-Omni | 多终点、跨阶段流式、分布式中止,单进程接不住 |
| 图像 / 视频生成 | SGLang diffusion 一侧 | 批处理语义与 LLM 完全不同,跟 LLM 混部没有收益 |
一条通用判据:看这个请求里有几种「一步」的定义。只有一种,用主线;有两种以上且它们的调度需求冲突,才需要多阶段。
下一篇:08 - 运行时性能排查方法论,把本篇这些案例背后的排查过程整理成一套可复用的流程。