首页 / 文章 / 连续批处理可改善P50,但可能毁掉P99:实测权衡
← 返回
IT技术

连续批处理可改善P50,但可能毁掉P99:实测权衡

✍️ zhirenhun 📅 2026/8/18 👁 126 阅读 ⏱ 84 分钟
连续批处理可改善P50,但可能毁掉P99:实测权衡

连续批处理是现代LLM服务系统的核心特性。它被包含在每个主要引擎中,经常出现在对比图表中,其对吞吐量的影响已经得到充分证实:Anyscale广受引用的基准测试报告称,与基础服务设置相比,吞吐量提升高达23倍,而Orca论文在早期系统上测量到高达36.9倍提升。这些结果是真实的,它们主要描述吞吐量和中位数(p50)延迟。例如,Anyscale文章的标题强调“在LLM推理中实现23倍吞吐量同时降低p50延迟”,突出典型性能的改进。本文探讨连续批处理对中位数之外延迟的影响。

悬而未决的问题是,分布在尾端(p99)的用户会遇到什么情况。本文探讨连续批处理如何不仅改变平均值,而且重塑整个延迟分布形态。虽然有时有人说连续批处理会大幅恶化尾部延迟(p99),但现实更为微妙:它重新分配了延迟方差,而不是简单地增加了它。

对于静态批处理,大部分延迟发生在准入阶段,在高负载下这很快就会成为瓶颈。另一方面,连续批处理允许几乎立即准入,但在令牌流式传输过程中可能引入偶尔的暂停(抖动)。延迟的整体模式和涉及的权衡取决于您的工作负载和引擎配置。本文探讨这些动态,使用实测数据来说明现代默认配置(如vLLM中的那些)如何缓和一些传统的极端情况。

请查看GitHub仓库以获取原始JSON文件、套件日志、绘图脚本、论文笔记以及H200运行中的三个实测图表。本文解释这些数字的含义;该仓库是可引用的存档。

测试设置

本文中的所有内容都是在运行vLLM v0.24.0和Llama 3.1 8B的DigitalOcean H200 GPU Droplet上进行的实测,由混合长度流量跟踪驱动(大部分请求较短,一些中等,一些较长):

  • 连续与静态式(门控,B=16)准入在相同服务器、相同跟踪、相同种子下进行比较
  • 到达率从1到20请求/秒递增,用于连续批处理组
  • 分块预填充的开与关,一个实际的配置开关

包含原始JSON文件、套件日志、绘图脚本、论文笔记以及H200运行中三个实测图表的Github仓库:github.com/anishsingh20/continuous-vs-static-batching

TL;DR

  • 著名的23x数字是吞吐量和p50的结果,不是尾部结果。 Anyscale自己的基准测试标题说“同时降低p50延迟。”混合长度并发负载下的尾部行为是故事中文献记录不足的一半,也是生产环境痛点所在,正如我相关的文章p50与p99延迟:中位数基准测试为何误导AI代理工作负载以一般形式论证的那样。来源:Anyscale连续批处理基准测试Yu等人,Orca,OSDI 2022
  • 连续批处理的尾部代价出现在令牌流内部,通过两个可分离的机制:长预填充阻塞进行中的解码,以及KV缓存压力下的抢占。下面的GPU Droplet测试工具在真实硬件上测量了这两种效应的大小。
  • 现代vLLM默认配置已经缓解了第一种机制。 根据vLLM自身的优化文档,vLLM V1默认启用分块预填充并采用解码优先的调度策略。关于预填充停滞的戏剧性说法描述的是没有这种缓解措施的引擎和配置。
  • 第二种机制——抢占——在第一次修复后依然存在。 vLLM V1的默认抢占模式是RECOMPUTE:在KV缓存压力下,正在运行的请求被驱逐,其整个预填充稍后重新运行。引擎在其Prometheus指标中统计这些事件,本文中的测试工具在每次运行周围记录该计数器。
  • 静态式准入保持了一个测量所揭示的真正优势:一旦答案开始生成,令牌流更平稳(在10请求/秒时,门控间隔p50为52.8毫秒,而连续批处理为129.6毫秒),因为锁定的批处理不接受任何可能中断它的东西。它的适用场景真实但狭窄。
  • GPU实验在H200 Droplet上运行。 门控准入在TTFT上严重失利(p50为196毫秒,而连续批处理约为19至48毫秒)。在10请求/秒下关闭分块预填充使最差间隔p99从189.9毫秒恶化到267.8毫秒(1.41倍)。在141 GB显存上运行8B模型时,抢占保持为零。完整JSON和图表:github.com/anishsingh20/continuous-vs-static-batching

术语和概念表

如果你刚接触LLM服务领域,这里是一张本文会用到的术语和概念表。

