Length Sweep · 第三轮 · RTX 4090

KV 的线性

固定并发 16,输入长度从 256 扫到 4096,800 条请求全部成功,1 Hz 采样 864 个点。 这一轮回答两件事:KV 占用到底是不是线性的,以及在有并发的情况下, 首字延迟里到底有多少是在"算",多少是在"等"。

GPU
RTX 4090 · 24 GB
vLLM
0.27.1 · V1
Torch
2.13.0+cu130
输入长度
256 → 4096 tok
输出 / 并发
256 tok / C=16
KV 池
4.64 GiB · 86,864 tok
请求
800 · 0 failed
抢占
0 次

结论线性得几乎没有误差

KV 占用随输入长度的增长,五个点全部落在一条直线上,R² = 0.999998。 斜率实测 15.91 —— 理论值是 16.00(并发数),偏差 0.6%。 这条直线的每一个系数都能从模型结构算出来,没有留下任何需要解释的余量。

实测拟合

KVtok = 15.91 · L + 4,156

理论式是 C · (L + 256) = 16L + 4096。截距差 60 个 token, 来自采样时刻各请求已生成的输出量不完全等于 256。换算成显存: 输入每加长 1 个 token,在 C=16 下多吃 891 KiB 的 KV 池。

线性拟合 R²

0.999998

5 个长度点,峰值 KV 与理论式最大偏差 0.4 pp

56 KiB / token

2 次独立验证

一次靠容量(KV 占用),一次靠带宽(ITL 斜率)

L=4096 时 KV 占用

79.8 %

这张卡在 4K 输入下最多只能塞 20 条请求

TTFT 里"真算"的比例

9 ~ 28 %

L=1024 时是 18%,其余 82% 是排队与抢步

线性斜率就是并发数

下面这张图里有两条线:粗虚线是 16 × (L + 256) ÷ 86,864 算出来的理论值, 细实线是服务端 vllm:gpu_cache_usage_perc 的实测峰值。 它们在五个点上几乎完全重叠 —— 这正是 PagedAttention 的设计承诺: KV 占用只由 token 数决定,和批次形状、请求到达顺序都无关。

Figure 1

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

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

横轴按 2 的幂等距排列。实测峰值与理论值的最大偏差出现在 L=4096:79.77% vs 80.16%, 差 0.39 个百分点,约 340 个 token —— 相当于 1 Hz 采样恰好没抓到最高点。

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

56 KiB/token 是从模型结构里长出来的

池子的绝对大小来自服务端日志,每 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: 4.64 GiB
GPU KV cache size:         86,864 tokens

# 对账
4.64 GiB ÷ 56 KiB = 86,880 token   (日志 86,864,差 0.02%,来自 4.64 的四舍五入)

# GQA 省了多少:如果是 MHA(28 个 KV head)
56 KiB × 7 = 392 KiB/token  →  池子只剩 12,409 token  →  C=16 时 L 最多 519

这条线为什么值得亲手量一遍

容量规划的全部内容就是这条直线。给定 KV 池 P、平均上下文 L+O, 并发上限就是 P ÷ (L+O),没有第二个变量。 本轮 L=4096 时上限是 20 条 —— 而启动参数写的是 --max-num-seqs 128, 名义值比真实值大 6.4 倍。

波形410 秒全程实况

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

Figure 2

运行数 / 等待数 / KV 占用

Running Waiting KV 占用(右轴)

锯齿周期等于单请求端到端时延:L=4096 时 11.4 s,与 e2e p50 = 11,374 ms 吻合。 谷底与峰顶差 11.5 个百分点(约 9,980 token),相当于 2.3 条请求的槽位在换手中途空着。

Waiting 队列的两副面孔

L≤512 时 Waiting 恒为 0;L=1024 只有开场 1 秒有 5 条在等; L=4096 开场 6 秒里 Waiting 从 14 一路降到 0 —— 因为 chunked prefill 每步只能塞进有限的 token, 16 条 4K 请求要排队进场。p99 TTFT = 6.01 s 几乎全部来自这 6 秒。

