3105 字
16 分钟
16GB 显卡跑 Qwen3.8-27B + MTP:从 vLLM 走弯路到 llama.cpp 84 tok/s 的完整调优记录

手头有两个文件:unsloth 的 Qwen3.8-27B-UD-IQ4_XS.gguf(14.25GB)和配套的 mtp-Qwen3.8-27B-Q4_0.gguf(1.37GB,MTP 头)。目标很简单:在一块 16GB 的 RTX 4070 Ti SUPER 上把 MTP 投机解码跑起来,看 decode 吞吐能到多少。结果前后花了一个通宵,中间经过 vLLM 的弯路、一个预编译包的 cuBLAS 崩溃、一次针对 3 个张量的手工重量化,最后落到单流 84 tok/s、部署成 systemd 服务。整个过程记下来。

先说结论#

配置(4070 Ti SUPER 16GB 台式机)单流 decode4 并发合计draft 接受率
IQ3_XXS 基线47.6 tok/s121 tok/s-
IQ3_XXS + MTP draft 384 tok/s145 tok/s59%
IQ4_XS 基线39.8 tok/s121 tok/s-
IQ4_XS + MTP塞不进 16GB--

decode 一律只算生成阶段的 token 速度,不含 prompt 处理。draft 3 是单流最优;draft 数再往上接受率掉得比批次收益快。同一套文件在 M4 Pro 24GB 的 Mac 上 Metal 后端跑,MTP 反而把 12.9 tok/s 拖到 7.6。

为什么不是 vLLM#

第一反应是用 vLLM。原因很朴素:vLLM 的批处理吞吐通常远高于 llama.cpp,而且 Qwen 官方 recipe 页面写着 --speculative-config '{"method":"mtp","num_speculative_tokens":3}' 一行就能开 MTP。

在那台机器上装完 vLLM 0.28.0 之后发现两个硬事实。

第一,vLLM 0.28.0 已经没有 GGUF loader 了。整个包里 grep -rli gguf 只命中 3 个文件,全是顺带提到,没有 gguf_loader.py,也没有装 gguf 依赖包。手头这两个 GGUF 文件在 vLLM 上没有任何入口,独立的 MTP GGUF 就更不可能当 draft 用。

第二,vLLM 走 MTP 只能用 HF 权重,而 27B 的 int4 HF 权重装不进 16GB。查了一圈 HuggingFace 上的量化版本:

仓库safetensors 体积MTP 张量
RedHatAI/Qwen3.8-27B-INT419.45GB保留(15 个)
cyankiwi/Qwen3.8-27B-AWQ-INT421.02GB保留
其他 W4A16 / GPTQ / AutoRound19-21GB不一
ISTA-DASLab/Qwen3.8-27B-3Bit-GSQ11.83GB索引里有 15 个,README 说已移除

int4 版本普遍 19-20GB,因为 embedding、lm_head、vision encoder 都留 BF16,光 embedding + lm_head 就 5GB(5120 × 248320 词表 × 2 字节 × 2)。3-bit 的 GSQ 版能装下,但它要求 vLLM 0.27.1 加一个源码补丁,README 又明说不支持投机解码。

结论:这两个 GGUF 文件只有 llama.cpp 一条路。vLLM 上要玩 MTP 得换 HF 权重和更大的卡。

llama.cpp 的 MTP 参数#

本机 brew 装的 llama.cpp(build 10621)已经带 MTP 支持,这是 2026 年 5 月 PR #22673 合并进去的。相关参数:

Terminal window
llama-server -m Qwen3.8-27B-UD-IQ3_XXS.gguf \
--spec-type draft-mtp \
-md mtp-Qwen3.8-27B-Q4_0.gguf \
--spec-draft-n-max 3 \
-ngl 999 -ngld 999 -fa on

--spec-type 的可选值里有 draft-mtp-md 指定 MTP 头文件,-ngld 控制 draft 模型的 GPU 层数。

那台 Ubuntu 机器上没有 curl、gcc、cmake,只有 wget。用 llama.app/install.sh 装预编译版,脚本依赖 curl,我写了个 curl 到 wget 的 shim 骗过去,它探测到 sm89 后下载了 CUDA 版(build 10679)。这一步埋了后面最大的坑。

统一的测速脚本#

llama-bench 不支持投机解码,所以写了个客户端:起 llama-server,等 /health,发固定的 4 条 prompt(贪心,256 token),从响应的 timings 字段读数据,再发 4 条并发。

timings.predicted_per_second 是 llama-server 自己算的 decode 速度,只包含生成阶段。draft_ndraft_n_accepted 给出投机解码的接受率。并发那一组我一开始用”总 token / 最长那条请求的 eval 时间”来算合计吞吐,后来发现这个算法在请求没有真正重叠时会严重高估(有一组算出 210 tok/s,实际墙钟只有 100),改成按墙钟算。并发吞吐只能看墙钟。

