数据截至 (上游 commit d7a2074112d2)
02 · GGUF:自描述的模型二进制格式
这一章讲什么: GGUF 文件长什么样、为什么这样设计、加载时怎么做到「打开文件即模型就绪」。它是 llama.cpp 对生态影响最大的一件产物——今天 Hugging Face 上的本地模型大多以 GGUF 发布。
1. 它要解决的小问题
2023 年的本地模型圈,格式是一笔烂账:
- PyTorch 的
.bin/.safetensors只有权重,超参数和分词器在别的文件里——缺了 config.json 就不知道模型几层。 - 想跑模型就得先装能读格式的框架,格式的命运绑定在框架上。
- 大文件全量读进内存,加载一个 13B 模型要等几十秒。
GGUF(GGML Universal File)的设计目标对症下药:一个文件装下所有信息、不依赖任何框架、支持 mmap 秒开。它前身是 GGML/GGJT 格式,迭代到 v3 定型(常量 GGUF_VERSION 3,ggml/include/gguf.h:42)。
2. 思路:KV 元数据 + 张量目录 + 对齐数据区
直觉是学「可执行文件」:文件头自描述,数据区按对齐摆放,加载器 mmap 之后让操作系统按需调页。
三件事决定这个格式:
- 自描述。 模型层数、维度、RoPE 参数、分词器词表和合并规则……全部作为 key-value 写在文件头。加载器不需要任何外部文件。
- 张量只登记目录,不内嵌结构。 每个张量记「名字 + 形状 + 量化类型 + 数据偏移」,数据本体集中放在最后的对齐区。
- 对齐是为了 mmap。 数据区起始和每个张量都按
general.alignment(默认 32 字节)对齐(ggml/src/gguf.cpp:620-621),mmap 之后张量指针天然满足 SIMD 对齐要求,零拷贝可用。
3. 图示:文件布局
偏移 0 ─────────────────────────────────────────────
magic "GGUF"(4 字节)
version = 3(u32)
n_tensors(i64) ┐
n_kv(i64) ┘ 头部计数
─────────────────────────────────────────────────────
KV 区:n_kv 条 key-value
"general.architecture" = "llama"
"llama.context_length" = 131072
"tokenizer.ggml.tokens" = [...]
……
─────────────────────────────────────────────────────
张量目录:n_tensors 条
每条 = 名字 + 维度数 + ne[] + ggml_type + 数据偏移
─────────────────────────────────────────────────────
padding 到 alignment 的倍数
─────────────────────────────────────────────────────
张量数据区:量化块连续摆放,每个张量按 alignment 对齐
───────────────────────────────────────── 文件末尾
怎么读这张图: 前三段是「小头」(几 十 KB 到几 MB),可以一次读完;最后一段是「大身」(几个 GB),mmap 之后由 OS 按需调页。ctx->offset 记录数据区起点(ggml/src/gguf.cpp:772)。
4. 真实实现:解析头部
解析入口是 gguf_init_from_reader(ggml/src/gguf.cpp:451),顺序就是上图的顺序:
- 验 magic——4 字节逐字符比对
"GGUF"(ggml/src/gguf.cpp:457-479)。 - 读版本号并做防御检查:版本 0、v1、超过当前支持版本都拒绝;还顺手用一个位运算
(version & 0x0000FFFF) == 0识别「文件和主机字节序不一致」(ggml/src/gguf.cpp:497-501)。 - 读
n_tensors、n_kv,两个计数都先过一遍「会不会溢出 size_t」的检查(ggml/src/gguf.cpp:514-535)。 - 循环读 KV;读完从 KV 里取
general.alignment决定对齐,并要求是 2 的幂(ggml/src/gguf.cpp:611-626)。 - 循环读张量目录:名字查重、维度数 ≤ 4、每行元素数是量化块大小的整数倍、字节数不溢出(
ggml/src/gguf.cpp:630-756,块对齐校验在:723-730)。 - seek 到对齐边界,记下数据区起点;再校验目录里的偏移必须严丝合缝地连续(
ggml/src/gguf.cpp:763-794)。
注意第 5、6 步的分量:这个解析器假设输入文件是不可信的——几乎所有能想到的畸形输入(负数计数、超长名字、重复张量名、非对齐行数)都有显式报错路径。这是「会成为生态标准」的格式才有 的防御密度。
5. 原理演示:自己写一个最小 GGUF 读取器
GGUF 简单到几十行代码就能读出目录——这正是「不绑定框架」的含义:
# 示意,非源码 —— 最小 GGUF 头部解析(逻辑对应 ggml/src/gguf.cpp:451-545)
import struct
def read_gguf_header(f):
assert f.read(4) == b"GGUF" # ① magic
version, = struct.unpack("<I", f.read(4)) # ② 版本,小端
n_tensors, n_kv = struct.unpack("<qq", f.read(16))
meta = {}
for _ in range(n_kv): # ③ KV 区
key = read_string(f) # 字符串 = u64 长度 + 字节
vtype, = struct.unpack("<I", f.read(4)) # 值类型(u32/f32/string/数组…)
meta[key] = read_value(f, vtype)
tensors = []
for _ in range(n_tensors): # ④ 张量目录
name = read_string(f)
n_dims, = struct.unpack("<I", f.read(4))
shape = struct.unpack(f"<{n_dims}q", f.read(8 * n_dims))
qtype, offset = struct.unpack("<iq", f.read(12))
tensors.append((name, shape, qtype, offset))
align = meta.get("general.alignment", 32) # ⑤ 数据区起点对齐
data_start = align_up(f.tell(), align)
return meta, tensors, data_start # 之后 mmap 即可按 offset 取张量
重点看: 全程只用 struct——没有任何框架依赖。这就是 GGUF 成为「本地模型通用语」的原因。
6. KV 里到底装了什么
KV 的 key 有命名约定:general.*(架构名、对齐、文件类型)、<arch>.*(该架构的超参数)、tokenizer.ggml.*(词表、merges、模板)。加载侧的消费者在 llama_model_base::load_hparams(src/llama-model.cpp:1196)和 load_vocab(src/llama-model.cpp:1384)。
几个典型的 key:
| key | 内容 | 谁读它 |
|---|---|---|
general.architecture | 架构名(如 llama、qwen3moe),决定用 src/models/ 里哪段构图代码 | 模型创建 |
<arch>.context_length / embedding_length / … | 训练上下文长、维度、层数、头数 | load_hparams |
tokenizer.ggml.model / tokens / merges | 分词器类型(BPE/SPM)、词表、合并规则 | load_vocab |
general.alignment | 数据区对齐(默认 32) | gguf 解析器 |
general.file_type | 整文件的量化档位(FTYPE) | 量化工具 |
分词器进文件是 GGUF 的关键决策:词表和 BPE merges 都在 KV 里,推理方不需要再去找 tokenizer.json——「一个文件即全部」由此闭环。
7. mmap:大模型「秒开」的来历
文件解析完,张量数据不急着读。llama_model_loader::init_mappings(src/llama-model-loader.cpp:1365)把文件 mmap 进地址空间(封装在 llama_mmap,src/llama-mmap.h:44-51),之后张量的 data 指针直接指向映射页 + 偏移。
效果分三层:
- 加载快。 几 GB 的模型,「加载」只是建了个映射,实际读页发生在首次用到该权重时,由 OS 缺页中断完成。
- 内存省。 只跑短对话时,没被碰过的层对应的页根本不进内存;多进程跑同一模型还能共享页缓存。
- 可锁可弃。 内存充裕可用 mlock 钉住(
init_mappings里的mlock_mmaps分支,src/llama-model-loader.cpp:1389-1394);紧张时可以madvise丢弃已用过的页。
mmap 不是无条件开的:load_tensors 会逐个问后端设备「你支持 mmap 吗」,有一个不支持就回退到普通读取(src/llama-model.cpp:1402-1411)——mmap 的页在主机内存,GPU 直读需要额外路径支持。
8. 从 HF 模型到 GGUF:转换器
GGUF 文件不是模型作者手搓的,而是 convert_hf_to_gguf.py(仓库根目录)从 Hugging Face 格式转换:读 config 定 KV、读权重按张量名映射、可选现场量化。每种架构在脚本里有一个对应的转换类(convert_hf_to_gguf.py:175 的 main 起)。
这也意味着 GGUF 的「语义层」是约定而非格式强制的:张量名(blk.12.attn_k.weight 这种)是转换脚本和 src/models/ 之间的契约。格式保证「数据能读出来」,不保证「名字是加载方期望的」——遇到第三方转换的 GGUF 加载失败,十有八九死在张量名匹配。
9. 关键细节/坑
- 版本只前进不回头。 v1 已拒载(
ggml/src/gguf.cpp:503-506),只支持 v2/v3(当前写入总是 v3)。 - 字节序假设是小端。 解析器用版本号的位模式探测大小端不匹配并直接报错(§4 第 2 步),不做转序——实际生态里 GGUF 一律小端。
- 对齐可自定义但必须是 2 的幂(
ggml/src/gguf.cpp:623-627)。 - 张量偏移必须连续无洞(§4 第 6 步),所以往已有 GGUF 里「插一个张量」等于重写整个文件。
- 超大文件靠拆分。
llama_model_loader支持多文件(files列表),tools/gguf-split负责切/合;单文件本身没有内建分片。
10. 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 格式常量 | ggml/include/gguf.h | GGUF_MAGIC、GGUF_VERSION、GGUF_DEFAULT_ALIGNMENT |
| 文件解析 | ggml/src/gguf.cpp | gguf_init_from_reader、gguf_context、gguf_reader |
| 张量目录校验 | ggml/src/gguf.cpp | gguf_tensor_info(读取循环内) |
| 写文件 | ggml/src/gguf.cpp | gguf_write_to_file |
| mmap 封装 | src/llama-mmap.h / src/llama-mmap.cpp | llama_mmap、llama_mlock |
| 模型加载器 | src/llama-model-loader.cpp | llama_model_loader::init_mappings、llama_model_loader |
| 超参数/词表消费 | src/llama-model.cpp | llama_model_base::load_hparams、llama_model_base::load_vocab |
| HF → GGUF 转换 | convert_hf_to_gguf.py | main |