Skip to main content

07 - 三种计算为什么合不来

点单机器人的三块拼图现在齐了:03 篇的听、你原有的大模型、04 篇的说。你把它们串起来,压测一下。

单人测试很漂亮:说完话到听见回答,一秒出头。

然后你把并发拉到 32。吞吐上去了,延迟也还行。再拉到 64 —— 吞吐一点没涨,延迟直接翻倍,还开始丢请求

你打开 nvidia-smi,GPU 利用率 40%。

这个组合很反直觉:卡有六成时间闲着,可你再多塞请求进去,吞吐就是不涨。按经验,GPU 没打满就说明还能再堆并发,堆到打满为止。这里不成立。

你试了几个常规动作,都没用:

  • 加大批处理?吞吐没动,尾延迟先坏了。
  • 换更好的卡?那六成空闲会变成七成空闲。
  • 给语音单独加一张卡?代码里三段绑在一个进程里,加不进去。

这一篇把那四成空闲一点点算出来 —— 不讲抽象的「三种范式」,就跟着请求走,看时间到底去了哪。

一、先看一个人的一秒

要理解三十二个人为什么会乱,得先看清楚一个人是怎么跑的。

一个人单独用时,这一秒是怎么花掉的(横条按真实比例)编码器 30 ms —— 四秒录音一次算完,算完就走预填充 20 ms + 解码 40 步 × 9 ms = 380 msAR 段 75 帧 × 5 ms = 375 ms,声码器 90 ms合计约 875 ms,加上传输和排队大约一秒�出头 —— 就是压测里单人测出来的那个数。这时候一切正常。注意三段横条的粗细:中间两段各有三四百毫秒,而它们是由几十上百个「小步」拼出来的,每一小步只有几毫秒。「几十上百个几毫秒的小步」是下一节所有麻烦的来源。一个人时看不出问题,三十二个人一起时就全暴露了。
这张图是后面所有讨论的基准。记住三个数:编码器一次 30 ms 且不可分割;大模型解码每步 9 ms、要跑 40 步;语音合成每帧 5 ms、要跑 75 帧。

三段的性质已经能看出不同了:

  • :一次 30 ms,中间没有停顿点,要么不开始,要么一口气跑完。
  • :40 个 9 ms 的小步,步与步之间可以停。
  • :75 个 5 ms 的小步,同样可以停。

「能不能中途停」这件事,一个人时完全无所谓。三十二个人时,它是全部问题的分水岭。

二、三十二个人一起时,调度器在干什么

GPU 一次只能执行一个任务。所谓调度器,干的就是不停地决定「这一微秒轮到谁」。

三十二个人都在解码时,一切都好:每人一步 9 ms,攒成一批一起算,转一圈 5 ms,每个人的字稳定往外冒。

问题出在有人刚说完话、需要跑编码器的时候。

调度器一次只能干一件事。横轴是时间,每格 5 ms理想情况:批里全是解码GPU解码解码解码解码解码解码每 5 ms 一步,32 个人的字同时往外冒来了一个新请求,得先跑编码器GPU解码编码器 30 ms —— 不可分割,中途插不进任何东西解码解码影�响这 30 ms 里,正在等字的 32 个人全部停住编码器本身只花 30 ms,但它造成的损失是 30 ms × 32 个人。并发越高,一次编码器的破坏力越大 —— 这是个乘法关系。而 GPU 在这 30 ms 里是满载的。所以你从利用率上看不出任何异常,只能看到延迟莫名其妙地跳。
这就是「GPU 闲着但堆并发没用」的第一层答案:卡在忙,只是忙错了地方 —— 它在给一个人算编码器,代价是另外三十二个人干等。
一次 30 毫秒的编码器,代价是 30 毫秒乘以并发数

编码器不可中断。它一旦开始,正在等字的所有人都得停下。

并发 8 时,你损失 8 人份的 30 ms;并发 64 时,损失 64 人份。并发越高,同样一次编码器造成的总损失越大 —— 这是个乘法,不是加法。

