llama.cpp 内部揭秘:GGML 计算图与多后端调度

3 minute read

Published:

背景与目标

llama.cpp 是一个纯 C/C++ 实现的 LLM 推理引擎,能在消费级 CPU、GPU、甚至嵌入式设备上运行 1.5‑bit 到 8‑bit 量化模型。它的核心是一套自研的张量计算库 ggml,提供了一种基于有向无环图(DAG)的计算抽象、跨硬件的后端调度和极致的内存优化。与 PyTorch/TensorFlow 等通用框架不同,ggml 专为推理场景设计——图结构在运行时构建、执行一次后销毁,依赖极少的第三方库(甚至不强制要求 BLAS),允许工程师精细地控制每条指令和每字节内存的走向。

本文将深入 ggml 的图构建、后端抽象、内存管理以及运行时内核融合、线程池同步等关键设计,从内存布局、CPU 原子操作到 SIMD 指令的层面,拆解 llama.cpp 如何以如此轻量的代码底座高效运行百亿参数的大模型。

核心设计与实现

张量表示:stride‑based 零拷贝

ggml 中的张量由结构体 ggml_tensor 定义(ggml/include/ggml.h)。最核心的字段是四个维度的元素个数 ne[4] 和四个维度的字节步长 nb[4]。这种设计等价于 NumPy 的 strides,使得转置、切片、广播、重塑等操作可以仅通过调整步长和偏移量完成,而无需物理拷贝数据。例如,将一个 128×64 的矩阵转置为 64×128,只需交换 ne[0] / ne[1]nb[0] / nb[1]

零拷贝视图通过 ggml_view_tensor 实现(ggml/src/ggml.c):它创建一个新的 ggml_tensor 头部,将其 view_src 指向原始张量,并设置 view_offs 和修改后的 nb,从而直接引用原数据区的一个子区域。整个推理流程中,embedding 查表、transformer 层中的 K/V 转置、attention 中的分片全部依靠视图完成,不需要任何深拷贝。

此外,ggml 使用了内存竞技场(arena)分配策略。初始化时通过 ggml_init_params 传入一块连续内存,所有张量的元数据和数据都通过 bump 指针方式从中分配。计算完成后整个竞技场一次性释放,避免了成百上千次 malloc/free 调用的开销。视图张量不分配额外数据内存,仅消耗一个 tensor 头大小的空间,使得复杂图的内存开销依然可控。

计算图构建:隐式拓扑排序

ggml 的计算图记录在 ggml_cgraph 中,本质是一个 ggml_tensor * 的动态数组,数组中的节点已经排好了拓扑顺序。这个顺序是由 ggml_build_forward_expandggml/src/ggml.c)通过递归 DFS 自动生成的。

构建过程非常简单:用户先声明一系列操作(如 ggml_rms_norm()ggml_mul()),每个操作会创建一个新的张量作为结果,并将其 src 数组指向输入张量,op 字段标记操作类型。然后调用 ggml_build_forward_expand(&cgraph, result_tensor),该函数递归遍历 result_tensor 的所有源张量,采用后序遍历——确保所有输入张量在被依赖的操作之前添加到图中。最终数组的顺序保证任何节点执行时其源节点已经计算完成。

举个例子,一个 RMSNorm + Mul 的组合会生成两个节点,图数组顺序为 [input, rms_norm_out, mul_out],执行时从 input 开始依次计算。

这种隐式排序的优点是零开销:无需运行任何排序算法,图本身就是一次 DFS 的副作用。缺点是图一旦扩展,节点顺序就固定下来,后续无法轻易插入新节点或重排,但对于推理这种一次编译、多次执行的场景,这完全足够了。

后端抽象与调度

ggml 通过一个轻量的后端接口来屏蔽硬件差异。核心调度函数是 ggml_compute_forward(CPU 后端位于 ggml/src/ggml-cpu/ggml-cpu.c)和一个类似的 CUDA 版 ggml_cuda_compute_forwardggml/src/ggml-cuda/ggml-cuda.cu)。调度逻辑主要由 switch (node->op) 构成,根据操作码分派到对应的 SIMD/GPU 内核。

为了允许后端对某些操作进行特殊处理,ggml 引入了 ggml_cpu_extra_compute_forward 钩子:在进入标准 switch 之前先调用这个钩子,如果返回 true,表示该操作已被后端单独处理(例如 CUDA 将几乎所有操作映射到 GPU 核)。CPU 后端利用这一钩子处理少数特殊操作,而绝大多数 op 走标准路径。

实际执行一个图时,调用 ggml_graph_compute,它会按照节点的拓扑顺序逐个处理,每次进入一个新节点前会通过原子屏障同步线程池(CPU 后端)或启动 GPU 核流(CUDA 后端)。这种 “每个节点一次同步” 的方式固然限制了并行粒度,但使得调度极度简单,且与后续的内核融合、内存视图等优化完美配合。

优化技术细节

运行时内核融合:RMS_NORM + MUL

