vLLM BF16 vs llama.cpp Q4_K_M · Round 2 · RTX 4090

llama.cpp 压测

这一轮的对照是公平的:同一张卡、同一个模型、同一份 1024/256 负载, 两个引擎都扫完了并发 1 到 64 的 7 个点,llama.cpp 侧关掉了 prompt cache。 第一轮只能在 3 个点上比,而且比得不干净。

GPU
RTX 4090 · 24 GB
vLLM
0.27.1 · bfloat16
llama.cpp
eab8ee41f · Q4_K_M
In / Out
1024 / 256 tok
并发档
1 · 2 · 4 · 8 · 16 · 32 · 64
llama.cpp 槽位
-np 64 · -c 262144
prompt cache
已关(--cache-ram 0)
失败请求
1 / 1332
驱动
580.159.03 · CUDA 13.0

术语先把这七个指标定义清楚

下面这些缩写不是同义词:每一个量的是流水线上不同的一段,彼此之间有确定的换算关系。 本轮负载:1024 输入 / 256 输出 · 并发 1 → 64 · 两个引擎各扫一遍。

两个引擎的指标都由同一个客户端(vllm bench serve)测量,所以 TTFT(首字延迟)/ ITL / E2EL 这几个数可以直接比。 服务端侧的计数器(KV 占用、抢占、running/waiting)只有 vLLM 有,llama.cpp 只暴露 slot,所以本报告的对照都只用客户端指标。

  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。vLLM 102→787 ms;llama.cpp 93→442 ms
ITL
Inter-Token Latency
token 间隔相邻两个输出 token 之间的时间。一条请求有(输出数 − 1)个 ITL 样本 —— 样本量大,所以它的 p99 最能暴露抖动。vLLM 15.83→22.50;llama.cpp 5.84→35.32 ms
ITL p99间隔的尾部把所有 ITL 样本排序后第 99% 那个值。一次卡顿只影响少数间隔,均值和中位数都看不出来,只有 p99 会跳。vLLM 16.3→207.6;llama.cpp 6.0→223.8 ms
TPOT
Time Per Output Token
每输出 token 耗时一条请求在第 1 个输出 token 之后的平均出字速度,即 (E2EL − TTFT) ÷ (输出 token 数 − 1)。数值上就是这条请求 ITL 的均值。vLLM 15.83→43.41;llama.cpp 5.85→57.54 ms
E2EL
End-to-End Latency
端到端时延从发出请求到收到最后一个 token 的总时间。恒等式:E2EL = TTFT + (输出数−1) × TPOT。vLLM 4,138→11,779;llama.cpp 1,585→15,193 ms
输出吞吐
output token/s
用户拿到的单位时间产出的输出 token 数。这是用户真正感知的产能。vLLM 61.9→1,388;llama.cpp 161.5→1,065 tok/s
总吞吐
total token/s
GPU 处理的输入 + 输出一起算。长输入场景下它上涨、输出吞吐下跌,所以两者不能互相替代。vLLM 309→6,939;llama.cpp 807→5,376 tok/s

三个最容易混的地方

1 · TPOT 和 ITL 均值是同一件事的两种算法。 TPOT 是每条请求先算自己的平均,再对所有请求取中位数;ITL 均值是把所有间隔放在一起取平均。 负载均匀时两者几乎相等;两者一分开,就说明存在少数被拖长的间隔。 本轮 C=1 两个引擎都是均匀的(vLLM 15.83 / 15.83,llama.cpp 5.84 / 5.83); 到 C=8,llama.cpp 分成了 10.30 / 18.33,vLLM 还是 16.63 / 18.25。

2 · 看 p50 得不出任何关于抖动的结论。 一次 200 ms 的停顿混在 25 万个间隔里,不影响中位数,对均值的影响也很小。 本系列的经验是:抖动只在 p99 上露头。 抢占更稀有(万分之一量级),连 p99 都反映不出来,只能看专门的计数器。

3 · 吞吐不是 1 / TPOT。 TPOT 衡量单条请求的出字速度,吞吐衡量整台机器的产能,后者约等于前者乘以并行的请求数。 并发提高时,TPOT 变差、吞吐变好,这正是压测要权衡的交换。

结论量化省的是字节,省不了调度

llama.cpp 把每步要读的权重从 14.1 GB 压到 4.7 GB,单流 decode 因此快 2.71 倍, 这个数能用字节账算到小数点后两位。 但并发一上去,优势就没了:两条吞吐曲线在 C≈8 就换位了, 不是第一轮估计的 C≈21。原因不在算力,在批处理红利: C=64 时,vLLM 仍能达到理论地板的 81%,llama.cpp 只有 25%。

