#
系列:《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 篇 · 总纲(阿里云社区)
上一篇:16-1《VQF 的野心:把量化与布局固化到文件》| 下一篇:16-3《tensor 目录与布局重排的落盘顺序》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:推理引擎的 VQFHeader 逐字段解剖:从 432 字节的 v2 头部出发,沿固定偏移拆 VQFSig 与 VQFTensor,看 _Static_assert 与版本门禁如何把关,并起底一处注释与真实字节不符的彩蛋。
关键词:手搓 Qwen 推理引擎、千问大模型推理、VQFHeader、VQFSig、VQFTensor、VQF、字节序、Qwen3-VL、零依赖纯 C
导语:上一回看清了 VQF 的整体野心,这一篇把镜头推到字节级:432 字节的文件头如何用固定偏移而非自描述来存放 VQFSig 与 VQFTensor,
_Static_assert又怎么替这组数字上保险。跟着od的真机对照,读懂决定千问模型权重能否被零依赖纯 C 引擎直接挂载的头部契约。
16-1 看了"野心",今天把 432 字节的头部拆到字节级:每个字段在文件里的偏移、真实文件里对应的十六进制、以及 _Static_assert 为什么是这组数字的保险。真机上 od 逐字节对照,顺便抓一个"文档注释与真实字节不符"的彩蛋——这正是 Day 3 字节序纪律的实战。
1. 知识点:小头 + 大目录 + 页对齐的固定布局
VQF v2 的文件头是一段不靠自描述、靠固定偏移的 C 结构体直接落盘(little-endian):
| 偏移 | 大小 | 字段 | 本文件实测 |
|---|---|---|---|
| 0 | 4 | magic = VQF_MAGIC |
46 51 46 57 |
| 4 | 4 | version = VQF_VERSION |
02 00 00 00 → 2 |
| 8 | 4 | flags |
63 00 00 00 → 0x063 |
| 12 | 4 | n_tensors |
29 00 00 00 → 41 |
| 16 | 164 | arch(VQFArch,模型属性自包含) |
dim=2048 / layers=28 / … |
| 180 | 4 | (对齐填充到 8B 边界) | 00 00 00 00 |
| 184 | 8 | data_offset(数据区起始,4KB 页对齐) |
00 10 00 00 00 00 00 00 → 4096 |
| 192 | 8 | file_len(不含尾部 tag) |
00 10 f5 8a 00 00 00 00 → 2,331,316,224 |
| 200 | 16 | enc_iv[16](加密时有效) |
全 0(明文) |
| 216 | 16 | enc_rsvd[16] |
全 0 |
| 232 | 200 | sig(VQFSig,SM2 签名块) |
全 0(未签名) |
| 432 | 16 | (保留,头部结构体之外) | 全 0 |
| 448 | n×64 | 目录 VQFTensor[] |
41 项,token_embed 起 |
头部固定 432 B(_Static_assert(sizeof(VQFHeader) == 432)),目录从 448 起——432 与 448 之间那 16 B 是结构体外的保留区,为什么不是 432 直接从目录开始?因为目录要"64B 对齐起步":dir_off = (432 + 63) & ~63 = 448(vqf.c 第 316 行)。头 + 目录整体塞进第一个 4KB 页(本文件目录占 41×64 = 2,624 B,448 + 2,624 = 3,072 < 4,096,正好一页;张量再多会整体把数据区推到下一页,见 16-3 的公式)。
为什么字段全用固定偏移、不用"tag + 变长"? 因为加载器要把头直接当作 C 结构体读(const VQFHeader *h = (const VQFHeader *)m.data;,vqf.c 第 560 行),任何变长都会让这个转换失效。代价是版本不兼容必须显式管理——这就是 version 字段存在的意义:v1 与 v2 的头部大小都不同(v1 没有 200 B 的 VQFSig,头 232 B,目录从 256 起),读取器绝不能拿 v2 的结构体去读 v1 的文件,所以 version 是 mmap 之后第一个要判的字段(vqf.c 第 562–566 行:[VQF] version %u unsupported)。
2. 对应代码:三个结构体 + 三个 _Static_assert
vqf_format.h 里依次是三个结构体:
typedef struct {
/* VQFSig,200 B(第 69–77 行) */
uint8_t pub[64]; /* SM2 公钥 x||y */
uint8_t r[32], s[32]; /* 签名分量 */
uint8_t digest[32]; /* 被签名的摘要 D */
uint8_t id[VQF_SIG_ID_MAX]; /* ID_A(自描述) */
uint32_t id_len, rsvd;
} VQFSig;
typedef struct {
/* VQFHeader,432 B(第 79–90 行) */
uint32_t magic, version, flags, n_tensors;
VQFArch arch; /* 164 B */
uint64_t data_offset, file_len;
uint8_t enc_iv[16], enc_rsvd[16];
VQFSig sig;
} VQFHeader;
typedef struct {
/* VQFTensor,64 B(第 92–98 行) */
char name[32]; /* 内部张量名:q4_q / v_q8_fc1 / token_embed ... */
uint32_t qtype; /* VQF_QT_* */
uint32_t rows, cols; /* 几何(诊断用) */
uint64_t offset, bytes; /* 64B 对齐的偏移与字节数 */
} VQFTensor;
头文件末尾(第 101–103 行)的三行是"三方共享头"的保险:
_Static_assert(sizeof(VQFSig) == 200, "VQFSig layout drift");
_Static_assert(sizeof(VQFHeader) == 432, "VQFHeader layout drift");
_Static_assert(sizeof(VQFTensor) == 64, "VQFTensor layout drift");
这个头被引擎(vqf.c)、离线签名工具、KAT 测试三方共用(头注释第 1–7 行)。任何一方改了字段导致尺寸漂移,编译期直接失败——VQFTensor 尤其关键:它内部有 4 B 对齐填充(name[32] 之后 qtype/rows/cols 各 4 B 到 44 B,offset 是 u64 需要 8B 对齐,所以在 44–48 处自动插 4 B 填充,offset 从 48 起),如果谁用 sizeof(VQFTensor) 之外的假设去遍历目录,就会像 Day 2 讲的 _Static_assert 一样当场暴露。
3. 改动后果:板端十六进制逐字段对照
实测口径:RK3588(Orange Pi 5 Plus)/ aarch64 / 2026-09-07。文件:
/mnt/emmc/Modl/Qwen3-VL-2B-Instruct/model.vqf;工具:od -A x -t x1。
3.1 头部前 16 字节(偏移 0–15)
$ od -A x -t x1 -j 0 -N 16 model.vqf
000000 46 51 46 57 02 00 00 00 63 00 00 00 29 00 00 00
逐字段:magic = 46 51 46 57;version = 02 00 00 00;flags = 63 00 00 00;n_tensors = 29 00 00 00 = 41。
彩蛋来了:文档与头注释都把这个 magic 标注为 "VQFW"(技术文档 §4.1 第 202 行 magic "VQFW" 0x57465146,vqf_format.h 第 21 行 /* "VQFW" */)。可真实文件头 4 字节是 46 51 46 57,按 ASCII 读是 FQFW。为什么对不上?
因为常量才是真,注释只是人写的。数值 0x57465146 的最高字节是 0x57('W'),最低字节是 0x46('F');小端落盘时最低字节在前,于是磁盘上是 46 51 46 57。若想让字节序列拼出 VQFW(56 51 46 57),常量得是 0x57465156——和实际的 0x57465146 只差最低一字节(0x56='V' vs 0x46='F')。也就是说这个 ASCII 标注大概率是从"本意"抄错了。好在 magic 只参与数值比较(vqf.c 第 561 行 h->magic != VQF_MAGIC),标注不影响任何校验;但这正是一个完美的教学样本:字节序纪律下,要以 od/xxd 的字节为准,注释和文档都可能骗你(Day 3 的"十六进制纪律"在这里抓到一个活的例子)。
3.2 中段:data_offset / file_len(偏移 184 / 192)
$ od -A x -t x1 -j 184 -N 24 model.vqf
0000b8 00 10 00 00 00 00 00 00 00 10 f5 8a 00 00 00 00
0000c8 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
data_offset(184)=00 10 00 00 00 00 00 00→0x1000= 4096(4KB 页对齐 ✅);file_len(192)=00 10 f5 8a 00 00 00 00→0x000000008af51000= 2,331,316,224;- 文件总长 2,331,316,232 =
file_len+ 8(尾部 FNV checksum)→ 加载器的 size 兜底(vqf.c 第 569–573 行:m.len != h->file_len + tag_len即拒)在每次加载都会过一遍; - 200 起 32 B 全 0 =
enc_iv+enc_rsvd均空(明文文件,符合 flags 无ENC位)。
3.3 arch 与 config.json 的对照(自包含的边界)
解析器把 arch(偏移 16 起,164 B)读出来,与模型目录的 config.json 对照(板端 jq):
| 字段 | VQF arch(文件) | config.json text_config |
一致? |
|---|---|---|---|
| dim / hidden_size | 2048 | 2048 | ✅ |
| n_layers | 28 | 28 | ✅ |
| n_heads / n_kv_heads | 16 / 8 | 16 / 8 | ✅ |
| head_dim | 128 | 128 | ✅ |
| ffn_dim / intermediate_size | 6144 | 6144 | ✅ |
| vocab_size | 151,936 | 151,936 | ✅ |
| rope_theta | 5,000,000 | 5,000,000 | ✅ |
| max_seq_len | 8192 | max_position_embeddings = 262,144 | ❌ |
不一致的 max_seq_len 恰恰是"自包含 ≠ 照抄 HF"的证据:转换时引擎把它截断到引擎支持的 8192(main.c 第 4047 行 if (cfg.max_seq_len > 8192) cfg.max_seq_len = 8192;,safetensors 路径同样在第 4291 行,注释写明"262144 若不截断会在初始化时崩掉缓存")。所以 VQF arch 记录的是"这台引擎实际可用的属性",不是 HF 配置的镜像——文件自包含的是运行时事实,不是上游事实。serve 日志 [SERVE] model=... max_seq=8192 打印的正是这个截断值。
3.4 版本门禁:拿真实 v1 文件头页实测"逐字段把关"
model 目录里存着旧版文件 model.vqf.v1.bak(4,159,295,496 B)。它头部前 16 字节是:
$ xxd -g1 -l 16 model.vqf.v1.bak
46 51 46 57 01 00 00 00 63 00 00 00 31 00 00 00
对比 v2:magic 相同(同族格式),version = 01(v1),flags 相同(0x063),n_tensors = 49(v1 带 x8 副本张量,文件更大)。v1 头没有 200 B 的 VQFSig,头部只有 232 B、目录从 256 起——结构体完全不同,绝不能混读。
板端实测:把 v1 文件的真实头页(4 KB)放进一个 v2 模型的目录结构里 serve(只拷头页,数据区未拷贝——因为版本检查在读数据之前就会拦下):
[SERVE] auto-load: loading model .../v1dir (wmode=0)
[VQF] version 1 unsupported (need 2) ← vqf.c:563 拦截
[SERVE] FATAL: model load failed (see [ST]/[TOK]/[VIS] errors above)
[SERVE] VQF load failed: .../v1dir/model.vqf
注意日志顺序:version 检查发生在 size 兜底之前(vqf.c 第 562 行 version 判定在 569 行 size 判定之前),所以哪怕文件被截断到只剩头页,读取器也先报"版本不支持"而不是"大小不对"——字段把关有严格的先后顺序:magic(文件是不是 VQF 族)→ version(我能不能用当前结构体读你)→ size(文件有没有被截断/拼接)→ 布局 flags(数据长什么样跟我认不认)→ 尾部 tag(内容有没有被篡改)。每一关都只需要上一个字段的结论,这是固定偏移头部才做得到的。
4. 学员调试任务
- A 档(板端动手):① 用
od分别 dump 偏移 0、184、192 处,把字节抄下来并换算成十进制,与parse_vqf.py输出核对;② 对model.vqf.v1.bak读前 16 字节,回答:它和 v2 的差异字段是哪个?③ 用 Pythonstruct.unpack('<I', data[8:12])读 flags,再手动解出 0x063 的每个置位。 - B 档(纯读源码):读 权重保护与可验证推理方案.md §3.3 的字段表与 vqf_format.h,回答:① 为什么
VQFSig会让目录起点从 v1 的 256 挪到 v2 的 448?(算一下 232+200 后的 64B 对齐)② 若有人把n_tensors改成 42 而不动目录,加载器先在哪一关失败?③file_len为什么"不含尾部 tag"?若含了,size 兜底还能不能成立?
预期输出:一张你自己抄过字节、亲手换算过的头部字段表;并能解释"version 决定能否继续读、size 决定有没有被截断"两道闸的分工。
收尾
- 本篇源码点名:vqf_format.h(VQFSig 69–77、VQFHeader 79–90、VQFTensor 92–98、
_Static_assert101–103)、vqf.c(magic 561、version 562–566、size 569–573、layout flags 702–713)、main.c(max_seq 截断 4047/4291)、权重保护与可验证推理方案 §3.3 - 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:头部看完了,真正告诉你"权重在哪、多大"的是目录。16-3 解剖 64 B 的
VQFTensor、数据区的落盘顺序,并交给你一个 10 行 Python 解析器 + FNV 校验复算的完整闭环。