0.8MB 跑通 Qwen|第 16-3 篇:推理引擎的 tensor 目录与布局重排的落盘顺序

简介: 《0.8MB跑通Qwen》系列实录:30天手搓零依赖纯C推理引擎,适配Qwen3-VL多模态模型(2B/8B/30B-A3B),真机跑通RK3588(aarch64)。详解VQFTensor目录结构、64字节对齐落盘、FNV校验及mmap加载机制,含零依赖Python解析器。

系列:《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-2《逐字段解剖:VQFHeader / VQFSig / VQFTensor》| 下一篇:17-1《safetensors → VQF:vqf_write 一次成型》

真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)

一句话导读:推理引擎的 VQFTensor 目录与落盘顺序:41 个 64 字节条目如何自描述每个张量的位置与格式,数据区按布局重排结果链式落盘;尾部 FNV 校验和怎么算,附一个零依赖 Python 解析器。

关键词:手搓 Qwen 推理引擎、千问大模型推理、VQFTensor、tensor 目录、落盘顺序、VQF、mmap、Qwen3-VL、零依赖纯 C

导语:文件头只回答"是谁、有多大",真正决定每个权重落在哪儿的,是紧随其后的张量目录。这篇沿 64 字节一格的 VQFTensor 条目往下走,讲清数据区为什么按布局重排的结果链式落盘、尾部校验和如何计算,并附上一个零依赖 Python 解析器,方便你在 RK3588 上亲手验证。

头部告诉我们"这个文件是谁、有多大",但真正回答"每个权重在哪、多大、什么格式"的是目录——41 个 64 B 的 VQFTensor 条目。今天解剖目录:条目内部的对齐布局、数据区按什么顺序落盘、以及那个 8 字节尾部校验和是怎么算的。你还会拿到一个真正的零依赖 Python 解析器。

1. 知识点:目录是"自描述索引",落盘顺序是"布局重排的结果"

没有目录会怎样?加载器就只能"按约定顺序"找权重——第 0 块是 embed、第 1 块是 q……那格式升级加一个张量,所有旧文件全部作废。目录解决了这个问题:每块数据自带 32 B 名字(q4_q、v_q8_fc1、token_embed……),加载器按名字把指针挂到 STModelWeights 的对应字段上(vqf.c 第 747–826 行的 if/else 链)。

但目录给的是"名字 → 位置"的映射,顺序本身没有语义——真正决定文件里谁先谁后的,是转换器(writer)按什么顺序把张量 dump 出去。这正是 VQF 的核心设计:数据区的顺序 = 转换时布局重排完成后的一次性落盘顺序。于是有两个推论:

  1. 目录条目自带 64 B 对齐的 offset,加载器不需要算,直接 base + offset 挂指针(零拷贝、零计算);
  2. 运行时不再有任何重排代码——Day 6-2 的 8x8 取数、Day 7-1 的 4x4 nibble,全部发生在转换期,结果以"这个 layout 的字节"躺在文件里,加载侧只认 flags 对不对(16-1)。

2. 对应代码:条目内部布局与顺序的产生

VQFTensor 在内存里的实际偏移(C 结构体对齐规则决定):

0   name[32]   张量名(不足补 0)
32  qtype      VQF_QT_*(1=F32, 2=Q8_0, 3=Q4_0, 4=Q4_8X8L, 5=F16)
36  rows       几何(诊断用)
40  cols       几何
44  ← 4 B 对齐填充(offset 是 u64,必须 8B 对齐)
48  offset     数据在文件中的偏移(64B 对齐)
56  bytes      数据字节数
64  条目总长(_Static_assert 守卫)

writer 生成目录的循环(vqf.c 第 328–336 行)——注意 offset 是链式累加的:

size_t off = data_off;                       /* 从页对齐的数据区起点开始 */
for (int i = 0; i < n; i++) {
   
    VQFTensor *e = (VQFTensor *)(buf + dir_off + i * 64);
    snprintf(e->name, 32, "%s", d[i].name);
    e->qtype = d[i].qtype; e->rows = d[i].rows; e->cols = d[i].cols;
    size_t b = vqf_tensor_bytes(d[i].qtype, d[i].rows, d[i].cols);
    e->offset = off;                          /* 上一个的末尾,就是下一个的起点 */
    e->bytes = b;
    off = (off + b + 63) & ~(size_t)63;       /* 每个张量 64B 对齐收尾 */
}

所以目录里暗含一个不变量:entry[i].offset == data_offset + Σ(前 i 个 entry 的 round64(bytes)),最后一个 entry 的数据末端 == file_len。加载器只做一件事:校验 offset + bytes <= file_len(第 752–755 行,越界即拒),然后按名字挂指针;遇到不认识的张量名直接忽略(第 825 行注释"未知名字忽略(向前兼容)")——这就是"目录制"给格式演进留的后门:新引擎写的文件旧引擎也能挂上它认识的张量。