单流 decode 步长

5.84 ms

vLLM 15.83 ms。字节账预测 2.99×,实测 2.71×

C=64 批处理效率

25 %

vLLM 81%,是它的 3.2 倍(同一条 roofline)

每多一条并发的边际成本

0.47 ms/tok

vLLM 0.11 ms,理论地板 0.066 ms

C=32 反常塌陷

444 tok/s

比 C=16 的 548 还低;C=64 的 1065 是它的 2.4 倍

第一轮的三条数据瑕疵,两条已消除、一条大幅改善

这一轮的交叉点 C≈8 比第一轮的 C≈21 更可信,因为第一轮那个数被 prompt cache 抬高了。 第一轮的三个问题和本轮的处理:

① llama.cpp 的 prompt cache 默认开着,C=32 有 23% 的请求白拿了 prefill。本轮用 --cache-ram 0 --slot-prompt-similarity 0 关掉了。

② C=32 掉了 8 条请求。本轮只掉 1 条(C=32,319/320)。

③ 只测了 1 / 8 / 32 三个点。本轮补齐到 7 个点。

本轮还补上了显存实测:nvidia-smi 记到 19,089 MiB,与权重加 KV 的推算差 1.5%。

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

两条线的差别在斜率,不是平移:llama.cpp 起点高、爬得慢,vLLM 起点低、爬得快。 C=8 时两边几乎重合(411 vs 416),C=16 就分开了(681 vs 548)。 这一轮两边都有 7 个点,不用再靠三点插值。

Figure 1

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

vLLM BF16(7 点) llama.cpp Q4_K_M(7 点) 交叉点 C ≈ 8.3

交叉点由 C=8 与 C=16 两点在 log–log 空间插值得到:llama/vLLM 的吞吐比从 1.010 降到 0.804, 在 C = 8.3 处过 1。 注意:llama.cpp 的 C=32 点(444 tok/s)低于它自己的 C=16(548), 这一档是反常的,见「反常」一节。

七个并发点的正面对照,倍数 >1 表示 llama.cpp 更好。
并发vLLM tok/sllama tok/s倍数 vLLM ITL p50llama ITL p50vLLM TTFT p50llama TTFT p50
161.9161.52.61×15.835.8410293
2118.7242.22.04×16.156.53154104
4225.1318.41.41×16.327.75288189
8411.4415.61.01×16.6310.30306204
16681.5548.00.80×17.2323.35474324
321026.7443.70.43×19.8261.36588478
641387.81064.80.77×22.5035.32787442

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

单流 decode 是全篇唯一一处理论与实测能对到 10% 以内的地方。 它是纯带宽问题:每生成一个 token,都要把整个模型的权重从显存读一遍。 量化把这些字节压小,步长就该按同样的比例变短。

# 每个 decode step 要读的字节
vLLM  bf16    参与矩阵乘的参数 7,070,619,136 × 2 B = 14.14 GB
              + KV(1 条 × 1152 tok × 56 KiB)      =  0.07 GB
                                                    ──────────
                                                     14.21 GB

llama Q4_K_M  Qwen2.5-7B-Instruct-Q4_K_M.gguf      =  4.68 GB
              + KV(fp16,和 vLLM 同价)             =  0.07 GB
                                                    ──────────
                                                      4.75 GB

# 字节账预测的步长比
14.21 ÷ 4.75 = 2.99 ×

# 实测
15.83 ms ÷ 5.84 ms = 2.71 ×      达成预测的 91%

# 差在哪:反推各自的实际带宽
vLLM   14.21 GB ÷ 15.83 ms = 898 GB/s  = 89% MBU
llama   4.75 GB ÷  5.84 ms = 813 GB/s  = 81% MBU

2.71 而不是 2.99,差的那 9% 是带宽效率

解量化本身要花掉一点带宽预算(读 4-bit 权重,在寄存器里展开成 fp16 再乘)。 两边用的是同一张卡、同一条 1,008 GB/s 的总线: vLLM 的 decode kernel 把带宽跑到 89%,llama.cpp 的 Q4_K_M 反量化路径跑到 81%。 这 8 个百分点就是 2.99 与 2.71 之间的全部差距。

量化没有骗人,它确实把字节数降到了 1/3; 但 decode 只快 2.7 倍,不是 3.0 倍,因为省下的字节有一小部分被解量化开销吃回去了。

prefill 这一头,量化帮不上忙