L=4096 在稳态里还零星出现 14 次 Waiting=1,此时 KV 只有 66–69%, 离池子满还很远 —— 说明卡住新请求的不是显存,是调度器每步的 token 预算。 全程 preempt 出现 0 次,没有任何请求被踢出重算。

排队首字延迟里,只有两成在算

这是本轮最想回答的问题。把 TTFT 拆成三段:排队(request_queue_time)、 prefill(request_prefill_time)、以及剩下的前端开销(分词 + HTTP + 首 token 送达)。 再把第二轮 C=1 的单流测量放进来作参照 —— 那才是"纯计算"的下限。

Figure 3

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

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

三段之和 = 平均 TTFT。绿色虚线是 C=1 下同长度 prefill 需要的纯计算时间, 锚点是第二轮实测的 L=1024 / C=1 → 92.2 ms,按 FLOPs 线性外推(2N ≈ 13.7 GFLOP/token,151 TF/s)。 它标在这里的意义是:线以下才是不可压缩的,线以上全都是并发带来的。

Figure 4

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

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

柱上标的是"等"的占比。越短的输入,这个比例越高 —— L=256 时 91% 的首字延迟跟算 prompt 无关。 长输入反而"实在":L=4096 时纯计算占到 28%。

同一段 prefill,并发下贵了 3.9 倍

最干净的一组对照:同样是 1024 个 token 的 prefill,第二轮 C=1 时服务端量到 92.2 ms, 本轮 C=16 时量到 358.8 ms。同一段计算、同一张卡,慢了 3.9 倍。 多出来的 267 ms 不是排队(排队只有 47 ms),而是 enable_chunked_prefill=True 下, 这段 prefill 被切成块、混进 16 条正在解码的批次里,一块一块挤着算完的。

L=1024 的 TTFTC=1(第二轮)C=16(本轮)倍数占 C=16 的
C=1 数据取自第二轮 c001.json 与同轮服务端直方图。单位 ms。「占 C=16 的」是各段的原始占比;下方结论卡里的 18/9/52/19 是扣掉 C=1 基线后的占比,两者口径不同。

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

把 Session 3 的单请求 TTFT 曲线和本轮叠在一起,515.5 ms 的平均首字延迟拆成四份 —— 每一段都先扣掉 C=1 的同名基线,剩下的才算并发带来的:

真正的 prefill 计算 18%(92.2 ms,C=1 实测,不可压缩)· 排队 9%(47.2 − 0.0 = 47.2 ms)· prefill 被抢步 52%(358.8 − 92.2 = 266.6 ms)· 前端开销放大 19%(109.5 − 12.1 = 97.4 ms)。 余下的 2% 是单流本身就有的 12.1 ms 前端开销,四段加基线正好合成 515.5 ms。

换个分母结论一样:并发带来的增量是 411.1 ms,其中排队占 11.5%、 prefill 被抢步占 64.9%、前端放大占 23.7% —— 无论怎么切, 被抢步的 prefill 都是最大的一块。

结论是:在 C=16 这种很普通的并发下,TTFT 已经基本是一个调度指标, 而不是一个算力指标 —— 想压 TTFT,先调 --max-num-batched-tokens,而不是换卡。

物理一张卡,两种完全不同的机器

--enable-mfu-metrics 打开后,/metrics 会多出 estimated_flops_per_gpu_total 与 estimated_read_bytes_per_gpu_total 两个累积计数器,采样脚本每秒抓一次。名字里的 estimated 是实打实的 —— 它是 vLLM 按模型结构和当步调度到的 token 数推出来的,不是 GPU 性能计数器, 因此不含 kernel launch、padding 这类实际损耗。 把这两条按秒画出来,会看到一个很干脆的现象:同一张 4090, 在"纯解码"的那一秒和"正在 prefill"的那一秒,算力相差 一个数量级。

