系列:《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 篇 · 总纲(阿里云社区)
上一篇:14-3《批处理正确性边界:margin 0.71 vs 0.032》 | 下一篇:15-2《prefill 与 decode:两条路径为何分开》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:入口架构:推理引擎的 main 拿到命令行参数后,怎么在 serve、离线转换、自检三种"命运"间分发、怎么初始化、最后怎么退出——本篇用 gdb 在板端抓真实的入口调用链,把参数到命运的映射画成图。
关键词:手搓 Qwen 推理引擎、千问大模型推理、入口架构、参数分发、自检套件、GDB、Qwen3-VL、零依赖纯 C
导语:引擎只有一个二进制、一个入口,却在拿到命令行参数后分出三种命运:常驻的 HTTP serve、一次成型的离线转换,以及跑完即退的自检套件。本篇站到入口看全貌,用 gdb 在 RK3588 板端抓出真实调用链,把「参数 → 分发 → 退出」画成一张图。
前 14 天我们不断进出引擎的各个角落。今天站到入口看全貌:main() 拿到命令行参数后,怎么在 serve / 离线转换 / 自检 之间分发、怎么初始化、最后怎么退出——用 gdb 在板端抓真实的调用链。
1. 知识点:一个入口,三种命运
引擎是一个二进制(0.8 MB,Day 1-1),入口只有 main(main.c 第 4756 行)。它的分发逻辑一句话:先统一解析参数,再按"你给了什么"决定命运:
- 啥都没给(双击/裸跑):默认进入 HTTP serve(main.c 第 5020–5022 行,端口 8080);
- 给了
--convert-vqf / --convert-gguf:走离线转换(第 5024/5038 行),完事直接 return——转换是"一次成型"的批处理,不驻留; - 给了
--serve / --port:进 serve 常驻(第 5088–5103 行,vllm_serve_main); - 给了别的(如
--wmode q4):落进自检套件(第 5128–5156 行):先跑一串无模型的确定性测试(Day 1 的自检门在这里全家桶),再跑模型基准(找不到模型就打印[SKIP]),最后跑公理覆盖检查,然后退出。
关键纪律:serve 与自检是两条互斥命运——自检套件跑完就 return;serve 则把自己挂在 HTTP accept 循环上永不返回(除非被 kill)。这就是 Day 14 那些请求能一直来的原因:进程没有"结束"的概念,只有"监听"。
2. 对应代码:main 的骨架与初始化顺序
main 的三段式(main.c):
int main(int argc, char **argv) {
/* 第 4756 行 */
...
for (i = 1; i < argc; i++) {
... } /* ① 统一参数解析(几千行的 if/else) */
...
if (g_serve_port >= 0) {
/* ② 分发 */
return vllm_serve_main(...); /* serve:常驻 */
}
if (bench_mixed == 1) {
st_bench_mixed_precision(); return 0; }
if (bench_mixed == 3) {
st_test_sparse_attn(); return 0; } /* --test-sparse,Day 10-3 那个会崩的自检 */
...
if (!perf_only) {
/* ③ 自检套件(无模型先行) */
test_superposition_axioms();
test_ntt_transform();
test_kvcache();
test_inference_pipeline(); /* 我们的 gdb 目标 */
test_real_inference();
vmm_self_test();
}
test_performance_benchmark(); /* 模型基准:找不到模型 → [SKIP] */
...
printf("\n[ALL TESTS COMPLETE]\n"); /* ④ 退出 */
return 0;
}
初始化顺序不是"一个 init 函数全干",而是按需分层:
- 设备/平台画像在最早的
[DEV]行完成(vllm_platform.h,Day 2); - serve 模式里模型加载是异步/懒的(Day 3 的 VQF mmap);
- 自检套件的"小引擎"(教材内核)由
vllm_kestrel_init初始化(见第 3 节 gdb 实证)。
3. 改动后果:gdb 打断点,看真实的入口调用链
口径:RK3588 / 板端 Debug 构建(
-O0 -g -march=armv8.2-a+dotprod,gcc 11.4)/ 2026-09。gdb 批模式:gdb -batch -x cmd ./build-debug/vllm_kestrel --wmode q4,断点在vllm_kestrel_init与vllm_kestrel_generate。
第一次命中(断点停在 vllm_kestrel_init,打印调用栈与配置):
== HIT vllm_kestrel_init ==
#0 vllm_kestrel_init (config=0x...) at vllm_scheduler.c:176
#1 test_inference_pipeline () at main.c:213 ← 谁调用 init
#2 main (argc=3, argv=...) at main.c:5132 ← 谁调用测试
cfg layers=2 heads=4 hd=64 vocab=32000 kv_bs=8 ← 传给 init 的配置
第二次及之后(vllm_kestrel_generate 每步一停,5 步 5 停):
== HIT vllm_kestrel_generate ==
#0 vllm_kestrel_generate (kvcache=..., sched=..., output_tokens=..., num_outputs=...) at vllm_scheduler.c:213
#1 test_inference_pipeline () at main.c:258
#2 main (argc=3, argv=...) at main.c:5132
对照套件输出的真实运行(同一次 gdb 会话):
=== Test 5: Full Inference Pipeline ===
[vLLM-Kestrel] Initialized with 2 layers, 4 heads, dim=64
[vLLM-Kestrel] KV-Cache: 256 blocks × 8 slots
Step 1: generated 1 tokens (running=3, finished=0)
Step 2: generated 3 tokens (running=3, finished=0)
...
Step 5: generated 3 tokens (running=2, finished=1)
[PASS] Inference pipeline: 5 steps in 0.004 seconds
[vLLM-Kestrel] Cleanup complete.
判读:
- 调用链三层分明:
main(入口/分发)→test_inference_pipeline(构造一个小模型配置:2 层/4 头/64 维/3.2 万词表)→vllm_kestrel_init(按配置分配 AttentionLayer、初始化 KV-Cache 管理)。argc=3说明--wmode q4两个参数确实先经过解析器; - generate 每步一个断点:5 步循环 = 5 次
vllm_kestrel_generate命中(main.c 第 257–267 行的for step < 5),每一步内部跑"forward → 采样 → 更新 current_pos/KV"(Day 15-3 单步拆); - "自检跑完自动退出"是真的:gdb 会话末尾程序
exited normally,打印[ALL TESTS COMPLETE]——自检命运有明确的出口,serve 命运没有。
诚实标注:这套
vllm_kestrel_init/generate(vllm_scheduler.c)是教材内核/自检层(合成权重的小引擎,PagedAttention 演示),不是 serve 每天跑的那条st_qwen_model_*生产路径。两条路径都真实存在:自检层无模型可跑(0.004s 完事),生产层要加载 2.3GB VQF。分清楚它们,是读懂这个仓库的第一课——15-2 讲生产层里 prefill 与 decode 为何分开。
4. 学员调试任务
- A 档(板端动手):用 Debug 构建复刻第 3 节:在
vllm_kestrel_init与vllm_kestrel_generate打断点跑--wmode q4,抄两份 backtrace 与cfg行;再换成--serve --port 18101跑一遍,观察"serve 命运"下这两个断点会不会被触发(预期:不会——它们属于自检层)。 - B 档(纯读源码):读 main.c 的解析循环与分发段(第 4756 行起、5020–5156 行),回答:① 裸跑(argc==1)与
--wmode q4各进哪条命运?② 自检套件与 serve 为什么是"互斥命运"?③ 若--convert-vqf与--serve同时给,代码先响应谁(提示:看 return 顺序)?
预期输出:你能画一张"入口 → 参数 → 命运(serve/转换/自检)→ 出口"的状态图,并用 gdb backtrace 证明主链路上的每个函数谁调用谁。
收尾
- 本篇源码点名:main.c(main 4756、serve 分支 5088–5103、自检套件 5128–5156、
test_inference_pipeline198–274)、vllm_scheduler.c(vllm_kestrel_init175、vllm_kestrel_generate212)。 - 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:生产层的真正前向被拆成两条路——prefill(一次处理一整段 prompt)与 decode(一次推进一个 token)。15-2 用 serve 路径的断点证据,讲清它们为什么必须分开。