系列:《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-3《tensor 目录与布局重排的落盘顺序》| 下一篇:17-2《GGUF → VQF:Q4_0…Q8_K 反量化再量化》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:推理引擎的 safetensors 转 VQF 一次成型:4.25 GB 的源模型如何变成 VQF 单文件,转换与 serve 共用同一条加载量化路径、重排只在转换时发生一次,板端同源重转验证位级可复现。
关键词:手搓 Qwen 推理引擎、千问大模型推理、safetensors、vqf_write、VQF、量化、位级一致、Qwen3-VL、零依赖纯 C
导语:权重从 safetensors 变成 VQF 单文件,凭什么能保证推理时读到的就是转换时写入的?答案藏在一条共用代码路径里——转换与 serve 走同一套量化、重排函数,重排只在转换期发生一次。这篇看 vqf_write 如何一次成型,并用板端同源重转检验千问权重的位级可复现。
Day 16 把 VQF 从头解剖到字节。今天看"写"的一侧:一个 4.25 GB 的 safetensors 模型,怎么变成 2.33~4.16 GB 的 VQF 单文件?答案在两条纪律里:转换序列与 serve 加载完全同一条代码路径(保证位级一致),量化与重排只在转换时发生一次(运行时零转换)。板端真机重转三次给你看。
1. 知识点:转换 = "加载一遍,原样固化"
关键认知:VQF 转换不是"转格式",而是"把内存里已经量化重排好的权重,按原布局抄进文件"。引擎在 serve 时是怎么把 safetensors 变成可用权重的?分配 → 逐层读 F32 → 量化成 Q8_0/Q4_0 → 8x8/4x4 重排(Day 5–7 讲的全过程)。VQF 转换走的是完全相同的函数序列,只是最后多一步 vqf_write 把内存布局按目录固化落盘。
于是"位级一致性"是构造出来的,不是碰巧的:转换与推理共用 f32_to_q8_0 / f32_to_q4_0 / repack_* 同一套代码(vqf.c 头注释第 9–11 行的红线声明、技术文档 §4.7"与 serve 完全相同的加载/量化序列")。推理时 mmap 直挂的字节,就是转换时写盘的那些字节。
转换的耗时大头因此不在"写",而在"前面那遍加载 + 量化"。板端数据:全量 dual(Q8+Q4+视觉)一次 1 分 42 秒(含从 eMMC 读 4.25 GB safetensors、28 层逐层量化重排、写出 4.16 GB)。
2. 对应代码:main 的转换主干与 vqf_write 的职责
离线转换入口在 main.c(第 5024–5031 行分发到 run_convert_vqf)。主干(第 4041–4102 行)分四段:
static int run_convert_vqf(const char *model_dir, const char *out_path) {
STModelConfig cfg;
st_parse_config(model_dir, &cfg); /* ① 读 config.json */
if (cfg.max_seq_len > 8192) cfg.max_seq_len = 8192; /* ② 引擎上限(Day16 的 8192 之谜) */
...
load_quant_weights(&cfg, &w); /* ③ 与 serve 同路径的 加载+量化+重排 */
... vision 权重加载(st_vision_load_weights,失败降级纯文本 VQF)...
flags = ... 布局位(Q8_8X8/Q4_4X4/X8/Q8BUF_Q4/G256)...
int rc = vqf_write(out_path, &w, &cfg, flags); /* ④ 固化落盘 */
if (rc == 0) {
vqf_load(&w2, out_path); ... } /* ⑤ 转换后立即 reload 自检 */
}
第 ③ 步是"共用量化路径"的心脏,main.c 第 4104–4107 行 的注释明说:
加载全部量化权重:serve 与 --convert-vqf 共用同一代码路径,保证 VQF 转换与内存加载位级一致(fixedpoint_quantize_saturate red line)。
vqf_write(vqf.c 第 248–286 行)则只做固化本身:调 vqf_collect 把"当前内存里所有非空张量"按固定顺序收进 dump 表(第 139–234 行:F16/F32 区 → Q8 区 → Q4 区 → X8 区 → Vision 区,谁非空收谁),随后写头、写目录、按目录链式偏移逐张量写数据(第 316–336 行),边写边累计 FNV(第 338–344 行),写完回填 file_len 再从文件重算一次校验和(第 485–511 行),最后打印对比:
[VQF] wrote %s: %d tensors, %.1f MB, flags=0x%x (seq=%d) memsum=%016llx file_sum=%016llx
memsum == file_sum 意味着"内存权重算出的校验和"与"落盘后从文件重算的校验和"一致——写盘没有丢字节、没有错位。
flags 的两层组装值得注意(Day 16 的 0x63 从哪来):调用方(main.c 第 4074–4083 行)只给布局位(Q8_8X8|Q4_4X4|X8|Q8BUF_Q4|G256);vqf_write 内部再补存储位(vqf.c 第 257–261 行:embed 是 F16 则置 EMB_F16、含 vision 则置 VISION)。所以自检日志打 reload OK (flags=0x3)、而文件里真正写的是 flags=0x63——前者是调用方视角,后者才是文件里查得到的(Day 16 解析到的 0x63 就是这么来的)。
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(Qwen3-VL-2B safetensors 4,255,140,312 B)。
Run 1:默认 wmode(dual)→ A1.vqf(--convert-vqf A1.vqf --model <dir>)
[SERVE] loading & quantizing lm_head (Q8_0 + Q4_0)...
[SERVE] lm_head quantized OK (peak staging 128 MB) ← lm_head 分块量化,峰值暂存 128MB
[VQF] vision weights loaded (ViT 24 layers, hidden=1024)
[VQF] collect 49 tensors, writing...
[VQF] wrote A1.vqf: 49 tensors, 3966.6 MB, flags=0x63 (seq=28) memsum=760ead0fd28edceb file_sum=760ead0fd28edceb
[VQF] self-check PASS: A1.vqf reload OK (flags=0x3) ← 自检重载通过(打印的是调用方局部 flags)
A1 耗时:1 分 42 秒(01:56:19 → 01:58:01)
49 张量 = 文本 22(embed F16 + 5 个 norm + Q8_0×8 + Q4_0×8)+ 视觉 27。default 是 dual:prefill 用 Q8、decode 用 Q4,两套文本权重都在文件里(技术文档 §5.1)。memsum == file_sum:写盘校验通过。
Run 2:同样的命令再来一次 → A2.vqf
sha256sum A1.vqf A2.vqf
bc18d4d8df85c0d8164a3750ada165bd2494a02dcb67a51605dc4820de13e47f A1.vqf
bc18d4d8df85c0d8164a3750ada165bd2494a02dcb67a51605dc4820de13e47f A2.vqf
两次独立转换 sha256 完全一致——量化与重排是确定性的(fixedpoint 定点取整、无浮点求和序依赖),"一次成型"里的"一次"可以随时重来而不变。
Run 3:换 wmode → A3q4.vqf(VLLM_WMODE=q4)
[VQF] collect 41 tensors, writing...
[VQF] wrote A3q4.vqf: 41 tensors, 2223.3 MB, flags=0x63 (seq=28) memsum=defe73f3f5d84337 file_sum=defe73f3f5d84337
耗时:53 秒(02:11:42 → 02:12:35)
sha256sum A3q4.vqf .../model.vqf
f2c168a59c8454b86ac1b9660067704b30b22ab6d910a32089607eb5f91636e6 A3q4.vqf
f2c168a59c8454b86ac1b9660067704b30b22ab6d910a32089607eb5f91636e6 .../Modl/Qwen3-VL-2B-Instruct/model.vqf
两个亮点:
- 41 张量 = Day 16 解剖的那份生产
model.vqf同款(2,331,316,232 B,flags 0x63)。也就是说:Day 16 讲的文件是 wmode=q4 产物(文本只存 Q4,无 Q8 文本、无 x8);q4 转换更省(53 s,少算一遍 Q8 文本量化重排,体积 2.22 GB vs dual 的 3.97 GB)。 - sha256 完全等于线上
model.vqf——那份文件是 9 月 5 日另一个构建转的;9 月 7 日当前构建用 q4 模式重转,产物逐字节一致。位级可复现跨过了构建与日期。这正是 vqf.c 头注释那条红线的兑现:转换与推理共用同一套定点量化代码,量化是纯函数,输入一致输出必一致。
诚实标注:① "跨构建一致"只在本仓库这些天里实测成立——两个构建的量化路径没改过;若有人改动
f32_to_q4_0的舍入或repack的排列,旧文件依旧能加载(格式不变),但重转不会逐字节相同——这正是需要"转换后 self-check + sha256 记录"的原因。② dual 的 A1(49 张量)与 q4 的 A3q4(41 张量)目录项不同,属预期(不同 wmode 固化不同内容),不是 bug。
4. 学员调试任务
- A 档(板端动手):对
--model目录分别跑默认与VLLM_WMODE=q4两次--convert-vqf,抄下 [VQF] wrote 行的 tensors/MB/flags/memsum/file_sum 与耗时;用sha256sum验证"同命令两次转换一致";用parse_vqf.py(Day 16)看 q4 与 dual 产物的目录项差异。 - B 档(纯读源码):读 main.c
run_convert_vqf(4041–4102)与vqf_collect(vqf.c 139–234),回答:① "转换与 serve 共用加载路径"具体指哪些函数?② 为什么vqf_collect的 ADD 要判空(指针为 NULL 就跳过)?③ 为什么 self-check 打印flags=0x3而文件里是0x63(提示:看 vqf_write 内部分别补了哪两个位)?
预期输出:你能画出"config → 加载量化 → collect → 头部组装 → 目录链 → 数据落盘 → 回填 file_len → 双校验 → reload 自检"的完整流程图,并解释 wmode 如何改变固化内容(张量集合与体积)。
收尾
- 本篇源码点名:main.c(分发 5024–5031、
run_convert_vqf4041–4102、load_quant_weights4104+)、vqf.c(头注释红线 1–11、vqf_collect139–234、vqf_write248–286、flags 补位 257–261、写盘 316–344、双校验 485–518)、vllm_safetensors.c(f32_to_q8_02916、f32_to_q4_02957、4x4 repack 3368)、技术文档 §4.7 / §9 - 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:safetensors 是自己家的源。要是手里只有 GGUF(llama.cpp 生态的模型)呢?17-2 用板端现成的两份 GGUF 走
--convert-gguf,看"反量化再量化"怎么绕路,以及为什么这条路产出的 VQF 没有视觉塔。