Overload Test · 第四轮 · RTX 4090
6144 输入 / 2048 输出 / 并发 64 —— 单请求满上下文要 448 MiB KV,64 条要 28 GiB, 而池子只有 4.64 GiB。这一轮的目的是把它压爆,然后看清楚"爆"到底长什么样。 128 条请求全部成功返回,零失败,用了 597 秒。
前三轮的结论是"加并发换吞吐、加长度换 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 —— 平均占用比满值低一些。
把这一行抄进容量规划文档,比事后看监控省事得多。
四种配置:KV 需求 vs 实际驻留请求数
前三种配置的 KV 需求都在池子以内,请求数 = 驻留数,一条不落。 第四种需求是池子的 6 倍,两根柱子第一次分家 —— 橙柱取的是 Running 峰值 14,全程均值只有 11.17。
这张图是这一轮的核心证据。开测第 2 秒 Running 就是 5、Waiting 就是 59 —— 队列从第一秒起就是满的,而且此后 330 秒一直维持在 51~54,直到 128 条请求快跑完才开始泄。 紫线(KV 占用)几乎全程贴着顶,红色阶梯是抢占计数。
Running / Waiting / KV 占用 / 抢占累计
KV 占用中位数 94.97%,82.5% 的采样点在 90% 以上,49.7% 在 95% 以上。 峰值触到 100.0%。这就是"顶到 ~1.0"该有的样子 —— 但它不是"顶住不动",而是一条锯齿。
请求都在哪儿:驻留 vs 排队(堆叠)
客户端始终维持 64 条在飞,两段之和恒为 64。绿色那条窄带就是这台机器的全部产能 —— 平均 11.17 条,占 17.5%。剩下 82.5% 的"并发"是记账上的,不是物理上的。
把前 140 秒放大,能看到一个非常规整的循环,而且每一圈都长一个样:
调度器一次放进 3~4 条新请求 → KV 冲到 99.6% → 抢占一条 → KV 掉 7.5 个百分点 →
继续解码又慢慢涨回 99.6% → 再抢一条。Running 沿着 13 → 12 → 11 → 10 往下走,
直到有一批请求跑完,再一次性放进新的。
抢占控制环(前 140 秒放大)
37 次抢占的触发点极其一致:抢占前一秒的 KV 占用均值 99.58%,最低的一次也有 96.81%。 这不是"随机崩溃",是一个把 KV 稳定在 92%–99.6% 之间的负反馈环。
| 抢占事件 | 发生时刻 | 抢占前 KV | 抢占后 KV | 释放 | Running |
|---|
每次抢占平均释放 7.52 个百分点 = 6,529 个 token —— 正好是一条请求当时持有的上下文。
也就是说:vLLM 每次只踢一条,踢的是 running 队列末尾那条(最晚进来的),
而不是把批次砍半。这是一个非常克制的策略,代价是它必须频繁地踢。
抢占计数:一条几乎笔直的阶梯
从第 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 采样脚本 —— 这也是为什么它值得单独写一节。
把四种配置并排放:两组"池子够"、一组"刚好够"、一组"爆 6 倍"。 吞吐、p99 ITL、抢占计数三个指标同时看,才能看清楚断崖到底断在哪一个维度上。
三指标并列:输出吞吐 / p99 ITL / 抢占次数
三个面板各自独立缩放。注意中间那块:p99 ITL 在四种配置下几乎没变 (190 / 202 / 213 / 216 ms)—— 断崖没有断在这里。
| 配置 | 单请求上下文 | KV 需求 | KV 峰值 | 驻留 / 并发 | Waiting 峰值 | 抢占 | 输出 tok/s | 总 tok/s | TTFT p50 | ITL p50 | ITL p90 | ITL p99 | E2EL p50 |
|---|
断崖断在吞吐和 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 尾部。
第三轮的结论是"C=16 时 TTFT 已经有 82% 是等待,只有 18% 在算"。 过载状态把这个比例推到了极端:排队 177.6 秒,prefill 1.06 秒,前端 0.73 秒。
TTFT 三段分解:四轮对比(对数纵轴)
纵轴是对数,否则本轮那根柱子会把前三轮压成一条线。 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 ← 与上面同量级
FLOPs 模型标定:第三轮五个点定线,本轮明显偏高
横轴是每个 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 实际算了多少"的依据 ——
在有抢占的场景下它会系统性低报。
单条请求的解码阶段实测 54.86 秒,而 2047 个 token × ITL 中位数 21.46 ms 只需要 43.9 秒。 差出来的 10.9 秒(20%)就是被别人的 prefill、别人的重算、 以及自己被抢占后的空档吃掉的。
全程 597 秒的墙钟去向
重算那一段只有 28 秒。这张图想说明的是:过载的代价 84% 花在了"只能跑 11 条流"上, 而不是"重算"上 —— 后者只占 4.7%。
实验计划里列了"该看到什么(照着核对)"。逐条对一遍,三条完全命中,一条部分命中,一条不成立。
| 预期 | 结果 | 实测 | 说明 |
|---|
| 指标 | 数值 | 指标 | 数值 |
|---|
--max-num-seqs 设成真实容量,而不是 128。
本轮 --max-num-seqs 128 允许调度器放进 13–14 条,然后靠抢占再踢回 10–11 条 ——
37 次抢占全部是这个"放多了再踢"造成的。按日志的 10.60x 把它设成
--max-num-seqs 10,理论上抢占会降到 0,吞吐不变但 ITL 尾部更干净。
这是最容易验证、收益最直接的一条。--max-num-partial-prefills / 限流,而不是靠客户端并发。
本轮 53 条请求在队列里排了 3 分钟以上,它们占着 HTTP 连接、占着客户端超时预算,
却对吞吐一点贡献都没有。在服务端设一个 --max-num-seqs + 前置队列长度上限,
让超出的请求快速失败(429),比让它们排 219 秒更有用。--metric-percentiles 50,90,99 看不见抢占。
把它改成 50,90,99,99.9,99.99 重跑一次,应该能直接看到那 37 个巨大间隔的量级 ——
按 ITL 标准差 211 ms 反推,单次抢占造成的空档在 10 秒量级,但这需要实测确认。iteration_tokens_total 的记录点。
本轮出现了两个计数器打架:token 账精确等于理论下限,FLOPs 账多出 26.6%。
在确认之前,涉及抢占的场景下 MFU / 算术强度这类指标都应该以 FLOPs 计数器为准。口径提醒
本轮只有 128 条请求、单次运行,没有重复测量;p99 相当于第 126–127 条,
而 TTFT 的分布主要由客户端的发压顺序决定(先发的排队短、后发的排队长),
所以 ttft p50 = 219 s 应理解为"批次中位数",不是稳态尾延迟。
要量稳态,需要改成固定到达率(--request-rate)而不是固定并发。