你会看到的术语 含义 简单示例
请求 一个用户向模型请求答案。 你发送“总结这封邮件”并等待回复。
Token 模型读取或写入的一小段文本(大致相当于一个短单词或单词的一部分)。 短语“Digital Ocean”可能由几个Token组成,而不是一个。
提示词 你发送的文本。 你的问题加上任何系统指令。
预填充 模型的第一项工作:在开始回答之前读取整个提示。提示越长,预填充耗时越长。 比如在打出回复第一句话之前先浏览一份20页的简报。
解码 模型的第二项工作:一次一个Token地写出答案。 逐字打出回复。
批处理 在GPU上同时运行多个请求,而不是一个一个来。 一个烤箱在同一加热周期内烤多个披萨。
静态批处理 取一组固定的请求,一起运行,并在处理完整组后再接收新请求。 一家餐厅只接待16人桌,并且在这批人全部离开前不会安排新客人入座。
连续批处理 在每个小步骤之后,已完成的请求离开,新请求加入,无需等待整组完成。 就像旋转门:完成的人出去,有空位时新人进来。
门控/门控准入 我们在实测中用于模拟静态批处理的方式:客户端只在当前组全部完成后再发送下一组。底层引擎仍然是连续的。 你亲自在门口收着下一批16张票,直到前16个人完成。
BB=16 批大小:该门控组中有多少个请求。B=16 表示每组16个。 餐厅例子中的“16人桌”。
连续 @ 10 req/s 持续准入,同时新请求以大约每秒10个的速度到达。 大约每秒有10条新的聊天消息到达服务器。
到达率(req/s) 新请求到达的速度。速率越高=服务器越繁忙。 1 req/s 是平静;20 req/s 是高峰。
实验的一侧(一种请求准入方式或一种配置)。 “连续臂”与“门控臂”是两种测试设置,而不是两种不同的GPU。
默认设置(分块开启) 正常的现代vLLM设置,包括开启分块预填充。 如果你不调整服务器,你会得到的出厂设置。
分块预填充 将长提示拆分为较小的片段,并将这些片段与正在进行的回答混合,这样一次长读取不会冻结其他人的流。 像读一本长书时按短章节读,章节之间还可以回答别人的问题。
分块关闭 / 分块预填充关闭 这个安全特性被故意关闭,以便我们能看到更旧、更粗糙的行为。 强迫厨房先完成一个巨大的订单,然后再处理炉子上的其他东西。
TTFT(首个Token时间) 用户看到答案的第一个片段需要多长时间。 聊天气泡中第一个字出现之前的等待时间。
最差间隔 / 最差Token间间隔 答案已经开始流式输出时,单词之间最长的停顿。 回复开始,然后在句子中间停顿一拍,然后继续。
p50 中间值:一半请求表现更好,一半更差。 典型/中位数体验。
p99 慢尾:大约只有百分之一的请求比这个更差。 在生产环境中你仍然需要关心的不走运用户体验。
总时间 从发送请求到完整答案完成需要多长时间。 一次聊天回复的开始到结束。
抢占 服务器用于进行中答案的工作内存耗尽,不得不将其中一个踢出,并在稍后重做其部分工作。 因为餐厅满了,在用餐中途清桌,然后让他们从头重新入座。
KV缓存 模型在生成时为每个活动对话准备的短期暂存区。 厨师为炉上每个订单保留的便利贴。
H200 / GPU Droplet 我们运行实测的云机器,配备一块强大的NVIDIA H200 GPU。 用于测量的实体厨房。
vLLM 在GPU上运行模型的开源服务器软件。 厨房的订单管理系统。
Trace 我们在每次测试中重放的固定混合短、中、长模拟请求,以确保比较公平。 同样的购物清单在每个收银通道过一遍。

两种批处理模型的实际工作原理

每个LLM请求都有两个步骤。首先,模型读取你的提示(预填充)。然后,它一次一个Token地写出答案(解码)。批处理只是决定在此期间谁共享GPU的规则。

模型 工作方式
静态批处理 收集一组固定的请求,一起运行,并且在该整组完成之前不接收新请求。
连续批处理 在每个小GPU步骤之后,已完成的请求离开,等待的请求可以立即加入。

静态批处理让你在门口等待(首次Token时间慢)。连续批处理让你快速进入,但一个中途加入的新长提示可能会让已经在流式输出的答案卡顿。下面的小节将详细展开这两方面。

静态批处理:批处理是一个锁定的房间

静态批处理是请求级调度。服务器收集请求,直到一个批次填满或计时器超时,然后运行所有这些请求的预填充,再对整个批次一起进行解码,直到其中的每个序列都完成。三个代价直接从这一定义推导而来。

批次填充等待:早到的请求只能空等,直到批次填满或超时,在计算发生前就付出了延迟。最长序列下限:批次只有在最长序列完成时才释放,因此一个需要100个令牌的请求必须等待需要600个令牌的邻居,整个批次的占用时间等于最大值,而不是平均值。利用率衰减:随着序列完成,它们的计算插槽在仍然锁定的批次内闲置,所以一个包含16个请求的批次可能在最终迭代中只做3个请求的工作,而13个插槽无所事事。这些闲置的灰色插槽正是Orca论文在修复之前记录的低效问题。来源:Yu等人,Orca:基于Transformer的生成模型的分布式服务系统,OSDI 2022,第521页至538页

静态批处理的时间线,显示了批次填充等待、共享的预填充块、不同长度的解码通道,其中已完成的序列留下空闲的灰色容量,最长序列使批次保持打开状态,新到达的请求在外部等待释放线。 一幅图展示三个代价:门前的填充等待,内部的灰色死容量,以及由最慢占用者设定的释放线。

容量后果是对尾部最重要的部分。静态批处理的可维持吞吐量最多是B除以批次占用时间,而占用时间由最长序列决定。如果到达率超过这个上限,队列就会无界增长,这意味着准入延迟随之无界增长,首令牌时间也无界增长。这是普通的排队论,也是下面测量结果中受限组痛苦准入数字背后的机制。

连续批处理:批次是一扇旋转门

Orca的洞察在于以单次迭代为粒度进行调度,而不是以整个请求为单位。每次前向传播之后,调度器重新决定批次:刚刚完成的序列立即退出并返回给客户端,等待中的请求则在飞行中加入释放出的容量。论文自己的摘要用一句话说明了它要解决的问题:批次中比其他请求更早完成的请求无法返回给客户端,而新到达的请求必须等到整个批次完全完成。迭代级调度消除了这两种等待。vLLM基于同样的设计,并增加了PagedAttention,它采用非连续块来管理KV缓存内存,使得连续批处理想要的激进动态批次组合不会被内存碎片所击败。来源:Yu等人,OSDI 2022Kwon等人,使用PagedAttention的大语言模型服务高效内存管理,SOSP 2023,第611页至626页

连续批处理的时间线,显示序列在飞行中退出,新到达的请求在下一个迭代加入释放的插槽,没有填充等待,没有死容量,也没有共享释放线。 没有填充等待,没有死容量,没有释放线。开放的问题是,在飞行中加入和离开会对已经在处理中的请求产生什么影响。

尾部成本从哪里进入

连续批处理赢得的一切,都是通过使批次组合动态化而赢得的。尾部成本也通过同一扇门进入,通过两种不同的机制,值得分别看待,因为它们对应不同的修复方法。

