第二章:GGUF 文件结构概览——文件头、元数据区、张量数据区的整体布局
GGUF 这玩意儿,说白了就是一个精心打包的“三明治”。
最上面一层是文件头,告诉你这是个什么文件、版本号多少、后面的数据怎么组织。中间是元数据区,存了模型的名字、参数量、词表大小这些“身份证信息”。最下面是张量数据区,所有神经网络的实际权重就躺在那里。
我个人习惯把 GGUF 的结构记成:头部 + 字典 + 大字节数组。当然,实际细节比这个粗糙说法要多不少。今天我们就把它完全拆开,看看每块到底长什么样。
2.1 从文件头开始:GGUF 的身份证
文件头是 GGUF 的起点,固定位于文件偏移量 0 处。长度是 固定的 40 字节,不多不少。
我记得第一次手动 hexdump 一个 GGUF 文件时,看到前四个字节是 47 47 55 46,也就是 ASCII 的 "GGUF",当时就觉得这设计挺直率的——不像某些格式还用魔数藏个隐晦含义。
| 字段 | 类型 | 字节大小 | 说明 |
|---|---|---|---|
| magic | uint32 | 4 | 魔数,固定为 0x46554747 ("GGUF") |
| version | uint32 | 4 | 版本号,目前主流是 3 |
| tensor_count | uint64 | 8 | 文件中的张量总数(含量化变体) |
| metadata_kv_count | uint64 | 8 | 元数据键值对的总数 |
| alignment | uint32 | 4 | 张量数据的对齐字节数,通常是 32 |
| padding[4] | uint8[4] | 4 | 保留填充字节 |
2.2 元数据区:模型信息的“字典”
文件头之后紧跟着的就是元数据区。这部分说白了就是一个键值对(KV)列表,长度由 header 里的 metadata_kv_count 决定。
每个 KV 项的结构统一如下:
- key:以 null 结尾的 UTF-8 字符串。读取时先读一个 uint64 表示字符串长度(不含 null 结尾符),再读对应字节数的字符数据,最后跳过一个 null 字节。
- value_type:uint32,指示值的类型。取值范围从 0 到 10 左右,每种类型对应不同的序列化方式。
- value:根据 value_type 解释的具体数据。可以是整数、浮点数、布尔、字符串、数组、甚至嵌套字典。
常见的 value_type 枚举如下:
| 枚举值 | 类型名 | 序列化长度 |
|---|---|---|
| 0 | uint8 | 1 字节 |
| 1 | int8 | 1 字节 |
| 2 | uint16 | 2 字节 |
| 3 | int16 | 2 字节 |
| 4 | uint32 | 4 字节 |
| 5 | int32 | 4 字节 |
| 6 | float32 | 4 字节 |
| 7 | bool | 1 字节(0/1) |
| 8 | string | uint64 长度 + 数据 + null |
| 9 | array | uint32 类型 + uint64 数量 + 各元素 |
元数据区里通常会包含 general.name、general.architecture、llama.vocab_size、llama.embedding_length 等关键键。这些信息供加载器决定如何初始化模型结构。
2.3 张量数据区:真正的“血肉”
元数据区结束后,就到了最核心的张量数据区。这里存放的是所有模型参数——权重、偏置、词嵌入等。
不过要注意:张量数据区并不是直接一个接一个地塞进去的。GGUF 做了一个很聪明的设计——使用 张量信息索引 和 张量数据块 分离存储。
具体布局顺序:
- 张量信息数组(tensor infos):紧跟在元数据区之后。每个张量信息包含:
- name:字符串(方式同元数据 key)
- n_dims:uint32,维度数量(通常 1 或 2)
- dims[0..n_dims-1]:uint64 数组,每个维度的大小
- data_offset:uint64,该张量在数据块部分相对于张量数据区起始的偏移量
- type:uint32,张量数据类型(如 F32、F16、Q4_0 等)
- 对齐填充:在 tensor_infos 结束后,文件指针可能会 pad 到 alignment 的倍数,保证后续数据对齐。
- 张量数据块:所有张量的实际数据按顺序堆叠。每个张量从 data_offset 指定位置开始,长度为
element_count * element_size(具体数值由 type 决定)。
2.4 整体结构图(一图胜千言)
下面我画了一张完整的 GGUF 文件结构图,把三块区域和内部细节都标了出来。你仔细看箭头走向,就能明白解析器是怎么一步步跳着读的。
2.5 对齐与填充:一个容易踩的坑
GGUF 要求张量数据块的起始地址是 alignment(文件头里的字段)的整数倍。默认 alignment 是 32 字节,但有些工具会改成 64 甚至 256。
我踩过这个坑:有一次在内存中 mmap 一个 GGUF 文件,直接用 data_offset 做指针偏移,没检查对齐,结果后面读张量时 CPU 发出 SIGBUS 信号——因为对齐访问导致段错误。
pad_len = (alignment - (current_offset % alignment)) % alignment。
2.6 实战小练习:用 C 读文件头
光讲理论没意思,我写个简单的 C 代码片段,你看一眼就明白如何 parse 前 40 字节:
#include <stdint.h>
#include <stdio.h>
typedef struct {
uint32_t magic;
uint32_t version;
uint64_t tensor_count;
uint64_t metadata_kv_count;
uint32_t alignment;
uint8_t padding[4];
} gguf_header_t;
int read_header(FILE *f, gguf_header_t *hdr) {
if (fread(hdr, sizeof(gguf_header_t), 1, f) != 1) return -1;
if (hdr->magic != 0x46554747) {
fprintf(stderr, "Bad magic = 0x%X\n", hdr->magic);
return -1;
}
printf("version: %u\n", hdr->version);
printf("tensor_count: %lu\n", hdr->tensor_count);
printf("metadata_kv_count: %lu\n", hdr->metadata_kv_count);
printf("alignment: %u\n", hdr->alignment);
return 0;
}
这个结构体正好 40 字节,编译器打包可以不用特意调对齐(x86 上默认对齐就 OK)。但如果你在 ARM 或 RISC-V 上跨平台编译,建议加上 __attribute__((packed)) 以兼容。
2.7 小结
GGUF 的三段式布局看起来简单,但细节不少。文件头提供导航信息,元数据区描述模型属性,张量数据区才是真正的模型重量。解析时务必注意对齐、null 结尾、版本兼容这三件事。
下次你手头有个 GGUF 文件,直接 hexdump 看前几十字节,对照上面的表格应该能一眼认出结构了。嗯,有了这个基础,下一件有趣的事就是怎么反序列化元数据里的各种值了——不过那是第三章的内容,今天先说这么多。