Length Sweep · 第三轮 · RTX 4090
固定并发 16,输入长度从 256 扫到 4096,800 条请求全部成功,1 Hz 采样 864 个点。 这一轮回答两件事:KV 占用到底是不是线性的,以及在有并发的情况下, 首字延迟里到底有多少是在"算",多少是在"等"。
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 数决定,和批次形状、请求到达顺序都无关。
KV 池占用 vs 输入长度(固定并发 16)
横轴按 2 的幂等距排列。实测峰值与理论值的最大偏差出现在 L=4096:79.77% vs 80.16%, 差 0.39 个百分点,约 340 个 token —— 相当于 1 Hz 采样恰好没抓到最高点。
| 输入长度 | 单请求 tok | ×16 = | 理论峰值 | 实测峰值 | 偏差 | 实测稳态 | 反算单请求 | 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: 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 倍。
五段测试连在一起看,KV 曲线是五级台阶,每一级都比上一级高得更多 —— 因为横轴的长度是翻倍的。
台阶顶部的锯齿是请求批次的换手:所有请求长度相同且 --ignore-eos,
它们几乎同时结束,腾出的槽位要等下一批 prefill 完才重新填满。
运行数 / 等待数 / 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 的单流测量放进来作参照 —— 那才是"纯计算"的下限。
TTFT 三段分解,与单流纯计算基线对照
三段之和 = 平均 TTFT。绿色虚线是 C=1 下同长度 prefill 需要的纯计算时间, 锚点是第二轮实测的 L=1024 / C=1 → 92.2 ms,按 FLOPs 线性外推(2N ≈ 13.7 GFLOP/token,151 TF/s)。 它标在这里的意义是:线以下才是不可压缩的,线以上全都是并发带来的。
TTFT 中"算"与"等"的比例
柱上标的是"等"的占比。越短的输入,这个比例越高 —— L=256 时 91% 的首字延迟跟算 prompt 无关。 长输入反而"实在":L=4096 时纯计算占到 28%。
最干净的一组对照:同样是 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 的 TTFT | C=1(第二轮) | C=16(本轮) | 倍数 | 占 C=16 的 |
|---|
这就是"排队贡献了多少"的答案
把 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
全程瞬时算力:prefill 秒 vs decode 秒
解码底噪在全程都稳定在 12–14 TF/s,和输入长度几乎无关; 每一根冲到 140–170 TF/s 的尖峰都是一次 prefill。长输入的尖峰更密、更宽, 整段测试的平均 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 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 脊点 | MFU | MBU | MFU ÷ MBU |
|---|---|---|---|---|---|
| L = 256 | 28.5 | 0.17 × | 14.2% | 81.6% | 0.174 |
| L = 512 | 41.0 | 0.25 × | 19.2% | 76.7% | 0.250 |
| L = 1024 | 67.6 | 0.41 × | 28.2% | 68.4% | 0.412 |
| L = 2048 | 116.6 | 0.71 × | 40.4% | 56.9% | 0.710 |
| L = 4096 | 201.2 | 1.23 × | 53.2% | 43.3% | 1.229 |
# 这不是巧合,是一个恒等式 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 的实测速率对照:
显存读带宽拆账:权重重读 vs KV 读取
L=256 与 L=512 上模型预测与实测差 <0.3%。L≥2048 时实测略高于预测(+3~6%), 多出来的是 prefill 阶段的激活读写 —— 那部分不在这个二项模型里。
第一次验证靠的是容量(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%
单步解码耗时 vs 上下文长度
横轴是线性刻度。上下文拉长 16 倍,单步解码只慢了 23%(16.61 → 20.43 ms)—— 因为大头是那 14.3 GB 权重,它跟上下文长度无关。这是"长上下文并不会让解码变慢多少"的定量版本。
长度这个轴上有一个容易踩的坑:总 token 吞吐一路上涨,看起来机器越用越好; 但用户真正拿到的输出 token 吞吐一路下跌。两条线朝相反方向走。
输出吞吐下跌,总吞吐上涨
L 从 256 涨到 4096:总吞吐 1,774 → 6,107 tok/s(+244%),输出吞吐 887 → 359 tok/s(−59%)。
如果你的看板只画了 total_token_throughput,这段退化是完全看不见的。
ITL 分位数:尾部在 L=4096 塌掉
p50 从 16.6 涨到 20.4 ms,温和。但 p90 在 L=4096 从 19.4 直接跳到 190.5 ms —— 超过一成的 token 间隔卡在别人的 prefill 上。均值 TPOT 39.4 ms 是 p50 的 1.9 倍, 说明将近一半的解码时间被别人的 prefill 吃掉了。
把三轮测试放在同一张图上:前两轮沿并发轴走(L=1024,C 从 1 到 64), 本轮沿长度轴走(C=16,L 从 256 到 4096)。横轴统一成 KV 池占用 —— 也就是"这次请求吃掉了多少显存预算"。
花同样的 KV,两条路径拿到的输出吞吐
两条曲线在 L=1024 / C=16 那一点相交(23.6% KV,683 vs 696 tok/s,三轮独立测量吻合到 1.9% 以内)。 往右走,并发轴向上、长度轴向下。
| KV 占用 | 并发轴配置 | 输出 tok/s | 长度轴配置 | 输出 tok/s | 并发轴 / 长度轴 |
|---|
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 | 耗时 s | KV 峰值 | Wait 峰值 | TTFT p50 | TTFT p99 | 排队 | prefill | TPOT | ITL p50 | ITL p90 | ITL p99 | E2EL p50 | 输出 tok/s | 总 tok/s | MFU | MBU | 功耗 W |
|---|
| L | 迭代次数 | 迭代/s | 平均步长 ms | 每步 token | 其中解码 | 解码占比 | 每步读 KV | 请求/s |
|---|
--max-concurrency 1 同样扫一遍 256→4096,
Figure 3 的绿色虚线就能从推算变成实测,"排队占比"这个数字才算真正落地。--max-num-batched-tokens,量它对 TTFT / ITL 的分配。
本轮 L=4096 的 p99 TTFT(6.0 s)几乎全部来自开场 6 秒的入场排队,而 p90 ITL(190 ms)
全部来自 prefill 抢步 —— 这两个数字被同一个旋钮控制,方向相反。
建议在 L=4096 固定的前提下扫 1024 / 2048 / 4096 / 8192,画出这条兑换曲线。--kv-cache-memory=6567016960(6.12 GiB),池子能从 86,864 扩到约 114,600 token。
L=4096 下的并发上限会从 20 提到 26,KV 占用从 79.8% 降到 60%,
可以验证"稳态里那 14 次 Waiting=1 到底是不是显存造成的"(本轮判断是调度器预算,不是显存)。--enable-prefix-caching=False,
每条请求的 prompt 都是全新随机 token,prefill 一分钱都省不掉。
真实业务的长 prompt 往往共享大段前缀 —— 开启后 KV 占用曲线的斜率应该明显低于 16,
那个"低多少"就是前缀复用率的直接测量。口径提醒
本轮每个长度点只有 160 条请求,p99 相当于第 158~159 条,样本量偏薄 ——
p99_ttft 在 L=2048/4096 上基本等于开场那几条排队最久的请求,应当理解为
"冷启动尖峰"而不是"稳态尾延迟"。要量稳态尾延迟,需要把 --num-prompts 提到 800 以上,
或者干脆丢掉前 2 个批次的样本。