Continuous Batching 与 Chunked Prefill

从单请求走向动态组批,理解调度粒度、请求生命周期与资源回收。

从单请求执行到连续批处理#

本章的核心设计目标是Continuous Batching + Chunked Prefill。

在 v1 版本中,系统主要解决的是单条请求内部的执行问题。v1 的重点在于单请求路径上的显存复用。

v2 版本则进一步将单请求扩展到到多请求并发执行。系统需要在同一执行周期内同时推进多个请求,并根据各请求所处阶段动态组织计算批次。

1.1. v1在并发场景下的主要问题#

吞吐下降:Decode 阶段每一步通常只生成一个 token,单条请求在该阶段产生的计算量有限,难以充分利用 GPU 的并行计算能力。若多个请求只能串行执行,则多个请求的 Decode 时间在墙钟时间上近似累加,整体吞吐受到明显限制。

尾延迟恶化:当长请求与短请求混合到达时,串行执行会导致后到达的短请求必须等待先到达的长请求完整结束。此时,短请求的响应时间被长请求阻塞,系统尾延迟显著增加。

页池与前缀复用能力未被充分利用:分页机制本身允许多个 Sequence 共享页池,前缀索引也支持跨请求复用公共前缀。但在串行控制流下,系统并未同时维护多个活跃请求,因此这些多请求共享能力难以发挥作用。

1.2 静态批处理的局限性#

静态批处理可以将多个请求组成一个批次并统一执行,从而提高硬件利用率。但其调度粒度较粗,最主要的原因是同一批次中的请求通常需要同时开始、同时结束。

这意味着,即使批次中的部分短请求已经完成生成,它们仍需等待最长请求结束,整个批次才能释放资源并接入新请求。因此,静态批处理虽然提高了并行度,但仍会造成以下问题:

  • 短请求被长请求阻塞;
  • 批次生命周期由最慢请求决定;
  • 请求完成后无法立即释放调度槽位;
  • 新请求必须等待整个批次结束才能加入。

因此,静态批处理适合请求长度相近、负载稳定的离线场景,但难以适应在线服务中请求长度差异大、到达时间随机的并发负载。

1.3 连续批处理的设计动机#

Continuous Batching 将调度粒度从“整个批次”细化到“单个执行步”。在每个执行步中,系统可以根据当前活跃请求的状态动态组成批次。新请求可以随时加入,已完成请求可以立即退出,Prefill 请求与 Decode 请求也可以被统一纳入调度。

相比静态批处理,连续批处理具有以下优势:

  1. 更高的吞吐 多个请求可以在同一步中共同参与计算,提高 GPU 利用率。

  2. 更低的尾延迟 短请求完成后可以立即返回结果,无需等待同批次中的长请求结束。

  3. 更灵活的资源回收 请求退出后,其占用的调度位置和显存资源可以及时释放给新请求。

  4. 更充分的多请求复用 页池和前缀索引可以在多个活跃请求之间发挥作用,提高跨请求共享效率。

因此,v2 版本引入 Continuous Batching,将 Engine 的执行对象从单条 Sequence 扩展为一组可动态变化的活跃请求,并通过 Chunked Prefill 进一步处理 Prefill 与 Decode 混合执行时的计算粒度问题。

Continuous Batching 概念#

在设计 v2 版本时,首先需要明确批处理究竟用于解决什么问题。

批处理的基本目的是将多个请求组织到同一次模型执行过程中,以提高 GPU 等硬件资源的利用率。然而,批处理可以采用不同的调度粒度。若以整个批次为固定执行单元,则形成静态批处理;若以单个迭代步为调度单元,则形成连续批处理。

Continuous Batching,即连续批处理,也常被称为 Iteration-level Scheduling 或 In-flight Batching。其核心思想是:调度器不再以整个批次的完整生命周期为单位分配资源,而是在每个生成步结束后重新决定下一步应执行哪些请求。目前,vLLM、SGLang 等主流推理系统普遍采用该类调度方式作为在线服务的基础机制。

2.1 自回归生成的两个阶段#

自回归语言模型的推理过程通常划分为两个阶段:Prefill 与 Decode。

  • Prefill 阶段 系统将用户输入的完整 Prompt 一次性送入模型,并计算其中所有位置的 Key 和 Value。该阶段的主要输出是完整的 KV Cache,用于后续 Decode 阶段复用。

  • Decode 阶段 系统每一步仅将上一阶段新生成的单个 token 作为输入,并结合已有 KV Cache 计算当前步的注意力结果。随后,模型采样得到下一个 token。该过程不断重复,直到生成终止符 EOS 或达到预设的最大生成长度。