机制一:预填充插入。当新请求在飞行中加入时,它的提示词需要预填充,而预填充是计算密集型的。在vLLM为其预分块行为记录的调度策略中,调度器优先处理预填充,并且不会将预填充和解码合并到同一次前向传播中。一个6,000令牌的提示词到达后,会变成一个只做预填充的迭代,在此期间每个进行中的解码流都不会产生任何输出。这些流中的每一个都会在同一时刻出现一个长的令牌间间隔,而这并非它们自身的问题。每当一个长提示词到达时,这种停顿就会重复,而在混合长度的流量下,这种情况是持续不断的。调度策略描述的来源:vLLM优化与调优文档

机制二:KV压力下的抢占。连续批处理激进地准入请求,每个被准入序列的KV缓存都会随着它生成的每个令牌而增长。当缓存满时,调度器必须驱逐某个序列。vLLM V1记录的默认抢占模式是RECOMPUTE(重新计算)而不是SWAP(交换):受害者的缓存被丢弃,当容量释放时,它的整个预填充会再次运行。对于受害者来说,这是一次流中断停顿,随后是完整的第二次预填充延迟,一个在任意中位数中都看不到的纯粹尾部事件。引擎通过其Prometheus指标暴露一个累积抢占计数器,并在设置disable_log_stats=False时记录日志,这使得该机制可以直接观察,而不是推断。来源:vLLM优化与调优文档,抢占部分

抢占机制的流程图:激进的准入填满KV缓存,一个正在运行的请求被抢占,vLLM V1的默认RECOMPUTE模式在资源释放时从零开始重建其缓存。 第二种机制在修复第一种机制后依然存在。本文中的测试工具会在每次运行前后记录引擎自身的抢占计数器。

改变局面的缓解措施:分块预填充

预填充插入机制有一个有据可查且已部署的修复方案,如实说明这一点正是本文区别于坊间传闻之处。分块预填充(chunked prefill)将长提示词拆分成多个片段,2,048 个 token 是一个有代表性的预算,并将每个片段与正在进行的解码一起批处理,而不是让片段单独运行。调度策略被颠倒过来:每轮迭代都优先调度解码,预填充块填充剩余的 token 预算。在运行片段时,在途流的 token 间隔会略微变宽,而不是出现一个长时间的死寂空隙。这一思路源自 Sarathi-Serve,该系统将其表述为通过消除预填充-解码干扰来驯服吞吐量与延迟之间的权衡。来源:Agrawal 等人,《驯服 LLM 推理中的吞吐-延迟权衡:Sarathi-Serve》,OSDI 2024

下面这部分是任何涉及该主题的基准测试都必须披露的信息,因为它会让结果产生数量级的变化:在 vLLM V1 中,分块预填充在可能的情况下默认启用,并激活解码优先策略。vLLM 的文档以异常直白的方式说明了两种方向的权衡:每轮迭代约 2,048 个 token 的较小预算能带来更好的 token 间延迟,因为较少的预填充 token 拖慢解码;较大的预算能带来更好的首 token 时间;高于 8,192 的预算被推荐用于原始吞吐量。机制一中描述的剧烈停顿现象,针对的是没有此缓解措施的引擎和配置,包括该功能默认关闭的旧版 vLLM 版本,以及任何禁用了该功能或将预算调高到足以重现问题的当前部署。来源:vLLM 优化与调优文档

这是任何相关基准测试中最重要的单一配置披露。下面测得的实际结果精确量化了开启与关闭在真实硬件上对尾延迟的影响。

实验

一个模型、一块 GPU、一个引擎构建、一条请求轨迹、一个随机种子。唯一的变量是准入策略,外加连续批处理臂中的一个配置开关。

  • 硬件:DigitalOcean GPU Droplet,规格为 gpu-h200x1-141gb,NVIDIA H200,区域 NYC2,由1-Click 推理就绪镜像创建。你可以在GPU Droplet 定价页面查看当前小时费率。
  • 模型:Llama 3.1 8B Instruct,BF16 精度,以 RedHatAI/Llama-3.1-8B-Instruct 形式提供服务(同一系列的无门控 BF16 再分发版本)。8B 模型能在不耗费数小时墙钟时间的情况下,保持解码迭代快速并让调度器动态清晰可见。
  • 引擎:vllm/vllm-openai:v0.24.0。使用 --no-enable-prefix-caching 禁用前缀缓存,使相同的填充提示词不会压缩预填充成本。
  • 轨迹:70% 短请求、20% 中等请求、10% 长请求,随机种子为 7,每次运行包含 500 个测量请求外加 25 个预热请求。结果连同数据已发布在公共 GitHub 仓库中。

注意:vLLM 没有传统的静态批处理模式。它完全围绕迭代级连续批处理(也称为在飞或动态批处理)构建,并与 PagedAttention 配合,以最大化 GPU 利用率并消除静态批处理固有的空闲等待时间。

负载爬坡与开关

连续批处理臂以开环泊松到达率运行,请求速率从每秒 1、5、10 到 20 个请求逐步爬坡,每个速率级别在弃用预热后测量 500 个请求。门控静态批处理臂通过其批处理门运行相同的轨迹。随后,连续批处理臂在显式禁用分块预填充的情况下重复爬坡,因为分块与不分块的差异是本实验产生的对决策最具参考价值的单一数字。两种配置均逐字记录在输出文件中。

测量内容

在每个请求层面,两个实验臂都测量:从首个流式内容块的客户端时间戳得到的 TTFT、从每个块的时间戳得到的最差 token 间间隔,以及总完成时间。每次运行层面:抓取引擎的 vllm:num_preemptions Prometheus 计数器在运行前后的值,从而直接观测而非推断机制二;此外,如果所部署的 vLLM 版本暴露了运行批处理占用率,也一并记录。客户端并发使用真实 OS 线程而非单一 asyncio 事件循环,原因与配套的延迟测试相同:一篇 2026 年的测量偏差论文将单进程异步客户端建模为 M/G/1 队列,该队列自身的瓶颈会在测量中夸大尾延迟指标。来源:Chandrasekar 和 Kramberger,《识别并缓解生产环境 LLM 推理基准测试中的系统性测量偏差》,arXiv

