跳到主要内容

持续批处理:LLM 服务中提升 GPU 利用率的最关键技术

· 阅读需 14 分钟
Tian Pan
Software Engineer

生产环境中大多数 LLM 推理基础设施的故障并不是模型故障——而是调度故障。团队部署了一个高性能模型,进行了压力测试,却发现用户在等待的同时,昂贵的 GPU 时间仅以 35% 的利用率在消耗。罪魁祸首几乎总是静态批处理(Static batching):这是从传统深度学习中继承下来的默认设置,但根本不符合语言模型生成文本的方式。

持续批处理(Continuous batching)——也称为迭代级调度(Iteration-level scheduling)或飞行中批处理(In-flight batching)——是解决这一问题的核心机制。它不是一个微调旋钮,而是对推理循环运行方式的架构性改变。在使用相同硬件的情况下,使用该技术的系统与不使用的系统相比,吞吐量可能相差 4–8 倍。

要理解其中的原因,首先需要了解原始方法到底出了什么问题。

为什么静态批处理会浪费你的 GPU

传统的批处理会收集一组请求,将它们作为一个整体由模型处理,直到每个序列都生成完毕,然后再进入下一个批次。这在图像分类中运行良好,因为每个输入都会产生一个固定大小的输出。但对于语言生成来说,这简直是一场灾难。

问题在于输出长度的差异。一个聊天请求可能在 15 个 token 内完成,而同一批次中的代码生成请求可能需要 800 个 token。在短请求完成后的 785 次迭代中,为其分配的 GPU 显存和计算槽位都处于闲置状态(即填充 padding),而整个批次必须等待最长的序列执行完毕。你支付了全部的吞吐成本,但利用率曲线显示 GPU 占用率仅为 30–60%。

动态批处理(Dynamic batching)通过在时间窗口内(例如 50ms)对请求进行分组来减少准入延迟,从而改善了这一状况,但批次级调度的问题依然存在:一旦窗口关闭,该批次将作为一个整体运行,直到最后一个序列完成。

持续批处理通过将调度决策从 请求 粒度转移到 迭代 粒度解决了这个问题。调度器在模型的每次前向传播时运行一次,而不是每个请求运行一次。当一个序列发出终止符(EOS)并结束时,其显存槽位会 立即 被释放。下一个等待中的请求会在 下一次迭代开始前 插入到批次中。没有请求需要等待另一个请求完成——批次构成在每个解码步骤都会发生变化。

吞吐量的提升非常显著。在大规模场景下引入迭代级调度的 ORCA 论文(OSDI 2022)表明,在同等延迟目标下,其吞吐量比 FasterTransformer 提高了 36.9 倍。Anyscale 的真实基准测试显示,与原生的 HuggingFace Transformers 推理相比,性能提升了 8 倍。结合基于 PagedAttention 的 KV 缓存管理,vLLM 的初始版本吞吐量比 HuggingFace Transformers 高出 24 倍,比 HuggingFace TGI 高出 3.5 倍。

调度器是如何运作的

在每次前向传播中,持续批处理调度器会执行一个简短的循环:

  1. 扫描运行中的批次,查找已完成的序列(已发出 EOS)
  2. 释放它们的 KV 缓存块
  3. 从队列中提取等待中的请求——数量取决于显存和批次大小限制
  4. 将所有活动序列连接成一个复合批次
  5. 运行一次模型前向传播;每个序列产生其下一个 token
  6. 重复上述过程

连接(Concatenation)步骤是使其在结构上与众不同的原因。静态批处理要求序列填充到相同长度,因为批处理矩阵运算需要统一的张量形状。而持续批处理则构造一个带有注意力掩码(Attention masks)的“超级序列”,防止任何请求关注到其他请求的 token。没有填充,没有浪费的计算——每一个 GPU FLOP 都在处理真实的 token。

这种连接形式自然地与 FlashAttention 的变长算子(Variable-length kernel)变体相集成,尽管长度不同,它仍能在单个 GPU 算子调用中处理所有序列。结果是,即使批次中混合了处于不同进度的短序列和长序列,GPU 占用率依然很高。

PagedAttention:内存管理层

持续批处理解决了 何时 调度请求。PagedAttention(vLLM, SOSP 2023)则解决了 何处 存储它们的 KV 缓存。

在 PagedAttention 出现之前,LLM 推理框架会为每个请求预分配一块连续的 GPU 显存,大小取决于其 最大可能输出长度。这由于碎片化导致了 60–80% 的显存浪费:过度保留的槽位、对齐间隙,以及大多数序列并不会用完其最大分配空间的事实。