这两个阶段具有显著不同的计算特征:Prefill 阶段的输入长度较长,计算量主要由 Prompt 长度决定;Decode 阶段每步只处理少量 token,单请求计算量较低,但对延迟较为敏感。

2.2 静态批处理的基本流程#

在早期推理服务系统中,通常采用静态批处理策略。其执行流程如下:

  1. 系统等待并收集一批请求,假设批大小为 B;
  2. 对该批中的所有请求统一执行 Prefill;
  3. 随后对该批请求统一执行 Decode;
  4. 当该批中所有请求均生成结束后,整批占用的计算资源才被释放;
  5. 系统开始处理下一批请求。

在这种模式下,GPU 在整个批次执行期间持续参与计算。然而,批次的实际执行时长并不由平均生成长度决定,而是由该批中生成长度最长的请求决定。

考虑一个简单示例。假设一个批次中包含三个请求 R1、R2 和 R3,其所需生成的 token 数分别为 30、200 和 80。在静态批处理模式下,整个批次必须执行到第 200 步才能结束。

该示例暴露出三类典型问题。

已完成请求占用资源 R1 在第 30 步已经生成 EOS,其生成过程已经结束。但在静态批处理中,R1 所占用的批次槽位通常需要保留到第 200 步整批结束。在此后的约 170 步中,该槽位无法被新请求使用,只能保持空闲、填充无效数据,或参与无实际意义的重复计算。这会导致 GPU 有效算力被稀释。

短请求受长请求阻塞 对于 R1 对应的用户而言,请求本身只需要 30 步即可完成,但其响应必须等待整个批次结束,即延迟至第 200 步。该等待并非由请求自身复杂度造成,而是由同批次中最长请求决定。这种现象会显著增加短请求的响应延迟,并恶化系统尾延迟。

新请求无法及时加入执行 静态批处理一旦开始执行,其批次成员集合通常保持固定。在该批次执行期间新到达的请求,例如 R4,无法中途加入当前批次,只能等待当前批次完全结束后再进入下一批。当请求到达率较高时,等待队列会持续增长,从而进一步抬高请求排队延迟。

上述问题的根本原因在于:静态批处理的调度粒度过粗。

静态批处理以“一整批请求的完整生命周期”作为调度单位。一旦批次形成,系统便在该批次的整个执行期间内保持固定的成员集合与资源占用关系。

然而,自回归语言模型的 Decode 阶段具有天然的逐步性。每一步生成一个 token,每一步结束后,各请求的状态都可能发生变化。例如,某些请求可能已经完成,某些请求仍可继续生成,同时队列中可能出现新的请求。

Continuous Batching 的引入正是为了解决这一不匹配。将调度单位从完整批次降低到单个迭代步,使系统能够在每一步结束后重新组织下一批执行请求,从而更充分地利用计算资源并降低请求延迟。

2.3 调度粒度变成单步#

解决静态批处理缺陷的关键是让调度决策跟上解码的处理速度。CB 的思路是在每一次模型前向计算之前,都重新决定本轮迭代要推进哪些请求。批次成员在系统运行过程中随着请求的结束与新请求的到来而动态变化。

一次完整的迭代可以划分为四个步骤:

  1. 调度:读取当前系统中的等待队列和正在运行的请求,根据既定策略和可用资源(如显存余量),选定本轮迭代要处理的序列,并决定每个序列在本步要向前推进几个 token(Decode 阶段通常是 1 个;Prefill 阶段则可能一次性处理整个 prompt 或将其切分)。
  2. 模型前向计算:将调度阶段选出的序列组装为一个微批次,实际执行一次 Transformer 前向过程。这次前向可能包含新请求的 Prefill,也可能同时包含多个已有请求的单步 Decode。
  3. 采样:对模型输出的 logits 进行采样,为每个序列产生 0 个或 1 个新 token。
  4. 资源回收:检查各序列的状态,若某序列已生成 EOS 或达到最大长度,立即将其从运行队列中移除,并释放它所占用的 KV 缓存页等显存资源。由此腾出的并发位,可在下一轮迭代的步骤中被新请求占用。

