Length Sweep · Round 2 · RTX 4090

KV 的线性

固定并发 16,输入长度从 256 扫到 4096,800 条请求全部成功,1 Hz 采样 501 个点。 这一轮回答三个问题:KV 占用是不是线性的;有并发时,TTFT(首字延迟)里有多少在"算"、 多少在"等";并发和长度这两条轴,是不是同一件事的两种走法。

GPU
RTX 4090 · 24 GB
vLLM
0.27.1 · V1
Torch
2.13.0+cu130
输入长度
256 → 4096 tok
输出 / 并发
256 tok / C=16
KV 池
6.41 GiB · 120,064 tok
请求
800 · 0 failed
抢占
0 次
驱动
580.159.03 · CUDA 13.0

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

下面这些缩写在正文里反复出现,它们不是同义词: 每个指标量的是流水线上不同的一段,彼此之间有确定的换算关系。 本轮负载:输入 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 的实测峰值。

Figure 1

KV 池占用 vs 输入长度(固定并发 16)

理论峰值 16·(L+256) 实测峰值 实测稳态中位数 KV 池上限 100%

横轴按 2 的幂等距排列。L=256 / 1024 / 2048 / 4096 四个点与理论值差 0.00 ~ 0.21 个百分点。 只有 L=512 明显偏低:9.81% vs 理论 10.23%。 这一点的 kvMax 恰好等于 kvP90, 是 1 Hz 采样没抓到峰顶的典型特征,不是模型偏差。

输入长度单请求 tok×16 = 理论峰值实测峰值偏差 实测稳态反算单请求KV 容量上限
「反算单请求」= 实测峰值 tok ÷ 16,与「单请求 tok」列对照。「KV 容量上限」= 120,064 ÷ (L+256),即这张卡在该长度下能同时容纳的请求数。

56 KiB/token 由模型结构决定

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。斜率是模型的性质,截距是机器的性质: 斜率可以跨机器使用,截距必须每台机器现读日志。

波形502 秒全程实况

五段测试连起来看,KV 曲线是五级台阶,每级比上一级高得更多,因为长度逐档翻倍。 台阶顶部的锯齿是请求批次的换手:所有请求长度相同且开了 --ignore-eos, 几乎同时结束;腾出的槽位要等下一批 prefill 完成才重新填满。

Figure 2

运行数 / 等待数 / KV 占用

Running Waiting 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 的单流测量作为参照,它是"纯计算"的下限。

Figure 3

TTFT 三段分解,与单流纯计算基线对照

排队 prefill(含抢步) 前端 + IPC 单流纯 prefill 计算(推算)

三段之和 = 平均 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)。 所以这条基线既不是纯推算,也不是单点巧合。

Figure 4

TTFT 中"算"与"等"的比例

纯 prefill 计算 排队 + 抢步 + 前端

柱上标的是"等"的占比。输入越短,这个比例越高: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 个百分点以内。

同一段 prefill,并发下的墙钟是单流的 3.9 倍

最干净的一组对照是同样 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 的 TTFTC=1(同轮)C=16(本节)倍数占 C=16 原始比
C=1 数据取自本轮并发扫描的 c001 与同轮服务端直方图。单位 ms。

这就是"排队贡献了多少"的答案

把并发扫描的单请求 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,该调的是调度器的单步预算,不是换卡。 这个参数是什么、本轮的值是多少,见本节末尾。

中位数涨到 3.5 倍,尾部涨到 15 倍

上面的分解图画的是平均值。换成分位数看,长度轴上同样是尾部先恶化, 原因也和并发轴一样:排队。

# 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%),能调的只剩这个预算。

反推:本轮的预算很可能是 2048

