请求不等齐才出发:推理服务里的连续批处理

上周有读者问:为什么同样一张 GPU,别人跑的请求量是你们的三倍?模型一样,量化一样,卡也一样。差距往往不在模型,在调度。

专属插画
请求不等齐才出发:推理服务里的连续批处理

请求不等齐才出发:推理服务里的连续批处理

上周有读者问:为什么同样一张 GPU,别人跑的请求量是你们的三倍?模型一样,量化一样,卡也一样。差距往往不在模型,在调度。

先说清楚为什么要批

推理每做一个 forward,都要把整个模型的权重从显存读一遍。一张 70B 的模型权重大概 140GB(fp16),单请求算 1 个 token,就是把这 140GB 搬腾一遍只换回 1 个 token,算力全在等内存带宽。两条请求一起算,权重还是搬那么一次,产出翻倍。批处理的第一性原理就在这:权重读取是固定成本,token 是边际产出,批越大摊销越薄。

代价是延迟换吞吐:一条请求的响应时间会随着同批请求数慢慢爬升。所以批处理策略的核心问题不是「批多大」,而是「谁跟谁同时算」。

静态批处理的死穴

早期推理服务常用静态批处理:攒够 N 条请求(或等到超时)一起送进 GPU,算完这一批,再攒下一批。

问题出在「一起送进」就意味着「一起毕业」。假设一个批里 4 条请求,最短的生成 50 个 token,最长的要 800 个 token。最短的那条在 50 步之后其实早就能返回了,但它必须陪着整批等 800 步跑完。GPU 在替三条快请求继续算第 800 步,产出的 token 全是它们真正需要的(每条只需 1 step per token),算力被白白烧掉。

而且「攒够 N 条」在新请求到来时是僵死的:一条新请求哪怕只等 20 毫秒就能上车,也得站在路边等当前这班车开完,往往是几百毫秒到几秒。

这两笔账加起来,就是静态批处理典型的 GPU 利用率 10%~30%。

连续批处理:按步换人

连续批处理(continuous batching,也叫 in-flight batching)把「批」从请求队列降级到 token 步级别。每一步 forward 之前,调度器看一眼:谁上次已经生成完 EOS 了?把它踢出当前批,腾出的 KV cache 显存立刻回收;队列里有新请求?把它塞进这一批,共享同一个 forward 的算子启动开销。

效果是三层:

1. **短请求不再陪跑**。50 token 的请求 50 步就返回,剩下 750 步的显存和算力归新请求用。

2. **新请求不等班**。起步延迟从「整批空档」压到「当前步结束」,通常几十毫秒量级,体感上服务变快了,排队少了,P99 延迟显著下降。

3. **批大小天然弹性**。GPU 同一时刻在算的其实是「当前这批」,可能 64 个请求,每步都在增减,利用率跟着负载走,而不是跟着定时轮询的运气走。

代价是内存管理变复杂:KV cache 要按请求粒度分配和释放,调度器要维护每步的「上车名单」。这正是 vLLM 的 PagedAttention 要解决的事——把 KV cache 切成固定大小的块,像操作系统管虚拟内存页一样用,碎片和预分配问题一起消掉。

实操建议

- 查一下你在跑的引擎和调度模式。TF-Server 默认静态批;vLLM、SGLang、TensorRT-LLM 默认连续批。如果你的 P99 尾延迟高、GPU 利用率却始终上不去,先怀疑调度而不是模型。

- 批大小别设成信仰参数。连续批下更值得盯的是 `max_num_seqs` 和 KV cache 总量:前者限并发请求数,后者限能同时塞进显存的 KV 总量。并发上不去多半是 KV 显存先到顶,加请求数没用,要么减小上下文上限,要么用量化 KV。

- 长短请求混跑时,给长请求设 `max_tokens` 上限。一条失控的 4K token 长回复能拖住一批人的整体周转,设上限是保护,不是限制质量。

一句话:单次 forward 再快,也救不了排在下一班车上的请求。让 GPU 每 20 毫秒都满载干的是有用的活,比让它每小时跑几次满批,划算得多。

留言区

欢迎分享你的想法!

发表留言

0/500

加载留言中…