Concurrency Sweep · RTX 4090 · Round 2

Qwen2.5-7B 并发曲线

固定 1024 输入 / 256 输出,并发从 1 扫到 64。 第二轮换了一台实例,KV 池比第一轮大 38%。 这条曲线回答两个问题:吞吐涨到多少时开始用延迟去换;KV 池变大后,拐点有没有移动。

GPU
RTX 4090 · 24 GB
vLLM
0.27.1 · V1
Torch
2.13.0+cu130
Dtype
bfloat16
In / Out
1024 / 256 tok
KV 池
6.41 GiB · 120,064 tok
Chunked prefill
on
失败请求
0 / 1332
驱动
580.159.03 · CUDA 13.0

术语先把这六个主线指标定义清楚

下面这些缩写不是同义词: 每个指标量流水线上不同的一段,彼此之间有确定的换算关系。 本轮负载: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 变差、吞吐变好,这正是压测要权衡的交换。

结论拐点仍然在 C=8 到 C=16 之间

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(对数)。

Figure 1

吞吐 – 延迟权衡曲线

p99 TTFT p50 TTFT 各并发点

p50 与 p99 在 C=8 之后急剧分离:高并发下不是所有请求整体变慢,而是少数请求被排到了后面。 灰色横带是三档常见 SLO。

效率并发翻倍,吞吐没有翻倍

实测吞吐从 C=4 开始明显偏离理想线性扩展,到 C=64 只有理想值的 35.1%。 双对数坐标下,理想线性扩展是一条直线。

Figure 2

扩展效率:实测 vs 理想线性

实测吞吐 理想线性(61.9 × C)

点旁标注为该点的扩展效率。

每多一个并发槽,还能换来多少

边际吞吐是判断拐点最直接的量:它衰减到了五分之一以下。 第一个并发槽带来 61.9 tok/s,C=32→64 这一段每个槽只剩 11.3 tok/s。 这一段的 KV 池占用刚过一半,所以下降的原因不是显存,而是并行度本身的收益在减少。

Figure 3

边际吞吐收益(每新增一个并发槽)

柱高 = 该区间内新增吞吐 ÷ 新增并发数。虚线是 C=1 的基准值 61.9 tok/s。

两堵墙MBU 下滑、MFU 上扬,交点在 C≈44

并发是 64 倍,吞吐只有理想值的 35.1%。服务端计数器显示,差额不是浪费,而是瓶颈换了一堵墙。 本轮给服务端加了 --enable-mfu-metrics,它把每次迭代的 FLOPs 和读取字节数累加进 /metrics。 累计量除以窗口墙钟,得到实际算力和实际带宽;再除以 4090 的峰值(165.2 TFLOP/s FP16 累加、 1008 GB/s),就是 MFU 和 MBU。

Figure 4

MBU(带宽利用率)与 MFU(算力利用率)随并发的此消彼长

MBU = 实测 GB/s ÷ 1008 MFU = 实测 TFLOP/s ÷ 165.2 交叉点 C ≈ 44

同一颗芯片,瓶颈从左边那堵墙换到了右边那堵。 C=1 时权重读取占满带宽(88%),算力几乎闲置(2.5%); C=64 时权重被摊薄 64 次,带宽需求降到 43%,GEMM 的算力需求升到 57%。 两条线在 C≈44 交于 49% 附近。此后再加并发,换来的是算力,付出的是带宽余量。 (《vllm-03-KV的线性》固定并发、扫输入长度,这对指标的趋势相同。)

服务端计数器直读。TFLOP/s 与 GB/s 由 /metrics 累计量除以窗口墙钟得到;功耗、温度、KV 均值三列是采样窗口内的算术均值(不是 p50 / 峰值)。这与《vllm-02-TTFT解剖》的口径不同,不要直接对比数字。
CTFLOP/sMFUGB/sMBUtok/iter功耗 W 均值温度 均值KV 均值
14.162.5%887.188.0%4.9365620.9%
27.944.8%856.785.0%9.7337601.9%
415.009.1%823.581.7%19.3349623.7%
827.2716.5%774.976.9%38.4364627.4%
1646.1427.9%695.369.0%75.23856414.1%
3270.4342.6%548.054.4%159.04116628.9%
6494.6157.3%435.043.2%311.64286957.2%

用 roofline 给每个并发点算一条地板

上表是实际跑到的值,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-压测》,差距是数量级的。

断点ITL 的尾部在 C=8 裂开

这张图最直接地证明了"只有 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 倍。

Figure 5

ITL 分位数分裂(对数纵轴)

p50 p90 p99 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 上。

这些停顿最终反映在端到端时间上

Figure 6

E2EL 构成:TTFT vs decode 耗时(p50)

TTFT decode 255 个 token

decode 段从 4.04 秒涨到 10.99 秒,是 E2EL 增长的主体。TTFT 的 p99 很高, 但按中位数算,它始终只占总时长的 2.5%–6.7%。

天花板真实并发上限是 93 条,不是 --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 本身的随机性。

Figure 7

各并发点的 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 p50TTFT p99 TPOT p50 ITL p50ITL p99 E2EL p50KV 上界KV 实测
延迟单位 ms。效率 = 吞吐 ÷ (C × 61.86)。KV 上界 = C × 1280 ÷ 120,064,即"全程满载"的理论值;KV 实测是采样窗口内的实测均值。表中吞吐只算输出 token。把输入一起算的总吞吐是它的 5 倍(309 → 6,939 tok/s)。长输入场景下两者会背道而驰,不能互相替代。

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

三个 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 上限很远。

选点按 SLO 反查配置

下表按 p99 TTFT ≤ 0.5 / 1 / 1.5 / 3 秒四档,反查并发最多能开到多少。 SLO(Service Level Objective,服务等级目标)是事先定下的延迟承诺, 例如"p99 TTFT 不超过 1.5 秒"。

如果 SLO 是选并发能拿到吞吐实际 p99 TTFTp50 ITL
高亮行为推荐默认值。

下一步该改什么

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