Engine Comparison · 第五轮 · RTX 4090

交叉点

同一张 4090、同一个 bench 客户端、同样的 1024 / 256,只换引擎: llama.cpp 的 Q4_K_M 在单流上快 2.70 倍,在并发 32 上被 vLLM FP16 反超 16%。 交叉点落在 并发 ≈ 20。这一轮真正值钱的不是"谁赢", 而是把"为什么单流赢"和"为什么批量输"都算到了字节和毫秒上。

GPU
RTX 4090 · 24 GB
模型
Qwen2.5-7B-Instruct
A 组
vLLM 0.27.1 · FP16
B 组
llama.cpp · Q4_K_M
输入 / 输出
1024 / 256 tok
并发点
C = 1 / 8 / 32
客户端
vllm bench serve
交叉点
C ≈ 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 节,结论已按"扣掉这些便宜之后依然成立"来写。

交叉两条吞吐曲线在 C≈20 换位

第一轮已经把 vLLM 的并发曲线从 1 扫到 64,这一轮在 1 / 8 / 32 三个点上叠了 llama.cpp。 两条线不是平移关系,是斜率不同 —— llama.cpp 起点高、爬得慢,vLLM 起点低、爬得快。

Figure 1

输出吞吐 vs 并发(双对数)

vLLM FP16(第一轮全扫描) llama.cpp Q4_K_M(本轮 3 点) 交叉点 C ≈ 20.7

交叉点由 C=8 与 C=32 两点在 log–log 空间线性插值得到:llama/vLLM 的吞吐比从 1.385 降到 0.861, 过 1 的位置是 C = 20.7。这个数字应该当量级看(±几),不是精确阈值。

三个并发点的正面对照。倍数一列 >1 表示 llama.cpp 更好。
指标C=1 vLLMC=1 llama倍数C=8 vLLMC=8 llama倍数C=32 vLLMC=32 llama倍数
输出吞吐 tok/s61.5156.42.54×410.6568.81.39×1033.5890.10.86×
单请求生成 tok/s62.8169.42.70×54.680.61.48×35.231.80.90×
TTFT p50 ms1051330.79×2994190.71×5697900.72×
TTFT p99 ms1111380.81×72911010.66×285337890.75×
ITL p50 ms(纯解码步)15.925.882.71×16.7410.121.65×19.9921.020.95×
ITL p99 ms(含停顿)16.526.552.52×99.631.23.19×190.3369.90.51×
端到端 p50 ms416316372.54×498436911.35×790397740.81×
失败 / 总数0/400/10—0/1200/80—0/3208/320—

延迟三行的"倍数"按 vLLM ÷ llama 计算,所以小于 1 表示 llama.cpp 更慢。 注意 ITL p99 在 C=8 时 llama.cpp 反而好 3.2 倍,到 C=32 就倒过来差 2 倍 —— 这条线索留到第 4 节。

字节账单流那 2.70 倍,能算到小数点后两位

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 倍,非常划算。

prefill 这一头,量化一点忙都帮不上

同一份 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 / 长文摘要(长输入短输出)收益小得多。

摊薄批处理红利,vLLM 吃到了 86%,llama.cpp 只吃到 32%

这是本轮的核心图。批处理为什么能提吞吐?因为一个解码步把权重读一次,可以同时喂给 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%)。

Figure 2

解码步长:理论地板 vs 实测(ITL p50)

vLLM 实测 vLLM 地板 llama.cpp 实测 llama.cpp 地板

用 ITL 的 p50 而不是 TPOT 均值,是为了剥掉 prefill 停顿,只看纯解码步。 两条虚线几乎是平的 —— 理论上批处理几乎不要钱。橙色实线离它自己的虚线越拉越远,就是红利没吃到。

同一条 roofline 下的效率。llama.cpp 的地板更低,但实测反而先追平、再落后。
CvLLM 地板 msvLLM 实测 ms效率llama 地板 msllama 实测 ms效率
115.1715.9295.3%4.715.8880.0%
815.6316.7493.4%5.1710.1251.1%
3217.2119.9986.1%6.7421.0232.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 → 输。省字节是一次性红利,摊薄字节是可复利的。

波次11 个整整齐齐的 32:llama.cpp 没在做 continuous batching

把服务端日志里 312 条 slot release 按时间聚类,得到的不是均匀分布, 而是 11 个大小恰好为 32 的波。前两波的 32 条请求在 10 毫秒内全部结束 —— 它们是被当成一个同步批次跑完的。这跟 continuous batching 的行为完全不同。

Figure 3

