静态、动态与连续批处理
GPU 专为高度并行的计算工作负载而设计,每秒能够执行数万亿甚至数千万亿次浮点运算(FLOPs)。然而,LLM 往往无法充分利用这些 GPU,因为芯片的大部分内存带宽都花费在加载模型参数上。
批处理(batching)有助于缓解这一瓶颈。在生产环境中,你的服务可能会同时涌入大量请求。与其逐个处理每个请求,不如将它们批量打包,这样就能在多个请求之间复用同一份已加载的模型参数,从而大幅提升吞吐量。
请使用下面的模拟器从宏观层面理解不同的批处理策略。
静态批处理
最简单的批处理形式是静态批处理(static batching)。在这种方式下,服务器会一直等到固定数量的请求到达,然后将它们作为一个批次一起处理。
静态批处理虽然易于实现,但存在明显的缺点。
- 批次中的第一个请求被迫等待最后一个请求,徒增不必要的延迟。想象一台打印机,除非你排好一定数量的文档,否则它不会开始打印,无论最后一份文档多久才能到达。
- 批次中的请求并不完全对等。在 LLM 推理中,有些请求可能只生成很短的回复,而另一些则可能涉及冗长、逐步的推理过程。由于批次中的所有请求都必须等到最慢的那个完成,这会导致计算资源浪费和延迟增加。
动态批处理
为解决静态批处理的问题,许多系统采用动态批处理(dynamic batching)。这种方式仍然会将到达的请求收集成批次,但不再坚持固定的批大小。相反,它设定一个时间窗口,并处理该时间窗口内到达的所有请求。如果批次提前达到大小上限,则立即启动。这就像一辆公交车,要么按固定时刻表发车,要么一旦坐满就发车,以先到者为准。
动态批处理有助于在吞吐量和延迟之间取得平衡。它确保较早的请求不会无限期地被后来的请求延误。然而,由于有些批次在启动时可能并未完全装满,它并不总能实现最大的 GPU 效率。另一个缺点是,与静态批处理一样,批次中最长的请求仍然决定了批次的完成时间;短请求不得不无谓地等待。
连续批处理
对于 LLM 推理而言,输出序列的长度差异很大。有些用户可能只问简单的问题,而另一些则要求详细的解释。静态和动态批处理都迫使短请求等待最长的那个,导致 GPU 资源无法得到充分利用。
连续批处理(continuous batching),也称为飞行中批处理(in-flight batching),正是为解决这些低效问题而设计的。连续批处理不会强制整个批次全部完成才返回结果。相反,它让批次中的每个序列独立地完成,并在完成后立即用新序列替换它。这就像一条流水线,只要一件产品完成(无论耗时多久),就立即加入新产品,让流水线始终保持满负荷运转。

