乐于分享
好东西不私藏

llama.cpp 源码解析(二):GGUF 格式与模型加载——从磁盘文件到内存张量

llama.cpp 源码解析(二):GGUF 格式与模型加载——从磁盘文件到内存张量

这是 llama.cpp 源码系列的第二篇。上一篇我们拆解了推理引擎 GGML,理解了张量、Arena、延迟执行、Galloc、KV Cache、线程模型的完整骨架。但还有一个问题没回答:模型文件本身是怎么设计的?从磁盘上的一个文件,到内存里一组可用的 ggml_tensor,中间经历了什么?

这就是本文要讲的内容。读完你会理解 GGUF 的二进制格式、mmap 为什么能做到"瞬间"加载、以及一个 HuggingFace 模型怎么转成 GGUF 用 llama.cpp 跑起来。


引言: 先说清楚这篇文章讲什么——以及为什么需要它

1 模型文件格式的本质——把"一堆数字"和"它们是什么"存进磁盘

抛开所有框架、所有工具,一个模型文件最底层到底存了什么?

两样东西:数据和元数据。

数据就是权重——训练结束后冻结下来的那几十亿个浮点数。元数据告诉你这些数字怎么用——它们按什么形状排列、属于哪一层、叫什么名字、模型总共多少层、用什么 tokenizer。

就这么简单。所有的格式之争,本质上都是在争"数据和元数据怎么组织、怎么打包、怎么读取"这三个问题的不同答案。

2 判断一个格式好不好的六个维度

拿到一个模型文件格式,可以从六个角度评价它:

维度
含义
为什么重要
安全性
打开文件时会不会执行恶意代码?
PyTorch 的 pickle 格式可以执行任意 Python,下载模型 = 运行未知代码
自描述
文件自己知不知道"我是什么"?
如果格式不自描述,就需要 config.json + tokenizer.json 等额外文件来补充
mmap 友好
能不能不经拷贝直接映射到内存?
决定了 100GB 模型是"瞬间"加载还是等几分钟
单文件
所有东西打包到一个文件?
单文件容易分发,多文件(如 safetensors 分片)需要索引
零依赖
读取格式需要什么运行时?
Python 依赖限制了部署场景(手机、嵌入式、WASM)
量化支持
能不能在文件内直接存储量化权重?
决定了是"加载后再量化"还是"加载即量化"

六个维度不是孤立的——一个维度的选择会影响其他维度。下面逐个格式看它们在这六个维度上的取舍。

3 格式一:PyTorch pickle(.pt / .pth / .bin)——最古老,也最危险

PyTorch 从 2016 年诞生起就用 Python 的 pickle 做序列化。Pickle 的设计初衷是"把任意 Python 对象存到磁盘,以后原样读回来"——包括类定义、函数、甚至 lambda。

怎么存的torch.save(model.state_dict(), "model.pt") → Python 的 pickle 模块把整个 state_dict(一个 OrderedDict,key 是层名,value 是 tensor)序列化成二进制。

问题在哪:pickle 的反序列化过程会执行 pickle 数据中的任意 Python 代码。这意味着一个恶意构造的 .pt 文件可以在你加载时删掉你的文件、窃取你的数据、安装后门。HuggingFace 早期大量使用这种格式,后来因为安全问题逐步迁移到 safetensors。

为什么推理不能用它:需要 Python + PyTorch 运行时(几百 MB)、不支持 mmap(必须一次性读到内存)、不支持量化(权重以 FP32/FP16 存储)、不是自描述的(需要额外代码定义模型类才能加载)。

4 格式二:Safetensors——安全替代 pickle

HuggingFace 在 2022 年推出了 safetensors。它的核心卖点是:纯数据,不能执行代码。

怎么存的:文件头 8 字节是 JSON 元数据的长度(uint64),后面紧跟一段 JSON 描述每个 tensor 的名字、形状、数据类型、在文件中的偏移量,再后面就是原生的 tensor 二进制数据。

Safetensors 文件布局:┌──────────────────────────────────────────────┐│ Header (8 bytes): JSON 部分的长度              │├──────────────────────────────────────────────┤│ JSON Metadata:                                ││   "token_embd.weight": {shape:[768,6400],     ││     dtype:"F16"offsets:[09830400]}       ││   "blk.0.attn_q.weight": {...}               ││   ...                                         │├──────────────────────────────────────────────┤│ Tensor Data (raw bytes):                      ││   [9.8MB of token_embd] [1.1MB of attn_q] ...│└──────────────────────────────────────────────┘

为什么它安全:JSON 不是图灵完备的——解析 JSON 不能执行代码。Tensor 数据是纯二进制,直接 memcpy 到目标地址。

为什么推理不能直接用:它只解决了"安全存储",没有解决"高效推理"。Safetensors 的元数据(JSON)在文件最前面,但这只是 tensor 级别的元数据——没有模型架构信息(多少层、什么 attention)、没有 tokenizer、没有推理超参数。而且它不支持量化格式内嵌——权重总是以 FP32/FP16/BF16 存储。