llama.cpp C=32:活跃 slot 数与 11 个释放波

活跃 slot 数(1 Hz 采样) 释放波(波内 32 条同时结束) prompt cache 命中的请求(前 15 秒)

三处掉到 1 的谷不是采样噪声:那一秒里 32 条同时 release、新的 32 条还没 launch, 恰好被采样点抓到。这正是"同步批次"的指纹。

释放波聚类(相邻 release 间隔 > 0.5 s 断开)。稳态周期 9.78 秒。
波次请求数起点 s波内跨度 s与上波间隔 s备注
1326.20.01—prompt cache 全中,prefill ≈ 0
23213.40.017.2仍大量命中缓存
3115.50.002.1被取消的那条(见第 7 节)
43222.53.439.1缓存耗尽,开始真 prefill
5–1032 ×632.1 … 81.23.7–4.49.8稳态:周期锁死在 9.8 s
112388.90.227.7收尾,不足一整批

9.8 秒一波,这个数能直接对上

# 一波 = 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 的中位数和均值分家

                       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 倍。

显存Q4 省下的 15 GiB,这一轮一点没用上

5.5 节的预期里有一条:"量化省下的显存换成了 KV cache 空间"。 本轮没有兑现 —— 不是因为不成立,是因为 -c 65536 把配置写小了。 llama.cpp 只开了 65,536 token 的 KV,比 vLLM 的 86,896 还少,剩下约 15 GiB 显存全程闲置。

Figure 4

24 GB 卡上的显存去向

权重 KV cache 激活 / CUDA graph / 其他 闲置

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,一个是它的真实缺陷,一个是采集漏了。 好消息是三个都不改变主结论 —— 但博文里必须写出来,不然就是选择性报告。

① prompt cache 默认开着,C=32 有 23% 的请求白拿了 prefill

# 服务端日志(原文)
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。

② C=32 掉了 8 条请求,服务端日志里找不到它们

# 对账
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 是反解码再切词的损耗,不是提前停了。

核对5.5 节写下的三条预期,对了两条半

动手前写的预期 vs 实际测到的。第三条不是错,是这轮的配置没给它机会。
预期结果实测
并发=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。这不是两个实现的高下,是两种优化目标。

数据完整测量结果

vLLM 行取自第一轮扫描(8/15,1024/256,prefix caching 关);llama.cpp 行为本轮(8/24)。
引擎 / C请求失败耗时 s输出 tok/s总 tok/sTTFT p50TTFT p90TTFT p99TPOT 均值ITL p50ITL p99E2E p50E2E p99
vLLM · 1400166.561.5307.410510811115.9215.9216.541634171
vLLM · 2600130.1118.1590.5——20016.4016.4016.8—4342
vLLM · 480091.3224.21121.1——38216.9816.9817.4—4595
vLLM · 8120074.8410.62053.229947472918.3216.7499.649845163
vLLM · 16200075.0682.63412.8——139021.01—179.2—6563
vLLM · 32320079.31033.55167.6569760285328.3719.99190.3790310072
vLLM · 64512093.31404.77023.37821396577341.7122.74201.01170116537
llama.cpp · 110016.4156.4782.01331371385.905.886.616371642
llama.cpp · 880035.7568.82860.8419792110112.4010.1231.236914182
llama.cpp · 32320889.2890.14471.57901868378931.4021.02369.9977412116
5.5 节那张对比表,填完的版本。带 ≈ 的是推算值。
项vLLM FP16llama.cpp Q4_K_M倍数
权重15.23 GB4.68 GB3.25×
总显存占用21.51 GiB(启动日志)≈ 8.5 GiB(推算,未采集)≈ 2.5×
KV 池86,896 tok · 共享65,536 tok · 32 份硬切0.75×
并发=1 输出 tok/s61.5156.42.54×
并发=8 输出 tok/s410.6568.81.39×
并发=32 输出 tok/s1033.5890.10.86×
并发=32 P99 TTFT2853 ms3789 ms0.75×
最大可承载并发(不排队)~10.6 @8192 / ~84 @102432(= -np,硬上限)—
失败率0 / 1332(四轮累计)8 / 410—

下一步这轮之后该补的四件事

再往后一步:把 KV 也量化

本轮两边的 KV 都是 FP16(56 KiB/token),这是刻意控制的变量。 llama.cpp 的 -ctk q8_0 -ctv q8_0 能把它砍一半 —— 那会同时影响 KV 容量和解码带宽, 是量化专题里比权重量化更有意思的一个旋钮:权重量化的红利随 batch 变小,KV 量化的红利随 batch 变大。