在前一章 计算图 (ggml_cgraph) 中,我们学会了如何定义一系列计算步骤并执行它们,就像拥有了一本"菜谱"。但是,在真实世界中,我们很少从零开始"发明"一个像 Llama 这样复杂的模型。更常见的场景是,我们下载一个由社区训练好并打包的模型文件,然后加载它来使用。
这就引出了一个新问题:一个包含了数十亿参数(张量)和各种配置信息的大型模型,应该如何被高效地存储、加载和分享呢?ggml
生态系统为此设计了一个标准化的解决方案:GGUF (GGML Universal Format) 文件格式。
而我们与 GGUF 文件打交道的工具,就是本章的主角:gguf_context
。
什么是 GGUF 上下文?
核心思想 :你可以把
gguf_context
想象成一个图书馆员,专门负责管理 GGUF 格式的书籍(模型文件)。这位管理员知道如何解读书籍的索引(元数据),并能根据你的需要快速找到并取出书中的具体内容(张量权重)。
简单来说,gguf_context
是一个解析 GGUF 文件的工具。当你给它一个 GGUF 文件时,它会读取并理解文件的所有内容,让你能轻松地访问模型的配置信息(比如"这是一个 Llama 架构的模型")和权重数据。它是加载和使用 ggml
模型的第一步。
GGUF 文件:一个精心打包的模型盒子
在我们使用"图书馆员"之前,先来看看他管理的"书籍"------GGUF 文件到底是什么样的。一个 GGUF 文件就像一个精心打包的模型工具箱,里面主要包含三个部分:
- 元数据 (Metadata):工具箱的"说明书"。它以"键值对"的形式存储了关于模型的所有信息,比如模型架构、层数、上下文长度、分词器词汇表等。
- 张量信息 (Tensor Info) :工具箱的"零件清单"。它详细列出了模型中每一个张量(权重)的名称、形状、数据类型(比如
F16
或 量化过的Q4_K
)以及它在数据区的具体位置。 - 张量数据 (Tensor Data):工具箱里所有的"实际零件"。这是一个巨大的、连续的二进制数据块,包含了模型所有张量的权重数据。
下面是 GGUF 文件结构的简化示意图:
版本号, 张量数量, KV数量"] B["1. 元数据 (键值对)
architecture: 'llama'
context_length: 4096
..."] C["2. 张量信息 (索引)
张量 'T1': 名称, 形状, 类型, 偏移量
张量 'T2': 名称, 形状, 类型, 偏移量
..."] D["3. 张量数据 (二进制块)
|--- 张量 T1 的数据 ---|--- 张量 T2 的数据 ---|..."] end A --> B --> C --> D
这种结构非常巧妙,因为它将"描述信息"和"海量数据"分开了。我们可以只读取前面的元数据和张量信息来快速了解模型,而无需加载后面庞大的张量数据。
动手实践:用 gguf_context
打开模型盒子
现在,让我们请出我们的"图书馆员" gguf_context
,来帮我们打开并检查一个 GGUF 模型文件。
第 1 步:初始化 GGUF 上下文
我们的第一步是创建一个 gguf_context
并让它从文件中读取信息。
c
#include "ggml.h"
#include "gguf.h"
#include <stdio.h>
int main(void) {
// 假设我们有一个名为 "tinyllama.gguf" 的模型文件
const char * model_path = "tinyllama.gguf";
// 1. 设置初始化参数
// 我们暂时只想读取元数据,并不想立即加载庞大的张量数据
struct gguf_init_params params = {
.no_alloc = true, // 关键:告诉 gguf 不要为张量分配内存
.ctx = NULL,
};
// 2. 从文件初始化 GGUF 上下文
struct gguf_context * gctx = gguf_init_from_file(model_path, params);
if (!gctx) {
fprintf(stderr, "无法加载模型: %s\n", model_path);
return 1;
}
// ... 后续操作 ...
代码解释:
gguf_init_params
告诉gguf_init_from_file
我们的意图。.no_alloc = true
是一个非常重要的设置。它指示gguf_context
只读取文件的元数据和张量信息,而不要为张量数据分配任何内存或加载它们。这使得我们可以快速"窥探"一个模型文件的内部,而无需消耗大量内存。gctx
现在就是我们的"图书馆员",他已经读完了"索引卡",准备好回答我们的问题了。
第 2 步:查询元数据
我们可以向 gctx
查询模型的基本信息。比如,这个模型的架构是什么?
c
// 查找名为 "general.architecture" 的键
const int key_idx = gguf_find_key(gctx, "general.architecture");
if (key_idx < 0) {
fprintf(stderr, "未找到模型架构信息\n");
// ... 清理并退出 ...
}
const char * arch = gguf_get_val_str(gctx, key_idx);
printf("模型架构: %s\n", arch);
代码解释:
gguf_find_key
在元数据中搜索指定的键,并返回其索引。gguf_get_val_str
根据索引获取该键对应的值(这里是一个字符串)。- 通过这种方式,我们可以查询到 GGUF 文件中存储的任何元数据。
第 3 步:检查张量信息
接下来,让我们看看模型的"零件清单"。
c
// 获取模型中的张量总数
const int n_tensors = gguf_get_n_tensors(gctx);
printf("模型共有 %d 个张量。\n", n_tensors);
// 查找一个特定张量的信息,例如 "output.weight"
const int tensor_idx = gguf_find_tensor(gctx, "output.weight");
if (tensor_idx < 0) {
fprintf(stderr, "未找到 'output.weight' 张量\n");
// ... 清理并退出 ...
}
const char * name = gguf_get_tensor_name(gctx, tensor_idx);
enum ggml_type type = gguf_get_tensor_type(gctx, tensor_idx);
printf("找到张量: %s, 类型: %s\n", name, ggml_type_name(type));
代码解释:
gguf_get_n_tensors
返回模型中张量的总数。gguf_find_tensor
允许我们按名称搜索特定的张量。gguf_get_tensor_name
和gguf_get_tensor_type
则可以获取该张量的具体信息。
第 4 步:真正加载模型权重
到目前为止,我们只读取了描述信息。现在,我们要做的是将所有张量权重加载到内存中,准备进行计算。为此,我们需要一个 ggml_context来存放这些张量。
c
// 在上一步之后... 先释放只读的 gctx
gguf_free(gctx);
// 准备一个新的 ggml_context 来存放张量
struct ggml_context * mctx = NULL;
// 重新设置参数,这次我们要加载张量
struct gguf_init_params params_full = {
.no_alloc = false, // false 表示要为张量分配内存和数据
.ctx = &mctx, // 传入 mctx 的地址,gguf 会为我们创建它
};
// 再次调用,这次会完整加载模型
gctx = gguf_init_from_file(model_path, params_full);
if (!gctx) { /* 错误处理 */ }
// ...
// 执行到这里,mctx 就已经是一个包含了模型所有权重的 ggml_context 了!
// 我们可以用它来构建计算图并进行推理。
// ...
代码解释:
- 这次,我们将
.no_alloc
设为false
,并把一个ggml_context
指针的地址 (&mctx
) 传给.ctx
。 - 当
gguf_init_from_file
看到这些参数时,它不仅会读取元数据,还会:- 在内部创建一个新的
ggml_context
(mctx
)。 - 将 GGUF 文件中的整个张量数据块读入
mctx
。 - 为每一个张量创建一个
ggml_tensor
结构,并将其data
指针指向数据块中正确的位置。
- 在内部创建一个新的
最后,别忘了清理所有东西:
c
// 任务完成,释放所有资源
gguf_free(gctx);
ggml_free(mctx);
return 0;
}
深入幕后:gguf_context
的内部结构
gguf_context
结构体(定义在 src/gguf.cpp
中)清晰地反映了 GGUF 文件的结构:
c
// 来自 src/gguf.cpp 的简化版结构
struct gguf_context {
uint32_t version;
// 存储所有键值对元数据 ("说明书")
std::vector<struct gguf_kv> kv;
// 存储所有张量的信息 ("零件清单")
std::vector<struct gguf_tensor_info> info;
size_t alignment; // 数据对齐方式
size_t offset; // 张量数据块在文件中的起始位置
size_t size; // 张量数据块的总大小
// 指向内存中张量数据块的指针 (当数据被加载时)
void * data;
};
kv
向量存储了所有的键值对。info
向量存储了每个张量的详细描述,包括一个ggml_tensor
结构体(但不含data
指针)和一个offset
字段,该字段记录了此张量数据相对于整个数据块开头的偏移量。data
指针则指向被完整加载到内存中的庞大张量数据。
当 gguf_init_from_file
被调用时,其内部流程大致如下:
已存入 gguf_context alt params.no_alloc == false GGUF->>GGML: 调用 ggml_init() 创建 ggml_context (mctx) GGML-->>GGUF: 返回 mctx GGUF->>File: 读取整个张量数据块到 mctx GGUF->>GGML: 循环创建 ggml_tensor, 并设置其 data 指针 end GGUF-->>User: 返回 gguf_context, (如果需要) mctx 也已就绪
这个过程清晰地展示了 gguf_context
如何充当文件和 ggml
核心数据结构之间的桥梁。
总结
在本章中,我们探索了 ggml
生态系统的文件格式标准 GGUF,以及与之交互的工具 gguf_context
。
- GGUF 是一个通用文件格式,用于打包模型的元数据 、张量信息 和张量数据。
gguf_context
就像一个"图书馆员",负责解析 GGUF 文件。- 我们可以使用
gguf_context
快速检查模型属性 而无需加载所有数据(通过.no_alloc = true
)。 - 我们也可以用它来将模型的全部权重加载到一个 ggml_context中,为后续的计算做准备。
现在我们已经知道如何将一个完整的、预先训练好的模型加载到内存中了。但是,我们的计算任务究竟是在哪里执行的呢?是在 CPU 上,还是可以利用强大的 GPU?ggml
如何管理不同的计算硬件呢?