第一轮:Mac 上 MTP 反而变慢#

M4 Pro 24GB 上先跑:

Mac Metal,4K 上下文单流 decodeMTP 接受率
IQ4_XS 基线11.7 tok/s-
IQ4_XS + MTP(单槽)9.1 tok/s59%
IQ3_XXS 基线12.85 tok/s-
IQ3_XXS + MTP7.6 tok/s58%

接受率和 CUDA 上完全一样,但速度倒退。MTP 每一步要跑 3 次 draft 头前向(每次都要过 248K 词表的 lm_head),再用主模型验证 4 个 token。Metal 上这套开销没有被批处理收益抵消。IQ4_XS + MTP 在 32K 上下文下还会 Metal 显存不足(kIOGPUCommandBufferCallbackErrorOutOfMemory),4 槽 × 4K 也不够,只有单槽才起得来。

Mac 这条线到此为止:Metal 上别开 MTP。

第二轮:CUDA 上 IQ3_XXS 神秘崩溃#

CUDA 那台上 IQ4_XS 基线一次跑通,单流 39.8 tok/s。IQ3_XXS 却在第一个请求就崩:

llamacpp/ggml/src/ggml-cuda/ggml-cuda.cu:107: CUDA error
E CUDA error: the resource allocation failed
E current device: 0, in function cublas_handle at ggml-cuda/common.cuh:1503
E cublasCreate_v2(&cublas_handles[device][curr_stream_no])

怪在几点:IQ3_XXS 比 IQ4_XS 小 3GB,显存不可能更紧;手动起 server 用 5 个 token 的 prompt 正常生成,换成十几个 token 的 prompt 就崩;GGML_CUDA_FORCE_MMQ=1 无效。

这个组合指向一个方向:某种张量类型没有 MMQ 内核,批次一大就退到 dequant + cuBLAS,而这个预编译包的 cuBLAS 初始化是坏的。IQ4_XS 全程走 MMQ 从没碰过 cuBLAS,所以没事。

用 gguf-py 把两个文件的张量类型分布 dump 出来:

=== Qwen3.8-27B-UD-IQ3_XXS.gguf
IQ3_XXS 120
IQ3_S 106
Q8_0 98
IQ4_XS 45
IQ2_S 35
Q4_K 26
IQ2_XXS 24
IQ2_XS 12
Q2_K 10
IQ1_S 8
Q5_K 7
Q3_K 6
Q6_K 6
IQ1_M 3 e.g. blk.13.ffn_gate.weight, blk.14.ffn_down.weight, blk.15.ffn_down.weight
=== Qwen3.8-27B-UD-IQ4_XS.gguf
(没有 IQ1_M)

unsloth 的 UD 量化是按层敏感度混用类型的,IQ3_XXS 这一档里有 3 个张量被压到了 IQ1_M。而 llama.cpp 的 ggml-cuda/mmq.cuh 支持的类型列表里有 IQ1_S 却没有 IQ1_M。三个 18MB 的张量,把整个文件在这台机器上判了死刑。

临时绕过:-ub 8#

-ub 8 -b 8 把 ubatch 压到 8,所有 matmul 走 MMVQ 路径,不碰 cuBLAS。decode 不受影响(单流 84 tok/s 就是这么测出来的),但 prompt 处理掉到 17 tok/s,一个 2K 的 prompt 要两分钟,部署上不能接受。

试过没用的:-ot 钉到 CPU#

Terminal window
-ot 'blk\.13\.ffn_gate\.weight=CPU,blk\.14\.ffn_down\.weight=CPU,blk\.15\.ffn_down\.weight=CPU'

verbose 日志显示张量确实被覆盖了,但落到的是 CUDA_Host 缓冲:

D tensor blk.13.ffn_gate.weight (18 MiB iq1_m) buffer type overridden to CUDA_Host

CUDA_Host 是钉在主机内存里、仍由 CUDA 后端计算的缓冲,矩阵乘照样走 cuBLAS,照样崩。

试过也没用的:llama-quantize COPY 模式#

Terminal window
llama-quantize --allow-requantize \
--tensor-type 'blk\.13\.ffn_gate\.weight=Q2_K' ... in.gguf out.gguf COPY

跑完文件字节数和原文件一模一样。COPY 模式下 quantize.cpp 对所有张量置 quantize = false--tensor-type 覆盖在这个分支里根本不会被看到。

根治:gguf-py 反量化重写#

最后用 gguf-py 直接改文件:读 GGUFReader,元数据原样复制,866 个张量里 863 个逐字节复制,那 3 个 IQ1_M 张量 dequantize 成 f32 再 quantize 成 Q8_0:

