loading

Loading

探索与发现

首页 AI 工程化

连续批处理上线后,吞吐涨了但延迟更抖

分类:AI 工程化
字数: (2719)
阅读: (3)
0
摘要:连续批处理提高了 GPU 利用率,代价是尾延迟更难预测。长 prefill 抢占 decode、每批 token 预算、调度器在填满时的稀释效应。写清 Orca、vLLM、Sarathi-Serve 这条路线上哪些旋钮真的要紧。

连续批处理(Continuous Batching)在推理服务里的地位已经没有争议了。Orca(OSDI 2022)提出迭代级调度之后,vLLM、LightLLM、TGI 基本都沿着这条路走。博客和论文里常见的说法是 GPU 利用率从 40% 提到 85%,吞吐量 2-4 倍。这些数字大体成立,但如果你是把 vLLM 直接部署上去看面板的工程师,会撞上另一半事实:吞吐确实涨了,尾延迟也涨了,而且抖得比静态批处理更没规律。

这篇文章只谈调度层面的那个矛盾:批得越满,单个请求的 TBT(token 间隔)越不可控。

先复习一遍它在换什么

LLM 推理分两段。Prefill 把整条 prompt 并行算一遍,计算密集;Decode 每次只出一个 token,内存带宽瓶颈,OP 密度很低。调用方看的两个指标:TTFT(首 token 延迟)主要由 prefill 长度和排队决定,TBT 决定「流畅感」,基本由 decode 阶段每个 iteration 的耗时决定。

静态批处理的问题是队头阻塞。一批请求要等最长的那个跑完才释放,短请求早就结束,但在显存和计算里陪着长请求继续占位。vLLM 那篇 PagedAttention 论文里说得更直接:传统做法为每个请求预分配 max_length 的 KV Cache,浪费能到 60%-80%。

连续批处理做两件事:把调度粒度从「请求级」降到「迭代级」,每个 decode step 结束都检查哪些请求出了 EOS,释放显存,空出的槽位给新到达的请求;同时用分页的 KV Cache 管理按需分配。合起来的效果是 batch 里的槽位几乎不留空。

iteration 0: [A, B, C, D]
iteration 2: [A, B, C]        # D 完成,释放
iteration 3: [A, B, C, E]     # E 加入
iteration 4: [A, B, C, E, F]  # F 加入

这一步本身没有引入额外计算,只是改变了「哪个请求在这一轮里」。但槽位填满了,单个 iteration 的工作量就变大了。

吞吐涨的机制,和延迟抖的机制是同一个

Decode 阶段的内存带宽瓶颈意味着,同一 iteration 里多塞一个请求,摊薄了模型权重的加载成本,总吞吐上升。但这一轮整体耗时也变长了,代价由这一轮里所有请求共同承担,而调度器不知道谁比较急。

更麻烦的是长 prefill 插进来的时候。你正在服务十个活跃的 decode 请求,这时来一个 1500 token 的 prompt。如果调度器让它整段做完,这十个请求会在几个毫秒到几十毫秒内完全停住,TBT 直接尖峰,用户看到的就是回答卡了一下。文献里把这个现象叫 Prefill Stall 或者 prefill 饥饿。

两种缓解手段,思路相反:

  • Chunked Prefill:把长 prefill 切成固定大小(比如 512 token)的块,块之间穿插 decode 请求。Sarathi-Serve 走的是这条路,它把 chunked prefill 和 decode 混在同一批里调度,通过限制每批的 token 预算来平衡两边的延迟。
  • Prefill-Decode 分离:DistServe、Mooncake 这类工作把两个阶段拆到不同实例上,decode 实例永远不会被长 prefill 打断。代价是多一跳 KV Cache 传输,以及两套实例的容量要分别规划。

LightLLM 也是把长 prompt 切块、让短请求精确填充计算空隙的做法。这些实现看起来是细节,实际决定了你的 TBT 分位数曲线长得像不像一把梳子。

线上参数该怎么看

几个经常被当成「开就完了」的开关,实际都有价格:

  • 每批 token 预算:这是最直接的旋钮。调大,吞吐上去了,TBT 的 p99 也上去。不是单调的好。
  • chunk size:切太小,prefill 被过度切碎,首 token 延迟拉长;切太大,decode 的穿插粒度又不够细。通常几百 token 级,需要一个能压真实流量回放的压测。
  • 最大并发序列数:受 KV Cache 分页的实际占用限制,不是纯参数。PagedAttention 让内存利用率从约 40% 提到 90% 以上,但 page 内部的碎片还在,长请求和短请求混在一起时,可用页的碎片情况会漂。
  • 等待队列的空闲槽是否立刻填:填得越积极,队列时间越短,但已经跑着的请求被稀释得越厉害。vLLM 的调度器会在「有等待序列且有可用显存」时持续加入,这在请求率突增时会让在跑的请求集体降速。

一个经常被忽略的现象:压测用固定长度输入输出,所有指标都好看;切到线上真实流量,长短不一、到达时间有突发,吞吐率往往只有压测的六到七成,p99 TBT 却能翻倍。根因不出在引擎的代码质量,出在你的 SLO 是按平均延迟定的,而调度器是在优化批填充率。

几个实际的判断

第一,如果你对 TBT 的 p99 有硬约束,就不该只看每秒 request 数来衡量调参是否成功。Goodput 这个提法在这类服务里比 Throughput 更有意义,也就是满足 SLO 的有效吞吐。很多情况下,把每批 token 预算调低一档,request 数掉 10%,p99 掉一半,是划算的。

第二,Chunked Prefill 不是无成本的开关。它把一次 prefill 在前向里拆成多次,同一批 token 数量下,kernel 启动和调度的开销会上去,在短 prompt 占多数的工作负载里未必划算。这个要按自己流量的 prompt 长度分布算,不能照搬别人的配置。

第三,Prefill-Decode 分离适合的场景和你的实际往往不重合。它适合 prefill 与 decode 各自配比极不均衡的 workload,也就是有人愿意为两套集群、多一跳 KV Cache 传输买单,换更稳的 decode 尾延迟。中小规模的部署,先把 chunked prefill 调对,收益已经拿到大半。

第四,监控上至少要看三层:每批的实际 token 数、迭代耗时的分布、以及被调度器推迟加入队列的次数。只看 GPU 利用率和总的 request 量,会把噪声当正常。

连续批处理带来的红利是真的重。Orca 在 Azure 线上负载上测得的 2.3-4.1 倍吞吐提升,那是把成批的存量浪费收回来。在这个基础上继续榨,空间来自更精细的预算和更清楚的优先级,而这需要你先承认一件事:调度器并不知道哪个请求的用户正在等,它只会按照你写进去的规则填槽。

转载请注明出处: 皮哈皮阿

本文的链接地址: https://blog.phpat.com/ai-engineering/650.html

手机扫码阅读

微信或手机浏览器扫一扫,随时随地随心阅读与分享

您可能对以下文章感兴趣
1 / 1
预览图