Overload Test · 第四轮 · RTX 4090

断崖

6144 输入 / 2048 输出 / 并发 64 —— 单请求满上下文要 448 MiB KV,64 条要 28 GiB, 而池子只有 4.64 GiB。这一轮的目的是把它压爆,然后看清楚"爆"到底长什么样。 128 条请求全部成功返回,零失败,用了 597 秒。

GPU
RTX 4090 · 24 GB
vLLM
0.27.1 · V1
输入 / 输出
6144 / 2048 tok
并发请求
C = 64 · 128 条
KV 需求
28.0 GiB
KV 池
4.64 GiB · 86,864 tok
超卖倍数
6.0 ×
抢占
37 次

结论并发旋钮拧到 64,实际只跑了 11 条

前三轮的结论是"加并发换吞吐、加长度换 KV",都还在线性区里。 这一轮越过了 KV 池的边界,线性关系全部作废: 并发参数写的是 64,服务端能同时驻留的只有 11 条(均值 11.17,峰值 14), 另外 53 条一直躺在 waiting 队列里 —— 中位数排了 219 秒才拿到第一个 token。

同样是 C=64,只改了序列长度

1,405 → 439 tok/s  ·  −69%

第二轮 C=64(1024/256)KV 峰值 93.3%、零抢占、64 条全部驻留; 本轮 C=64(6144/2048)KV 顶到 100%、抢占 37 次、只有 11 条驻留。 并发数一个字没改,吞吐掉了三分之二,首字延迟涨了 281 倍。

TTFT p50

219 秒

均值 179 秒,其中 99.0% 是排队,prefill 只占 0.6%

抢占次数

37

每 16.1 秒一次,触发点稳定在 KV 99.6%

实际驻留 / 请求并发

11 / 64

与日志预告的 10.60x 上限吻合

重算掉的 prefill

+33 %

约 24–29 万 token,只有 FLOPs 计数器记下了这笔账

超卖这笔账在启动日志里就写着

压爆之前,能不能爆、什么时候爆,其实是可以算出来的 —— 而且 vLLM 启动时就把答案打在日志里了。 第三轮验证过的那条线性关系(56 KiB/token)在这里直接给出上限。

# 服务端启动日志(一字未改)
GPU KV cache size: 86,864 tokens
Maximum concurrency for 8,192 tokens per request: 10.60x

# 本轮请求形状
6144 输入 + 2048 输出 = 8,192 token  ← 正好顶满 --max-model-len
单请求满上下文 KV = 8192 × 56 KiB = 448 MiB

# 要多少 / 有多少
64 × 448 MiB = 28.0 GiB   需求
                4.64 GiB   实有        →  超卖 6.0 倍

# 实测
Running 中位数 11 条,峰值 14 条,均值 11.17
Waiting 峰值 60 条,均值 38.9 条

日志里的 10.60x 是可以直接当容量红线用的

Maximum concurrency for N tokens per request 这一行是 vLLM 用 KV池 ÷ max_model_len 算出来的。本轮实测均值 11.17 略高于 10.60, 因为请求刚进来时只占 6144 token,要解码 2048 步才涨到 8192 —— 平均占用比满值低一些。 把这一行抄进容量规划文档,比事后看监控省事得多。

Figure 1

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

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

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

波形597 秒全程实况

这张图是这一轮的核心证据。开测第 2 秒 Running 就是 5、Waiting 就是 59 —— 队列从第一秒起就是满的,而且此后 330 秒一直维持在 51~54,直到 128 条请求快跑完才开始泄。 紫线(KV 占用)几乎全程贴着顶,红色阶梯是抢占计数。

Figure 2

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

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

KV 占用中位数 94.97%,82.5% 的采样点在 90% 以上,49.7% 在 95% 以上。 峰值触到 100.0%。这就是"顶到 ~1.0"该有的样子 —— 但它不是"顶住不动",而是一条锯齿。

Figure 3

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

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

客户端始终维持 64 条在飞,两段之和恒为 64。绿色那条窄带就是这台机器的全部产能 —— 平均 11.17 条,占 17.5%。剩下 82.5% 的"并发"是记账上的,不是物理上的。

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

