4. 张量元数据区:理解张量名称、维度、类型在文件中的存储方式

好,咱们继续往下挖。上一章我们把 GGUF 的元数据区搞清楚了,说白了就是一堆键值对,告诉模型的基本信息。但模型真正干活的东西——那些权重参数,也就是张量,它们的信息存在哪儿呢?

嗯,就在张量元数据区。这个区域紧跟在元数据区后面,专门用来描述每个张量的名字、形状、数据类型,以及它在文件里的具体位置。我个人习惯把这个区域叫做「张量目录」,因为它就像一本书的目录,告诉你每一章(每个张量)从哪页开始、叫什么名字。

4.1 张量元数据区的整体结构

先看整体布局。张量元数据区其实就是一个数组,数组里每个元素对应一个张量的描述信息。结构如下:

// 伪代码表示
struct gguf_tensor_info {
    uint32_t name_len;      // 张量名称长度(字节)
    char[]   name;          // 张量名称(UTF-8)
    uint32_t n_dimensions;  // 维度数量(通常 1-4)
    uint64_t dimensions[];  // 每个维度的大小
    uint32_t type;          // 张量数据类型(枚举值)
    uint64_t offset;        // 张量数据在文件中的偏移量(从文件头开始)
};

整个张量元数据区就是由 n_tensors 个这样的结构体连续排列而成。n_tensors 这个值是从元数据区里拿到的,我记得在上一章讲元数据区时提到过,它通常存在 general.architecture 附近。

关键点:张量元数据区不包含张量的实际数值,只包含描述信息。实际数值在后面的张量数据区。这就像你买家具,先看说明书(元数据区),再看零件清单(张量元数据区),最后才动手组装(张量数据区)。

4.2 张量名称的存储方式

张量名称的存储方式很直接,就是长度前缀法。先存一个 uint32_t 表示名称的字节长度,然后紧接着存名称字符串本身,不包含结尾的 \0

举个例子,假设有个张量叫 output.weight

// 实际存储的二进制内容
0x0D 0x00 0x00 0x00   // name_len = 13 字节
'o' 'u' 't' 'p' 'u' 't' '.' 'w' 'e' 'i' 'g' 'h' 't'  // 名称内容

为什么不用 C 风格的 null-terminated 字符串?我猜是为了对齐和解析效率。长度前缀法让你在解析时能直接跳转到下一个字段,不需要逐字节扫描找 \0。我在项目中遇到过一些早期版本的 GGUF 文件,名称长度字段写错了,导致解析时整个张量信息都错位,排查了好久才发现是模型转换工具的一个 bug。

小技巧:如果你在写解析器,建议先校验 name_len 是否在合理范围内(比如 1-256 字节),避免恶意文件导致内存溢出。

4.3 张量维度的存储方式

维度信息分两步存。先存一个 uint32_t 表示维度数量(n_dimensions),然后存一个 uint64_t 数组,每个元素表示该维度的大小。

常见的维度数量:

  • 1 维:偏置向量(bias),比如 [4096]
  • 2 维:权重矩阵,比如 [4096, 4096][4096, 11008]
  • 3 维:某些注意力机制的中间张量,比如 [32, 128, 128]
  • 4 维:卷积核,但在 LLM 中很少见

这里要注意一个坑:GGUF 中维度的存储顺序是 行优先(row-major),也就是最左边的维度变化最慢。举个例子,一个形状为 [3, 4, 5] 的张量,在内存中先存完 5 个元素(最后一维),再换下一组 5 个,以此类推。

我曾经在写一个模型转换工具时,把维度顺序搞反了,结果模型推理出来的结果全是乱码。排查了两天才发现是维度解析的问题,真是血的教训。

4.4 张量数据类型的存储方式

数据类型用一个 uint32_t 枚举值表示。GGUF 支持多种数据类型,常见的有:

枚举值 类型名称 字节数 说明
0 GGML_TYPE_F32 4 32 位浮点数,最通用
1 GGML_TYPE_F16 2 16 位浮点数,半精度
2 GGML_TYPE_Q4_0 0.5 4 位量化,块大小为 32
3 GGML_TYPE_Q4_1 0.5 4 位量化,带最小值
6 GGML_TYPE_Q5_0 0.625 5 位量化
7 GGML_TYPE_Q5_1 0.6875 5 位量化,带最小值
10 GGML_TYPE_Q8_0 1 8 位量化

你想想看,为什么要有这么多种类型?说白了就是为了在模型大小和推理精度之间做权衡。F32 精度最高但体积最大,Q4_0 体积最小但精度有损失。我个人建议,如果是做推理服务,用 Q4_0 或 Q5_0 就够用了;如果是做微调或训练,还是老老实实用 F16 或 F32。

注意:量化类型的存储方式不是简单的压缩,而是分块存储的。比如 Q4_0,每 32 个元素为一组,每组有一个共享的 scale 因子,然后每个元素用 4 位存储。解析时需要用 scale 反量化回浮点数。这个细节我们会在后面的「量化数据解析」章节详细讲。

