Overload Test · Round 2 · RTX 4090

断崖

6144 输入 / 2048 输出 / 并发 64:单请求满上下文要 448 MiB KV,64 条要 28 GiB, 而 KV 池只有 6.41 GiB。这一轮故意把 KV 池压爆,看清"爆"是什么样子。 128 条请求全部成功返回,零失败,用时 492 秒。

GPU
RTX 4090 · 24 GB
vLLM
0.27.1 · V1
输入 / 输出
6144 / 2048 tok
并发请求
C = 64 · 128 条
KV 需求
28.0 GiB
KV 池
6.41 GiB · 120,064 tok
超卖倍数
4.4 ×
抢占
38 次
驱动
580.159.03 · CUDA 13.0

术语先把这些指标定义清楚

下面这些缩写不是同义词:每一个量的是流水线上不同的一段,彼此之间有确定的换算关系。 本轮负载: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 条

并发参数写的是 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%(见「对照」一节的两轮对照表), 方向一致、幅度接近。这一行可以直接抄进容量规划文档,结果略保守。

Figure 1

四种配置:KV 需求 vs 实际驻留请求数

请求的并发数 实际驻留(Running 峰值) KV 需求占池比例(右轴,对数)

前三种配置的 KV 需求都在 KV 池以内,请求数 = 驻留数,一条不落。 第四种的需求是 KV 池的 4.4 倍,两根柱子第一次分开。橙柱取的是 Running 峰值 19,全程均值只有 15.65。

波形492 秒全程实况

这张图是本轮的核心证据:队列从头到尾都是满的。开测几秒内 Waiting 就冲到 60, 直到 128 条请求快跑完才开始下降。 紫线(KV 占用)几乎全程贴顶,红色阶梯是抢占计数。

Figure 2

Running / Waiting / KV 占用 / 抢占累计

Running(左轴) Waiting(左轴) KV 占用(右轴 %) 抢占累计(右轴,0–40)

KV 占用中位数 95.85%,74.6% 的采样点在 90% 以上,59.7% 在 95% 以上。 峰值 99.99%。这就是"顶到 ~1.0"的样子:它不是顶住不动,而是一条锯齿。

Figure 3

请求都在哪儿:驻留 vs 排队(堆叠)

Running(正在算) Waiting(在排队)

绿色窄带就是这台机器的全部产能:平均 15.65 条,占 24.5%。 客户端始终维持 64 条在飞,两段之和恒为 64。其余 75.5% 的"并发"只是记账上的,不是物理上的。

锯齿抢占是一个负反馈控制环

前 140 秒放大后,能看到一个规整的循环,每一圈都一样: 调度器一次放进几条新请求 → KV 冲到 99.6% → 抢占一条 → KV 掉 5.2 个百分点 → 继续 decode,KV 慢慢涨回 99.6% → 再抢占一条。Running 沿着 19 → 18 → 17 → 16 往下走, 直到一批请求跑完,再一次放进新的。

Figure 4

抢占控制环(前 140 秒放大)

KV 占用 % Running 抢占时刻 触发线 99.58%

38 次抢占的触发点非常一致:抢占前一秒的 KV 占用均值为 99.58%。 这不是随机崩溃,而是一个把 KV 稳定在 93%–99.6% 之间的负反馈环。 第一轮量到的触发点也是 99.58%:两台机器得到同一个数, 说明这是调度器里的硬阈值,不是巧合。

抢占事件发生时刻抢占前 KV抢占后 KV释放Running
只列前 12 次,全部 38 次的统计见正文。「释放」为 KV 占用的下降,括号内换算成 token。

每次抢占平均释放 5.20 个百分点 = 6,242 个 token,正好是一条请求当时持有的上下文。 也就是说,vLLM 每次只抢占一条,抢占的是 running 队列末尾那条(最晚进来的), 而不是把批次砍半。这个策略很克制,代价是必须频繁抢占。 释放量取决于请求的上下文长度,不取决于 KV 池大小。

Figure 5

抢占计数:一条几乎笔直的阶梯

num_preemptions_total 线性参考(5.4 次/分钟)

