Read OSS

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)会自动处理这种情况。

调度器承担三项职责:

  1. 图分区 — 对图中的每个操作,决定由哪个后端执行。默认策略是将操作分配给拥有该操作主输入张量的后端,若该后端不支持此操作,则回退到第一个支持该操作的已注册后端。

  2. 缓冲区分配 — 根据各张量所属的执行后端,为其分配对应的后端缓冲区。

  3. 数据传输插入 — 当某个操作的输入张量分散在不同后端时(例如 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 的几个关键设计决策:

  1. 自描述: 加载和使用模型所需的所有元数据都内嵌在文件中。general.architecture 键标识模型类型,llama.block_countllama.embedding_length 等超参数提供形状信息,分词器词表和合并规则也以 KV 数组的形式内嵌其中。

  2. 数据对齐: 张量数据默认对齐到 32 字节(可通过 general.alignment 键配置),从而支持基于 mmap 的高效加载——张量可以直接从内存映射文件中访问,无需额外拷贝。

  3. 类型系统: KV 值支持 uint8、int8、uint16、int16、uint32、int32、float32、bool、string,以及上述任意类型的数组,还有 uint64、int64、float64。gguf_type 枚举共定义了 13 种类型。

  4. 版本 3: 当前版本支持 64 位张量计数和偏移量,可处理超过 4GB 的文件。魔数为 "GGUF"(小端序为 0x46554747)。

提示: 仓库中附带的 gguf-py Python 库提供了便捷的 GGUF 文件检查方式:执行 python -m gguf.scripts.gguf_dump model.gguf 即可打印所有元数据和张量形状。

下一步

至此,我们已从张量操作、硬件后端一路剖析到文件格式,覆盖了 GGML 的全貌。但一次完整的解码调用远不止于此。在下一篇文章中,我们将追踪完整的推理流水线:llama_decode() 如何管理批处理与微批处理,多态内存系统如何为 Transformer 的 KV 缓存和循环模型的状态缓冲区提供支持,以及系统在出现故障时如何恢复而不破坏状态。