这个值没有写进服务端日志(日志只给了 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
七组测量(L=1024 与 C=16 是同一个运行点),反推出来的单步 prefill 块大小
场景ITL p50ITL p99超出≈ 块大小
长度轴 L=512(本节)16.7655.738.9 ms437 tok
长度轴 L=1024(本节)17.23185.5168.3 ms1,886 tok
长度轴 L=2048(本节)18.23200.3182.1 ms2,041 tok
长度轴 L=4096(本节)20.26214.2193.9 ms2,174 tok
并发轴 C=1617.23183.7166.5 ms1,866 tok
并发轴 C=3219.82197.2177.4 ms1,989 tok
并发轴 C=6422.50207.6185.1 ms2,075 tok
并发轴数据取自同轮的并发扫描(L=1024)。

六个场景全部落在 2,000 上下

长度轴和并发轴是两组完全不同的负载,却给出同一个数: 单步 prefill 块被 2048 卡住了。 L=512 那一行只有 437,因为 prompt 太短,凑不满一整块。

这也解释了后面 Figure 10 的现象:p99 ITL 稳定在 200 ms 左右, 不随长度继续涨。块大小被预算限死,长输入只增加块出现的频率,不增加块本身。

相关的几个参数,以及一个能证伪的实验

参数本轮值管什么
--max-num-batched-tokens未显式设置(反推 ≈ 2048) 单步 token 上限 —— 决定 prefill 块多大,直接决定 ITL 尾部
--max-num-seqs128 单步 请求条数 上限。与上一条同时生效,先满的那条起作用
--max-model-len8,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
Figure 5

全程瞬时算力:prefill 秒 vs decode 秒

实测 TF/s(每秒) 4090 BF16 峰值 165.2 TF/s

decode 底噪全程稳定在 12–14 TF/s(中位 13.8),和输入长度基本无关; 每根冲到 145–170 TF/s 的尖峰都是一次 prefill。长输入的尖峰更密、更宽, 整段测试的平均 MFU 就是这样被抬高的。全程最高一点是 169.2 TF/s = 102.4% MFU。

Figure 6

Roofline 换边:算力利用率 vs 带宽利用率

MFU(算力利用率) MBU(带宽利用率)

分子是窗口内 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。

输入长度算术强度÷ 脊点MFUMBU
L = 25628.50.17 ×14.3%82.1%
L = 51243.80.27 ×20.4%76.3%
L = 102467.20.41 ×28.3%68.9%
L = 2048118.20.72 ×40.9%56.7%
L = 4096199.51.22 ×53.1%43.6%
算术强度 = Δflops ÷ Δread_bytes,单位 FLOP/byte。「÷ 脊点」这一列同时也就是 MFU ÷ MBU(0.174 / 0.267 / 0.410 / 0.721 / 1.218)—— 原因见下方恒等式。
# 这不是巧合,是一个恒等式
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 的实测速率对照:

Figure 7

显存读带宽拆账:权重重读 vs KV 读取

权重重读 KV 读取 实测 read_bytes 速率 4090 峰值 1,008 GB/s

二项模型在全部五个点上都略低于实测:L=256 差 −2.2%,L=4096 差 −6.7%。 差额随 L 单调增大,多出的部分是 prefill 阶段的激活读写,它不在二项模型里; 长输入的 prefill 占比更高,差额也更大。 残差有方向、有解释,说明这个二项模型抓住了主干。

ITL 的斜率,是 56 KiB/token 的第二次独立验证

第一次验证用的是容量(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%)
Figure 8

单步 decode 耗时 vs 上下文长度

实测 ITL p50 16.23 + 0.00091·L(纯带宽推算)

横轴是线性刻度。上下文拉长 16 倍,单步 decode 只慢了 23%(16.47 → 20.26 ms), 因为大头是 14.14 GB 权重,它与上下文长度无关。这是"长上下文不会让 decode 变慢多少"的定量版本。

把斜率换成绝对值:每个长度点的 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 吞吐一路下跌。两条线方向相反。

Figure 9

输出吞吐下跌,总吞吐上涨

输出 token/s(用户拿到的) 总 token/s(GPU 处理的)

L 从 256 涨到 4096:总吞吐 1,786 → 6,109 tok/s(+242%),输出吞吐 893 → 359 tok/s(−60%)。 如果看板只画了 total_token_throughput,就完全看不到这段退化。

Figure 10

ITL 分位数:尾部在 L=4096 塌掉

p50 p90 p99 均值(= TPOT)

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 占用。

但每个输入 token 的成本一路下降

输出吞吐跌了 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、长文摘要、代码库问答属于处理密集;对话式、补全类负载属于生成密集。 两边该盯的指标不同,该调的参数也不同。

对照同样的 KV 占用,输出吞吐差 3.9 倍

并发轴(L=1024,C 从 1 到 64)和长度轴(C=16,L 从 256 到 4096)放在同一张图上。 横轴统一成 KV 池占用,也就是"这次请求占用了多少显存预算"。

Figure 11

花同样的 KV,两条路径拿到的输出吞吐

并发轴(L=1024 固定) 长度轴(C=16 固定)

两条曲线在 L=1024 / C=16 处相交(17.0% KV,681 vs 699 tok/s,两次独立测量相差 2.6% 以内)。 往右,并发轴上升,长度轴下降。

KV 占用并发轴配置输出 tok/s 长度轴配置输出 tok/s并发轴 / 长度轴
KV 占用相近的点两两配对。两条轴均取自本轮(Round 2)。

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%。

两轮测试在 L=1024 / C=16 上的吻合度

指标第一轮 c016第二轮 c016第二轮 len_1024极差
c016 是 200 条请求的并发扫描点,len_1024 是 160 条请求的长度扫描点,采样与统计口径一致。最后一行 38.7% 的极差不是测量分歧:两轮 KV 池分别是 86,896 与 120,064 tok,同样 20,480 个 token 的占比自然不同;分子完全一致(20,485 vs 20,435,差 0.24%)。

换个横轴,两条轴就重合了

上图说明"KV 占用预测不了产出"。但只要把横轴换成一次迭代里参与计算的 token 总数 (即 --max-num-batched-tokens 那一节量的 iteration_tokens), 两条轴的利用率曲线就会叠在一起。

Figure 12

两条轴,同一条曲线:MFU / MBU vs 每步 token 数

MBU · 长度轴(C=16 固定) MFU · 长度轴(C=16 固定) 并发轴(L=1024 固定,C 从 1 到 64)

两组完全独立的实验落在同一条曲线上: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 p50TTFT p99 排队prefill TPOTITL p50ITL p90ITL p99 E2EL p50 输出 tok/s总 tok/stok/iter MFUMBU功耗 W
延迟单位 ms。排队 / prefill 取自服务端直方图均值。MFU / MBU 的分子为 estimated_flops_per_gpu_total 与 estimated_read_bytes_per_gpu_total 的窗口差分速率,分母分别为 165.2 TF/s 与 1,008 GB/s。tok/iter = iteration_tokens_total ÷ 迭代数,即每次前向实际算的 token 数(decode + prefill 块);功耗为窗口 p50。

这批数据能不能信:四条自检

一处需要注意的口径:表里的 prefill 是墙钟,不是纯计算

prefill 这一列取自服务端 request_prefill_time 直方图, 量的是一条请求的 prefill 从开始到结束经过的时间。 开了 chunked prefill 后,一条请求的 prefill 会被切片,和别人的 decode 交错执行, 所以这个数字含有"等待调度"的时间。 本报告「排队」一节那 267 ms 的"抢步"就是从它里面拆出来的。 要用它算纯 prefill 算力,需用 Figure 5 的分相曲线或 FLOPs 计数器交叉验证,不要直接除。

下一步这轮暴露出来的四个口子

这是 Round 2 五份压测报告的第 3 份(vLLM 长度扫描)。 整轮的横向结论与生产选型建议见 《选型指南-vllm-llama-cpp》。 那份报告合并五组测试的数据,回答"两个引擎各自强在哪、生产上怎么选"。 同目录另有:vllm-01-Qwen2.5-7B并发曲线 · vllm-02-TTFT解剖 · vllm-04-断崖 · llama-cpp-压测。