从第 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 采样脚本。

对照before / after:池子够 vs 池子爆

四种配置并排放:三组 KV 池够用,一组超卖 4.4 倍。 吞吐、p99 ITL、抢占计数三个指标同时看,才能看清断崖断在哪个维度上。

Figure 6

三指标并列:输出吞吐 / p99 ITL / 抢占次数

输出 token/s p99 ITL (ms) 抢占次数

三个面板各自独立缩放。中间那块:p99 ITL 在四种配置下几乎没变 (197 / 208 / 214 / 228 ms),断崖没有断在这里。

配置单请求上下文KV 需求KV 峰值 驻留 / 并发Waiting 峰值抢占 输出 tok/s总 tok/s TTFT p50ITL p50ITL p90ITL p99E2EL p50
「KV 需求」= 并发 × 单请求满上下文 ÷ 120,064。四行全部取自 Round 2。延迟单位 ms。

断崖断在吞吐和 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 爆了就去换更快的卡。 为什么没用,见下一节。

另一组对照:KV 池大 38%,吞吐只多 21%

第一轮跑过一次形状完全相同的压测(6144 / 2048 / C=64 / 128 条),只有 KV 池不同。 这构成了一次意外的对照实验:KV 池大 38%,能换回多少?

两轮形状完全相同,唯一的差别是 KV 池。第一轮的 KV 池数据取自它自己那次启动的日志(86,864 / 10.60x)。 注意:第一轮的 Test 1–2 是另一次启动,KV 池是 86,896 / 10.61x,不能混用。
指标第一轮第二轮变化
KV 池4.64 GiB · 86,864 tok6.41 GiB · 120,064 tok+38.2%
日志预告容量10.60x14.66x+38.3%
实测 Running 均值11.1715.65+40.1%
超卖倍数6.0×4.4×−27%
输出吞吐438.8 tok/s532.4 tok/s+21.3%
耗时597.4 s492.4 s−17.6%
TTFT p50219.2 s169.0 s−22.9%
E2EL p50270.4 s238.2 s−11.9%
抢占次数3738+2.7%
Waiting 均值38.9436.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 里 99% 是排队

过载把 TTFT 里排队的比例推到了极端:排队 135.0 秒,prefill 1.10 秒,前端不到 0.5 秒。 长度扫描在 C=16 时的结论是"TTFT 已经有 82% 是等待,只有 18% 在算"。

Figure 7

TTFT 三段分解:四种配置对比(对数纵轴)

排队 queue_time prefill 前端 + IPC

纵轴用对数,否则本轮那根柱子会把其余三根压成一条线。 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   ← 与上面同量级
Figure 8

FLOPs 模型标定:长度扫描五个点定线,本轮明显偏高

长度扫描(零抢占) 拟合 13.53 + 0.000173·平均上下文 本轮(38 次抢占)

横轴是每个 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 阶段被偷走的 27%

单条请求的 decode 阶段实测 65.61 秒,而 2047 个 token × ITL 中位数 23.39 ms 只需要 47.9 秒。 多出的 17.7 秒(27%)被三件事吃掉:别人的 prefill、别人的重算, 以及自己被抢占后的空档。

Figure 9

全程 492 秒的墙钟去向

纯 decode(16,457 步 × 23.39 ms) 正常 prefill(78.6 万 token,已扣除重算) 抢占重算(约 24–30 万 token)

重算那一段只有 31 秒。过载的代价绝大部分花在"只能跑 16 条流"上, 而不是重算上;重算只占 6.2%。

预期五个常见预期,哪些站得住

压爆 KV 池之前,多数人对"会看到什么"有一套直觉。逐条核对的结果: 三条完全命中,一条形态不同,一条在 vLLM V1 上根本观测不到。

常见预期结果实测说明
「部分」表示现象存在但形态与预期不同;「不成立」表示在 vLLM V1 上这条无法观测到。

数据本轮完整测量结果

指标数值指标数值
延迟取自 stress.json,占用与计数取自 1 Hz 采样窗口(490 秒,489 个采样点)。

下一步这轮之后该做的五件事

口径提醒

本轮只有 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-压测。