真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:字节序与十六进制读法纪律:以 mmap 直挂下磁盘字节即进程字节为前提,讲清小端与大端的排布差异、VQF 魔数按小端落盘成 FQFW 的陷阱,落点在把 version 字段改写成大端后引擎加载期拒绝的实验。
3-1 说权重是 mmap 直挂的——磁盘字节就是进程字节。那把话挑到最绝:磁盘上 u32 的“1、2、3”,和进程里 int 的“1、2、3”,字节长得一样吗? 答案:只在小端(little-endian)机器上一样。今天我们就聊这个差点被所有人忽视的细节——字节序,并且用一把真实的“字节序手术刀”给引擎动刀。
1. 知识点:为什么“1”在内存里有两种长相
一个 32 位整数 0x01020304,在内存里可以有两种排法:
- 小端(LE):低字节在前 →
04 03 02 01(x86、aarch64 都是小端); - 大端(BE):高字节在前 →
01 02 03 04(老 PowerPC、网络字节序等)。
麻烦在于:文件格式的作者必须二选一写死,而读文件的机器可能是另一种。所以工业界有个铁律:磁盘格式必须显式声明字节序,读取端要么匹配、要么显式转换——绝不允许“碰运气”。本仓库的格式声明是小端。看 vqf_format.h 第 21–22 行的魔数与版本:
#define VQF_MAGIC 0x57465146u /* "VQFW" */
#define VQF_VERSION 2 /* v2: 头部追加 VQFSig(SM2 签名) */
0x57465146 按小端落盘,字节就是 46 51 46 57 = ASCII FQFW,而不是你以为的 VQFW!很多人在 xxd 里看到 FQFW 第一反应是“文件坏了”——其实这是小端字节序的经典陷阱。“FQFW” 就是 “VQFW” 的小端写法,就像“倒着读”才是它本来的意思。读取端(引擎)与写入端(转换工具)共用同一个头、跑在同一个小端架构上,所以 memcpy 直读就行——但前提是格式约定永远小端,谁写都得按小端写,谁读都按小端读(GGUF 也一样,见 vllm_gguf.c 第 32 行注释 "GGUF" LE)。
2. 对应代码:真实文件头部的“逐字节地图”
先看真实证据。板端 Qwen3-VL-2B 的 model.vqf 开头 48 字节(xxd):
00000000: 4651 4657 0200 0000 6300 0000 2900 0000 FQFW....c...)...
00000010: 0008 0000 1c00 0000 1000 0000 0800 0000 ................
00000020: 8000 0000 0018 0000 8051 0200 0020 0000 .........Q... ..
对照 VQFHeader(vqf_format.h 第 79–90 行)逐字段解读(全部小端):
| 偏移 | 字节 | 小端读出 | 对应字段 | 含义 |
|---|---|---|---|---|
| 0x00 | 46 51 46 57 |
—— | magic = 0x57465146 |
就是“VQFW”的小端写法(上面说的陷阱) |
| 0x04 | 02 00 00 00 |
2 | version |
VQF v2 |
| 0x08 | 63 00 00 00 |
0x63 | flags |
Q8_8X8 + Q4_4X4 + EMB_F16 + VISION |
| 0x0C | 29 00 00 00 |
41 | n_tensors |
41 个张量目录项 |
| 0x10 | 00 08 00 00 |
2048 | arch.dim |
2B 模型隐藏维 2048 |
| 0x14 | 1c 00 00 00 |
28 | arch.n_layers |
28 层 |
| 0x18 | 10 00 00 00 |
16 | arch.n_heads |
16 注意力头 |
| 0x1C | 08 00 00 00 |
8 | arch.n_kv_heads |
8 KV 头 |
| 0x20 | 80 00 00 00 |
128 | arch.head_dim |
头维度 128 |
| 0x24 | 00 18 00 00 |
6144 | arch.ffn_dim |
FFN 维 6144 |
| 0x28 | 80 51 02 00 |
152448 | arch.vocab_size |
词表 152k |
| 0x2C | 00 20 00 00 |
8192 | arch.max_seq_len |
窗口 8192 |
注意每个多字节字段都是“低字节在前”(如 00 08 00 00 不是 0x00000800,而是 0x00000800 的小端 = 0x800 = 2048,看错了就成 512 了)。十六进制纪律第一条:先问字节序,再读数字。
2.1 读取端为什么敢直接 memcpy
vqf.c 的 vqf_load 里,读字段就是 memcpy(&u, p + off, 4) 这类——不做字节序转换。因为它有两个前提:格式约定小端 + 目标架构 aarch64 就是小端。这是一个“平台少”的红利:不用写一堆 ntohl 式的转换层。但要记住代价——哪天要支持大端机器,这些 memcpy 点全都得改成显式小端读取,一个都不能漏(这也是“格式文档必须写明小端”的原因)。
3. 改动后果:把 version 字段改成“大端”,看引擎怎么翻脸
现在做真刀实验:复制一份真实的 model.vqf(2.33 GB),把偏移 0x04 的 version 字段从正常小端 02 00 00 00 改成大端写法 00 00 00 02——字段内容没变(还是数字 2),只是字节顺序反了。
# corrupt_vqf.py:在偏移 4 写入大端序的 2
import struct
with open('model_copy.vqf', 'r+b') as f:
f.seek(4)
f.write(struct.pack('>I', 2)) # 大端 2 -> 小端机器读出 = 0x02000000
改动后让引擎加载这个“病号”文件(--load-format vqf --auto-load)。板端实测(RK3588 / 2026-09)真实输出:
[SERVE] auto-load: loading model /mnt/emmc/Modl/BadEndian (wmode=0)
[VQF] version 33554432 unsupported (need 2)
[SERVE] FATAL: model load failed (see [ST]/[TOK]/[VIS] errors above).
[SERVE] VQF load failed: /mnt/emmc/Modl/BadEndian/model.vqf
注意看 33554432 = 0x02000000——正是“大端写的 2”被小端机器读出来的结果。引擎没懵:它在 vqf_load 里先比对 magic、再比对 version(vqf.c 第 561–566 行),version 对不上立刻 fprintf 一句人话并拒绝加载。这个实验的教学意义有两层:
- 字节序错误不是“运行期算错”,而是“加载期被拒”——文件格式的校验链把它挡在门外,比算错结果温和一万倍;
- 校验要有顺序:magic 先验(防“这根本不是 VQF”)、version 次之(防“版本不兼容”)、size 再验——错误越靠前越早暴露。这是 Day 1“错误前移”在文件层上的又一次体现。
顺带一提:vqf_format.h 里
_Static_assert管的是“布局漂移”(大小对不对),这里version校验管的是“语义兼容”(版本对不对)——两道闸门管两件事,别混。
4. 学员调试任务
- A 档(板端动手):
xxd -l 48 <你的 model.vqf>,对照第 2 节表格读出 magic/version/n_tensors/dim/n_layers 五个字段,确认你能“倒着读”小端数字;cp一份 vqf(若太大可只保留头部所在的目录结构,但 loader 需要完整文件),用 python 或printf+dd把偏移 0x04 改成00 00 00 02,跑--auto-load,截图[VQF] version 33554432 unsupported (need 2);- 改回小端,确认加载恢复正常。
- B 档(纯读源码):读
vqf.c里vqf_load开头(第 549–573 行)的校验链,画出“magic → version → size → flags”的检查顺序图,并解释每道检查各防什么。
预期输出:你能不看资料就说出“为什么 FQFW 就是 VQFW”,并解释小端数字在 xxd 里该怎么读。
收尾
- 本篇源码点名:vqf_format.h(
VQF_MAGIC/VQF_VERSION/VQFHeader字段)、vqf.c(vqf_load校验链第 549–573 行)。 - 开源仓库:Kestrel-LLM (Gitee)(源码可得双许可:学习 / 学术研究免费)
关键词:字节序、小端、大端、十六进制、VQF