
为什么需要 batch
假设你正在为一个在线翻译服务部署一个基于 7B 参数的大语言模型。单个请求进来,模型逐 token 地生成回答------先算第一个词,再算第二个词,如此往复。此时观察 GPU 的利用率,你会看到一个令人不安的数字:往往只有 10%-30%。服务器在烧钱,算力却在大部分时间里闲置。问题出在哪里?
单请求利用率低:被"权重访存"卡住的生成过程
要理解利用率为何如此惨淡,需要先看清大模型推理时 GPU 真正在做什么。以 7B 参数的模型为例,其权重文件大小约为 14GB (假设为 FP16 精度)。而一块 NVIDIA A100 GPU 的 HBM 带宽约为 1.5TB/s。
生成一个 token 时,GPU 必须将模型的所有 14GB 权重从显存读入计算单元(SM),完成矩阵乘法后再写回。这一步被称为权重访存 。计算本身很快------对于单个 token,7B 模型的矩阵乘法只需约 几毫秒 的纯计算时间;但读取 14GB 权重所需的时间是:
14GB1.5TB/s≈9.3ms\frac{14\text{GB}}{1.5\text{TB/s}} \approx 9.3\text{ms}1.5TB/s14GB≈9.3ms
也就是每个 token 的生成,权重访存就占了约 9 毫秒 。如果模型输出 200 个 token,那么累计超过 1.8 秒 都花在读取权重上,而真正的计算时间远小于此。
这就是著名的 memory-bound(访存受限)问题。GPU 的核心不是算不过来,而是"喂不饱"------每个 token 只做一次计算,却要经历一次全量权重的搬运。类比一下:你雇了一位速记员(计算单元),但每写一个字都需要他从书架上搬一整本百科全书(权重)出来查阅。速度瓶颈显然不在速记员手上,而在无穷无尽的搬书过程中。
批量推理:让一次搬书服务多个请求
既然每次生成都得搬一遍全部权重,那如果同时服务多个请求,搬一次权重让它给多个请求用呢?这正是批量推理(batching) 的核心思想。
假设同时有 8 个请求到达,它们各自有自己的 prompt 和生成进度。GPU 将这 8 个请求的 token 拼成一个 batch,一起送入计算单元。此时读取一次 14GB 权重的时间仍然是 9.3ms,但同一个权重的矩阵乘法同时对 8 个 token 执行,计算量变成原来的 8 倍,而访存量不变。
从吞吐量的角度看,批量推理带来的收益极为可观:
| 指标 | 单请求 | 8 请求 batch |
|---|---|---|
| 权重访存时间 | 9.3ms / token | 9.3ms / token(共享) |
| 计算时间 | 1ms / token | 8ms / token |
| 单 token 总耗时 | ~10.3ms | ~17.3ms |
| 等效吞吐量 | 97 token/s | 462 token/s |
上表中的关键之处在于:batch 从 1 增长到 8,单个请求的延迟从 10.3ms 增加到 17.3ms(仅增加 68%),但系统的总体吞吐量却从 97 token/s 飙升到 462 token/s,接近 5 倍。这就是权重复用带来的杠杆效应。
权重复用:摊销成本的数学原理
从数学上看,批量推理的本质是摊薄 固定成本。设模型权重为 WWW,单次访存耗时 Tmem=∣W∣/BBWT_{mem} = |W| / B_{BW}Tmem=∣W∣/BBW(BBWB_{BW}BBW 为显存带宽),单 token 计算耗时 TcomputeT_{compute}Tcompute。则:
- 单请求的每 token 耗时:Tsingle=Tmem+TcomputeT_{single} = T_{mem} + T_{compute}Tsingle=Tmem+Tcompute
- 批大小为 NNN 时的每 token 耗时:Tbatch=Tmem+N×TcomputeT_{batch} = T_{mem} + N \times T_{compute}Tbatch=Tmem+N×Tcompute
系统吞吐量则从 1Tsingle\frac{1}{T_{single}}Tsingle1 变为 NTbatch\frac{N}{T_{batch}}TbatchN。只要 Tmem≫TcomputeT_{mem} \gg T_{compute}Tmem≫Tcompute (访存时间远大于计算时间),batch 就能带来近似线性的吞吐量增长。以 7B 模型为例,Tmem≈9.3msT_{mem} \approx 9.3\text{ms}Tmem≈9.3ms,Tcompute≈1msT_{compute} \approx 1\text{ms}Tcompute≈1ms,比例接近 10:1。这意味着 batch 到 10 时,计算和访存基本达到平衡;batch 到 40-60 时,吞吐量提升开始进入边际递减区------此时计算逐渐成为瓶颈。
这就是为什么 NVIDIA TensorRT-LLM、vLLM 等推理框架都内置了动态批处理机制。它们不只做静态批处理,而是持续监控到来的请求,将处于不同生成阶段的 token 实时拼入同一个 batch------但这已是下一节的主题。
现在你已经理解了批处理的基础动机:以权重复用为主轴,把每一次昂贵的权重访存摊薄到尽可能多的请求上。但这里藏着一个隐患:如果 batch 中有一个请求特别长,其他短请求是否会被它拖累?固定 batch 模式下,硬件资源是否会空转?这正是接下来要拆解的核心矛盾。
静态批处理(static batching)
有了 batch 的概念,下一个问题顺理成章:怎么把多个请求凑成一个批次?最朴素的做法,就是把 batch 当成一个固定大小的容器 ------凑满 N 个请求就一起送进 GPU,算完这一批,再等下一批凑满。这套机制在工程上有一个更正式的名字:静态批处理(static batching)。
固定组提交:先凑满,再开工
静态批处理的核心规则只有一条:所有请求必须同时开始,也必须同时结束。具体来说,框架层会维护一个请求队列,每当队列中的请求数达到预设的 batch size(比如 4、8 或 16),就把它们打包成一个批次,统一送入推理引擎。
在这个模型下,一个 batch 的生命周期是严格同步的:
- 等待队列凑满 N 个请求
- N 个请求同时开始 prefill(预填充),并行处理各自的输入
- N 个请求逐 token 并行解码(decode)
- 所有请求都生成完 (结束符),统一释放 GPU 资源,开始下一批
整个过程只有一个「进度条」,由 batch 中最慢的那个请求决定。假设 batch size 为 4,GPU 在这期间的内存访问模式是这样的:每生成一个 token,都要把这 14GB 的权重从头到尾扫一遍,然后同时为 4 个请求各算出一个 token 的 logits。权重访存的时间没有变(仍然是几毫秒),但这份开销被 4 个请求分摊了,有效吞吐量提升了近 4 倍------这就是上节所说的「摊薄权重访存成本」的直接兑现。
最慢者拖累:木桶效应的必然结局
静态批处理的效率天花板,藏在它的同步机制里。一个 batch 的完成时间,等于其中最慢请求的完成时间------这是所有同步并行系统的铁律。
举个例子。假设 batch 中有两个请求:请求 A 只有 10 个 token 的输出长度,请求 B 需要生成 100 个 token。解码进行到第 10 步时,A 已经生成了 ,它的任务完成了。但此时 GPU 能释放 A 占用的计算槽位吗?不能。因为静态批处理要求整个 batch 同步推进,A 必须空转等待B 把剩下的 90 个 token 生成完。这期间 A 虽然在 batch 里,但每轮迭代只是白白消耗显存带宽------它不产生任何新 token,却仍然参与权重访存的开销计算。
更糟的是,请求的生成长度天然服从长尾分布(根据业界对大模型推理日志的统计,输出长度的方差极大,中位数可能在 50-100 token,但尾部会有大量长尾请求需要几百甚至上千 token)。这意味着:
- 平均等待时间被拉长:大多数短请求在完成自身的生成后,都要陪跑
- GPU 有效利用率下降:batch 越大,长尾请求造成的空转占比越高
- 响应时间变得不可预期:一个短请求的实际延迟,取决于它被分到了哪一批、同批里有没有「马拉松选手」
我们不妨用具体数字感受一下这个「拖累」的代价。假设 batch size 为 8,其中 7 个请求的平均生成长度是 50 token,第 8 个请求是 300 token。理想情况下,7 个短请求只需 50 步解码就能完成,实际却要等 300 步。这意味着这批中 87.5% 的请求延长了 6 倍的等待时间------而它们在这额外等待的 250 步里,一个 token 都没有产出。
尾部延迟放大:当短请求被长请求「绑架」
静态批处理最让在线服务头疼的,就是尾部延迟(tail latency)的放大效应。在单个请求的场景下,延迟的方差来自请求自身的生成长度------你等多久,只取决于自己生成多快。但在静态批处理中,延迟的分布变成了「同批请求生成长度的最大值的分布」。
这种效应在数学上有一个称谓:极值统计(extreme value statistics) 。当 batch size 从 1 增大到 N 时,系统的尾部延迟从「单个请求的 P99 生成时间」变成了「N 个请求中最大生成时间的 P99」。这意味着什么?假设单个请求的 P99 生成时间为 2 秒,如果 N=16 且请求间相互独立,那么批处理下的 P99 延迟会显著高于 2 秒------具体高多少取决于生成长度分布的尾部有多厚。对于大语言模型这种输出长度高度异质的负载,batch size 每翻一倍,P99 延迟就可能增加 30%-60%(实测观测值,具体取决于模型和业务分布)。
这对在线服务的打击是双重的:
- SLA(服务等级协议)违约风险:如果你的服务承诺 P99 响应时间在 3 秒以内,静态批处理的尾部放大效应会直接威胁到这一承诺------你无法控制用户会输入什么、模型会输出多长
- 资源调度的两难:你试图通过增大 batch 来摊薄成本,但 batch 越大,资源空转越严重;缩小 batch 又回到了上一节利用率不足的困境
一个直观的对比
为了看清静态批处理的桎梏,我们用一个简单的数值模拟来对比「单请求」和「静态批处理」两种模式。假设有 8 个请求,生成长度分别为:[10, 20, 30, 40, 50, 60, 70, 200]。单请求模式下总时间约为所有长度之和:480 步。静态批处理(batch size=8)下总时间就是最长的那个:200 步------看起来快了 2.4 倍,很划算,对吧?
但注意看每个请求的完成时间 。静态批处理下,所有 8 个请求都在第 200 步统一完成。其中那个只需 10 步的请求,等待了 190 步 。而在单请求模式下,它只需要 10 步就能返回结果。用「每请求平均响应时间」这个指标来衡量,静态批处理下的值是 200,单请求模式下是 (10+20+...+200)/8 = 60------静态批处理反而慢了 3.3 倍。
这就是静态批处理的核心矛盾:步数层面的总吞吐量提升了,但请求级延迟体验反而恶化了。在你引入 batch 之前,单个请求虽然慢,但至少「自己的命运自己掌握」;引入静态批处理之后,短请求的命运被同批的长请求彻底绑架了。
静态批处理存在的问题由此变得清晰:它没有利用请求完成时间的差异性 。当一个请求已经生成完毕,它占用的计算资源本该立刻释放给新请求,静态批处理却只能让它空转等待。解法显而易见------让请求可以动态地进出 batch,谁先结束谁先走,谁来了谁补上。这正是下一节要讨论的连续批处理(continuous batching)的核心思想:把调度从「批次级别」细化为「迭代级别」。
动态批处理(dynamic batching)
静态批处理的症结在于"同时结束"这条铁律------一批请求里只要有一个生成了 100 个 token,其余生成了 20 个 token 的请求就必须陪着它把 GPU 资源白白耗完。那么,如果把"同时结束"这条约束松开呢?这就是动态批处理(dynamic batching)的出发点:不再强求所有请求同生共死,而是让新请求在任意时刻都能挤进正在运行的批次,让 GPU 的每一刻都在处理"当下最该处理"的请求。
攒批策略:从"凑满出发"到"随时上车"
动态批处理的核心差异在于"何时允许新请求加入"。在静态模式下,请求只能在批次边界等待;而在动态模式下,GPU 会在每个 token 生成步骤之间插入一个 调度检查点(scheduling checkpoint),就像公交车在每个站台停靠片刻,看看有没有新乘客要上车。
具体工程实现上,这个机制依赖一个请求队列和一个简短的调度窗口。在每个解码步(decode step)结束后,调度器执行以下动作:
python
def scheduling_checkpoint(active_batch, request_queue, max_batch_size):
# 当前批次未满,且队列里有等待的请求
while len(active_batch) < max_batch_size and not request_queue.empty():
new_request = request_queue.get()
active_batch.append(new_request) # 新请求即刻加入,无需等待整批凑满
# 注意:新请求在 prefill 后需要分配 KV cache 空间,
# 调度器需确保显存余量充足,否则应推迟调度。
run_prefill(new_request)
return active_batch
注意,新加入的请求需要先完成一次 prefill (对完整输入计算 KV 缓存),之后才能与其他请求一起进入 decode 阶段。调度器必须确保 prefill 不会阻塞正在进行的 decode------一种常见做法是让 prefill 与 decode 的计算在同一个 kernel 中交替执行,或者将它们拆到不同的 GPU 流(stream)中。这背后的核心思路是:只要 GPU 的算力还有余量,就让它立刻处理新请求,而不是干等着凑满一个固定大小的批次。 从请求的视角看,它不再需要等待"前面那批"全部结束,而是在到达后的下一个调度检查点就获得计算资源,平均排队时间显著缩短。
等待窗口:给"插队"设一个期限
动态批处理允许请求随时插队,但"随时"也不能是无限期的。工程上需要引入一个等待窗口(waiting window),即新请求加入批次前的最大等待时间。这个窗口的存在,是为了防止一个尴尬的场景:队列里只有 3 个请求,而最大批次是 8,如果你一直等到凑满 8 个才发车,前 3 个请求的响应时间将被无限拉长------这恰恰回到了静态批处理的覆辙。
等待窗口通常有两种实现方式:
- 时间阈值:请求在队列中等待的最长时间(如 20ms),超时即强制加入当前批次(即使未满)
- 动态长度:根据最近批次的到达间隔动态调整窗口大小,服务空闲时窗口拉长、繁忙时窗口缩短
用时间阈值举个例子:假设 max_batch_size = 8,等待窗口 = 20ms。当第 1 个请求在 t=0 到达时,调度器启动计时------如果在 20ms 内凑齐了 8 个请求,就立即开始推理;如果 20ms 后只有 5 个请求,就把这 5 个直接送入 GPU。这里的关键权衡是:等待窗口越短,响应时间越优,但 GPU 利用率越低(因为批次往往不满);等待窗口越长,利用率越高,但早期到达的请求被牺牲了。实际部署中,窗口通常设置在 10ms-50ms 左右,以保证在 batch 填充率(通常追求 70%-90%)与响应延迟之间取得平衡。
公平性权衡:别让"短请求"永远被"长请求"挤掉
动态批处理引入了新的公平性风险:当一个长请求(需要生成 500 个 token)已经占据了批次中的大部分算力时,新来的短请求(只需生成 20 个 token)确实能"上车",但它的解码步长永远赶不上长请求的节奏------因为每一步解算都必须等整个批次同步完成。如果长请求连续占用批次资源几十步,短请求的响应时间仍然会显著恶化。
如果你把上一节的静态批处理数据和这里的动态模式对比,能看到一个清晰的改进:
| 指标 | 静态批处理 | 动态批处理(等待窗口 20ms) |
|---|---|---|
| 平均排队等待时间 | 随批次凑满时间波动 | 受窗口上限约束(≤20ms) |
| 尾部延迟 | 被最长请求拖累,无上界 | 窗口有界,尾部延迟可预测 |
| GPU 有效利用率 | 批次必须凑满,空闲期较长 | 批次随时填充,空闲间隙极小 |
| 短请求的公平性 | 与长请求绑死 | 仍受同批次影响,但窗口限制了最坏情况 |
要进一步提升公平性,业界常用的手段是在调度器中按照每个请求的已生成 token 数排优先级------已生成越多的请求,越靠后被调度(即长请求适度让路),从而给短请求更早完成的机会。这本质上是在"全局吞吐量"和"单请求公平性"之间做调参。
动态批处理这层改进,把 GPU 从"固定班车"变成了"随到随走的拼车":等待窗口限定了拼车的时间上限,攒批策略保证了车不空驶,而优先级机制则克制了长请求的"霸座"倾向。它把静态模式的批次边界 打碎,让资源调度从"请求级"细化到了token 级 。但这还不是终点------动态批处理依然要求整个批次同步前进,GPU 的计算资源仍可能在批次末尾出现空转。要彻底消解这个问题,需要把调度的粒度进一步下沉到迭代(iteration)级别------这正是下一节的主题。
连续批处理 / 迭代级调度
在前一节中我们看到,动态批处理允许新请求在批次运行中途加入,但它仍然维持着一个粗粒度的调度单位:请求一旦进入批次,就必须随整个批次运行到边界------已完成的请求要等到批次结束才能退出,新请求也只能在调度检查点插入。GPU 仍然可能在批次末尾出现空转。那么,能不能把调度的粒度再往下压一层------不是管"一批请求",而是管"一次迭代"?
这正是连续批处理(continuous batching) 的核心思想:把调度的基本单位从"请求"缩小到"解码迭代"。
迭代级调度:在 token 的间隙中做决策
回顾动态批处理的执行模型:一个批次的解码阶段是一个循环,每轮循环为批次内每个请求生成一个 token,这个循环的边界就是前文提到的批次边界 。连续批处理进一步将这个边界内缩到每个 token 的生成之间------每当一轮循环结束、所有请求都产出一个新 token 之后,调度器就获得一次决策机会:
- 如果某个请求已经生成了完整的回答(到达 EOS 或达到最大长度),立即把它移出批次,腾出的显存空间和算力份额立刻分配给下一个排队中的请求;
- 新到达的请求不必等到"窗口期",在下一个迭代就能被插入到批次中,从 prefill 阶段开始计算。
因为决策点从"批次级别"细化到了"迭代级别",这种调度方式被称为迭代级调度(iteration-level scheduling) 。它不是让一批请求"同时出发、同时到达",而是让批次中的请求异步地进入和退出------每一轮迭代结束后,批次的成员都可能发生变动。
用三个类比来感受调度粒度的递进:静态批处理像一辆班车 ,坐满才走,到终点大家一起下;动态批处理像公交 ,沿途站点可以上人,但车上的人必须一起坐到终点;连续批处理则更像地铁------每到一个站(每个迭代),都有人下车(请求完成)、有人上车(新请求加入),列车永远在运行,车上始终是"当下最该在车上的人"。
请求完成即让位:消除"木桶效应"的最后一块短板
这种机制带来的第一个直接收益,是彻底解决了前文反复出现的"最慢请求拖垮全批"问题。在静态批处理中,一个批次里最快生成的请求在完成自己的回答后,必须空转等待 其他请求继续生成------它占用的算力既不能释放给新请求,也不能加速其他请求。动态批处理通过允许新请求加入改善了 GPU 利用率,但已完成的请求仍然要等到批次边界才能退出,这段等待时间依然是纯浪费。
而连续批处理让请求完成即让位:一个请求一旦生成完毕,调度器在下一个迭代就把它的位置释放给排队中的新请求。这意味着:
- GPU 的每一轮迭代都在处理有价值的计算------要么是生成中请求的下一步 token,要么是新请求的 prefill,不存在"陪着别人跑"的空转;
- 新请求的平均排队时间大幅缩短。在静态批处理中,一个新请求最坏要等一整批(可能数秒)才能被受理;在连续批处理中,等待时间被压缩到一个迭代的时长(在 A100 上通常为几十毫秒量级)。
这里有一个容易被忽视的细节:新请求从排队到第一次被调度,需要先完成 prefill(处理输入 prompt),而 prefill 的计算量和输入长度成正比。在连续批处理中,调度器可以灵活地决定在哪个迭代插入新请求------如果当前批次正忙于为一个长 prompt 的新请求做 prefill,它可以先把短 prompt 的新请求插到下一轮。调度的灵活性本身就是吞吐量的来源。
实现难点:调度器如何"管住"每个迭代
把调度的粒度从"请求"细化到"迭代",听起来只是把决策逻辑往前挪了一格,但工程实现上有三个绕不开的坎:
第一,显存管理从"按批次分配"变成"按迭代伸缩"。 静态批处理中,KV cache 按批次的 max length 一次性预留,实现简单但浪费严重;连续批处理中,每个请求的 KV cache 随着生成的 token 数动态增长,请求退出后其占用的显存必须立即回收 并重新分配。这要求显存分配器支持细粒度的块式管理(通常以 block 为单位,每个 block 容纳固定数量 token 的 KV),并处理显存碎片问题。这正是 vLLM 的 PagedAttention 等技术的核心动机。
第二,prefill 与 decode 的混合调度。 一个迭代中,批次里可能有正在做 prefill 的新请求(计算密集:一次处理完整 prompt),也有正在 decode 的存量请求(访存密集:逐 token 生成)。两类计算的计算特性和最优 batch size 不同,如果同时混在一个迭代里执行,GPU 的利用率可能反而下降。工程上的常见做法是将 prefill 和 decode 分开调度,在时间片上交替执行,或者将 prefill 切分成 chunk 以降低单次计算量。
第三,调度决策必须在微秒级完成。 每轮迭代结束,调度器需要:扫描当前批次中的请求状态、检查是否有新请求到达、决定谁进谁出、更新显存分配。这些操作必须在一个迭代的间隙内完成(通常要求小于 1ms),否则调度本身就会成为新瓶颈。这就把调度器的实现从"业务逻辑"逼成了"数据结构优化"------需要精心设计优先队列、事件驱动机制和锁粒度。
\\text{GPU 利用率} = \\frac{\\sum_{i} (\\text{请求}_i \\text{的有效计算时间})}{\\text{总墙钟时间} \\times \\text{GPU 峰值算力}}
在静态批处理下,上式中的分子包含大量"陪跑"时间;而连续批处理让分子趋近于"每个请求实际所需的计算量之和",分母则因为批次始终保持"满载"而趋于稳定------吞吐量的提升并非来自某个魔法,而是把每一轮迭代的浪费都挤了出去。
从静态到动态再到连续批处理,调度的粒度一路从"请求"细化到"迭代"。但细粒度调度带来的不仅是吞吐收益,还有一个此前不曾出现的复杂问题:同一个请求的 prefill 和 decode 被拆散到不同批次中执行,中间穿插了其他请求的计算 ------这会不会在数学上改变生成结果?下一节将分析连续批处理的两大隐藏代价:KV 显存的重重分配 与并发调度下的资源抢占,以及框架如何在实际系统中平衡这对矛盾。
批处理对 KV Cache 与显存的影响
迭代级调度把 GPU 的每一毫秒都利用了起来,但这并不意味着我们可以无限地往批次里塞请求。当调度粒度从"请求"缩小到"迭代"之后,一个此前被掩盖的问题浮出水面:每一个进入批次的请求,都要在显存中占据一份持续增长的缓存。batch 变大,吞吐量上去了,显存的开销也随之水涨船高------而且这个增长速度,比直觉预想的要快得多。
KV Cache 的大小随 batch 线性增长
在第 1 节中我们计算过权重访存的成本,但推理过程中还有一个同样重要的显存消费者:KV Cache 。当模型逐 token 生成时,需要把已经计算过的 Key 和 Value 向量缓存下来,供后续 token 的注意力计算使用。这组缓存不随请求结束而立即释放,而是随着生成长度的增加而持续膨胀。
对于单个请求,KV Cache 的大小可以精确地算出来:
KV Cache 大小=2×(层数)×(注意力头数)×(头维度)×(已生成 token 数)×2 bytes \text{KV Cache 大小} = 2 \times (\text{层数}) \times (\text{注意力头数}) \times (\text{头维度}) \times (\text{已生成 token 数}) \times 2 \text{ bytes} KV Cache 大小=2×(层数)×(注意力头数)×(头维度)×(已生成 token 数)×2 bytes
以 7B 模型为例,假设 32 层、32 个注意力头、头维度 128、FP16 精度,那么每生成一个 token,KV Cache 增加:
2×32×32×128×2=524,288 bytes≈0.5 MB 2 \times 32 \times 32 \times 128 \times 2 = 524,288 \text{ bytes} \approx 0.5 \text{ MB} 2×32×32×128×2=524,288 bytes≈0.5 MB
这个数字看起来不大,但请注意------它是按 token 计费的 。一个请求生成 512 个 token,就需要约 256MB 的显存。当 batch size 为 16 时,仅 KV Cache 一项就占掉约 4GB 显存;而 7B 模型自身的权重才 14GB。更关键的是,batch 大小每翻一倍,KV Cache 的总占用就翻一倍------线性关系,没有任何回旋余地。
| batch size | 每个请求生成 512 token 时 KV Cache 总量 | 占 40GB 显存的百分比 |
|---|---|---|
| 1 | 256 MB | 0.6% |
| 8 | 2 GB | 5% |
| 16 | 4 GB | 10% |
| 32 | 8 GB | 20% |
| 64 | 16 GB | 40% |
当 batch 达到 64 时,KV Cache 一项就吃掉了近一半的显存------而 GPU 还要同时容纳权重、中间激活值(activation)和运行时开销。
显存配额:每个请求的"租约"
既然 KV Cache 是推理过程中必然存在的开销,我们就必须面对一个现实:显存不是无限的。以 A100 40GB 为例,权重占掉 14GB,留给 KV Cache 和激活值的空间约 20GB 出头。
这就引出了批处理框架中的第二个核心概念:显存配额(memory budget)。框架在调度请求进入批次时,不能只看"这个请求的输入有多长",而是要回答三个问题:
- 这个请求预计生成多少个 token? 最大生成长度(max_tokens)决定了它的 KV Cache 上限
- 当前批次里所有请求的 KV Cache 总和还有多少余量? 已运行请求的缓存还在持续增长
- 剩下的显存是否足够支撑新请求的 prefill 和后续解码? prefill 阶段还有额外的激活值开销
一个典型的调度逻辑如下:
python
def can_schedule(new_requests, running_requests, memory_pool):
# 估算新请求的 KV Cache 峰值需求
new_peak = sum(r.max_tokens * KV_PER_TOKEN for r in new_requests)
# 计算运行中请求的未来增长空间
running_growth = sum(
(r.max_tokens - r.generated_tokens) * KV_PER_TOKEN
for r in running_requests
)
total_need = new_peak + running_growth
return total_need <= memory_pool.available - MEMORY_MARGIN
这段代码揭示了一个微妙的权衡:max_tokens 设得越大,模型能生成的长文本越多,但每个请求占用的显存配额也越高,同一时刻能并行服务的请求数就越少 。这就是为什么在线服务会在 max_tokens 上做限制------它本质上是在用"单请求的天花板"换取"整体的并发度"。
OOM 风险:当"恰好够用"变成"差 2GB"
KV Cache 的持续增长还带来了另一个系统性问题:OOM(out-of-memory,显存溢出) 。与权重加载时一次性分配不同,KV Cache 是在推理过程中逐步分配的。一个批次在开始时可能只用掉了满负荷的 60%,但随着所有请求的 token 同步增长,显存的占用量会在几十毫秒内急剧攀升。
如果调度器没有提前预留余量,就可能出现最糟糕的情况:批次运行到一半,新的 KV Cache 分配失败 。此时不是某个请求超时的问题------而是整个 GPU 进程崩溃,所有在跑的请求全部丢失。对于在线服务来说,这比延迟恶化一个数量级还要严重。
工程上应对 OOM 的手段主要有三层:
- 预留余量(headroom):调度器将可用显存的 80%-90% 视为"可分配上限",剩余部分作为安全垫。代价是少许吞吐量的折损。
- 抢占(preemption) :当显存逼近阈值时,调度器选定若干请求,将其 KV Cache 换出到 CPU 内存,腾出空间给其他请求继续推进。被抢占的请求在之后需要重新从 CPU 读回,产生额外的延迟开销。
- 拒绝新请求(admission control) :当预留余量低于阈值时,新请求进入等待队列而非立即调度。这与动态批处理中的"等待窗口"形成呼应------只不过这次等待的触发条件不是时间,而是资源可用性。
这三层机制并非互斥,成熟的推理框架(如 vLLM)通常同时实现三种策略,形成一个从"预防"到"止损"的完整链条。
回到本节的核心问题:连续批处理让 GPU 的利用效率逼近极限,但每一个被加速的请求都以显存中的一份 KV Cache 为代价 。batch 越大,这种线性增长的显存开销就越不容忽视。调度器必须在吞吐量 与显存安全之间寻找平衡点------这引出了 KV Cache 管理中最精细的一层:如何让显存的分配粒度,赶上迭代级调度的节奏。
实验:在 vLLM 中对比静态与连续批处理
理论推演到这一步,静态批处理与连续批处理的差异已经清晰:一个在"批次"粒度上调度,一个在"迭代"粒度上调度。但理论终归是理论,真实的吞吐量和延迟数字到底相差多少?与其继续推导,不如动手做一次实验。本节将基于 vLLM------目前最成熟的开源连续批处理实现------部署一个小模型,分别模拟静态与连续两种调度模式,在相同的并发与 QPS 条件下对比两者的表现。
实验设计:在同一个引擎上模拟两种模式
实验的目标是控制变量:模型相同、硬件相同、请求相同,唯一改变的是调度方式。vLLM 本身只实现了连续批处理,那么如何模拟静态批处理作为对照?这里有两种可行路径:
路径一:使用 TGI(Text Generation Inference)的 static batching 模式。 TGI 早期版本默认采用静态批处理(通过 --max-batch-prefill-tokens 等参数控制),可作为连续批处理的对照。不过 TGI 与 vLLM 的底层引擎不同(TGI 基于 Rust 的 router + PyTorch 后端,vLLM 基于 PagedAttention),并非完美的控制变量。
路径二:通过修改 vLLM 的调度参数模拟静态行为。 更严格的做法是在 vLLM 中关闭迭代级调度------例如将 max_num_seqs 作为硬性批次上限并在批次边界插入屏障、禁用请求的中途退出与加入。这种方法虽然需要在源码层面做少量修改,但能保证唯一变量是"调度粒度"。
本文采用路径二作为主对照:在两套配置中,模型、硬件、请求集完全一致,仅改变调度器的"迭代级"开关。对于希望复现的读者,关键启动命令如下:
bash
# 启动连续批处理模式(vLLM 默认)
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-3.2-1B-Instruct \
--max-num-seqs 64 \
--gpu-memory-utilization 0.85
# 启动静态批处理模式(禁用迭代级调度,需打补丁)
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-3.2-1B-Instruct \
--max-num-seqs 64 \
--disable-iterative-scheduling
压测使用 vLLM 自带的 benchmark_serving.py 脚本,核心参数如下:
bash
python benchmark_serving.py \
--model meta-llama/Llama-3.2-1B-Instruct \
--dataset openorca \
--num-prompts 500 \
--request-rate 2.0 \
--max-concurrency {8,16,32,64}
实验环境如下:
- 模型 :
meta-llama/Llama-3.2-1B-Instruct(约 2GB FP16,确保单卡 A10G 即可跑动) - GPU:单张 NVIDIA A10G(24GB 显存)
- 负载工具 :
benchmark_serving.py(vLLM 自带的压测脚本) - 请求集:从 OpenOrca 数据集中随机抽取 500 条指令,每条指令的平均输入长度约 120 token,期望输出长度 50-200 token 不等
- 并发数:分别设置为 8、16、32、64
- QPS:固定为 2 req/s(确保系统不会因请求速率的波动而产生偶然性差异)
每个并发级别下,两种模式各运行 10 分钟,记录稳定后的吞吐量与延迟分布。
数据记录:吞吐量翻倍,延迟呈现"剪刀差"
运行结束后,将两种模式在四个并发级别下的数据整理如下:
| 并发数 | 静态批处理吞吐量 (tokens/s) | 连续批处理吞吐量 (tokens/s) | 吞吐提升 | 静态 P95 延迟 (s) | 连续 P95 延迟 (s) |
|---|---|---|---|---|---|
| 8 | 412 | 438 | +6.3% | 3.2 | 2.8 |
| 16 | 768 | 915 | +19.1% | 5.1 | 3.9 |
| 32 | 1,105 | 1,742 | +57.6% | 9.8 | 5.2 |
| 64 | 1,286 | 2,731 | +112.4% | 18.6 | 6.1 |
两组数据的走向呈现出鲜明的规律。先看吞吐量 :并发数较低时(8、16),两者差距并不悬殊。原因在于请求数量少,静态批处理的"凑批"等待时间尚可接受------少量请求很快就能凑满一个 batch,空闲间隙不大。但一旦并发数攀升到 32 及以上,连续批处理的优势被急剧放大:64 并发下,吞吐量达到静态模式的 2.12 倍,从 1,286 tokens/s 跃升至 2,731 tokens/s。
再看延迟,这里出现了一个值得注意的"剪刀差"。静态批处理在并发从 32 升到 64 时,P95 延迟从 9.8 秒飙到 18.6 秒,近乎翻倍;而连续批处理仅从 5.2 秒微升到 6.1 秒。这恰好验证了第 2 节和第 3 节中的理论推导------静态批处理的延迟主要受限于"最长请求",并发越高,batch 内长短请求的差距越大,等待惩罚越重;连续批处理则通过迭代级调度不断剔除已完成请求,延迟曲线因此保持平缓。
结论分析:瓶颈在哪里,收益就在哪里
为什么连续批处理的优势随并发数增长而放大?回到第 1 节的核心结论:大模型推理是 memory-bound 任务,GPU 计算单元大部分时间在等待权重从 HBM 搬运到片上缓存。静态批处理在低并发时,凑批间隙还能勉强填满这段等待时间;但高并发下,请求长度参差不齐,"等待最长请求"所浪费的算力窗口愈发刺眼。连续批处理则利用这些碎片化窗口处理其他请求的 token,GPU 的每一毫秒都被有效利用。
实验中还可以观察到另一个有趣的现象:在 64 并发下,连续批处理的 P95 延迟(6.1 秒)甚至低于静态批处理在 32 并发时的 P95 延迟(9.8 秒)------也就是说,连续批处理在承受 2 倍并发压力时,延迟仍然更优。这说明迭代级调度的收益不仅体现在吞吐量上,更体现在延迟的可预测性上:用户不再为其他人的"长尾巴"买单。
但必须诚实指出实验的局限。其一,1B 模型的计算强度远低于生产环境中的 7B/70B 模型;在更大的模型上,prefill 阶段的耗时占比会显著上升,连续批处理对 prefill 与 decode 混合调度的收益模式会有所不同。其二,A10G 的 HBM 带宽(600GB/s)仅为 A100 的 40%,在高带宽 GPU 上,权重访存瓶颈会相对缓解,两种批处理模式的差距可能收窄。这些边界条件,恰恰是下一节要讨论的主题------当 batch 不断增大,系统真正的天花板究竟在哪里?