量化省的是读权重的字节,而 prefill 的瓶颈不在读权重:权重读进来一次,要算 1024 个 token。 prefill 是算力密集的(第三节那张 roofline 图量过:prefill 秒能跑到 146 TF/s)。 所以 TTFT 这一栏,llama.cpp 从 C=1 到 C=64 每一档的 p50 都更低:

# TTFT p50(ms),越小越好
C=1    vLLM 102   llama  93    llama 略快
C=8    vLLM 306   llama 204    llama 快(它此时批更小)
C=16   vLLM 474   llama 324
C=32   vLLM 588   llama 478
C=64   vLLM 787   llama 442

# 但这是拿吞吐换的:同一档下 llama.cpp 的 ITL 差得多
C=64   vLLM ITL 22.5 ms   llama ITL 35.3 ms

上面比的是客户端量到的 TTFT,含调度与网络。 换成两边服务端自己记的纯 prefill 再比一次,结论和幅度都一样。 两条独立路径同向,说明这 9% 不是测量噪声:

# A · 服务端量到的纯 prefill(1024 tok,C=1)
llama.cpp  prompt eval           85.3 ms   日志中位数,40 条
vLLM       request_prefill_time  93.5 ms   ← llama 快 8.8%

# B · 客户端量到的 TTFT(两边都含调度与网络)
llama.cpp  TTFT p50              92.7 ms
vLLM       TTFT p50             102.3 ms   ← llama 快 9.4%

这 9% 不是量化的功劳

这 9% 来自实现细节(kernel、框架开销),不是"少读了 3 倍字节"换来的。 prefill 一次前向要算 1024 个 token,权重只读一遍就摊给了所有 token,所以瓶颈在 FLOPs,不在带宽; Q4_K 的解量化只会让 FLOPs 更多。

量化换来的是 decode 速度和显存,不是 TTFT。 对话类负载(短输入长输出)收益大;RAG / 长文摘要(长输入短输出)收益小得多。

摊薄批处理红利,vLLM 吃到 81%,llama.cpp 只吃到 25%

批处理的价值在于:一次读进来的权重,给批内所有请求共用。 所以并发翻倍时,单步耗时不该翻倍,只该多出读那些 KV 的时间。 这个下限就是 roofline 地板;地板 ÷ 实测步长,就是批处理效率。

Figure 2

decode 步长:实测 vs roofline 地板

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

两条地板之间的距离(量化红利)是恒定的,拉开差距的是各自离地板有多远。 vLLM 的实测线始终贴着自己的地板(81%–89%); llama.cpp 从 C=1 的 81% 掉到 C=16 的 24%、C=32 的 11%,C=64 回到 25%, 掉下去容易,回不到 vLLM 那条水平线。 地板 = (权重字节 + 并发 × 1152 tok × 56 KiB) ÷ 1,008 GB/s。

每多接一条并发,要多付多少

# 理论地板:每多一条并发,每步多读 1152 tok × 56 KiB = 0.066 GB
0.066 GB ÷ 1,008 GB/s = 0.0655 ms / 槽位

# 实测斜率(C=1 → C=64 的 ITL 增量 ÷ 63)
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 每多接一条并发,单步要多花 0.47 ms;vLLM 只多花 0.11 ms。 相差的 0.36 ms 和权重精度无关,它是 PagedAttention、 continuous batching、CUDA graph 复用这些调度侧工程的价值。

这 0.36 ms 就是「交叉」一节里 C≈8 换位的全部来源。

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

时间账能读出效率掉得这么快的直接原因。 如果引擎在做 continuous batching,一条请求结束就该立刻补进下一条, 总耗时应该远小于"批数 × 单条端到端时延"。实测正好相反。

# llama.cpp C=32:320 条请求 ÷ 32 个槽位 = 10 批
单条 e2e p50            = 17.85 s
总耗时 ÷ 10             = 179.73 ÷ 10 = 17.97 s
                                        ──────────
                          两者相差 0.7%

# 同样的检验用在 C=64 上
单条 e2e p50            = 15.19 s
总耗时 ÷ 8              = 121.61 ÷ 8  = 15.20 s   相差 0.1%

# 对照 vLLM C=32
单条 e2e p50            = 7.96 s
总耗时 ÷ 10             = 79.79 ÷ 10  = 7.98 s   ← 也吻合,因为负载是定长齐发