在 CPU 后端中,有一个不显眼但极为有效的优化——运行时操作融合(ggml/src/ggml-cpu/ggml-cpu.c, ~L3600)。它的核心思想是在执行图中的节点时,向前“偷看”下一个节点,如果两者满足特定模式,则用一个融合内核替代两个独立内核,省去中间张量的写回和重读。

以下是融合 RMSNorm 和元素级乘法的代码片段:

// ggml/src/ggml-cpu/ggml-cpu.c: ggml_cpu_try_fuse_ops
static int ggml_cpu_try_fuse_ops(
        const struct ggml_cgraph * cgraph,
        const int node_n,
        const struct ggml_compute_params * params,
        const struct ggml_cplan * cplan) {
    if (ggml_cpu_disable_fusion || cplan->use_ref) { return 0; }
    struct ggml_tensor * node = cgraph->nodes[node_n];
    if (node->op == GGML_OP_RMS_NORM) {
        const enum ggml_op fuse_ops[] = { GGML_OP_RMS_NORM, GGML_OP_MUL };
        if (ggml_can_fuse(cgraph, node_n, fuse_ops, 2)) {
            struct ggml_tensor * mul_node = cgraph->nodes[node_n + 1];
            const struct ggml_tensor * mul_w = (mul_node->src[0] == node)
                ? mul_node->src[1] : mul_node->src[0];
            if (node->src[0]->type  == GGML_TYPE_F32 &&
                mul_node->type      == GGML_TYPE_F32 &&
                mul_w->type         == GGML_TYPE_F32 &&
                mul_w->ne[0]        == node->ne[0]   &&
                mul_w->nb[0]        == sizeof(float)) {
                ggml_compute_forward_rms_norm_mul_fused(params, node, mul_node);
                return 1;
            }
        }
    }
    return 0;
}

这段代码在执行到 RMSNorm 节点时,检查紧随其后的节点是否为 MUL,且权重张量是连续、匹配形状的 f32 向量。若满足,则将两者融合为一个 rms_norm_mul_fused 核:在一次循环中先计算 x_i * rcp(sqrt(mean(x^2) + eps)),再乘以 w_i,完全消除中间归一化结果的存储和加载。在 CPU 上,这可以将内存带宽需求降低约 30%,对 decode 阶段的短向量推理尤其有效。

融合条件是严格的:2 个连续节点、类型匹配、无其他消费者。因此它更像一个手写的 peephole 优化,弥补了 ggml 没有图级编译器的短板。

混合自旋等待线程池与原子屏障

CPU 后端的多线程由 ggml_threadpoolggml/src/ggml-cpu/ggml-cpu.c)管理。这个线程池采用了一种混合调度策略:每个 worker 循环检查原子变量 n_graph(表示当前要处理的图节点编号),如果它发生变化,则立刻加入新的任务;否则根据配置的 poll 级别选择继续自旋还是睡眠于条件变量。这种设计在保持极低唤醒延迟(自旋部分)和节能(休眠部分)之间取得了平衡。

节点间的同步使用自实现的原子屏障 ggml_barrier,其核心逻辑如下:

// ggml/src/ggml-cpu/ggml-cpu.c: ggml_barrier
void ggml_barrier(struct ggml_threadpool * tp) {
    int n_threads = atomic_load_explicit(&tp->n_graph, memory_order_relaxed) & GGML_THREADPOOL_N_THREADS_MASK;
    if (n_threads == 1) return;
    int n_passed = atomic_load_explicit(&tp->n_barrier_passed, memory_order_relaxed);
    int n_barrier = atomic_fetch_add_explicit(&tp->n_barrier, 1, memory_order_seq_cst);
    if (n_barrier == (n_threads - 1)) {
        atomic_store_explicit(&tp->n_barrier, 0, memory_order_relaxed);
        atomic_fetch_add_explicit(&tp->n_barrier_passed, 1, memory_order_seq_cst);
        return;
    }
    while (atomic_load_explicit(&tp->n_barrier_passed, memory_order_relaxed) == n_passed) {
        ggml_thread_cpu_relax();
    }
}

该屏障通过两个原子计数器 (n_barriern_barrier_passed) 实现。第一个到达的 n_threads-1 个线程会循环读取 n_barrier_passed 并自旋;最后一个线程到来时重置 n_barrier,并递增 n_barrier_passed,释放所有等待者。这里使用 memory_order_seq_cst 确保所有线程看到的操作顺序一致,防止屏障后的指令被提前执行。ggml_thread_cpu_relax() 是一个 PAUSE 指令(x86 _mm_pause(),ARM __yield()),避免空转时占用过多的总线带宽和功耗。

这个自定义屏障比 pthread_barrier 更轻量,因为它直接嵌入线程池的控制流中,不经过内核态,且配合图执行中节点粒度的同步,能保持所有 core 的零手动调度。

动态负载均衡:原子工作窃取

矩阵乘法(ggml_compute_forward_mul_mat)是推理最耗时的操作。它的并行化没有使用简单的按行划分,而是采用了原子工作窃取,将输出矩阵划分为若干 2D tile,每个线程通过原子操作动态认领下一个 tile:

