在上一章 上下文 (ggml_context) 中,我们学习了如何搭建一个高效的"工作台"来管理所有张量 (ggml_tensor)的内存。现在,我们有了原材料(张量)和工作空间(上下文),但我们还缺少最关键的一样东西:一份菜谱,告诉我们如何一步步处理这些原材料,最终做出一道美味的"大餐"(比如一个模型的推理结果)。
这本菜谱,在 ggml
中就是计算图 (ggml_cgraph)。
什么是计算图?为什么需要它?
想象一下,你想计算一个简单的线性方程:y = a * x + b
。
a
,x
,b
是你的输入数据(原材料)。*
(乘法) 和+
(加法) 是你需要执行的操作步骤。y
是你想要的最终结果。
你不会一口气就算出 y
。你会先计算 a * x
,得到一个中间结果,然后再将这个中间结果与 b
相加。这个先后顺序和依赖关系,就可以用一张图来表示:
这张图就是计算图。
核心思想 :
ggml_cgraph
就像一张菜谱。菜谱上记录了做一道菜需要的所有步骤(操作)和原材料(张量),以及它们之间的先后顺序。仅仅看着菜谱并不会做出菜。ggml_cgraph
记录了你定义的所有张量操作(比如加法、乘法),形成了一个操作流程图。这个图本身不执行计算,但它详细地告诉了ggml
"如何"进行计算。
这种"只记录,不执行"的模式(也称为"延迟执行")是 ggml
的一个核心设计哲学,它带来了巨大的好处:
- 高效执行 :
ggml
可以先拿到完整的"菜谱",然后通盘考虑如何最高效地安排烹饪步骤,比如哪些步骤可以并行处理,如何最节省地使用内存。 - 自动求导 :对于模型训练,
ggml
可以沿着这张图反向追溯,自动计算出每个参数的梯度,这是训练神经网络的关键。 - 跨平台优化 :同一张计算图可以被不同的后端(CPU, GPU)解释和执行,
ggml
可以为不同硬件生成最优的执行计划。
动手实践:构建并执行你的第一个计算图
让我们用代码来实现 y = a * x + b
这个过程。
第 1 步:准备工作(创建上下文和张量)
首先,我们需要一个上下文 (ggml_context) 作为我们的工作台,并创建输入张量 a
, x
, b
。
c
#include "ggml.h"
#include <stdio.h>
int main(void) {
// 1. 准备工作台
struct ggml_init_params params = { 16 * 1024 * 1024, NULL, false };
struct ggml_context * ctx = ggml_init(params);
// 2. 准备原材料 (所有张量都是 1x1 的标量)
struct ggml_tensor * a = ggml_new_tensor_1d(ctx, GGML_TYPE_F32, 1);
struct ggml_tensor * x = ggml_new_tensor_1d(ctx, GGML_TYPE_F32, 1);
struct ggml_tensor * b = ggml_new_tensor_1d(ctx, GGML_TYPE_F32, 1);
// ... 后续步骤将在这里继续 ...
第 2 步:定义计算步骤(构建依赖关系)
接下来,我们调用 ggml
的操作函数来"画"出我们的计算图。记住,这些函数调用不会立即执行计算!
c
// 步骤 1: a * x
struct ggml_tensor * ax = ggml_mul(ctx, a, x);
// 步骤 2: (a * x) + b
struct ggml_tensor * y = ggml_add(ctx, ax, b);
这两行代码做了什么?
ggml_mul(ctx, a, x)
创建了一个新的张量ax
。ax
的内部记录着:"我是由GGML_OP_MUL
操作产生的,我的输入是a
和x
"。ggml_add(ctx, ax, b)
创建了最终的张量y
。y
的内部记录着:"我是由GGML_OP_ADD
操作产生的,我的输入是ax
和b
"。
至此,一个描述 y = a * x + b
的依赖关系网络已经在内存中形成了。
第 3 步:创建并构建计算图
现在,我们需要把这个内存中的依赖关系,翻译成一个实际的、可执行的计划,也就是 ggml_cgraph
对象。
c
// 1. 创建一个空的计算图对象
struct ggml_cgraph * gf = ggml_new_graph(ctx);
// 2. 从最终的目标张量 y 开始,反向追踪并构建图
ggml_build_forward_expand(gf, y);
ggml_new_graph(ctx)
创建了一个空的"菜谱本"。ggml_build_forward_expand(gf, y)
是一个神奇的函数。它会从y
开始,像侦探一样沿着y
的输入 (ax
和b
),再沿着ax
的输入 (a
和x
),一路回溯,找出所有参与计算的节点,并把它们按照正确的执行顺序(比如先算乘法再算加法)记录到gf
这个"菜谱本"里。
第 4 步:执行计算
菜谱已经准备好了,现在是时候"开火做菜"了!在执行前,我们需要为输入张量赋予具体的值。
c
// 为输入张量设置具体数值
ggml_set_f32(a, 3.0f); // a = 3
ggml_set_f32(x, 2.0f); // x = 2
ggml_set_f32(b, 4.0f); // b = 4
// 使用 4 个线程执行计算图中的所有操作
ggml_graph_compute_with_ctx(ctx, gf, 4);
ggml_set_f32
用来给张量赋值。ggml_graph_compute_with_ctx
是真正执行计算的地方。它会严格按照gf
中记录的步骤,一个接一个地执行,比如先执行a*x
,然后把结果加上b
。计算出的结果会直接存放在对应张量(ax
和y
)的data
指针所指向的内存中。
第 5 步:获取结果并清理
计算完成后,我们可以从 y
张量中读取结果。
c
// 从 y 张量中获取计算结果
float result = ggml_get_f32_1d(y, 0);
printf("y = a * x + b = 3*2 + 4 = %.1f\n", result);
// 释放所有资源
ggml_free(ctx);
return 0;
}
输出应该是:
css
y = a * x + b = 3*2 + 4 = 10.0
深入幕后:ggml_cgraph
的内部结构
ggml_cgraph
结构体(在 ggml-impl.h
中定义)本质上是一个节点列表,它存储了所有需要计算的张量。
c
// 来自 ggml-impl.h 的简化版结构
struct ggml_cgraph {
int n_nodes; // 图中有多少个计算节点
int n_leafs; // 图中有多少个输入节点 (叶子节点)
struct ggml_tensor ** nodes; // 指向计算节点的张量指针数组
struct ggml_tensor ** leafs; // 指向叶子节点的张量指针数组
// ... 其他用于自动求导等的字段 ...
};
当我们调用 ggml_build_forward_expand(gf, y)
时,ggml
内部会进行一次"图遍历":
这个过程(专业上称为拓扑排序 )确保了 gf->nodes
数组中的张量是按照正确的依赖顺序排列的。ggml_graph_compute_with_ctx
只需要从头到尾遍历 gf->nodes
数组,对每个张量执行其对应的 op
操作,就能完成整个计算。
总结
在本章中,我们学习了 ggml
的执行核心------计算图 (ggml_cgraph)。
- 计算图是计算任务的**"菜谱"或"蓝图"**,它定义了操作的流程和依赖关系。
- 在
ggml
中,我们先定义图,再执行计算 。调用ggml_mul
、ggml_add
等函数只是在构建图的依赖关系,并不会立即计算。 - 我们通过
ggml_new_graph
和ggml_build_forward_expand
来创建和构建一个可执行的计算图。 - 最后使用
ggml_graph_compute_with_ctx
来真正地执行计算。 - 这种将"定义"与"执行"分离的设计,为
ggml
带来了高效、可优化和功能强大的特性。
现在,我们已经掌握了 ggml
中从数据表示到内存管理再到计算执行的整个核心流程。但是,在实际应用中,我们很少会像这样从零开始手动构建一个大型神经网络。我们更常见的做法是加载一个别人已经训练好的模型文件。