使用连续批处理生成七个序列。在第一次迭代(左)中,每个序列都根据其提示词(黄色)生成一个 token(蓝色)。随着时间推移(右),各序列在不同迭代中通过输出序列结束 token(红色)而完成,此时会插入新的序列。图片来源
该技术使用迭代级调度(iteration-level scheduling),即批次组成会在每次解码迭代中动态变化。一旦批次中的某个序列完成 token 生成,服务器就会插入一个新请求占据它的位置。这最大限度地提高了 GPU 占用率,并通过避免原先等待批次中最慢序列完成所产生的空闲时间,让计算资源始终忙碌。
主流的推理框架,如 vLLM、SGLang、MAX、TensorRT-LLM(飞行中批处理)和 LMDeploy(持久批处理),都支持连续批处理或类似的机制。关于长批次或混合长度批次中的内存管理,请参阅 PagedAttention。
分块预填充
当新请求到达而其他请求正在解码时,连续批处理会引入调度冲突。在一个预填充(prefill)迭代中处理新请求的整个提示词,可以最小化首 token 时间(TTFT),但长预填充会推迟所有正在解码的请求生成下一个 token。在流式应用中,用户可能会看到输出暂停,而另一个用户的提示词正在被处理。
分块预填充(chunked prefill) 将提示词拆分为更小的 token 范围,并在多个迭代中调度它们。每个块都会扩展该请求的 KV 缓存,后续块会关注前面已处理的提示词 token。由于注意力计算并未改变(每个 token 仍通过 KV 缓存关注所有先前的 token),分块预填充在数学上等价于一次性处理整个提示词,并且只有在处理完最后一个块之后才会输出第一个 token。
调度器可以在同一个批次中,将一个预填充块与来自活跃请求的解码 token 组合在一起。SARATHI 将这一方式称为解码最大化批处理(decode-maximal batching):预填充块提供足够的并行工作量来饱和 GPU 的计算能力,而解码 token 则以极小的额外成本搭载在同一次模型执行上。这可以防止长提示词垄断某一次迭代,还能在流水线并行下减少流水线气泡。
分块预填充的好处在于,即使某个请求的提示词很长,预填充计算也会被划分为更小的块。活跃的解码请求不必等待一次长预填充迭代结束,它们可以在预填充块之间继续生成 token。这降低了 token 间延迟(ITL),并使流式响应更加平滑。
代价在于,分块会引入额外的调度和注意力开销,因为提示词是通过多个较小的预填充步骤处理,而不是一次性完成。因此,TTFT 可能会增加,尤其是在使用较小块大小时。
大多数推理框架都允许你调整块大小,不过它们通常将其表示为每个批次的 token 预算,而不是块数量:
MAX
max serve --model meta-llama/Llama-3.1-8B-Instruct \
--enable-chunked-prefill \
--max-batch-input-tokens 8192 \
--chunked-prefill-min-chunk-size 512
--max-batch-input-tokens 设置每个批次中未编码 token 的目标数量,而 --chunked-prefill-min-chunk-size 则为调度器可创建的最小块大小设定了下限。
vLLM
vllm serve --model meta-llama/Llama-3.1-8B-Instruct \
--enable-chunked-prefill \
--max-num-batched-tokens 8192
--max-num-batched-tokens 限制了调度器在单次迭代中最多可发出的 token 数量,因此较长的提示词会被拆分到多次迭代中。
SGLang
sglang serve --model-path meta-llama/Llama-3.1-8B-Instruct \
--chunked-prefill-size 8192
--chunked-prefill-size 是单个预填充块中的最大 token 数。在 SGLang 中将其设为 -1 可禁用分块预填充。
选择取值时,请注意以下几点:
- 较小的块 为调度器提供了更多运行解码的机会,降低了活跃请求的 ITL 尖峰。
- 较大的块 能更高效地处理新提示词,通常可改善 TTFT,但活跃的解码请求在 token 之间的等待时间可能更长。
- 过小的块 会降低 GPU 利用率并增加注意力开销,因为后续块必须重新读取先前块创建的 KV 缓存条目。
没有放之四海而皆准的最佳值。合适的大小取决于模型、GPU、提示词长度分布、并发度以及延迟目标。它应当被视为一个与工作负载相关的调度参数,而非通用常量。
常见问题
LLM 批处理中的填充 token 是什么?
文本序列在分词(tokenization)后的长度天然不同。例如:
Sentence A: "Hello world" # [15496, 995] - length 2
Sentence B: "How are you today?" # [2437, 389, 345, 1909] - length 4
由于序列长度不同,你无法直接将这些序列堆叠成一个稠密的矩形张量。填充(padding)通过向较短的序列添加占位 token 来解决这一问题,使批次中每个请求的张量长度相同。常见的填充 token 形式包括 PAD、<pad> 或 token ID 0,具体 ID 取决于分词器。
注意力掩码(attention mask)会告诉模型哪些位置是填充,但填充仍然会浪费计算和内存。如果一个批次包含长度如下的序列:
[1024, 1000, 50, 20]
较短的序列可能会被填充到长度 1024。这能保持 GPU 核函数的张量形状规整,但 50-token 和 20-token 的请求现在携带了大量未使用的位置。
LLM 推理中的不规则张量是什么?
不规则张量(ragged tensor)可以在不把所有序列都填充到同一固定长度的情况下表示变长序列。运行时只存储真实的 token,以及诸如序列长度、偏移量或 KV 缓存块位置等元数据。这有助于推理引擎更高效地处理混合长度的提示词和连续批处理,尤其是配合诸如 PagedAttention 这样的分页 KV 缓存布局时。