Jellow 编辑发布,AI 辅助生成并完成技术核对。 计算示例和实测结果会在文中分别说明。
假设固定并发压测显示一个副本能完成 19 req/s,而线上流量是 6 req/s,能否据此断定容量充足?不能。固定并发中的新请求由旧请求完成触发:服务越慢,客户端发得越慢;真实用户却不会因为 GPU 忙就自动停止到达。
本文要解决的工程问题是:给定请求长度分布、到达模式和延迟 SLO,单副本可以安全接收多大的外生到达率。相关算子、缓存与服务文章汇总在模型推理加速专题页;本文只讨论容量测量。资料和接口核对日期为 2026-09-11,实际复现仍应固定 vLLM 版本或提交哈希。
从张量形状看“一个请求”有多重
先假设普通稠密因果注意力、单请求、h 个注意力头,每头维度为 d_h;输入长度 L≥1,实际生成长度 O≥1。
Prefill 中,Q 和 K 的形状都是 [h,L,d_h]。由缩放点积注意力 softmax(QKᵀ/√d_h)V 和因果遮罩可知,每层每头需要处理一个三角形。Transformer 原论文给出这一注意力定义。有效 query-key 点积数为:
A_prefill = h × L(L+1)/2
第一个输出 token 可以从 prefill 的 logits 取得。为了得到其余 O-1 个 token,decode 依次让形状 [h,1,d_h] 的 query 读取长度为 L+1 到 L+O-1 的 KV Cache:
A_decode = h × [(O-1)L + O(O-1)/2]
所以每层有效点积总数为:
A(L,O) = h × [L(L+1)/2 + (O-1)L + O(O-1)/2]
每个点积约含 2d_h 次浮点运算。这个式子没有计入投影、MLP、内核填充、批处理和调度,因此不是实际延迟模型;它只解释为什么容量实验必须保留 (L,O) 的联合分布。
一个反例是 (L,O)=(2048,128) 与 (512,1664)。两者都有 L+O-1=2175 个实际参与注意力的位置,每头总计都是 2175×2176/2=2,366,400 个有效点积;但前者的 prefill/decode 分别为 2,098,176/268,224,后者则为 131,328/2,235,072。总工作量相同,不代表 TTFT、TPOT 或批处理效率相同。
NVIDIA 的参数说明也要求按业务保留输入和输出长度分布:长输入主要增加 prefill 压力与 TTFT,长输出增加生成阶段压力与 ITL。固定长度测试可以用 ignore_eos 控制输出长度,但容量验收还应回放生产停止条件。若对 TTFT、TPOT、ITL 的口径不熟悉,可先看站内的推理指标入门。
固定并发把等待移到了哪里
用单服务器 FCFS 队列隔离这个问题。第 i 个请求的预定到达时间、服务时间、开始时间和完成时间分别为 a_i、s_i、b_i、f_i:
b_i = max(a_i, f_{i-1})
f_i = b_i + s_i
w_i = b_i - a_i
开放环压测先产生与完成事件无关的 a_i。在未条件化的泊松过程中,间隔独立服从 Exp(λ),其中 λ 是目标 RPS。闭环并发为 1 时,下一个请求恰在前一个完成后发出,因此 a_i=f_{i-1}、w_i=0:它测到的是 1/E[s],没有测到外生 λ 下的排队尾延迟。
并发上限大于 1 时同样可能发生代价转移。信号量、网关或连接池会把多余请求留在客户端;若计时从真正发往模型服务时才开始,用户等待就从指标中消失。vLLM 将 --request-rate 与 --max-concurrency 分开,并明确指出并发帽可能使实际执行率低于设定值。CLI 参数定义还提供 client_queue_time 和 e2el_including_client_queue 来显示这部分等待。
MLPerf LoadGen 的 Server 场景同样采用随机泊松到达,允许请求保持 outstanding,并按延迟阈值判定;Offline 场景报告吞吐。MLPerf 官方实现说明描述的是两个不同问题,不能互相替代。
vLLM 的有限速率不是未条件化泊松过程
截至 2026-09-11,vLLM 文档把 --burstiness=1 描述为泊松到达,其他正值对应 Gamma 间隔;但源码还有一个必须说明的实现细节。
在固定有限 request-rate、没有 ramp-up、且没有使用 self-timed trace 的路径中,vLLM 会先采样 Gamma 间隔;当 burstiness=1 时,这一步确实是指数间隔。随后程序累加所有间隔,并统一乘以:
normalize_factor = (num_requests/request_rate) / last_cumulative_delay
因此最后一个请求的计划偏移会精确对齐 num_requests/request_rate。当前 serve.py直接展示了采样与缩放逻辑。缩放以后,各间隔共同受总时长约束,不再是相互独立的未条件化指数变量;它与未条件化泊松过程存在实现差异。长时间测试中差异可能很小,但本文研究的正是到达过程,不能省略这个边界。
若生产突发和相关性很重要,最终验收应使用带真实时间戳的 timed_trace --self-timed,或使用能够按原始时间戳发送的外部负载发生器。本文后面的标准库例子使用未缩放的独立指数间隔,验证的是未条件化泊松队列,不是 vLLM 调度器的逐行复刻。
容量应由请求级联合 SLO 定义
令第 r 个独立重复运行中,第 i 个请求的联合指示量为:
I_{r,i} = 1[请求成功 且 TTFT≤B_f 且 TPOT≤B_d 且 E2E≤B_e]
未设置的约束可以删去,错误和超时必须记为 0。该次运行的达标率和合格请求吞吐为:
Â_r(λ) = Σ_i I_{r,i}/N_r
Ĝ_r(λ) = Σ_i I_{r,i}/T_r
分别查看 TTFT p99 和 TPOT p99 不等价于联合达标。两类违规若发生在不同请求上,各自有 99% 的请求通过时,联合通过率仍可能只有 98%。vLLM 对同一成功请求的全部 --goodput 条件执行逻辑 AND,再计算 good_completed/duration;它输出的是 request goodput,不是把错误纳入分母后的联合达标率。官方源码可以核对这一区别。DistServe 论文则把 per-GPU goodput 定义为达到 SLO attainment 目标时可承载的最大请求率。论文原文
FCFS 等待时间会向后传播,所以同一次运行中的 I_{r,i} 通常存在序列相关性。不能把几万个请求直接视为独立 Bernoulli 样本,再用逐请求 IID Wilson 区间决定 PASS/FAIL。
本文采用多个相互独立的完整运行。预先指定 R 组 seed,每次重新建立负载随机过程并按实验策略重置服务状态,以整次运行作为实验单位。定义保守下包络:
A_floor(λ) = min_{r=1..R} Â_r(λ)
只有每次运行都满足全部验收条件,候选 RPS 才通过:
λ* = max{λ | 对所有 r:Â_r≥q,错误率_r≤ε,实际发送率合格,且队列无持续上升}
这条 min 规则是预先声明的保守验收规则,不是“95% 置信下界”,也不声称具有某个未证明的覆盖率。如果无法做独立重启,可以按时间分块报告最差块,但块长度必须覆盖业务突发与队列相关时间;相邻小块不能被冒充为独立样本。
一个验证排队效应和重复判定的标准库例子
下面不是 GPU 实测。它运行 12 个相互独立的单服务器队列:80% 请求服务 20 ms,20% 服务 180 ms;E2E SLO 为 250 ms,目标达标率为 95%。不同重复分别生成服务序列与未缩放的泊松到达;不同 RPS 使用配对 seed,便于把负载随机性对齐后观察变化。
import math
import random
N, WARM, RUNS = 50_000, 5_000, 12
SLO, TARGET = 0.250, 0.95
SEEDS = tuple(range(101, 101 + RUNS))
def percentile(xs, p):
ys = sorted(xs)
return ys[max(0, math.ceil(p / 100 * len(ys)) - 1)]
def one_run(rate, seed):
service_rng = random.Random(seed)
arrival_rng = random.Random(seed + 10_000)
service = [0.020 if service_rng.random() = WARM:
waits.append(start - arrival)
passed += (finish - arrival <= SLO)
measured = N - WARM
actual_rate = (measured - 1) / (arrival - first_measured_arrival)
return percentile(waits, 95), passed / measured, actual_rate
closed_rates = []
for seed in SEEDS:
rng = random.Random(seed)
service = [0.020 if rng.random() = TARGET else "FAIL"
print(f"{rate:>4} {rate*0.052:>4.2f} "
f"{min(actuals):.2f}-{max(actuals):.2f} "
f"{max(q95s)*1000:>14.1f} "
f"{min(attains):.2%}-{max(attains):.2%} "
f"{min(attains):>7.2%} {check}")
Python 3 标准库的实际输出为:
closed-loop: rate_range=19.07-19.43 req/s, min_attain=100.00%
rate rho actual_rps_range worst_p95q_ms attain_range floor slo_check
2 0.10 1.99-2.01 64.2 98.85%-99.00% 98.85% PASS
4 0.21 3.98-4.02 137.7 97.22%-97.50% 97.22% PASS
6 0.31 5.96-6.03 170.7 94.70%-95.26% 94.70% FAIL
8 0.42 7.95-8.04 232.7 90.84%-91.78% 90.84% FAIL
10 0.52 9.94-10.05 314.9 85.52%-86.58% 85.52% FAIL
14 0.73 13.91-14.07 673.9 64.74%-67.65% 64.74% FAIL
18 0.94 17.89-18.10 3889.7 19.30%-26.52% 19.30% FAIL
固定并发基线在 12 次运行中得到 19.07—19.43 req/s,并且因为单次服务时间都小于 250 ms,最差达标率仍是 100%。开放环中,4 RPS 的 12 次运行全部超过 95%,而 6 RPS 的保守下包络只有 94.70%,因此这里只得到 4—6 RPS 的 SLO 夹点,不能把 4 RPS 杜撰成精确容量。
rho 使用理论均值 E[s]=0.8×0.020+0.2×0.180=0.052 s。即使平均利用率只有约 31%,服务时间方差和到达重叠也已让 6 RPS 的部分运行失守。这个模型没有模拟 GPU、网络或连续批处理;实际服务时间会随批次和调度变化,示例阈值不能搬到生产。
可复现的 vLLM 容量实验
先固定模型与软件版本、权重精度、并行度、调度参数、GPU 与驱动、客户端机器、网络、采样参数和缓存状态。数据集至少保留 (输入 token 数,实际输出 token 数,缓存命中条件) 的联合分布。
| 层次 | 负载 | 回答的问题 |
|---|---|---|
| 隔离基线 | 并发 1、低到达率 | 无排队时各长度桶的服务时间 |
| 饱和基线 | request-rate=inf,扫描固定并发 |
峰值吞吐和批处理拐点 |
| 容量扫描 | 有限外生 RPS,先不设并发帽 | 队列和联合 SLO 在哪里失守 |
| 生产验收 | 真实时间戳、网关限制和缓存策略 | 突发、相关性及代价转移后的体验 |
截至核对日,下面命令可作为固定 (1024,128) 长度桶的模板。800/50 ms 是占位 SLO,必须替换;它没有在本文环境中连接模型服务,也不是 GPU 实测。
vllm bench serve \
--backend openai \
--base-url http://127.0.0.1:8000 \
--model MODEL_ID \
--dataset-name random \
--input-len 1024 \
--output-len 128 \
--ignore-eos \
--temperature 0 \
--request-rate 8 \
--burstiness 1.0 \
--num-prompts 10000 \
--num-warmups 32 \
--seed 11 \
--percentile-metrics ttft,tpot,e2el,client_queue_time,e2el_including_client_queue \
--metric-percentiles 50,95,99 \
--goodput ttft:800 tpot:50 \
--save-result \
--save-detailed
当前 vLLM 的一个 --seed 不是独立的“到达种子”。serve.py 用它设置 Python random 和 NumPy RNG;数据集代码又把同一个值用于随机请求生成、抽样或顺序洗牌,具体作用取决于数据集和 --disable-shuffle;NumPy RNG 还用于到达间隔调度。serve.py与数据集源码共同给出了这一语义。
因此比较配置 A/B 时应使用配对设计:重复 1 的 A 和 B 都用 seed 11,重复 2 都用 seed 23,之后继续使用多组不同 seed。每一组 seed 是一个新的完整重复,同时改变该重复的请求随机性、顺序和到达调度;同一组中的 A/B 则共享这些随机条件。不能把换 seed 描述成只换“到达种子”。若必须固定请求序列而只改变到达时间,应准备固定顺序的数据或 timed trace,并由外部调度器控制时间戳。
不同重复之间还要按实验目标重置状态。vLLM 的基准说明提醒,重复请求可能命中上一次留下的前缀缓存并虚增吞吐;生产不依赖这种复用时,应重置缓存、重启服务或使用会重置缓存的 sweep 流程。
先粗扫得到一过一败的 RPS 夹点,再在夹点中加密。边界候选建议增加到至少 10—12 个预先指定的独立重复,并用更长生产时间窗复验。每次都要单独保存实际发送率、错误、联合达标率和队列时间序列,不能先合并所有请求再计算一个 IID 区间。
若生产网关限制并发,再增加相同的 --max-concurrency,同时报告客户端排队、用户视角 E2E、拒绝和超时。当前 --goodput 只接受 ttft、tpot、e2el,不接受客户端排队指标;有外部背压时必须从详细记录重新计算联合指示量,并把失败请求放回分母。
指标和排障顺序
一次容量扫描至少保存:设定与实际发送 RPS、成功/错误/超时数、客户端排队、TTFT/TPOT/E2E 的 p50/p95/p99、逐运行联合达标率及其最小值,并按 (L,O) 桶分解。
NVIDIA 的指标定义中,TTFT 通常包含排队、prefill 和网络,E2E 还覆盖 batching。数学上的 TPOT 只在实际输出 token 数大于 1 时定义,公式为 (E2E-TTFT)/(输出 token 数-1);当 O=1 时分母为零,应从 TPOT 统计中排除或明确采用工具约定。当前 vLLM 在 goodput 检查中把这类请求的 TPOT 置为 0,属于实现约定,不能当作数学定义。工具实现仍可能不同。根据 vLLM Benchmark CLI,推测解码一次流式返回多个 token 时,ITL 记录的是流式事件间隔,而 TPOT 会把时间摊到 token 上,两者尤其不能混用。
排障按请求生命周期进行:
- 负载发生器:实际发送率是否达到设定值;客户端 CPU、连接数或网络是否先饱和;排队是否藏在信号量之前。
- 工作量:核对实际
(L,O)、停止原因、采样参数、前缀命中率和失败请求,先排除两次实验负载不同。 - 服务队列:观察等待请求数和 queue time 是否持续上升。若上升,先降低 RPS 或增加副本,不要先调算子。
- 阶段拆分:队列平稳但 TTFT 变差,检查 prompt 长度、缓存命中和 prefill;TPOT 变差则检查 decode、活动序列数和长输出干扰。
- 资源证据:最后结合 KV Cache 使用率、抢占次数、GPU/CPU、显存带宽和网络判断瓶颈。vLLM 的生产指标清单提供等待请求数、queue time、prefill/decode 时间、KV Cache 使用率和抢占计数。
安全余量不是固定的 70% 或 80%。它取决于长度方差、突发程度、SLO、缓存和扩容反应时间。固定并发仍是有价值的饱和基线;只有开放环到达率、多个独立重复的联合 SLO、稳定队列和真实背压同时进入验收,测出的 RPS 才能用于容量决策。
继续阅读
- Codex 怎么和子 agent 协作:从一次任务分派看消息、上下文与状态管理
- 大模型推理加速(四):推测解码为什么不改变分布,什么时候反而更慢
- 大模型推理加速(三):FlashAttention 为什么更快,又没有省掉哪些计算