17 · 草稿模型上 GPU:双 backend 投机解码
一句话:把 Qwen3-0.6B 草稿模型从 CPU 搬到 GPU 是投机解码落地链条上的最后一块拼图——但草稿上 GPU 是必要条件,不是充分条件,端到端吞吐只涨了 +11%。 前置:建议先读 第 16 篇(batch verify 的数值一致性修复)。 环境:x86-64 + NVIDIA RTX 5090(CUDA 后端)· target=Qwen3-30B-A3B q4local、draft=Qwen3-0.6B · 开关
VLLM_CUDA_DECODE=1 VLLM_CUDA_INFER=1 VLLM_CUDA_SPEC=4 VLLM_CUDA_SPEC_DRAFT=1。
一、问题与结论
结论先行,下表数据均为实测:
| 指标 | CPU 草稿(改前) | GPU 草稿(改后) |
|---|---|---|
| 草稿 backend | 无(ist2.no_cuda=1) | 独立 draft_cuda(2 GiB wcache) |
| 草稿权重预热 | — | 253 个 |
| greedy TOKIDS(32 tok) | 32/32 | 32/32 逐位一致 |
| 接受率(32 tok) | 30.4% | 34.6% |
| 吞吐(32 tok) | 77.18 tok/s | 86.00 tok/s(+11%) |
| 吞吐(16 tok) | 98.75 tok/s | 101.68 tok/s(+3%) |
草稿上 GPU 后吞吐仍低于无 spec 基线(118.79 tok/s,实测)。瓶颈不在草稿速度,在 0.6B 对 30B 的接受率(30–50%)+ 每轮 verify/correction 的固定开销(见第四节)。
二、背景:为什么必须双 backend
主模型的 full-GPU decode 靠 device 权重缓存(wcache)hit-only 跑。wcache 的 key 是 st_cuda_wkey(layer, proj) = (layer<<32)|(proj+1)——只看 (layer, proj),不看模型。
草稿(0.6B)和主模型(30B)的 layer 0 QKV 投影都用同一个 key 0x0000000000000001,共用一个 vllm_cuda_t 会互相覆盖权重。所以必须给草稿一个独立的 vllm_cuda_t 实例,让两套 wcache(还有两套 device KV mirror、两套 decode 几何)各归各的。
图 1:为什么必须是双 backend。device wcache 的 key
st_cuda_wkey(layer, proj) = (layer<<32)|(proj+1)只看 (layer, proj)、不看模型,于是 target(30B)与 draft(0.6B)的 layer 0 QKV 撞同一个 key,共用一个vllm_cuda_t就会互相覆盖权重;解法是给草稿绑独立的draft_cuda(2 GiB 预算),两套 state 经st_cuda_of(st)各解析到自己的 backend,各持有自己的 wcache / device KV mirror / decode 几何。(host strip-cache 的 key 多带(layer, proj, N, K),草稿dim=1024vs 主模型dim=2048不碰撞,故可共用。)
一个反直觉的顺带结论:host strip-cache 可以共用。st_npu_gw_get(layer, proj, N, K, q4) 用 (layer, proj, N, K) 匹配,而草稿 dim=1024 vs 主模型 dim=2048,N/K 不同,天然不碰撞。所以 host 侧一行不用改,只要 device 侧拆开。
三、核心机制:per-state backend 参数化与接线
3.1 给 state 挂一个 backend,统一解析
现状是 st_qwen_model_forward 内部硬编码全局 g_cuda。最小改动是给 STQwenInferenceState 挂一个 backend,让所有 CUDA 路径按 st->cuda 解析。仓库里的字段定义(include/model/vllm_safetensors.h):
/* 文件名:include/model/vllm_safetensors.h,STQwenInferenceState */
int no_cuda; /* 1 = force CPU decode (spec-draft model) */
struct vllm_cuda_s *cuda; /* per-state CUDA backend(NULL = 用全局 g_cuda;
* spec 草稿模型绑定独立实例 draft_cuda) */
int cuda_kv_ready; /* per-state device KV mirror 已初始化 */
int cuda_kv_slot; /* device KV mirror slot(-1 = 未分配;-2 = 池满已放弃) */
int cuda_decode_ready; /* per-state full-GPU decode 已初始化 */
统一解析函数与「逐层 GEMM offload 抑制」(src/model/vllm_safetensors.c):
/* 文件名:src/model/vllm_safetensors.c */
/* Per-state backend resolution: the spec-draft model binds its own independent
* vllm_cuda_t instance (draft_cuda); everything else uses the global g_cuda. */
static vllm_cuda_t *st_cuda_of(const STQwenInferenceState *st) {
return st->cuda ? st->cuda : g_cuda;
}
/* 文件名:src/model/vllm_safetensors.c,st_qwen_model_forward() */
const int saved_cuda_suppress = g_cuda_suppress;
/* 草稿(no_cuda 或绑了非全局 backend)forward 期间,关掉走全局 g_cuda 的
* 逐层 GEMM offload(草稿权重不在主模型 g_cuda 里)。 */
if (st->no_cuda || (st->cuda && st->cuda != g_cuda)) g_cuda_suppress = 1;
改动范围刻意收窄到「full-GPU decode + KV」两条核心路径(st_cuda_attn_on/decode_on/kv_append/try_attn_q8/decode_init、full-GPU decode 分支、st_cuda_spec_decode),全部换成 st_cuda_of(st);全局 g_cuda_kv_ready/g_cuda_decode_ready 下沉为 per-state。
逐层 GEMM offload(dyn_matvec_*)仍走全局 g_cuda(它们没有 st 参数),但草稿走 full-GPU decode 成功就提前 return,根本不进逐层循环;失败才回退纯 CPU。
3.2 接线:创建独立 draft backend 并预热
在 main.c 里,主 backend 就绪后按 VLLM_CUDA_SPEC_DRAFT 建一个独立的 g_draft_cuda(src/main.c):
/* 文件名:src/main.c */
/* 草稿与主模型 device wcache key 撞((layer<<32)|proj),必须独立 vllm_cuda_t。
* 草稿权重小(~0.6GB int8),给 2GB 预算。 */
vllm_cuda_cfg_t dcfg;
memset(&dcfg, 0, sizeof(dcfg));
dcfg.device = cfg.device;
dcfg.verbosity = 1;
dcfg.wcache_mb = 2048;
if (vllm_cuda_init(&g_draft_cuda, &dcfg) == 0 && vllm_cuda_available(g_draft_cuda))
printf(" CUDA draft backend: ready (wcache 2 GiB)\n");
草稿初始化时绑定 draft_cuda、显式 init KV mirror、再临时切全局句柄预热权重(src/main.c):
/* 文件名:src/main.c */
ist2.no_cuda = 0;
ist2.cuda = g_draft_cuda;
const int kv2 = ist2.cfg.n_kv_heads * ist2.cfg.head_dim;
const int dslot = vllm_cuda_kv_slot_alloc(g_draft_cuda);
if (dslot >= 0 &&
vllm_cuda_kv_init(g_draft_cuda, dslot, ist2.cfg.n_layers,
ist2.cfg.max_seq_len, kv2, ist2.cfg.n_kv_heads) == 0) {
ist2.cuda_kv_slot = dslot;
ist2.cuda_kv_ready = 1;
}
/* 预热草稿权重:临时切全局 backend,预热后切回主模型 */
vllm_cuda_t *saved = g_cuda;
st_cuda_set(g_draft_cuda);
int warmed = st_cuda_preload_all(&w2, ist2.cfg.n_layers, d2, q2,
kv2, ist2.cfg.ffn_dim, ist2.cfg.vocab_size);
st_cuda_set(saved);
两个细节:
- KV mirror 显式 init。主模型的 KV mirror 是 prefill 时
st_cuda_kv_append懒初始化的;草稿没有这条 CPU prefill 路径(草稿 prefill 直接走 full-GPU decode),而vllm_cuda_decode_run前置检查kv_nl != 0,所以得先显式vllm_cuda_kv_init。 - 预热靠切全局 backend,而不是给
st_cuda_preload_all加参数——它内部经st_cuda_preload_one → vllm_cuda_wcache_preload(g_cuda, ...)用全局句柄,切一下g_cuda再切回来最省事(投机循环单线程,无并发)。
四、实测数据:为什么收益只有 +11%
把每轮投机解码的时间拆开(30B target 满配置,实测):
| 段 | 耗时 | 说明 |
|---|---|---|
| 草稿自回归 4 token(GPU) | ~17 ms | 0.6B dense 的 full-GPU decode,非 ~1 ms 理想值 |
| 主模型 batch verify(M=5) | ~3 ms | 已摊销 |
| correction forward(M=1) | ~8 ms | 全量 decode,未摊销 |
图 2:每轮投机解码的时间拆解(30B target 满配置)。三段依次是草稿自回归 4 token(~17ms)→ 主模型 batch verify M=5(~3ms,已摊销)→ correction forward M=1(~8ms,全量 decode 未摊销),合计 ~28ms 产出约 3 token ≈ 9 ms/token。草稿从 CPU 换 GPU 只省第一段,而第一段占比远不如「correction 每次 8ms」和「接受率只有一半」来得重——这就是端到端只涨 +11%(77.18 → 86.00 tok/s)的原因。
每轮 ~28 ms 产出 ~3 token(接受率 50%),≈ 9 ms/token。草稿从 CPU 换 GPU,省的只是第一段——而第一段在总时间里的占比,远不如「correction 每次 8 ms」和「接受率只有一半」来得重。
两个真正的天花板:
- 接受率:0.6B 对 30B 的 greedy 一致率只有 30–50%,仓库 wiki 早先预估的「同家族 60–80%」对 50× 参数差的 draft 明显偏乐观。
- correction 没摊销:每轮 correction 走一次 M=1 全量 decode(读全量权重),和普通 decode 一样贵,把投机省下的部分又吐回去。
五、边界与已知限制
- 吞吐仍低于无 spec 基线:86.00 vs 118.79 tok/s(实测)。草稿上 GPU 没有让投机转正。
- 收益口径绑定本机:target=Qwen3-30B-A3B + draft=Qwen3-0.6B,
VLLM_CUDA_SPEC=4,RTX 5090。换模型规模/换卡需重测。 - draft backend 预算是写死的 2 GiB:够放 0.6B int8(~0.6 GB)+ 余量;更大 draft 需调整。
- 未做:草稿侧的 prefill 批量化、多草稿并行;本篇只覆盖单草稿、单轮 k=4。
CPU 对照(迁移前基线)
- CPU 参考:
kestrel-llm/src/model/vllm_safetensors.c:st_qwen_model_forward—— 单 backend 前向(草稿与主模型共用全局g_cuda,无 per-state backend 概念)。 - 迁移要点:把 backend 参数化——给
STQwenInferenceState挂st->cuda,草稿绑独立vllm_cuda_t(因 device wcache key 只看(layer,proj));全局g_cuda_*_ready下沉 per-state;host strip-cache 因 key 多带 N/K 可共用。 - 真机验证:未独立复现:需 30B+0.6B 双 backend 与
VLLM_CUDA_SPEC=4的接受率/吞吐。
六、小结
- wcache key 只看 (layer,proj),换模型必须拆 backend(或给 key 加命名空间)。这是「双 vllm_cuda_t」的根因,也是 第 10 篇 那条「症状≠病灶」的反面——这里病因和症状都在 key 上。
- host strip-cache 的 key 比 device wcache 更细(多带 N/K),所以「撞不撞」要分别看,不能因为 device 撞了就假设 host 也撞。
- per-state ready 标志是双 backend 的代价。把全局
g_cuda_*_ready下沉到 state,否则切 backend 会互相污染。 - 草稿上 GPU 不是收益的充分条件。投机解码的收益 = f(接受率, verify/correction 摊销),草稿速度只是其中一个因子,且不是当前的主导因子。
相关篇目:第 10 篇、第 16 篇、第 18 篇 源码与配套资源:本仓库 gitee.com/pei-xiaogua…; CPU 推理源码 gitee.com/pei-xiaogua…