3. 改动后果:板端逐字节拆目录 + 校验和对拍

实测口径:RK3588(Orange Pi 5 Plus)/ aarch64 / Release 构建 / 2026-09-07。文件:/mnt/emmc/Modl/Qwen3-VL-2B-Instruct/model.vqf(2,331,316,232 B,41 张量)。

3.1 第一条目录项(偏移 448 起,od 原始字节)

$ od -A x -t x1 -j 448 -N 136 model.vqf
0001c0 74 6f 6b 65 6e 5f 65 6d 62 65 64 00 00 00 00 00
0001d0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0001e0 05 00 00 00 80 51 02 00 00 08 00 00 00 00 00 00
0001f0 00 10 00 00 00 00 00 00 00 00 18 25 00 00 00 00
000200 66 69 6e 61 6c 5f 6e 6f 72 6d 00 00 00 00 00 00   ← 第 2 条开始

逐段拆读 entry[0](token_embed):

字段 字节 值
name 74 6f 6b 65 6e 5f 65 6d 62 65 64 00... token_embed
qtype(0x1e0) 05 00 00 00 5 = F16(EMB_F16)
rows(0x1e4) 80 51 02 00 0x25180 = 151,936
cols(0x1e8) 00 08 00 00 0x800 = 2,048
pad(0x1ec) 00 00 00 00 对齐填充
offset(0x1f0) 00 10 00 00 00 00 00 00 0x1000 = 4,096 == data_offset
bytes(0x1f8) 00 00 18 25 00 00 00 00 0x25180000 = 622,329,856

自检:token_embed 是 F16,151,936 × 2,048 × 2 B = 622,329,856 B ✅。它是目录第 0 项、也正好是数据区的第一块(offset == data_offset)——embed 是每次推理必读的权重,放在最前。

3.2 落盘顺序 = 41 个张量的完整地图

解析器全量输出按顺序归类(bytes 均为 64 对齐、无缝隙):

[0]   token_embed   F16   622,329,856    ← 文本词嵌入(≈0.58 GiB)
[1]   final_norm    F32   8,192          ← 各层 norm(每层 2048×4B)
[2-5] attn/ffn/q/k_norm   F32   ≈0.5 MiB  ← 28 层 × 2048×4B / q、k 的 128×4B
[6-13] q4_q/k/v/o/gate/up/down/lm   Q4_0  967,753,728   ← 文本主干(≈0.90 GiB)
[14-17] v_q8_qkv/proj/fc1/fc2       Q8_0  320,864,256   ← 视觉塔 24 层 qkv+MLP
[18-28] v_patch/v_pos/v_*bias/v_norm*   F32  17,010,688   ← ≈16 MiB
[29-34] v_merger_*(合并器 3 层)   F32  100,696,064   ← ≈96 MiB
[35-40] v_ds_*(DeepStack 下采样) F32  302,161,920   ← ≈288 MiB

顺序规律一眼可见:先 embed 大块,再小 norm,再文本 q4 主干,最后视觉塔(q8 块 → F32 附属)——这就是转换器 d[] 数组的 dump 顺序,也是数据区的物理顺序。抽查两个 bytes 公式都对得上(qtype 的每块字节数):

q4_lm:    151,936 × 2,048 / 32 × 18  = 175,030,272   (Q4_0: 每 32 元素 16B 数据+2B scale)
v_q8_qkv: 24 × 3,145,728 / 32 × 34   = 80,216,064    (Q8_0: 每 32 元素 32B 数据+2B scale)
attn_norm: 57,344 × 4                 = 229,376       (F32: 28 层 × 2048 × 4B)

三个总量不变量全部成立(解析器断言):

dir bytes sum = 2,331,312,128
file_len − data_offset = 2,331,316,224 − 4,096 = 2,331,312,128  ✅ 目录总和==数据区大小
last entry end = 2,331,316,224 == file_len  ✅ 末尾无多余字节
file_len + 8 (FNV) == 2,331,316,232 == 文件总长  ✅ 与 stat 一致

无缝隙、无重叠——因为目录总和恰好等于数据区长度,而每个 offset 又由 writer 链式累加保证。数据区从 4,096(页对齐)开始,第一个张量就顶在页边界上,mmap 后最省页。

3.3 尾部 8 B:FNV-1a 校验和的对拍

文件最后 8 字节(明文文件才有;加密文件是 32 B HMAC-SM3 tag):

