#
系列:《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-3《单步走一次生成:token 循环与 KV 追加》| 下一篇:16-2《逐字段解剖:VQFHeader / VQFSig / VQFTensor》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:推理引擎的 VQF 格式野心总览:权重来自 2.17 GiB 的单文件,把量化与内核布局固化进文件本体,兑现"加载即用、零转换零计算";本篇先看清这份野心靠什么设计落地。
关键词:手搓 Qwen 推理引擎、千问大模型推理、VQF、量化、布局固化、RK3588、Qwen3-VL、零依赖纯 C
导语:千问大模型的权重,能不能跳过所有现场转换直接可用?这篇从 VQF 格式的野心讲起:它把量化类型与内核布局一起固化进一份 2.17 GiB 的单文件,让加载退化为 mmap 加指针直挂,为 RK3588 上的零依赖纯 C 推理引擎兑现"加载即用"。
前 15 天我们把"怎么算"讲透了(量化、重排、GEMM、注意力、KV)。但引擎的权重不是从一坨裸字节里现算出来的——它来自一个 2.17 GiB 的单文件。今天开始连续三天深潜这个文件格式 VQF:它为什么能"加载即用、零转换零计算",这个野心是靠什么设计兑现的。今天先看总设计。
1. 知识点:普通权重文件 vs "内存布局的镜像"
对比三种容器就明白 VQF 的野心在哪:
- safetensors:一个 JSON 索引 + 若干字节块。索引告诉你"张量名叫什么、什么形状、数据在哪个偏移",加载器拿到的是原料——必须自己分配内存、逐张量拷贝/反量化/重排,才能变成可推理的权重。原料与运行时布局是两回事。
- GGUF:多一层量化元数据(qtype/scale 编码在数据块里),但"加载后要不要把 Q4 解成 float、要不要转 8x8 tile"仍由运行时决定——同一文件在不同参数下可能得到不同内存布局。
- VQF:把三样东西固化进文件本身:① 每个张量的量化类型(
qtype);② 内核使用的布局变体(flags:8x8 tiled?4x4 nibble?embed 是不是 F16?);③ 推理所需的全部模型属性(arch,自包含)。文件不再只是"数据",而是内存布局的镜像:加载 = mmap + 元数据校验 + 指针直挂。
仓库技术文档第 195 行原话是:VQF 把量化 + 布局固化到文件,加载 = mmap + 元数据校验 + 指针直挂(零转换零计算)。这就是 16-1 的题眼。
代价随之而来:布局一旦固化,加载时就"必须"是那个布局。文件说"我的 q4 是 4x4 布局",而当前引擎配置说"我要 8x8"——怎么办?答案是拒绝加载,而不是悄悄重排(vqf.c 布局 flags 一致性校验)。这是"可验证性高于便利性"的取舍:宁可报错,也不让同一文件在不同配置下悄悄产生不同的位级行为。
2. 对应代码:一个头文件就把"野心"钉死
格式定义在独立头文件 vqf_format.h。文件头注释(第 9–14 行)直接给出 v2 布局总览:
[0..432) VQFHeader(含 VQFSig 签名块)
[448..) 目录 entries(VQFTensor × n_tensors,64B/个)
[data_offset..file_len) 数据区(64B 对齐;VQF_FLAG_ENC 时为 SM4-CTR 密文)
[file_len..) 尾部:ENC→HMAC-SM3 tag(32B);明文→FNV-1a checksum(8B)
野心落地的三根柱子(同一头文件):
#define VQF_HEADER_BYTES 4096 /* 头+目录固定 4KB,页对齐(第 23 行) */
#define VQF_ALIGN 64 /* 数据区内张量 64B 对齐(第 24 行) */
/* 布局 flags:转换时固化的内核布局变体,加载时须与当前引擎一致(第 26–35 行) */
#define VQF_FLAG_Q8_8X8 (1u<<0) /* q8_* 为 8x8 tiled 布局 (VLLM_Q8_8X8=1) */
#define VQF_FLAG_Q4_4X4 (1u<<1) /* q4_* 为 4x4 布局 (repack_q4_0_4x4) */
#define VQF_FLAG_X8 (1u<<2) /* x8_* 8x8l decode 副本存在 */
#define VQF_FLAG_Q8BUF_Q4 (1u<<3) /* q8_* 实际存 pre-unpacked Q4 int8 (q4i) */
#define VQF_FLAG_G256 (1u<<4) /* q8_* 为 G=256 分组量化 */
#define VQF_FLAG_EMB_F16 (1u<<5) /* token_embed 存 F16(省 ~0.6GB,读取时转 F32) */
#define VQF_FLAG_VISION (1u<<6) /* 含 vision 张量(多模态):v_q8_ 系列 */
flags 里每一个 bit 都是 Day 5–7 讲过的内容:Q8 对称量化(5-2)、q4_0 格式(7-1)、8x8 布局重排(6-2)、4x4 nibble(7-1)、混合精度路由(7-2)。区别在于:那些天我们讨论的是"运行时代码怎么处理",VQF 则把"转换时的决定"写成文件里的 bit,加载时只允许"一致"。
目录与数据区偏移的计算在 vqf.c 第 316–319 行:
size_t dir_off = (sizeof(VQFHeader) + 63) & ~(size_t)63; /* 448 */
size_t data_off = (dir_off + n*sizeof(VQFTensor) + 4095) & ~4095; /* 页对齐 */
加载侧的"布局错配拒绝"在 vqf.c 第 702–713 行:把文件 flags 与引擎当前 flags 各取布局相关位(Q8_8X8|Q4_4X4|X8|Q8BUF_Q4|G256)比对,不等则打印 [VQF] layout mismatch: file=0x.. engine=0x.. 并拒绝。"引擎当前 flags"来自 vqf_cur_flags()(第 533–543 行)——它会读环境变量 VLLM_Q8_8X8、检查 st_wmode_effective() 等。换句话说:你在转换时把 8x8 固化进了文件,加载时就不能用环境变量把它关掉。
3. 改动后果:在板端让文件"自报家门"
实测口径:RK3588(Orange Pi 5 Plus)/ aarch64 / Release 构建
build-rk3588(gcc 11.4,-O2 -march=armv8.2-a+dotprod)/ 2026-09-07。文件:/mnt/emmc/Modl/Qwen3-VL-2B-Instruct/model.vqf(Qwen3-VL-2B,v2,2,331,316,232 B)。
用零依赖 Python 解析器(16-3 会讲它怎么写的,先直接用)读固定头:
$ python3 parse_vqf.py model.vqf --full | head -12
size = 2331316232 (2.17 GiB)
magic = 0x57465146 bytes=b'FQFW'
version = 2
flags = 0x063 Q8_8X8,Q4_4X4,EMB_F16,VISION
n_tensors = 41
arch : dim=2048 layers=28 heads=16 kv=8 hd=128 ffn=6144 vocab=151936 maxseq=8192 ...
flags = 0x063 就是文件的"自白书",按 bit 拆开:
| bit | 置位 | 含义(对照目录实况) |
|---|---|---|
| 1<<0 | ✅ | Q8_8X8:视觉塔的 v_q8_* 权重是 8x8 tiled 布局 |
| 1<<1 | ✅ | Q44X4:文本主干的 `q4*` 权重是 4x4 nibble 布局 |
| 1<<2..4 | ⬜ | 无 x8 副本、无 q8buf_q4、非 G256 |
| 1<<5 | ✅ | EMB_F16:token_embed 存半精度(省 ~0.6GB) |
| 1<<6 | ✅ | VISION:文件含视觉张量(v_q8_* / v_patch_* / v_merger_* / v_ds_*) |
| 1<<7..8 | ⬜ | 明文、无签名(enc_iv 与 sig 块全 0,16-2 见字节) |
目录实况逐条对上了这个"自白"(parse 输出节选,qtype 见目录):
[ 0] token_embed qtype=5(F16) rows=151936 cols=2048 off=4096 bytes=622329856 ← EMB_F16
[ 6] q4_q qtype=3(Q4_0) rows=57344 cols=2048 ... ← Q4_4X4 文本块
[13] q4_lm qtype=3(Q4_0) rows=151936 cols=2048 ...
[14] v_q8_qkv qtype=2(Q8_0) rows=24 cols=3145728 ... ← Q8_8X8 视觉块
[17] v_q8_fc2 qtype=2(Q8_0) ...
这个文件是"混合精度"的:文本主干 Q4_0(4bit)+ 视觉塔 Q8_0(8bit)+ embed F16——正是设备画像里 recommended_wmode=dual 的思路(Day 7-2 的混合精度路由),只不过路由结果不是运行时算的,是转换时写死的。读取时它不必再判断"这层该用 q4 还是 q8":目录里已经写死了。
引擎"加载即用"的实证(同一文件、Release 构建、板端 serve 启动日志):
[M-A] VQF loaded: /mnt/emmc/Modl/Qwen3-VL-2B-Instruct/model.vqf
[SERVE] model=... dim=2048 layers=28 heads=16 ff=6144 vocab=151936 max_seq=8192
[M-B] tokenizer loaded
HEALTH_OK after 3s
注意 [SERVE] model= 这一行打印的几何参数全部来自文件头部的 arch,而不是 config.json——这就是"模型属性自包含"(技术文档 §4.3)。整个加载没有"反量化、重排、拷贝"环节,mmap 之后把目录里的 offset 换成指针即可(加载器锚点 vqf.c 第 747–826 行)。
诚实标注:布局错配拒绝(vqf.c 第 702–713 行)要真实验证需要一份"改了 flags 位的完整副本"(2.3 GB 拷贝)或修改引擎默认布局,本教程没做这个重实验;16-2 我们用 v1 文件头页做了同族的版本门禁实测(version 字段把关),机制的"逐字段把关、错配即拒"是一致的。另外,本文件是明文未签名(无 ENC/SIGNED),加密/签名路径的字段我们只在代码与文档层面讲解。
4. 学员调试任务
- A 档(板端动手):把
parse_vqf.py传到板端,对model.vqf跑--full;亲手把flags=0x063拆成 bit 列表,并在目录里为每个置位的 bit 找到至少一个对应的 tensor(例如 EMB_F16 → 目录第 0 项 qtype=5)。再把 4,159,295,496 B 的model.vqf.v1.bak用xxd -l 16看前 16 字节,对比 version 字段。 - B 档(纯读源码):读
vqf_cur_flags()(vqf.c 第 533–543 行),回答:① 引擎当前布局 flags 由哪几路输入决定(全局开关 / 环境变量 / wmode)?② 若把VLLM_Q8_8X8=0传给一个"转换时固化了 Q8_8X8 的文件",加载会发生什么(先推演,再读第 702–713 行确认)?③ 为什么作者选择"拒绝"而不是"加载时自动转成引擎想要的布局"?
预期输出:你能把"flags 每个 bit ↔ 目录张量 ↔ 引擎运行开关"画成一张对应表,并口头解释"布局固化"如何同时带来性能收益(免重排)与一致性保证(错配即拒)。
收尾
- 本篇源码点名:vqf_format.h(头注释 9–14、布局常量 21–24、flags 27–35)、vqf.c(dir/data 计算 316–319、
vqf_cur_flags533–543、layout mismatch 702–713、目录直挂 747–826)、main.c(vqf 路径 4235–4268)、技术文档 §4.1–4.3 - 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:既然布局和量化都固化进了一个 432 B 的头部,那这 432 B 里到底逐字节放了什么?16-2 拿板端
od的十六进制,一个字段一个字段地解剖,顺带揭一个"文档注释与真实字节不符"的彩蛋。