Length Sweep · Round 2 · RTX 4090
固定并发 16,输入长度从 256 扫到 4096,800 条请求全部成功,1 Hz 采样 501 个点。 这一轮回答三个问题:KV 占用是不是线性的;有并发时,TTFT(首字延迟)里有多少在"算"、 多少在"等";并发和长度这两条轴,是不是同一件事的两种走法。
下面这些缩写在正文里反复出现,它们不是同义词:
每个指标量的是流水线上不同的一段,彼此之间有确定的换算关系。
本轮负载:输入 256 → 4096 · 输出 256 · 固定并发 16。本报告固定并发、只改输入长度,所以"输出吞吐"和"总吞吐"朝相反方向走,下表这两行要分清。
t=0 客户端发出请求
│
│ [1] 排队 queue_time —— 在 waiting 队列里等调度器放行
│ [2] prefill —— 把 L 个输入 token 一次算完,写进 KV Cache
│ [3] 前端 + IPC —— 分词、HTTP、API server 与引擎之间的通信
│
━━━━━ [1]+[2]+[3] = TTFT 首字延迟 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
│
│ 第 1 个输出 token 送达
│
│ [4] ITL × 255 —— 相邻两个输出 token 之间的间隔
│ 这 255 个间隔的平均值 = TPOT
│
━━━━━ [1]+[2]+[3]+[4] = E2EL 端到端时延 ━━━━━━━━━━━━━━━━━━━━━━━━━
# 恒等式:E2EL = TTFT + 255 × TPOT
# 吞吐 = 所有请求的输出 token 总数 ÷ 整段测试的墙钟时间(不是 1/TPOT)
| 指标 | 中文 | 量的是什么 | 本轮实测 |
|---|---|---|---|
| TTFT Time To First Token | 首字延迟 | 从客户端发出请求,到收到第 1 个输出 token。它包含排队、prefill、前端开销三段,不含后续 decode。 | 270 → 1,319 ms(均值) |
| ITL Inter-Token Latency | token 间隔 | 相邻两个输出 token 之间的时间。一条 256 token 的请求有 255 个 ITL 样本。样本量大,所以 ITL 的 p99 最能暴露抖动。 | 16.47 → 20.26 ms(p50) |
| ITL p99 | 间隔的尾部 | 所有 ITL 样本排序后第 99% 位置的值。一次卡顿只影响少数间隔,均值和中位数都看不出来,只有 p99 会跳。 | 20.3 → 214.2 ms |
| 输出吞吐 output token/s | 用户拿到的 | 单位时间产出的输出 token 数,也就是用户感知到的产能。 | 893 → 359 tok/s(下降) |
| 总吞吐 total token/s | GPU 处理的 | 输入和输出一起算。长输入时它上涨,输出吞吐却下跌 —— 两者不能互相替代。 | 1,786 → 6,109 tok/s(上升) |
| 抢占 preemption | KV 池不够时抢占 | KV 池装不下时,调度器把一条正在运行的请求退回队列,丢弃它已算出的 KV,恢复时要重算 prefill。计数器是 vllm:num_preemptions_total。vLLM V1 不打日志行,只能从 /metrics 读。 | 全程 0 次 |
| KV 占用 gpu_cache_usage_perc | KV 池用了多少 | KV 池的使用率。KV 池大小 = 显存扣掉权重与激活后的余量,由启动日志给出。每 token 占 56 KiB,所以占用率 ≈ 并发 × 上下文 ÷ 池容量。 | 6.8% → 57.8% |
| Running / Waiting | 在跑的 / 在等的 | 服务端正在 decode 的请求数,和还在队列里等的请求数。两者之和等于客户端的在途并发数。Running 才是真实产能。 | Running 中位数 16(L=4096 为 15)、均值 14.8~15.4;Waiting 仅开场几秒非零 |
| tok/iter iteration_tokens_total | 每步吃几个 token | 一次前向一起算的 token 数 = 正在 decode 的请求数 + chunked prefill 切进来的块。本报告最后证明它才是真正的自变量:并发轴和长度轴换算成它之后会重合。 | 30.3 → 265.1 |
| MFU / MBU 算力 / 带宽利用率 | 离两堵墙还有多远 | 实测 FLOP/s ÷ 165.2 TF/s,与实测 byte/s ÷ 1,008 GB/s。单看一个没用,放一起才知道该往哪儿优化。 | MFU 14.3→53.1%;MBU 82.1→43.6% |
| 算术强度 arithmetic intensity | 每搬一字节能算几次 | 实测 FLOP/s ÷ 实测 byte/s,单位 FLOP/byte。低于脊点(4090 是 163.9)就是带宽受限,高于就是算力受限。 | 28.5 → 199.5,在 L≈3,157 穿过脊点 |
| SLO Service Level Objective | 服务等级目标 | 事先定下的延迟承诺,例如"p99 TTFT 不超过 1.5 秒"。压测的用途是反查:在这条线以内,并发最多能开到多少。 | 本节反查:p99 < 2 s ⇒ L 的红线在 1024~2048 |
三个最容易混的地方
1 · TPOT 和 ITL 均值是同一件事的两种算法。 TPOT 是"每条请求先算自己的平均,再对请求取中位数";ITL 均值是"把所有间隔放在一起平均"。 负载均匀时两者几乎相等(本轮 C=1 都是 15.83 ms)。 两者一分开,就说明有少数间隔被拖长。
2 · p50 不能用来判断抖动。 一次 200 ms 的停顿混在 25 万个间隔里,不影响中位数,对均值影响也很小。 本系列反复验证的经验是:抖动只在 p99 上出现。 抢占更稀有(万分之一量级),连 p99 都反映不出来,只能看专门的计数器。
3 · 吞吐不是 1 / TPOT。 TPOT 是单条请求的出字速度,吞吐是整台机器的产能,约等于单条出字速度乘以并行的请求条数。 并发提高时 TPOT 变差、吞吐变好,压测要权衡的就是这个交换。
KV 占用随输入长度线性增长:五个点全部落在一条直线上,R² = 0.99993。 斜率实测 15.99,理论值是 16.00(并发数),偏差 0.045%。 这条直线的每个系数都能从模型结构算出来,没有需要额外解释的余量。
实测拟合
KVtok = 15.99 · L + 3,933
理论式是 C · (L + 256) = 16L + 4096。截距差 163 个 token,
原因是采样时各请求已生成的输出量不完全等于 256。换算成显存:
在 C=16 下,输入每加长 1 个 token,多占 896 KiB KV 池。
线性拟合 R²
0.99993
5 个长度点,斜率与并发数只差 0.045%
每 token 的 KV
56 KiB
两条独立路径验证:容量计数器与 ITL 斜率
两条轴落在同一条曲线
≤0.7 pp
并发轴与长度轴的 MFU 逐点偏差;真正的自变量是每步 token 数
TTFT 里"真算"的比例
9 ~ 28 %
L=1024 时是 18%,其余 82% 是排队、抢步与前端开销
KV 占用只由 token 数决定,和批次形状、请求到达顺序都无关。
这是 PagedAttention 的设计承诺,下图两条线在五个点上几乎完全重合:
粗虚线是 16 × (L + 256) ÷ 120,064 算出的理论值,
细实线是服务端 vllm:gpu_cache_usage_perc 的实测峰值。
KV 池占用 vs 输入长度(固定并发 16)
横轴按 2 的幂等距排列。L=256 / 1024 / 2048 / 4096 四个点与理论值差 0.00 ~ 0.21 个百分点。
只有 L=512 明显偏低:9.81% vs 理论 10.23%。
这一点的 kvMax 恰好等于 kvP90,
是 1 Hz 采样没抓到峰顶的典型特征,不是模型偏差。
| 输入长度 | 单请求 tok | ×16 = | 理论峰值 | 实测峰值 | 偏差 | 实测稳态 | 反算单请求 | KV 容量上限 |
|---|
KV 池的总大小来自服务端日志;每 token 的占用完全由 Qwen2.5-7B 的注意力结构决定: 28 层、4 个 KV head(GQA,不是 28 个 Q head)、head_dim 128、bfloat16。
# 单 token 的 KV 字节数 2 (K,V) × 28 层 × 4 KV head × 128 dim × 2 B = 57,344 B = 56.0 KiB # 服务端日志(第二轮) Available KV cache memory: 6.41 GiB GPU KV cache size: 120,064 tokens # 对账 6.41 GiB ÷ 56 KiB = 120,025 token (日志 120,064,差 0.03%,来自 6.41 的四舍五入) # GQA 省了多少:如果是 MHA(28 个 KV head) 56 KiB × 7 = 392 KiB/token → 池子只剩 17,152 token → C=16 时 L 最多 816 # 这条公式的实用价值:不用跑测试就能算容量红线 单请求占满 8,192 上下文 = 8192 × 56 KiB = 448 MiB 池子 6.41 GiB ÷ 448 MiB = 14.65 条 # 启动日志自己也算了一遍,是同一个数 Maximum concurrency for 8,192 tokens per request: 14.66x ← 差 0.07%,同样来自 6.41 的四舍五入
这条线为什么值得亲手量一遍
容量规划只需要这条直线。给定 KV 池 P、平均上下文 L+O,
并发上限就是 P ÷ (L+O),没有第二个变量。
本轮 L=4096 时上限是 27 条,而启动参数写的是 --max-num-seqs 128,
名义值是真实值的 4.6 倍。
这个 27 只适用于这台机器。第一轮用同样的算法得到 20 条, 因为那台机器的 KV 池只有 4.64 GiB。斜率是模型的性质,截距是机器的性质: 斜率可以跨机器使用,截距必须每台机器现读日志。
五段测试连起来看,KV 曲线是五级台阶,每级比上一级高得更多,因为长度逐档翻倍。
台阶顶部的锯齿是请求批次的换手:所有请求长度相同且开了 --ignore-eos,
几乎同时结束;腾出的槽位要等下一批 prefill 完成才重新填满。
运行数 / 等待数 / KV 占用
锯齿周期等于单请求端到端时延:L=4096 时 11.4 s,与 e2e p50 = 11,368 ms 吻合。 稳态谷底与峰顶差约 5 个百分点(约 6,000 token),相当于 1.4 条请求的槽位在换手中途空着。
Waiting 队列的两副面孔
L=256 时 Waiting 恒为 0;L=512 与 L=1024 只在开场 1 秒有 9 / 5 条在等; L=4096 开场 6 秒里 Waiting 从 12 降到 0,因为 chunked prefill 每步只能放进有限的 token, 16 条 4K 请求要排队进场。p99 TTFT = 6.03 s 几乎全部来自这 6 秒。
卡住新请求的不是显存,是调度器每步的 token 预算:
L=4096 稳态时 KV 只有 52%–58%,离 KV 池满还远。
第一轮同一段测试 KV 已到 80%,当时无法区分这两个原因;
这一轮 KV 池大了 38%,现象完全一样,结论就清楚了。
全程 preempt 出现 0 次,没有请求被抢占后重算。
这是本轮最想回答的问题。TTFT 拆成三段:排队(request_queue_time)、
prefill(request_prefill_time)、剩下的前端开销(分词 + HTTP + 第 1 个 token 送达)。
同轮 C=1 的单流测量作为参照,它是"纯计算"的下限。
TTFT 三段分解,与单流纯计算基线对照
三段之和 = 平均 TTFT。绿色虚线是 C=1 下同长度 prefill 的纯计算时间:
以同轮实测的 L=1024 / C=1 → 93.5 ms 为锚点,按输入长度线性外推,即
93.5 ÷ 1024 = 0.0913 ms / 输入 token。
线以下是不可压缩的部分,线以上全部由并发带来。
这条斜率经过独立验证,两条完全独立的路径差 2.4%:
0.0913 ms/token 反推出的 prefill 算力是 142.9 TF/s;
按秒采样抓到的 prefill 段平台值是 146.3 TF/s(见下文 Figure 5)。
所以这条基线既不是纯推算,也不是单点巧合。
TTFT 中"算"与"等"的比例
柱上标的是"等"的占比。输入越短,这个比例越高:L=256 时 91.3% 的 TTFT 与算 prompt 无关。 长输入的 TTFT 里计算更多:L=4096 时纯计算占 28.4%。 五个点(91.3 / 87.8 / 81.9 / 77.6 / 71.6)与第一轮(91.0 / 87.9 / 82.0 / 77.7 / 72.2)逐点吻合,差距在 0.6 个百分点以内。
最干净的一组对照是同样 1024 个 token 的 prefill:同轮 C=1 时服务端量到 93.5 ms,
本节 C=16 时量到 360.5 ms。同一段计算、同一张卡,墙钟是 C=1 的 3.9 倍。
多出的 267 ms 不是排队(排队只有 47 ms)。原因是开了 enable_chunked_prefill:
这段 prefill 被切成块,混进 16 条正在 decode 的批次,一块一块地算完。
第一轮这个倍数是 3.9,这一轮还是 3.9。
| L=1024 的 TTFT | C=1(同轮) | C=16(本节) | 倍数 | 占 C=16 原始比 |
|---|
这就是"排队贡献了多少"的答案
把并发扫描的单请求 TTFT 和本节放在一起,517.0 ms 的平均 TTFT 可以拆成四份。 注意:下面四个百分比是扣掉 C=1 同名基线之后的口径, 和上表「占 C=16 原始比」那一列不同:
真正的 prefill 计算 18%(93.5 ms,C=1 实测,不可压缩)· 排队 9%(46.8 − 0.0 = 46.8 ms)· prefill 被抢步 52%(360.5 − 93.5 = 267.0 ms)· 前端开销放大 20%(109.7 − 8.8 = 100.9 ms)。 余下的 1.7% 是单流本身的 8.8 ms 前端开销;四段加这个基线正好是 517.0 ms。
换一个分母,结论不变:并发带来的增量是 414.7 ms,其中排队占 11.3%、 prefill 被抢步占 64.4%、前端放大占 24.3%。无论怎么切, 被抢步的 prefill 都是最大的一块。 (第一轮同一组数是 11.5 / 64.9 / 23.7,三位数字全部复现。)
结论:在 C=16 这种普通并发下,TTFT 基本已是调度指标, 不是算力指标。要压低 TTFT,该调的是调度器的单步预算,不是换卡。 这个参数是什么、本轮的值是多少,见本节末尾。
上面的分解图画的是平均值。换成分位数看,长度轴上同样是尾部先恶化, 原因也和并发轴一样:排队。
# L 从 256 拉到 4096(16 倍) TTFT p50 297.5 → 1047.4 ms 3.5× TTFT p99 390.3 → 6027.1 ms 15.4× ← 尾部涨得远比中位数快 # 拆开看是哪一段在涨 排队 1.5 → 318.5 ms 212× prefill 229.4 → 804.4 ms 3.5× ← 和 p50 同速,它不是尾部的原因
长 prefill 之间会互相阻塞:一条 4096 token 的请求在做 prefill 时, 后面排队的请求要等它走完一批 chunk 才轮得到。所以排队时间涨到 212 倍,prefill 本身只涨到 3.5 倍。 这和上一段"TTFT 是调度指标"是同一件事的两种量法。
这条尾部曲线可以直接当选型工具用
如果业务方的 SLO 是「p99 TTFT < 2 秒」,在 C=16 这个运行点上, 输入长度的红线在 1024 与 2048 之间: L=1024 时 p99 = 1.45 s,还有余量;L=2048 时已到 2.97 s,超标。 注意:这条线是并发与长度的联合约束。降并发能把红线往右推; 本报告只在 C=16 量了一条。
--max-num-batched-tokens 是什么它是 vLLM 调度器的单步 token 预算:每次 forward(一个 engine step)里, 一批最多能放多少 token。上文反复出现的"抢步""混批"都由这个数控制。
# 一个 engine step 的 batch 是拼出来的 decode token 每条正在生成的请求各贡献 1 个 + prefill chunk 新请求的 prompt 被切块,塞进来 一块 ───────────────────────────────────────────── = iteration_tokens ≤ --max-num-batched-tokens # 采样脚本里那一列量的正是这个和。本轮 C=16 各长度点的实测: L=256 每步 30.3 个 token 其中 decode 16、prefill ≈ 14 L=1024 每步 76.5 个 token 其中 decode 16、prefill ≈ 60 L=4096 每步 265.1 个 token 其中 decode 15、prefill ≈ 250 # 注意这是【平均】。真正决定 ITL 尾部的是【单步峰值】,见下。
它是 ITL 的旋钮,因为它决定了一个正在 decode 的请求,每一步要陪别人算多少 prefill。
这就是本节结论指向的交换。显存这条路在上一节已经排除 (L=4096 稳态 KV 只有 52%–58%),能调的只剩这个预算。
这个值没有写进服务端日志(日志只给了 max_num_seqs 128 和
max_model_len 8192),但可以从 ITL 尾部反推。
思路:一次 ITL 尖峰,就是那一步顺带算了一大块 prefill,
多出来的时间除以 prefill 的实测算力,就是那一块有多大。
# 反推公式 块大小(token) = (ITL p99 − ITL p50) × 146.3 TF/s ÷ 13.05 GFLOP/token ↑ 尖峰比常态多花的时间 ↑ prefill 段实测 ↑ 2N/token
| 场景 | ITL p50 | ITL p99 | 超出 | ≈ 块大小 |
|---|---|---|---|---|
| 长度轴 L=512(本节) | 16.76 | 55.7 | 38.9 ms | 437 tok |
| 长度轴 L=1024(本节) | 17.23 | 185.5 | 168.3 ms | 1,886 tok |
| 长度轴 L=2048(本节) | 18.23 | 200.3 | 182.1 ms | 2,041 tok |
| 长度轴 L=4096(本节) | 20.26 | 214.2 | 193.9 ms | 2,174 tok |
| 并发轴 C=16 | 17.23 | 183.7 | 166.5 ms | 1,866 tok |
| 并发轴 C=32 | 19.82 | 197.2 | 177.4 ms | 1,989 tok |
| 并发轴 C=64 | 22.50 | 207.6 | 185.1 ms | 2,075 tok |
六个场景全部落在 2,000 上下
长度轴和并发轴是两组完全不同的负载,却给出同一个数: 单步 prefill 块被 2048 卡住了。 L=512 那一行只有 437,因为 prompt 太短,凑不满一整块。
这也解释了后面 Figure 10 的现象:p99 ITL 稳定在 200 ms 左右, 不随长度继续涨。块大小被预算限死,长输入只增加块出现的频率,不增加块本身。
| 参数 | 本轮值 | 管什么 |
|---|---|---|
--max-num-batched-tokens | 未显式设置(反推 ≈ 2048) | 单步 token 上限 —— 决定 prefill 块多大,直接决定 ITL 尾部 |
--max-num-seqs | 128 | 单步 请求条数 上限。与上一条同时生效,先满的那条起作用 |
--max-model-len | 8,192 | 单请求上下文上限。关掉 chunked prefill 时,token 预算必须 ≥ 它 |
一个足够具体、对不上就说明模型错了的预测
把预算显式设成 --max-num-batched-tokens 512,重跑 L=4096 / C=16。
按上面同一条公式,p99 ITL 应该从 214 ms 降到
20.26 ms + 512 × 13.05 GFLOP ÷ 146.3 TF/s = 20.26 ms + 45.7 ms = 约 66 ms 同时 TTFT 会变差,因为 4096 个 token 要切成 8 块而不是 2 块
如果对上,就说明"ITL 尾部 = 单步 prefill 块"这个模型成立; 对不上,就说明还有别的因素在制造那 200 ms。 这个实验也能验证反推出的 2048:显式设成 2048 后如果 p99 不变,就证实了。
同一张 4090,在纯 decode 的一秒和正在 prefill 的一秒,算力相差一个数量级。
数据来自 --enable-mfu-metrics:打开后 /metrics 多出
estimated_flops_per_gpu_total 与 estimated_read_bytes_per_gpu_total
两个累积计数器,采样脚本每秒抓一次。名字里的 estimated 名副其实:
它是 vLLM 按模型结构和当步调度的 token 数推算的,不是 GPU 性能计数器,
因此不含 kernel launch、padding 这类实际损耗。
# 采样脚本抓到的每秒瞬时 TF/s(L=4096 开场 11 秒) t+0 Running=1 Waiting=2 28.3 TF/s ← 刚起步 t+1 Running=4 Waiting=12 146.4 TF/s ← 纯 prefill,贴着峰值跑 t+2 Running=6 Waiting=10 144.7 TF/s t+3 Running=9 Waiting=7 146.3 TF/s t+4 Running=11 Waiting=5 144.7 TF/s t+5 Running=14 Waiting=2 146.2 TF/s t+6 Running=16 Waiting=0 144.6 TF/s t+7 Running=16 Waiting=0 43.9 TF/s ← 最后一块 prefill 收尾 t+8 Running=16 Waiting=0 12.6 TF/s ← 纯 decode,掉到 1/12 t+9 Running=16 Waiting=0 12.3 TF/s t+10 Running=16 Waiting=0 12.6 TF/s
全程瞬时算力:prefill 秒 vs decode 秒
decode 底噪全程稳定在 12–14 TF/s(中位 13.8),和输入长度基本无关; 每根冲到 145–170 TF/s 的尖峰都是一次 prefill。长输入的尖峰更密、更宽, 整段测试的平均 MFU 就是这样被抬高的。全程最高一点是 169.2 TF/s = 102.4% MFU。
Roofline 换边:算力利用率 vs 带宽利用率
分子是窗口内 estimated_flops_per_gpu_total / estimated_read_bytes_per_gpu_total
的首尾差分除以窗口时长,分母是 165.2 TF/s(BF16 稠密峰值)与 1,008 GB/s。
L=256 时 MBU 82.1% 已经贴住带宽墙,MFU 只有 14.3%;L=4096 时反过来,MFU 53.1%、MBU 43.6%。
两条线在 L≈3,157 相交(L=2048 处相差 −15.8 pp、L=4096 处 +9.5 pp,在 log₂ 上插值)。
把 prompt 拉长,等于把算力密集的 prefill 在时间里的占比拉高, 稀缺资源随之从带宽换成算力。两条线交叉在代数上等价于「算术强度穿过脊点」, 这是恒等式,不是巧合;推导与逐点验算见下一小节。
GPU 干活时有两堵墙:每秒能算多少次乘加,和每秒能从显存搬多少字节。 任何 kernel 都会先撞上其中一堵。MFU 与 MBU 分别量离这两堵墙还有多远。 单看一个没用,放一起看才知道该往哪儿优化:MBU 高就压显存流量 (量化权重、加大 batch、缩 KV),MFU 高就换更好的 kernel 或更低的精度。
MFU 这个叫法来自 Google 的 PaLM 论文。论文区分 MFU(只算模型本身必需的 FLOPs) 与 HFU(含重计算等硬件实际执行的 FLOPs)。本节全部用 MFU 口径。
# 分子 —— 采样脚本每秒抓的两个单调累积量,分析时做窗口首尾差分 ÷ 窗口时长 estimated_flops_per_gpu_total → CSV 的 flops 列 estimated_read_bytes_per_gpu_total → CSV 的 read_bytes 列 # vLLM 内部怎么估的(拿本轮五个零抢占的点反推,能对上) FLOPs/token ≈ 13.05 GFLOP 2N,N = 7.07e9,即总参数减掉 embedding + 0.000401 GFLOP × 上下文长度 注意力项 = 4 × d_model(3584) × 层数(28) 每次迭代读字节 ≈ 14.14 GB 全部权重(不含 embedding),与 batch 无关 + 16 × L × 56 KiB 批内所有请求的 KV # 第二条可以当场验证(L=256) 55.8 迭代/s × (14.14 + 0.35) GB = 809 GB/s 实测 827.7 GB/s 差 −2.2%,见下节
# 分母 —— 两个规格数 165.2 TF/s RTX 4090 BF16 张量核心「稠密」峰值 2:4 稀疏是 330.3 —— 用它会让 MFU 直接腰斩,写报告必须注明取的是哪一个 1,008 GB/s 显存带宽 = 384-bit × 21 Gbps GDDR6X # 代进去 L=256 : MFU = 23.55 ÷ 165.2 = 14.3% MBU = 827.7 ÷ 1008 = 82.1% L=4096: MFU = 87.64 ÷ 165.2 = 53.1% MBU = 439.2 ÷ 1008 = 43.6%
真正在变的量是算术强度(arithmetic intensity):每从显存搬一个字节,能算多少次浮点运算。 低于脊点就是带宽受限,高于就是算力受限。 4090 的脊点 = 165.2 TF/s ÷ 1,008 GB/s = 163.9 FLOP/byte。
| 输入长度 | 算术强度 | ÷ 脊点 | MFU | MBU |
|---|---|---|---|---|
| L = 256 | 28.5 | 0.17 × | 14.3% | 82.1% |
| L = 512 | 43.8 | 0.27 × | 20.4% | 76.3% |
| L = 1024 | 67.2 | 0.41 × | 28.3% | 68.9% |
| L = 2048 | 118.2 | 0.72 × | 40.9% | 56.7% |
| L = 4096 | 199.5 | 1.22 × | 53.1% | 43.6% |
# 这不是巧合,是一个恒等式 MFU ÷ MBU = (FLOPs ÷ bytes) ÷ (峰值算力 ÷ 峰值带宽) = 算术强度 ÷ 脊点 # 所以「两条线交叉」和「算术强度穿过脊点」是同一件事 # 但分相来看,负载从来没有停在脊点上: 单个 decode 步 209 GFLOP ÷ 14.5 GB = 12 ~ 15 FLOP/byte 远低于 163.9,深度带宽受限 一段 prefill L × 13.05 GFLOP ÷ 14.14 GB L=256 → 236 FLOP/byte L=4096 → 3,780 FLOP/byte 远高于 163.9,深度算力受限
把 prompt 拉长,没有让任何一个 kernel 变得更"算力密集": decode 步的强度反而从 14.8 降到 11.9(KV 读得更多)。 变的只是算力密集的 prefill 在时间轴上的占比,于是平均强度被抬过了脊点。
四个别踩的坑
1 · estimated_ 不是硬件计数器。
它按模型结构和当步调度的 token 数推算,不含 kernel launch 开销、CUDA graph padding,
也不含 softmax / LayerNorm / RoPE / 采样这些非 GEMM 算子。
好处是它跟随调度器;坏处是它系统性地偏"干净"。
2 · read_bytes 只数读,不数写。
KV 写入、激活写回都没算进去,所以这里的 MBU 是下界,
真实带宽占用比 82.1% 更高。
3 · 分母是规格书数字。它假设 Boost 频率、不降频、无其它开销。 本轮按秒采样里有一点算出 169.2 TF/s = 102.4% MFU。 能超过 100%,说明这对分子分母的精度约为 ±5%,不要读第三位有效数字。
4 · 分相看才有意义。纯 decode 时 MFU 恒在 8.4% 上下,这些秒该看 MBU; 纯 prefill 时 MBU 只有百分之十几,该看 MFU(实测 88%–102%)。 整段窗口的平均值只能回答"这段负载的配比是什么", 不能回答"我的 kernel 写得好不好"。
显存读字节计数器的账可以完整对上。每个 decode step 要读两样东西:
全部权重(14.14 GB,与 batch 无关),以及批内所有请求的 KV(= 16 × L × 56 KiB,随 L 线性增长)。
两项按实测迭代率相乘,再与 read_bytes 的实测速率对照:
显存读带宽拆账:权重重读 vs KV 读取
二项模型在全部五个点上都略低于实测:L=256 差 −2.2%,L=4096 差 −6.7%。 差额随 L 单调增大,多出的部分是 prefill 阶段的激活读写,它不在二项模型里; 长输入的 prefill 占比更高,差额也更大。 残差有方向、有解释,说明这个二项模型抓住了主干。
第一次验证用的是容量(KV 占用计数器)。第二次不看计数器,只看时间: 单步 decode 的中位耗时随上下文线性增长,斜率应该等于 "多读的 KV 字节 ÷ 显存带宽"。
# 实测线性拟合(5 个点,R² = 0.99989) ITL_p50(L) = 16.23 ms + 0.00098 ms/token · L # 理论:每步多读的 KV = 16 条 × 1 token × 56 KiB 16 × 57,344 B ÷ 1,008 GB/s = 0.00091 ms/token # 吻合度 0.00091 ÷ 0.00098 = 93% ← KV 读取跑到了显存带宽峰值的 93%(第一轮 92%)
单步 decode 耗时 vs 上下文长度
横轴是线性刻度。上下文拉长 16 倍,单步 decode 只慢了 23%(16.47 → 20.26 ms), 因为大头是 14.14 GB 权重,它与上下文长度无关。这是"长上下文不会让 decode 变慢多少"的定量版本。
上面验证的是斜率(每多一个 token 多花多少时间)。同一个模型也能给出绝对值: 一步 decode 至少要把权重完整读一遍,再把批内所有请求的 KV 读一遍。 这些字节除以峰值带宽,就是这一步的时间下限。
# 地板 = (权重 + running均值 × 上下文 × 56 KiB) / 1008 GB/s # 权重取 14.14 GB —— 总参数减掉 embedding,查表不流过 HBM L 上下文 running均 地板 ms 实测 ITL p50 达成率 256 384 15.39 14.37 16.47 87.2% 512 640 14.60 14.56 16.76 86.9% 1024 1152 15.28 15.03 17.23 87.2% 2048 2176 15.03 15.89 18.23 87.2% 4096 4224 14.85 17.60 20.26 86.9% # 16 倍长度跨度里,达成率只在 0.4 个百分点内浮动
容量规划可以完全用算的,压测只用来标定
给定上下文长度和并发数,现在能预测两件事:KV 占用(「线性」一节:L=512 偏差 0.42 个百分点,其余四个点在 0.21 以内) 和decode 步长(这张表,达成率稳定在 87%)。两者合起来足以回答 "这张卡能扛多少并发、p99 会到多少",不用租机器穷举每个配置。
并发轴上的同一笔账在《vllm-01-Qwen2.5-7B并发曲线》里:C 从 1 到 64,达成率从 89.0% 降到 81.0%。 两条轴的达成率都在 80% 到 90% 之间,但长度轴几乎是平的,并发轴掉了 8 个点。 原因是加并发要多付调度和 kernel 效率的代价,而拉长度只是让同一批权重多带几个 token。
长度轴上有一个容易误判的地方:总 token 吞吐一路上涨,看起来机器越用越好; 但用户拿到的输出 token 吞吐一路下跌。两条线方向相反。
输出吞吐下跌,总吞吐上涨
L 从 256 涨到 4096:总吞吐 1,786 → 6,109 tok/s(+242%),输出吞吐 893 → 359 tok/s(−60%)。
如果看板只画了 total_token_throughput,就完全看不到这段退化。
ITL 分位数:尾部在 L=4096 塌掉
p50 从 16.5 涨到 20.3 ms,涨幅温和。p99 却从 L=256 的 20.3 ms 跳到 L=4096 的 214.2 ms: 百分之一的 token 间隔卡在别人的 prefill 上。TPOT 中位数 40.5 ms 是 ITL p50 的 2.0 倍, 说明将近一半的 decode 时间被别人的 prefill 占用。
输出吞吐跌了 60%,不代表处理输入变贵了。prefill 耗时除以输入长度, 得到的单位成本一路走低,方向和输出吞吐相反。
# prefill 墙钟 ÷ 输入长度 L=256 229.4 ms ÷ 256 = 0.896 ms / 输入 token L=512 281.7 ms ÷ 512 = 0.550 L=1024 360.5 ms ÷ 1024 = 0.352 L=2048 547.1 ms ÷ 2048 = 0.267 L=4096 804.4 ms ÷ 4096 = 0.196 ← 比 L=256 便宜 4.6 倍
原因是上一节的 roofline:prefill 是算力密集的,一次前向把 14.14 GB 权重读一遍, 可以摊给 4096 个 token,而不是 256 个。 所以把多个短请求拼成一个长请求,往往比分开发更划算。
但这只降低了单位成本,没降低总成本
同一批数据里,TTFT p50 仍然从 298 ms 涨到 1047 ms,用户还是要多等。 "每 token 更便宜"是运营的账,"TTFT 更长"是用户的账,两本账方向相反。 拼请求之前,先确认你优化的是哪一本。
Figure 9 两条线的差距,划分了两类负载
两条线不会相交:总吞吐 = 输入 + 输出,永远不低于输出吞吐。两者的差距随 L 拉大: L=256 时总吞吐是输出的 2 倍,L=4096 时是 17 倍。差距小的一端是生成密集: GPU 大部分时间在出字,输出吞吐是主要指标;差距大的一端是处理密集: 大部分算力花在处理输入上。 RAG、长文摘要、代码库问答属于处理密集;对话式、补全类负载属于生成密集。 两边该盯的指标不同,该调的参数也不同。
并发轴(L=1024,C 从 1 到 64)和长度轴(C=16,L 从 256 到 4096)放在同一张图上。 横轴统一成 KV 池占用,也就是"这次请求占用了多少显存预算"。
花同样的 KV,两条路径拿到的输出吞吐
两条曲线在 L=1024 / C=16 处相交(17.0% KV,681 vs 699 tok/s,两次独立测量相差 2.6% 以内)。 往右,并发轴上升,长度轴下降。
| KV 占用 | 并发轴配置 | 输出 tok/s | 长度轴配置 | 输出 tok/s | 并发轴 / 长度轴 |
|---|
KV 占用是成本指标,不是产出指标
长上下文用同样的显存,换来的并行 decode 流更少。 C=64/L=1024 和 C=16/L=4096 占用的 KV 相近(67.5% vs 57.8%),p99 TTFT 也相近(5.92 s vs 6.03 s), 但输出吞吐前者是后者的 3.9 倍(1,388 vs 359 tok/s)。 原因是 decode 吞吐正比于批内序列条数,而 KV 占用正比于批内 token 总数。 (这个 3.9 与「排队」一节 prefill 墙钟的 3.9 无关,数值相同只是巧合。)
总 token 吞吐反而接近(6,939 vs 6,109 tok/s):GPU 干的活总量差不多, 区别只在其中多少变成了用户看得见的输出。 这个 3.9 倍在第一轮也是 3.9 倍,尽管两轮的 KV 池差了 38%。
| 指标 | 第一轮 c016 | 第二轮 c016 | 第二轮 len_1024 | 极差 |
|---|
上图说明"KV 占用预测不了产出"。但只要把横轴换成一次迭代里参与计算的 token 总数
(即 --max-num-batched-tokens 那一节量的 iteration_tokens),
两条轴的利用率曲线就会叠在一起。
两条轴,同一条曲线:MFU / MBU vs 每步 token 数
两组完全独立的实验落在同一条曲线上:MFU 全程不超过 0.7 个百分点,MBU 不超过 3.6。 横轴是对数刻度。做法是把并发轴按 tok/iter 在对数轴上插值到长度轴的五个点,再逐点相减: MFU 是 +0.3 / +0.1 / +0.0 / +0.5 / −0.7,MBU 是 +3.6 / +2.0 / +0.3 / +0.1 / −2.3。
真正的自变量既不是并发数,也不是序列长度
真正的自变量是每步 token 数。C=64 × 1,152 和 C=16 × 4,224 的 tok/iter 分别是 311.6 和 265.1,在同一区间,利用率也在同一位置 (MBU 43.2% vs 43.6%)。这解释了为什么 《vllm-01-Qwen2.5-7B并发曲线》Figure 4 的"两堵墙"和本报告 Figure 6 几乎一样: 它们画的本来就是同一条曲线,只是从两条不同的路走上去。
实用含义:要估一个没测过的配置撞哪堵墙,不用管它是"高并发短上下文" 还是"低并发长上下文",先算出 tok/iter,再到这条曲线上查。 两条轴只在产出侧分开:同样的 tok/iter,并发轴换来更多输出 token, 这就是上一段的 3.9 倍。
| L | 耗时 s | KV 峰值 | Wait 峰值 | TTFT p50 | TTFT p99 | 排队 | prefill | TPOT | ITL p50 | ITL p90 | ITL p99 | E2EL p50 | 输出 tok/s | 总 tok/s | tok/iter | MFU | MBU | 功耗 W |
|---|
--ignore-eos 生效。
各档输出侧工作量完全一致,差异只能来自输入。--random-input-len 在变。enable_prefix_caching: False,五个点之间没有缓存命中。一处需要注意的口径:表里的 prefill 是墙钟,不是纯计算
prefill 这一列取自服务端 request_prefill_time 直方图,
量的是一条请求的 prefill 从开始到结束经过的时间。
开了 chunked prefill 后,一条请求的 prefill 会被切片,和别人的 decode 交错执行,
所以这个数字含有"等待调度"的时间。
本报告「排队」一节那 267 ms 的"抢步"就是从它里面拆出来的。
要用它算纯 prefill 算力,需用 Figure 5 的分相曲线或 FLOPs 计数器交叉验证,不要直接除。
--max-num-batched-tokens 重跑,
看它是否随块大小线性缩小,才能确认因果。激活 ≈ α × prefill_tokens 拟合出 α,把这笔账完全对平。
这是 Round 2 五份压测报告的第 3 份(vLLM 长度扫描)。
整轮的横向结论与生产选型建议见 《选型指南-vllm-llama-cpp》。
那份报告合并五组测试的数据,回答"两个引擎各自强在哪、生产上怎么选"。
同目录另有:vllm-01-Qwen2.5-7B并发曲线 · vllm-02-TTFT解剖 · vllm-04-断崖 · llama-cpp-压测。