rewrite_iq1m.py(核心部分)
for t in reader.tensors:
if t.tensor_type == T.IQ1_M:
f32 = gguf.quants.dequantize(t.data, t.tensor_type)
q = gguf.quants.quantize(f32, T.Q8_0)
converted[t.name] = q
writer.add_tensor_info(t.name, q.shape, q.dtype, q.nbytes, T.Q8_0)
else:
writer.add_tensor_info(t.name, t.data.shape, t.data.dtype, t.data.nbytes, t.tensor_type)

每个张量从 18.6MB 变成 90.3MB,文件从 10.93GB 到 11.16GB。IQ1_M 本身已经是极度有损的类型,反量化再压成 Q8_0 不会引入额外误差。在那台机器上跑这个脚本只要 5 秒(先在 Mac 上跑完往过传,8MB/s 的 scp 传了十分钟还没完,白等)。

新文件用默认 ubatch 跑长 prompt:

OK prompt_n 58 pp tok/s 418.6 decode tok/s 46.6
cuda errors: 0

prompt 处理从 17 到 418 tok/s,崩溃消失。

NOTE

-fa off 在这台机器上也会触发同一个 cuBLAS 崩溃,因为关掉 flash attention 后注意力矩阵乘走 cuBLAS。这个预编译包的 cuBLAS 到底为什么坏没有深究,换成自己编译的版本大概率没这个问题,但那台机器连 gcc 都没有。

第三轮:参数扫描#

写了个 sweep 脚本,每组配置起一次 server 测一遍,一组 40 秒左右。

draft 数#

draft-n-max单流 decode接受率
281.468%
383.759%
482.949%
578.243%
673.037%
861.529%

3 是拐点。再多的 draft 只是让每一步验证批次更大、接受率更低。

没有影响的参数(全在 ±1 tok/s 内)#

-ub 8 / 16 / 32 / 64 / 512、KV cache q8_0--spec-draft-n-min 2--ctx-checkpoints 1/2--spec-draft-backend-sampling 开关。KV q8_0 因为零成本,部署时直接用上换上下文长度。

只会变慢的参数#

--spec-draft-p-min 0.5 / 0.75 / 0.9 → 80 / 74 / 67 tok/s。它让 draft 在置信度低时提前停,接受率涨到 73-92%,但每步 token 数掉得更多。

-ub 8 对单流无影响,但 4 并发从 145 掉到 90。

并发#

配置并发数合计 decode(墙钟)
基线 np44121
MTP draft 3 np44145
MTP draft 2 np6 + KV q86178
基线 np88162
MTP np88OOM

MTP 的并发收益只有 1.2×,因为投机解码的本质是拿闲置算力换延迟,批次一大算力本来就不闲。

GDN 循环状态缓存:显存公式#

扫描里 draft 5 以上和 MTP np8 全部 OOM,一开始以为是运行间隙显存没释放,加了等显存回落的逻辑还是 OOM。verbose 日志里找到了真相:

draft-n-max 3, np 4: CUDA0 RS buffer size = 2394.00 MiB
draft-n-max 5, np 4: CUDA0 RS buffer size = 3591.00 MiB

RS 是 recurrent state 缓存。Qwen3.8-27B 是 64 层混合架构,48 层 Gated DeltaNet 加 16 层全注意力。GDN 层没有 KV cache,只有一份固定大小的循环状态,每个序列约 150MB。投机解码验证 draft 时如果某个 token 被拒绝,状态要回滚,所以 llama.cpp 给每个序列留了 (draft + 1) 份状态快照:

RS 显存 = 槽数 × (draft + 1) × 150MB

2394 / 4 / 4 = 150,3591 / 4 / 6 = 150,严丝合缝。--ctx-checkpoints 管的是另一套东西,对它没作用。

整块卡的显存账(IQ3_XXS 重写版):

项目大小
主模型权重(GPU 部分)10.2GB
token_embd(留在 CPU 的 mmap)0.4GB
MTP 头0.8GB
RS 缓存槽数 × (draft+1) × 150MB
KV cachef16 64KB/token,q8_0 32KB/token(只有 16 层全注意力吃 KV)
compute buffer约 0.3GB

所以 draft 数和并发数互相挤占:单槽 draft 3 只要 600MB RS,剩下约 3.5GB 全给 KV,q8_0 能放 80K 上下文(96K 实测 OOM,差 384MB)。

长上下文下的 decode#

部署配置(单槽、draft 3、KV q8_0、80K 上下文)实测:

实际上下文prompt 处理decode
2K1422 tok/s76
7K1532 tok/s80
25K1304 tok/s75
51K1205 tok/s57