实验设计图:一条已发布的混合长度轨迹馈送到门控静态准入臂和开放连续准入臂,二者针对同一 GPU Droplet 上的同一固定 vLLM 服务器运行,均生成 TTFT、最差间隔和总时间分布,以及引擎抢占计数器。 除准入策略外一切保持不变。门控静态臂作为替代方案披露,并说明了其偏差的方向。

在 DigitalOcean H200 GPU Droplet 上的实测结果

本节是全文的主干。以上内容解释了机制。以下是混合长度轨迹在真实引擎和真实 GPU Droplet 上实际发生的情况。

GitHub 仓库:github.com/anishsingh20/continuous-vs-static-batching,包含测试工具、逐请求 JSON、套件日志、元数据以及此处解释的三张图表。

保持不变的内容

字段
Droplet 大小 gpu-h200x1-141gb, NYC2
GPU和驱动 NVIDIA H200, 143771 MiB
引擎镜像 vllm/vllm-openai:v0.24.0
提供的模型ID RedHatAI/Llama-3.1-8B-Instruct(BF16 Llama 3.1 8B Instruct 可再分发版本)
前缀缓存 已禁用(--no-enable-prefix-caching),因此相同的填充提示不会合并预填充
禁用分块标志(nochunk分支) --no-enable-chunked-prefill
采集的抢占计数器 vllm:num_preemptions_total
轨迹分布 70%短(200/100),20%中(1000/300),10%长(6000/600),随机种子7
样本大小 每单元500个测量请求 + 25个丢弃的预热请求

各单元之间唯一有意改变的变量:准入策略(开放连续 vs 门控B=16)、连续分支的到达速率,以及一个配置开关(分块预填充开/关)。

如何阅读每一列

  • TTFT p50 / p99:从请求发送到首个流式内容块的客户端时间。这是门控/静态准入通常失败的地方。
  • 最差间隔 p50 / p99:一次响应内连续流式块之间的最大停顿。这就是连续批处理中预填充插入停滞显现的地方。只跟踪TTFT的仪表盘会错过它。
  • 总p99:第99百分位的端到端完成时间。在开环泊松负载下,这也反映了GPU忙碌时的排队情况;它本身并不是一个纯粹的“调度质量”数值。
  • 抢占次数:运行前后vllm:num_preemptions_total的差值。非零意味着机制二(KV回收 / RECOMPUTE)确实触发了。

完整实测数据表

速率(请求/秒) 分支 配置 TTFT p50 TTFT p99 最差间隔 p50 最差间隔 p99 总p99 抢占次数
1 连续 默认(分块开启) 17.0 154.0 5.9 129.7 3434.1 0
5 连续 默认(分块开启) 19.3 161.2 18.3 134.9 4409.3 0
10 连续 默认(分块开启) 24.1 252.2 129.6 189.9 5723.2 0
20 连续 默认(分块开启) 47.7 394.8 159.9 212.7 12510.4 0
5 连续 分块预填充关闭 19.6 161.1 18.1 134.3 4482.3 0
10 连续 分块预填充关闭 24.2 249.4 129.4 267.8 5844.2 0
n/a 门控,B=16 默认 195.8 637.2 52.8 203.6 4073.6 0

除抢占计数外,所有值均为毫秒。每个单元零错误。

逐行解读:使用引擎默认设置的连续分支(分块开启)

速率1请求/秒。这是低负载基线。TTFT中位数为17.0毫秒:请求被立即接纳,第一个token快速到达。最差间隔中位数为5.9毫秒,这是一条平滑的流。p99间隔129.7毫秒和p99总耗时3434.1毫秒已经告诉你,混合轨迹中长提示/长生成尾部存在于数据中:10%的请求在约6,000 token的预填充后要求最多600个输出token,因此即使调度器大部分时间空闲,完成时间的尾部也很长。抢占次数:0。

速率5请求/秒。TTFT几乎不动(p50 19.3,p99 161.2)。负载首先显现的地方是流:间隔p50从5.9上升到18.3毫秒。这是默认设置下机制一的开始:其他请求的预填充已经在与你的解码共享迭代。总p99上升到4409.3毫秒。抢占次数仍为0。

速率10请求/秒。这是该硬件/模型/轨迹的拐点。TTFT p50仍然良好,为24.1毫秒,但TTFT p99攀升至252.2毫秒。铁证是间隔p50:129.6毫秒。现在中位数请求遇到超过一百毫秒的最差token间停顿。间隔p99为189.9毫秒。如果你只看TTFT中位数,你仍然会认为系统健康。看着屏幕上token出现的用户已经会感到卡顿。总p99为5723.2毫秒。抢占次数仍为0。

速率20请求/秒。开环压力显然已经超出舒适区。TTFT p50再次翻倍至47.7毫秒;TTFT p99达到394.8毫秒。间隔p50/p99分别为159.9 / 212.7毫秒。总p99猛增至12510.4毫秒:在此到达速率下,GPU无法以请求到达的速度消耗所施加的负载,因此完成时间包含了真实的排队,而不仅仅是每个请求的计算时间。抢占次数仍为零:对于这种轨迹长度混合,H200在8B模型上的KV余量非常大。

连续默认设置阶梯的结果:连续准入在整个负载阶梯上保持了TTFT中位数较小,但一旦并发上升,token流就会变得粗糙,到10至20请求/秒时,尾部就主要由负载主导。这正是文章前半部分“痛苦从前门转移到了流中”的故事。

逐行解读:关闭分块预填充的连续分支

速率5,分块关闭。与相同速率下的默认设置相比,数值几乎相同:TTFT p99 161.1对161.2,间隔p99 134.3对134.9,总p99 4482.3对4409.3。在运行8B模型的H200上,这种中等负载下,关闭分块并不会重现传说中的灾难。这点值得发表。这意味着V1的默认缓解措施和这块GPU的余量正在默默地发挥作用。