仍沿用之前的例子:R1、R2、R3 分别需要生成 30、200、80 个 token,并假设在第 31 步时新请求 R4 到达。在 Continuous Batching 下,批次的变化 过程如下:

步序(Decode) 本步 batch 成员 本步发生的事
─────────────────────────────────────────────────────────────
1 … 30 {R1, R2, R3} 三人各前进一步
31 {R2, R3, R4} R1 释放槽位
R4 占用空位
32 … 80 {R2, R3, R4} Go On
81 {R2, R4, …} R3 释放槽位
… {R2, …} R2 一直跑到 200

用时间线图来对比,静态批与连续批的差异非常明显(横轴为解码步数,实心块表示正在计算,空白块表示占坑但无有效输出,“|”表示请求完成):

静态批处理:整批寿命 = max(R1,R2,R3) = 200;R4 被阻塞在外
步: 0 30 80 200
|---------|--------|---------------|
R1 █████████|░░░░░░░░░░░░░░░░░░░░░░░░ ← 30步已完成,其后空转
R2 ██████████████████████████████████
R3 █████████████████|░░░░░░░░░░░░░░░░ ← 80步已完成,其后空转
R4 ████… ← 整批结束后才能开始
连续批处理:每步都可以更换成员,空槽即时被填充
步: 0 30 80 200
|---------|--------|---------------|
R1 █████████| ← 结束即释放
R2 ██████████████████████████████████
R3 █████████████████|
R4 ████████████████████████… ← 从第31步起占用R1腾出的槽位
↑ ↑ ↑
三人并行 R1↓ R4↑ R3↓ 再招新的请求

在静态批处理中,第 30 步之后 R1 的槽位仍然被空占,而 R4 只能等待整批结束。连续批处理则在同一时刻将空位立刻转给 R4,长请求 R2 不受任何影响,短请求也无需再被拖住。

由此可以清晰地理解“Continuous”的含义:即系统在生成过程中持续地接纳请求的结束与新请求的到来,做到边生成、边换请求。系统的并发度不再是一个固定值,而是根据等待队列的长度、运行中请求的状态以及显存预算自然浮动。

负载高时可以保持高并发,负载低时可能仅剩一两条序列;但无论并发度如何变化,其调度逻辑始终是同一个循环,在降低延迟、提升吞吐和高效利用显存之间取得了更优的Trade-off。

2.4 Prefill 与 Decode 在 CB 里的角色#

连续批处理是一种调度范式,它在具体执行时仍然要处理语言模型的两个经典阶段。这两个阶段的计算形态差异很大,因此在系统设计中承担了不同的角色。

  • Prefill:一次性处理一个提示词的全部 token,本质上是计算密集型的矩阵乘法操作。在连续批处理中,请求从等待队列被取出,经过 Prefill 建立完整的 KV 缓存,申请相应的显存页和 token 预算,之后转为运行态,加入后续的 Decode 循环。
  • Decode:每次只处理 1 个 token,计算量很小,但对显存带宽的压力很大,属于访存密集型操作。在连续批处理中,系统把当前处于运行态的多条请求合并为一个形状为 [B, 1] 的批次,通过一次前向计算同时推进所有请求的一步解码。

一个超长的 prompt 如果独占一整步迭代,就会再次造成队头阻塞,只不过阻塞对象从长 Decode换成了长 Prefill。为此,又发展出了 Chunked Prefill和PD交错作为连续批处理的配套机制,它们负责将长 Prefill 切碎、与 Decode 穿插执行,避免 Prefill 阻塞 Decode。

Decode 阶段对连续批处理的依赖性特别强。单条请求每步只算 1 个 token,kernel 启动、数据搬运等固定开销占比很高;只有把 B 条请求捆进同一次 forward,才能将这些开销有效摊薄,从而在相同硬件上实现更高的多请求吞吐。

2.5 CB 优化哪些指标?#

从系统设计的角度看,连续批处理直接作用于调度层面。

第一性收益含义
多请求吞吐 / GPU 有效利用率同样硬件与到达流下,单位时间完成更多 token
尾延迟形态短请求完成时间与同批最长输出解耦;有空位即可处理
负载CB 相对静态批
输出长度差异大(聊天、工具调用、有的 10 token 有的 1k)收益大,因为空槽回收频繁
输出几乎等长、离线整齐批处理收益小,静态批实现更简单也往往够用
到达率极低(经常只有 1 条在跑)几乎无换入空间,CB 退化成单请求

