系列:《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-1《跨进程恢复——服务重启了,会话不能断》 | 下一篇:12-3《复现 13.1×:跨进程恢复实测怎么做》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:磁盘 KV 快照格式:推理引擎落下的那份 0.47 GB 快照到底是什么——64 字节头加 token ids 加 28 层每层 K、V 的 F32 原样行,本篇逐字段对账到字节级,回答快照为什么这么胖、写读又为何宁大勿错。
关键词:手搓 Qwen 推理引擎、千问大模型推理、Qwen3-VL、零依赖纯 C、磁盘 KV、快照格式、F32、跨进程恢复
导语:那份 0.47 GB 的磁盘 KV 快照到底是什么?本篇逐字段拆开:64 字节头(magic、版本、几何、token 数与哈希)加 token ids,再加 28 层每层 K、V 的 F32 原样行,一路对账到字节级,回答快照为什么这么胖、以及写读为何选择"宁大勿错"。
12-1 里 S1 结束落下一个 471,605,344 B 的快照。今天拆开看:这 ~0.47 GB 到底是什么?答案不神秘:一个 64B 的头 + 一段 token ids + 28 层 × 每层 K 和 V 的 F32 原样行。把字节算明白,比背任何公式都牢。
1. 知识点:为什么"原样 F32"会让快照这么胖
KV 的每一行是"一个 token 的 K 或 V 向量"。Qwen3-VL-2B:8 个 KV 头 × 128 维,一个向量 = 1024 个 float。
- 一个 token、一层:K(1024 f32=4096 B)+ V(4096 B)= 8192 B;
- 28 层 → 229,376 B/token(≈224 KiB);
- 加 4 B 的 token id → 229,380 B/token。
所以快照大小 ≈ 64 + n × (4 + 28×8192)。n=2056 时 = 64 + 2056×229,380 = 471,605,344 B——和 ls -la 看到的字节数分毫不差(下面是实测对账)。n=8109(8K 会话)就是基准报告那个 1.87 GB。
为什么用 F32 而不是把内存里的 q8 直接落盘?两个理由,都是"宁大勿错":
- 位级一致:恢复 == 重算,是 12-1 能宣称"回答与从未重启一致"的前提。F32 原样拷贝没有第二次量化损失;q8 落盘要再标定一次 scale,恢复出来的 KV 和内存里的 q8 不是同一批字节;
- 简单可校验:几何字段写进头里,读的时候逐项核对,错模型直接拒载。
代价就是胖 + 写读慢(8K 会话写读 eMMC 各 ~15s)——用体积和 I/O 买正确性,边缘设备上这是诚实的选择(L3 磁盘稀疏那套 Q4 压缩路径是另一种取舍,见优化配置文档 §5,默认未开)。
2. 对应代码:把文件布局和实测字节对上
布局写在 vllm_safetensors.c 第 12320–12335 行的头注释里,照抄如下(加行号):
[0..3] magic "VLKV"(实际字节序见下方 xxd 实测)
[4..7] version = 1
[8..11] n_layers
[12..15] nkv (KV heads)
[16..19] hd (head dim)
[20..23] kv_bs (KV block size in positions)
[24..27] file_n_tokens (rows saved)
[28..31] kv_dim (nkv * hd)
[32..39] token hash (FNV-1a 64)
[40..43] fmt = 0 (F32 K/V payload)
[44..63] reserved (zero)
tokens: file_n_tokens * int32
payload: per layer: per full kv_bs block: K block then V block
板端实测对账:这是 12-1 那份真实快照的头 64 字节(xxd -l 64),逐字段圈出来:
00000000: 564b 4c56 0100 0000 1c00 0000 0800 0000 VKLV............
00000010: 8000 0000 2000 0000 0808 0000 0004 0000 .... ...........
00000020: b89c b5a6 0a3b dba0 0000 0000 0000 0000 .....;..........
| 字段 | 字节区间 | 实测值 | 含义 |
|---|---|---|---|
| magic | 0–3 | 56 4b 4c 56 |
魔数(小端存 0x564C4B56) |
| version | 4–7 | 01 00 00 00 = 1 |
格式版本 |
| n_layers | 8–11 | 1c 00 00 00 = 28 |
层数 ✓ |
| nkv | 12–15 | 08 00 00 00 = 8 |
KV 头数 ✓ |
| hd | 16–19 | 80 00 00 00 = 128 |
头维 ✓ |
| kv_bs | 20–23 | 20 00 00 00 = 32 |
块大小 ✓ |
| file_n_tokens | 24–27 | 08 08 00 00 = 2056 |
快照行数 ✓ |
| kv_dim | 28–31 | 00 04 00 00 = 1024 |
nkv×hd ✓ |
| token hash | 32–39 | b89c b5a6 0a3b dba0 |
FNV-1a 64 |
再用几何字段对账文件大小:
64(头) + 2056×4(token ids) + 2056×28层×(1024×4B×2)
= 64 + 8224 + 471,597,056 = 471,605,344 B ← 与 ls 完全一致
写/读实现(vllm_safetensors.c):写是 st_kv_disk_save(第 12357 行起)按 kv_bs 块流式 fwrite(先 K 后 V,第 12389–12400 行),不是一次性塞内存;读是 st_kv_disk_load(第 12406 行起)先逐字段校验几何(第 12428–12430 行,"错模型拒载"的硬闸),再按文件的块边界跳读——注意注释第 12442–12447 行那个坑:只恢复前 n_tokens 行时,必须沿"文件的块边界"走,否则读到半块会错位(K 块没读完就去跳 V)。
目录纪律(vllm_server.c):快照上限 DISKKV_MAX_TOKENS = 8192(第 451 行,与 max_seq 窗口对齐);目录最多 DISKKV_MAX_FILES = 8 个文件(第 455 行),超出按 mtime LRU 淘汰(第 648 行 [KV-DISK] LRU evicted)。12-1 的实验跑完目录里躺着两个文件(S1 与 S2 各存一份,共 ~948 MB)——每轮成功请求都会落一份,8 份封顶、旧的自然被淘汰。
3. 改动后果:改一个字节会怎样?答:被头校验拦住
快照的安全模型分两层:
- 文件名层:文件名是 token 序列的 FNV-1a 哈希(
kv_<fnv64>.kv),serve 层索引它;但哈希不是信任根——serve 层在 load 前还会做逐 token LCP(vllm_server.c 第 583 行dkv_best_lcp),哈希碰撞最坏只是"多查一次文件",绝不会恢复错 KV(代码注释原话在第 12342–12343 行); - 头校验层:load 时几何字段逐项比对(层数/头数/维数/块大小),一个不对就整个拒载(
st_kv_disk_load返回非 0)——比如把这份 28 层 2B 模型的快照喂给 36 层的模型目录,会被干净地跳过,不会用错几何去解释字节。
动手验证(A 档任务):把 file_n_tokens 字段(偏移 24–27)改大,或把 nkv 改成 7,重跑恢复——load 会拒载或按边界错位拒绝,日志不会有 loaded ... prefix。改回原值则恢复如初。
4. 学员调试任务
- A 档(板端动手):复刻第 2 节的 xxd 对账:跑一次
--disk-kv出快照 →xxd -l 64 <file>逐字段圈出 → 用ls -la的字节数验证公式64 + n×(4 + 28×8192)。再手工改file_n_tokens字段(如dd conv=notrunc),重启进程重放请求,观察 load 拒绝路径。 - B 档(纯读源码):读
st_kv_disk_load(vllm_safetensors.c 第 12406 行起),回答:① 几何校验为什么逐项写死而不是"尝试恢复再说"?② 为什么只恢复前 n_tokens 行时"必须按文件块边界跳"(第 12442–12447 行注释)?③ token ids 为什么不重新校验——serve 层在哪一步已经替它做过了?
预期输出:你能不看文档、只靠 xxd + 几何字段把任意一份 .kv 的 token 数与预计字节数算出来,并对上 ls -la。
收尾
- 本篇源码点名:vllm_safetensors.c(布局注释 12320–12335、save 12357、load 12406、
fnv1a6412344)、vllm_server.c(DISKKV_MAX_TOKENS451、DISKKV_MAX_FILES455、LRU 648)。 - 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:快照与字节都懂了,来复现仓库那句"13.1×"——12-3 把跨进程恢复实验的每一步命令、每一行日志摆出来,并诚实拆解"为什么 wall 口径只有 7.1× 而 prefill 口径 23.6×"。