# 采样脚本抓到的每秒瞬时 TF/s(L=4096 开场 10 秒)
t+0  Running=0  Waiting=0   86.6 TF/s   ← 上一轮收尾
t+1  Running=2  Waiting=14  146.3 TF/s  ← 纯 prefill,贴着峰值跑
t+2  Running=5  Waiting=11  144.8 TF/s
t+3  Running=7  Waiting=9   146.4 TF/s
t+4  Running=10 Waiting=6   144.8 TF/s
t+5  Running=12 Waiting=4   146.3 TF/s
t+6  Running=15 Waiting=1   122.4 TF/s
t+7  Running=16 Waiting=0   12.3 TF/s   ← 纯 decode,掉到 1/12
t+8  Running=16 Waiting=0   12.4 TF/s
t+9  Running=16 Waiting=0   12.4 TF/s
Figure 5

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

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

解码底噪在全程都稳定在 12–14 TF/s,和输入长度几乎无关; 每一根冲到 140–170 TF/s 的尖峰都是一次 prefill。长输入的尖峰更密、更宽, 整段测试的平均 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 81.6% 已经贴住带宽墙,MFU 只有 14.2%;L=4096 时反过来,MFU 53.2%、MBU 43.3%。 两条线在 L≈3,150 相交(L=2048 处相差 −16.4 pp、L=4096 处 +9.9 pp,在 log₂ 上插值)。

这个交叉点不是巧合:MFU ÷ MBU = (FLOPs ÷ bytes) ÷ (峰值算力 ÷ 峰值带宽), 所以两条线相等,在代数上就等价于「算术强度 = 脊点」。 4090 的脊点是 165.2 TF/s ÷ 1,008 GB/s = 163.9 FLOP/byte; 本轮五个点的实测算术强度依次是 28.5 / 41.0 / 67.6 / 116.6 / 201.2 FLOP/byte, 正好在 L≈3,150 处穿过它。这是一句货真价实的 roofline 陈述。

但它说的是「平均值」。实际负载从来没停在脊点上,它在两个极端之间跳: 单个 decode 步的算术强度只有 12–15 FLOP/byte(远低于脊点,深度带宽受限), 而 prefill 读一次权重要算几千个 token,强度是 242(L=256)到 3,867(L=4096)FLOP/byte (远高于脊点,深度算力受限)。按秒拆开看(Figure 5)也是同一件事:decode 秒恒为 12–14 TF/s、 prefill 秒恒为 145–170 TF/s,变的只是两种秒的配比。

所以这条曲线真正在说的是:把 prompt 拉长,等于把算力密集的 prefill 在时间里的占比拉高, 稀缺资源随之从带宽换成算力。这也是"提高 batch size 能提吞吐"在长上下文下失效的原因 —— decode 的算术强度大致正比于批内序列条数,而长上下文用同样的 KV 预算只能装下更少的条数 (第三节已经量过:L=4096 时这张卡最多只塞得下 20 条)。

这两个比值到底是什么

一张 GPU 干活时有两堵墙:每秒能算多少次乘加,和每秒能从显存搬多少字节。 任何 kernel 都会先撞上其中一堵。MFU 与 MBU 就是分别量"离这两堵墙还有多远"的比值 —— 单看一个没什么用,放一起看才知道该往哪儿优化:MBU 高就去压显存流量 (量化权重、加大 batch、缩 KV),MFU 高就去换更好的 kernel 或更低的精度。

MFU 这个叫法来自 Google 的 PaLM 论文,它特意区分了 MFU(只算模型本身必需的 FLOPs) 与 HFU(算硬件真正执行的,含重计算等),两者可以差不少。本节全部是 MFU 口径。

# 分子 —— 采样脚本每秒抓的两个单调累积量,分析时做窗口首尾差分 ÷ 窗口时长
estimated_flops_per_gpu_total       →  CSV 的 flops 列
estimated_read_bytes_per_gpu_total  →  CSV 的 read_bytes 列

# vLLM 内部怎么估的(拿本轮五个零抢占的点反推,能对上)
FLOPs/token   ≈ 13.5 GFLOP                   2N,N ≈ 6.75e9,约等于非嵌入参数量
              + 0.000401 GFLOP × 上下文长度   注意力项 = 4 × d_model(3584) × 层数(28)

每次迭代读字节 ≈ 14.3 GB                     全部权重,与 batch 无关
              + 16 × L × 56 KiB             批内所有请求的 KV

