系列:《0.8MB 跑通 Qwen:从零实现 ARM 零依赖纯 C 推理引擎》(30 天 × 90 篇) | 适配模型:Qwen3-VL-8B-Instruct(千问3_VL_8B_Instruct)· Qwen3-VL-2B-Instruct · Qwen3-30B-A3B | 测试设备:RK3588(4×Cortex-A76 + 4×Cortex-A55,aarch64)
系列总纲:《0.8MB 跑通 Qwen》30 天 90 篇 · 总纲(阿里云社区)
上一篇:12-3《复现 13.1×:跨进程恢复实测怎么做》 | 下一篇:13-2《草稿 + 验证:spec decode 的实现》
源码精读篇:本文为源码/方法论精读,无独立实测;文中数字均引述仓库 docs 的板端实测记录
一句话导读:decode 为什么慢:真实交互里用户等的是逐词生成,推理引擎每吐一个词就是一次完整前向——本篇直面逐词、带宽、不可并行这三重结构性慢,为"一次前向多产出"的推测解码铺路。
关键词:手搓 Qwen 推理引擎、千问大模型推理、decode、逐词生成、内存带宽、Qwen3-VL、零依赖纯 C
导语:在 RK3588 上跑 Qwen3-VL-2B 这样的千问大模型,生成一个词的代价远不只是算力:每吐一个 token 都要把全部权重重读一遍,而且因果依赖让它无法并行。本篇把这重结构性慢讲清楚,也为「一次前向多产出」的推测解码埋下引子。
前 12 天我们把 prefill 玩出了花:批量、量化、稀疏、复用、落盘。可真实交互里,用户等的是逐词生成——engine 每吐一个词就是一次完整前向。今天直面 decode 的三个"结构性慢",为明天的主角(推测解码)铺垫。
1. 知识点:decode 的三重枷锁
一次 decode(生成一个 token)为什么不能像 prefill 一样轻松提速?三个原因,一个比一个本质:
- 逐词:第 t+1 个 token 是第 t 个的输出采样来的——因果依赖,没法把"接下来的 32 个词"一次性并行算出来(prefill 能批,是因为 prompt 是现成的;decode 的词还没生成);
- 带宽:每生成一个词,都要把全部权重读一遍(qkv/O/gate+up/down/lm_head 各矩阵)。10-1 里测过:decode 的 GEMM 部分在 Qwen3-VL-2B 上是 ~43ms/词(q4 dual,Day 10 表 B 可复算)——这是内存带宽的地板,不是算力问题;
- 不可隐藏:prefill 的 GEMM 可以在层与批之间流水;decode 每一步的层与层之间虽然能流水,但"读权重"这件事每词都得重来,没有第二份工作可以塞进带宽空隙。
Day 10 的实测把它拆得很清楚(2K 上下文、精确 q8 KV):每词总耗时 ≈ 92ms,其中 GEMM 43ms 恒定 + 注意力 49ms 随上下文涨。注意力可以用稀疏(Day 10)压,GEMM 的 43ms 是 decode 的硬地板。
那 43ms 还能不能绕过?方向只剩一个:让一个词的前向"产出"多于一个词——这就是推测解码(speculative decode)的出发点:反正都要读一遍权重,不如拿这遍前向去"验证"好几个候选词。
2. 对应代码:generate 主循环长什么样
decode 的"逐词"在 serve 的 decode_loop 里(vllm_server.c 第 900 行起),骨架是:
while (gen < max_tokens) {
/* 一次循环 = 一个词 */
int id = -1;
...
if (spec_on && gn < gcap) {
/* 13-2 的主角:先试一轮草稿 */
int K = spec_build_draft(...);
if (K > 0) {
... 批量验证 + 接受/回退 ... }
}
if (emit_n <= 0 && id < 0) {
id = 贪心 argmax(st->logits); /* 普通单步解码 */
}
...
st_qwen_model_forward(st, id); /* 一步前向,往前挪一个位置 */
}
关键在最后一行:普通模式下,一次循环只产出 1 个 token、只做 1 次前向。第 926–1024 行那个 spec_on 分支(13-2 拆)尝试打破"1 循环 = 1 token"——一次前向验证 K 个草稿,一次循环吐出 K+1 个 token。
3. 改动后果:把 decode 的地板数字钉在桌上(板端实测)
口径:RK3588 / Qwen3-VL-2B / 2026-09 / serve 贪婪 / 默认权重 dual、KV q8。数据来自引擎逐词日志与客户端计时。
| 上下文 | GEMM(qkv+o+gu+down+lm) | 注意力 | 每词总耗时 | 出处 |
|---|---|---|---|---|
| ~2K | ~43 ms | ~49 ms | ~92 ms | Day 10 表 B(精确) |
| ~4K | ~44 ms | ~111 ms | ~155 ms | Day 10 表 B(精确) |
判读:
- GEMM ~43ms 与上下文无关——它是"每词读一遍全部权重"的带宽成本,decode 的地板;
- 注意力随上下文涨——Day 10 的稀疏把它压平(4K 档 111→55ms),但 GEMM 纹丝不动;
- 所以 decode 提速只剩两条路:压缩权重读取(量化,Day 5-7 已做)或每次前向多产出(Day 13 的 spec)——后者不动权重量化,而是把"读一遍权重"的价值放大到多个词上。
前置诚实:spec 不是免费的——它要先花一次批量 prefill(K 个草稿一起算)去验证,赌中了赚 K 个词、赌不中赔一次批量前向。Day 10 里 2B 的 decode 是 ~120ms/词量级(比文档当年假设的 341ms/词快得多),验证开销占不占得回来,13-2/13-3 用板端数字回答。
4. 学员调试任务
- A 档(板端动手):跑一次普通生成(
--spec不加),对同一 prompt 用VLLM_DEC_PROF=1抓[DEC-SINGLE],数一数:生成 N 个词,日志里出现几条[DEC-SINGLE]?是不是 N 条、每条 1 个前向?(预期:是——普通 decode 每词一次前向,这是"逐词"的日志级证据。) - B 档(纯读源码):读 decode_loop(vllm_server.c 第 900–1110 行),回答:① 普通路径下
gen每次循环 +1 的代码在哪?②st_qwen_model_forward每循环被调用几次?③ 第 932 行if (spec_on && ...)里 spec_on 成立要同时满足哪些条件(写出来)?
预期输出:你能用"1 循环 = 1 词 = 1 前向"概括普通 decode,并逐条写出第 910–912 行 spec_on 成立的全部条件。
收尾
- 本篇源码点名:vllm_server.c(decode_loop 900、普通路径 1105–1110、spec_on 门 910–912)。
- 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:"一次前向产出多个词"听着像作弊——13-2 拆
spec_build_draft与批量验证:草稿从哪来(n-gram)、怎么验(K 个位置一次 prefill)、赌错怎么回退(KV rollback),以及为什么回退是无损的。