07 - 三种计算为什么合不来
点单机器人的三块拼图现在齐了:03 篇的听、你原有的大模型、04 篇的说。你把它们串起来,压测一下。
单人测试很漂亮:说完话到听见回答,一秒出头。
然后你把并发拉到 32。吞吐上去了,延迟也还行。再拉到 64 —— 吞吐一点没涨,延迟直接翻倍,还开始丢请求。
你打开 nvidia-smi,GPU 利用率 40%。
这个组合很反直觉:卡有六成时间闲着,可你再多塞请求进去,吞吐就是不涨。按经验,GPU 没打满就说明还能再堆并发,堆到打满为止。这里不成立。
你试了几个常规动作,都没用:
- 加大批处理?吞吐没动,尾延迟先坏了。
- 换更好的卡?那六成空闲会变成七成空闲。
- 给语音单独加一张卡?代码里三段绑在一个进程里,加不进去。
这一篇把那四成空闲一点点算出来 —— 不讲抽象的「三种范式」,就跟着请求走,看时间到底去了哪。
一、先看一个人的一秒
要理解三十二个人为什么会乱,得先看清楚一个人是怎么跑的。
三段的性质已经能看出不同了:
- 听:一次 30 ms,中间没有停顿点,要么不开始,要么一口气跑完。
- 想:40 个 9 ms 的小步,步与步之间可以停。
- 说:75 个 5 ms 的小步,同样可以停。
「能不能中途停」这件事,一个人时完全无所谓。三十二个人时,它是全部问题的分水岭。
二、三十二个人一起时,调度器在干什么
GPU 一次只能执行一个任务。所谓调度器,干的就是不停地决定「这一微秒轮到谁」。
三十二个人都在解码时,一切都好:每人一步 9 ms,攒成一批一起算,转一圈 5 ms,每个人的字稳定往外冒。
问题出在有人刚说完话、需要跑编码器的时候。
编码器不可中断。它一旦开始,正在等字的所有人都得停下。
并发 8 时,你损失 8 人份的 30 ms;并发 64 时,损失 64 人份。并发越高,同样一次编码器造成的总损失越大 —— 这是个乘法,不是加法。
更麻烦的是它在监控上完全隐形:这 30 ms 里 GPU 是满载的,利用率曲线上看不出任何异常,只能看到延迟毫无规律 地往上跳。
这就是三段计算的第一处冲突:它们对「一步该多长」的答案不一样。解码希望步越短越好,好让所有人轮得快;编码器的步天生就是 30 ms,切不开。调度器夹在中间,只能牺牲一边。
三、第二处冲突:显存的形状
三段计算不光步长不同,占显存的方式也完全不同。
gpu_memory_utilization 是总显存的比例、SGLang 的 mem_fraction_static 是权重加载后剩余显存的比例,后者在多阶段场景下语义是含糊的,于是它改用一套显式语义。一个人时这不是问题 —— 装得下就行。三十二个人时,三条曲线叠在同一块显存上,而且各自的时间点不对齐:甲的编码器尖峰可能正好撞上乙的解码高位。
于是容量规划变成一件没有正确答案的事:
- 按峰值规划:平时浪费一大半,因为三段峰值同时撞上的概率并不高。
- 按平均规划:撞上了就 OOM,而且这种 OOM 在测试环境几乎复现不出来 —— 它要三段的时间点恰好对上。
下文把「多个阶段挤在同一张卡上」简称共置。怎么给共置的阶段分显存,下一章 05 篇专讲。
四、第三处冲突:谁能中途插队
第三处,也是最要命的一处:三段对「批」的规矩要求完全相反。
先说 01 篇那套连续批处理为什么好用。
批不是固定的一拨人,是个随时进出的池子:谁生成完谁走,空出来的位置立刻补新人。所以新请求几乎不用排队。
这套规矩对另外两段都不成立:
- 编码器没法「随时补一个进来」。一批要形状一样才能堆在一起算,来一个长度不同的,只能等下一批。
- 扩散更硬:一批开跑就锁死,中途来的只能等整批跑完。
三者逐项摊开:
| 编码器 | 自回归 | 扩散 | |
|---|---|---|---|
| 批怎么组织 | 攒够或超时就发一批 | 随时进出 | 开跑即锁死 |
| 什么条件能合批 | 形状要对齐 | 几乎无条件 | 同模型、同形状、同步数 |
| 新请求要等多久 | 最多一个攒批窗口 | 下一步,毫秒级 | 当前批全部跑完 |
| 批开大的代价 | 攒批等待推高尾延迟 | 每人的字变慢 | 尾延迟直接翻倍 |
你只有一套批处理规矩。要同时不违反这三行,只能取交集 —— 也就是最保守的那套:按最严的条件合批、按最长的步长轮转、按最大的显存留余量。
SGLang-Omni 的 Whisper 路径不是死等 N 毫秒攒批,而是只在「还有别的请求正在构建中」时才等,且最多等 6 毫秒;单个请求或者后面没活了,立刻放行。
高并发时能攒到批,低并发时不会平白给每个请求加 6 ms。写成「无条件等 N 毫秒」是常见的偷懒做法,代价是低负载下每个人都白等。
五、那四成空闲是怎么来的
现在可以把账算出来了。并发 64 时那 60% 的非忙碌时间,主要是三块:
| 去处 | 占比量级 | 为什么 |
|---|---|---|
| 等编码器让路 | 大 | 每次准入都要停 30 ms,并发越高被停的人越多 |
| 步与步之间的空隙 | 大 | 每步只算 5~9 ms,而准备下一步的主机侧工作也要几毫秒,GPU 在这期间空着 |
| 排队等准入 | 视配置 | 运行中的请求数有上限,超了的只能在门外等 —— 这段时间 GPU 跟这个请求毫无关系 |
三块里没有一块是「算力不够」。所以:
- 换更快的卡,只是让那 40% 的忙碌时间变成 30%,空隙原样保留。
- 加大批,能摊薄第二块,但第一块和第三块反而更糟。
「服务不够快」这句话对应两种完全不同的病:GPU 打满了(算力不够,去优化 kernel)和 GPU 没打满(编排不好,去优化调度)。
治法几乎没有重叠。不先量一下就动手,多半是在治错的病。下一章 08 篇 讲怎么量准。
六、顺带一提:连尺子都不通用
上面讲的是三段怎么抢资源。还有一处冲突在意想不到的地方 —— 衡量快慢的尺子。
举个真会发生的例子。有人告诉你「这个语音合成服务 RTF 是 0.05,比实时快二十倍」,听起来非常快。你接进去一试,用户抱怨说完话要等两秒才有声音。
两边都没撒谎。RTF 量的是「合成一秒音频要花几秒」,是把整段做完再算的账;用户感觉到的是「按下按钮到听见第一个字」。一个服务完全可以总账漂亮、第一口很慢 —— 比如它攒够整句才开始出声。
七、出路只有一条
把前面几节摞起来,「一个进程、一套调度、一张卡跑完整个请求」是站不住的:
- 三段的批处理规矩互斥,共用一套只能取最保守的;
- 三段的瓶颈资源不同 —— 编码器吃算力、解码吃带宽和主机调度、扩散吃算力,挤在一张卡上互相抢;
- 三段的扩容比例不同。如果 AR 段是瓶颈,你想加的是 AR 段的卡,不是声码器的卡;绑在一个进程里就没法分开扩。
于是结构上只剩一条路:每一段做成独立的阶段,各配一个匹配它的调度器,阶段之间靠传输连起来,放置和并行度按段单独配。
这条路的代价也不小,而且是全新的一批麻烦:张量要跨进程搬、阶段边界成了新的故障面、一个请求的耗时要跨进程拼才看得出来。
这些新麻烦怎么解,是下一章 SGLang-Omni 的全部内容。
下一篇:08 - Omni 模型与全双工,先看清楚被服务的对象还能长成什么样。