// ggml/src/ggml-cpu/ggml-cpu.c: ggml_compute_forward_mul_mat (~L1900)
    int current_chunk = ith;
    while (current_chunk < nchunk0 * nchunk1) {
        const int64_t ith0 = current_chunk % nchunk0;
        const int64_t ith1 = current_chunk / nchunk0;
        const int64_t ir0_start = dr0 * ith0;
        const int64_t ir0_end = MIN(ir0_start + dr0, nr0);
        const int64_t ir1_start = dr1 * ith1;
        const int64_t ir1_end = MIN(ir1_start + dr1, nr1);
        ggml_compute_forward_mul_mat_one_chunk(params, dst, src0->type, ...);
        if (nth >= nchunk0 * nchunk1) break;
        current_chunk = atomic_fetch_add_explicit(&params->threadpool->current_chunk, 1, memory_order_relaxed);
    }

这里 nchunk0nchunk1 是输出矩阵两个维度的 tile 数目,对应每个 tile 的坐标 (ith0, ith1) 确定了要计算的输出子块范围。线程完成一个 tile 后再次原子递增 current_chunk,获得下一个。由于 tile 尺寸可选(32 或 16 元素),即使不同行在一个 batch 内计算量不同,依然能自然平衡负载。该原子操作是 relaxed 顺序的,因为没有顺序依赖,仅要求 “安全地递增共享计数器”。

内层 ggml_compute_forward_mul_mat_one_chunk 会根据量化类型选择对应的向量点积函数,并且预先将量化权重转换为高精度格式暂存于线程局部缓冲区 wdata,从而多次点积累时可复用,大幅减少重复反量化开销。

零拷贝视图与内存竞技场:无缝衔接

零拷贝视图贯穿整个推理流水线。例如,在 llama_model 构建 KV cache 张量时(src/llama-kv-cache.cpp),系统先分配一个大的 3D 张量(n_stream × n_kv × n_embd),然后通过视图创建每个 layer 的 K 和 V 子张量。attention 计算中的 K^T 同样是通过交换 nb[0]nb[1] 并设置偏移实现的视图,根本不会触碰实际数据。

所有这些临时张量的内存都来自同一个竞技场,计算完成后一并丢弃,连 free 都不用调用。当通过 mmap 加载 GGUF 模型时,权重张量直接映射到文件页缓存,与竞技场中的临时张量互不干扰,实现了从磁盘到 CPU cache 的最短路径。

性能与权衡

上述优化让 llama.cpp 在不断膨胀的模型规模前展现出惊人的效率,但它们并非毫无代价:

  • 运行时融合:增加了内核实现的复杂度和维护成本,只适用于特定操作对,通用性差。
  • 自旋线程池:在低负载或空载时会消耗 CPU 周期,需要经验性调整 poll 级别;若设置不当,可能加剧功耗。
  • 原子工作窃取:多线程竞争原子变量在高核心数下会产生轻微碰撞开销,且 tile 大小选择不当时可能导致负载不均。
  • 零拷贝视图:所有后端必须正确处理视图的 base pointer 和偏移,否则会产生难以调试的 bug;视图一旦创建,不能修改元数据,容易引入隐藏的异步错误。
  • 内存竞技场:无法单独释放中间张量,导致内存占用峰值可能略高于精细管理的方式,但在推理这种“全图生命周期一致”的场景中,这问题并不突出。

总体而言,这些设计遵循了推理引擎的核心哲学:“一次构建,多次复用,极致回避不必要的数据移动”。工程师可通过环境变量(如 GGML_CPU_FUSION_DISABLE=1, GGML_POLL=2)对特定平台进行微调,以适应不同的硬件与功耗目标。

相关论文

  • FlashAttention (Dao et al., 2022) — 提出了 IO‑aware 的精确 attention 算法,通过分块减少 HBM 与 SRAM 间的数据搬运。ggml 在 CUDA 后端中直接实现了 FlashAttention‑2 核(ggml/src/ggml-cuda/fattn-flash.cu)。
  • Stream‑K (Mohamed et al., 2023) — 工作中心式并行矩阵乘法,ggml 在 CUDA 的 mmq 核中检测到合适的架构与形状时启用 stream‑k 分解。
  • Speculative Decoding (Leviathan et al., 2023) — llama.cpp 在 llama-speculative.cpp 中实现了 draft‑target 的投机解码流水线。
  • 量化相关的 AWQ (Lin et al., 2024) 与 QuaRot (Ashkboos et al., 2024) 思想也影响了其重要性引导量化与 KV‑cache 旋转。

总结

ggml 为 llama.cpp 提供了一个极度精简又高度可扩展的计算基础设施。它用不到 2000 行的核心 C 代码构建了张量表示、隐式拓扑排序、后端抽象,以及一套行之有效的性能优化工具箱(运行时融合、自定义线程池、工作窃取、零拷贝视图)。理解这些内部实现,不仅有助于在部署 LLM 时做出精准的配置抉择,也为构建其他领域的高性能计算库提供了宝贵的工程参考。

最终,llama.cpp 强大的关键并不在于某个单一的神奇优化,而在于这种从头设计的整体架构——让每个字节、每条指令和每个 CPU 周期都发挥出最大的价值。