PagedAttention 将操作系统的虚拟内存分页模型应用于 KV 缓存管理。KV 缓存被划分为固定大小的块(vLLM 默认每块 16 个 token),根据序列生成 token 的需求动态分配,而不是预先分配。一个块表将每个序列的逻辑块映射到物理 GPU 显存位置——这些块不需要连续。显存浪费降至 4% 以下(每个序列仅浪费最后一个未填满的块)。

第二个好处是:物理块可以通过写时复制(Copy-on-write)语义在序列之间共享。束搜索(Beam search)的分支、并行采样以及共享相同系统提示词(System prompt)的请求,在发生分歧之前都可以引用相同的物理 KV 块。对于束搜索,这可以减少高达 55% 的开销,并使吞吐量比非共享分配提高 2.2 倍。

SGLang 通过 RadixAttention 进一步扩展了这一能力:这是一种基数树(Radix tree)数据结构,可以在 不同请求 之间维护 KV 缓存,实现自动前缀重用。共享系统提示词、少样本示例或 RAG 上下文的请求可以重用彼此已缓存的 KV 块,而无需重新计算。在具有大量前缀共享的工作负载中,这可以使推理速度提高 5 倍。

权衡曲线:连续批处理何时有效,何时无效

连续批处理 (Continuous Batching) 的收益与输出长度的方差成正比。在每个请求都精确生成 50 个 Token 的工作负载中,相比静态批处理的优势几乎为零——因为没有需要消除的空闲填充 (Idle padding)。在输出范围从 5 到 1000 个 Token 的混合聊天工作负载中,性能提升可达 4–8 倍。

工作负载的适配情况如下:

  • 聊天、智能体 (Agents)、交互式助手:高方差,大量并发用户,长短响应混合。连续批处理在此类场景中具有极强的优势——这正是它设计的初衷。
  • 变动 QPS 下的在线 API:序列会被立即接纳,而不是等待批处理填满,这在中等负载下显著降低了首词延迟 (TTFT)。
  • 具有共享前缀的 RAG 流水线:RadixAttention (SGLang) 或前缀缓存 (vLLM) 通过跨请求的 KV 重用复合提升收益。
  • 具有同质输出的离线批处理推理:静态批处理在此场景中具有竞争力,且通常更简单。当序列长度预先已知且统一时,连续批处理的调度开销几乎没有收益。
  • 极低 QPS(每秒个位数请求):所有方法的表现都差不多;连续批处理的调度开销比利用率收益更重要。

高并发下的干扰问题是从业者最容易忽略的细微差别。当一个长上下文请求进入批次时——例如 32K Token 的 RAG 文档或长代码文件——其 Prefill (预填充) 计算是计算密集型的 (Compute-bound),会在数毫秒内填满 GPU 矩阵乘法单元。在该 Prefill 运行期间,所有当前活动的序列的 Decode (解码) 迭代都会停顿。正在生成内容的用户会看到词间延迟 (TBT) 突然飙升。这就是 Prefill-Decode 干扰问题。

标准的缓解措施是 分块预填充 (Chunked prefill):与其一次性处理长 Prompt,不如将其拆分为块(通常默认每个块 512 个 Token),并将每个块与常规的 Decode 迭代交错进行。vLLM 通过 --enable-chunked-prefill 暴露了这一功能。其权衡是略微降低了纯吞吐量,以换取活动会话中更可预测的 TBT。

一种更激进的解决方案是 Prefill-Decode 解耦 (Prefill-decode disaggregation)(如 DistServe 架构):它将 Prefill 计算和 Decode 计算分配给完全独立的 GPU 池,从而消除了干扰,代价是池之间的 KV 缓存传输开销。随着交互式和长上下文工作负载共享基础设施,这种模式正逐渐成为大规模部署的标准。

高并发下的失效模式

理解失效模式是区分优秀团队与普通团队的关键。

逐出级联 (The Eviction Cascade)

最严重的生产故障发生在长上下文请求压垮 KV 缓存容量时。其过程如下:

  1. 几个长上下文请求(RAG、文档分析)消耗了大量的 KV 缓存块。
  2. 内存压力迫使调度器 抢占 (Preempt) 短上下文请求——它们的 KV 块被逐出,请求被送回等待队列。
  3. 当内存释放后,被抢占的请求重新进入队列,必须从头开始 重新计算 (Recompute) 其 KV 缓存——包括它们已经生成的所有 Token。
  4. 如果新请求不断到达,GPU 将花费所有周期为被抢占的序列计算 Prefill,从而产生零个新的输出 Token。

明显的特征是:GPU 利用率达到 100%,吞吐量持平或下降,抢占计数器上升,P99 延迟从 200ms 飙升至数秒。系统进入了一种比以往任何时候都忙碌但却不做任何有用功的状态。

