GGML:llama.cpp 底层的张量引擎
前置知识
- ›第 1-2 篇文章
- ›具备基础的 GPU 计算知识(宿主内存与设备内存、计算内核)
GGML:llama.cpp 底层的张量引擎
llama.cpp 中的每一次矩阵乘法、每一步注意力计算、每一次量化权重查找,最终都会流经 GGML——一个独立的 C 语言张量库,代码位于 ggml/ 目录中。GGML 并不只是"数学后端",它定义了整个执行模型、内存布局、数据类型以及硬件抽象层,正是这些设计使得可移植的量化推理成为可能。
本文将从内部深入剖析 GGML,涵盖其惰性求值模式、核心数据结构、支持硬件可插拔的三级后端虚函数表系统、编译期后端注册、跨设备计算调度器、量化类型体系,以及 GGUF 文件格式。
惰性求值与核心抽象
GGML 采用两阶段执行模型,熟悉 TensorFlow 1.x 或 JAX 追踪机制的开发者会感到似曾相识:先构建计算图,再执行它。ggml_mul_mat() 这类张量操作不会触发任何实际计算,它们只是创建描述"要计算什么"的图节点。真正执行数学运算的只有 ggml_graph_compute()。
ggml.h 的头部注释通过一个完整示例说明了这一点:先通过调用张量操作定义 f(x) = a*x² + b,再设置输入值并调用 ggml_graph_compute 来实际求值。
整个模型由三个核心类型驱动:
ggml_tensor(定义于第 658 行)是最基本的单元,存储以下信息:
type— 数据类型(F32、F16、Q4_K 等)ne[4]— 各维度的元素数量(最多支持 4 维)nb[4]— 各维度的字节步长op— 生成该张量的操作类型src[GGML_MAX_SRC]— 指向源张量的指针(即图的边)data— 指向实际数据的指针(可能位于 CPU 或 GPU 内存)buffer— 拥有该数据的后端缓冲区
ggml_context 是用于分配张量的内存竞技场(arena)。它通过预先分配好的缓冲区进行碰撞指针式分配,避免了每个张量单独 malloc 的开销。构建计算图时,所有中间张量的描述符(而非数据本身)都从 context 中分配。
ggml_cgraph 是计算有向无环图(DAG),包含节点列表(带有操作的张量)和叶节点(输入张量)。ggml_build_forward_expand(gf, tensor) 会递归遍历张量的 source 链接,发现所有依赖操作并将其加入图中。
flowchart LR
subgraph "Build Phase"
A["ggml_new_tensor(ctx)"] --> B["ggml_mul_mat(ctx, W, x)"]
B --> C["ggml_add(ctx, result, bias)"]
C --> D["ggml_build_forward_expand(gf, output)"]
end
subgraph "Execute Phase"
D --> E["ggml_backend_sched_alloc_graph(sched, gf)"]
E --> F["set tensor data"]
F --> G["ggml_backend_sched_graph_compute(sched, gf)"]
end
提示: 计算图实际上是通过
ggml_tensor中的src[]数组隐式编码的。每个张量都知道自己的输入是什么。ggml_build_forward_expand只需递归遍历这棵树,就能发现完整的 DAG。这里没有独立的"图构建器"API——图本身就是张量之间的连接关系。
后端虚函数表系统
GGML 支持 CPU、NVIDIA GPU(CUDA)、Apple GPU(Metal)、AMD GPU(Vulkan、ROCm/HIP)、Intel GPU(SYCL)、华为 NPU(CANN)等多种硬件。这一能力通过经典的 C 语言虚函数表模式实现,共分三个抽象层级,定义于 ggml/src/ggml-backend-impl.h:
第一层:ggml_backend_buffer_type_i — 内存分配策略。每种缓冲区类型负责在特定设备上分配缓冲区,并明确所需的对齐要求以及内存是否支持宿主访问。这一层回答的是"能否在这块 GPU 上分配 2GB 内存"这类问题。
第二层:ggml_backend_buffer_i — 已分配缓冲区上的数据传输操作。提供 set_tensor()、get_tensor()、memset_tensor() 和 cpy_tensor(),用于在宿主内存和设备内存之间搬移数据。
第三层:ggml_backend_i — 计算接口。提供 graph_compute()(执行计算图)、异步张量操作以及同步原语。
classDiagram
class ggml_backend_buffer_type_i {
+get_name()
+alloc_buffer(size)
+get_alignment()
+get_max_size()
+is_host()
}
class ggml_backend_buffer_i {
+free_buffer()
+get_base()
+set_tensor(tensor, data, offset, size)
+get_tensor(tensor, data, offset, size)
+clear(value)
}
class ggml_backend_i {
+get_name()
+free()
+graph_compute(graph)
+synchronize()
}
class ggml_backend_buffer_type {
iface: buffer_type_i
device: backend_dev_t
context: void*
}
class ggml_backend_buffer {
iface: buffer_i
buft: buffer_type_t
context: void*
size: size_t
}
ggml_backend_buffer_type --> ggml_backend_buffer_type_i : "contains"
ggml_backend_buffer --> ggml_backend_buffer_i : "contains"
ggml_backend_buffer_type "1" --> "*" ggml_backend_buffer : "creates"
每个硬件后端都提供这些接口的具体实现。以 CUDA 后端为例:其缓冲区类型通过 cudaMalloc 分配 GPU 内存,缓冲区接口使用 cudaMemcpy 进行数据传输,计算后端则为每个 GGML 操作分发相应的 CUDA kernel。
后端注册与发现
GGML 如何在运行时感知可用的后端?答案是通过 ggml/src/ggml-backend-reg.cpp 中的编译期注册机制。ggml_backend_registry 的构造函数借助 #ifdef 条件宏来注册各个后端:
ggml_backend_registry() {
#ifdef GGML_USE_CUDA
register_backend(ggml_backend_cuda_reg());
#endif
#ifdef GGML_USE_METAL
register_backend(ggml_backend_metal_reg());
#endif
#ifdef GGML_USE_VULKAN
register_backend(ggml_backend_vk_reg());
#endif
// ...
#ifdef GGML_USE_CPU
register_backend(ggml_backend_cpu_reg());
#endif
}
注册顺序至关重要:GPU 后端优先注册,CPU 最后。后端调度器(详见下一节)默认按注册顺序为操作分配后端,因此 GPU 的优先级高于 CPU。
flowchart TD
CMAKE["CMake -DGGML_CUDA=ON"] --> DEFINE["#define GGML_USE_CUDA"]
DEFINE --> INCLUDE["#include ggml-cuda.h"]
INCLUDE --> REG["register_backend(ggml_backend_cuda_reg())"]
REG --> DEV["Enumerate CUDA devices"]
DEV --> READY["Backend ready for graph compute"]
每次 register_backend() 调用都会枚举该后端所暴露的所有设备(例如两张 NVIDIA GPU),并将其加入全局设备列表。GGML 还支持从共享库动态加载后端,无需重新编译核心库即可接入树外后端。
提示: CPU 始终最后注册(
#ifdef GGML_USE_CPU),这确保了 GPU 后端在调度器中拥有更高优先级。如果编译时没有包含任何 GPU 后端,则所有任务由 CPU 负责处理。
后端调度器
一个计算图可能横跨多个后端——例如部分层运行在 GPU 上,而嵌入层和输出层运行在 CPU 上。后端调度器(ggml_backend_sched)会自动处理这种情况。
调度器承担三项职责:
-
图分区 — 对图中的每个操作,决定由哪个后端执行。默认策略是将操作分配给拥有该操作主输入张量的后端,若该后端不支持此操作,则回退到第一个支持该操作的已注册后端。
-
缓冲区分配 — 根据各张量所属的执行后端,为其分配对应的后端缓冲区。
-
数据传输插入 — 当某个操作的输入张量分散在不同后端时(例如 GPU 操作需要来自 CPU 的张量),自动插入拷贝操作,将数据搬移到正确的设备上。
sequenceDiagram
participant Ctx as llama_context
participant Sched as Backend Scheduler
participant GPU as CUDA Backend
participant CPU as CPU Backend
Ctx->>Sched: alloc_graph(gf)
Sched->>Sched: Assign ops to backends
Sched->>Sched: Insert cross-device copies
Sched->>GPU: Allocate GPU tensors
Sched->>CPU: Allocate CPU tensors
Ctx->>Sched: graph_compute(gf)
Sched->>GPU: Execute GPU subgraph
GPU->>CPU: Transfer intermediate results
Sched->>CPU: Execute CPU subgraph
Sched-->>Ctx: Done
用户设置的 n_gpu_layers 参数在更高层面控制这一分区行为:libllama 的模型加载代码会根据该参数将层张量分配到 GPU 或 CPU 缓冲区类型,调度器则遵循这些分配决策。
量化类型
GGML 的量化类型系统是其最具特色的设计之一。与其将权重存储为 32 位浮点数,GGML 支持分块量化格式,在保持可接受精度的同时大幅降低内存占用。
核心类型分为几个系列:
| 系列 | 类型 | 块大小 | 比特/权重 |
|---|---|---|---|
| IEEE 标准 | F32, F16, BF16 | 1 | 32, 16, 16 |
| 均匀量化 | Q8_0, Q4_0, Q4_1, Q5_0, Q5_1 | 32 | 8.5, 4.5, 5.0, 5.5, 6.0 |
| K-quants | Q2_K, Q3_K, Q4_K, Q5_K, Q6_K | 256 | 2.6–6.6 |
| I-quants | IQ1_S, IQ2_XXS, IQ3_S, IQ4_NL | 不定 | 1.6–4.5 |
| 三值量化 | TQ1_0, TQ2_0 | 256 | 1.7, 2.1 |
K-quants 使用包含 256 个元素的超块,每块独立存储 scale 和 min 值,在精度与体积的权衡上优于均匀量化。I-quants 则借助重要性矩阵和查找表,实现更激进的压缩比。
每种量化类型都在 ggml.h 中注册了一个类型特征结构体,记录块大小、类型大小以及量化/反量化函数的指针。实际的量化/反量化 kernel 在 CPU 上实现于 ggml/src/ggml-quants.c,在 GPU 上则位于各后端的专属文件中。
flowchart LR
F16["F16 Model\n14 GB"] --> Q8["Q8_0\n7.2 GB"]
Q8 --> Q4K["Q4_K_M\n4.1 GB"]
Q4K --> Q2K["Q2_K\n2.7 GB"]
Q2K --> IQ2["IQ2_XXS\n2.1 GB"]
style F16 fill:#e1f5fe
style Q8 fill:#b3e5fc
style Q4K fill:#81d4fa
style Q2K fill:#4fc3f7
style IQ2 fill:#29b6f6
GGUF 文件格式
GGUF(GGML Universal File format)是一种自描述的二进制格式,同时存储模型元数据和张量数据。其结构在 ggml/include/gguf.h 的头部注释中有详细说明:
flowchart TD
subgraph "GGUF File Layout"
MAGIC["Magic: 'GGUF' (4 bytes)"]
VERSION["Version: 3 (uint32)"]
NT["Tensor count (int64)"]
NKV["KV pair count (int64)"]
KV["KV Metadata Pairs\n- architecture, n_layer, n_embd...\n- tokenizer data\n- chat template"]
TD["Tensor Descriptors\n- name, dimensions, type, offset"]
PAD["Alignment padding"]
DATA["Tensor Data Blob\n(aligned to GGUF_DEFAULT_ALIGNMENT)"]
end
MAGIC --> VERSION --> NT --> NKV --> KV --> TD --> PAD --> DATA
GGUF 的几个关键设计决策:
-
自描述: 加载和使用模型所需的所有元数据都内嵌在文件中。
general.architecture键标识模型类型,llama.block_count、llama.embedding_length等超参数提供形状信息,分词器词表和合并规则也以 KV 数组的形式内嵌其中。 -
数据对齐: 张量数据默认对齐到 32 字节(可通过
general.alignment键配置),从而支持基于 mmap 的高效加载——张量可以直接从内存映射文件中访问,无需额外拷贝。 -
类型系统: KV 值支持 uint8、int8、uint16、int16、uint32、int32、float32、bool、string,以及上述任意类型的数组,还有 uint64、int64、float64。
gguf_type枚举共定义了 13 种类型。 -
版本 3: 当前版本支持 64 位张量计数和偏移量,可处理超过 4GB 的文件。魔数为
"GGUF"(小端序为 0x46554747)。
提示: 仓库中附带的
gguf-pyPython 库提供了便捷的 GGUF 文件检查方式:执行python -m gguf.scripts.gguf_dump model.gguf即可打印所有元数据和张量形状。
下一步
至此,我们已从张量操作、硬件后端一路剖析到文件格式,覆盖了 GGML 的全貌。但一次完整的解码调用远不止于此。在下一篇文章中,我们将追踪完整的推理流水线:llama_decode() 如何管理批处理与微批处理,多态内存系统如何为 Transformer 的 KV 缓存和循环模型的状态缓冲区提供支持,以及系统在出现故障时如何恢复而不破坏状态。