速率10,分块机制失效。现在切换开关变得重要了,但影响程度适中。TTFT基本不变(p99为249.4对252.2)。最差间隔的p99从189.9毫秒上升到267.8毫秒,增加了1.41倍。间隔p50在两种情况下都保持在约129毫秒。因此,禁用分块预填充会加宽流式输出停滞的尾部,但不会改变首令牌时间。机制的方向:已确认。但民间说法中的“悬崖式陡增”幅度:在当前配置下被否定了。

逐批处理:门控准入(静态替身),B=16

门控并不是第二个引擎。它是在同一个vLLM服务器上,由客户端保持最多16个在途请求,等待全部16个完成后再准入下一批16个。这复现了静态批处理的外部形态:填充边界、全部完成一起释放、最长序列占用。

实测结果:TTFT p50为195.8毫秒,TTFT p99为637.2毫秒。与10请求/秒的连续模式(24.1 / 252.2)相比,门控的中位数TTFT大约差8倍,其p99 TTFT差2.5倍。这就是那个“锁住的房间”:批次中较早到达的请求必须等待批次形成,并等待慢邻居完成后下一批才能开始,因此首令牌时间吸收了准入延迟。

门控的最差间隔在负载下比连续模式更平稳:间隔p50为52.8毫秒,间隔p99为203.6毫秒。流式输出并不是灾难模式——准入才是。总p99(4073.6毫秒)在这张表中实际上低于10到20请求/秒的连续模式,因为门控自然地进行自我限流。它永远不会把每秒20个到达的请求开环地灌入引擎,所以你不能把总p99解读为“门控整体更快”。你应该解读为“门控拒绝接受相同的提供负载”。

门控的结论:静态/门控在前门输掉了。它的定位仍是需要流式平滑性且能通过工程手段规避准入延迟的工作负载,而非通用API服务。

抢占列:为什么每个单元格都是零

第二种机制在vLLM的文档中是真实存在的,在更紧张的GPU上也确实会出现。但它没有出现在本次测试中。一个8B BF16模型跑在141 GB的H200内存上,配合这种提示/输出组合,从未触发RECOMPUTE。测试工具在每次运行前后抓取了vllm:num_preemptions_total;每次的差值都是0.0。这是一个发现,而不是一个缺失的测量:如果你在类似硬件上的p99故事是“我们在抢占”,那你可能运行的是更大的模型、更长的上下文、更高的max_num_seqs。所以请在生产环境中持续关注抢占计数器。

图表1:TTFT p99和最差间隔p99随到达率的变化

在H200上,连续默认配置与关闭分块预填充时,实测TTFT p99和最差间隔p99随到达率的变化。

该图表包含两个并排的图形。两者都只覆盖“连续”测试(新请求可以随时加入)。

回答的问题 线条的含义 测试中发生了什么
“当服务器变得更繁忙时,第一个词出现需要等多久?” 图上位置越高 = 用户等待第一个词的时间越长。 随着每秒发送的请求增多,等待时间上升。关闭“分块预填充”几乎不改变左图。
“当答案已经在输出中时,卡顿会有多严重?” 图上位置越高 = 句中尴尬停顿的时间越长。 随着流量增加,卡顿变得更严重。在10请求/秒时,关闭分块预填充会使卡顿明显更严重(约268毫秒对约190毫秒)。

要点:左图是“开始说话的时间”;右图是“说话时的口吃”。分块预填充对口吃图的改善大于对开始图的改善。

图表2:决策点上各方案的p99对比

连续默认、连续关闭分块以及门控B=16的实测p99对比。

每个方案有三根柱:TTFT p99、最差间隔p99、总p99。

这是一个并排展示三种配置的柱状图:

  1. 连续模式,正常设置,约每秒10个新请求
  2. 连续模式,相同流量,但关闭分块预填充
  3. 每批16个的门控分组(我们对旧式静态批处理的替身)

每种配置有三根彩色柱:

柱的颜色 通俗含义 测试中谁“胜出”
蓝色 第一个词之前的等待 连续模式胜出(等待时间短得多)。门控在这里很慢(约637毫秒)。
橙色 答案输出过程中的最差卡顿 正常连续模式优于“关闭分块”模式。
绿色 整个答案完成的时间 门控在这里看起来更小,但部分原因是门控每次只允许16个请求进入,所以它从未承受相同的高峰流量。不要单独把绿色柱当作“门控整体更好”的证据。

要点:使用连续模式,你更快得到回复;使用门控模式,你必须先排队等待才开始;而如果关闭分块预填充,回复会以更尴尬、断续的方式输出,就像一个人说话时频繁停顿。

测量结果的边界

门控/静态准入恰恰在机制分析预测的地方失败:TTFT。门控B=16的中位数TTFT为195.8毫秒,而连续模式在5请求/秒时为19.3毫秒、在10请求/秒时为24.1毫秒。这就是在真实硬件上实测到的“锁住房间”的填充-释放成本。门控的最差间隔保持在中等水平(p99为203.6毫秒),与“流式平滑,准入痛苦”的结论一致。

实验的关键结论

  1. 门控/静态准入在TTFT上惨败。中位数196毫秒,而连续式仅为十几到四十多毫秒。证实了“锁室”论点。
  2. 连续式的交互风险表现为负载下的流式卡顿,这从10 req/s起在gap p50/p99中可见,而非中位TTFT。
  3. 分块预填充仍然有效,在10 req/s下测得的gap-p99提升为1.41x。坊间传言夸大了当前vLLM V1默认配置的悬崖效应。
  4. 从仓库复现或引用: github.com/anishsingh20/continuous-vs-static-batching

测试工具标准输出

这是测试工具输出的原始内容,记录了实验过程中进行的测量日志。

