Concurrency Sweep · RTX 4090 · Round 2
固定 1024 输入 / 256 输出,并发从 1 扫到 64。 第二轮换了一台实例,KV 池比第一轮大 38%。 这条曲线回答两个问题:吞吐涨到多少时开始用延迟去换;KV 池变大后,拐点有没有移动。
下面这些缩写不是同义词:
每个指标量流水线上不同的一段,彼此之间有确定的换算关系。
本轮负载:1024 输入 / 256 输出 · 并发 1 → 64。
其余指标(MBU、MFU、KV 占用、抢占等)在用到的那一节再解释。
t=0 客户端发出请求
│
│ [1] 排队 queue_time —— 在 waiting 队列里等调度器放行
│ [2] prefill —— 把 1024 个输入 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。 | 102 ms(C=1)→ 787 ms(C=64) |
| ITL Inter-Token Latency | token 间隔 | 相邻两个输出 token 之间的时间。一条 256 token 的请求有 255 个 ITL 样本。样本量大,所以它的 p99 最能暴露抖动。 | 15.83 → 22.50 ms(p50) |
| ITL p99 | 间隔的尾部 | 把所有 ITL 样本排序后第 99% 那个值。一次卡顿只影响少数间隔,均值和中位数看不出来,只有 p99 会跳。 | 16.3 → 207.6 ms |
| TPOT Time Per Output Token | 每输出 token 耗时 | 一条请求在第 1 个 token 之后的平均出字速度,即 (E2EL − TTFT) ÷ (输出 token 数 − 1)。数值上就是这条请求 ITL 的均值。 | 15.83 → 43.41 ms |
| E2EL End-to-End Latency | 端到端时延 | 从发出请求到收到最后一个 token 的总时间。恒等式:E2EL = TTFT + (输出数−1) × TPOT。 | 4,138 → 11,779 ms |
| 输出吞吐 output token/s | 用户拿到的 | 单位时间产出的输出 token 数,即用户感知到的产能。 | 61.9 → 1,387.8 tok/s |
三个最容易混的地方
1 · TPOT 和 ITL 均值是同一件事的两种算法。 TPOT 是每条请求先算自己的平均,再对请求取中位数;ITL 均值是把所有间隔放在一起平均。 负载均匀时两者几乎相等(本轮 C=1 都是 15.83 ms)。 两者一分开,就说明存在少数被拖长的间隔。
2 · p50 不能说明抖动。 一次 200 ms 的停顿混在 25 万个间隔里,对中位数没有影响,对均值影响也很小。 本系列反复验证的经验是:抖动只在 p99 上露头。 抢占这种更稀有的事件(万分之一量级)连 p99 都反映不出来,只能看专门的计数器。
3 · 吞吐不是 1 / TPOT。 TPOT 是单条请求的出字速度,吞吐是整台机器的产能,约等于单条请求的出字速度乘以并行的请求条数。 并发提高时 TPOT 变差、吞吐变好,这正是压测要权衡的交换。
KV 池比第一轮宽松得多,但拐点没有移动:限制拐点的是调度器,不是显存。 吞吐到 C=64 仍没有饱和,但每个新增并发槽换来的 token/s 从 C=8 起快速下降。 划出拐点的是 ITL 的尾部,不是吞吐曲线:ITL 尾部在 C=8 突然裂开。
推荐运行点
C = 16 · 681 tok/s
吞吐是单流的 11.0 倍,p50 ITL 只有 17.2 ms(比单流慢 8.9%),p99 TTFT 是 1.41 秒。 C=32 的吞吐再多 50.7%,代价是 p99 TTFT 翻倍到 2.85 秒。
C=8 是抖动开始出现的点,C=16 是抖动仍可接受的点。 C=16 的 p99 ITL 已到 184 ms,但 p50 ITL 只有 17.2 ms,p99 TTFT 仍在 1.5 秒 SLO 以内。 要完全消掉 ITL 尖峰,只能退回 C=4,代价是丢掉三分之二的吞吐。
单流带宽利用率 MBU
88.0 %
61.9 tok/s,decode 已贴住显存带宽
C=64 吞吐增益
22.4 ×
并发是 64 倍,最后一段每槽只剩 11.3 tok/s
ITL p99 / p50 最大比
10.7 ×
出现在 C=16,均值看不见这个裂口
C=64 时 KV 池占用
57.2 %
采样窗口实测均值,理论上界 68.2%。显存不再是这一轮的墙
曲线越往右,每份吞吐的延迟代价越高。 从 C=1 到 C=16,p99 TTFT 增加 1.30 秒,吞吐变成 11 倍;从 C=32 到 C=64, 吞吐只多 35%,p99 TTFT 却增加 3.07 秒。 横轴是实际产出的 token/s,纵轴是 p99 TTFT(对数)。
吞吐 – 延迟权衡曲线
p50 与 p99 在 C=8 之后急剧分离:高并发下不是所有请求整体变慢,而是少数请求被排到了后面。 灰色横带是三档常见 SLO。
实测吞吐从 C=4 开始明显偏离理想线性扩展,到 C=64 只有理想值的 35.1%。 双对数坐标下,理想线性扩展是一条直线。
扩展效率:实测 vs 理想线性
点旁标注为该点的扩展效率。
边际吞吐是判断拐点最直接的量:它衰减到了五分之一以下。 第一个并发槽带来 61.9 tok/s,C=32→64 这一段每个槽只剩 11.3 tok/s。 这一段的 KV 池占用刚过一半,所以下降的原因不是显存,而是并行度本身的收益在减少。
边际吞吐收益(每新增一个并发槽)
柱高 = 该区间内新增吞吐 ÷ 新增并发数。虚线是 C=1 的基准值 61.9 tok/s。
并发是 64 倍,吞吐只有理想值的 35.1%。服务端计数器显示,差额不是浪费,而是瓶颈换了一堵墙。
本轮给服务端加了 --enable-mfu-metrics,它把每次迭代的
FLOPs 和读取字节数累加进 /metrics。
累计量除以窗口墙钟,得到实际算力和实际带宽;再除以 4090 的峰值(165.2 TFLOP/s FP16 累加、
1008 GB/s),就是 MFU 和 MBU。
MBU(带宽利用率)与 MFU(算力利用率)随并发的此消彼长
同一颗芯片,瓶颈从左边那堵墙换到了右边那堵。 C=1 时权重读取占满带宽(88%),算力几乎闲置(2.5%); C=64 时权重被摊薄 64 次,带宽需求降到 43%,GEMM 的算力需求升到 57%。 两条线在 C≈44 交于 49% 附近。此后再加并发,换来的是算力,付出的是带宽余量。 (《vllm-03-KV的线性》固定并发、扫输入长度,这对指标的趋势相同。)
| C | TFLOP/s | MFU | GB/s | MBU | tok/iter | 功耗 W 均值 | 温度 均值 | KV 均值 |
|---|---|---|---|---|---|---|---|---|
| 1 | 4.16 | 2.5% | 887.1 | 88.0% | 4.9 | 365 | 62 | 0.9% |
| 2 | 7.94 | 4.8% | 856.7 | 85.0% | 9.7 | 337 | 60 | 1.9% |
| 4 | 15.00 | 9.1% | 823.5 | 81.7% | 19.3 | 349 | 62 | 3.7% |
| 8 | 27.27 | 16.5% | 774.9 | 76.9% | 38.4 | 364 | 62 | 7.4% |
| 16 | 46.14 | 27.9% | 695.3 | 69.0% | 75.2 | 385 | 64 | 14.1% |
| 32 | 70.43 | 42.6% | 548.0 | 54.4% | 159.0 | 411 | 66 | 28.9% |
| 64 | 94.61 | 57.3% | 435.0 | 43.2% | 311.6 | 428 | 69 | 57.2% |
上表是实际跑到的值,roofline 给出理论上最快能到多少。 decode 一步至少要读一遍完整权重,再读一遍每条运行中请求的 KV。 这些字节除以峰值带宽,就是这一步的时间下限。
# roofline 对账:解码步长的理论地板 = (权重 + C × 单条 KV) / 带宽 权重(参与矩阵乘) = 7.07e9 × 2 B = 14.14 GB embedding 是查表,不流过 HBM 单条 KV = 1152 tok × 56 KiB = 0.066 GB 1152 = (1024+1280)/2,全程平均上下文 C 地板 ms 实测 ITL p50 达成率 1 14.09 15.83 89.0% 8 14.55 16.63 87.5% 32 16.13 19.82 81.4% 64 18.22 22.50 81.0% # 结论:vLLM 从 C=1 到 C=64,roofline 达成率只从 89% 掉到 81% # 交叉验证:这一列与同轮 read_bytes 算出的 MBU(88.0 → 43.2%)在 C=1 处只差 1.0 pt
这张表是"批处理几乎不要钱"的直接证据
并发是 64 倍,理论代价 29%,实付 42%。 地板从 14.09 涨到 18.22 ms(+29%),因为要多读 63 条 KV (63 × 0.066 GB = 4.16 GB)。 实测从 15.83 涨到 22.50 ms(+42%),多出的 13 个百分点是调度和 kernel 效率的损耗。 用同一方法量 llama.cpp 的结果见《llama-cpp-压测》,差距是数量级的。
这张图最直接地证明了"只有 P99 ITL 才暴露抖动"。 p50 ITL 从 C=1 到 C=64 只从 15.8 ms 涨到 22.5 ms,涨幅 42%,看不出问题。 p99 ITL 却在 C=8 从 17.6 ms 跳到 102.1 ms,是上一档的 5.8 倍。
ITL 分位数分裂(对数纵轴)
阴影区是 p50 与 p99 之间的跨度。它在 C=4 时几乎看不见,C=8 之后张开到一个数量级。
均值线始终夹在 p50 与 p99 之间,两头都不代表。
图上有两种倍数,不要混用:
点上方标的 p99/p50 是同一个并发档内最慢 1% 的间隔是中位数的几倍
(C=8 是 6.1×),说明这一档有多不均匀;
C=4 与 C=8 之间斜虚线标的 ×5.8 是 p99 相对上一档的倍数,
说明抖动在哪一步出现。
这不是抢占,而且这一轮尤其不可能是
抢占计数器 vllm:num_preemptions_total 全程 0 次。
抢占指 KV 池装不下时,调度器把一条运行中的请求退回队列:它已算出的 KV 被丢弃,恢复时要重算 prefill。
vLLM V1 不为抢占打日志行,只能从 /metrics 读。
队列等待只出现在起步阶段,约 6 秒内就排空了。 Waiting 是队列里等待的请求数,Running 是正在 decode 的请求数,两者之和等于客户端在飞的并发。 C=64 那一档起步瞬间 Waiting 排到 55 条,原因是客户端一次性投放 64 条。 稳态下 90% 的采样点 Waiting 是 0。
更关键的是,这一轮 C=64 的 KV 池占用只有 67.5%,离抢占阈值很远。 同样的 ITL 裂口在 KV 池宽松 38% 的机器上原样重现, 基本排除了"KV 紧张导致抖动"这个解释。
真正的原因是 enable_chunked_prefill:
新请求的 prefill 被切块后混进正在 decode 的 batch。
已经在 decode 的请求要等 prefill 块算完,于是出现一次 100–200 ms 的停顿。
C=64 时 p90 ITL 已达 187.5 ms,即超过一成的 token 间隔卡在别人的 prefill 上。
E2EL 构成:TTFT vs decode 耗时(p50)
decode 段从 4.04 秒涨到 10.99 秒,是 E2EL 增长的主体。TTFT 的 p99 很高, 但按中位数算,它始终只占总时长的 2.5%–6.7%。
--max-num-seqs 写的 128
--max-num-seqs 128 只是名义上限。
实际能同时跑多少条,由 KV 池大小除以单请求占用决定。
KV 池大小 = 显存扣掉权重与激活之后的余量,由启动日志给出,每 token 占 56 KiB。
KV 占用(计数器 gpu_cache_usage_perc)是 KV 池的使用率。
这一轮的 KV 池比第一轮宽松,上限也按同样的比例提高。
# 服务端日志实测(第二轮) Available KV cache memory: 6.41 GiB GPU KV cache size: 120,064 tokens # 单请求占用 = 输入 + 输出 1024 + 256 = 1,280 tokens # 真实并发上限 120,064 ÷ 1,280 = 93.8 条 # 第一轮同样的算法,同样的启动参数,拿到的是 86,896 ÷ 1,280 = 67.9 条 ← 差在 profiling 结果,见下
# Round 1 启动日志 Available KV cache memory: 4.64 GiB GPU KV cache size: 86,896 tokens Maximum concurrency for 8,192 tokens per request: 10.61x Actual usage: 15.49 GiB weights+non-torch, 1.04 GiB peak activation # Round 2 启动日志(同一条命令,同样 --gpu-memory-utilization 0.9) Available KV cache memory: 6.41 GiB GPU KV cache size: 120,064 tokens Maximum concurrency for 8,192 tokens per request: 14.66x Actual usage: 14.49 GiB weights+non-torch, 0.26 GiB peak activation
权重那一项少了 1.00 GiB,峰值激活少了 0.78 GiB,所以 KV 池多出 1.77 GiB。 两轮的 vLLM / torch 版本号相同(0.27.1 / 2.13.0+cu130)。 差别在驱动与 CUDA(Round 1 是 590.48.01 / CUDA 13.1,Round 2 是 580.159.03 / CUDA 13.0), 以及显存 profiling 本身的随机性。
各并发点的 KV 池占用
条长 = 全程满载的理论上界 C × 1280 ÷ 120,064。C=64 需要 81,920 tokens, 占 KV 池 68.2%,与服务端观测到的峰值 67.5% 吻合;全程实测均值只有 57.2%(见下方对账)。 被注释掉的 C=128 需要 163,840 tokens,是 KV 池的 1.36 倍,仍然跑不起来。
这一轮 C=64 没有碰到 KV 墙
显存账:权重 + non-torch 14.49 GiB,峰值激活 0.26 GiB,CUDA Graph 0.34 GiB,
KV 池 6.41 GiB,合计 21.50 GiB。--gpu-memory-utilization 0.90
给出的预算是 21.16 GiB,卡上总共有 23.52 GiB。
差别不在配置,在 profiling。换了驱动之后, vLLM 测到的"已占用显存"(权重 + non-torch)少了 1.00 GiB(15.49 → 14.49 GiB),峰值激活也少了 0.78 GiB;两项省下的 1.77 GiB 全部进了 KV 池(4.64 → 6.41 GiB)。 所以并发上限必须在每台机器上读日志确认,不能从别的机器照搬。
| 并发 | 请求数 | 耗时 s | 输出 tok/s | 效率 | TTFT p50 | TTFT p99 | TPOT p50 | ITL p50 | ITL p99 | E2EL p50 | KV 上界 | KV 实测 |
|---|
--ignore-eos 生效,
各档的工作量相同。吞吐差异不可能来自"有的请求提前停了"。--temperature 0 生效:sweep 日志里没有
no longer sets temperature==0 这句 WARNING(Round 1 每档都有)。
采样路径与 llama.cpp 侧同构。non-default args 里是
enable_prefix_caching: False。KV 使用率随并发严格线性
(0.9 / 1.9 / 3.7 / 7.4 / 14.1 / 28.9 / 57.2 %),没有缓存命中造成的塌陷。
《vllm-03-KV的线性》把这条线性关系单独做成了实验,那边的对账更严格。vllm:num_preemptions_total 全程为 0。本轮没有一个点触到 KV 上限。三个 KV 数字,一把尺子对得上
理论上界 C × 1280 ÷ 120,064(C=64 得 68.2%)、服务端观测到的峰值(67.5%)、
采样窗口内的实测均值(57.2%),量的是同一件事的三个时刻。
去掉上界里的两处高估,它就落到实测均值上:
# ① 1280 是请求结束那一刻的占用,全程平均只有 (1024+1280)/2 = 1152 # ② running 均值略小于 C —— 起步和收尾都有一小段没坐满 C 实测 KV 均值 running 均值 running × 1152 ÷ 120,064 1 0.94% 1.0 0.93% 8 7.40% 7.7 7.39% 16 14.11% 14.7 14.12% 32 28.94% 30.3 29.04% 64 57.18% 59.9 57.44% # 七个点最大偏差 0.5%,PagedAttention 的账在这个尺度上是平的
Figure 7 的 68.2% 是"64 条全程满载"的上界, 57.2% 才是这台机器实际占用的量。按三个数中的任何一个,本轮都离 KV 上限很远。
下表按 p99 TTFT ≤ 0.5 / 1 / 1.5 / 3 秒四档,反查并发最多能开到多少。 SLO(Service Level Objective,服务等级目标)是事先定下的延迟承诺, 例如"p99 TTFT 不超过 1.5 秒"。
| 如果 SLO 是 | 选并发 | 能拿到吞吐 | 实际 p99 TTFT | p50 ITL |
|---|
--kv-cache-memory=8471115264,
6.41 → 7.89 GiB,上限 93 → 115 条。这仍是只有好处的改动,只是没有第一轮那么急。--max-num-batched-tokens。
调小 → prefill 块更细 → ITL 尖峰更矮,但 TTFT 会变差。这是明确的取舍:
流式聊天在意 ITL,批量任务在意 TTFT。
这一轮已证明抖动与 KV 余量无关,所以只能从这里下手。--kv-cache-memory 把两边固定在同一个值。
这是 Round 2 五份压测报告的第 1 份(vLLM 并发扫描)。
整轮的横向结论与生产选型建议见 《选型指南-vllm-llama-cpp》。
那份报告合并五组测试的数据,回答"两个引擎各自强在哪、生产上怎么选"。
同目录另有:vllm-02-TTFT解剖 · vllm-03-KV的线性 · vllm-04-断崖 · llama-cpp-压测。