3 PD 交错概念#

在 Continuous Batching 框架下,新请求必须经过 Prefill 阶段才能进入运行队列参与 Decode。当一条请求携带极长的提示词(例如 2k token)时,若要求其 Prefill 在单次迭代内一次性完成,就会产生严重的调度问题。

  • 问题一:假设某次迭代调度了一条 2000 token 的 Prefill 请求。Prefill 本身是计算密集型操作,处理 2000 个 token 的矩阵乘和注意力计算将消耗大量时间,单步迭代的耗时因此被显著拉长。在这一整步迭代期间,所有已经在运行中的 Decode 请求都无法得到推进,只能被动等待。
  • 问题二:即使我们将长 Prefill 切分成块,如果调度策略只要等待队列中有 Prefill 请求,就需要先处理 Prefill,不能处理 Decode,那么当一个超长 prompt 被切成多块后,系统仍然会连续许多步只执行 Prefill 块。在这段持续的 Prefill 窗口中,所有正在运行中的 Decode 请求的输出进度仍然停滞。此时,那些已经处于生成阶段的短请求,其 dec@P(Prefill 窗口期间产出的 decode token 数)会降为零,用户侧观察到的是已经开始的生成突然卡住不动。

这两种本质是同一个问题:Prefill 与 Decode 在时间维度上未能有效交错,导致延迟敏感型请求被吞吐敏感型操作过度阻塞。

为了解决问题一,现代推理引擎引入了 Chunked Prefill 机制,但是要根治 Decode 饥饿问题,必须在同一个迭代步内实现 Prefill 与 Decode 的混合执行,即在每一轮迭代中同时推进一部分 Prefill和一部分 Decode。这种调度方式被称为 PD 交错(Prefill‑Decode interleaving)。

PD 交错的一步典型流程如下:

  • 优先保障 Decode 为当前运行队列中的每一条请求预留一个 token 的计算预算。这一步确保了无论 Prefill 压力多大,所有正在生成的请求至少能前进一步。

  • 剩余预算用于一个 Prefill Chunk 设定一个单步 token 总预算(例如 max_num_batched_tokens)。从该预算中扣除步骤 1 已占用的 Decode token 数后,若仍有剩余容量,则从等待队列中取出一个 Prefill 请求的下一块(chunk),其 token 数量不超过剩余预算。

  • 预算不足时推迟 如果当前剩余 token 预算不足以容纳一个最小的 Prefill chunk,则该 Prefill chunk 被推迟到后续迭代。

伪代码如下:

for each iteration:
budget = max_num_batched_tokens
# 1. 先安排 Decode
for each seq in running:
schedule(seq, 1 token for decode)
budget -= 1
# 2. 若有剩余预算,从 waiting 中拉一个 Prefill chunk
if waiting is not empty:
next_chunk = waiting.front().next_chunk(max_tokens = budget)
if next_chunk.size > 0:
schedule(next_chunk)

这种混合策略使得系统能够在同一个 batch 中同时包含 [B_dec, 1] 的 Decode 序列和 [1, N_chunk] 的 Prefill 序列,两者在一次 forward 中并行完成。

引入这一机制后,需要一套与之匹配的评价指标,以准确衡量短请求在多长请求并存时的服务质量。

定义两个关键指标:

dec@P:在一条长 Prefill 请求尚未完成其全部 Prefill 的时段(Prefill 窗口)内,其他正在运行的短请求已经产出的 decode token 数。这个值越大,说明短请求被阻塞的程度越低。

short_P:从长请求进入系统排队起,到所有与之同时存在的短请求全部完成生成所经历的时间。这个值越小,说明短请求受长请求拖累的程度越轻。

需要注意的是,从总吞吐量(tokens/s)的角度看,PD 交错与纯 Prefill‑first 策略往往非常接近。两者完成的总 token 数几乎没有差异,PD 交错仅仅是通过细粒度穿插改变了完成的时序。甚至在部分实现中,由于 Decode 和 Prefill 合批可能带来额外的 kernel 调度开销或 batch 形状不规则,总吞吐还可能出现微弱下降。因此,在验证和对比时,应优先观察 dec@P 和 short_P 这类延迟分布指标,总吞吐量仅作为辅助参考,不应作为主要优化目标。

参考:

联系杜子源源