Concurrency Sweep · Round 2 · RTX 4090
同样是 1024 / 256 负载,同样扫到 64 并发,采样器全程跟跑了 973 秒。 第一轮只能说"TTFT(首字延迟)涨到了 5.8 秒",这一轮能说清它涨在哪一段。 这一轮 KV 池还宽松了 38%,正好用来判断排队是显存造成的,还是调度造成的。
下面这些缩写不是同义词。每个指标量的是流水线上不同的一段,彼此有确定的换算关系。
本轮负载:1024 输入 / 256 输出 · 并发 1 → 64。本报告把 TTFT 再拆成 排队 + prefill + 前端 三段,所以下表 TTFT 那一行还会继续细分。
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 → 1,000 ms(均值) |
| 排队 request_queue_time | TTFT 第 1 段 | 请求在 waiting 队列里等调度器放行的时间,本报告的主角。来自服务端 histogram,按每档测量窗口差分得到均值。 | 0 → 380 ms |
| prefill request_prefill_time | TTFT 第 2 段 | 算完 1024 个输入 token 并写进 KV Cache 的时间。这是"真算"的一段,也来自服务端 histogram。 | 94 → 479 ms |
| 前端 残差,非直接测量 | TTFT 第 3 段 | 分词、HTTP、API server 与引擎之间的 IPC。它由客户端 TTFT 减去上面两段得出,所以也吸收了窗口对齐误差。看趋势可以,不要逐毫秒引用。 | 9 → 141 ms |
| tok/iter iteration_tokens_total | 每次迭代吃几个 token | 一次前向里一起算的 token 数,等于正在 decode 的请求数加上 chunked prefill 切进来的块。它解释了 ITL 尖峰的来源。 | 4.9 → 317.4 |
| ITL Inter-Token Latency | token 间隔 | 相邻两个输出 token 之间的时间。一条 256 token 的请求有 255 个 ITL 样本。样本多,所以 ITL 的 p99 最能暴露抖动。 | 15.83 → 22.50 ms(p50) |
| ITL p99 | 间隔的尾部 | 所有 ITL 样本排序后第 99% 位置的值。一次卡顿只影响少数间隔,均值和中位数都看不出来,只有 p99 会跳。 | 16.3 → 207.6 ms |
| 输出吞吐 output token/s | 用户拿到的 | 单位时间产出的输出 token 数,也就是用户真正感知的产能。 | 61.9 → 1,387.8 tok/s |
| 抢占 preemption | KV 池不够时触发 | KV 池装不下时,调度器把一条正在跑的请求退回队列。它已算出的 KV 被丢弃,恢复时要重算 prefill。计数器是 vllm:num_preemptions_total。vLLM V1 不打日志行,只能从 /metrics 读。 | 全程 0 次 |
| KV 占用 gpu_cache_usage_perc | KV 池用了多少 | KV 池的使用率。池大小 = 显存扣掉权重与激活后的余量,由启动日志给出。每 token 占 56 KiB,所以占用率 ≈ 并发 × 上下文 ÷ 池容量。 | 1.1% → 67.5% |
| Running / Waiting | 在跑的 / 在等的 | 服务端正在 decode 的请求数,和还在队列里等的请求数。两者之和等于客户端在飞的并发数。Running 才是真实产能。 | Running 1→61;Waiting 峰值 55(5 秒内排空) |
三个最容易混的地方
1 · TPOT 和 ITL 均值是同一件事的两种算法。 TPOT 先算每条请求自己的平均,再对请求取中位数;ITL 均值把所有间隔放在一起平均。 负载均匀时两者几乎相等(本轮 C=1 都是 15.83 ms),有长尾时会分开。 两者一分开,就说明有少数间隔被拖长了。
2 · 只看 p50,得不出任何关于抖动的结论。 一次 200 ms 的停顿混在几十万个间隔里(本轮共约 34 万个),对中位数没有影响,对均值影响也很小。 本系列反复出现的一条经验:抖动只在 p99 上露头。 抢占更稀有(万分之一量级),连 p99 都反映不出来,只能看专门的计数器。
3 · 吞吐不是 1 / TPOT。 TPOT 是单条请求的出字速度,吞吐是整台机器的产能;吞吐约等于单条出字速度乘以并行的请求条数。 并发提高时 TPOT 变差、吞吐变好,这正是压测要权衡的交换。
并发越高,TTFT 里排队的比重越大:从 C=1 到 C=64,排队从 0 涨到 380 毫秒, 占 TTFT 的比重从 0% 升到 38%。同期 prefill 只是 C=1 时的 5.1 倍。 并发越高,用户等的越不是计算,而是别人。
核心分解结果 · C=64
排队 380 ms + prefill 479 ms + 前端 141 ms
三段加起来 1,000 ms,等于客户端观测的平均 TTFT。 其中排队从 C=1 的 0 ms 涨到 380 ms,是唯一一个近乎从零起步的分量。
C=64 前端开销(残差)
141 ms
C=1 时只有 9 ms,随并发增长
C=64 排队占 TTFT
38.0 %
C=1 时这一项是 0
C=64 持续功耗
447 W
已经压在 4090 的 450 W 功耗墙上
每次迭代 token 数
317 tok
C=64 时,decode 61 + prefill 256
服务端的 request_queue_time 和 request_prefill_time 两个 histogram
差分后,得到每个并发点的平均排队时间和平均 prefill 时间。
两者之和比客户端观测的 TTFT 少一截,这一截是 vLLM 前端(API server)的处理与 IPC 开销。
TTFT 三段分解(平均值,堆叠)
柱顶数字是该并发点的平均 TTFT。每档的 histogram 差分只统计该档测量窗口内完成的请求 (即 Figure 4 里的灰色分区)。档位切换时的收尾请求不计入下一档, 否则上一档那些零排队的收尾请求会稀释本档的平均排队。 前端那一段是残差(客户端 TTFT 减去服务端两段),所以也吸收了窗口对齐误差,不宜逐毫秒解读。 但它随并发从 9 ms 增长到 141 ms,这个趋势是真实的。
排队 vs prefill:谁在推高 TTFT
纵轴为对数。在本轮测到的范围内,两条线始终没有交叉,排队一直在 prefill 下方。 要看的是斜率,不是高低:prefill 从 94 ms 涨到 479 ms,只是原来的 5.1 倍; 排队从 C=1/C=2 的 0(低于计数器精度)涨到 380 ms。 排队是唯一近乎从零起步的分量,所以谈它的"倍数"没有意义,要看绝对增量。
所以两者的差距一路收窄:排队占 prefill 的比例从 C=4 的 0.6% 升到 C=16 的 10.6%、C=32 的 30.4%,C=64 已经追到 79%。 按这个趋势,排队迟早会反超 prefill。但本轮最高只压到 64, 在哪一档反超只能外推,不是实测,这里不给数。
这一轮回答了"排队是不是 KV 池逼出来的":不是
第一轮 C=64 时 KV 占用 93.3%,running 中位数只有 62。
当时的解释是"KV 池确实开始限制并发了"。
同一个现象在 KV 池宽松得多的机器上原样重现,说明它不是显存造成的。
这一轮 KV 池大了 38%,C=64 的占用只有 67.5%,离满还差三分之一;
但 running 中位数还是 61,排队还是涨到 380 ms。
排队来自请求完成与下一条 prefill 完成之间的自然空隙,加上 chunked prefill 的调度节奏。
实测每 1 个 decode token 配约 4 个 prefill token。
iteration_tokens_total 记录每次 engine 迭代处理的 token 数。
如果只有 decode,这个数应等于正在跑的请求数;多出来的部分,
是 chunked prefill 切碎后塞进同一批的 prefill 块。
每次迭代的 token 构成
decode : prefill 几乎不随并发变化,恒定在 1 : 4 上下, 正好对应输入输出比(1024 : 256)。decode 部分按窗口内 running 的中位数估计。
这是 ITL p99 尖峰最直接的物理解释:正在 decode 的请求,每一步都要等同批次里的 prefill 块算完。C=64 时每次迭代处理 317 个 token,其中 decode 61 个、prefill 256 个。 所以 decode 的每一步都被拖长,尾部出现 200 ms 量级的停顿。 七个档位共 34,779 次迭代,这个 1 : 4 始终没变。
七个并发点在时间轴上依次排开,可以直接看到队列何时堆起来、KV 占用何时冲顶。 这一轮 KV 占用没有冲顶。
运行数 / 等待数 / KV 占用
灰色分区是各并发点的测量窗口。C≤8 时 waiting 全程为 0。 从 C=16 起,每个档位的起步瞬间出现一根尖峰(C=64 时 55 条), 但都在 5 秒内排空;稳态下 90% 的采样点仍是 0。KV 曲线阶梯上行,最高只到 67.5%。
功耗与温度
C=64 期间功耗中位数 447 W、峰值 474 W,已经压在 4090 默认的 450 W 限制上。 温度最高 73 °C,不构成瓶颈。
功耗墙可能是效率衰减的一部分原因
C=64 时 GPU 持续运行在 447 W,C=1 时只有 368 W。撞上功耗限制后 GPU 会降频, 直接压低实际可用的带宽和算力,而 vLLM 的任何指标都看不到这件事。 所以 C=64 的效率(吞吐 ÷ 64 倍单流吞吐)只有 35.1%,不能全部归因于 prefill 抢算力,至少还要算上功耗墙这一份。
KV 占用不该是测了才知道的黑箱,它应该能用并发数、序列长度、每 token 的 KV 体积三个数直接算出来。 拿稳态实测值去对,误差大小反映这本账理解得准不准。
峰值要对峰值,均值要对均值,混用会系统性高估 11%。
直觉公式是 并发 × (in_len + out_len) × 56 KiB ÷ 池大小。
但 in + out = 1280 是请求结束那一刻的占用;
稳态下大量请求生成到一半,平均只占 in + out/2 = 1152。
# 池容量(服务端日志,第二轮) GPU KV cache size = 120,064 tokens = 7,504 blocks × 16 # 单请求占用(block_size = 16,两个数都整除,无对齐损耗) 峰值 = ceil(1280/16) = 80 blocks = 1,280 tokens 均值 = ceil(1152/16) = 72 blocks = 1,152 tokens # 预期占用率 峰值% = C × 1280 ÷ 120,064 均值% = C × 1152 ÷ 120,064
KV 占用:稳态实测 vs 理论预期
实线为实测,虚线为理论值,两两几乎重合。稳态段取每个并发点窗口的中间 60%, 去掉档位切换时的爬升与回落。
| 并发 | running p50 | 实测 p50 | 预期均值 | 误差 | 实测峰值 | 预期峰值 | 误差 |
|---|
账算平了,而且比第一轮更平
峰值口径误差 0.00 ~ −0.72 个百分点,七个点全部在 1 pp 以内, 前四个点精确到小数点后两位。 均值口径在低并发下同样贴合,只有 C=64 偏差 −2.69 pp。
这个偏差不是模型错了,而是"名义并发 ≠ 实际并发"。
C=64 时 running 中位数只有 61。把 61 代入公式:预期 58.53%,实测 58.72%,
误差 +0.19 个百分点。
差的这 3 条是请求换手的自然空隙,不是显存约束, 理由见前面「分解」一节。
C=8~32 剩下 −0.18 ~ −1.49 pp 的小幅负偏差,来自请求生命周期错开,方向和量级都合理。 理论均值假设输出进度均匀分布在 0~256 之间;但从请求完成到新请求 prefill 完成之间 有短暂空隙,所以实际占用略低于理想值。
一个这轮没暴露的坑
做容量规划时,要按 block 向上取整,不能直接按 token 数算。
本例的 1280 和 1152 都能被 block_size=16 整除,对齐损耗恰好为零,这是运气。
非整除情形的算例见《vllm-03-KV的线性》。
两轮相隔十天,换了实例和驱动版本,配置相同。 所以这比第一轮内部的两次重跑更能检验方法是否可靠。
吞吐复现性:第一轮 vs 第二轮
7 个点的吞吐差异全部在 −1.20% ~ +0.61% 之间,p99 TTFT 差异在 −2.1% ~ +2.6% 之间。 差异有方向:低并发第二轮略快,高并发第二轮略慢。 前者像是驱动或时钟差异,后者对应第二轮功耗更高、更贴近功耗墙(功耗中位数 447 W vs 440 W)。 但整体幅度都在 1% 量级,不影响任何结论。
重跑的唯一动机,是给客户端补上 --temperature 0,
让两个引擎的采样路径同构。动手前的判断是:第一轮的 vLLM 用了模型
generation_config.json 的默认值
(temp 0.7 / top_k 20 / repetition_penalty 1.05)。
repetition_penalty 每出一个 token 都要扫一遍已生成的历史,这个成本只有 vLLM 付。
所以第一轮的 vLLM 数据应该偏悲观,对 vLLM 不公平。
| 项目 | 第一轮 | 第二轮 | 预期影响 |
|---|---|---|---|
| temperature | 0.7(配置文件默认) | 0(显式指定) | 贪心解码,少一次概率变换 |
| top_k | 20 | 关 | 少一次 top-k 排序 |
| repetition_penalty | 1.05 | 关 | 每步少扫一遍历史,这是假设中的主要成本 |
| 采样 WARNING | 每档都打 | 消失 | 确认参数真的生效了 |
| 驱动 / CUDA | 590.48.01 / 13.1 | 580.159.03 / 13.0 | 非受控变量,连带换了实例 |
| 实测吞吐差 | −1.20% ~ +0.61%(七个并发点) | 在噪声里 | |
这个判断是错的,必须写进结论
repetition_penalty 1.05 对 vLLM 的吞吐没有可测量的影响。
七个档位的差异全部在 ±1.2% 以内,方向也不一致
(低并发第二轮略升、高并发第二轮略降,原因见上图 note)。这是噪声,不是信号。
这一轮没有白跑:它把"采样参数不同构"这个变量从怀疑名单里划掉了。 之前,llama.cpp 与 vLLM 之间的任何差距都可能被质疑"是不是采样设置不一样造成的"; 现在这条退路没了,剩下的差异只能归因于引擎本身。排除一个假设也是结果。 它还让两轮成了一组可以互相印证的数据: 下面这段敢说"复现到 1%",前提正是这两轮除机器外确实同构。
跨机器复现到 1%,但有一个数不能跨机器搬
吞吐、延迟、ITL 裂口位置、混批比例、KV 对账误差全部复现。 唯一差了 38% 的是 KV 池大小:4.64 GiB / 86,896 tok 变成 6.41 GiB / 120,064 tok。 启动命令相同,只因为驱动从 590.48.01 换成 580.159.03, vLLM profiling 探到的已占用显存(权重 + non-torch)就少了 1 GiB,峰值激活也少了 0.78 GiB,KV 池因此多出 1.77 GiB。 凡是从 KV 池推出来的数(并发上限、抢占阈值),必须在每台机器上现读日志。
| 并发 | 输出 tok/s | queue | prefill | 前端* | TTFT 均值 | TTFT p99 | ITL p50 | ITL p99 | running | wait 峰值 | KV% | 功耗 W |
|---|
num_preemptions_total
始终为 0。--enable-mfu-metrics 后,
estimated_flops_per_gpu_total 与 estimated_read_bytes_per_gpu_total
全程有效,C=1 实测 4.16 TFLOP/s、887 GB/s。第一轮这两个 counter 全程为 0。--max-num-batched-tokens 再跑一轮,
验证"prefill 块变小 → ITL 尖峰变矮、TTFT 变差"这个交换关系。
现在有了 iteration_tokens 指标,可以直接量化块大小的变化。
KV 池已经排除,这是唯一剩下的旋钮。--kv-cache-memory
把两轮固定到同一个字节数,"并发上限""抢占阈值"这类结论才能做真正的单变量对照。
这是 Round 2 五份压测报告的第 2 份(vLLM TTFT 分解)。
整轮的横向结论与生产选型建议见 《选型指南-vllm-llama-cpp》。
那份把五组测试的数据合起来,回答"两个引擎各自强在哪、生产上怎么选"。
同目录另有:vllm-01-Qwen2.5-7B并发曲线 · vllm-03-KV的线性 · vllm-04-断崖 · llama-cpp-压测。