把前 140 秒放大,能看到一个非常规整的循环,而且每一圈都长一个样: 调度器一次放进 3~4 条新请求 → KV 冲到 99.6% → 抢占一条 → KV 掉 7.5 个百分点 → 继续解码又慢慢涨回 99.6% → 再抢一条。Running 沿着 13 → 12 → 11 → 10 往下走, 直到有一批请求跑完,再一次性放进新的。

Figure 4

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

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

37 次抢占的触发点极其一致:抢占前一秒的 KV 占用均值 99.58%,最低的一次也有 96.81%。 这不是"随机崩溃",是一个把 KV 稳定在 92%–99.6% 之间的负反馈环。

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

每次抢占平均释放 7.52 个百分点 = 6,529 个 token —— 正好是一条请求当时持有的上下文。 也就是说:vLLM 每次只踢一条,踢的是 running 队列末尾那条(最晚进来的), 而不是把批次砍半。这是一个非常克制的策略,代价是它必须频繁地踢。

Figure 5

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

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

从第 9 秒的第 1 次到第 580 秒的第 37 次,平均间隔 16.1 秒,几乎完全均匀。 过载状态下抢占不是"偶发事件",是稳态的一部分。

照着核对:日志里没有 "is preempted by"

实验计划里写"server 日志里出现类似 Sequence group ... is preempted by ... 的行" —— 这一条在 vLLM V1 上不成立。全量 340 KB 服务端日志里 preempt 出现 0 次,那是 V0 引擎的日志格式。V1 只在 /metrics 里暴露 vllm:num_preemptions_total,不打任何日志行。

也就是说:如果你只看服务端日志,这 37 次抢占是完全隐形的。 能抓到它的唯一办法就是本轮用的这个 1 Hz 采样脚本 —— 这也是为什么它值得单独写一节。

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

把四种配置并排放:两组"池子够"、一组"刚好够"、一组"爆 6 倍"。 吞吐、p99 ITL、抢占计数三个指标同时看,才能看清楚断崖到底断在哪一个维度上。

Figure 6

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

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

三个面板各自独立缩放。注意中间那块:p99 ITL 在四种配置下几乎没变 (190 / 202 / 213 / 216 ms)—— 断崖没有断在这里。

配置单请求上下文KV 需求KV 峰值 驻留 / 并发Waiting 峰值抢占 输出 tok/s总 tok/s TTFT p50ITL p50ITL p90ITL p99E2EL p50
「KV 需求」= 并发 × 单请求满上下文 ÷ 86,864。前两行取自第二轮,第三行第三轮,第四行本轮。延迟单位 ms。

断崖断在吞吐和 TTFT,没有断在 ITL

这是本轮最反直觉的一条。实验计划预期"P99 ITL 出现巨大尖峰(被抢占的请求要重算)", 实测 p99 ITL = 215.6 ms,和前三轮零抢占时的 190–213 ms 属于同一水位; p90 ITL 甚至更好(22.1 ms,第二轮 C=64 是 121.9 ms)。

原因是算术:128 条 × 2047 个间隔 = 262,016 个 ITL 样本, 抢占只制造了 37 个巨大间隔,占 0.0141% —— 落在 p99.99 上, --metric-percentiles 50,90,99 根本够不着。 而 p90 反而变好,是因为批内只剩 11 条流,别人的 prefill 抢步变少了。

这条经验值得单独记下来:p99 ITL 不是抢占的监控指标。 抢占的指标只有两个 —— num_preemptions_total 和 TTFT 尾部。

排队TTFT 里 99% 是排队

第三轮的结论是"C=16 时 TTFT 已经有 82% 是等待,只有 18% 在算"。 过载状态把这个比例推到了极端:排队 177.6 秒,prefill 1.06 秒,前端 0.73 秒。

Figure 7

TTFT 三段分解:四轮对比(对数纵轴)

排队 queue_time prefill 前端 + IPC

纵轴是对数,否则本轮那根柱子会把前三轮压成一条线。 prefill 段只从 0.41 s 涨到 1.06 s,涨的全部是排队段:0.13 s → 177.6 s,1,420 倍。