# 第二条可以当场验证(L=256)
56.3 迭代/s × (14.3 + 0.34) GB = 824 GB/s    实测 822.5 GB/s   差 0.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.5 ÷ 165.2 = 14.2%     MBU = 822.5 ÷ 1008 = 81.6%
L=4096:  MFU = 87.9 ÷ 165.2 = 53.2%     MBU = 436.9 ÷ 1008 = 43.3%

反向走的原因:算术强度

真正在动的量是算术强度(arithmetic intensity)—— 每从显存搬一个字节,能顺带算多少次浮点。 它低于脊点就是带宽受限,高于就是算力受限。 4090 的脊点 = 165.2 TF/s ÷ 1,008 GB/s = 163.9 FLOP/byte。

输入长度算术强度vs 脊点MFUMBUMFU ÷ MBU
L = 25628.50.17 ×14.2%81.6%0.174
L = 51241.00.25 ×19.2%76.7%0.250
L = 102467.60.41 ×28.2%68.4%0.412
L = 2048116.60.71 ×40.4%56.9%0.710
L = 4096201.21.23 ×53.2%43.3%1.229
算术强度 = Δflops ÷ Δread_bytes,单位 FLOP/byte。注意最后一列与「vs 脊点」列完全相同 —— 见下方恒等式。
# 这不是巧合,是一个恒等式
MFU ÷ MBU = (FLOPs ÷ bytes) ÷ (峰值算力 ÷ 峰值带宽) = 算术强度 ÷ 脊点

# 所以「两条线交叉」和「算术强度穿过脊点」是同一件事

# 但分相来看,负载从来没有停在脊点上:
单个 decode 步    216 GFLOP ÷ 14.5 GB  = 12 ~ 15 FLOP/byte      远低于 163.9,深度带宽受限
一段 prefill      L × 13.5 GFLOP ÷ 14.3 GB
                  L=256   → 242 FLOP/byte
                  L=4096  → 3,867 FLOP/byte                远高于 163.9,深度算力受限

换句话说:把 prompt 拉长,没有让任何一个 kernel 变得更"算力密集" —— decode 步的强度甚至还从 14.9 掉到了 12.0(KV 读得更多)。 变的只是算力密集的 prefill 在时间轴上占了更大比例,于是平均强度被抬过了脊点。

四个别踩的坑

1 · estimated_ 不是硬件计数器。 它按模型结构和当步调度到的 token 数推算,不含 kernel launch 开销、CUDA graph padding, 也不含 softmax / LayerNorm / RoPE / 采样这些非 GEMM 算子。 好处是它跟着调度器走(第四轮的抢占重算就是靠它抓到的);坏处是它系统性地偏"干净"。

2 · read_bytes 只数读,不数写。 KV 写入、激活写回都没算进去,所以这里的 MBU 是下界 —— 真实的带宽占用比 81.6% 还要高。

3 · 分母是规格书数字。Boost 频率、不降频、无其它开销。 本轮按秒采样里有几个点算出 169.6 TF/s = 102.7% MFU —— 能超过 100%,就说明这对分子分母的精度大约在 ±5%,别读第三位有效数字。

4 · 分相看才有意义。纯 decode 的 MFU 恒在 8% 上下,看它没用, 那些秒该看 MBU(实测约 87%);纯 prefill 的 MBU 只有百分之十几,该看 MFU(实测 88%–103%)。 整段窗口的平均值只适合回答"这段负载的配比是什么", 不适合回答"我的 kernel 写得好不好"。

带宽每一步都要把整个模型重读一遍

显存读字节这条计数器可以完整对上账。每个 decode step 要读两样东西: 全部权重(约 14.3 GB,与 batch 无关),以及批内所有请求的 KV(= 16 × L × 56 KiB,随 L 线性增长)。 把这两项按实测迭代率乘出来,和 read_bytes 的实测速率对照:

Figure 7

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

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

L=256 与 L=512 上模型预测与实测差 <0.3%。L≥2048 时实测略高于预测(+3~6%), 多出来的是 prefill 阶段的激活读写 —— 那部分不在这个二项模型里。

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