掉速比普通 dense 模型温和得多,因为只有 16 层全注意力随上下文线性增长,48 层 GDN 是常量。

内存 offload 是死路#

那台机器有 62GB 内存,自然会想能不能拿来换上下文。两种方式都测了。

KV cache 留内存(--no-kv-offload,上下文开到 262K):

上下文decode
2K26.1
25K14.8
51K10.9
103K6.3

短上下文就从 84 掉到 26。这个开关把 GDN 的循环状态也一起放到了 CPU(显存正好少了 RS + KV 那 1GB),48 层 GDN 每步都要跨 PCIe 读写状态。

权重部分放内存: -ngl 56(8 层放 CPU)开 128K 还差 512MB OOM;-ngl 48(16 层放 CPU)能起,decode 1.9 tok/s。CPU 层加上 MTP 每步 4 个 token 的验证批次,内存带宽直接被打穿。

结论:这块卡上这个模型,KV、GDN 状态、权重必须全在显存里。想超过 80K 只有换更小的量化(UD-IQ2_XXS 约 8GB,估计能到 170K,质量会掉)或者砍 draft(每少一个省 150MB,约多 4-5K 上下文)。

功耗#

用 nvidia-smi 每秒采样 GPU,RAPL 计数器读 CPU 包功耗,decode 512 token 一轮:

状态GPUCPU 包SM 频率decode
空闲(模型常驻显存)18 W1.7 W210 MHz-
decode,功耗墙 285 W262 W 均值42 W2700 MHz72 tok/s
功耗墙 220 W202 W40 W2430 MHz67 tok/s
功耗墙 180 W174 W40 W1665 MHz52 tok/s
功耗墙 150 W144 W40 W1245 MHz40 tok/s

一个反直觉的点:MTP 下的 decode 不是纯显存带宽瓶颈。显存频率四档都是 10251 MHz,SM 频率一降速度就跟着掉,说明 draft 头前向和多 token 验证批次让它偏算力受限。普通单 token decode 那套”压功耗不掉速”的经验在这里不成立。220 W 是能效甜点,速度掉 7%,每 token 能耗降 14%。

最终部署#

serve-qwen38.sh
COMMON=(-m Qwen3.8-27B-UD-IQ3_XXS-noiq1m.gguf --spec-type draft-mtp -md mtp-Qwen3.8-27B-Q4_0.gguf
-ngl 999 -ngld 999 -fa on -ctk q8_0 -ctv q8_0
--host 0.0.0.0 --port 8080 --cache-reuse 256 --no-webui --metrics)
case "$MODE" in
single) exec llama serve "${COMMON[@]}" -np 1 -c 81920 --spec-draft-n-max 3 ;; # 80K,84 tok/s
dual) exec llama serve "${COMMON[@]}" -np 2 -c 65536 --spec-draft-n-max 3 ;; # 2×32K
batch) exec llama serve "${COMMON[@]}" -np 6 -c 36864 --spec-draft-n-max 2 ;; # 6×6K,178 tok/s 合计
esac

套上 systemd 服务,显存占 15.7GB,实测请求 decode 87 tok/s、接受率 65%。

经验总结#

  • vLLM 0.28 没有 GGUF loader。 手上是 GGUF 就别想 vLLM,MTP 也只认 HF checkpoint 里自带的 MTP 层。
  • UD 混合量化里可能藏着没有 MMQ 内核的类型。 遇到”小文件反而崩、短 prompt 正常长 prompt 崩”,先 dump 张量类型分布对照 mmq.cuh 的支持列表。IQ1_M 是目前唯一一个 IQ 系列里没有 MMQ 的。
  • llama-quantize 的 COPY 模式不做任何量化,--tensor-type 在 COPY 下无效。 改个别张量用 gguf-py 直接重写更可靠。
  • -ot ...=CPU 在 CUDA 后端会落到 CUDA_Host,张量仍由 GPU 算,不能用来绕开某个内核。
  • 混合架构模型 + 投机解码的显存公式:RS = 槽数 × (draft+1) × 每序列状态大小。 这决定了 draft 数、并发数、上下文三者的取舍。
  • 并发吞吐只看墙钟。 用 per-request 的 eval 时间去推合计吞吐,在请求没有真正重叠时会高估一倍。
  • Metal 上别开 MTP。 同样的接受率,速度倒退 40%。
  • MTP decode 偏算力受限,压功耗墙会掉速;220 W 附近是能效甜点。
16GB 显卡跑 Qwen3.8-27B + MTP:从 vLLM 走弯路到 llama.cpp 84 tok/s 的完整调优记录
https://blog.lishuyu.app/posts/2026-09-03-qwen38-27b-mtp-16gb-llamacpp/
作者
猫猫魔女
发布于
2026-09-03
许可协议
CC BY-NC-SA 4.0