ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

深入理解ggml_cgraph:计算图、调度执行与llama.cpp推理核心

深入理解ggml_cgraph:计算图、调度执行与llama.cpp推理核心 如果你一直在折腾本地大模型推理八成听过 GGML 这个名字。llama.cpp 能在普通笔记本上跑起来底层功劳很大一部分来自这个库。而 ggml_cgraph 是 GGML 里最绕不开的一个结构所有模型的前向计算最终都会变成一张计算图cgraph 就是这张图的载体。我第一次啃到它是在研究 llama.cpp 的推理流程时当时满眼都是带ggml_前缀的函数看到一个ggml_cgraph直接懵了后来才发现只要把 cgraph 搞明白整个 GGML 的执行模型基本就通透了一半。这篇文章会把ggml_cgraph从设计思路、数据结构、图构建、调度执行一直聊到实际踩坑。适合正在读 llama.cpp / ggml 源码的人也适合想自己基于 GGML 写推理程序或者自定义算子的人。我不打算只讲定义而是带着“为什么这么设计”去拆顺便把一些文档里不会写的细节一并倒出来。1. ggml_cgraph 到底是干什么的1.1 计算图在推理引擎里的位置一句话解释计算图就是把张量运算按依赖关系组织起来的有向无环图。节点是“运算输入输出张量”边是依赖关系。神经网络的前向过程看着像一堆张量飞来飞去实际上本质就是这张图上的节点按顺序执行。为什么 GGML 要费劲搞这种间接层因为直接一句一句按顺序执行矩阵乘、加、激活函数、softmax在固定代码里看起来简单但灵活性很差。你换一个模型结构就要改一遍执行代码。有了计算图之后模型定义只负责“搭图”执行引擎只负责“算图”两者解耦。而且有图之后才能做统一的内存规划、拓扑排序、算子调度、多线程并行这些是手写逐行调用比不了的。实际上 GGML 最初就是从 ggml.c 这个单文件里慢慢长起来的。cgraph 结构出现得很早它是 ggml 从“张量工具库”变成“推理引擎”的分水岭。llama.cpp 里每个模型在跑一次前向时都会重新构建一张图喂给ggml_graph_compute去执行。你运行 prompt 时看到的llm_build_*那一堆代码其实就是在用算子接口搭 cgraph。1.2 ggml_cgraph 的设计定位ggml_cgraph不是一个复杂到吓人的结构体。它的核心职责就三件事保存所有参与计算的节点张量/算子对象保存所有叶节点通常是输入张量、权重张量提供一张哈希表用于在构建过程中对节点去重避免同一个结果被重复计算。这三件事听着简单但每个细节都值得抠。你去读 ggml.h 会发现struct ggml_cgraph的字段很少几乎就是一个“节点数组 叶节点数组 哈希表 线程数”。真正复杂的其实在节点struct ggml_tensor上cgraph 只是在外面包了一圈管理逻辑。我个人的理解是ggml_cgraph更像是一个“执行计划容器”。它不像 TensorFlow 的 GraphDef 那样有完整的序列化格式也不做自动微分它的目标很纯粹——为单机 CPU/GPU 推理提供一张可以直接遍历执行的计算图。搞清楚这个定位后面看代码就不会老想着“这玩意儿怎么没有反向传播”因为人家压根不做训练。2. 数据结构与核心字段2.1 ggml_cgraph 结构体逐字段拆解很多初学者喜欢先背结构体定义但其实带入场景去理解字段更容易。以下代码是常见版本里ggml_cgraph的样子不同版本字段略有差异但核心一致struct ggml_cgraph { int size; // nodes 数组的容量 int n_nodes; // 当前图中节点数 int n_leafs; // 当前图中叶节点数 struct ggml_tensor **nodes; // 节点指针数组 struct ggml_tensor **leafs; // 叶节点指针数组 struct ggml_hash_set hash_set; // 哈希表用于节点查重 int n_threads; // 默认执行线程数 };size和n_nodes的关系需要搞清楚。size是nodes数组的容量n_nodes是当前已经放进去多少个节点。构建图时每加入一个新节点n_nodes但如果n_nodes接近size就会触发扩容逻辑。默认图容量在旧版本里是 2048内容足够跑大多数中小模型但如果你自定义的模型结构特别深或者一次处理特别长的序列就可能碰到“graph too large”之类的错误。leafs数组存的是叶节点。叶节点的定义是没有上游依赖的张量典型就是输入tokenembedding、位置编码、以及模型的各个权重张量。构建图时会递归判断如果某个算子的所有 src 已经都是叶那就把 src 加进 leafs。有了leafs列表执行器才知道哪些地方需要从外部拷贝数据、哪些地方是计算的起点。hash_set是最容易被忽视但很关键的部分。GGML 在构建图时是允许节点合并/复用的同一个张量如果被多个算子使用不该在图中出现两份否则计算量会翻倍。哈希表就是用来判断“这个节点是否已经插入过图”。这个设计在复用型模型中尤其有用典型的比如残差连接里的张量会在多个加法节点里被引用。2.2 ggml_tensor 节点id、op、src 依赖cgraph 里的每个节点实际上都是一个ggml_tensor。你可能觉得“张量节点”和“算子节点”是两回事但在 GGML 里它们统一成同一个结构体一个张量如果op GGML_OP_NONE它就是纯数据叶节点如果op是某个算子类型它就是一个带运算的张量其输出就是它本身这个张量对象。看下关键字段struct ggml_tensor { struct ggml_tensor *view_src; // 如果张量是 view指向原始张量 size_t view_offs; // view 的偏移量 int n_dims; // 维度数 int64_t ne[GGML_MAX_DIMS]; // 各维度大小 size_t nb[GGML_MAX_DIMS]; // 各维度 stride也就是步长 enum ggml_op op; // 算子类型例如 GGML_OP_ADD struct ggml_tensor *src[GGML_MAX_SRC]; // 输入依赖最多支持多个 void *data; // 张量数据指针 char name[GGML_MAX_NAME]; // 调试用的名称 };src数组是理解图结构的关键。一个GGML_OP_MUL_MAT节点src[0]和src[1]分别指向两个输入张量一个GGML_OP_ADD节点src[0]和src[1]指向左右操作数。执行引擎遍历节点时看到某个算子就知道去src里找输入数据。如果src里的张量本身也是一个算子节点那就必须先算它这自然形成了依赖链。ne和nb描述的是张量形状和内存布局。理解 GGML 时我建议直接把“张量维度”和“内存布局”分开记忆。ne[0]是最内层维度大小nb[0]是两个相邻元素的内存间隔ne[1]是下一维大小nb[1]是这一维的步长。计算图里的数据流只看ne就够真正执行时才严格依赖nb。2.3 哈希表为什么能去重hash_set的结构本身不复杂但它的引入是个典型的“工程收益大于理论复杂度”的决策。构建图时常见的序列模型会出现大量共享输入。最典型的就是残差网络的residual x这种加法同一个x会出现在不同位置。如果不做去重图里可能对x的源算子重复展开最终导致同一份计算结果被算两遍。GGML 的做法是ggml_build_forward_expand每次准备插入节点时先用张量对象的指针、算子类型、src 指针组合成 key去哈希表里查一下。如果已经存在直接复用之前节点如果不存在才加入nodes数组。这个哈希表还能帮你在调试图时快速确认为什么某个节点没有出现在nodes里十有八九是因为它已经挂在另一个节点的 src 链上并被去重了。我在自己写自定义分支结构时踩过一个坑想强行把同一个张量作为输出节点塞进图里结果发现哈希表把重复条目过滤掉了导致最后拿到的输出节点不是预期那个。后来才意识到cgraph 的节点集合逻辑上就是“运算结果集”同一个结果只能有一个代表。3. 手动搭建一个计算图3.1 初始化上下文与图GGML 里所有张量的生命周期都绑定在ggml_context上。开始建图前先初始化 contextstruct ggml_init_params params { .mem_size 16 * 1024 * 1024, // 16MB 内存池 .mem_buffer NULL, // 由 ggml 内部申请 .no_alloc false, // 是否立即分配张量数据 }; struct ggml_context *ctx ggml_init(params); struct ggml_cgraph *gf ggml_new_graph(ctx);很多新手在这里就出问题mem_size给得太小后面张量一多就报ggml_new_tensor_impl: not enough space in the contexts memory pool。这其实是 GGML 内存池的分配策略导致的。context 相当于一个大的内存管理员张量对象、图节点、部分算子中间结果都从这里面分配。如果计划模型比较复杂建议直接给 64MB 或更大或者使用ggml_new_graph_custom来指定图容量。ggml_new_graph默认会从 context 里借用内存来存放nodes和leafs的指针数组。所以别忘了这个图的生命周期也受 context 影响——context 释放后图对象就成了悬空指针。3.2 常见算子的添加方式创建张量后调用算子函数会自动生成算子节点。例如struct ggml_tensor *a ggml_new_tensor_2d(ctx, GGML_TYPE_F32, 4, 1); struct ggml_tensor *b ggml_new_tensor_2d(ctx, GGML_TYPE_F32, 4, 1); struct ggml_tensor *c ggml_add(ctx, a, b);此时c已经是一个op GGML_OP_ADD的张量节点它的src[0]指向asrc[1]指向b。但注意此时c还没有被加入任何 cgraph。它只是“在 context 里存在的张量对象”。真正让它进入图的是ggml_build_forward_expandggml_build_forward_expand(gf, c);调用后GGML 会从c出发递归把依赖链上的所有算子节点加入gf-nodes把纯数据张量加入gf-leafs。如果你跳过这步直接执行图图里什么都没有什么也不会算。这也是新手很容易混淆的点ggml_add只是创建了一个计算关系的描述对象真正决定“这个结点要参与执行”的是 build 过程。类似地ggml_mul_mat、ggml_soft_max、ggml_norm都只是构造节点不会立即算结果。3.3 图构建完成后的校验构建完图别急着执行先检查一下printf(nodes: %d, leafs: %d\n, gf-n_nodes, gf-n_leafs);如果n_nodes是 0说明build_forward_expand没有生效。最常见的原因是你建的张量本身是GGML_OP_NONE没有任何算子依赖比如你直接对两个叶张量调用了ggml_build_forward_expandGGML 认为没有必要把纯数据节点放进nodes。这符合设计nodes只放“需要计算的算子节点”叶节点只进leafs。如果你给节点起了名字还可以顺手打印节点信息。GGML 提供了ggml_graph_dump_dot之类的调试函数虽然格式比较粗糙但对我们理清依赖关系已经够用。自己写比较复杂的模型时建议每一步都打印n_nodes至少能快速定位“哪一步开始图突然膨胀”。3.4 示例通过 ggml_mul_mat 构建一层简单网络为了更贴合实际我写一个带矩阵乘加偏置的小例子。假设输入是 4 维向量权重是 [4, 4]输出还是 4 维struct ggml_tensor *input ggml_new_tensor_2d(ctx, GGML_TYPE_F32, 4, 1); struct ggml_tensor *weight ggml_new_tensor_2d(ctx, GGML_TYPE_F32, 4, 4); struct ggml_tensor *bias ggml_new_tensor_2d(ctx, GGML_TYPE_F32, 4, 1); struct ggml_tensor *mat ggml_mul_mat(ctx, weight, input); struct ggml_tensor *out ggml_add(ctx, mat, bias); ggml_build_forward_expand(gf, out); ggml_graph_compute_with_ctx(ctx, gf, 1);执行完out-data里就是计算结果。这里最能反映 cgraph 的便利性你不需要手动控制“先算矩阵乘再算加”执行引擎会按照依赖关系把顺序理清楚。有一点值得注意ggml_mul_mat的第一个参数和第二个参数谁是权重、谁是输入不同版本里的约定不同。很多踩坑帖都在这里栽过。建议你写代码前先看注释或者直接打印ne验证不要靠猜。4. 图执行与调度原理4.1 为什么要拓扑排序cgraph 的nodes数组虽然按照构建顺序存放节点但构建顺序并不一定等于合法执行顺序。比如你先把add节点加入图再把mul_mat加入图但如果add的输入之一是mul_mat的输出那mul_mat显然必须提前执行。GGML 在ggml_graph_compute里会做一次拓扑排序确保每个节点的 src 依赖都已经被计算。这也是 cgraph 和普通“操作列表”的本质区别。普通列表是我们手动保证调用顺序而 cgraph 是构建时随意执行时引擎负责排序。这个设计解放了模型定义代码llama.cpp 在构建整个 transformer 层时就是上一层、下一层混着调用算子接口完全不需要手工维护执行顺序。拓扑排序的实现并不复杂但 GGML 为了性能做了一些优化排序过程不会大量重建数组而是通过指针数组调整顺序。执行完一次图后如果你再次调用 compute 同一个图理论上无需重新拓扑因为节点顺序已经被排好了。4.2 ggml_graph_compute 的调度顺序ggml_graph_compute也可以拆成两个阶段准备阶段和实际执行阶段。准备阶段会为每个节点生成一个执行计划ggml_cplan里面包含节点是否需要临时缓冲区、需要多少内存等。实际执行阶段则是一个大循环for (int i 0; i gf-n_nodes; i) { struct ggml_tensor *node gf-nodes[i]; if (node-op GGML_OP_NONE) continue; // 根据 node-op 调用对应的 forward 函数 ggml_compute_forward(params, node); }每个算子都有对应的forward实现比如GGML_OP_ADD对应ggml_compute_forward_addGGML_OP_MUL_MAT对应ggml_compute_forward_mul_mat。这些 forward 函数内部再根据张量类型F32、F16、整型和硬件后端CPU、Metal、CUDA分发到具体实现。这也解释了为什么 GGML 的算子实现很“平铺直叙”因为 cgraph 已经提供了一个统一执行入口剩下就是每个算子把自己的活干完。你在学习源码时可以顺着ggml_graph_compute往下走看到某个节点op是什么然后跳到对应 forward 函数就能把一个算子从图定义到底层计算完整串起来。4.3 中间结果与工作区图执行过程中很多算子需要临时工作区。例如矩阵乘的tile策略、卷积的 im2col 变换都会在计算前先准备一块 scratch buffer。这些 buffer 如果每个节点临时 malloc性能会很差。GGML 的做法是在ggml_graph_plan阶段统计所有节点所需的最大工作内存然后一次性分配一块工作区执行时反复使用。cgraph 里没有显式保存这个工作区指针它通常挂在真正执行的环境/上下文中。这导致一个常见坑如果你在调用 compute 前手动改了底层线程数或者工作区大小有可能导致内存不足。特别是自定义算子时如果 forward 函数里需要超出图计划大小的临时内存轻则越界重则程序崩溃。所以自定义算子一定要同步更新ggml_graph_plan的work_size计算逻辑。4.4 多线程执行ggml_cgraph里的n_threads表示这个图默认用多少线程执行。在ggml_graph_compute_with_ctx里传入线程数后GGML 会按算子和数据大小把任务拆分到多个线程上。经典的拆分单位是矩阵乘的“行块”和“列块”。例如两个矩阵乘[M, K] * [K, N]GGML 会把输出按行分成若干块每个线程算一块。这个拆分是在 forward 函数内部做的并不是整个图并行。所以要注意cgraph 的“并行”是算子内并行不是节点间并行。多个独立算子并不会被同时执行。这一点和真正基于流图调度系统如 CUDA Graph / XLA的并行粒度不同。GGML 选这条路是权衡了实现复杂度和模型结构特点transformer 层里的大算子矩阵乘、逐元素本身就够并行节点间并行收益没那么明显。实际使用时n_threads不是越高越好。我实测在 8 核笔记本上跑 4 线程和 8 线程差距不大线程数继续往上反而因为同步开销性能下降。5. llama.cpp 里的实际用法与优化5.1 build_graph 流程llama.cpp 每个模型文件里都有一堆llm_build_*函数。这些函数做的事情本质上是同一件事根据模型超参构造出一个ggml_cgraph。比如llm_build_lora、llm_build_ffn、llm_build_moe_ffn最终都会把生成的 tensor 节点 expand 进gf里。我建议你直接在源码里搜ggml_build_forward_expand会看到它被调用得非常频繁。每一次调用就是把一个子图“贴”到大图上。这也是理解 llama.cpp 模型结构最快的方法你不需要每一行都读懂先找哪些结果是最终输出再往回找它的 src 依赖整个网络的连接关系就出来了。在某些新版本中llama.cpp 会一次构建整张图然后在 decode 阶段反复复用同一张图通过更新输入张量数据来避免重复建图开销。这种做法对 cgraph 的另一个优势体现得很明显节点复用和哈希去重让图结构保持稳定避免每步重建导致的内存碎片。自己写服务端推理时这种“预建图循环执行”的思路值得借鉴。5.2 常见问题与排查技巧我在实践中碰到过的 cgraph 相关问题大概可以列几个典型情况。第一graph too large。这个错误本质是gf-size不够装新节点。解决办法有三调大默认图大小、用ggml_new_graph_custom显式指定更大的容量、或者精简图结构。从模型侧优化可以做的检查是是不是哪里不小心把同一个大子图重复 expand 了很多次虽然去重能挡住完全相同的节点但形状不同、节点名不同的等价计算不会被去重。第二节点数据全是零或随机值。这个大概率是输入张量的data没有正确填充。因为ggml_new_tensor只负责分配内存不会初始化数据。你在 build 之前往input-data里写数据这是 OK 的但如果 build 之后再重新修改某个节点的ne或nb可能导致计算时读取到错误位置。第三执行完图之后拿到的输出不对。检查方向是输出张量是否真的在nodes数组里或者被哈希去重复用到了其他位置。如果输出是从某个中间张量 view 出来的还要检查view_src的依赖。第四context 内存溢出。这个在长序列推理中尤其常见因为序列越长中间张量越多上下文内存池需求越大。可以先用ggml_used_mem(ctx)观察内存占用如果接近mem_size说明你需要增加初始化参数或者改用no_alloc 外部分配策略。5.3 基于 cgraph 的性能优化心得一个有意思的点是cgraph 构建本身也会引入开销。节点越复杂、哈希表冲突越多构建图耗时越长。对一次推理来说这个开销占比可能不高但如果你在做高频小 batch 推理图和执行计划的构建开销就会被放大。解决方案是尽量复用已有的图只更新输入数据而不是每次重新 build。另一个优化点是合理规划节点维度。GGML 的很多算子实现内部对ne[0]维有最优路径比如矩阵乘对最内层维度有对齐要求。如果你能保证ne[0]是 16 或 32 的倍数很多底层 kernel 能跑得更快。这不是 cgraph 本身的功能但它确实会通过张量节点的ne[]影响最终性能。最后我强烈建议你自己写一个小模型在ggml_graph_compute里打断点看nodes数组的遍历顺序。你会直观感受到拓扑排序之后非依赖节点是按照插入顺序排的依赖节点会紧跟在依赖源之后。理解到这个粒度后cgraph 就真的变成了一张可以被你任意摆布的图而不是源码里的一片迷雾。我在实际做自定义算子接入 GGML 时前几次几乎都栽在“没有把自定义节点正确 expand 进图”和“工作区大小没有更新”这两个问题上。后来我形成了自己的固定套路每加一个算子先写一个最小测试图打印n_nodes、检查src依赖、确认work_size再往完整模型里接。这套流程看起来很笨但真的能省掉大量调试时间。如果你也想深入 GGML不妨从手写一个包含两三个算子的 cgraph 开始跑通后再去读 llama.cpp 的构建代码。相信我把 cgraph 玩明白了后面再去看算子实现、内存分配、量化逻辑都会轻松很多。
返回列表