总耗时几乎等于"批数 × 单条端到端时延"(吻合到 0.7% / 0.1%), 说明新请求只在整批结束后才成批进场,每 18.0 秒一次。 如果引擎在做 continuous batching,一条结束就补一条,不会有这种整齐的批边界。 换手间隙只有约 0.5 秒(= 实测 TTFT p50 478 ms),占一波的 3%。

注意:本轮没有对 llama.cpp 做 1 Hz 采样, 所以这里只有时间账,没有槽位占用的实测曲线。补采样是「下一步」的第 3 条。

停顿的证据:ITL 的中位数和均值分家

llama.cpp 在高并发下,ITL 的中位数和均值分开了,说明有一批远大于中位数的间隔把均值拉了上去。 如果 decode 是均匀的,这两个数应该几乎相等。

          ITL p50    ITL 均值   均值/中位
C=1        5.84       5.83      1.00   完全均匀
C=2        6.53       7.79      1.19
C=4        7.75      11.71      1.51
C=8       10.30      18.33      1.78
C=16      23.35      26.50      1.13
C=32      61.36      67.96      1.11
C=64      35.32      56.25      1.59

# 对照 vLLM,两个数几乎粘在一起
C=64      22.50      42.22      1.88   ← vLLM 也有,来自 chunked prefill 抢步
C=1       15.83      15.83      1.00

换个算法量同一件事:decode 时间里有多少在等别人

TPOT 均值 ÷ ITL p50 − 1 比上面那张表(ITL 自己的均值与中位数)更直接。它量的是 "一条请求的平均出字间隔,比它自己的典型间隔多出了多少", 多出来的部分就是被别人的 prefill 堵住的时间。两个引擎并排:

  C        llama.cpp        vLLM
  1           0.2%          -0.0%    单流,两边都没有别人可等
  2          19.8%           1.0%
  4          50.7%           3.4%    ← llama 已有一半时间在等,vLLM 还不到 4%
  8          78.7%           9.8%
 16          46.8%          21.9%
 32          89.5%          44.0%
 64         105.4%          87.7%    ← 到这一档两边都很难看了

差别在于 prefill 是切片插入还是整块插入

llama.cpp 在 C=4 就有一半 decode 时间在等待,vLLM 到 C=8 才 9.8%。 vLLM 开着 enable_chunked_prefill:新请求的 prompt 被切成块, 和正在运行的 decode 混在同一个 step 里,每一步只堵一小会儿。 llama.cpp 是整块插入:一条新请求进来,所有正在 decode 的 slot 都被堵住, 直到它的 1024 个 token 算完。

反常C=32 那一档塌了,而且不是噪声

llama.cpp 在 C=32 只跑出 444 tok/s, 比它自己的 C=16(548)还低;C=64(1065)是它的 2.4 倍。 吞吐曲线的单调性在这里断了。

# 逐槽解码速率 = 1000 ÷ ITL p50
C=1    171.2 tok/s/槽
C=2    153.1
C=4    129.0
C=8     97.1
C=16    42.8
C=32    16.3   ← 比 C=64 还慢
C=64    28.3

# 总吞吐 = 逐槽速率 × 槽数
C=16    42.8 × 16 = 685   (实测 548,因为不是所有槽都满)
C=32    16.3 × 32 = 522   (实测 444)
C=64    28.3 × 64 = 1811  (实测 1065)

反常的也可能是 C=64 太好,不只是 C=32 太差。 C=64 的 ITL p50(35.32 ms)明显优于 C=32(61.36),roofline 达成率也从 11% 回到 25%, 但它的 CUDA graph 复用率同样只有 1.35%,所以复用率解释不了这次回升。

两种读法都说明:C=32 与 C=64 之间还有一个这一轮没抓到的变量。 所以下面的对照实验要在 C=16 / 32 / 64 三档同时做,不能只重跑 C=32。

三条线索,但还不足以定案

① CUDA graph 复用率整档归零,复用率曲线和逐槽速率的塌陷同步。 算法:服务端每条请求结束时打印累计的 graphs reused; 按并发档切段做差分,再除以该档的 decode 步总数,就得到复用了已捕获 CUDA graph 的 decode 步比例:

取自 llamacpp-server.log 的 1,359 条 print_timing, 按各档切段(31 条 warmup + 40 / 59 / 79 / 120 / 200 / 318 / 512)。 增量 = 本档最后一条的读数 − 上一档最后一条的读数; decode 步总数 = 本档条数 × 256 的估算值(实际产出略少,llama.cpp 每条 250–256 不等,见「数据」节自检)。 roofline 达成率 = 地板 ÷ 实测 ITL p50,地板 = (4.68 GB + C × 1152 tok × 56 KiB) ÷ 1,008 GB/s。
C条数decode 步总数graph 增量复用率ITL p50roofline 达成率
14010,24010,16099.22%5.8480.6%
25915,1046,48742.95%6.5373.1%
47920,2243,27316.18%7.7563.3%
812030,7201,9806.45%10.3050.2%
1620051,2001,4572.85%23.3524.4%
3231881,40800.00%61.3611.0%
64512131,0721,7731.35%35.3225.0%