==== CONTINUOUS DEFAULTS RATE RAMP ====
--- continuous rate=1 2026-08-04T07:00:15Z ---
{
  "arm": "continuous",
  "n": 500,
  "started": "2026-08-04T07:00:15Z",
  "rate_or_batch": 1.0,
  "ttft_p50": 17.0,
  "ttft_p99": 154.0,
  "gap_p50": 5.9,
  "gap_p99": 129.7,
  "total_p50": 507.2,
  "total_p99": 3434.1,
  "preemptions_delta": 0.0,
  "errors": 0
}
--- continuous rate=5 2026-08-04T07:09:36Z ---
{
  "arm": "continuous",
  "n": 500,
  "ttft_p50": 19.3,
  "ttft_p99": 161.2,
  "gap_p50": 18.3,
  "gap_p99": 134.9,
  "total_p99": 4409.3,
  "preemptions_delta": 0.0,
  "errors": 0
}
--- continuous rate=10 2026-08-04T07:11:31Z ---
{
  "arm": "continuous",
  "n": 500,
  "ttft_p50": 24.1,
  "ttft_p99": 252.2,
  "gap_p50": 129.6,
  "gap_p99": 189.9,
  "total_p99": 5723.2,
  "preemptions_delta": 0.0,
  "errors": 0
}
--- continuous rate=20 2026-08-04T07:12:30Z ---
{
  "arm": "continuous",
  "n": 500,
  "ttft_p50": 47.7,
  "ttft_p99": 394.8,
  "gap_p50": 159.9,
  "gap_p99": 212.7,
  "total_p99": 12510.4,
  "preemptions_delta": 0.0,
  "errors": 0
}
==== GATED B=16 ====
{
  "arm": "gated",
  "n": 500,
  "rate_or_batch": 16,
  "ttft_p50": 195.8,
  "ttft_p99": 637.2,
  "gap_p50": 52.8,
  "gap_p99": 203.6,
  "total_p99": 4073.6,
  "preemptions_delta": 0.0,
  "errors": 0
}
==== RESTART NO-CHUNK ====
Using chunked-disable flag: --no-enable-chunked-prefill
--- nochunk continuous rate=5 ---
{ "ttft_p99": 161.1, "gap_p99": 134.3, "total_p99": 4482.3, "preemptions_delta": 0.0 }
--- nochunk continuous rate=10 ---
{ "ttft_p99": 249.4, "gap_p99": 267.8, "total_p99": 5844.2, "preemptions_delta": 0.0 }
==== SUITE COMPLETE ====

Runbook:在您的 DigitalOcean GPU Droplet 上重现此测试

开始前需要准备什么。 一台已部署的 GPU Droplet,且 NVIDIA 驱动和 Docker 正常工作(一键推理就绪镜像 已预装两者)。对 Droplet 的 SSH 访问。要么能通过 Hugging Face 访问 meta-llama/Llama-3.1-8B-Instruct,要么使用发布运行中使用的无门控可再分发 RedHatAI/Llama-3.1-8B-Instruct。大约 30 GB 可用磁盘用于 BF16 8B 权重。Droplet 上安装 Python 3。不需要其他任何东西:该测试工具仅使用 Python 标准库。克隆 anishsingh20/continuous-vs-static-batching 以获取测试工具和绘图脚本。

步骤 1:确认 GPU 可见。

nvidia-smi

将输出中的驱动程序版本和GPU名称记录到结果文件中。如果此命令失败,请先停止并修复驱动程序,然后再执行其他操作。

步骤2:拉取固定的引擎镜像并记录其摘要。

docker pull vllm/vllm-openai:v0.24.0
docker images --digests | grep vllm

将sha256摘要逐字复制到您的结果文件中。固定版本加上摘要是使运行在vLLM的调度器再次更改后仍然可复现的关键。

步骤3:启动服务器,arm one,所有默认设置。

# Meta repo is license-gated; published run used RedHatAI/Llama-3.1-8B-Instruct (ungated BF16).
export HF_TOKEN=your_hugging_face_token_here   # only needed for meta-llama/*

docker run -d --name vllm-default --gpus all --ipc=host -p 8000:8000 \
  -e HUGGING_FACE_HUB_TOKEN=$HF_TOKEN \
  -v ~/.cache/huggingface:/root/.cache/huggingface \
  vllm/vllm-openai:v0.24.0 \
  --model RedHatAI/Llama-3.1-8B-Instruct \
  --no-enable-prefix-caching

V1 默认开启分块预填充(先解码),根据 vLLM 的优化文档。前缀缓存被禁用,因此相同的填充提示不会在请求之间降低预填充成本。首次启动会下载权重,需要几分钟。使用 docker logs -f vllm-default 跟踪进度,并等待服务器就绪行。

步骤 4:检查端点和指标抓取的健康状态。

curl -s http://localhost:8000/v1/models | head -c 400
curl -s http://localhost:8000/metrics | grep -i preemption

第一个命令必须返回一个包含精确模型ID的模型列表。第二个必须返回抢占计数器行;注意其确切的指标名称,因为测试工具会抓取指标端点,而固定版本上的计数器名称拼写值得用目测确认一次。如果grep没有返回任何内容,请运行curl -s http://localhost:8000/metrics | grep vllm:并记录该版本上计数器的名称。

步骤5. 保存测试工具并运行预热和基线。

将以下部分中的测试工具复制到 Droplet 上的 batching_bench.py 中,然后以连续模式进行速率斜坡测试。正确的速率取决于你的硬件,因此应持续增加速率直到尾部延迟明显膨胀,而不是轻信任何固定列表。对于单个 H200 上的 8B 模型,一个合理的起始阶梯是:

python3 batching_bench.py --arm continuous --rate 1  --n 500 --out cont_r1.json
python3 batching_bench.py --arm continuous --rate 5  --n 500 --out cont_r5.json
python3 batching_bench.py --arm continuous --rate 10 --n 500 --out cont_r10.json
python3 batching_bench.py --arm continuous --rate 20 --n 500 --out cont_r20.json

每次运行默认丢弃25个预热请求,并在完成时打印每个级别的摘要。如果p99在速率20时没有变化,继续将速率加倍,直到找到拐点,并记录你运行的每个级别。

步骤6:在同一台服务器上运行门控臂。

python3 batching_bench.py --arm gated --batch-size 16 --n 500 --out gated_b16.json

门控臂批量提交16个请求,等待所有完成后,再提交下一批。这是公开的静态批处理替代方案,因为vLLM没有静态模式,而且该方案被标记为对静态批处理略有美化。

