Engine Comparison · 第五轮 · RTX 4090
同一张 4090、同一个 bench 客户端、同样的 1024 / 256,只换引擎: llama.cpp 的 Q4_K_M 在单流上快 2.70 倍,在并发 32 上被 vLLM FP16 反超 16%。 交叉点落在 并发 ≈ 20。这一轮真正值钱的不是"谁赢", 而是把"为什么单流赢"和"为什么批量输"都算到了字节和毫秒上。
Q4_K_M 把每步要读的权重从 15.23 GB 压到 4.68 GB —— 3.25 倍。 单流解码是纯访存,这 3.25 倍几乎原样兑现成 2.70 倍的速度(差的那点是解量化的算力开销,后面对得上账)。 但把并发拉到 32 之后,vLLM 的解码步长只涨了 26%,llama.cpp 涨了 3.6 倍 —— 权重读取被摊薄这件事,llama.cpp 基本没吃到。
同一份工作负载,只换引擎
C=1: 2.54× 领先 → C=32: 0.86× 落后
输出吞吐 156 → 569 → 890 tok/s(llama.cpp)对 61 → 411 → 1034 tok/s(vLLM)。 llama.cpp 从 C=1 到 C=32 只放大了 5.7 倍,vLLM 放大了 16.8 倍。 两条曲线在 C ≈ 20 相交。
单流解码步长
5.88 ms
vLLM 15.92 ms。字节账预测 2.72×,实测 2.70×
C=32 批处理效率
32 %
vLLM 86%。同一条 roofline,差 2.7 倍
每多一条并发的边际成本
0.49 ms/tok
vLLM 0.13 ms,理论地板 0.066 ms
C=32 失败请求
8 / 320
vLLM 四轮共 1332 条,零失败
这一轮的三条数据瑕疵,先说在前面
① llama.cpp 的 prompt cache 默认是开的,C=32 有 23% 的请求 prefill 被完全跳过,
而 vLLM 侧显式关了 prefix caching —— 对比对 llama.cpp 偏有利;
② C=32 有 8 条请求失败,服务端日志里找不到它们;
③ llama.cpp 的 nvidia-smi 显存没采到,显存那一节是推算值。
详见第 7 节,结论已按"扣掉这些便宜之后依然成立"来写。
第一轮已经把 vLLM 的并发曲线从 1 扫到 64,这一轮在 1 / 8 / 32 三个点上叠了 llama.cpp。 两条线不是平移关系,是斜率不同 —— llama.cpp 起点高、爬得慢,vLLM 起点低、爬得快。
输出吞吐 vs 并发(双对数)
交叉点由 C=8 与 C=32 两点在 log–log 空间线性插值得到:llama/vLLM 的吞吐比从 1.385 降到 0.861, 过 1 的位置是 C = 20.7。这个数字应该当量级看(±几),不是精确阈值。
| 指标 | C=1 vLLM | C=1 llama | 倍数 | C=8 vLLM | C=8 llama | 倍数 | C=32 vLLM | C=32 llama | 倍数 |
|---|---|---|---|---|---|---|---|---|---|
| 输出吞吐 tok/s | 61.5 | 156.4 | 2.54× | 410.6 | 568.8 | 1.39× | 1033.5 | 890.1 | 0.86× |
| 单请求生成 tok/s | 62.8 | 169.4 | 2.70× | 54.6 | 80.6 | 1.48× | 35.2 | 31.8 | 0.90× |
| TTFT p50 ms | 105 | 133 | 0.79× | 299 | 419 | 0.71× | 569 | 790 | 0.72× |
| TTFT p99 ms | 111 | 138 | 0.81× | 729 | 1101 | 0.66× | 2853 | 3789 | 0.75× |
| ITL p50 ms(纯解码步) | 15.92 | 5.88 | 2.71× | 16.74 | 10.12 | 1.65× | 19.99 | 21.02 | 0.95× |
| ITL p99 ms(含停顿) | 16.52 | 6.55 | 2.52× | 99.6 | 31.2 | 3.19× | 190.3 | 369.9 | 0.51× |
| 端到端 p50 ms | 4163 | 1637 | 2.54× | 4984 | 3691 | 1.35× | 7903 | 9774 | 0.81× |
| 失败 / 总数 | 0/40 | 0/10 | — | 0/120 | 0/80 | — | 0/320 | 8/320 | — |
延迟三行的"倍数"按 vLLM ÷ llama 计算,所以小于 1 表示 llama.cpp 更慢。 注意 ITL p99 在 C=8 时 llama.cpp 反而好 3.2 倍,到 C=32 就倒过来差 2 倍 —— 这条线索留到第 4 节。
0.2 节的说法是"decode 是访存密集,时间 ≈ 要读的字节 ÷ 带宽"。 这一轮是第一次能用两种权重精度去验证它 —— 而且验得相当准。
# 一次解码步要读的字节 权重 FP16 7.62e9 × 2 B = 15.23 GB Q4_K_M = 4.68 GB → 3.25 倍 KV 平均上下文 1152 tok × 56 KiB = 0.066 GB / 条 # C=1 的理论地板(4090 峰值带宽 1008 GB/s) vLLM (15.23 + 0.066) / 1008 = 15.17 ms 实测 15.92 → 效率 95.3% llama ( 4.68 + 0.066) / 1008 = 4.71 ms 实测 5.88 → 效率 80.0% # 把两笔账乘起来 预测加速 = 3.25 × (80.0% / 95.3%) = 2.72× 实测加速 = 15.92 / 5.88 = 2.70× ← 差 0.7%
Q4 在单流上"少读 3.25 倍字节",但只跑出 2.70 倍
差的那 16% 是 解量化的算力开销:Q4_K 的权重必须在片上还原成浮点才能进矩阵乘, 这部分算力在 C=1 时无处摊薄,直接体现为带宽效率从 95% 掉到 80%。 这不是 llama.cpp 写得差,是量化本身的代价 —— 净收益仍然是 2.7 倍,非常划算。
同一份 1024 token 的提示词,llama.cpp 在 C=1 下 prefill 用了 85.7 ms(12,024 tok/s), vLLM 的整个 TTFT(含调度和网络)是 104.9 ms。 两者基本打平,而 llama.cpp 少读了 3.25 倍权重却没换来任何优势 —— 因为 prefill 是算力密集:1024 个 token 一起过网络,权重读一次就够摊到 1024 个 token 头上, 瓶颈早就从带宽换到了 FLOPs,而 Q4 的解量化只会让 FLOPs 更多。
这条结论值得单独记住:量化换来的是解码速度和显存,不是首字延迟。 对话类负载(短输入长输出)量化收益大;RAG / 长文摘要(长输入短输出)收益小得多。
这是本轮的核心图。批处理为什么能提吞吐?因为一个解码步把权重读一次,可以同时喂给 C 条序列 ——
C 越大,每 token 分摊到的权重字节越少。理论地板 (W + C×KV) / BW 随 C 增长得非常慢:
vLLM 从 15.17 涨到 17.21 ms(+13%),llama.cpp 从 4.71 涨到 6.74 ms(+43%)。
实测呢:vLLM 15.92 → 19.99(+26%),llama.cpp 5.88 → 21.02(+257%)。
解码步长:理论地板 vs 实测(ITL p50)
用 ITL 的 p50 而不是 TPOT 均值,是为了剥掉 prefill 停顿,只看纯解码步。 两条虚线几乎是平的 —— 理论上批处理几乎不要钱。橙色实线离它自己的虚线越拉越远,就是红利没吃到。
| C | vLLM 地板 ms | vLLM 实测 ms | 效率 | llama 地板 ms | llama 实测 ms | 效率 |
|---|---|---|---|---|---|---|
| 1 | 15.17 | 15.92 | 95.3% | 4.71 | 5.88 | 80.0% |
| 8 | 15.63 | 16.74 | 93.4% | 5.17 | 10.12 | 51.1% |
| 32 | 17.21 | 19.99 | 86.1% | 6.74 | 21.02 | 32.1% |
# 解码步长对 C 求斜率(用 C=1 与 C=32 两点) 理论地板 KV 0.066 GB / 1008 GB/s = 0.066 ms / 条 / token vLLM (19.99 - 15.92) / 31 = 0.131 ms / 条 / token ← 地板的 2.0 倍 llama.cpp (21.02 - 5.88) / 31 = 0.488 ms / 条 / token ← 地板的 7.4 倍
llama.cpp 的批量解码,更像是"把 GEMV 做了 32 遍"
如果批处理真的只多读 KV,边际成本应该贴着 0.066 ms。vLLM 的 0.131 ms 说明它的 batched GEMM 基本吃到了红利;llama.cpp 的 0.488 ms 是地板的 7.4 倍,意味着权重每步被重复读/重复解量化的成分很大 —— Q4_K 的矩阵乘在 batch>1 时没有像 FP16 GEMM 那样把权重复用起来。
这解释了全部现象:C=1 时权重字节少 3.25 倍 → 赢;C 变大后 vLLM 把自己那 15.23 GB 摊薄了 32 倍, llama.cpp 却几乎没摊薄自己的 4.68 GB → 输。省字节是一次性红利,摊薄字节是可复利的。
把服务端日志里 312 条 slot release 按时间聚类,得到的不是均匀分布,
而是 11 个大小恰好为 32 的波。前两波的 32 条请求在 10 毫秒内全部结束 ——
它们是被当成一个同步批次跑完的。这跟 continuous batching 的行为完全不同。
llama.cpp C=32:活跃 slot 数与 11 个释放波
三处掉到 1 的谷不是采样噪声:那一秒里 32 条同时 release、新的 32 条还没 launch, 恰好被采样点抓到。这正是"同步批次"的指纹。
| 波次 | 请求数 | 起点 s | 波内跨度 s | 与上波间隔 s | 备注 |
|---|---|---|---|---|---|
| 1 | 32 | 6.2 | 0.01 | — | prompt cache 全中,prefill ≈ 0 |
| 2 | 32 | 13.4 | 0.01 | 7.2 | 仍大量命中缓存 |
| 3 | 1 | 15.5 | 0.00 | 2.1 | 被取消的那条(见第 7 节) |
| 4 | 32 | 22.5 | 3.43 | 9.1 | 缓存耗尽,开始真 prefill |
| 5–10 | 32 ×6 | 32.1 … 81.2 | 3.7–4.4 | 9.8 | 稳态:周期锁死在 9.8 s |
| 11 | 23 | 88.9 | 0.22 | 7.7 | 收尾,不足一整批 |
# 一波 = 32 条 × 256 token 纯解码 256 tok ÷ 28.2 tok/s(每 slot 稳态)= 9.08 s prefill 32 × 1024 = 32,768 token,分批塞进去 ≈ 0.7 s 合计 ≈ 9.8 s ← 实测周期 9.78 s 一波产出 32 × 256 = 8,192 token ÷ 9.8 s = 836 tok/s ← 实测 890(含前两波缓存红利)
为什么会退化成同步批次
本轮所有请求形状完全一样(1024 in / 256 out),开测时 32 条同时到达,于是 32 个 slot 同步启动、同步结束; bench 一收到完成就立刻补发 32 条,再同步启动 —— 系统被锁进了一个自持的波形。 llama.cpp 是支持 continuous batching 的,但它没有 chunked prefill: 新请求的 prefill 会整块插进迭代里,把正在解码的 slot 全部堵住。所以就算请求长度不一致, 它也只能在"堵一下、放一批"之间摆动,而不像 vLLM 那样把 prefill 切片和 decode 混在同一个 step 里。
ITL p50 TPOT 均值 停顿占比 llama.cpp C=8 10.12 ms 12.40 ms +22.6% llama.cpp C=32 21.02 ms 31.40 ms +49.4% vLLM C=8 16.74 ms 18.32 ms +9.4% vLLM C=32 19.99 ms 28.37 ms +41.9% # p99 ITL —— 一次停顿有多长 llama.cpp C=32 369.9 ms vLLM C=32 190.3 ms
ITL 的中位数是"没被打断时的解码步长",TPOT 均值把停顿也摊了进去。 llama.cpp 在 C=32 上有 49% 的解码时间是在等别人的 prefill,单次最长停顿 370 ms —— 是 vLLM(190 ms,已开 chunked prefill)的 1.9 倍。
5.5 节的预期里有一条:"量化省下的显存换成了 KV cache 空间"。
本轮没有兑现 —— 不是因为不成立,是因为 -c 65536 把配置写小了。
llama.cpp 只开了 65,536 token 的 KV,比 vLLM 的 86,896 还少,剩下约 15 GiB 显存全程闲置。
24 GB 卡上的显存去向
vLLM 的四段来自启动日志原文。llama.cpp 的三段是推算:
权重取 GGUF 文件大小,KV 取 32 slot × 2048 tok × 56 KiB,其他项按 0.6 GiB 估。
本轮忘了执行 5.3 节要求的 nvidia-smi --query-gpu=memory.used,这格数据是缺的。
# KV 每 token 的字节数 —— 两个引擎完全一致,三处独立验证 公式 2(K,V) × 28 层 × 4 KV头 × 128 dim × 2 B = 56 KiB vLLM 4.64 GiB ÷ 86,896 tok = 56.0 KiB llama 69.961 MiB ÷ 1,279 tok(prompt cache 条目)= 56.0 KiB # 两边的 KV 池 vLLM 86,896 tok · 共享池(PagedAttention,谁需要谁拿) llama 65,536 tok · 硬切成 32 份(n_ctx_slot = 2048,kv_unified = false)
下一轮把 -c 拉满,这条预期才算真正测过
按 56 KiB/token 算,把 llama.cpp 的 KV 开到 -c 262144 要 14 GiB, 加上 4.4 GiB 权重刚好装进 24 GB —— 那才是"量化把显存换成 KV 容量"的样子: 262k token 的池子,是 vLLM FP16 的 3.0 倍。 本轮的 65,536 只够 32 条 × 2048,连 vLLM 都比不过,这个对比等于没做。
另一处不对等:llama.cpp 的 slot 上下文是硬分配的,
设了 -np 32 -c 65536 就是每条固定 2048,一条请求用不完也不能借给别人;
vLLM 的 86,896 是一个按 block 分配的共享池。同样的 token 数,vLLM 的利用率天然更高。
这一轮的数据不是干净的。三个问题,一个偏向 llama.cpp,一个是它的真实缺陷,一个是采集漏了。 好消息是三个都不改变主结论 —— 但博文里必须写出来,不然就是选择性报告。
# 服务端日志(原文) slot get_availabl: id 31 | selected slot by LCP similarity, f_sim_best = 1.000 slot print_timing: id 24 | prompt eval time = 24.54 ms / 1 tokens ← 应该是 1024 srv alloc: - making room for prompt cache entry, removing oldest entry (69.961 MiB) # 各轮的 prefill 命中率 C=1 0 / 10 0.0% C=8 10 / 80 12.5% C=32 72 / 312 23.1% 命中的 prefill 均值 55 ms,未命中 291 ms
成因很清楚:三轮 bench 都用 --seed 42 + --dataset-name random,
C=8 的前 10 条提示词和 C=1 的完全相同,C=32 的前 90 条和前两轮相同。
llama-server 的 prompt cache(默认 8 GiB 主机内存)跨轮把它们留下了,于是 90 条里有 72 条直接命中。
而 vLLM 侧是显式 --no-enable-prefix-caching 跑的。
这个偏差对 llama.cpp 有利,而它依然输了
白拿 23% 的 prefill,llama.cpp 的 TTFT 仍然全面落后(p50 790 vs 569,p99 3789 vs 2853),
吞吐仍然低 16%。扣掉这份便宜只会让差距更大,所以主结论方向上是稳的 ——
但具体数值偏乐观,不能当精确值引用。修法:llama-server 加 --no-prompt-cache(或每轮重启),
并给每轮换不同的 --seed。
# 对账 bench 报告 completed 312 · failed 8 · 总数 320 服务端 launch_slot_ c1 10 + c8 80 + c32 312 = 402 次 → 那 8 条从来没拿到 slot # 唯一一条服务端主动放弃的记录(它反而被算成了 completed) W common_chat_peg_parse: unparsed Content-only output: �� W srv stop: cancel task, id_task = 5800 I slot release: id 0 | task 5800 | n_tokens = 1026 ← 只生成了 2 个 token
最可能的解释:--dataset-name random 造出来的提示词是随机 token id,
反解成文本后可能带上非法 UTF-8 字节序列。生成侧已经抓到一次现行(task 5800 因为输出无法解析被取消),
请求侧同样的问题会让 llama-server 在建任务之前就拒掉 —— 这正好解释"没有 slot 记录"。
C=32 的 230 条新提示词里失败 8 条 = 3.5%,而前 90 条(前两轮验证过能跑)一条没失败,与这个解释相符。
这一条没有被证实,因为 bench 的终端输出没存
vllm bench serve 会为每条失败请求打印错误字符串,本轮只保存了 JSON,那部分证据丢了。
下次必须 2>&1 | tee 一份。验证方法:换 --dataset-name sharegpt(真实文本)重跑 C=32,
如果失败归零,假设成立。
不管原因是什么,2.5% 的失败率本身就是结论的一部分: 同样的客户端打 vLLM,四轮 1332 条请求零失败。
--ignore-eos 这条倒是验过了:服务端日志里 311 条请求全部 eval time = ... / 256 tokens,
一个不多一个不少。bench 报的 254.1–255.9 是反解码再切词的损耗,不是提前停了。
| 预期 | 结果 | 实测 |
|---|---|---|
| 并发=1 时 llama.cpp 可能更快(权重只有 1/3,访存量小) | 成立 | 快 2.70×,且能用字节账算到 0.7% 误差以内 |
| 并发一高 vLLM 大幅反超(continuous batching + PagedAttention) | 成立 | C=32 反超 16%,交叉点 C≈20;机制查清了是"权重没被摊薄",不只是调度 |
| 量化省下的显存换成了 KV cache 空间 | 未验证 | -c 65536 只开了 65,536 tok,比 vLLM 的 86,896 还少,15 GiB 显存闲置 |
| (计划外)llama.cpp 的批处理调度弱得多 | 部分成立 | 调度确实退化成 11 个同步波;但真正的瓶颈是 Q4_K 批量矩阵乘本身,不只是调度 |
写博文时这句话不能写成"vLLM 赢了"
正确的一句是:Q4 把单流延迟砍到 1/2.7,代价是批处理红利几乎吃不到; 并发超过 ~20 之后,FP16 + 好调度反超。 本地单人用、代码补全、边缘设备 → llama.cpp Q4 明显更合适; 多租户在线服务 → vLLM。这不是两个实现的高下,是两种优化目标。
| 引擎 / C | 请求 | 失败 | 耗时 s | 输出 tok/s | 总 tok/s | TTFT p50 | TTFT p90 | TTFT p99 | TPOT 均值 | ITL p50 | ITL p99 | E2E p50 | E2E p99 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| vLLM · 1 | 40 | 0 | 166.5 | 61.5 | 307.4 | 105 | 108 | 111 | 15.92 | 15.92 | 16.5 | 4163 | 4171 |
| vLLM · 2 | 60 | 0 | 130.1 | 118.1 | 590.5 | — | — | 200 | 16.40 | 16.40 | 16.8 | — | 4342 |
| vLLM · 4 | 80 | 0 | 91.3 | 224.2 | 1121.1 | — | — | 382 | 16.98 | 16.98 | 17.4 | — | 4595 |
| vLLM · 8 | 120 | 0 | 74.8 | 410.6 | 2053.2 | 299 | 474 | 729 | 18.32 | 16.74 | 99.6 | 4984 | 5163 |
| vLLM · 16 | 200 | 0 | 75.0 | 682.6 | 3412.8 | — | — | 1390 | 21.01 | — | 179.2 | — | 6563 |
| vLLM · 32 | 320 | 0 | 79.3 | 1033.5 | 5167.6 | 569 | 760 | 2853 | 28.37 | 19.99 | 190.3 | 7903 | 10072 |
| vLLM · 64 | 512 | 0 | 93.3 | 1404.7 | 7023.3 | 782 | 1396 | 5773 | 41.71 | 22.74 | 201.0 | 11701 | 16537 |
| llama.cpp · 1 | 10 | 0 | 16.4 | 156.4 | 782.0 | 133 | 137 | 138 | 5.90 | 5.88 | 6.6 | 1637 | 1642 |
| llama.cpp · 8 | 80 | 0 | 35.7 | 568.8 | 2860.8 | 419 | 792 | 1101 | 12.40 | 10.12 | 31.2 | 3691 | 4182 |
| llama.cpp · 32 | 320 | 8 | 89.2 | 890.1 | 4471.5 | 790 | 1868 | 3789 | 31.40 | 21.02 | 369.9 | 9774 | 12116 |
| 项 | vLLM FP16 | llama.cpp Q4_K_M | 倍数 |
|---|---|---|---|
| 权重 | 15.23 GB | 4.68 GB | 3.25× |
| 总显存占用 | 21.51 GiB(启动日志) | ≈ 8.5 GiB(推算,未采集) | ≈ 2.5× |
| KV 池 | 86,896 tok · 共享 | 65,536 tok · 32 份硬切 | 0.75× |
| 并发=1 输出 tok/s | 61.5 | 156.4 | 2.54× |
| 并发=8 输出 tok/s | 410.6 | 568.8 | 1.39× |
| 并发=32 输出 tok/s | 1033.5 | 890.1 | 0.86× |
| 并发=32 P99 TTFT | 2853 ms | 3789 ms | 0.75× |
| 最大可承载并发(不排队) | ~10.6 @8192 / ~84 @1024 | 32(= -np,硬上限) | — |
| 失败率 | 0 / 1332(四轮累计) | 8 / 410 | — |
nvidia-smi --query-gpu=memory.used,把 5.5 表里那个 ≈ 去掉。--no-prompt-cache,每轮换 seed。
当前的 TTFT / 吞吐数字对 llama.cpp 偏乐观 23%,得知道扣掉之后交叉点挪到哪。tee 下来。
要么坐实"随机 token 造出非法 UTF-8",要么发现别的问题。再往后一步:把 KV 也量化
本轮两边的 KV 都是 FP16(56 KiB/token),这是刻意控制的变量。
llama.cpp 的 -ctk q8_0 -ctv q8_0 能把它砍一半 —— 那会同时影响 KV 容量和解码带宽,
是量化专题里比权重量化更有意思的一个旋钮:权重量化的红利随 batch 变小,KV 量化的红利随 batch 变大。