这张表是相关性,不是因果

复用率和 roofline 达成率两列几乎同起同落。 C=32 那一档 81,408 步里一次都没复用,它正是塌得最狠的一档。 C=1 复用 99.22%,几乎每一步都命中,它也是唯一贴住 roofline 的一档。 理论上限是 99.6%,因为每条请求的那一次 prefill 步本来就复用不了。

机制上也说得通:CUDA graph 把几百次 kernel launch 打包成一次提交, batch 形状一变就要重新捕获。vLLM 把 batch padding 到固定的几个桶, 任何实际 batch 都能命中已捕获的图;llama.cpp 的活跃 slot 数每步都在变(30/31/32 来回跳), 图就一直命中不了。

要确认因果,必须做对照实验:用 -DGGML_CUDA_USE_GRAPHS=OFF 重编一份,再扫一遍。 如果关掉图之后 C=1 大幅变慢、C=32 几乎不变,因果链就闭合了。 服务端日志没有给出原因(verbosity 已开到 3)。

另外两条线索

② 唯一一条失败请求也出在这一档(319/320)。

③ 排除项:不是显存(llama.cpp 的 KV 池按 -c 262144 分配,C=32 只用掉一半)、 不是温度(全程 ≤73 °C)、不是 prompt cache(本轮已关)。

在补上对照实验之前,C=32 这一档不应该写进任何结论。 本报告的交叉点用 C=8/16 算,批处理效率引用 C=64,都绕开了这一档。

显存Q4 省下的那 10 GiB,这一轮全花在 KV 上了

量化把权重从 14.49 GiB 压到 4.36 GiB,省下 10 GiB,但这一轮的 KV 池又把它吃回去了。 原因是 llama.cpp 开了 -np 64 -c 262144(64 个槽位 × 4096 上下文),KV 池按满配分配。

Figure 3

24 GiB 显存的两种分法

vLLM 侧来自启动日志实测。llama.cpp 侧的总量也是实测: fingerprint.txt 里 nvidia-smi 记到 19,089 MiB = 18.64 GiB。 只有分段是推算:权重 4.36 GiB + KV(262,144 tok × 56 KiB = 14.0 GiB)= 18.36 GiB, 与实测总量差 1.5%,账是闭合的。

这是一个配置选择,不是引擎的性质

Q4 的显存红利是真的,只是这一轮的启动参数没让它体现出来。 如果把 llama.cpp 的 -c 调成和 vLLM 一样的有效容量, 它的 KV 池会小得多,空出来的显存可以开更多槽位或跑更大的模型。

在"能同时装多少上下文"这一项上,量化确实赢了: vLLM 侧的 6.41 GiB KV 池换算成 120,064 token,llama.cpp 的 262,144 token 是它的 2.18 倍。 只是本轮的负载(1280 tok/请求)用不到这么多。

数据完整测量结果

两个引擎七个并发点的全部指标。延迟单位 ms。
引擎并发请求完成耗时 s 输出 tok/s总 tok/s TTFT p50TTFT p99 ITL p50ITL 均值ITL p99 E2EL p50
vLLM14040165.5461.930910210915.8315.8316.34,138
vLLM26060129.42118.759315420316.1516.3116.94,313
vLLM4808090.99225.1112528838816.3216.8817.64,549
vLLM812012074.67411.4205730673416.6318.25102.14,978
vLLM1620020075.13681.53408474140717.2321.01183.75,850
vLLM3232032079.791026.75133588285419.8228.53197.27,962
vLLM6451251294.451387.86939787592422.5042.22207.611,779
llama.cpp1404063.40161.580893975.845.836.01,585
llama.cpp2606062.63242.212231041896.537.7913.11,859
llama.cpp4808063.50318.416091893767.7511.7120.32,349
llama.cpp812012073.58415.6208620476010.3018.33104.83,416
llama.cpp1620020092.51548.02762324160023.3526.50187.56,467
llama.cpp32320319179.73443.72261478320661.3667.96239.717,851
llama.cpp64512512121.611064.85376442626835.3256.25223.815,193

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

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

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