5 格式三:ONNX——跨框架的"通用语言"

ONNX(Open Neural Network Exchange)是微软和 Facebook 在 2017 年发起的。它的目标是:用 PyTorch 训练,用 TensorRT 推理,中间用 ONNX 做桥梁。

ONNX 用 Protobuf 定义了一个完整的计算图——不仅有权重,还有操作节点(Conv、MatMul、ReLU...)。这比 safetensors 更进一步:safetensors 只知道"有这么些 tensor",ONNX 知道"这些 tensor 怎么用"。

为什么 LLM 推理不用它:ONNX 是为视觉模型(固定输入大小、静态图)设计的。LLM 需要动态形状(batch 可变、序列长度可变)、KV Cache、autoregressive 循环——这些都是 ONNX 的弱项。加上 onnxruntime 的依赖链仍然很重。

6 格式四:AWQ / GPTQ——GPU 服务端的量化格式

这两个是专门为 GPU 服务端推理设计的量化格式,不是独立的文件格式——它们通常以 PyTorch checkpoint 或 safetensors 为载体,内部包含量化后的权重和量化参数。

  • AWQ
    (Activation-aware Weight Quantization):按激活值的大小分配量化精度,重要通道保留更高精度
  • GPTQ
    (GPT Post-Training Quantization):用 Hessian 矩阵逐层优化量化方案,质量接近原始

它们的问题:必须配合特定的推理引擎(vLLM、TGI、TensorRT-LLM),不能在 llama.cpp 中直接使用。因为它们的量化方案(per-channel scaling、分组量化)和推理逻辑是耦合的。

7 格式五:GGUF——专为本地推理而生

GGUF 的诞生动机很纯粹:前面四种格式没有一个适合"在用户的破笔记本上跑大模型"。

PyTorch pickle 不安全 + 需要 Python。Safetensors 安全但没有模型级元数据 + 需要 Python 库。ONNX 不适合动态 LLM。AWQ/GPTQ 绑定了 GPU 服务端推理引擎。

GGUF 的设计目标一句话:打开文件,一切就绪。 不需要 config.json,不需要 tokenizer.json,不需要 Python,不需要 GPU。一个文件,纯 C 读取,mmap 瞬间加载,量化格式内嵌,跨平台。

8 六维对比:一张表看懂定位

[图1:五种模型格式全景对比] — 五列卡片横向排列,每列标注格式名、诞生年份、核心特征。底部六维对比网格,GGUF 全部 ✓。时间线 2016→2023。

格式
安全性
自描述
mmap
单文件
零依赖
量化
诞生年份
定位
PyTorch .pt
❌ pickle
❌ 需代码
❌ Python
2016
训练 checkpoint
Safetensors
✓ 纯数据
△ Tensor级
否*
❌ 需库
2022
安全分发
ONNX
✓ 计算图
❌ runtime
2017
跨框架交换
AWQ/GPTQ
❌ 需引擎
2023
GPU 服务端
GGUF✓ KV+Tensor✓ 纯C✓ 40+2023本地推理

*Safetensors 通常分片存储(每个文件 < 5GB),需索引文件来拼接。但也可以不分片。

9 完整管线:一个模型从训练到部署的全路径

[训练框架:PyTorch / JAX / TensorFlow]   ↓ 输出训练好的权重PyTorch .pt  (pickle, 不安全)   ↓ 或直接存为Safetensors  (安全, 但仍是"通用数据容器")   ↓[格式转换 + 量化]  ← 这一步是本文的主题   ├─→ AWQ/GPTQ → vLLM/TGI        (GPU 服务器, 高并发)   ├─→ ONNX      → onnxruntime     (跨平台, 但不适合LLM)   └─→ GGUF      → llama.cpp       (本地/边缘, 零依赖)        ↓   llama-quantize (可选: FP16→Q4_K_M)        ↓   llama-cli -m model.gguf

GGUF 在这条管线中的位置很清楚:它是从"训练生态"到"部署生态"的桥梁。 上游的 safetensors 是训练框架的"出口格式",下游的 GGUF 是推理引擎的"入口格式"。中间的转换脚本(convert_hf_to_gguf.py)做两件事:把 safetensors 的 FP16 权重拷过来,把 HuggingFace config.json 和 tokenizer.json 转成 GGUF 的 KV 元数据。

10 这篇文章的写法

上一篇我们对着 8000 行 GGML 源码深度拆解,内容非常多。这篇会短一些——因为 GGUF 本身就是一个故意做得很简单的格式。它的设计哲学和 GGML 一脉相承:把事情做到刚好能用,然后停手。打开 ggml/include/gguf.h,前 31 行注释就是完整的文件格式规范。不需要 RFC 文档,不需要单独维护 wiki,源码即规范。

