手头有两个文件: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 台式机) | 单流 decode | 4 并发合计 | draft 接受率 |
|---|---|---|---|
| IQ3_XXS 基线 | 47.6 tok/s | 121 tok/s | - |
| IQ3_XXS + MTP draft 3 | 84 tok/s | 145 tok/s | 59% |
| IQ4_XS 基线 | 39.8 tok/s | 121 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-INT4 | 19.45GB | 保留(15 个) |
| cyankiwi/Qwen3.8-27B-AWQ-INT4 | 21.02GB | 保留 |
| 其他 W4A16 / GPTQ / AutoRound | 19-21GB | 不一 |
| ISTA-DASLab/Qwen3.8-27B-3Bit-GSQ | 11.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 合并进去的。相关参数:
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_n 和 draft_n_accepted 给出投机解码的接受率。并发那一组我一开始用”总 token / 最长那条请求的 eval 时间”来算合计吞吐,后来发现这个算法在请求没有真正重叠时会严重高估(有一组算出 210 tok/s,实际墙钟只有 100),改成按墙钟算。并发吞吐只能看墙钟。
第一轮:Mac 上 MTP 反而变慢
M4 Pro 24GB 上先跑:
| Mac Metal,4K 上下文 | 单流 decode | MTP 接受率 |
|---|---|---|
| IQ4_XS 基线 | 11.7 tok/s | - |
| IQ4_XS + MTP(单槽) | 9.1 tok/s | 59% |
| IQ3_XXS 基线 | 12.85 tok/s | - |
| IQ3_XXS + MTP | 7.6 tok/s | 58% |
接受率和 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 errorE CUDA error: the resource allocation failedE current device: 0, in function cublas_handle at ggml-cuda/common.cuh:1503E 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.ggufIQ3_XXS 120IQ3_S 106Q8_0 98IQ4_XS 45IQ2_S 35Q4_K 26IQ2_XXS 24IQ2_XS 12Q2_K 10IQ1_S 8Q5_K 7Q3_K 6Q6_K 6IQ1_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
-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_HostCUDA_Host 是钉在主机内存里、仍由 CUDA 后端计算的缓冲,矩阵乘照样走 cuBLAS,照样崩。
试过也没用的:llama-quantize COPY 模式
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:
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.6cuda errors: 0prompt 处理从 17 到 418 tok/s,崩溃消失。
NOTE
-fa off在这台机器上也会触发同一个 cuBLAS 崩溃,因为关掉 flash attention 后注意力矩阵乘走 cuBLAS。这个预编译包的 cuBLAS 到底为什么坏没有深究,换成自己编译的版本大概率没这个问题,但那台机器连 gcc 都没有。
第三轮:参数扫描
写了个 sweep 脚本,每组配置起一次 server 测一遍,一组 40 秒左右。
draft 数
| draft-n-max | 单流 decode | 接受率 |
|---|---|---|
| 2 | 81.4 | 68% |
| 3 | 83.7 | 59% |
| 4 | 82.9 | 49% |
| 5 | 78.2 | 43% |
| 6 | 73.0 | 37% |
| 8 | 61.5 | 29% |
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(墙钟) |
|---|---|---|
| 基线 np4 | 4 | 121 |
| MTP draft 3 np4 | 4 | 145 |
| MTP draft 2 np6 + KV q8 | 6 | 178 |
| 基线 np8 | 8 | 162 |
| MTP np8 | 8 | OOM |
MTP 的并发收益只有 1.2×,因为投机解码的本质是拿闲置算力换延迟,批次一大算力本来就不闲。
GDN 循环状态缓存:显存公式
扫描里 draft 5 以上和 MTP np8 全部 OOM,一开始以为是运行间隙显存没释放,加了等显存回落的逻辑还是 OOM。verbose 日志里找到了真相:
draft-n-max 3, np 4: CUDA0 RS buffer size = 2394.00 MiBdraft-n-max 5, np 4: CUDA0 RS buffer size = 3591.00 MiBRS 是 recurrent state 缓存。Qwen3.8-27B 是 64 层混合架构,48 层 Gated DeltaNet 加 16 层全注意力。GDN 层没有 KV cache,只有一份固定大小的循环状态,每个序列约 150MB。投机解码验证 draft 时如果某个 token 被拒绝,状态要回滚,所以 llama.cpp 给每个序列留了 (draft + 1) 份状态快照:
RS 显存 = 槽数 × (draft + 1) × 150MB2394 / 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 cache | f16 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 |
|---|---|---|
| 2K | 1422 tok/s | 76 |
| 7K | 1532 tok/s | 80 |
| 25K | 1304 tok/s | 75 |
| 51K | 1205 tok/s | 57 |
掉速比普通 dense 模型温和得多,因为只有 16 层全注意力随上下文线性增长,48 层 GDN 是常量。
内存 offload 是死路
那台机器有 62GB 内存,自然会想能不能拿来换上下文。两种方式都测了。
KV cache 留内存(--no-kv-offload,上下文开到 262K):
| 上下文 | decode |
|---|---|
| 2K | 26.1 |
| 25K | 14.8 |
| 51K | 10.9 |
| 103K | 6.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 一轮:
| 状态 | GPU | CPU 包 | SM 频率 | decode |
|---|---|---|---|---|
| 空闲(模型常驻显存) | 18 W | 1.7 W | 210 MHz | - |
| decode,功耗墙 285 W | 262 W 均值 | 42 W | 2700 MHz | 72 tok/s |
| 功耗墙 220 W | 202 W | 40 W | 2430 MHz | 67 tok/s |
| 功耗墙 180 W | 174 W | 40 W | 1665 MHz | 52 tok/s |
| 功耗墙 150 W | 144 W | 40 W | 1245 MHz | 40 tok/s |
一个反直觉的点:MTP 下的 decode 不是纯显存带宽瓶颈。显存频率四档都是 10251 MHz,SM 频率一降速度就跟着掉,说明 draft 头前向和多 token 验证批次让它偏算力受限。普通单 token decode 那套”压功耗不掉速”的经验在这里不成立。220 W 是能效甜点,速度掉 7%,每 token 能耗降 14%。
最终部署
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 附近是能效甜点。