# 本轮 TTFT 的构成(服务端直方图均值)
排队      177,566 ms   99.0%
prefill     1,062 ms    0.6%
前端+IPC       730 ms    0.4%
────────────────────────────
TTFT mean 179,358 ms

# 一条请求的一生(均值)
排队 177.6 s  →  prefill 1.1 s  →  解码 54.9 s   =  e2e 233.6 s
76% 的时间在队列里,什么都没发生

Little 定律的注脚

吞吐 0.214 req/s、驻留 11.17 条 → 平均驻留时间 52.2 s,与实测 prefill+decode 55.9 s 吻合。 客户端却压着 64 条 → 队列长度 64 − 11.17 = 52.8 条, 排队时间 = 52.8 ÷ 0.214 = 247 s,与实测 p50 排队 219 s 同量级。 过载下的 TTFT 完全是排队论,和 GPU 无关。

对账两个计数器打架:重算到底发生了没有

抢占的教科书代价是"被踢的请求要从头重算 prefill"。这一轮有 37 次抢占, 按每条 6,500~8,000 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.980e16 FLOP
模型 1.564e16 FLOP   ← 用第三轮 5 个零抢占数据点标定
差额 +4.17e15 FLOP = +26.6%

# 把差额换算回 prefill token
4.17e15 ÷ 14.57 GFLOP/token = ≈ 28.6 万 token
28.6 万 ÷ 37 次 = 7,732 token / 次

# 独立校验:抢占时释放的 KV
37 次 × 6,529 token = 24.2 万 token   ← 与上面同量级
Figure 8

FLOPs 模型标定:第三轮五个点定线,本轮明显偏高

第三轮(零抢占) 拟合 13.52 + 0.00034·平均上下文 本轮(37 次抢占)

横轴是每个 token 前向时的平均上下文长度。第三轮五个点几乎在一条直线上, 斜率 0.00034 GFLOP/上下文 token,与注意力的理论值 0.000401 同量级。 本轮那个红点高出拟合线 26.6% —— 这就是重算的账。

结论:重算发生了,但调度器的 token 计数器没记

两条独立证据(FLOPs 差额 28.6 万 token、抢占释放的 KV 24.2 万 token)互相印证, 重算量约 24–29 万 token,相当于 prefill 工作量多做了 31%–36%。 而 iteration_tokens_total 精确地等于理论下限,一个 token 都没多记。

实际代价没有想象中大:25 万 token 的 prefill 按实测 150 TF/s 算约 28 秒,占全程 597 秒的 4.7%。 真正让吞吐掉到 31% 的不是重算,是并发被 KV 池砍到了 11 条。 重算是伤口,并发塌缩才是断崖。

这个矛盾建议在源码层面确认一次(vLLM V1 scheduler.py 的抢占分支, 以及 iteration_tokens 的记录点)。在确认之前, 不要把 iteration_tokens_total 当作"GPU 实际算了多少"的依据 —— 在有抢占的场景下它会系统性低报。

解码阶段被偷走的 20%

单条请求的解码阶段实测 54.86 秒,而 2047 个 token × ITL 中位数 21.46 ms 只需要 43.9 秒。 差出来的 10.9 秒(20%)就是被别人的 prefill、别人的重算、 以及自己被抢占后的空档吃掉的。

Figure 9

全程 597 秒的墙钟去向

纯解码(23,248 步 × 21.46 ms) 正常 prefill(78.6 万 token,已扣除重算) 抢占重算(约 24–29 万 token)

重算那一段只有 28 秒。这张图想说明的是:过载的代价 84% 花在了"只能跑 11 条流"上, 而不是"重算"上 —— 后者只占 4.7%。

核对实验计划的五条预期,对了三条

实验计划里列了"该看到什么(照着核对)"。逐条对一遍,三条完全命中,一条部分命中,一条不成立。

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

数据本轮完整测量结果

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

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

口径提醒

本轮只有 128 条请求、单次运行,没有重复测量;p99 相当于第 126–127 条, 而 TTFT 的分布主要由客户端的发压顺序决定(先发的排队短、后发的排队长), 所以 ttft p50 = 219 s 应理解为"批次中位数",不是稳态尾延迟。 要量稳态,需要改成固定到达率(--request-rate)而不是固定并发。