尾部 8B = 37 43 d8 f5 f3 73 fe de     (小端读作 0xdefe73f3f5d84337)

FNV-1a 64 的语义(vqf.c):初值 0xcbf29ce484222325,覆盖范围 = 目录(448 起 n×64)+ 数据区(data_offset..file_len)——头部与"目录尾→数据区起点"之间的 1KB 填充不算。板端用 30 行 C 复算(mmap 直扫,算法与 vqf.c 逐字节一致):

$ ./fnv_verify model.vqf
version=2 n_tensors=41 data_off=4096 file_len=2331316224 data_bytes=2331312128
computed=defe73f3f5d84337
stored  =defe73f3f5d84337
match=YES (7.21 s)

computed == stored,证明尾部 8B 确实是"目录 + 数据区"的 FNV-1a。这个 7.21 s 值得玩味:全量扫 2.33 GB 在板端要 7 秒。这就是为什么加载器默认跳过整文件校验(vqf.c 第 643–646 行注释:mmap 页面由内核按需加载,全量校验会把整个文件从 eMMC 读一遍,直接抵消 mmap 的加载收益),只在 VLLM_VQF_CHECK=1 时跑(完整性诊断用);而"文件有没有被截断"则由每次加载必做的 file_len + 8 == 文件总长(vqf.c 第 569–573 行)廉价兜底。完整性按需、截断必查——这是 mmap 模型下的刻意取舍。

3.4 你的 10 行解析器(零依赖)

把第 1、2 节的布局翻译成 Python,核心就这么几行(完整带断言的版本在板端 /mnt/emmc/day16_tools/parse_vqf.py):

import struct, sys
path = sys.argv[1]; f = open(path, "rb"); sz = __import__("os").path.getsize(path)
magic, ver, flags, n = struct.unpack("<IIII", f.read(16))
d0, fl = struct.unpack("<QQ", f.read(16))            # 184 起:data_offset/file_len
print(f"v{ver} n={n} flags=0x{flags:03x} data_off={d0} file_len={fl} size={sz}")
f.seek(448)                                          # dir_off = (432+63)&~63
for i in range(n):
    name, qt, r, c, o, b = struct.unpack("<32sIII4xQQ", f.read(64))
    print(f"[{i:2}] {name.split(b'\\0')[0].decode():<18} qt={qt} {r}x{c} off={o} bytes={b}")

跑出来的第 0 条就是 3.1 节那条 token_embed。注意 <32sIII4xQQ 里那个 4x——它就是 C 结构体在对齐规则下插入的 4 字节填充;漏掉它,后面所有条目全部错位。这一行格式串 = 你对 16-2 那个 64 B 布局的理解的"可执行版本"。

4. 学员调试任务

  • A 档(板端动手):把 parse_vqf.py 跑通后扩展它,加入五个断言:① 每个 entry 的 offset % 64 == 0;② offset 严格递增且等于上一个的 end;③ entry[0].offset == data_offset;④ 最后一个 entry 的 end == file_len;⑤ 用 qtype 的公式抽查 q4_lm、v_q8_qkv、attn_norm 的 bytes。再用 gcc 编 fnv_verify.c 复算校验和(板端 7.2 s),确认 match=YES。
  • B 档(纯读源码):读 writer 循环(vqf.c 第 328–336 行)与加载器挂载循环(第 747–826 行),回答:① 如果转换器把两个张量写成同名,加载器会怎样(提示:看 if/else 链是"赋值"不是"累加")?② "未知名字忽略"这个设计,对格式升级意味着什么?③ 为什么校验和要把"目录尾到 data_offset 的 1KB 填充"排除在哈希范围外(提示:看 writer 里这段 pad 是怎么写的、加载侧哈希范围从哪到哪)?

预期输出:你自己的解析器能在板端打印 41 条目录并全部通过五个断言;能解释"数据区顺序由转换决定、由目录描述、由校验和保护"三者如何闭环。

收尾

  • 本篇源码点名:vqf_format.h(VQFTensor 92–98)、vqf.c(writer 目录链 316–336、FNV 写侧 338–344/485–511、size 兜底 569–573、整文件校验门 643–662、挂载链 747–826)、权重保护与可验证推理方案 §3.3
  • 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
  • 下篇预告:三天都在"读"这个文件。明天换到"写"的一侧:vqf_write 怎么把 safetensors 的 F32 权重量化、重排、落盘成我们刚解剖过的字节——转换与推理为什么能共享同一套量化代码,位级一致性靠什么保证。
