Overload Test · Round 2 · RTX 4090
6144 输入 / 2048 输出 / 并发 64:单请求满上下文要 448 MiB KV,64 条要 28 GiB, 而 KV 池只有 6.41 GiB。这一轮故意把 KV 池压爆,看清"爆"是什么样子。 128 条请求全部成功返回,零失败,用时 492 秒。
下面这些缩写不是同义词:每一个量的是流水线上不同的一段,彼此之间有确定的换算关系。
本轮负载:6144 输入 / 2048 输出 · 并发 64 · 128 条。本系列只有这一份报告的 preemptions 不为零,下表里抢占那一行是本报告的主角。
t=0 客户端发出请求
│
│ [1] 排队 queue_time —— 在 waiting 队列里等调度器放行
│ [2] prefill —— 把 6144 个输入 token 一次算完,写进 KV Cache
│ [3] 前端 + IPC —— 分词、HTTP、API server 与引擎之间的通信
│
━━━━━ [1]+[2]+[3] = TTFT 首字延迟 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
│
│ 第 1 个输出 token 送达
│
│ [4] ITL × 2047 —— 相邻两个输出 token 之间的间隔
│ 这 2047 个间隔的平均值 = TPOT
│
━━━━━ [1]+[2]+[3]+[4] = E2EL 端到端时延 ━━━━━━━━━━━━━━━━━━━━━━━━━
# 恒等式:E2EL = TTFT + 2047 × TPOT
# 吞吐 = 所有请求的输出 token 总数 ÷ 整段测试的墙钟时间(不是 1/TPOT)
| 指标 | 中文 | 量的是什么 | 本轮实测 |
|---|---|---|---|
| TTFT Time To First Token | 首字延迟 | 从客户端发出请求,到收到第 1 个输出 token。它包含排队 + prefill + 前端开销三段,不含之后的 decode。 | p50 169 秒;均值 137 秒,其中 98.9% 是排队 |
| ITL Inter-Token Latency | token 间隔 | 相邻两个输出 token 之间的时间。本轮一条 2048 token 的请求有 2,047 个 ITL 样本,128 条共 26.2 万个。样本量太大,38 次抢占被稀释到 p99 够不着的地方(见「对照」一节)。 | 23.39 ms(p50) |
| ITL p99 | 间隔的尾部 | 把所有 ITL 样本排序后第 99% 的值。一次卡顿只影响少数间隔,均值和中位数都看不出来,只有 p99 会跳。 | 227.7 ms |
| TPOT Time Per Output Token | 每输出 token 耗时 | 一条请求在第一个 token 之后的平均出字速度,即 (E2EL − TTFT) ÷ (输出 token 数 − 1)。数值上等于这条请求 ITL 的均值。 | 29.17 ms |
| E2EL End-to-End Latency | 端到端时延 | 从发出请求到收到最后一个 token 的总时间。恒等式:E2EL = TTFT + (输出数−1) × TPOT。 | p50 238 秒 |
| 输出吞吐 output token/s | 用户拿到的 | 单位时间产出的输出 token 数,即用户感知的产能。 | 532.4 tok/s |
| 总吞吐 total token/s | GPU 处理的 | 输入 + 输出一起算。长输入场景下它会上涨,输出吞吐却下跌,两者不可互相替代。 | 2,129.6 tok/s |
| 抢占 preemption | KV 不够时腾位置 | KV 池装不下时,调度器把一条正在运行的请求退回队列。它已算出的 KV 被丢弃,恢复时要重算 prefill。计数器是 vllm:num_preemptions_total。vLLM V1 不打日志行,只能从 /metrics 读。 | 38 次,每 11.2 秒一次 |
| KV 占用 gpu_cache_usage_perc | KV 池用了多少 | KV 池的使用率。KV 池大小 = 显存扣掉权重与激活之后的余量,由启动日志给出。每 token 占 56 KiB,所以占用率 ≈ 并发 × 上下文 ÷ 池容量。 | 中位 95.85%,峰值 99.99% |
| Running / Waiting | 在跑的 / 在等的 | 服务端正在 decode 的请求数,和还在队列里等的请求数。两者之和等于客户端在飞的并发数。Running 才是真实产能。 | Running 均值 15.65;Waiting 均值 36.4、峰值 60 |
三个最容易混的地方
1 · TPOT 和 ITL 均值是同一件事的两种算法。 TPOT 先算每条请求自己的平均,再对请求取中位数;ITL 均值把所有间隔放在一起平均。 负载均匀时两者几乎相等,有长尾时会分开: 两者一分开,就说明有少数间隔被拖长。 本轮 TPOT 29.17 ms、ITL 均值 31.29 ms,已经分开。
2 · 看 p50 得不出任何关于抖动的结论。 一次 200 ms 的停顿混在本轮 26.2 万个间隔里,对中位数没有影响,对均值影响也很小。 本系列反复验证的经验是:抖动只在 p99 上露头。 抢占更稀有(万分之一量级),连 p99 都够不着,只能看专门的计数器。
3 · 吞吐不是 1 / TPOT。 TPOT 衡量单条请求的出字速度,吞吐是整台机器的产能,约等于单条出字速度乘以并行的请求条数。 并发提高时 TPOT 变差、吞吐变好,这正是压测要权衡的那个交换。
并发参数写的是 64,服务端能同时驻留的只有 16 条(均值 15.65,峰值 19)。 前三轮"加并发换吞吐、加长度换 KV"的结论都在线性区里;这一轮越过了 KV 池的边界,线性关系全部失效。 另外 48 条一直留在 waiting 队列里,中位数排了 169 秒才拿到第一个 token。
KV 池比第一轮大 38%,断崖的形状一点没变。 驻留数从 11.17 升到 15.65(+40%,和 KV 池的 +38% 基本同步), 但抢占照样稳定发生,TTFT 照样是三位数秒,p99 ITL 照样看不见抢占。 超卖的后果只跟"超了多少倍"有关,跟 KV 池的绝对大小无关。
如果这份报告只能留一句,就是:重算是伤口,并发塌缩才是断崖。 38 次抢占带来的重算只占全程墙钟的 6.2%; 让吞吐掉六成的,是 64 条并发被 KV 池砍到只剩 16 条。
同样是 C=64,只改了序列长度
1,388 → 532 tok/s · −62%
并发扫描里的 C=64(1024/256):KV 峰值 67.5%、零抢占、64 条全部驻留。 本轮 C=64(6144/2048):KV 顶到 100%、抢占 38 次、只有 16 条驻留。 并发数没变,吞吐掉了六成,TTFT 是原来的 215 倍。
TTFT p50
169 秒
均值 137 秒,其中 98.9% 是排队,prefill 只占 0.8%
抢占次数
38
每 11.2 秒一次,触发点稳定在 KV 99.58%
实际驻留 / 请求并发
16 / 64
与日志预告的 14.66x 上限吻合
重算掉的 prefill
+30 %
约 24–30 万 token,但只占全程墙钟的 6.2%;只有 FLOPs 计数器记下了这笔账
压爆不等于崩溃:这是本轮最该写进结论的一条
超卖 4.4 倍、KV 顶到 100%、抢占 38 次, 结果是零失败、零错误、输出 token 一个不少: 128 条请求全部返回,每条精确 2,048 个输出 token。
PagedAttention 加上抢占重算,把"资源不够"变成了排队更久, 而不是"请求失败"或"结果不对"。 这个设计把硬失败转成了软降级: 代价全部记在延迟上,一分没记在正确性上。 所以下文所有"断崖"都指延迟和吞吐的断崖,不是可用性的断崖。
KV 池会不会爆、什么时候爆,可以提前算出来:vLLM 启动时就把答案打在日志里。 长度扫描验证过的线性关系(56 KiB/token)在这里直接给出上限。
# 服务端启动日志(一字未改) GPU KV cache size: 120,064 tokens Maximum concurrency for 8,192 tokens per request: 14.66x # 本轮请求形状 6144 输入 + 2048 输出 = 8,192 token ← 正好顶满 --max-model-len 单请求满上下文 KV = 8192 × 56 KiB = 448 MiB # 要多少 / 有多少 64 × 448 MiB = 28.0 GiB 需求 6.41 GiB 实有 → 超卖 4.4 倍 # 实测 Running 中位数 16 条,峰值 19 条,均值 15.65 Waiting 峰值 60 条,均值 36.4 条
日志里的 14.66x 可以直接当容量红线用
Maximum concurrency for N tokens per request 这一行由 vLLM 按
KV池 ÷ max_model_len 算出。本轮实测均值 15.65 略高于 14.66,
因为请求刚进来时只占 6144 token,要 decode 2048 步才涨到 8192,平均占用比满值低。
两轮实测都比预告值高 5%–7%(见「对照」一节的两轮对照表),
方向一致、幅度接近。这一行可以直接抄进容量规划文档,结果略保守。
四种配置:KV 需求 vs 实际驻留请求数
前三种配置的 KV 需求都在 KV 池以内,请求数 = 驻留数,一条不落。 第四种的需求是 KV 池的 4.4 倍,两根柱子第一次分开。橙柱取的是 Running 峰值 19,全程均值只有 15.65。
这张图是本轮的核心证据:队列从头到尾都是满的。开测几秒内 Waiting 就冲到 60, 直到 128 条请求快跑完才开始下降。 紫线(KV 占用)几乎全程贴顶,红色阶梯是抢占计数。
Running / Waiting / KV 占用 / 抢占累计
KV 占用中位数 95.85%,74.6% 的采样点在 90% 以上,59.7% 在 95% 以上。 峰值 99.99%。这就是"顶到 ~1.0"的样子:它不是顶住不动,而是一条锯齿。
请求都在哪儿:驻留 vs 排队(堆叠)
绿色窄带就是这台机器的全部产能:平均 15.65 条,占 24.5%。 客户端始终维持 64 条在飞,两段之和恒为 64。其余 75.5% 的"并发"只是记账上的,不是物理上的。
前 140 秒放大后,能看到一个规整的循环,每一圈都一样:
调度器一次放进几条新请求 → KV 冲到 99.6% → 抢占一条 → KV 掉 5.2 个百分点 →
继续 decode,KV 慢慢涨回 99.6% → 再抢占一条。Running 沿着 19 → 18 → 17 → 16 往下走,
直到一批请求跑完,再一次放进新的。
抢占控制环(前 140 秒放大)
38 次抢占的触发点非常一致:抢占前一秒的 KV 占用均值为 99.58%。 这不是随机崩溃,而是一个把 KV 稳定在 93%–99.6% 之间的负反馈环。 第一轮量到的触发点也是 99.58%:两台机器得到同一个数, 说明这是调度器里的硬阈值,不是巧合。
| 抢占事件 | 发生时刻 | 抢占前 KV | 抢占后 KV | 释放 | Running |
|---|
每次抢占平均释放 5.20 个百分点 = 6,242 个 token,正好是一条请求当时持有的上下文。
也就是说,vLLM 每次只抢占一条,抢占的是 running 队列末尾那条(最晚进来的),
而不是把批次砍半。这个策略很克制,代价是必须频繁抢占。
释放量取决于请求的上下文长度,不取决于 KV 池大小。
抢占计数:一条几乎笔直的阶梯
从第 15 秒的第 1 次到第 430 秒的第 38 次,平均间隔 11.2 秒,几乎完全均匀。 过载状态下,抢占不是偶发事件,而是稳态的一部分。 抢占比第一轮更频繁,因为驻留数多了 4.5 条,KV 池填满得更快。
照着核对:日志里没有 "is preempted by"
实验计划里的这条预期在 vLLM V1 上不成立:"server 日志里出现类似 Sequence group ... is preempted by ... 的行"。
服务端日志里 preempt 出现
0 次;那是 V0 引擎的日志格式。V1 只在 /metrics 里暴露
vllm:num_preemptions_total,不打任何日志行。
只看服务端日志,这 38 次抢占完全隐形。 唯一能抓到它的办法,是本轮用的 1 Hz 采样脚本。
四种配置并排放:三组 KV 池够用,一组超卖 4.4 倍。 吞吐、p99 ITL、抢占计数三个指标同时看,才能看清断崖断在哪个维度上。
三指标并列:输出吞吐 / p99 ITL / 抢占次数
三个面板各自独立缩放。中间那块:p99 ITL 在四种配置下几乎没变 (197 / 208 / 214 / 228 ms),断崖没有断在这里。
| 配置 | 单请求上下文 | KV 需求 | KV 峰值 | 驻留 / 并发 | Waiting 峰值 | 抢占 | 输出 tok/s | 总 tok/s | TTFT p50 | ITL p50 | ITL p90 | ITL p99 | E2EL p50 |
|---|
断崖断在吞吐和 TTFT,没有断在 ITL
这是本轮最反直觉的一条。实验计划预期"P99 ITL 出现巨大尖峰(被抢占的请求要重算)"; 实测 p99 ITL = 227.7 ms,和零抢占时的 197–214 ms 在同一水位。 p90 ITL 甚至好得多(24.7 ms,并发扫描 C=64 是 187.5 ms)。
原因是算术:128 条 × 2047 个间隔 = 262,016 个 ITL 样本。
抢占只制造了 38 个巨大间隔,占 0.0145%,落在 p99.99 上,
--metric-percentiles 50,90,99 够不着。
p90 反而变好,是因为批内只剩 16 条流,别人的 prefill 插队占步变少了。
p99 ITL 不是抢占的监控指标。
抢占的指标只有两个:num_preemptions_total 和 TTFT 尾部。
两轮独立复现,可以当结论用。
反过来用:ITL 和 TTFT 谁在爆,直接指向两种不同的病
上面那条结论有一个实用的逆命题。线上收到"用户抱怨慢"时,先分清是哪一个在爆:
ITL 正常 + TTFT 爆炸 → 容量不够,请求在排队 药:加卡、减并发、缩 max_model_len、上限流 ← 本轮就是这一种:ITL p50 23.39 ms 几乎正常, 而 TTFT p50 是 169 秒 ITL 也慢 → 解码本身有问题 药:查 batch 配置、kernel、量化实现 ← Test 5 里 llama.cpp 遇到的是这一种
两种病,两种药。吃错药的典型是:看到 TTFT 爆了就去换更快的卡。 为什么没用,见下一节。
第一轮跑过一次形状完全相同的压测(6144 / 2048 / C=64 / 128 条),只有 KV 池不同。 这构成了一次意外的对照实验:KV 池大 38%,能换回多少?
| 指标 | 第一轮 | 第二轮 | 变化 |
|---|---|---|---|
| KV 池 | 4.64 GiB · 86,864 tok | 6.41 GiB · 120,064 tok | +38.2% |
| 日志预告容量 | 10.60x | 14.66x | +38.3% |
| 实测 Running 均值 | 11.17 | 15.65 | +40.1% |
| 超卖倍数 | 6.0× | 4.4× | −27% |
| 输出吞吐 | 438.8 tok/s | 532.4 tok/s | +21.3% |
| 耗时 | 597.4 s | 492.4 s | −17.6% |
| TTFT p50 | 219.2 s | 169.0 s | −22.9% |
| E2EL p50 | 270.4 s | 238.2 s | −11.9% |
| 抢占次数 | 37 | 38 | +2.7% |
| Waiting 均值 | 38.94 | 36.44 | −6.4% |
驻留数几乎线性跟随 KV 池,吞吐却是亚线性的
KV 池 +38.2% → 驻留 +40.1%(几乎 1:1,和日志预告的 +38.3% 也对得上) → 吞吐只有 +21.3%。多驻留的那 4.5 条请求,边际吞吐贡献在递减: 并发扫描已经量化过,从 C=16 到 C=32,扩展效率从 69% 掉到 52%。 在过载区,扩容 KV 池的收益比"能多装几条"看起来的要小。
抢占次数几乎没变(37 → 38)。 这说明抢占频率由"KV 顶满"这个状态决定,而不由 KV 池的绝对大小决定。 只要还在超卖,扩容只是让断崖挪了位置,没有消除它。
过载把 TTFT 里排队的比例推到了极端:排队 135.0 秒,prefill 1.10 秒,前端不到 0.5 秒。 长度扫描在 C=16 时的结论是"TTFT 已经有 82% 是等待,只有 18% 在算"。
TTFT 三段分解:四种配置对比(对数纵轴)
纵轴用对数,否则本轮那根柱子会把其余三根压成一条线。 prefill 段只从 0.48 s 涨到 1.10 s,涨的全部是排队段:0.35 s → 135.0 s,是原来的 386 倍。
# 本轮 TTFT 的构成(服务端直方图,全部 128 条) 排队 135,042 ms 98.9% prefill 1,097 ms 0.8% 前端+IPC 418 ms 0.3% ← 这一段是相减得到的残差 ──────────────────────────── TTFT mean 136,557 ms 100% # 用排队论核对一遍(Little's Law) 平均排队长度 36.4 条 ÷ 完成速率 0.260 条/s = 140 s 实测 TTFT p50 = 169 s 同量级,差异来自客户端一次性投放 64 条造成的先后次序
过载下的 TTFT 完全是排队论,和 GPU 无关
prefill 本身只要 1.10 秒。平均 137 秒的 TTFT 里,有 135 秒在等前面的人。 这个数由"队列长度 ÷ 出队速率"决定,跟卡多快、KV 多大、模型多小没有任何关系。 换一张 H100,也只会让那 1.10 秒变成 0.4 秒。
过载时唯一有效的手段是限流,不是加速。 在服务端设并发上限,对超出的请求快速失败(429), 比让它们在队列里排 169 秒有用得多;排队的请求还占着连接和客户端超时预算。
两个计数器给出了相反的答案:算力账多出近三成,token 账却一点没多。 抢占的教科书代价是"被抢占的请求要从头重算 prefill"。本轮有 38 次抢占, 按每条 6,200~8,200 token 算,应该多出二三十万 token 的重复计算。
# 计数器 A:iteration_tokens_total_sum(调度器记的 token 账) 实测总量 1,048,576 token 理论下限 128 × (6144 + 2048) = 1,048,576 token 差额 0 ← 一个 token 不多不少,精确停在理论下限上 # 计数器 B:estimated_flops_per_gpu_total(算力账) 实测 1.9884e16 FLOP 模型 1.5395e16 FLOP ← 用长度扫描 5 个零抢占数据点标定 差额 +4.489e15 FLOP = +29.2% # 把差额换算回 prefill token 4.489e15 ÷ 14.77 GFLOP/token = ≈ 30.4 万 token 30.4 万 ÷ 38 次 = 7,997 token / 次 # 独立校验:抢占时释放的 KV 38 次 × 6,242 token = 23.7 万 token ← 与上面同量级
FLOPs 模型标定:长度扫描五个点定线,本轮明显偏高
横轴是每个 token 前向时的平均上下文长度。长度扫描的五个点大致在一条直线上(R²=0.90), 斜率 0.000173 GFLOP/上下文 token。 本轮的红点高出拟合线 29.2%,这就是重算的账。
结论:重算发生了,但调度器的 token 计数器没记
两条独立证据互相印证(FLOPs 差额 30.4 万 token、抢占释放的 KV 23.7 万 token):
重算量约 24–30 万 token,即 prefill 工作量多做了 30%–39%。
而 iteration_tokens_total 记下的是 1,048,576,
精确等于 128 × 8192 这个理论下限,一个 token 都没多。
实际代价没有想象中大:30 万 token 的 prefill 按实测 146 TF/s 算约 31 秒,占全程 492 秒的 6.2%。 让吞吐掉到 38% 的不是重算,而是并发被 KV 池砍到了 16 条。 重算是伤口,并发塌缩才是断崖。
这个矛盾在两台不同的机器上原样重现,基本可以排除偶然。
在源码层面确认之前,不要把 iteration_tokens_total
当作"GPU 实际算了多少"的依据:有抢占时它会系统性低报。
单条请求的 decode 阶段实测 65.61 秒,而 2047 个 token × ITL 中位数 23.39 ms 只需要 47.9 秒。 多出的 17.7 秒(27%)被三件事吃掉:别人的 prefill、别人的重算, 以及自己被抢占后的空档。
全程 492 秒的墙钟去向
重算那一段只有 31 秒。过载的代价绝大部分花在"只能跑 16 条流"上, 而不是重算上;重算只占 6.2%。
压爆 KV 池之前,多数人对"会看到什么"有一套直觉。逐条核对的结果: 三条完全命中,一条形态不同,一条在 vLLM V1 上根本观测不到。
| 常见预期 | 结果 | 实测 | 说明 |
|---|
| 指标 | 数值 | 指标 | 数值 |
|---|
--max-num-seqs 设成真实容量,而不是 128。
本轮 --max-num-seqs 128 允许调度器放进 18–19 条,再靠抢占退回到 15–16 条;
38 次抢占全部由这种"放多了再抢占"造成。按日志的 14.66x 设成
--max-num-seqs 14,理论上抢占会降到 0,吞吐不变,ITL 尾部更干净。
这是最容易验证、收益最直接的一条。
注意这个值随机器变:第一轮该设 10,这一轮该设 14。--max-num-seqs 管驻留,队列上限管积压。--metric-percentiles 改成 50,90,99,99.9,99.99 重跑一次,
应该能直接看到那 38 个巨大间隔的量级。iteration_tokens_total 的记录点。
两轮都出现同一个矛盾:token 账等于(甚至略低于)理论下限,FLOPs 账多出 26%–28%。
两台机器都复现,已经不是偶然。确认之前,有抢占的场景下 MFU / 算术强度这类指标
都应以 FLOPs 计数器为准。--kv-cache-memory 把 KV 池显式固定为 4 / 6 / 8 GiB 三档,
同一形状各压一次,就能直接画出这条曲线,
还能顺便验证"抢占频率与 KV 池大小无关"这个更强的说法。口径提醒
本轮只有 128 条请求、单次运行,没有重复测量;p99 相当于第 126–127 条。
TTFT 的分布主要由客户端的发压顺序决定(先发的排队短、后发的排队长),
所以 ttft p50 = 169 s 应理解为"批次中位数",不是稳态尾延迟。
要量稳态,需要改用固定到达率(--request-rate),而不是固定并发。
另外,和第一轮的对照不是严格的 A/B。 两轮的 KV 池差异来自驱动 / CUDA 版本和显存 profiling 的随机性, 不是人为设定的自变量: 第一轮内部换一次服务端启动,KV 池就从 86,896 变成了 86,864。 所以本报告里所有"第一轮 vs 第二轮"的对照,应读作 "两个 KV 池大小下各观察到一次",而不是"控制变量实验"。 要确认它,就做上面第五条那个实验。
这是 Round 2 五份压测报告的第 4 份(vLLM 超卖压爆)。
整轮的横向结论与生产选型建议见 《选型指南-vllm-llama-cpp》:
那份报告合并五组测试的数据,回答"两个引擎各自强在哪、生产上怎么选"。
同目录另有:vllm-01-Qwen2.5-7B并发曲线 · vllm-02-TTFT解剖 · vllm-03-KV的线性 · llama-cpp-压测。