跳到主要内容

数据截至 (上游 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 之后让操作系统按需调页

三件事决定这个格式:

  1. 自描述。 模型层数、维度、RoPE 参数、分词器词表和合并规则……全部作为 key-value 写在文件头。加载器不需要任何外部文件。
  2. 张量只登记目录,不内嵌结构。 每个张量记「名字 + 形状 + 量化类型 + 数据偏移」,数据本体集中放在最后的对齐区。
  3. 对齐是为了 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),顺序就是上图的顺序:

  1. 验 magic——4 字节逐字符比对 "GGUF"(ggml/src/gguf.cpp:457-479)。
  2. 读版本号并做防御检查:版本 0、v1、超过当前支持版本都拒绝;还顺手用一个位运算 (version & 0x0000FFFF) == 0 识别「文件和主机字节序不一致」(ggml/src/gguf.cpp:497-501)。
  3. n_tensorsn_kv,两个计数都先过一遍「会不会溢出 size_t」的检查(ggml/src/gguf.cpp:514-535)。
  4. 循环读 KV;读完从 KV 里取 general.alignment 决定对齐,并要求是 2 的幂(ggml/src/gguf.cpp:611-626)。
  5. 循环读张量目录:名字查重、维度数 ≤ 4、每行元素数是量化块大小的整数倍、字节数不溢出(ggml/src/gguf.cpp:630-756,块对齐校验在 :723-730)。
  6. 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架构名(如 llamaqwen3moe),决定用 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:175main 起)。

这也意味着 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.hGGUF_MAGICGGUF_VERSIONGGUF_DEFAULT_ALIGNMENT
文件解析ggml/src/gguf.cppgguf_init_from_readergguf_contextgguf_reader
张量目录校验ggml/src/gguf.cppgguf_tensor_info(读取循环内)
写文件ggml/src/gguf.cppgguf_write_to_file
mmap 封装src/llama-mmap.h / src/llama-mmap.cppllama_mmapllama_mlock
模型加载器src/llama-model-loader.cppllama_model_loader::init_mappingsllama_model_loader
超参数/词表消费src/llama-model.cppllama_model_base::load_hparamsllama_model_base::load_vocab
HF → GGUF 转换convert_hf_to_gguf.pymain