真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:mmap 直挂的权重加载设计:解释冷启动快不是读得快而是建立映射、权重页由内核按需缺页载入,拆 vqf.c 里 vqf_load 头指针即映射首地址的三步,落点在 VQF 与 safetensors 两条加载路线的计时对照。
Day 1 我们说"冷启动 2 秒",Day 2 我们钉死了文件布局(_Static_assert)。今天把这两件事接起来:几 GB 的权重是怎么在一秒多里"进"到进程的? 答案不是"读得快",而是根本没读——mmap 直挂。
1. 知识点:mmap 为什么快,快在哪里
先想一个问题:一个 2.3 GB 的权重文件,冷启动只用了约 1.2 秒加载。eMMC 顺序读大约 300–500 MB/s,2.3 GB 全量读一遍至少要 5–8 秒——光是 I/O 就不够。所以引擎一定没有"全量读"。
mmap(内存映射)的做法是:把文件映射进进程地址空间,内核给你一段"看起来像内存"的区段。映射本身只建立页表关系,不搬数据;真正把磁盘页读进内存,发生在你第一次访问那个页的时候(缺页中断,由内核按需加载)。
对推理引擎来说,这带来两个质变:
- 加载 = 建立映射 + 校验元数据,不是搬运权重——时间只花在头部那几百字节和目录上;
- 权重页按需进入:推理从第一层开始读,内核就只提前取那一部分;而且同一文件被多次运行共享页缓存,第二次启动更快。
对比"读文件"路线:malloc 一块内存 → fread 全量搬 → 解析 → 量化——每个字节都要先经 CPU 拷一遍,还要自建缓存。mmap 让内核替你当"搬运工 + 缓存管理员"。
(一个关键细节:mmap 之后 malloc 出来的 memcpy 不需要了,但布局必须逐字节正确——因为磁盘字节就是进程字节,没有"读进来再纠正"的机会。这就是 Day 2 三道 _Static_assert 存在的理由。)
2. 对应代码:vqf_load 的三行"快"
打开 src/model/vqf.c 的 vqf_load(第 549 行起),加载核心就几步:
int vqf_load(STModelWeights *w, const char *path) {
st_mmap_t m;
/* ... */
if (st_mmap_open(&m, 0, path) != 0) {
/* (1) mmap 整文件 */
fprintf(stderr, "[VQF] mmap failed: %s\n", path);
return -1;
}
if (m.len < sizeof(VQFHeader) + sizeof(VQFTensor)) {
... }
const VQFHeader *h = (const VQFHeader *)m.data; /* (2) 头 = 映射首地址 */
if (h->magic != VQF_MAGIC) {
st_mmap_close(&m); return -1; }
if (h->version != VQF_VERSION) {
fprintf(stderr, "[VQF] version %u unsupported (need %u)\n", ...);
st_mmap_close(&m); return -1;
}
/* ... 校验 flags / 布局一致性 ... */
注意 (2):头指针就是 m.data,没有 memcpy、没有反序列化。后续所有权重指针都通过 vqf_mount(base, off)(第 545 行)在映射上取址——权重"驻留"在文件里,进程只是拿到了一组指针。
文件第 641–646 行的注释把这个取舍说得很透:
/* 默认跳过整文件校验:转换时已 self-check 通过,位级一致由构造保证;
* mmap 页由内核按需加载,整文件校验会把 4GB 从 eMMC 全量读一遍
* (~20s),直接抵消 mmap 的加载收益。设 VLLM_VQF_CHECK=1 才校验 */
这段注释本身就是论点:全量读一遍要 ~20 秒(4GB 量级),这正是"读文件路线"的成本量级。
3. 改动后果:同机同模型,两条路线实测对照
在板端(RK3588 / Orange Pi 5 Plus,模型目录里同时躺着 model.safetensors 4.25 GB 与预量化好的 model.vqf 2.33 GB),我们给引擎两条加载路线各掐了一次表:启动服务 → /health 返回 200。引擎每次自动加载会打印 [SERVE] auto-load: loading model ...,就绪打印 [SERVE] model ready: ...。
路线一:VQF + mmap 直挂
./vllm_kestrel --serve --port 18091 --model /mnt/emmc/Modl/Qwen3-VL-2B-Instruct \
--load-format vqf --auto-load &
# 轮询 http://127.0.0.1:18091/health 直到 200
板端实测(2026-09,页缓存已热):
[1] elapsed=1.00s health=200 [SERVE] model ready: ... (wmode=0, vision=1)
[2] elapsed=1.21s health=200 [SERVE] model ready: ... (wmode=0, vision=1)
路线二:safetensors 全量读路线(同一模型、同一进程语义,只换 --load-format safetensors):
[st] elapsed=36.65s health=200 [SERVE] model ready: ... (wmode=0, vision=1)
约 33 倍差距(36.65s vs ~1.1s)。诚实地说清这个对照包含了两个因素:
- VQF 是预量化格式,加载只需取指针;safetensors 路线要在加载期全量读入、重新量化成引擎布局(CPU 密集);
- 文件大小也不同(2.33 GB vs 4.25 GB)。
但这两点正是设计本身:VQF 把"量化"这个最贵的活从每次启动挪到一次性转换期,再用 mmap 把"搬运"交给内核。
仓库基准报告里冷启动口径是 2.01s(spawn→HTTP ready,重复 3 次中位),加载段 1175ms;本机页缓存热时我们测到 1.0–1.2s 即可就绪——冷/热缓存差就是 mmap 按需加载的直接证据(基准报告还给了对照:safetensors 路线的记忆基线 22–37s,与我们的 36.65s 落在同一区间)。
若你想亲手复现"把 mmap 换成全量读":不需要改代码——引擎的
--load-format safetensors就是这条路线(运行时全量读 + 量化)。想隔离"纯 I/O"成本,把VLLM_VQF_CHECK=1打开再加载一次,引擎会按注释所说把 2.3GB 全量扫一遍校验,你会看到加载时间从 ~1s 涨到十几秒。
4. 学员调试任务
- A 档(板端动手):
- 找到你板上的
.vqf(或.safetensors),记录文件大小; - 复现两条路线计时(脚本要点:后台起引擎 → 轮询
/health到 200 →kill),各跑一次,记录elapsed; - 连续跑两次 VQF 路线,观察第二次是否更快(页缓存命中),这正是 mmap 的按需加载在说话。
- 找到你板上的
- B 档(纯读源码):读
vqf.c的vqf_load全文,画一张"文件 → 页表 → 进程地址空间"的图,标出哪几步是 CPU 干的、哪几步是内核干的。
预期输出:你能解释"mmap 不是读得快,是没读",以及"为什么 VQF 预量化 + mmap 直挂能把 36 秒压到 1 秒"。
收尾
- 本篇源码点名:vqf.c(
vqf_load第 549 行起;VLLM_VQF_CHECK注释第 641–646 行)、st_mmap_open(vllm_platform.h)。 - 开源仓库:Kestrel-LLM (Gitee)(源码可得双许可:学习 / 学术研究免费)
关键词:mmap、权重加载、VQF、按需加载、RK3588
下篇预告:映射进来的是一串字节,而字节的"顺序"由谁说了算?下一篇 3-2 讲字节序纪律——我们把真实文件的 version 字段改了个字节序,引擎立刻翻脸。