我第一次读 GGUF 相关代码的时候挺惊讶的——"?"总共就这两个文件:gguf.cpp(1556 行,读写实现)和 gguf.h(约 200 行,接口定义)。但越简单的设计越难做——你要在"足够用"和"不复杂"之间找到精确的平衡点。


第一章:GGUF 的二进制格式——五段结构

1.1 规范就在源码注释里

打开 ggml/include/gguf.h,前 31 行就是完整的文件格式规范。不用翻文档,不用查 wiki——源码注释就是规范本身。这很 GGML——把复杂的事情说得尽可能简单。

1. File magic "GGUF" (4 bytes)                    ← 文件头,固定 0x474755462. File version (uint32_t)                         ← 当前是 33. Number of ggml tensors in file (int64_t)         ← 有多少个权重张量4. Number of key-value-pairs in file (int64_t)      ← 有多少个元数据键值对5. For each KV pair:                                ← 遍历所有 KV 对   1. The key (string)   2. The value type (gguf_type)   3a. If array: type + count + binary of elements   3b. Otherwise: binary of value6. For each ggml tensor:                            ← 遍历所有张量   1. The tensor name (string)   2. The number of dimensions (uint32)   3. For each dimension: size (int64)   4. The tensor data type (int32)   5. The tensor data offset in tensor data blob (uint64)7. The tensor data binary blob (optional, aligned)  ← 实际权重数据

画成图就是五段:

