Round 2 · 整轮综合 · RTX 4090 · Qwen2.5-7B-Instruct
本报告把五份子报告的结论合成一条选型路径:vLLM FP16 与 llama.cpp Q4_K_M 各自强在哪,生产上该怎么选。 测试条件相同:同一张 4090、同一个 7B 模型、同一份压测脚本,共五组测试、约 3,600 条请求。
两个引擎没有绝对的好坏,只是取舍曲线不同,而且两条曲线在很低的位置就交叉了。
真正的分界线不是并发数,是延迟预算
p99 TTFT ≈ 730 ms
分界点在 p99 ≈ 730 ms:预算比这紧,llama.cpp 赢;比这松,vLLM 赢。 按吞吐算,两条曲线在 C ≈ 8.3 换位。但生产上的并发由 SLO 反推,不能自由选, 所以横轴应该是"你能忍受多大的 TTFT"。
read_bytes 计数器相差 1.2%。llama.cpp 单流优势
2.61 ×
161.5 vs 61.9 tok/s(C=1)
vLLM 峰值优势
1.30 ×
1,387.8 vs 1,064.8 tok/s(C=64)
C=64 批处理效率
81 vs 25 %
同一条 roofline,前者是后者的 3.2 倍
llama.cpp KV 容量
2.18 ×
262,144 vs 120,064 token(标称)
| 指标 | 量的是什么 | 为什么这份报告需要它 |
|---|---|---|
| TTFT | 从发出请求到收到第一个 token | 本报告的选型分界线画在它的 p99 上。它是用户按下回车后等待的时间,SLO 通常也定在它的 p99 上。 |
| ITL | 相邻两个输出 token 的间隔 | 出字的流畅度。ITL 正常而 TTFT 爆炸,说明在排队;ITL 也慢,说明 decode 本身有问题。这是两种病。 |
| SLO | 事先定下的延迟承诺 | 例如"p99 TTFT ≤ 1 秒"。有了 SLO,"该开多大并发"才能算出来。 |
| MFU / MBU | 算力 / 带宽跑到峰值的百分之多少 | 用来判断瓶颈在哪堵墙上。decode 是访存密集(看 MBU),prefill 是算力密集(看 MFU)。 |
换个 SLO,结论就翻面。 "交叉点 C≈8"假设你能自由选择并发。但生产上的并发由 SLO 反推: 先定"p99 TTFT 不能超过 X",再看这张卡在这条线下最多能接多少并发。
吞吐 / 延迟帕累托前沿:横轴是你能忍受的 p99 TTFT
两条线在 p99 ≈ 730 ms 附近交叉:左边橙线在上(llama.cpp 赢), 右边蓝线在上(vLLM 赢)。每个点是一个并发档,越靠左上越好(同样延迟下吞吐更高)。 llama.cpp 的 C=32 点是反常的(见《llama-cpp-压测》的「反常」一节)。
| SLO | vLLM 并发 | vLLM 吞吐 | llama.cpp 并发 | llama.cpp 吞吐 | 谁赢 |
|---|---|---|---|---|---|
| p99 < 500 ms | 5.3 | 286 tok/s | 5.3 | 355 tok/s | llama.cpp +24% |
| p99 < 1000 ms | 11.1 | 523 | 10.3 | 460 | vLLM +14% |
| p99 < 2000 ms | 22.6 | 836 | 20.0 | 512 | vLLM +63% * |
| 不设 SLO(峰值) | 64 | 1,388 | 64 | 1,065 | vLLM +30% |
* "vLLM +63%" 应读作 vLLM 优势的上界。 llama.cpp 侧的 C=20.0 落在 C=16 与 C=32 之间,而 C=32 那一档反常塌陷 (444 tok/s,比它自己的 C=16 还低),所以插值偏低。 500 ms 那一行与 730 ms 的交叉点都只用到 C≤8,不受影响。
这张表是整份报告最该记住的东西
同一份硬件、同一个模型,结论在 500 ms 和 2000 ms 之间翻了个面。 任何"A 引擎比 B 引擎快 N 倍"的说法,不说清延迟约束就没有意义。
原因:SLO 越紧 → 允许的并发越低 → 越接近单流 → 量化的带宽红利占主导; SLO 越松 → 并发越高 → 越依赖批处理效率 → vLLM 的调度优势占主导。
什么时候该用 llama.cpp
在下面这些场景,llama.cpp 不是"凑合",是更优解: 单人 / 低并发(C ≤ 8)、边缘设备、显存吃紧但要长上下文、 延迟 SLO 非常紧(p99 < 500 ms)。 它单流快 2.6 倍,每一档的 p50 TTFT 都更低(p99 从 C=8 起反而更高),KV 容量是 vLLM 的 2 倍多。
什么时候该用 vLLM
适用场景:多租户在线服务、并发 > 10、要用 SLO 做容量承诺、 过载时需要抢占 / 排队而不是直接失败。 并发一过 8,批处理效率就成了唯一重要的事。 这一项 vLLM 是 llama.cpp 的 3 倍以上,C=16 到 C=64 一直保持在 3 倍以上。
下表汇总五份子报告里的优势项。粗体是这一行的赢家, 最后一列是数据出处。
| 比什么 | vLLM FP16 | llama.cpp Q4_K_M | 出处 |
|---|---|---|---|
| 单流 decode 步长 | 15.83 | 5.84(2.71×) | llama-cpp-压测 · 字节账 |
| 单流吞吐 | 61.9 tok/s | 161.5(2.61×) | llama-cpp-压测 · 交叉 |
| TTFT p50(各档) | 102 → 787 | 93 → 441 | llama-cpp-压测 · 数据 |
| C=64 峰值吞吐 | 1,387.8(1.30×) | 1,064.8 | llama-cpp-压测 · 交叉 |
| C=64 批处理效率 | 81% | 25% | llama-cpp-压测 · 摊薄 |
| 每多一条并发的成本 | 0.106 ms(地板 1.6×) | 0.468 ms(7.1×) | llama-cpp-压测 · 摊薄 |
| KV 池容量(标称) | 120,064 tok | 262,144(2.18×) | llama-cpp-压测 · 显存 |
| KV 分配方式 | 按 block 共享 | 每槽硬分配,用不完不能借 | llama-cpp-压测 · 显存 |
| 过载时的行为 | 排队 + 抢占,零失败 | 未测 | vllm-04-断崖 |
| 混批(chunked prefill) | 有,C=4 时仅 3.4% 停顿 | 无,C=4 已 50.7% 停顿 | llama-cpp-压测 · 波次 |
| 可观测性 | /metrics 全套计数器 | 只有日志里的自报速率 | 全部五份 |
| 容量可预测性 | 启动日志直接给上限 | 要自己算 -np × -c | vllm-03-KV的线性 · vllm-04-断崖 |
上表中,llama.cpp 赢的全是"单条请求"的项,vLLM 赢的全是"一批请求"的项。 背后是两条独立的机制。
decode 是访存密集的:生成一个 token 要把整个模型权重读一遍。 量化把字节压小,步长就按同样比例变短。
prefill 是算力密集的:一次前向处理上千个 token,权重读一次摊给所有 token。 瓶颈在 FLOPs,不在带宽;Q4_K 的解量化只会让 FLOPs 更多。
# 同一颗芯片,瓶颈随批量大小在两堵墙之间移动(vLLM 计数器实测) C=1 MBU 88.0% MFU 2.5% ← 贴着访存墙,算力几乎闲置 C=64 MBU 43.2% MFU 57.3% ← 反过来了,交点在 C≈44 # 长度扫描把并发钉死、只拉输入长度,走到了同一个地方 L=256 MBU 82.1% MFU 14.3% L=4096 MBU 43.6% MFU 53.1% # 真正的自变量既不是并发也不是长度,而是"一次迭代里参与计算的 token 总数" # 两条轴按 tok/iter 对齐后,MFU 逐点差 <0.7 pt、MBU <3.6 pt
推论:量化收益随负载形状变化
对话类负载(短输入长输出)量化收益大; RAG / 长文摘要(长输入短输出)收益小得多。 量化换来的是decode 速度和显存,不是 TTFT。 服务端量到的纯 prefill 是 llama.cpp 85.3 ms、vLLM 93.5 ms,只快 9%, 而且这 9% 来自实现细节,不是位宽。
批处理的原理:一个 decode 步把权重读一次,同时喂给 C 条序列。
C 越大,每个 token 分摊到的权重字节越少。
理论地板 (权重 + C × 单条KV) / 带宽 随 C 增长得非常慢。
用这个地板除以实测 ITL p50,就得到"批处理红利吃到了多少"。
roofline 达成率:两个引擎在同一条标尺上
两条地板之间的距离就是量化红利,而它是恒定的。 拉开差距的完全是"各自离自己的地板有多远":蓝线几乎是平的(89.0% → 81.0%), 橙线一路塌到 11%。达成率 = roofline 地板 ÷ 实测 ITL p50。
# 每多接一条并发,解码步长要多付多少 理论地板 单条 KV ÷ 带宽 = 0.066 ms / 条 vLLM (22.50 − 15.83) ÷ 63 = 0.106 ms ← 地板的 1.6 倍 llama.cpp (35.32 − 5.84) ÷ 63 = 0.468 ms ← 地板的 7.1 倍
llama.cpp 的批量 decode,更像是"把单流做了 C 遍"
llama.cpp 的边际成本 0.468 ms 是地板的 7.1 倍, 说明权重每步被重复读、重复解量化的成分很大。 如果批处理只多读 KV,边际成本应该贴着 0.066 ms;vLLM 的 0.106 说明它基本吃到了红利。
这解释了全部现象:C=1 时 vLLM 每步读的字节是 llama.cpp 的 3 倍,所以 llama.cpp 赢; C 变大后,vLLM 把自己的 14.14 GB 摊薄了 64 次,llama.cpp 几乎没摊薄自己的 4.68 GB,所以输。
直接证据在《llama-cpp-压测》里:CUDA graph 复用率从 99.2% 崩到 0%,
与达成率的塌陷同步发生。两者相关性极强,但要证明因果,还需要
-DGGML_CUDA_USE_GRAPHS=OFF 的对照实验。
容量是全篇唯一纯算术的部分,精确到不需要压测。 上面两条机制决定"跑多快",容量决定"能跑多少"。
# 每 token 的 KV 字节,完全由模型结构决定 2 (K,V) × 28 层 × 4 KV head × 128 dim × 2 B = 56.0 KiB GQA 让它比 MHA 小 7 倍 # 于是容量红线是一道除法 单请求占满 8,192 上下文 = 8192 × 56 KiB = 448 MiB vLLM 池子 6.41 GiB ÷ 448 MiB = 14.65 条 ↑ 启动日志自己就打出来了:Maximum concurrency 14.66x # 压爆那一轮的实测:驻留均值 15.65 条,比预告高 5% # (因为请求刚进来只占 6144 token,要解码 2048 步才涨到 8192) # 第一轮同样是 11.17 vs 10.60 —— 两台机器,同一个 +5% 偏差率
24 GB 卡上的显存去向:量化红利兑现在哪
量化省下 10.1 GiB 权重,换来 2.18 倍 KV 容量。
但这是标称值:llama.cpp 的槽位上下文是硬分配的,vLLM 的 KV 池按 block 共享。
vLLM 四段来自启动日志原文。llama.cpp 的总量 19,089 MiB 是 nvidia-smi 实测;
权重加 KV 算出来是 18.36 GiB,与实测 18.64 相差 1.5%,账对得上。
把序列拉到 6144/2048、并发设为 64,KV 池会超卖 4.4 倍: 单请求满上下文要 448 MiB,64 条要 28.0 GiB,而 KV 池只有 6.41 GiB。 下表两组都是 C=64,只改了序列长度:
| 指标 | C=64 · 1024/256 | C=64 · 6144/2048 | 倍数 |
|---|---|---|---|
| KV 需求 / KV 池 | 4.4 / 6.41 GiB(够) | 28.0 / 6.41 GiB(爆) | — |
| 实际驻留 Running | 59.9 / 64 | 15.65 / 64 | 0.26× |
| 输出吞吐 | 1,387.8 tok/s | 532.4 | 0.38× |
| TTFT p50 | 0.79 s | 169.0 s | 215× |
| ITL p50 | 22.50 ms | 23.39 ms | 1.04× |
| 抢占次数 | 0 | 38 | — |
| 失败请求 | 0 | 0 | — |
压爆不等于崩溃 —— 这是 PagedAttention 的价值
PagedAttention 把硬失败变成了软降级。 超卖 4.4 倍、KV 顶到 100%、抢占 38 次,结果是零失败、零错误、输出 token 一个不少, 代价只是排队 169 秒。
ITL 和 TTFT 的组合本身就能用来诊断:
ITL 正常 + TTFT 爆炸 = 容量不够,在排队
(加卡、减并发、缩短 max_model_len 都能解决);
ITL 也慢 = decode 本身有问题(要查 batch 配置、kernel、量化实现)。
前者是《vllm-04-断崖》里 vLLM 的病,后者是《llama-cpp-压测》里 llama.cpp 的病。两种病,两种药。
Maximum concurrency for N tokens per request: M
是用 KV 池 ÷ max_model_len 算的,两轮实测都只比它高 5%。--max-model-len 比加卡便宜。
容量红线与它成反比:从 8192 降到 4096,可驻留请求数直接翻倍。
多数业务的真实上下文远小于配置值。ITL p50(decode 是否健康)、waiting 队列长度(容量够不够)、
num_preemptions_total(有没有在做无用重算)。
TTFT 是结果不是原因,看它诊断不出问题。
不要用 p99 ITL 找抢占:它看不见抢占。38 次抢占混在 26 万个间隔里只占 0.0145%,
落在 p99.99 上。--max-num-seqs 设成真实容量。
注意:这个值跟着机器走。
按本轮日志的 14.66x 设成 14,理论上抢占降到 0、吞吐不变、ITL 尾部更干净。
本轮实际设了 128:调度器放进 18–19 条,再靠抢占压回 15–16 条,38 次抢占全是这么来的。sweep.sh 打两个端口,并显式写死
--temperature 0、--random-range-ratio 0、每档独立 seed。--ignore-eos 在两边行为一致;
"客户端一致"不等于"工作量一致"。
本轮 vLLM 七档每条精确 256 个输出 token,llama.cpp 是 250–256 不等,合计少 1.35%。
吞吐是"输出 token ÷ 墙钟",所以这张表把 llama.cpp 低估了约 1.35%。
这对 llama.cpp 不利,但远小于 C≥16 时 20%–57% 的差距,不改变任何结论。-DGGML_CUDA_USE_GRAPHS=OFF 的对照实验。
本报告的交叉点用 C=8/16 算,批处理效率引用 C=64,都绕开了它;
唯一受影响的是 SLO 表 <2000 ms 那一行,已在上面标注。iteration_tokens_total 精确等于理论下限
128 × 8192 = 1,048,576,一个 token 都没多记;
而 FLOPs 计数器多出 29%。两台机器都复现了,不是偶然。
在源码层面确认之前,涉及抢占的场景下 MFU / 算术强度都应以 FLOPs 计数器为准。-ctk q8_0 -ctv q8_0 能把它砍一半。--kv-cache-memory
显式设三档,做真正的对照。-c 没有调平。
本轮给它开了 -np 64 -c 262144,KV 池按满配分配,
量化省下的显存又被吃回去了。要设成等效容量再测一次,才能看到 Q4 真正的显存红利。-b/-ub 对停顿的影响。一个被证伪的假设,也记在这里
假设是"第一轮的 repetition_penalty 1.05 让 vLLM 偏悲观",这是第二轮重跑 vLLM 的动机。
结果七个档位的差异全在 ±1.2% 以内,方向也不一致。这是噪声,假设错了。
排除一个假设也是结果:重跑把"采样参数不同构"从怀疑名单里划掉了,
剩下的差异只能归因于引擎本身。详见《vllm-02-TTFT解剖》。
| 报告 | 负载 | 回答的问题 |
|---|---|---|
| vllm-01-Qwen2.5-7B并发曲线 | 1024/256 · C=1→64 | 吞吐涨到多少才开始拿延迟去换?拐点在 C=8~16,C=64 时扩展效率只剩 35.1%。含两堵墙(MBU/MFU 交点 C≈44)。 |
| vllm-02-TTFT解剖 | 1024/256 · C=1→64 | TTFT 涨在哪一段?排队、prefill、前端三段分解,以及功耗墙和混批比例。含两轮复现性对照。 |
| vllm-03-KV的线性 | C=16 · L=256→4096 | KV 占用是不是线性的?R²=0.99993,56 KiB/token 两次独立验证。含 roofline 换边与算术强度。 |
| vllm-04-断崖 | 6144/2048 · C=64 | 越过 KV 容量会怎样?超卖 4.4 倍、驻留只剩 16 条、抢占 38 次,但零失败。含抢占负反馈控制环。 |
| llama-cpp-压测 | 1024/256 · C=1→64 · 双引擎 | 量化省下了什么?单流 2.71×、批处理效率 81% vs 25%、CUDA graph 复用率崩塌。含 C=32 反常。 |