更麻烦的是它在监控上完全隐形:这 30 ms 里 GPU 是满载的,利用率曲线上看不出任何异常,只能看到延迟毫无规律地往上跳。

这就是三段计算的第一处冲突:它们对「一步该多长」的答案不一样。解码希望步越短越好,好让所有人轮得快;编码器的步天生就是 30 ms,切不开。调度器夹在中间,只能牺牲一边。

三、第二处冲突:显存的形状

三段计算不光步长不同,占显存的方式也完全不同。

横轴为一次请求内的时间,纵轴为该阶段占用的显存0编码器:尖峰后立即回落自回归:随解码步数持续爬升扩散:全程恒定规划:按最大输入长度算峰值即可规划:按并发 × 最大上下文算,且必须留抢占余量 —— 这是 KV 分页管理存在的理由爬到顶端那条竖线是请求结束时的整块释放,中途没有任何自然的回落点三条曲线叠在同一张卡上时,最危险的是「编码器尖峰」撞上「自回归高位」。SGLang-Omni 的做法是给共置阶段做显式预算,而不是各自去问「现在还剩多少」。
「各自去问还剩多少」这个默认行为在单模型服务里没问题,在多阶段共置下是错的:阶段是顺序加载的,先加载的看到的「剩余」包含了后面还没加载的那部分。SGLang-Omni 专门指出 vLLM 的 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 量的是「合成一秒音频要花几秒」,是把整段做完再算的账;用户感觉到的是「按下按钮到听见第一个字」。一个服务完全可以总账漂亮、第一口很慢 —— 比如它攒够整句才开始出声。

各自的两个核心指标,以及它们在问什么文本生成TTFT:等多久看到第一个字TPOT:之后每个字的间隔合起来决定「读起来卡不卡」语音合成TTFB / TTFC:多久听到第一声RTF:合成 1 秒音频花几秒RTF 必须小于 1 才不会播着播着断图像视频生成端到端时间:唯一有意义的每步时间 × 步数 × CFG 倍数没有中间产物,谈不上「首字」三个高频错误① 用 RTF 评价流式体验。RTF 0.05 的服务也可能首包要等 2 秒 —— 用户听到的是 TTFB,不是 RTF。② 拿并发下的 RTFx 当单请求速度。RTFx 500 完全可能是「并发 32、每路 RTFx 15」,单请求并没有变快。③ 报扩散步数不报 CFG。�开了 CFG 的 30 步等于 60 次网络前向,成本是标称的两倍。共同点是:这三个错误都会让一个服务在纸面上看起来比实际好,而且都不需要任何造假。
验收一个多模态服务时,最省事的做法是要求同时给出「并发 1 的延迟分位数」和「饱和并发下的吞吐」两组数 —— 前者不能用批处理掩盖,后者不能用低负载掩盖,两个数一起看基本堵住了所有能自我美化的口子。

七、出路只有一条

把前面几节摞起来,「一个进程、一套调度、一张卡跑完整个请求」是站不住的:

  • 三段的批处理规矩互斥,共用一套只能取最保守的;
  • 三段的瓶颈资源不同 —— 编码器吃算力、解码吃带宽和主机调度、扩散吃算力,挤在一张卡上互相抢;
  • 三段的扩容比例不同。如果 AR 段是瓶颈,你想加的是 AR 段的卡,不是声码器的卡;绑在一个进程里就没法分开扩。

于是结构上只剩一条路:每一段做成独立的阶段,各配一个匹配它的调度器,阶段之间靠传输连起来,放置和并行度按段单独配

这条路的代价也不小,而且是全新的一批麻烦:张量要跨进程搬、阶段边界成了新的故障面、一个请求的耗时要跨进程拼才看得出来。

这些新麻烦怎么解,是下一章 SGLang-Omni 的全部内容。

下一篇08 - Omni 模型与全双工,先看清楚被服务的对象还能长成什么样。