先说一个结论:在 ggml 这套代码里,张量本身只是一堆“哑数据”,真正驱动模型跑起来的其实是ggml_cgraph。很多读者第一次接触 ggml 是在 llama.cpp 的源码里,看来看去都是ggml_mul_mat、ggml_add、ggml_soft_max这类算子调用,以为 ggml 就是一个张量运算库。但如果你真的要改推理逻辑、做多后端适配,或者只是单纯想把一个大模型的前向过程完整梳理出来,就会发现所有算子的调用最终都收束到一张计算图上——这就是ggml_cgraph。
ggml_cgraph是一张有向无环图(DAG),图中的节点是张量及其对应的算子,边是张量之间的数据依赖。它在整个推理框架里承担的是“调度中枢”的角色:预先把模型的每一层计算展开成节点,再在真正执行阶段按拓扑顺序把节点派发到 CPU 或 GPU 上,同时把中间结果的存放与复用一并规划好。你可以把它理解成一份“作战地图”,算子是士兵,张量是弹药,没有地图调度师,兵再多也是一盘散沙。
这篇文章既适合已经读过一些 ggml 源码、想深入理解 cgraph 结构的人,也适合正在做推理框架移植、性能优化或者二次开发的朋友。我会从设计动机、核心数据结构、构建与执行流程、调试手段、常见坑位几个角度展开,尽量把文档里不会写的内容也讲清楚。
1. 为什么 ggml 会选择“图”而不是命令式调用
如果你只是在自己的工具链里调几个 ggml 算子,比如做一次矩阵乘法、一次激活函数,确实不需要建图。但把场景拉大到真实的大模型推理,你就明白命令式调用的短板了。
1.1 命令式调用的四个瓶颈
第一,内存没法全局规划。一个 7B 模型的前向过程里有几十上百个中间张量,如果每个算子内部都临时分配内存,推理过程中 malloc/free 的开销会非常可观,而且碎片化问题会越来越严重。命令式编程里,每个算子的输入输出生命周期只能靠开发者手动理解,稍有大意就会产生悬挂指针或者内存泄漏。
第二,并行度上不去。大模型里有很多彼此独立的计算,比如多头注意力里各个 head 的 QK^T 计算、MLP 里的多分支计算。如果是一个算子的命令式调用,你只能在单个算子内部开线程并行;但算子之间的依赖一旦没有全局视图,就无法在算法层面判断哪些节点可以并发,白白浪费多核资源。
第三,设备调度困难。在真实部署环境里,计算可能要跨越 CPU、CUDA、Metal 等多个设备。命令式调用时你只能在每个算子函数里判断“当前张量在哪个设备上、要不要搬移”,逻辑散落在各处,很难做整体的数据搬移优化。而图可以在执行前就把设备边界和搬移点一次性分析出来。
第四,调试和复现没有抓手。命令式环境下你很难回答“这层到底有没有执行”“权重在哪里被覆盖了”“某个中间结果的 shape 是多少”这类问题。但如果你手上有一张完整展开的图,很多东西可以直接从图上看到。
1.2 静态图 vs 动态图:ggml 站哪边
用过 PyTorch 的人对“计算图”肯定不陌生,但 PyTorch 那种动态图(Eager Mode)是跟着 Python 执行过程实时构建的,构建与执行交织在一起。ggml 更像 TensorFlow 1.x 或者 JAX 的 jit 风格:先构建一张静态图,然后反复执行这张图。
静态图的好处在于:
- 所有张量的 shape、类型、依赖关系在构建阶段就确定了,执行阶段不需要再做任何动态推断;
- 可以一次性分配好所有中间结果的内存空间,执行时零 malloc;
- 图的拓扑顺序天然等价于执行顺序,调度器不需要在运行时做复杂的优先级仲裁;
- 后端调度器可以把整张图按设备切块,优化数据搬移策略。
ggml 之所以选择静态图而不是动态图,最核心的原因是它面向推理场景,推理的前向结构是固定的。对比一下两者的差异会更清楚:
| 维度 | 命令式逐算子调用 | ggml_cgraph 静态图 |
|---|---|---|
| 内存分配 | 每个算子内部各自处理,难以复用 | 构建阶段统一规划,执行期零分配 |
| 并行调度 | 只能在算子内部开线程 | 节点级任务拆分 + 算子内部再拆分 |
| 设备迁移 | 逻辑分散,不能全局优化 | 后端调度器统一切图 |
| 调试手段 | 依赖日志和打断点 | 整图打印、导出、可视化 |
| 执行模式 | 调用一次执行一次 | 构建一次,反复执行 |
可以说,ggml 用“构建一次、执行多次”的模式,换来了推理过程中的确定性开销,这一点在长上下文、高并发场景下非常重要。
2. 核心数据结构:tensor、cgraph 与 context 的关系
要真正搞懂ggml_cgraph,不能只盯着图容器本身,因为它和ggml_tensor、ggml_context是强绑定的。这三者的关系,我习惯用一句话概括:context 是内存池,tensor 是池子里捞出来的对象,cgraph 是把这些对象串起来的依赖图谱。
2.1 图中的节点:ggml_tensor
在 ggml 里,一个ggml_tensor既可以是数据本身,也可以是一个算子的输出。它的关键字段大致如下(我这里做了一定简化,实际结构以你的源码版本为准):
struct ggml_tensor { struct ggml_object obj; // 对象头,标记它在 context 内存池里的位置 enum ggml_type type; // 数据类型:F32, F16, Q8_0 等 enum ggml_op op; // 操作类型:NONE, MUL_MAT, ADD, SILU... int32_t n_dims; // 维度数量,ggml 里最多 4 维 int64_t ne[GGML_MAX_DIMS]; // 每个维度的元素个数 size_t nb[GGML_MAX_DIMS]; // 每个维度的字节步长 struct ggml_tensor *src[GGML_MAX_SRC]; // 算子输入,最多 GGML_MAX_SRC 个 struct ggml_tensor *grad; // 反向计算时对应的梯度张量 void *data; // 指向实际数据缓冲区 char name[GGML_MAX_NAME]; // 张量名称,调试时极有用 };这里最关键的是op和src。当一个张量的op不是GGML_OP_NONE,它就是一个计算节点,代表“对 src 里的张量执行某个算子,结果保存在自己的 data 里”。这个设计非常紧凑:张量既是数据容器,也是指令描述。
nb[]是 ggml 里比较有特色的东西。它不是数学上的 shape,而是每个维度的字节步长。举例来说,如果你有一个形状为[4, 8]的 F32 矩阵,ne[0]=4, ne[1]=8,如果按行主序存储,那么nb[0]=4(相邻列之间间隔 4 字节),nb[1]=32(相邻行之间间隔 32 字节)。有了nb[],ggml 可以用极小的开销描述转置、切片等操作,而不必真正搬动数据。
需要特别注意,ggml_tensor本身不管理内存,它只记录数据的地址和布局。真正的内存块在ggml_context里,这是一个非常重要的设计决策:tensor 的 data 指向 context 缓冲区中的某个偏移位置。
2.2 图容器:ggml_cgraph
ggml_cgraph本身的结构并不复杂:
struct ggml_cgraph { int size; // 容量:最多能容纳多少个节点 int n_nodes; // 当前节点数 int n_leafs; // 当前叶子数 struct ggml_tensor ** nodes; // 内部节点指针数组 struct ggml_tensor ** leafs; // 叶子节点指针数组 struct ggml_hash_set visited_hash_table; // 构建时用于去重的哈希表 };节点和叶子的区别是理解这张图的钥匙:
- leaf:没有输入源(
src为空)或者op == GGML_OP_NONE的张量,通常就是模型的输入、权重、bias 这类真实携带数据的张量; - node:由算子产生的中间张量,例如
mul_mat的输出、add的输出; nodes[]数组的排列顺序就是执行顺序,也就是依赖关系的拓扑序;visited_hash_table是构建图时用的,防止同一个张量被重复展开,这个后面细说。
ggml_cgraph最初是在栈上分配的一个结构体,后来为了避免大对象拷贝,改成在 context 里分配。实际使用中,你一般不会直接操作nodes[],而是通过构建 API 往图里填充内容。
2.3 context:图的最大生命周期边界
ggml_context是所有张量、图、哈希表的数据归属地。你可以把它看成一个“内存竞技场”(arena):
struct ggml_init_params { size_t mem_size; // 内存池总大小 void * mem_buffer; // 外部缓冲区,NULL 表示内部自行分配 bool no_alloc; // 为 true 时,不立即分配张量数据空间 }; struct ggml_context * ctx = ggml_init(params);你在 context 里每创建一个张量,就从内存池里划一块出去。图结构本身、图的节点数组、叶子数组、哈希表也都在同一块池子里。这就带来一个非常犀利的性质:一个 context 销毁,它所包含的所有张量和图一并失效。这与 C++ 里对象各自管理生命周期的习惯截然不同,是使用 ggml 时最容易踩坑的地方之一。
关于no_alloc:如果为 true,创建张量时不会分配 data 空间,data 留给你自己去分配(比如通过后端分配显存)。这是多后端场景下几乎必用的模式,因为 CPU 张量数据和 CUDA 张量数据的存储方式完全不同。
3. 一次典型的构建-执行流程
直接看代码最直观。下边是一个简单的两层 MLP 在 ggml 里的完整流程,包括建图和执行:
// 1. 初始化 context struct ggml_init_params params = { .mem_size = 16 * 1024 * 1024, // 16MB 内存池 .mem_buffer = NULL, .no_alloc = false, }; struct ggml_context * ctx = ggml_init(params); // 2. 创建输入和权重张量 struct ggml_tensor * x = ggml_new_tensor_2d(ctx, GGML_TYPE_F32, 8, 1); // 输入,8维 struct ggml_tensor * w1 = ggml_new_tensor_2d(ctx, GGML_TYPE_F32, 32, 8); // 权重 struct ggml_tensor * b1 = ggml_new_tensor_2d(ctx, GGML_TYPE_F32, 32, 1); // bias struct ggml_tensor * w2 = ggml_new_tensor_2d(ctx, GGML_TYPE_F32, 8, 32); // 权重 struct ggml_tensor * b2 = ggml_new_tensor_2d(ctx, GGML_TYPE_F32, 8, 1); // bias // 3. 构建算子链 struct ggml_tensor * h1 = ggml_silu(ctx, ggml_add(ctx, ggml_mul_mat(ctx, w1, x), b1)); struct ggml_tensor * out = ggml_add(ctx, ggml_mul_mat(ctx, w2, h1), b2); // 4. 建图 struct ggml_cgraph * graph = ggml_new_graph(ctx); ggml_build_forward_expand(graph, out); // 5. 执行 ggml_graph_compute_with_ctx(ctx, graph, 4); // 4 线程这段代码执行完毕后,你可以通过ggml_graph_print(graph)看到图的完整信息。这个流程看起来简单,但背后发生的事非常值得拆开看。
3.1 构建时发生了什么
ggml_build_forward_expand的输入是目标张量out,它要做的事情是:以out为根,递归展开所有祖先,形成一张完整的 DAG。
展开过程本质上是深度优先遍历:
- 从
out开始,检查它的src数组,例如out = add(mul_mat(w2, h1), b2),它的 src 包含mul_mat(w2, h1)这个节点和b2这个叶子; - 对每个 src 递归执行同样的检查,直到遇到“没有 src 的张量”(也就是 leaf)为止;
- 递归返回时,先把当前张量加入
nodes[],再加入父节点所在的数组。
这个“先递归子节点、再记录父节点”的顺序,保证了nodes[]天然就是一个拓扑序列:任何节点的 src 都排在其自身之前。执行阶段只要从nodes[0]一路顺序执行到nodes[n_nodes-1],就绝对不会出“依赖还没算完就开算”的错误。
visited_hash_table的作用是防止同一张量被展开两次。这在 Transformer 里特别关键:hidden_state 往往同时被注意力路径和残差路径引用,如果不做去重,同一个中间张量会在 nodes 数组里出现多次,执行阶段就会重复计算一遍,纯属浪费算力。
3.2 叶子和节点的分配结果
对应到上面的 MLP 例子,建完图之后大致是这样的分布:
leafs[]:x、w1、b1、w2、b2,一共 5 个叶子;nodes[]:第一个mul_mat(w1, x)的输出、第一个add的输出、silu的输出、第二个mul_mat(w2, h1)的输出、最终的out,如果不把中间张量单独命名,那实际占用的节点数是 5 个。
注意,ggml_mul_mat(ctx, w1, x)这个调用会立刻创建一个新的张量对象,它的op字段被标记为GGML_OP_MUL_MAT,src 指向w1和x。这个张量在建立前就已经在 context 里占好内存了。换句话说,图构建过程并不会重新分配数据,而只是把已经分配好的张量按依赖关系串起来。
3.3 执行时发生了什么
ggml_graph_compute_with_ctx内部会先根据图的信息生成一个执行计划(plan),然后按照nodes[]的顺序逐个调度节点。每个节点最终会调用到ggml_compute_forward这个分发器,根据节点的op类型路由到具体的 kernel,比如ggml_compute_forward_mul_mat、ggml_compute_forward_add等。
执行结束后,out->data里就是最终结果。因为你使用的是非no_alloc的 context,所以所有中间张量的数据位置在创建阶段就固定了,执行过程中没有 malloc,也没有 free。
4. 图调度器:真正驱动多线程计算的部分
如果说ggml_cgraph是地图,那一层层调度机制就是司机。很多人以为执行图就是“按顺序跑一遍算子”,实际上远不止这么简单,ggml 在执行阶段还有一层很讲究的任务并行化设计。
4.1 从图到执行计划
ggml_graph_compute_with_ctx首先会调用内部的计算规划函数,为整张图生成一个ggml_cplan或者等价结构,里面包含几个关键决策:
- 需要多大的临时工作缓冲区(work buffer);
- 每个节点拆分成多少个任务(task);
- 线程数如何分配。
工作缓冲区是图执行里不可忽视的一部分。一些算子(比如某些矩阵乘法、卷积算子、transpose 后的 reshape)需要额外的临时空间,这些空间不写在张量自己的 data 里,而是统一从 work buffer 里取。如果一个图里所有算子都不需要额外空间,work buffer 可能很小;但如果图里有大矩阵乘法或者布局转换,work buffer 可能占好几 MB,必须提前算好并分配。
4.2 任务的拆分与并行
具体到单个节点,ggml 的做法是先把一个算子按数据维度拆成若干个“任务”,再把任务丢给线程池执行。以mul_mat为例,输出矩阵的行是可以独立计算的,调度器会把输出行切分到不同线程上,每个线程算一部分行,最后拼成一个完整结果。
任务拆分不是越多越好。每个任务都有调度开销,任务太多反而在线程同步上浪费时间。ggml 在规划阶段会参考当前节点的工作量和总线程数,估算一个合理的任务数量。通常情况下,图里的每个节点不是单纯一个任务,而是被拆成n_threads个左右的子任务,这样才能让多核利用率上去。
实际执行队列看起来大致是这样的:
- 遍历
nodes[],为当前节点生成任务; - 把任务放入线程池共享队列;
- 各线程原子地从队列里取任务执行;
- 当前节点的所有任务完成后,再进入下一个节点。
这个机制保证了节点内可以并行,节点间严格按依赖顺序执行。有些读者可能会问:既然多个独立节点也能并行,为什么 ggml 不跨节点并行?答案是:减少复杂度。Transformer 里真正的并行机会主要来自单个算子内部的张量拆分,跨节点的并行收益通常已经被算子内部的拆分覆盖了,与其引入复杂的跨节点依赖仲裁,不如把节点内并行做好。
4.3 多后端场景下的图分区
如果你使用的是新版 ggml 并接入 CUDA/Metal 后端,那么调度逻辑会更复杂一些:后端调度器会把ggml_cgraph按设备边界切成多个子图,同一个子图里的节点放在同一个后端执行,跨子图的边就是需要搬运数据的张量。
这里有一个很实际的建议:尽量把模型参数的初始化分配在目标设备上。如果你的权重张量是 CPU 上的no_alloc=false分配,然后被 CUDA 后端拿去执行,调度器每次执行图时都要把权重从 CPU 搬移到 GPU,这个开销很容易变成推理瓶颈。正确的做法是:权重张量用no_alloc上下文创建,然后通过后端的分配函数在显存里分配,这样图调度器看到的权重已经“住”在正确的设备上,省掉了反复搬移。
5. 调试与可视化:从源码到一张看得见的图
图是静态的,但调试图的过程往往是动态的。我在实际开发里总结出几条看上去简单但非常有效的调试路径。
5.1 用 ggml_graph_print 快速体检
ggml_graph_print(graph)会打印整张图的节点和叶子信息。输出里能看到每个张量的名字、op 类型、形状、内存大小。刚构建完图后先打印一次,花十几秒扫一遍节点数量和自己预想的是否一致,能避免很多后续执行阶段的诡异问题。
比如你预期一段 Transformer 代码会把所有层展开成上百个节点,但打印出来发现只有十几层,那多半是权重张量在构建阶段被意外复用或者展开被截断了。这种问题如果不上图,直接看代码很难定位。
5.2 导出 DOT 文件做可视化
ggml_graph_dump_dot(graph, "graph.dot", NULL)可以把图导出成 Graphviz 的 dot 文件。然后用dot -Tsvg graph.dot -o graph.svg生成图片。对复杂模型来说,这个 SVG 虽然节点很多,但能非常直观地看出数据流方向。特别是当你想排查“某个中间张量到底有没有连接到输出”时,直接把图渲染出来,比盯着一堆定义看快得多。
5.3 用 JSON 导出做程序化分析
新版 ggml 还提供了ggml_graph_export/ggml_graph_import,把图导出成 JSON 格式。这个对自动化分析很有用。比如你想统计模型里有几个GGML_OP_MUL_MAT节点、每层的计算量分布、是否有 tensor 被重复引用,解析 JSON 就行。
在实际调优时,我经常把导出 JSON 之后,再用 Python 脚本统计一遍大算子分布,这样能快速找到计算热点。比如在一个 7B 模型里,绝大多数耗时集中在GGML_OP_MUL_MAT,那优化方向就很明确——去优化矩阵乘法的实现或者量化方式,而不是花时间抠 softmax 的边界处理。
5.4 观察点:节点数也有限制
调试过程中你可能会遇到graph size error这类断言。根源在于ggml_new_graph(ctx)默认创建的图容量只有 2048 个节点(实际宏名和你手里的版本有关)。如果你的模型比较大,展开后的节点数不够用,就需要用自定义容量函数:
struct ggml_cgraph * graph = ggml_new_graph_custom(ctx, 8192, false);在动手之前,先确认你需要的节点数。一个非常粗略的经验公式:Transformer 模型层数乘以每层算子数再乘以一个安全系数。7B 的典型裸 GGUF 模型展开后大约几百到一千多个节点,2048 往往够用;但如果你做了复杂的投机采样、多层 KV cache 融合或者 batch 很大,就可能逼近上限。
6. 实战中的坑与经验
最后这部分是我最想写的。ggml 这套 API 看起来简洁,但真把它拼进一个大型推理服务的时候,你一定会碰到若干“源码里看不出问题、一跑就炸”的坑。
6.1 context 生命周期是一切问题的根源
前面反复强调过,tensor、graph、grad 都是从同一个 context 内存池里分配的。这意味着只要 context 销毁,所有指向其中张量的指针立刻悬空,没有任何引用计数来保护你。
实践中一个非常典型的错误是:推理服务里某个线程为了做一些中间处理申请了一个临时 context,然后又想把这个 context 里的张量塞进主推理图里复用。看似合理,但等临时 context 释放之后,主图里的那个节点就变成野指针,直接段错误。
正确的做法是:所有要进入同一张图的张量,必须来自同一个 context,而且要保证这个 context 的生命周期覆盖整个图的执行周期。多线程场景下,最好为每个逻辑单元(比如一个请求会话)单独建 graph 和 context,请求结束再统一释放,而不是在线程间共享图结构。
6.2 图容量不够,不一定是模型太大
GGML_DEFAULT_GRAPH_SIZE默认容量是 2048,但你真的展开一个长上下文模型时,节点数很容易超。不过我遇到过更隐蔽的情况:代码里每轮推理都反复调用ggml_build_forward_expand往同一个图里追加内容,结果图被“撑爆”。旧节点数据还在,新节点也在增加,但节点数组只是做了追加,不会定期清理。
这种情况下,你应该每轮推理重新建图,或者复用图结构但先清理旧内容。ggml 的图在构建完成之后,n_nodes和n_leafs可以被重置,但我在实践中更倾向重新建一张新图,因为旧图里的哈希表状态残留有时会让你排查问题时分心。图构建的开销和推理计算相比可以忽略不计,别太纠结“复用图”这种微优化。
6.3 no_alloc 上下文里的“虚假成功”
no_alloc = true的上下文非常容易骗人:代码里创建张量、建图、打印都正常,一旦执行就出错,或者出现全部预测结果都相同这类诡异现象。原因通常是:张量的 data 指针是空指针,但代码路径里没有检查,甚至某些算子对空指针写入时不会立刻崩溃,直到某个瞬间写坏内存才爆出来。
如果你确实需要在no_alloc模式下工作,务必确认以下几点:
- 权重张量数据是否已经通过后端张量分配函数分配并填入;
- 输入张量是否也在目标后端分配了空间,并且执行前设置好数据;
- 图执行使用的后端与张量分配的后端是否一致。
6.4 图构建不要放在热点循环里
虽然我前面说“别纠结复用图”,但反过来也有一种极端:每次推理前都从零开始构建整张图。对 llama.cpp 这类框架来说,构建一次图的路径包含不少哈希操作、结构分配、字符串复制,虽然相对推理耗时不算大,但放在高频小请求场景,比如百毫秒级的短请求,构建开销占比就会被放大。
最佳实践是**“模型加载时建图,请求到来时只执行图”**。对于同一模型、同一参数配置,图的拓扑结构基本不变,唯一变化的是输入张量的 data 内容。你可以把 graph 和模型权重放在同一个 context 里,在模型初始化阶段 build 一次,之后每个请求只需要更新输入数据并调用 compute。
6.5 线程数不是越大越好
ggml_graph_compute_with_ctx(ctx, graph, n_threads)里的 n_threads 很多人直接填 CPU 核心数,但实测下来经常不是最优解。
原因是多方面的:
- 每个算子的任务拆分粒度有限,线程太多时,任务队列同步开销会吃掉收益;
- 内存带宽是共享的,线程越多,争抢带宽越严重,矩阵乘这类带宽敏感算子尤其明显;
- 算子内部可能已经有 SIMD 和 cache-blocking 优化,线程数翻倍不等于吞吐翻倍。
我的经验是:对于纯 CPU 推理,线程数从 4 开始往上试,观察每档的 token/s 增速。如果你发现从 8 线程加到 16 线程,性能只提升几个百分点甚至下降,那说明瓶颈已经从计算转为内存带宽或者同步开销了,继续加大线程数没有意义。
另外,如果 CPU 上有超线程,我一般不用满全部逻辑核心,而是留出几个核心给操作系统和驱动,避免上下文切换带来的抖动。
6.6 学会用源码回答“这个算子到底干了什么”
最后一条经验偏向工程方法:ggml 的算子实现往往藏得很深,但每个算子最终的入口都是ggml_compute_forward系列函数。遇到一个算子行为不符合预期时,不要瞎猜,直接去源码里搜ggml_compute_forward_+ 算子名,然后往下看到 kernel 实现。图本身的打印信息只能告诉你“它被调度了”,不能告诉你“它算得对不对”,真正决定计算正确性的是底层 kernel。
调试过程中,我用得最多的组合是:ggml_graph_print确认结构、ggml_graph_dump_dot查看数据流、再用源码确认某个算子的边界条件。这个组合在排查 llama.cpp 的各种魔改分支时,帮我省掉了大量盲目实验的时间。
ggml 的计算图设计并不算新潮,但它把“静态图 + 内存池 + 多线程任务拆分 + 多后端调度”这几个硬核特性紧凑地塞在了一套 C API 里。理解ggml_cgraph,不只是读几个结构体字段,而是理解它背后的调度哲学:先规划,再执行,一切准备好之后,剩下的就是流水线般的吞吐。希望这篇拆解能帮你少踩几个坑。