服务大型语言模型本质上是一个伪装成硬件问题的调度问题。现代GPU是一台吞吐量机器。它希望一次执行数千次算术运算。但服务接收到的请求通常一次一个地到达,时机不可预测,提示和响应的长度差异很大。推理调度器的工作是管理一台擅长批量工作的机器,而负载却是零散而不规则地流入。
本文首先解释单个请求如何在单个GPU上工作,然后以显而易见的方式扩展它,即将许多请求一起批处理,展示它在真实流量下如何崩溃。然后解释连续批处理,这是驱动vLLM、Text Generation Inference(TGI)、SGLang以及当今几乎所有严肃推理系统的调度器设计。最后,探讨了这些系统实际上的差异以及原因。
连续批处理在每个解码步骤都会更新批次,而不是每个批次只更新一次。这消除了导致朴素静态批处理低效的队头阻塞和空闲槽位,这也是vLLM、TGI和SGLang共享的调度器设计。
每一步都能接纳新任务,这种自由度需要两个支撑机制。 抢占(重新计算或交换)处理了生成过程中KV缓存耗尽的情况,而分页KV缓存管理(PagedAttention)通过消除碎片,使内存的可支撑范围大幅扩展。
每个主要系统都共享这一基础,并且日益共享一套工具包。它们都面临同一个难题:将新请求计算密集的预填充集成到正在进行的解码流中,而解决它的主要技术(分块预填充、前缀缓存、分页内存)如今在vLLM、TGI和SGLang中都很常见。差异主要在于侧重点和默认设置。
使用Transformer生成文本分为两个阶段。第一阶段是预填充。模型在单次前向传播中摄取整个提示,一次计算所有标记之间的注意力,并构建键值(KV)缓存,即存储下来的每个标记的键和值,供后续标记关注。预填充是计算受限的。这里有大量并行数学运算需要完成,GPU的算术单元是瓶颈。第二阶段是解码。模型一次生成一个标记,每个新标记都需要自己的前向传播。解码是内存受限的。每一步只做极少的计算,但必须通过内存流式读取模型的全部权重以及不断增长的KV缓存才能完成。
KV缓存是将在本文后面主导每个决策的资源。模型处理的每个标记,无论是提示中的还是生成的,都会向缓存中添加一条条目,只要请求处于活动状态,该条目就会驻留在GPU内存中。长时间的对话意味着更大的缓存。许多并发请求意味着许多缓存在竞争同一有限的内存池。缓存让模型避免了每一步都重新计算整个序列的注意力,但也正是它让内存(而非计算)成为你通常最先耗尽的资源。
当一次只运行一个请求时,GPU在解码期间几乎处于空闲状态。一个解码步骤要在芯片中流转数GB的权重,以产生一个标记所需的算术量,导致计算单元大部分未被使用。
如果单个请求在解码期间浪费了GPU,直观的解决方案是将多个请求一起运行。由于解码是内存受限的,这种方法效果很好。一旦你为一条序列在芯片中流转模型权重付出了成本,在同一处理步骤中添加更多序列几乎是免费的。这就是静态批处理(有时称为请求级批处理)。
它收集一组共N个请求,并将每个序列填充到最长序列的长度,因为张量需要统一的形状。然后它逐步对整个批次进行前向传播,直到批次中的每个序列都完成生成。然后它一次性返回全部N个响应,并开始处理下一组。
但请注意,一旦批次开始,它就从始至终承诺处理一个固定的请求集合。正是这个决定——在批次的整个生命周期内冻结其“成员资格”——导致了静态批处理的所有问题。
该修复方法由2022年推出的Orca系统引入,是将调度决策从批次级别下放到单个词元级别。调度器不再选择一个批次并运行到完成,而是在每一次前向传播之前运行,并重新决定批次中包含哪些请求。批次不再是一个固定的组,而是一个随每个词元变化的活动花名册。这就是连续批处理,也称为迭代级或进行中批处理。
引擎运行一个循环。调度器选出当前批次,模型对该批次执行一次前向传播,采样的词元被追加到每个序列中,调度器更新其记账信息,退役任何刚发出序列结束词元的请求,并立即释放其KV缓存。由于释放出的容量在紧接着的下一步就可用,等待中的请求可以立即被接纳。队头阻塞消失了,因为短请求在完成的那一刻就离开,而不是等待同批次的其他请求。空闲槽位消失了,因为释放的容量被立即回填。中途准入问题也消失了,因为新到达的请求在一两步内就能加入,而不是等待整个批次排空。
连续批处理引入了两个新问题。首先,新接纳的请求不能简单地开始解码。必须首先对其提示词进行预填充,这是一个计算密集的前向传播,如果不小心,可能会阻塞其他所有请求的解码。其次,由于每一步都可以自由接纳请求,你可能会过度提交KV缓存,并在生成中途耗尽内存。
新请求的预填充是一个大的、计算密集型突增,而保持现有请求存活的解码步骤则是一串小的、内存受限的更新。每个引擎都必须在不阻塞解码流的情况下,将这种突增融入正在进行的解码流中。
这个问题的三个答案是预填充优先、分块预填充和分离式架构。预填充优先会暂停解码来运行预填充。它很简单,但每当新请求到达时,都会在其他每个请求的输出中产生可见的卡顿。分块预填充将长的预填充切分成更小的片段,并将它们交织到解码步骤中,因此新请求在推进的同时,不会有任何一步被它的预填充主导。分离式架构更进一步,在完全独立的GPU池上运行预填充和解码,并在它们之间传输KV缓存,因此这两个阶段根本不会竞争。
分块预填充已成为大多数现代引擎的默认选择,因为它在不增加额外硬件的情况下平滑了延迟。分离式架构是一种更新、更重的方法,主要在大规模场景下才值得投入。
第二个问题是内存。由于连续批处理只要有空间就不断接纳请求,而且每个活跃请求的KV缓存会随着其生成的每个词元而增长,一个片刻前还放得下的批次就可能溢出GPU的内存池。要解决这个问题,你需要更高效地使用现有内存,并且当内存不足时,收回一些。
早期系统为每个请求分配一个连续的内存块,大小按可能的最大长度预留,这会导致内存池碎片化,并使部分内存因过度预留和缝隙而浪费。由vLLM引入的PagedAttention借鉴了操作系统中的虚拟内存和分页思想。KV缓存被划分为小的固定大小块,请求的逻辑连续块序列通过每个请求的块表映射到可位于GPU内存中任意位置的物理块上。请求看到的是干净、连续的视图,但物理现实是散布的块与其他请求的块及空闲空间混在一起。这消除了碎片化,允许内存随着序列增长按需分配,并使抢占和前缀共享变得廉价。
分页内存能大大扩展内存池,但在足够高的负载下仍可能被填满。此时,调度器需要有权收回内存,逐出或冻结先前接纳的请求,以便其余请求继续运行。这被称为抢占,有两种实现方式。重计算会丢弃请求的KV缓存以立即释放内存,然后稍后通过重新运行预填充来重建缓存:对内存友好,但需要再次付出计算成本。交换会将KV块复制到CPU内存,并在请求恢复时再复制回来,保留了计算结果,但代价是外设组件互连快速(PCIe)带宽和主机RAM。vLLM默认使用重计算,但两者都被广泛使用。抢占哪个请求是一个策略选择。引擎通常首先逐出最新或优先级最低的请求,并在内存释放后将其恢复。
上述技术——分块预填充、前缀缓存和分页内存——在很大程度上已被整合为一个共享工具包,所有成熟的引擎都在实现这些技术。真正的差异与其说在于某个引擎采用了哪些技术,不如说在于每个想法源自何处、每个引擎默认侧重什么,以及它是如何构建的。
vLLM 是内存优先的基线实现,也是其他大多数引擎衡量自身的参考实现。PagedAttention是它的标志性贡献,其调度器也展现了同样的思路。较旧的V0引擎采用预填充优先的方法,随之而来的是延迟问题;而重新架构的V1引擎默认使用分块预填充,只需决定每个请求每步推进多少个token。
TGI(Hugging Face的Text Generation Inference)拥有相同的核心技术,包括分块预填充和前缀缓存,但它的独特选择在于结构上。它将服务拆分为两部分。首先,一个快速的路由器,用Rust编写,处理请求排队和所有批处理决策。其次,独立的工作进程只负责运行模型的前向传播。它的调度旋钮,如max_batch_total_tokens和waiting_served_ratio,位于该路由器中,控制着它填充每个批次的激进程度。Hugging Face尤其重视前缀缓存,并报告TGI对前缀缓存的处理使其在非常长的重复提示上表现强劲。其起源于生产级服务。
SGLang 将前缀复用推向了极致。它的RadixAttention将缓存的前缀组织成一棵树,使得共享公共前缀(如系统提示、一组少样本示例或一组工具定义)的请求可以复用彼此的KV缓存,而无需重新计算。前缀缓存在如今各引擎中已经很普遍,但SGLang的基数树版本是早期且有影响力的实现,并且该引擎在提示重叠严重的工作负载(如智能体和结构化生成流水线)上仍然特别强大。供应商优化的引擎则完全朝着另一个方向推进。NVIDIA的TensorRT-LLM会提前编译模型并执行“飞行中批处理”,以灵活性为代价换取了原始速度上的优势。
连续批处理与PagedAttention是同一回事吗?
不是。连续批处理是一种调度技术(每一步运行哪些请求)。PagedAttention是一种内存管理技术(KV缓存如何存储)。它们是互补的。正是PagedAttention让内存足够支撑连续批处理一次接纳大量请求,但你可以只实现其中一种而不必实现另一种。
连续批处理会损害每个请求的延迟吗?
间接会。连续批处理提高了吞吐量并让GPU保持满载,但更大、更满的批次意味着每一步要做更多工作,内存压力也随之上升。调整批次大小和token预算,并使用分块预填充来避免预填充停顿,这就是在吞吐量与首Token时延及Token间时延之间进行平衡的方法。
——
一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。