预防措施需要工作负载隔离:永远不要将长上下文请求(10K+ 上下文的 RAG)路由到与短上下文交互式请求相同的服务实例。容量计算是无情的——在运行 Llama 13B 的 40GB A100 上,模型权重消耗约 26GB,剩下约 14GB 用于 KV 缓存。在 2048 Token 的平均序列长度下,大约只能容纳 7 个并发序列,随后就会开始发生抢占。

基于内存的队头阻塞 (Memory-Based Head-of-Line Blocking)

连续批处理消除了短请求在长请求后面等待的经典队头阻塞问题。但在饱和状态下,一种更隐蔽的形式依然存在。

当 GPU 内存耗尽且无法接纳等待队列中的请求时,FIFO (先进先出) 有序队列会完全停滞——即使队列中较靠后的短请求本可以装入内存。强制执行公平性约束的系统无法对队列进行重新排序。研究记录了在持续饱和状态下,连续批处理的队头阻塞 (HOL blocking) 时间可达数十秒。设计良好的调度器会检测到这种情况,要么打破 FIFO 顺序接纳短请求,要么在队列深度积压之前在 API 网关处应用背压 (Backpressure)。

块大小碎片化 (Block Size Fragmentation)

vLLM 默认的 16 Token KV 缓存块大小是有意为之的。将其增加到 128 Token——对于长上下文工作负载来说,这是一个很有诱惑力的配置更改——会在高并发下导致严重的内碎片 (Internal fragmentation):在 256 个并发序列下,128 Token 的块会浪费大约 16,000 个 Token 插槽,而这些插槽本可以容纳额外的序列。块大小改变很少能提高吞吐量,反而经常会损害吞吐量。

选择和配置连续批处理运行时

目前的生产环境选项及其权衡:

vLLM 是大多数团队的默认选择:它拥有最广泛的模型支持、简单的部署流程和活跃的开发社区。v0.6.0 版本(2024 年 9 月)解决了重大的 CPU 开销问题——在该版本之前,只有 38% 的实际耗时用于 GPU 计算,仅 HTTP 服务器开销就占了 33%。在 v0.6.0 之后,与之前的版本相比,Llama 8B 的吞吐量提高了 2.7 倍,每个输出标记的时间 (time-per-output-token) 快了 5 倍。

TensorRT-LLM 在 NVIDIA 硬件上编译后可提供最高的原始吞吐量,但它需要一个可能耗时数小时的编译步骤,且移植性较差。它最适合在受控硬件上进行长期运行的单模型部署。

SGLang 在现代硬件上的表现与 TensorRT-LLM 相当,并增加了 RadixAttention 用于跨请求的 KV 缓存复用——这对于具有大量前缀共享的聊天机器人和 RAG 工作负载来说是一个决定性的优势。LMSYS 基准测试显示,在 2024 年,SGLang 在 Llama-70B 上的吞吐量比 vLLM 高出 3.1 倍。

vLLM 的关键配置参数:

  • max_num_seqs:控制最大并发序列数。当观察到高抢占率 (preemption rates) 时,降低此参数可以减少级联风险,但会牺牲吞吐量。
  • --enable-chunked-prefill:强烈建议用于混合交互式/长上下文的工作负载。它将 Prefill (预填充) 分块与 Decode (解码) 交替进行,以防止 TBT 飙升。
  • gpu_memory_utilization(默认 0.9):调高此参数可以增加 KV 缓存容量,但会减少模型权重开销的缓冲空间。建议不要超过 0.95。

在监控方面,将 vllm:num_preemptions 作为首要的健康信号。抢占率上升是接近驱逐级联 (eviction cascade) 的早期预警。在指标变得危急之前设置告警——当吞吐量明显下降时,级联通常已经发生了。

调度问题尚未完全解决

连续批处理将 LLM 服务在现实工作负载下的 GPU 利用率从 30–60% 提升到了 80–95%。ORCA 论文的见解——迭代级调度 (iteration-level scheduling) 比请求级调度更好地匹配了语言生成的统计结构——是一个真正的阶跃式进步。

悬而未决的问题在于边缘情况:如何在没有干扰的情况下处理极度混合的工作负载,如何在解耦的 Prefill 和 Decode 池中高效分配 KV 缓存,以及如何在多节点之间进行调度而不会在节点间产生队头阻塞 (head-of-line blocking)。这些都是活跃的研究和工程领域,分块预填充 (chunked prefill) 和解耦 (disaggregation) 是目前的尖端解决方案。

对于当今大多数生产团队来说,实际行动更为简单:确保你的服务运行时使用了连续批处理(vLLM、SGLang、TGI 和 TensorRT-LLM 默认都支持),将长上下文和短上下文工作负载隔离到不同的实例上,如果看到 TBT 飙升则启用分块预填充,并密切关注你的抢占计数器。你现有的硬件将能够支撑明显更多的流量。

References:Let's stay in touch and Follow me for more thoughts and updates