相关文章
|
1天前
|
编译器 C++
0.8MB 跑通 Qwen|第 6-3 篇:推理引擎的 4x4 asm 内核——从 llama.cpp 提取的 MIT 代码
本系列《0.8MB跑通Qwen》聚焦ARM端零依赖纯C推理引擎,适配Qwen3-VL多模态模型,在RK3588上实测部署。核心采用llama.cpp中450行MIT许可NEON汇编内核,通过机械提取实现零转录风险,并严守许可署名规范,诠释开源合规与工程严谨的统一。(239字)
|
运维 安全 Cloud Native
谈谈云原生安全
根据自己的理解 简单谈谈云原生安全
6016 0
谈谈云原生安全
|
1天前
|
存储 缓存 C++
0.8MB 跑通 Qwen|第 9-1 篇:推理引擎的 KV 也量化——q8 KV 把缓存与 decode 带宽压到一半
本系列手搓零依赖纯C推理引擎,仅0.8MB即可在RK3588上跑通Qwen3-VL多模型。本文详解q8 KV量化:将f16 KV压至INT8,按token每头独立定标,缓存与decode带宽降低约48%,兼顾精度与效率,f32仅作调试探针。
|
1天前
|
JSON 网络协议 前端开发
0.8MB 跑通 Qwen|第 20-3 篇:SSE 流式——推理引擎响应怎么写一半就发给客户端
本系列《0.8MB跑通Qwen》用纯C手搓零依赖推理引擎,适配Qwen3-VL多尺寸模型,在RK3588(aarch64)实测SSE流式响应:首token延迟130ms,后续约50ms/token,支持标准+自定义事件,真正实现端侧“打字机”体验。(239字)
|
1天前
|
调度 C++
0.8MB 跑通 Qwen|第 7-2 篇:推理引擎的混合精度路由——flags 决定谁用 Q8、谁用 Q4
本系列《0.8MB跑通Qwen》聚焦ARM端零依赖纯C推理引擎,适配Qwen3-VL多模态模型,在RK3588上实现实测。核心创新是混合精度路由:prefill阶段用Q8保精度,decode阶段用Q4省带宽,通过flags比特位动态调度,兼顾速度与质量。(239字)
|
1天前
|
缓存 NoSQL 区块链
0.8MB 跑通 Qwen|第 15-3 篇:推理引擎单步走一次生成——token 循环与 KV 追加
本系列《0.8MB跑通Qwen》用纯C手搓零依赖推理引擎,实测RK3588板端运行Qwen3-VL多模型;本文以gdb单步追踪decode循环,直观展示“采样→前向→KV追加→停判”全过程,将大模型逐token生成钉在胶片上。(239字)
|
1天前
|
存储 容器
0.8MB 跑通 Qwen|第 17-2 篇:GGUF → VQF——推理引擎的 Q4_0…Q8_K 反量化再量化
本系列《0.8MB跑通Qwen》聚焦ARM零依赖纯C推理引擎,适配Qwen3-VL多版本模型,在RK3588平台实测。本文详解GGUF→VQF的“反量化再量化”路径:将llama.cpp已量化权重(Q4_0/Q8_0等)先还原为F32,再经统一量化与重排固化为VQF格式,确保内核兼容性与可验证性。(239字)
|
1天前
|
开发工具 C++ git
0.8MB 跑通 Qwen|第 5-3 篇:推理引擎的参考实现对拍——怎么读懂 rel err 的数量级
本系列《0.8MB跑通Qwen》聚焦ARM端零依赖纯C推理引擎开发,实测适配Qwen3-VL多模型,在RK3588上完成Q8/Q4量化与NEON加速。核心创新在于“两把尺子”误差分析法:内核一致性(1e-7)验证手写代码正确性,量化损失(1e-2)界定精度边界,实现可验证、可调试、可落地的轻量级LLM部署。(239字)
|
1天前
|
编解码 缓存 计算机视觉
0.8MB 跑通 Qwen|第 19-1 篇:ViT 编码——推理引擎里图片是怎么变成视觉 token 的
本系列《0.8MB跑通Qwen》用纯C手写零依赖推理引擎,实现在RK3588(aarch64)上部署Qwen3-VL多模态模型。真机实测:64×64图经24层ViT编码得4个视觉token,耗时仅295.3ms,支持图文理解与视频分析。(239字)
|
1天前
|
C++
0.8MB 跑通 Qwen|第 10-3 篇:推理引擎里诚实的坑——哪些场景稀疏无收益,甚至负优化
本篇实测揭示稀疏注意力的真相:短上下文(&lt;1K)开`--sparse-attn`反成负优化!RK3588板端验证,详列8条“禁用清单”,每条附源码行号与实测数据。明确边界:q4 KV、推测解码、L3驱逐等均与稀疏互斥,并坦承`test-sparse`自检崩溃缺陷。(239字)

热门文章

最新文章