第一次验证靠的是容量(KV 占用计数器)。第二次完全不看计数器,只看时间: 单步 decode 的中位耗时随上下文线性增长,而这个斜率应该恰好等于 "多读的 KV 字节 ÷ 显存带宽"。

# 实测线性拟合(5 个点,R² = 0.99998)
ITL_p50(L) = 16.36 ms + 0.00099 ms/token · L

# 理论:每步多读的 KV = 16 条 × 1 token × 56 KiB
16 × 57,344 B ÷ 1,008 GB/s = 0.00091 ms/token

# 吻合度
0.00091 ÷ 0.00099 = 92%  ← KV 读取跑到了显存带宽峰值的 92%
Figure 8

单步解码耗时 vs 上下文长度

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

横轴是线性刻度。上下文拉长 16 倍,单步解码只慢了 23%(16.61 → 20.43 ms)—— 因为大头是那 14.3 GB 权重,它跟上下文长度无关。这是"长上下文并不会让解码变慢多少"的定量版本。

代价吞吐的分叉

长度这个轴上有一个容易踩的坑:总 token 吞吐一路上涨,看起来机器越用越好; 但用户真正拿到的输出 token 吞吐一路下跌。两条线朝相反方向走。

Figure 9

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

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

L 从 256 涨到 4096:总吞吐 1,774 → 6,107 tok/s(+244%),输出吞吐 887 → 359 tok/s(−59%)。 如果你的看板只画了 total_token_throughput,这段退化是完全看不见的。

Figure 10

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

p50 p90 p99 均值(= TPOT)

p50 从 16.6 涨到 20.4 ms,温和。但 p90 在 L=4096 从 19.4 直接跳到 190.5 ms —— 超过一成的 token 间隔卡在别人的 prefill 上。均值 TPOT 39.4 ms 是 p50 的 1.9 倍, 说明将近一半的解码时间被别人的 prefill 吃掉了。

对照同样的 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 那一点相交(23.6% KV,683 vs 696 tok/s,三轮独立测量吻合到 1.9% 以内)。 往右走,并发轴向上、长度轴向下。

KV 占用并发轴配置输出 tok/s 长度轴配置输出 tok/s并发轴 / 长度轴
KV 占用相近的点两两配对。并发轴数据取自第二轮,长度轴取自本轮。

KV 占用是成本指标,不是产出指标

C=64/L=1024 和 C=16/L=4096 吃掉的 KV 差不多(94.3% vs 80.2%),p99 TTFT 也差不多(5.75 s vs 6.01 s), 但输出吞吐差 3.9 倍(1,405 vs 359 tok/s)。原因很直白: 解码吞吐正比于批内序列条数,而 KV 占用正比于批内 token 总数。 长上下文用同样的显存,换来的是更少的并行解码流。

有意思的是总 token 吞吐反而接近(7,025 vs 6,107 tok/s)—— GPU 干的活儿总量差不多, 区别只在于这些活儿有多少变成了用户看得见的输出。

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

指标第一轮第二轮第三轮(本轮)极差
第一/二轮为 200 条请求的 c016,本轮为 160 条请求的 len_1024,采样与统计口径一致。

数据完整测量结果

L耗时 s KV 峰值Wait 峰值 TTFT p50TTFT p99 排队prefill TPOTITL p50ITL p90ITL p99 E2EL p50 输出 tok/s总 tok/s 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。功耗为窗口内均值。

调度与批次形状

L迭代次数迭代/s平均步长 ms 每步 token其中解码解码占比 每步读 KV请求/s
「每步 token」= iteration_tokens 直方图均值(prefill 块 + 解码 token)。「每步读 KV」= 16 × L × 56 KiB。

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

口径提醒

本轮每个长度点只有 160 条请求,p99 相当于第 158~159 条,样本量偏薄 —— p99_ttft 在 L=2048/4096 上基本等于开场那几条排队最久的请求,应当理解为 "冷启动尖峰"而不是"稳态尾延迟"。要量稳态尾延迟,需要把 --num-prompts 提到 800 以上, 或者干脆丢掉前 2 个批次的样本。