4.5 张量偏移量的作用

每个张量信息里还有一个 offset 字段,表示这个张量的实际数据在文件中的起始位置(从文件头开始算)。这个偏移量指向的是张量数据区,也就是紧跟在张量元数据区后面的那块区域。

为什么需要这个偏移量?因为 GGUF 文件允许你随机访问某个张量的数据,而不需要把整个文件都加载到内存。比如你只想读取 output.weight 这个张量,直接跳到偏移量位置,读取 n_elements * element_size 字节就行了。

这里有个细节:偏移量必须是 对齐 的。GGUF 要求张量数据的起始地址是 32 字节对齐的。如果偏移量不对齐,解析器会报错。我遇到过一些第三方工具生成的 GGUF 文件,偏移量没对齐,导致 llama.cpp 加载时直接崩溃。

4.6 完整解析示例

咱们来看一个实际的解析流程。假设我们要解析一个 GGUF 文件,读取第一个张量的信息:

// 伪代码:解析第一个张量元数据
// 假设已经读取了文件头,知道了 n_tensors = 100

// 1. 定位到张量元数据区起始位置
// 文件头大小 + 元数据区大小 = 张量元数据区起始
uint64_t tensor_info_start = header_size + metadata_size;

// 2. 读取第一个张量的名称
fseek(file, tensor_info_start, SEEK_SET);
uint32_t name_len = read_uint32(file);
char* name = malloc(name_len + 1);
fread(name, 1, name_len, file);
name[name_len] = '\0';

// 3. 读取维度信息
uint32_t n_dims = read_uint32(file);
uint64_t* dims = malloc(n_dims * sizeof(uint64_t));
for (int i = 0; i < n_dims; i++) {
    dims[i] = read_uint64(file);
}

// 4. 读取数据类型
uint32_t type = read_uint32(file);

// 5. 读取偏移量
uint64_t offset = read_uint64(file);

// 6. 计算元素个数和大小
uint64_t n_elements = 1;
for (int i = 0; i < n_dims; i++) {
    n_elements *= dims[i];
}
uint64_t element_size = get_type_size(type); // 根据类型获取字节数
uint64_t data_size = n_elements * element_size;

printf("张量: %s\n", name);
printf("形状: [");
for (int i = 0; i < n_dims; i++) {
    printf("%lu", dims[i]);
    if (i < n_dims - 1) printf(", ");
}
printf("]\n");
printf("类型: %d (每个元素 %lu 字节)\n", type, element_size);
printf("数据偏移: 0x%lx\n", offset);
printf("数据大小: %lu 字节\n", data_size);

这个流程看起来简单,但实际写代码时要注意字节序问题。GGUF 默认是小端序(little-endian),如果你的机器是大端序,需要做转换。不过现在绝大多数 PC 和服务器都是小端序,这个问题基本不用操心。

4.7 张量元数据区的结构图

为了让你更直观地理解,我画了一张结构图:

GGUF 张量元数据区结构 GGUF 文件整体布局 文件头 | 元数据区 | 张量元数据区 | 张量数据区 张量元数据区(放大视图) 张量信息 #1 name_len: 13 name: "output.weight" n_dims: 2 dims: [4096, 4096] type: 0 (F32) offset: 0x12345678 --- 数据大小: 64MB 张量信息 #2 name_len: 10 name: "norm.weight" n_dims: 1 dims: [4096] type: 1 (F16) offset: 0x22345678 --- 数据大小: 8KB ... 张量信息 #N name_len: ... name: "..." n_dims: ... dims: [...] type: ... offset: ... 每个张量信息中的 offset 指向张量数据区中的对应数据块

这张图展示了张量元数据区在文件中的位置,以及每个张量信息块内部的结构。你可以看到,每个张量信息块都包含名称、维度、类型和偏移量,这些信息共同构成了一个完整的「张量目录」。

4.8 实际项目中的注意事项

最后,分享几个我在实际项目中踩过的坑:

  • 名称编码问题:张量名称是 UTF-8 编码的,但有些工具会生成 GBK 编码的名称。解析时最好做编码检测,或者统一用 UTF-8。
  • 维度数量校验:有些损坏的文件会写 n_dimensions = 0 或 n_dimensions > 6,解析时一定要做范围校验。
  • 偏移量重叠:理论上每个张量的数据区不应该重叠,但有些文件会出现重叠。解析时最好检查一下,避免读取到脏数据。
  • 类型枚举值:GGUF 规范在不断更新,新的量化类型会添加新的枚举值。解析器要做好未知类型的处理,不要直接崩溃。

嗯,张量元数据区的内容就这些。说白了,它就是一张「地图」,告诉你每个张量叫什么、长什么样、用什么格式存、在文件哪里。搞懂了这个,你就能随心所欲地读取 GGUF 文件中的任何张量了。


专注资料整理