连续批处理实战:大模型推理吞吐优化与KV Cache调度调优
大模型推理服务上线那阵子,监控面板上的显存占用一直贴着顶,GPU 算力利用率却像心电图一样走走停停,QPS 死活上不去。我一开始怀疑是模型算子没写对,把 kernel 逐个 profile 了一遍,单卡单请求的 decode 速度其实完全正常,问题出在“批”这个字上——请求是按批喂进去的,但批里的请求长短不一,短的几十个 token 就结束了,长的要吐两千多个 token,整批必须等最长的那条跑完才能收工,中间这段时间短请求的位置全在空转。后来把调度逻辑从“整批进出”改成“每一步都重新组批”,也就是常说的 Continuous Batching(连续批处理),同样的卡、同样的模型,吞吐直接翻了四倍多,尾延迟还降了一截。这篇就围绕 Continuous Batching 的技术原理和落地实现来展开,把我从原理到代码、从参数测算到线上踩坑的完整路径写清楚。不管你是刚接手推理服务的新手,还是已经在调优吞吐的老手,应该都能从中拿到能直接照着做的部分。
1. 静态批处理为什么把吞吐卡死了
静态批处理(Static Batching)的逻辑非常朴素:攒够 N 条请求,或者等超时,把它们拼成一个 batch 送进模型,等这个 batch 里所有请求都生成完,再统一返回,然后开下一批。这套做法在图像分类、离线打分这类“一个输入一个输出”的任务里毫无问题,但搬到自回归生成上,就撞上了根本性的错配——生成是一个 token 一个 token 往外吐的,每条请求需要吐多少次完全不可预知,而静态批处理要求整个 batch 的节奏必须对齐。
1.1 三道看不见的成本
第一道成本是填充浪费。为了让同一个 batch 内的张量形状规整,短序列会被 padding 到批内最长长度,这些填充位置照样参与矩阵乘,照样占显存带宽,算出来的结果直接丢掉。当一批里混着 50 token 的短问答和 2000 token 的长文摘要时,填充率能低到令人心疼的程度。
第二道成本是长尾阻塞。批里最慢的那条请求决定了整批的完成时间,短请求明明早就生成结束了,它的算力槽位却要空等长请求,等于白占着资源不干活。实际业务流量里,长短请求往往是混着来的,这种阻塞在高峰期会被无限放大。
第三道成本是准入空窗。一个 batch 从开始跑到最后一个 token 出来,中间是不接收新请求的,新来的请求只能在队列里干等。批越大,单批运行时间越长,空窗期越长,排队延迟就越高。你为了提升吞吐把 batch 调大,结果延迟指标先炸了,这就是很多人调参调到后面原地打转的原因。
我用一个模拟项目做过对照压测,模型选 7B 量级,输入输出长度按真实业务分布采样:六成请求总长度在 256 token 以内,三成在 512 到 1024 之间,一成超过 1500。同一张 80GB 显存的加速卡,静态批处理 batch size 设成 32,跑出来的数据是这样的:
| 指标 | 静态批处理 batch=32 | 连续批处理(同等显存上限) |
|---|---|---|
| 有效 token 占比 | 约 41% | 约 96% |
| 算力利用率峰值 | 78% | 92% |
| 算力利用率均值 | 33% | 81% |
| 输出吞吐(token/s) | 1180 | 5240 |
| P99 首 token 延迟 | 2.4s | 0.42s |
| 平均排队等待 | 1.7s | 0.09s |
注意“有效 token 占比”这个指标,它指的是真正参与有效计算、结果被采用的 token 数占全部算过的 token 数的比例。41% 意味着超过一半的算力在给填充位和已经生成完的请求做无用功。这个数字才是吞吐上不去的真凶,跟显存大小、跟模型量化精度都没关系。
1.2 连续批处理到底改了什么
Continuous Batching 的核心改动只有一句话:调度的粒度从“请求级”下沉到“迭代级”。模型每跑完一步(一个 iteration,通常对应生成一个 token),调度器就把所有已经结束的请求踢出运行集,再从等待队列里把新请求补进来,组成下一个 step 的 batch。批的成员是动态的,每个 step 的 batch 组成都可能不一样。
这个改动带来的效果是连锁的。请求一旦生成完就立刻腾出槽位,长尾阻塞消失;新请求不必等整批结束,只要还有显存和算力余量就能进来,准入空窗消失;填充需求也大幅降低,因为每个 step 只需要处理当前还活着的请求,短请求不会拖着一堆无意义的 padding 跑完全程。而代价是调度器和显存管理必须做得更精细——这正是后面几节要重点讲的部分。
需要说清楚的是,Continuous Batching 不是银弹。它的收益在“解码阶段占主导”的场景下最明显,也就是输出 token 数与输入 token 数比值较大的任务,比如长文生成、多轮对话、代码补全。如果你的业务绝大多数请求是短输入短输出,或者输入端超长、输出只有几个 token 的分类式调用,那么瓶颈其实在预填充(prefill)阶段,单纯上连续批处理收益有限,得配合预填充分块、前缀缓存这些手段一起用。
2. 核心机制拆解:每一步调度器都在做什么
把连续批处理讲清楚,绕不开请求生命周期和调度决策这两个层面。很多资料只讲“动态组批”这个结论,不讲调度器每步的具体判断逻辑,结果读者自己写的时候发现到处都是坑:什么时候该抢占、KV Cache 不够了怎么办、prefill 和 decode 能不能混在一个 step 里。这些才是工程落地的关键。
2.1 请求生命周期的三个队列
一条请求进来,先落到等待队列(waiting),此时它的 prompt 还没做前向计算,KV Cache 一点没占。调度器判定准入后,它在下一个 step 里执行预填充,把 prompt 的 KV 一次性算出来写进显存池,然后转入运行队列(running)。之后每个 step 它只做单 token 的 decode 增量计算,直到命中结束符或达到最大长度,转为已完成状态,回收全部显存。
中间还有一个容易被忽略的状态:被抢占(swapped / recompute)。当显存池不够分配新的块时,调度器必须从运行队列里挑一些请求踢出去,给更紧急或者更早进来的请求腾地方。被踢出去的请求有两种处理方式,一种是把它的 KV Cache 从显存搬到内存里存着,等有空间了再搬回来接着算;另一种更粗暴,直接丢掉 KV,回头重新跑一遍预填充重算。前者省算力但吃主机内存带宽,后者省内存但重算成本高,选哪个取决于你的机器配置和请求长度分布。
提示:抢占是个必须显式设计的机制,不是异常兜底。很多自研调度器只考虑了“显存够用”的理想情况,一上真实流量就在显存分配处直接报错退出,根因就是把抢占当成了不该发生的事。
2.2 一次调度决策里藏着的五个判断
每个 step 开始前,调度器要按顺序做完这几件事。第一,检查运行队列里有没有已经结束的序列,有就回收块、释放槽位。第二,判断本轮还能不能塞新请求,可塞数量受两个上限约束:一个是最大并发序列数(max_num_seqs),一个是本 step 允许处理的最大 token 总数(max_num_batched_tokens)。第三,如果等待队列里有请求,且其 prompt 长度能塞进 token 预算,就把它拉进来做预填充。第四,如果显存块不够,触发抢占,从运行队列尾部挑牺牲者。第五,把所有存活序列的当前输入拼成一个变长 batch,装好位置编码、注意力掩码,调用模型前向。
这里头最容易被低估的是 token 预算这个约束。因为预填充是“一次算 N 个 token”,而解码是“一次算 1 个 token”,两者对算力的消耗量级完全不同。如果你不加约束地让新请求的预填充和已有请求的解码混在同一个 step 里,一个 8000 token 的超长 prompt 预填充就可能把整个 step 的时间拉到几百毫秒,所有正在解码的请求都要陪着卡这么久,尾延迟瞬间失控。所以成熟的实现都引入了分块预填充(chunked prefill),把长 prompt 切成若干个小块,每块大小由 token 预算决定,分几个 step 慢慢吃进去。
2.3 KV Cache 的分页管理才是地基
连续批处理能跑起来,前提是 KV Cache 能被灵活地分配和回收。早期做法是给每条请求预留一段连续显存,按最大可能长度占位,这跟静态批处理的毛病一模一样——预留按最大值算,实际用多少看运气,碎片和浪费都严重。现在主流的方案借鉴了操作系统虚拟内存的思路,把 KV Cache 切成固定大小的块(block),每个块存若干个 token 的 KV,请求的显存由一张块表(block table)来索引,块与块在物理显存上可以不相邻。
分页带来三个直接好处。其一,按需分配,请求生成长度超过一个块才分配下一个块,显存利用率接近实际使用量。其二,共享前缀,同一个 system prompt 被大量请求复用时,它们的块表前几项可以指向同一批物理块,显存直接省下来,这对多轮对话和批量模板调用场景收益巨大。其三,抢占变得简单,把块表搬走就等于把请求搬走,不用拷贝大段连续内存。
块大小(block_size)是个需要认真对待的参数。设大了,块内碎片多,短请求尾部会浪费;设小了,块表变长,调度和内核寻址的开销上升。实测下来 16 是个比较稳的默认值,长序列偏多的业务可以试 32,短请求密集的可以降到 8 看看延迟曲线再定。
3. 动手写一个最小可用的调度器
原理讲完,落地才有意义。这部分我用 Python 写一个结构完整但刻意简化的调度器骨架,把关键的判断逻辑都保留下来,你可以照着往自己的推理服务里套。真实的实现还要处理张量拼接、内核调用、多卡通信这些,但调度这层的核心就在这几百行里。
3.1 数据结构与关键字段设计
先定请求对象和块管理器。请求对象要记录它在等待、运行、完成三种状态间的流转,以及它当前已经生成了多少 token、占用了哪些块、上次被抢占时的进度。块管理器负责块级别的分配释放,并且要维护每个块的引用计数,因为前缀共享意味着一个物理块可能被多条请求同时引用,引用计数归零才能真正回收。
from dataclasses import dataclass, field from enum import Enum class Status(Enum): WAITING = 1 RUNNING = 2 SWAPPED = 3 FINISHED = 4 @dataclass class Sequence: seq_id: int prompt_len: int max_new_tokens: int block_table: list = field(default_factory=list) status: Status = Status.WAITING num_generated: int = 0 # 已生成 token 数 num_computed: int = 0 # 已完成前向计算的 token 数 prefill_done: bool = False @property def total_len(self): return self.prompt_len + self.num_generated @property def is_finished(self): return self.num_generated >= self.max_new_tokens块管理器的核心是两个字典:空闲块列表和块引用计数。分配时从空闲列表头部取,释放时把引用计数减一,归零才回收到空闲列表。这里有个容易被忽略的细节,块表本身也是要占显存的,块表长度等于最大序列长度除以块大小,几千条并发时这块的开销不能不算。
3.2 调度主循环与准入判断
调度循环每一步干的事可以浓缩成下面这段。注意里面几个数值都是可配置的,后面第 4 节会讲怎么定。
def schedule_step(waiting, running, block_mgr, cfg): # 1) 回收已结束序列 still_running = [] for seq in running: if seq.is_finished: block_mgr.free(seq.block_table) seq.status = Status.FINISHED else: still_running.append(seq) running = still_running token_budget = cfg.max_num_batched_tokens # 2) 运行中的序列优先保证解码,每个占 1 个 token budget_left = token_budget - len(running) if budget_left < 0: # 预算不足,按优先级抢占尾部序列 while budget_left < 0 and running: victim = running.pop() block_mgr.swap_out(victim) # 搬到主机内存或重算 budget_left += 1 # 3) 在剩余预算内接纳新请求做预填充 while waiting and len(running) < cfg.max_num_seqs and budget_left > 0: seq = waiting[0] need = min(seq.prompt_len, cfg.max_chunk_size, budget_left) if not block_mgr.can_allocate(seq, need): break # 块够就准入 waiting.pop(0) block_mgr.allocate(seq, need) seq.status = Status.RUNNING seq.prefill_done = (need >= seq.prompt_len) budget_left -= need running.append(seq) # 4) 组装本 step 的输入张量 return build_batch(running, block_mgr)这段伪代码里有三处取舍值得说。第一处是“运行中序列优先”,这是为了保证已准入请求的进度不被新请求挤掉,否则新来的长 prompt 会把老请求饿死。第二处是抢占的牺牲者从尾部挑,尾部通常是最近准入的、已生成 token 最少的,丢掉它在计算上的浪费最小。第三处是预填充的分块上限单独设一个参数 max_chunk_size,而不是直接吃满剩余预算,这样做是为了给解码留出稳定的时间窗。
3.3 混合调度的取舍:预填充和解码同不同步
最朴素的实现是每个 step 里既有新请求的预填充、又有老请求的解码,好处是 GPU 永远不会闲,坏处是两类计算的耗时差异巨大,混在一起会让解码的时间抖动变大。一个 4 个 block 的预填充块加上 200 条解码序列,前向时间可能比纯解码 step 长三到五倍,端到端看就是吐字速度忽快忽慢。
两种处理路径。第一种是严格分离,所有 step 要么全是预填充要么全是解码,靠一个标记位切换。实现简单,延迟稳定,但预填充阶段 GPU 对显存的访问模式和计算模式跟解码不一样,切换本身有开销,而且预填充期间解码队列只能干等。第二种是分块混合,把预填充切成统一大小的块,限制每个 step 里预填充 token 的总量不超过解码 token 总量的某个倍数,比如 2 倍。这样既能让 GPU 保持满载,又能把每个 step 的时间控制在一个可预测的区间内。
我自己的项目最终选的是第二种,参数上把预填充的 token 预算固定成 max_num_batched_tokens 的三分之一左右,剩下的留给解码。这个比例不是拍脑袋定的,是在压测里按延迟 SLO 反推出来的:比例再高,P99 的 token 间隔就开始突破阈值;比例再低,预填充排队又变长,首 token 延迟上去了。这个数你按自己的流量分布重新调,方法是固定其他参数,只动这一个,画一条延迟曲线出来找拐点。
4. 参数怎么定:把显存和吞吐算明白
调度器逻辑写对只是第一步,参数定不对照样跑不出效果。很多人的做法是照着别人的配置抄一份,结果一换模型或者一换卡就全崩。下面这套测算方法是我用得最顺手的,先算显存账,再算吞吐账,最后用压测校准。
4.1 KV Cache 显存占用的计算公式
单条请求的 KV Cache 占用可以这样算:
每 token 的 KV 字节数 = 2 × 层数 × KV 头数 × 头维度 × 数据类型字节数 单请求占用 = 每 token 字节数 × 序列总长度以 7B 量级、32 层、采用分组查询注意力(8 个 KV 头)、头维度 128、FP16(2 字节)的模型为例:
每 token = 2 × 32 × 8 × 128 × 2 = 131072 字节,也就是 128KB。
如果业务平均总长度按 1024 token 算,单请求约占 128MB。80GB 显存减去模型权重(7B FP16 约 14GB)和框架自身开销、激活缓存、CUDA 上下文这些,留给 KV Cache 的大概是 58GB。除以 128MB,理论上能同时容纳约 464 条请求。
但这个数字不能直接用。第一,平均长度是个统计量,真实并发里必然有长请求,按 P95 长度算会更安全;第二,分页管理有块内碎片,按经验要再乘个 0.85 的效率系数;第三,要留至少 10% 的余量给突发。综合下来,把 max_num_seqs 定在 250 到 300 之间是个比较稳的起点,然后再用压测往上顶,顶到出现抢占频繁触发为止。
4.2 四个必须调对的参数
| 参数 | 含义 | 定值思路 | 常见错误 |
|---|---|---|---|
| max_num_seqs | 单步最多并发的序列数 | 按 4.1 的显存账算,取理论值的 60% 到 70% | 直接按理论最大设置,一遇长请求就频繁抢占 |
| max_num_batched_tokens | 单步处理的 token 总数上限 | 取并发数乘 1.2 到 2 倍,兼顾预填充 | 设得过小导致预填充排队,首 token 延迟飙升 |
| max_chunk_size | 单次预填充分块大小 | 512 到 2048,长 prompt 多就设大 | 不设上限,一个长 prompt 冻结整个批次 |
| block_size | KV Cache 块大小 | 16 起步,按业务长度分布微调 | 设 8 以下,块表开销反噬吞吐 |
显存利用率这个参数也值得单独提一句,很多框架有一个类似 gpu_memory_utilization 的开关,控制框架能占用多少比例的显存。默认值通常偏保守,比如 0.9,实际可以往上探到 0.92 到 0.95,因为框架保证不了的那部分余量往往已经算进去了。但这个值每往上加一点,OOM 风险就上升,加之前先确认你的容器没有被其他进程占着显存,否则很容易在跑了几小时之后突然挂掉。
4.3 吞吐和延迟的权衡要用曲线说话
调参最忌讳的是单点试验。正确做法是固定一组参数,只扫一个变量,把吞吐和延迟同时画出来看拐点。比如扫 max_num_seqs 从 64 到 384,每个值跑五分钟稳定流量,记录输出 token 吞吐、首 token 延迟 P50/P99、token 间隔 P99、抢占次数。你会看到吞吐随并发上升而上升,到某个点之后开始走平甚至下降,那个点就是这台机器在这套模型上的甜点位。
我做过的一次扫描里,从 64 加到 192,吞吐从 2100 涨到 5100,涨幅还在线性区间;从 192 加到 256,涨到 5600,边际收益明显衰减;加到 320 时吞吐反而掉到 5300,同时抢占次数从每千步 3 次跳到 120 次。原因很直白——块分配开始吃紧,调度器频繁踢人又拉人,KV 搬来搬去把带宽吃光了。这就是为什么用一个静态参数拍脑袋是不可靠的。
5. 线上踩过的坑与排查清单
纸上讲完原理,真正的经验都在故障里。下面这些现象我一个不落地遇到过,排查路径也一并写出来,你遇到类似的可以直接对号入座。
5.1 五类高频异常及其定位路径
第一类是吞吐突然腰斩但显存没爆。先看抢占计数,如果每秒抢占次数在不正常地涨,基本就是块分配跟不上,把 max_num_seqs 往下调,或者把 block_size 从 16 调到 32 试试。如果抢占计数正常,再去看是不是请求长度分布变了,比如某个上游业务突然开始发超长 prompt,把预填充的 token 预算吃干净了。
第二类是首 token 延迟很高但吐字速度正常。这个几乎一定是预填充排队。检查等待队列长度和单步预填充 token 量,如果等待队列一直积压而每步预填充量很小,就是 max_num_batched_tokens 设得太保守,或者 max_chunk_size 太小导致长 prompt 被切得太碎。
第三类是吐字速度忽快忽慢,token 间隔抖动大。原因通常是预填充和解码混在一个 step 里没有做比例限制,某个 step 撞上一个大的预填充块,时间就炸了。解决办法是加上文说的 token 预算比例控制,或者干脆在负载高的时候改成严格分离模式。
第四类是显存缓慢泄漏,跑几小时后 OOM。这类问题最阴。常见根因有两个,一个是完成了的请求的块表没有正确释放引用,尤其是做了前缀共享之后,引用计数减一的地方漏了;另一个是块表本身随并发数线性增长,单条块表不大但几千条堆起来就吃满了。排查方法是在每个 step 结束后打印已分配块数,看它是否随请求完成回到基线,如果不能回落就是泄漏。
第五类是并发上调后吞吐不升反降。除了前面说的抢占问题,还要检查是不是触发了内核层面的效率拐点。有些注意力实现在 batch 超过某个规模后会退化,这时候要看是否应该把请求拆到多个引擎实例上做进程级并行,而不是在一个实例里死磕单步 batch。
5.2 排查速查表
| 现象 | 高概率原因 | 首选动作 |
|---|---|---|
| 吞吐腰斩、显存正常 | 频繁抢占 | 降 max_num_seqs 或升 block_size |
| 首 token 延迟高 | 预填充排队 | 升 max_num_batched_tokens |
| token 间隔抖动 | 预填充与解码混跑无比例限制 | 加 token 预算比例控制 |
| 显存缓慢增长 | 块引用未释放、块表膨胀 | 打印每步块占用,查回收路径 |
| 高并发下吞吐回落 | 抢占或内核退化 | 扫描曲线找拐点,考虑多实例 |
| 单条长文本卡住整批 | 无分块预填充 | 强制 max_chunk_size 上限 |
5.3 三条从坑里总结出来的经验
不要等 OOM 才处理抢占。抢占逻辑要在上线前就压测验证,方法是人为把 max_num_seqs 设成理论值的两倍,强制触发抢占,观察服务是优雅降级还是直接崩溃,以及在抢占期间延迟指标恶化到什么程度。这个测试十分钟就能做完,能省掉后面好几个通宵。
压测流量一定要混合长度。单一长度的压测数据会严重高估你的性能,因为连续批处理最擅长的恰恰是处理混合长度,你用等长请求测,等于把它最大的优势给屏蔽了。我一般按业务真实分布的采样结果生成三档长度,短中长按 6:3:1 混合,这样测出来的数字才敢往生产上放。
优先做指标埋点再做调参。每步的 batch 组成、预填充 token 数、解码 token 数、块占用率、抢占次数,这几个数必须打出来。没有这些数据,所有调参都是玄学,你只能靠“感觉快了一点”来判断,这对线上服务来说是危险的。
最后分享一个在实际操作中的体会。连续批处理这套东西,原理一句话就能讲完,真正花时间的是边界情况的处理:空 batch 怎么走、单条请求超长怎么切、抢占回来的请求进度怎么接、块表在共享和独占之间怎么切。我建议你在动手写之前,先把调度器的状态机画在纸上,把每个状态转移的触发条件列清楚,尤其是“被抢占”和“重新准入”这两条路径,画明白了再写代码,返工能少一大半。至于上线之后的调参,别急着追峰值吞吐,先把延迟的稳定性做出来,稳定的 4000 比抖动的 5500 更能扛住真实流量。