┌──────────────────────────────────────────────┐│ 1. Magic  "GGUF"  (4 bytes)                  │├──────────────────────────────────────────────┤│ 2. Header                                     ││    version     = 3        (uint32)            ││    n_tensors   = 90       (int64)             ││    n_kv        = 33       (int64)             │├──────────────────────────────────────────────┤│ 3. KV Metadata (33 pairs, variable length)    ││    "general.architecture"  → "qwen3"          ││    "qwen3.block_count"     → 8                ││    "qwen3.embedding_length" → 768              ││    "tokenizer.ggml.tokens" → ["<|endoftext|>",││                               "<|im_start|>",││                               ...]            ││    ... (30 more)                               │├──────────────────────────────────────────────┤│ 4. Tensor Info Array (90 entries)              ││    [0"token_embd.weight"                     ││        dims=[7686400], type=F16, offset=0    ││    [1"blk.0.attn_norm.weight"                ││        dims=[768], type=F32, offset=9.8M       ││    [2"blk.0.attn_q.weight"                   ││        dims=[768768], type=F16, offset=...   ││    ... (87 more)                                ││   [89"output_norm.weight"                    ││        dims=[768], type=F32, offset=127.8M     │├──────────────────────────────────────────────┤│ 5. Tensor Data (aligned to 32 bytes)           ││    实际的权重值,按 Tensor Info 的 offset 定位    ││    总大小 = sum of all tensor sizes             │└──────────────────────────────────────────────┘

1.2 存储类型系统

GGUF 的 KV 元数据支持 12 种基本类型(ggml/include/gguf.h:53-68):

enum gguf_type {    GGUF_TYPE_UINT8   = 0,   // 无符号 8 位整数    GGUF_TYPE_INT8    = 1,    GGUF_TYPE_UINT16  = 2,    GGUF_TYPE_INT16   = 3,    GGUF_TYPE_UINT32  = 4,    GGUF_TYPE_INT32   = 5,   // 枚举用这个    GGUF_TYPE_FLOAT32 = 6,   // 浮点数    GGUF_TYPE_BOOL    = 7,   // 布尔值(存储为 int8)    GGUF_TYPE_STRING  = 8,   // 字符串(长度 uint64 + 内容)    GGUF_TYPE_ARRAY   = 9,   // 数组(类型 + 数量 + 元素列表)    GGUF_TYPE_UINT64  = 10,    GGUF_TYPE_INT64   = 11,   // 大整数    GGUF_TYPE_FLOAT64 = 12,};

字符串的存储方式是 string_length (uint64) 后跟 C 字符串(无 null 终止符)。这样读取时知道要读多少字节,不用扫描 null。

1.3 真实例子:MiniMind 的 GGUF 文件

用 llama.cpp 自带的 llama-gguf 工具看一眼真实的 MiniMind 文件:

$ llama-gguf minimind-f16.gguf r nHeader:  magic    = GGUF  version  = 3  n_tensors = 90  n_kv      = 33KV[0]:  general.architecture        = "qwen3"        ← 决定了构建器KV[1]:  general.type                = "model"KV[2]:  general.name                = "Minimind 3"KV[3]:  general.version             = "3"KV[9]:  qwen3.block_count           = 8              ← 层数KV[10]: qwen3.context_length        = 32768          ← 训练上下文KV[11]: qwen3.embedding_length      = 768            ← 嵌入维度KV[12]: qwen3.feed_forward_length   = 2432           ← FFN 维度KV[13]: qwen3.attention.head_count  = 8              ← Q 头数KV[14]: qwen3.attention.head_count_kv = 4            ← KV 头数 (GQA!)KV[15]: qwen3.rope.freq_base        = 1000000.0      ← RoPE 频率KV[19]: general.file_type           = 1              ← 1 = F16KV[21]: tokenizer.ggml.model        = "gpt2"         ← tokenizer 类型KV[22]: tokenizer.ggml.pre          = "qwen2"        ← pre-tokenizerKV[23]: tokenizer.ggml.tokens       = ["<|endoftext|>", ...] ← 6400 tokensTensor[0]:  name="token_embd.weight",       dims=[768,6400], type=F16, offset=0Tensor[1]:  name="blk.0.attn_norm.weight",  dims=[768],     type=F32, offset=9.8M...Tensor[85]: name="blk.7.attn_output.weight", dims=[768,768], type=F16, offset=125MTensor[89]: name="output_norm.weight",      dims=[768],     type=F32, offset=127.9M

33 个键值对告诉 llama.cpp 关于这个模型的一切。有了它们,不需要任何额外的配置文件。

1.4 为什么 Tensor Info 在数据区前面

这不是随意的。回想上一篇讲的 mmap:GGUF 文件被 mmap 映射后,程序先读取前面的元数据区(几十 KB),这些页面被加载到物理内存。然后它知道了每个张量在文件里的精确偏移——token_embd.weight 从字节 X 开始、blk.0.attn_q.weight 从字节 Y 开始。

这些偏移量直接作为 tensor->data 的地址,不需要 fread。当程序第一次访问某个张量的数据时,操作系统触发缺页中断,按需加载对应的 4KB 页。 如果某些层的权重在当前推理中没有被用到(比如只跑前几层的情况),它们对应的页面永远不会被加载。

GGUF 的格式设计唯一的指导原则就是"怎么让 mmap 用得最爽"。

[图2:GGUF 文件五段二进制结构] — 纵向展示 Magic → Header → KV 元数据 → Tensor Info → Tensor Data 的完整布局,标注每段大小和用途。

[图3:Safetensors vs GGUF 格式布局对比] — 看完 GGUF 的五段结构后,这里并排对比 Safetensors(Header→JSON→Data)和 GGUF(Magic→Header→KV→TensorInfo→Data)。核心差异:Safetensors 只存张量级元数据,GGUF 内嵌模型架构、超参数、词表、对话模板。


第二章:GGUF 的内存表示——gguf_context

2.1 两个数据结构:gguf_context 和 gguf_tensor_info

当 gguf_init_from_file 被调用时,它创建两个 C++ 结构(ggml/src/gguf.cpp:217):

struct gguf_context {    uint32_t version = GGUF_VERSION;    std::vector<struct gguf_kv> kv;              // 所有 KV 键值对    std::vector<struct gguf_tensor_info> info;   // 所有张量的信息    size_t alignment = GGUF_DEFAULT_ALIGNMENT;   // 对齐要求(默认 32)    size_t offset    = 0;                        // 数据区在文件中的起始位置    size_t size      = 0;                        // 数据区的总字节数    void * data = nullptr;                       // 数据区指针(mmap 地址 或 malloc)};

gguf_kv 存一个键值对的类型和值,gguf_tensor_info 存一个张量的名字、形状、ggml 类型、在数据区中的偏移量。

2.2 读取流程:gguf_init_from_file

这个函数是 GGUF 的核心实现(ggml/src/gguf.cpp,约 200 行)。把它拆成四步,每一步都很直白:

步骤 1:验证"这真的是个 GGUF 文件"。 读前 4 个字节,看是不是 GGUF(0x47475546)。如果不是——直接报错。然后读版本号和你最关心的两个数字:文件里有几个张量、几个 KV 对。

uint32_t magic   = read_u32(file);   // 必须是 "GGUF"uint32_t version = read_u32(file);   // 当前是 3uint64_t n_tensors = read_u64(file); // 例如 90(MiniMind)uint64_t n_kv      = read_u64(file); // 例如 33

步骤 2:读所有 KV 元数据。 循环 n_kv 次,每次读一个 key(字符串)+ type(枚举)+ value(按 type 决定长度)。GGUF 用 type 字段告诉读取者"接下来多少字节、怎么解析"——这是自描述格式的核心。

for (int i = 0; i < n_kv; i++) {    string key   = read_string(file);        // "general.architecture"    int32_t type = read_i32(file);           // GGUF_TYPE_STRING = 8    // 根据 type 决定怎么读 value    switch (type) {        case GGUF_TYPE_STRING: value = read_string(file); break;  // "qwen3"        case GGUF_TYPE_UINT32: value = read_u32(file);   break;  // 8        case GGUF_TYPE_ARRAY:  /* 先读元素类型+数量,再逐个读 */ break;        // ...    }    ctx->kv.push_back({key, type, value});}

步骤 3:读所有 Tensor Info。 循环 n_tensors 次,每次读名字、维度数组、ggml 类型、在数据区的偏移量。这一步读完,每个张量的"身份证"就齐了,但数据还躺在磁盘上。

for (int i = 0; i < n_tensors; i++) {    string name     = read_string(file);         // "blk.0.attn_q.weight"    uint32_t n_dims = read_u32(file);            // 2    int64_t ne[4] = {1,1,1,1};    for (int d = 0; d < n_dims; d++)        ne[d] = read_u64(file);                  // [768, 768]    ggml_type type = (ggml_type)read_i32(file);  // GGML_TYPE_F16    uint64_t offset = read_u64(file);            // 在数据区偏移 1.1MB    ctx->info.push_back({name, ne, type, offset});}

步骤 4:处理数据区。 此时文件指针恰好在数据区开头。记录下这个位置和数据区大小,然后用 mmap 或 malloc+fread 建立访问通道。数据区本身可能非常大(几十 GB),但在 mmap 模式下,这一行执行完函数就返回了——数据没有真正加载到物理内存。


第三章:llama_model_loader——从 gguf_context 到 llama_model

3.1 加载器做什么

src/llama-model-loader.cpp (1695 行) 负责把 gguf_context 转换成 llama_model。上一篇我们学过 llama_model 的结构——它包含所有层的权重张量引用。

转换步骤分四步:

第 1 步:验证和读取超参数。 从 KV 元数据中提取架构信息:

// llama-model-loader.cpp 内部std::string arch = gguf_get_val_str(meta, "general.architecture");// arch = "qwen3"int n_layer  = gguf_get_val_u32(meta, "qwen3.block_count");       // 8int n_embd   = gguf_get_val_u32(meta, "qwen3.embedding_length");  // 768int n_head   = gguf_get_val_u32(meta, "qwen3.attention.head_count"); // 8int n_head_kv = gguf_get_val_u32(meta, "qwen3.attention.head_count_kv"); // 4// ... 十几个超参数全部从 KV 对中提取

第 2 步:确定模型架构。general.architecture = "qwen3" 触发架构匹配链:

"qwen3" → llama-arch.cpp → LLM_ARCH_QWEN3 → llama-model.cpp → 选择 builder

在 llama-arch.cpp (906 行) 中有一个巨大的架构特征表,记录了每种架构的"能力":

// llama-arch.cpp 内部(简化)static const std::map<llm_arch, llm_arch_features> ARCH_FEATURES = {    {LLM_ARCH_LLAMA,  {.has_attention=true, .has_rope=true, ...}},    {LLM_ARCH_QWEN3,  {.has_attention=true, .has_rope=true,                       .has_qk_norm=true, .has_swiglu=true, ...}},    {LLM_ARCH_MAMBA,  {.has_ssm=true, ...}},    // ... 130+ 个架构};

第 3 步:创建张量壳子。 为每个权重在 model_ctx 里创建 ggml_tensor。llama.cpp 用一套简洁的命名规则来组织这些张量:

模型级(不属于任何层):  token_embd   — 词嵌入矩阵,把 token ID 映射成 768 维向量  output_norm  — 输出前的 RMS 归一化  output       — 输出投影(lm_head),把 768 维映射回 6400 维词表每层(blk = block,即 Transformer 层):  blk.{i}.attn_norm    — 注意力前的归一化  blk.{i}.attn_q       — Q 投影权重(768×768)  blk.{i}.attn_k       — K 投影权重(384×768,GQA 的 KV 头只有 Q 的一半)  blk.{i}.attn_v       — V 投影权重(384×768)  blk.{i}.attn_output  — 注意力输出投影(768×768)  blk.{i}.ffn_gate     — FFN 的门控权重(SwiGLU 的 gate 分支)  blk.{i}.ffn_up       — FFN 的上投影(SwiGLU 的 up 分支)  blk.{i}.ffn_down     — FFN 的下投影(把 2432 维压回 768 维)

代码里通过一个 create_tensor lambda 批量创建:

// llama-model.cpp:3074auto create_tensor = [&](tensor_name, shape, flags) -> ggml_tensor * {    // 在 model_ctx (Arena, no_alloc=true) 里分配 ggml_tensor 结构体    return ml.create_tensor(hparams, ..., tensor_name, shape, flags);};for (int il = 0; il < n_layer; il++) {    layers[il].wq = create_tensor(TN::ATTN_Q, {n_embd, n_embd}, il);    layers[il].wk = create_tensor(TN::ATTN_K, {n_embd_k_gqa, n_embd}, il);    // ... 每个层创建 ~13 个权重张量}

我第一次看到这个循环的时候,脑子里闪过上一篇的 llm_build_qwen3——那个 builder 也是用同样的循环在 graph_ctx 里创建计算图节点。一个负责"把权重从文件读到内存",一个负责"把权重变成计算图"。两人用同一个循环结构,各司其职。很整齐。

90 个 ggml_tensor 壳子创建完毕,全部 data=NULL

第 4 步:分配 backend_buffer,落实数据。 这是上一篇第二章 2.3 讲过的逻辑:

// llama-model.cpp:8187buf = ggml_backend_alloc_ctx_tensors_from_buft(model_ctx, buft);// → 遍历 model_ctx 中所有 tensor// → 分配 122MB 连续 buffer(CPU malloc / GPU cudaMalloc / mmap)// → 逐个设置 tensor->data = buffer_base + offset// → 逐个设置 tensor->buffer = buf// → 如果 mmap:data 指针直接指向 GGUF 文件的虚拟映射地址

3.2 分片文件的加载

FAT32 文件系统单文件最大 4GB。几十 GB 的大模型必须拆成多个分片发布。GGUF 的分片命名遵循 NAME-%05d-of-%05d.gguf 格式:

model-00001-of-00004.gguf  (0~4GB)model-00002-of-00004.gguf  (4GB~8GB)model-00003-of-00004.gguf  (8GB~12GB)model-00004-of-00004.gguf  (12GB~16GB)

llama.cpp 加载分片的过程(llama-model-loader.cpp 内部):

1. 检测文件名中的 -%05d-of-%05d 模式 → 推断有 4 个分片2. 打开第一个分片 → 读取 Header + KV 元数据   (后续分片的 Header 也被读取做校验,但 KV 元数据只从第一个文件取)3. 读取所有分片的 Tensor Info   → 每个分片有自己的 Tensor Info,但 n_tensors 总数相同   → 加载器把同名的 Tensor Info 合并:offset 加上前面分片的数据累积偏移4. mmap 数据区   → 每个分片独立 mmap,拼接成逻辑上连续的地址空间   → 或 malloc 一份大 buffer,把各分片数据依次 memcpy 进去关键约束:一个张量的数据不能跨分片边界。GGUF 的分片是对齐到张量边界的——不会有张量的前半段在分片 N、后半段在分片 N+1 的情况。

加载器用 llama_get_list_splits 自动检测和拼接(llama-model-loader.cpp:78)。这也是为什么 GGUF 的 Tensor Info 里有 offset 字段——对单文件来说它是"文件内偏移",对分片来说它是"合并后的逻辑偏移"。


第四章:mmap 深度配合——GGUF 的核心设计哲学

4.1 传统做法 vs mmap——差了几个数量级

传统 fread 做法fopen → malloc(100GB) → fread(100GB) → fclose。对于 100GB 的模型,你需要 100GB 空闲物理内存。fread 是把整个文件从头到尾拷贝到 malloc 出的 buffer 里——100GB 的数据搬运,即使 SSD 读写 3GB/s 也要半分钟以上。

mmap 做法fopen → mmap(建立映射) → fclose。核心是这一行系统调用:

void * data = mmap(    NULL,             // 让内核选地址    file_size,        // 映射整个数据区    PROT_READ,        // 只读    MAP_PRIVATE,      // 私有映射(修改不影响原文件)    fd,               // 文件描述符    data_offset       // 从文件的第 data_offset 字节开始映射);

这行代码不拷贝任何数据。它只是在进程的页表里建了一条记录:"虚拟地址 0x7f0000000000 到 0x7f0XXXXXXXXX,对应磁盘上 model.gguf 的第 data_offset 到末尾"。操作系统返回"好的,已记录"——函数就返回了。

缺页中断怎么工作? 当程序第一次通过 tensor->data 访问某个权重值(比如 blk.0.attn_q.weight->data[0]):

CPU: 我要读虚拟地址 0x7f0000100000 的 4 字节MMU: 这个地址对应的物理页还没加载 → 触发 page fault (中断 #14)内核: 从页表查出 → 对应 model.gguf 文件偏移 1.1MB      → 在物理内存找一页空闲的 4KB      → 发起磁盘 I/O:读 model.gguf 的 1.1MB~1.1MB+4KB      → 把数据写入物理页      → 更新页表:虚拟地址 0x7f0000100000 → 物理页 X      → 返回用户态CPU: 重试刚才的读操作 → 成功读到数据程序: 完全不知道刚才发生了什么

整个过程中,程序看到的只是"读了一个内存地址,拿到了值"。内核在背后默默做了磁盘 I/O。

[图4:mmap 缺页中断五步机制] — 三层结构(用户态/内核/物理内存),展示 ①mmap 映射 → ②程序访问 → ③缺页中断 → ④内核处理 → ⑤数据返回的完整流程。

4.2 mmap 和 GGUF 的配合——设计即优化

GGUF 的五段结构让 mmap 的效果最大化:

Header (16B) + KV (几KB) + Tensor Info (几KB)  → 必须立即读,但只有几十 KBTensor Data (122MB~100GB)                       → mmap 映射,按需加载

程序只需要立刻读 Header + KV + Tensor Info(总共几十 KB),然后对数据区做 mmap。推理时访问到哪个张量,操作系统加载哪个页面。没访问到的永远不占物理内存。

如果 GGUF 把元数据放在数据区后面会怎样? 那读取元数据前,必须先跳过整个数据区——要么 fread 整个文件(太慢),要么 seek 到末尾(不支持 mmap 流式读取)。GGUF 的"元数据在前"布局是专门为 mmap 设计的。

多轮对话的叠加优化:第一轮推理时,每层的权重按顺序被访问,操作系统逐页加载。第二、第三轮时,这些页面已经在前几轮中被加载过了,直接从操作系统的 page cache(页缓存)命中——没有磁盘 I/O。操作系统会自动把"热"页面留在内存,"冷"页面换出。用户感受到的效果是:"第一轮稍微慢一点,后面越跑越快。" 这是 mmap + OS page cache 自带的免费午餐。

4.3 GGUF 的默认对齐:32 字节

GGUF 数据区默认 32 字节对齐(GGUF_DEFAULT_ALIGNMENT = 32)。这个数字不是随便选的——对应 AVX/AVX2 的 256 位寄存器宽度,也是 DDR4/DDR5 内存总线的 burst 大小。对齐的张量数据可以一次内存访问完成,不对齐的需要两次。而且 32 是 4096(页大小)的约数,确保张量不会跨页边界——跨页边界的张量会触发两次缺页中断,而不是一次。


第五章:从 HuggingFace 到 GGUF——转换工具的工作原理

5.1 convert_hf_to_gguf.py 做了什么

这个 13423 行的 Python 脚本是 GGUF 生态的关键。它读 HuggingFace 格式的模型目录,输出 GGUF 文件:

HuggingFace 目录:├── config.json        → 读出架构名、层数、维度 → 写入 GGUF 的 KV 区├── tokenizer.json     → 读出所有 token → 写入 GGUF 的词表 KV 对├── model-00001-of-N.safetensors → 读出所有权重 → 写入 GGUF 的数据区└── ...           ↓ convert_hf_to_gguf.pyGGUF 文件:├── Header (magic + version + n_tensors + n_kv)├── KV Metadata (33 对: architecture=qwen3, block_count=8, ...)├── Tensor Info (90 个: name, shape, type, offset)└── Data (122MB 对齐后的权重值)

5.2 支持的架构

在 convert_hf_to_gguf.py 内部,每种架构是一个 ModelBase 的子类。以 Qwen3 为例:

@ModelBase.register("Qwen3ForCausalLM")classQwen3Model(TextModel):    model_arch = gguf.MODEL_ARCH.QWEN3

@ModelBase.register 装饰器把 HuggingFace 的类名("Qwen3ForCausalLM")和 GGUF 的架构名(QWEN3)绑定。当脚本读到一个 HuggingFace 模型的 config.json 里 "architectures": ["Qwen3ForCausalLM"],自动匹配到这个类。

5.3 如果你有一个全新的模型

需要三步(llama.cpp 官方文档 docs/development/HOWTO-add-model.md 对此有说明):

假设你有一个叫 MyModel 的新架构,HuggingFace 类名是 MyModelForCausalLM,结构和 LLaMA 基本一样。需要改三个地方:

第 1 步:Python 端注册(convert_hf_to_gguf.py,约 20 行)

@ModelBase.register("MyModelForCausalLM")  # ← config.json 的 architectures 字段classMyModelModel(TextModel):    model_arch = MODEL_ARCH.MYMODEL    # 告诉转换器"我的张量叫什么名字":GGUF 命名 → HuggingFace 命名    _model_tensors = {        MODEL_TENSOR.TOKEN_EMBD:  "model.embed_tokens",        MODEL_TENSOR.OUTPUT:      "lm_head",        MODEL_TENSOR.ATTN_Q:      "model.layers.{bid}.self_attn.q_proj",        MODEL_TENSOR.ATTN_K:      "model.layers.{bid}.self_attn.k_proj",        # ... 其他层映射    }

转换时脚本遍历 safetensors 文件,按这个映射把 model.layers.0.self_attn.q_proj.weight 重命名为 GGUF 标准名 blk.0.attn_q.weight,同时把 FP16/BF16 数据原样拷贝。

第 2 步:注册张量布局(gguf-py/gguf/constants.py,约 30 行)

classMODEL_ARCH(IntEnum):    MYMODEL = auto()  # ← 新增# 告诉 GGUF 这个模型有哪些类型的张量,按什么顺序MODEL_TENSORS[MODEL_ARCH.MYMODEL] = [    MODEL_TENSOR.TOKEN_EMBD, MODEL_TENSOR.OUTPUT_NORM, MODEL_TENSOR.OUTPUT,    MODEL_TENSOR.ATTN_NORM, MODEL_TENSOR.ATTN_Q, MODEL_TENSOR.ATTN_K,    MODEL_TENSOR.ATTN_V, MODEL_TENSOR.ATTN_OUT,    MODEL_TENSOR.FFN_NORM, MODEL_TENSOR.FFN_GATE, MODEL_TENSOR.FFN_UP,    MODEL_TENSOR.FFN_DOWN,]

第 3 步:写 C++ Graph Builder(src/models/mymodel.cpp

如果架构和 LLaMA 基本一样,直接参考 src/models/llama.cpp 模板——embedding → N层 Transformer → lm_head。每层:attn_norm → QKV → RoPE → Flash Attn → output → 残差 → ffn_norm → gate/silu × up → down → 残差。约 100~200 行 C++。

做完后还需要在 llama-arch.cpp 注册、llama-model.cpp 关联 llm_type、src/models/models.h 声明。这些都是模板化操作——复制已有架构的注册代码,改名字即可。

第 1+2 步约 50 行 Python(做完就能转 GGUF),第 3 步取决于架构复杂度。llama.cpp 官方文档 docs/development/HOWTO-add-model.md 有完整指南。


第六章:一个完整的加载流程——从命令行到你第一个 token

把上面所有内容串起来。当你在终端输入:

llama-cli -m minimind-f16.gguf -p "你好"

发生的完整流程:

llama_model_load_from_file("minimind-f16.gguf")  │  ├─► gguf_init_from_file("minimind-f16.gguf")  │     → 读 Header: version=3, n_tensors=90, n_kv=33  │     → 读 33 个 KV 对 → gguf_context.kv  │     → 读 90 个 Tensor Info → gguf_context.info  │     → mmap 数据区 → gguf_context.data = 0x7f...  │  ├─► llama_model_loader 构造  │     → 读 "general.architecture" = "qwen3"  │     → 读 超参数: n_layer=8, n_embd=768, n_head=8, ...  │     → 读 tokenizer: 6400 个 token, BPE merges  │  ├─► 确定架构 → LLM_ARCH_QWEN3 → llm_build_qwen3  │  ├─► 在 model_ctx(Arena, no_alloc=true)创建 90 个 ggml_tensor  │     token_embd, output_norm, output  │     blk.0.attn_q, blk.0.attn_k, blk.0.attn_v, blk.0.attn_output  │     blk.0.attn_q_norm, blk.0.attn_k_norm  │     blk.0.ffn_gate, blk.0.ffn_down, blk.0.ffn_up  │     ... (8层)  │     全部 data=NULL  │  ├─► ggml_backend_alloc_ctx_tensors_from_buft(model_ctx, cpu_buft)  │     → 计算总大小 = 122 MB  │     → mmap 映射: 每个 tensor->data 直接指向 GGUF 文件内对应偏移  │     → 或 malloc: 分配 122MB, memcpy 从文件读取  │  └─► 返回 llama_model *         model->layers[0].wq->data → 0x7f... (GGUF 文件内偏移)         model->layers[0].wq->ne   = [768768]         model->hparams.n_layer    = 8         model->vocab.n_tokens     = 6400llama_init_from_model(model, params)  → 创建 llama_context, KV Cache, sampler, ...llama_decode(ctx, batch)  → 第一篇第九章讲的完整推理流程

每个阶段都干净利索,没有多余步骤。这就是 GGUF + mmap + GGML 三者组合的威力——从磁盘文件到跑出第一个 token,中间没有一次完整的数据拷贝。

[图5:完整加载流程] — 五阶段纵向流程图,右侧标注每阶段耗时。mmap 路径总耗时 < 1ms,malloc 路径数分钟。


第七章:核心源码索引

概念
文件:行号
GGUF 二进制格式规范
ggml/include/gguf.h:1-31
gguf_type 枚举(12 种 KV 类型)
ggml/include/gguf.h:53-68
gguf_context 结构体
ggml/src/gguf.cpp:217-228
gguf_init_from_file(读取 GGUF)
ggml/src/gguf.cpp
llama_model_loader 构造
src/llama-model-loader.cpp
从 KV 元数据提取超参数
src/llama-model.cpp
 (llama_model 构造)
创建张量壳子(create_tensor lambda)
src/llama-model.cpp:3074
分配 backend buffer(mmap 分支)
src/llama-model.cpp:8158-8178
架构自动检测匹配表
src/llama-arch.cpp
mmap 文件映射工具
src/llama-mmap.cpp
HuggingFace → GGUF 转换
convert_hf_to_gguf.py
 (13423 行)
新模型适配官方指南
docs/development/HOWTO-add-model.md

第八章:下一篇预告

GGUF 格式和模型加载讲完了。下一篇系列第三篇:130+ 种架构的支持机制——llama.cpp 如何用一套 Builder 模式支持 LLaMA、Qwen、Mistral、Gemma、DeepSeek 等上百种架构?llama-model.cpp 这个 9666 行的最大源文件是如何组织起来的?每种架构的 build() 函数长什么样?