跳转至

第 12 章 在线调度:连续批处理与 Chunked Prefill

开场问题:一个 4000 token 的长 prompt 进来,为什么正在流式输出的其他用户会集体卡一下?

12.1 静态批处理的三种浪费

传统批处理是:凑够 N 个请求,一起跑完,全部返回,再凑下一批。三个问题都很致命。

等齐才开始:先到的请求要干等后来的。等最慢的才结束:batch 里有个请求要生成 3000 token,其余 63 个生成 50 token 就完了,但它们的显存和 batch 槽位被占着直到最后。中途不能加人:新请求只能等下一批。

于是 GPU 大部分时间在为已经完成的序列做无意义的 padding 计算。

12.2 连续批处理:以迭代为单位重组

关键转变是把调度粒度从"一批请求"降到"一次前向"。

每一步 Decode 前向本质上是:batch 里每个序列各贡献一个 token,一起过一遍模型,各自拿到自己的下一个 token。既然如此,这个 batch 的成员完全可以每一步都不同。于是引擎的主循环变成:每次迭代开始前,把已完成的序列移出,从等待队列里挑新的请求加入,然后执行这一步前向。

效果是 GPU 永远满载,且新请求平均只需等待一步(几十毫秒)就能加入,而不是等一整批结束。这是现代推理引擎与上一代最本质的区别,其他优化都建立在它之上。

实现上的关键是变长支持:batch 里各序列长度不同,不能用规整的四维张量,而要用 ragged 布局(把所有序列的 token 首尾相连,另外用一个数组记录每条序列的起止位置),配合支持变长的 attention kernel。

12.3 分块 Prefill:不让长 prompt 霸占 GPU

连续批处理解决了 Decode 之间的调度,但 Prefill 仍然是个大块头。一个 8000 token 的 Prefill 要占 GPU 几百毫秒,这期间整个 batch 的 Decode 全部暂停——所有用户同时卡一下,这就是开场问题。

分块 Prefill 的做法是:把长 prompt 切成若干块(比如每块 512 token),每次迭代只处理一块,穿插在正常的 Decode 迭代之间。因为是因果注意力,后面的块本来就要看到前面块的 KV,分块处理在数学上完全等价,不需要任何近似。

代价是这个长请求自己的 TTFT 变长了(它要等好几轮才 Prefill 完),换来的是其他所有请求的 ITL 保持平稳。这是一个明确的公平性取舍:牺牲一个长请求,保护一群短请求。

12.4 Token 预算:吞吐和延迟之间那个旋钮

实现上,调度器每轮给自己设一个 token 预算,比如"这一步最多处理 8192 个 token"。先把所有活跃的 Decode 请求装进去(每个占 1 个 token),剩余预算用来装 Prefill 的块。

预算调大:每步干的活多,吞吐高,但单步耗时长,ITL 变差。预算调小:吐字更平滑,但吞吐下降。这是在线服务最重要的一个调参,应该由 SLO 反推,而不是取框架默认值。

12.5 排队论只需要知道这一点

系统利用率接近 100% 时,吞吐不再增长,而排队延迟会急剧上升——这是排队论的基本结论,不是工程实现问题。实践含义很直接:不要把推理集群压到 95% 利用率。

正确做法是画出延迟—吞吐曲线,找到 p99 开始陡升的那个膝点,把容量规划在膝点之前留出余量。流量有抖动,压在膝点上意味着任何一次小高峰都会变成一次 SLO 事故。

12.6 抢占与公平

显存不够时要踢人:踢最新来的(保护已经等了很久的)、踢最长的(释放最多显存)、还是踢优先级最低的?被踢的请求是丢弃 KV 重算,还是换出到主机内存?

还要防饥饿:如果总是优先短请求,长请求可能永远排不上;如果按 FCFS,一个超长请求会把后面所有人堵住。生产系统通常按 SLO 分级加上老化机制(等待越久优先级越高)来平衡。

核心问答

Q:连续批处理和动态 batch 的区别? 动态 batch 只是在开始前灵活凑批,一旦开跑成员就固定了;连续批处理在每一步生成都可以加人和踢人。

Q:分块 Prefill 会影响输出结果吗? 不会。因果注意力下分块计算和一次算完在数学上完全等价,纯粹是调度层面的切分。

Q:调度器为什么必须知道剩余 KV 块数? 因为接纳一个请求意味着承诺给它持续供应 KV 块。接纳太多,中途必然要抢占,反而造成抖动和重算。

Q:为什么不能把 GPU 压满? 排队系统在接近满载时延迟指数上升。追求 95% 利用率的代价是 p99 失控,最终 goodput 反而更低。