步骤7:禁用分块预填充后重启服务器,并重新运行连续递增负载测试。

docker rm -f vllm-default

docker run -d --name vllm-nochunk --gpus all --ipc=host -p 8000:8000 \
  -v ~/.cache/huggingface:/root/.cache/huggingface \
  vllm/vllm-openai:v0.24.0 \
  --model RedHatAI/Llama-3.1-8B-Instruct \
  --no-enable-prefix-caching \
  --no-enable-chunked-prefill

已在发布的固定版本上验证:v0.24.0 接受 --no-enable-chunked-prefill。标志拼写在不同版本之间仍会变化,因此在引用其他标签之前,请对照您的镜像进行确认。然后以 nochunk_r5.json 等输出名称,在速率 5 和 10 下重复测试。

第 8 步:归档所有内容。

将所有 JSON 输出文件、镜像摘要、nvidia-smi 输出行、两条逐字记录的 docker 命令,以及每次运行的日期和时间从 Droplet 中复制出来。使用 python3 benchmarks/plot_results.py 重新生成图表。已发布的归档文件位于 anishsingh20/continuous-vs-static-batching

测试框架

#!/usr/bin/env python3
"""
Batching-policy latency harness for a vLLM server on a DigitalOcean GPU Droplet.

Arms:
  continuous : open-loop Poisson arrivals at --rate req/s (real OS threads)
  gated      : batches of --batch-size, submit all, wait for all, repeat

Per request: TTFT, worst inter-token gap, total time (client-side streaming
timestamps). Per run: vllm preemption counter scraped before and after.

Start the server first (pin the image tag AND record the digest):
  docker run --gpus all -p 8000:8000 vllm/vllm-openai:v0.24.0 \
    --model meta-llama/Llama-3.1-8B-Instruct
  # chunked-off arm: add  --no-enable-chunked-prefill
  # (verified on v0.24.0; re-check if you change the pin)

Run:
  python3 batching_bench.py --arm continuous --rate 5 --n 500 --out cont_r5.json
  python3 batching_bench.py --arm gated --batch-size 16 --n 500 --out gated.json
"""
import argparse, concurrent.futures, json, os, random, threading, time
import urllib.request

def make_trace(n, seed):
    rng = random.Random(seed)
    trace = []
    for i in range(n):
        r = rng.random()
        if r < 0.70: p, o = 200, 100
        elif r < 0.90: p, o = 1000, 300
        else: p, o = 6000, 600
        trace.append({"id": i, "prompt_tokens": p, "max_tokens": o})
    return trace

def build_prompt(n_tokens):
    # ~1 token per word for a filler word; exactness is not required,
    # only that both arms use the identical trace.
    return "ocean " * n_tokens

def percentile(xs, p):
    xs = sorted(xs)
    k = (len(xs) - 1) * p / 100
    f = int(k); c = min(f + 1, len(xs) - 1)
    return xs[f] if f == c else xs[f] * (c - k) + xs[c] * (k - f)

def scrape_preemptions(base):
    """Prefer vllm:num_preemptions_total (v0.24+); fall back to unlabelled name."""
    try:
        with urllib.request.urlopen(base.replace("/v1", "") + "/metrics", timeout=10) as r:
            lines = r.read().decode().splitlines()
        for prefix in ("vllm:num_preemptions_total", "vllm:num_preemptions"):
            for line in lines:
                if line.startswith(prefix) and not line.startswith(prefix + "_"):
                    return float(line.split()[-1])
    except Exception:
        pass
    return None

def one_request(base, model, item):
    payload = {
        "model": model,
        "prompt": build_prompt(item["prompt_tokens"]),
        "max_tokens": item["max_tokens"],
        "temperature": 0,
        "stream": True,
    }
    req = urllib.request.Request(
        base + "/completions",
        data=json.dumps(payload).encode(),
        headers={"Content-Type": "application/json",
                 "Authorization": "Bearer " + os.environ.get("VLLM_API_KEY", "none")},
    )
    start = time.perf_counter()
    ttft = None; last = None; worst_gap = 0.0; n_chunks = 0
    try:
        with urllib.request.urlopen(req, timeout=600) as r:
            for raw in r:
                line = raw.decode(errors="ignore").strip()
                if not line.startswith("data:") or line[5:].strip() == "[DONE]":
                    continue
                now = time.perf_counter()
                if ttft is None:
                    ttft = (now - start) * 1000
                else:
                    worst_gap = max(worst_gap, (now - last) * 1000)
                last = now; n_chunks += 1
    except Exception as e:
        return {"id": item["id"], "error": str(e)}
    return {"id": item["id"], "ttft_ms": round(ttft, 1),
            "worst_gap_ms": round(worst_gap, 1),
            "total_ms": round((last - start) * 1000, 1), "chunks": n_chunks}

def run_continuous(base, model, trace, rate, seed):
    rng = random.Random(seed + 1)
    results = []; lock = threading.Lock()
    with concurrent.futures.ThreadPoolExecutor(max_workers=256) as pool:
        futures = []
        for item in trace:
            futures.append(pool.submit(one_request, base, model, item))
            time.sleep(rng.expovariate(rate))
        for f in concurrent.futures.as_completed(futures):
            with lock:
                results.append(f.result())
    return results

