系列:《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 篇 · 总纲(阿里云社区)
上一篇:15-1《入口架构:参数 → 分发(serve/自检)→ 退出》 | 下一篇:15-3《单步走一次生成》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:prefill 与 decode:推理引擎一次请求先 1 次 prefill、再循环 N 次 decode,两条独立的前向路径为什么必须分开——本篇用 gdb 断点抓到的调用链作证据,看 prompt 整段写 KV、生成逐词补 KV。
关键词:手搓 Qwen 推理引擎、千问大模型推理、prefill、decode、token 循环、GDB、Qwen3-VL、零依赖纯 C
导语:一次请求先来一遍 prefill、再循环 N 次 decode,两条独立的前向路径为什么不能合并?prompt 是现成的、可以批着算,生成的词却因因果依赖只能一个一个来,KV 的生命周期也彼此不同。本篇用 gdb 断点抓到的调用链作证据,看整段写 KV 与逐词补 KV 的分工。
15-1 里我们区分了"教材内核"与"生产路径"。今天把生产路径的两条前向摆开:prefill(一次吃下整段 prompt)与 decode(一次推进一个 token)——serve 每处理一个请求,先走前者、再循环后者。gdb 断点抓到的调用链就是证据。
1. 知识点:一次请求 = 1 次 prefill + N 次 decode
任何请求的"算"都分成两种形态:
| prefill | decode | |
|---|---|---|
| 输入 | 整段 prompt(如 18 个 token) | 1 个新 token |
| 前向数 | 1 次(内部按 mini-batch 分批走层) | 每 token 1 次 |
| 产出 | 每个位置算出 KV 并缓存;末位 logits | 下一步的 logits |
| 时机 | 请求开头一次 | 然后循环到 EOS/max_tokens |
为什么分开而不是"全按单 token 循环"?三个理由:
- prompt 是现成的,可以批:18 个 token 可以 18 路并行走一层、权重只读一遍(Day 14 的"读一遍"红利在 prefill 内部天然存在)——若当成 18 次 decode 串行做,权重要读 18 遍;
- decode 的词是现算的,只能串行:第 t+1 个 token 依赖第 t 个的输出(13-1 的因果),必须一次一个;
- KV 生命周期不同:prefill 把整段历史写进缓存(只写一次);decode 每步在缓存尾部追加一行,同时要读全历史做注意力(Day 8-10)。
引擎里两者各有独立入口(不是同一个函数加个 flag):prefill 走 st_qwen_model_prefill_batch(vllm_safetensors.c 第 10471 行起),decode 走 st_qwen_model_forward(第 9336 行起)——Day 10 稀疏的 prefill/decode 两套内核、Day 14 的批量前向(st_qwen_model_forward_batch)都是在这两条主干上的变体。
2. 对应代码:serve 层怎么编排这两步
请求处理函数 run_completion(vllm_server.c)先调 prefill(第 1321 行),再进 decode_loop(第 1334 行):
ist_reset(ctx, keep); /* Day 11:保留前缀或清零 */
st_qwen_model_prefill_batch(st, ids + keep, n_ids - keep); /* ① prefill:只算增量 */
...
int rc = decode_loop(ctx, temperature, top_p, min_p, max_tokens,
stream, conn, out, met, ...); /* ② decode:逐词循环 */
decode_loop 的循环体(第 1105–1110 行附近)每轮做:采样 argmax(st->logits) → gen++ → st_qwen_model_forward(st, id)——一次循环一个 token、一次前向(Day 13-1 的"1 循环 = 1 词 = 1 前向"就在这里)。
3. 改动后果:gdb 断点实录——18 个 token 的请求怎么被拆开
口径:RK3588 / 板端 Debug 构建 / 2026-09 / gdb 断在
st_qwen_model_prefill_batch与st_qwen_model_forward,客户端发一条Say hello in one word.。
① prefill 命中(请求开头,缓存为空):
== PF-HIT n_cache0=0 seq_len=0 ==
#0 st_qwen_model_prefill_batch (st=..., token_ids=..., n_tokens=18)
at vllm_safetensors.c:10471
#1 run_completion (...) at vllm_server.c:1321
#2 handle_chat (...) at vllm_server.c:2559
#3 server_handler (...) at vllm_server.c:3190
[PREFILL-TIMING] n=18 total=6296.1ms | GEMM=6207.9ms(98.6%) ATTN=23.9ms( 0.4%)
注意传给 prefill 的 prompt 是拼好聊天模板后的完整文本:<|im_start|>user\nSay hello in one word.<|im_end|>\n<|im_start|>assistant\n——18 个 token,一次 prefill 全吃下(Day 21 讲模板注入)。
② decode 命中(prefill 之后,缓存已有 18 行):
== DEC-HIT token_id=9707 seq_len(before)=18 cache0=18 ==
#0 st_qwen_model_forward (st=..., token_id=9707) at vllm_safetensors.c:9336
#1 decode_loop (...) at vllm_server.c:1109
#2 run_completion (...) at vllm_server.c:1334
判读:
- 调用链是分叉的:prefill 的上一帧是
run_completion:1321,decode 的上一帧是run_completion:1334 → decode_loop:1109——同一请求、两条独立路径,各自的调用栈形状不同; - 状态变化对上了:prefill 进入时
cache0=0, seq_len=0(18 个 token 还没写缓存);decode 进入时seq_len=18, cache0=18——18 行 KV 正是 prefill 写下的,decode 从第 18 位开始追加; - 一次请求 = 1×prefill + ≤8×decode:
max_tokens=8,所以 decode 最多 8 次前向(模型输出Hello后提前 EOS 停止,实际更少); - 诚实标注:这是 -O0 Debug 构建,18-token prefill 花了 6.3s(Release 下同样 18 token 是 ~0.15s 量级,Day 3-10 的口径);gdb 实证看的是结构与状态,不是速度。
4. 学员调试任务
- A 档(板端动手):用 Debug 构建复刻第 3 节(gdb 批模式断 prefill/forward,发一条 2 词以内的请求),抄出 PF 与 DEC 两段 backtrace 与
n_tokens/seq_len,回答:这请求被拆成 1 次 prefill + 几次 decode?为什么不是每次请求都 decode 满 max_tokens? - B 档(纯读源码):读 run_completion 的 prefill 调用(vllm_server.c 1321)与 decode_loop 的循环体(1105–1110),回答:①
ids + keep的keep从哪来(提示:Day 11)?② decode 循环每轮的"采样→forward"顺序为什么不能反过来?③ prefill 与 decode 各自用哪套注意力内核(Day 10 的两个函数名)?
预期输出:你能用两段 gdb backtrace 讲清"prefill 一次写 18 行 KV、decode 一次补 1 行"的数据流,并指出两条路径的代码入口行号。
收尾
- 本篇源码点名:vllm_safetensors.c(
st_qwen_model_prefill_batch10471、st_qwen_model_forward9336、批量前向 9829)、vllm_server.c(run_completion 1321/1334、decode_loop 1105–1110)。 - 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:路径拆开了,最后一关是"真的走一遍"——15-3 用 gdb 从 prefill 断点一路单步进 decode,逐词看 token 循环与 KV 追加,直到 EOS 停表。