def run_gated(base, model, trace, batch_size):
    results = []
    for i in range(0, len(trace), batch_size):
        batch = trace[i:i + batch_size]
        with concurrent.futures.ThreadPoolExecutor(max_workers=batch_size) as pool:
            futures = [pool.submit(one_request, base, model, it) for it in batch]
            for f in concurrent.futures.as_completed(futures):
                results.append(f.result())
        # the gate: nothing new is admitted until every request above returned
    return results

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--base-url", default="http://localhost:8000/v1")
    ap.add_argument("--model", default="meta-llama/Llama-3.1-8B-Instruct")
    ap.add_argument("--arm", choices=["continuous", "gated"], required=True)
    ap.add_argument("--rate", type=float, default=5.0)
    ap.add_argument("--batch-size", type=int, default=16)
    ap.add_argument("--n", type=int, default=500)
    ap.add_argument("--warmup", type=int, default=25)
    ap.add_argument("--seed", type=int, default=7)
    ap.add_argument("--out", required=True)
    args = ap.parse_args()

    trace = make_trace(args.n + args.warmup, args.seed)
    pre = scrape_preemptions(args.base_url)
    t0 = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())

    if args.arm == "continuous":
        recs = run_continuous(args.base_url, args.model, trace, args.rate, args.seed)
    else:
        recs = run_gated(args.base_url, args.model, trace, args.batch_size)

    post = scrape_preemptions(args.base_url)
    good = [r for r in recs if "error" not in r][args.warmup:]
    tt = [r["ttft_ms"] for r in good]
    gp = [r["worst_gap_ms"] for r in good]
    to = [r["total_ms"] for r in good]
    summary = {
        "arm": args.arm, "n": len(good), "started": t0,
        "rate_or_batch": args.rate if args.arm == "continuous" else args.batch_size,
        "ttft_p50": round(percentile(tt, 50), 1), "ttft_p99": round(percentile(tt, 99), 1),
        "gap_p50": round(percentile(gp, 50), 1), "gap_p99": round(percentile(gp, 99), 1),
        "total_p50": round(percentile(to, 50), 1), "total_p99": round(percentile(to, 99), 1),
        "preemptions_delta": (post - pre) if (pre is not None and post is not None) else None,
        "errors": len(recs) - len(good) - args.warmup,
    }
    with open(args.out, "w") as f:
        json.dump({"summary": summary, "records": good}, f, indent=2)
    print(json.dumps(summary, indent=2))

if __name__ == "__main__":
    main()

将测量结果作为清单阅读

当你打开他人的批处理基准测试,或从 GitHub 仓库重新运行测试工具时,请使用此清单。

  1. TTFT 是否爆炸性增长而间隔保持平稳? 这是门控/静态准入(或服务器在首个 token 之前就已过载)。我们的门控分支:TTFT p50 为 195.8 毫秒,间隔 p99 仅为 203.6 毫秒。
  2. 间隔是否爆炸性增长而 TTFT 中位数看起来正常? 这是连续预填充插入(机制一)。我们的连续默认配置:在 10 请求/秒时,TTFT p50 仍为 24.1 毫秒,而间隔 p50 跃升至 129.6 毫秒。
  3. 关闭分块预填充是否使间隔显著恶化? 如果是,则你正确归因于机制一。我们的结果:在 10 请求/秒时,间隔 p99 从 189.9 毫秒升至 267.8 毫秒(1.41 倍)。如果差异很小,请如实说明,因为默认配置可能已经达到了效果。
  4. vllm:num_preemptions_total 是否发生变化? 如果是,则机制二在起作用,解决方案是 KV 余量 / max_num_seqs,而不是关于批处理的更多传说。我们的结果:从未变化。
  5. 跟踪是否混合长度? 同质短提示会掩盖这两种机制。我们的种子 7 混合已随 JSON 发布。

何时使用哪种方式

连续批处理,引擎默认设置。 吞吐量主导的工作:离线评分、评估套件、合成数据、摘要队列。没有人会盯着加载指示器,因此流内抖动无关紧要,23 倍级的吞吐量优势才是关键。按照 vLLM 的指导提高 token 预算,然后停止调优。

连续批处理,启用尾部延迟手段。 具有 p99 SLA 和混合长度流量的交互式服务:聊天、智能体、任何流式传输给人类的内容。保持分块预填充开启,保持预算适度,监控抢占计数器,并测量最差的 token 间隔而非仅测 TTFT,因为这是本文所述机制隐藏在标准仪表盘之外的地方。如果长上下文请求与部署共享,请先将它们路由到别处,再进行调优。

门控或静态准入。 两个真正适用的场景。硬实时内部循环,使用固定大小、固定长度的批处理,其中不中断的 token 流是要求,准入延迟通过构造被消除。以及序列长度几乎相同的离线任务,其中锁定房间不会浪费任何东西,因为所有人无论如何都会同时完成。通用 API 服务不属于这两者。

三面板决策框架:连续批处理配合默认设置用于吞吐量和同质工作,连续批处理配合尾部延迟手段用于受 p99 约束的交互式混合流量,以及门控或静态准入用于硬实时和高规律性离线任务。 连续批处理是默认选择。决策在于你拉动哪些杠杆,而两个静态场景虽然真实存在但范围狭窄。

结论

连续批处理为改善 LLM 推理工作负载中的 p50 延迟提供了强大工具,但也在更高百分位引入了挑战,有时导致 p99 延迟显著增加。本文展示了批处理策略的选择和分块预填充等功能的使用如何能显著改变性能特征,尤其是尾部延迟。分块预填充有助于抑制最差的间隔,为可预测性至关重要的混合和实时工作负载提供了实用杠杆。

如果你使用不同的 GPU、模型大小或设置进行实验并观察到不同影响,特别是关于分块预填充或抢占的影响,你的结果对社区很有价值。请考虑通过在 GitHub 仓库中提交 issue 或 pull request 来分享你的 run_metadata.json 和基准测试摘要。确切的数字可能有所不同,但关于准入策略和引擎功能如何影响不同延迟指标的更大模式具有广泛适用性。

最终,没有一刀切的解决方案。理解这些机制使你能够根据工作负载做出明智的选择,在可能的情况下利用连续批处理实现大规模吞吐量,或针对交互式、对尾部延迟敏感的用户体验优先场景进行调优。通过深思熟虑的配置和测量,你可以两全其美:高效利用和可预测的性能。

参考文献

论文

引擎文档与基准测试

——

🧑‍💻

zhirenhun

一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。

← 上一篇
如何用vLLM扩展AI智能体的LLM推理
下一篇 →
如何用AI现代化遗留应用而避免变成重写

📌 相关推荐

GraphRAG 是推理问题,而非数据库问题
2026/8/30
构建市场时光机:使用 Python 和 WebSocket 重放交易会话
2026/8/30
如何自行基准测试LLM推理:值得信赖的数字设计标准
2026/8/30
← 返回文章列表