GGML库核心概念:张量、计算图与上下文
各位同学,今天我们来聊聊GGML库的三个核心概念。说实话,这三个东西是llama.cpp的基石。你搞懂了它们,后面写算子就跟玩似的。
我记得刚接触GGML时,第一反应是:这玩意儿怎么跟PyTorch这么像?后来才发现,GGML的设计哲学就是「轻量、可控、零依赖」。它没有CUDA、没有cuDNN,纯C++硬扛。嗯,这才是硬核玩家的选择。
一、张量(ggml_tensor)—— 数据的基本单元
张量是什么?说白了就是一个多维数组,附带了一些元数据。在GGML里,每个张量都对应一个ggml_tensor结构体。
我个人习惯把张量想象成一个「带标签的集装箱」。集装箱本身是数据,标签告诉你怎么解读这些数据。
核心字段一览
| 字段 | 类型 | 说明 |
|---|---|---|
type |
ggml_type |
数据类型,比如GGML_TYPE_F32、GGML_TYPE_Q4_0 |
ne[] |
int64_t[4] |
各维度的元素个数,最多4维 |
nb[] |
size_t[4] |
各维度的步长(字节偏移量) |
data |
void* |
指向实际数据的指针 |
op_params |
int32_t[16] |
算子参数缓存区 |
这里有个坑,我刚开始踩过。你看ne[]和nb[]的索引顺序——ne[0]是最内层维度,ne[3]是最外层。这跟NumPy的习惯正好相反。我曾经写过一个算子,矩阵乘法结果全错,查了两天才发现是维度顺序搞反了。
ne[0]作为行数。结果GGML的布局是列优先(column-major),ne[0]其实是列数。记住:ne[0] = 最内层维度,ne[1] = 次内层,以此类推。
张量的生命周期
张量本身不管理内存。它只是指向一块内存区域。这块内存由谁管理?上下文(ggml_context)来管。我们后面会细说。
创建张量的典型方式:
// 创建一个 3x4 的 F32 张量
struct ggml_tensor* t = ggml_new_tensor_2d(ctx, GGML_TYPE_F32, 4, 3);
// 注意:4是列数(ne[0]),3是行数(ne[1])
你想想看,为什么GGML要自己搞一套张量系统,而不是直接用现成的库?因为量化。GGML支持多种量化格式(Q4_0、Q5_1、Q8_0等),这些格式在标准库中根本不存在。
二、计算图(ggml_cgraph)—— 算子的编排器
计算图是什么?说白了就是一张「算子执行计划表」。你把所有算子节点按依赖关系连起来,GGML帮你决定先算谁、后算谁。
我参与llama.cpp优化时,最常干的事就是盯着计算图看。为什么?因为图的结构直接决定了内存访问模式和并行度。
图的构建
GGML的计算图是显式构建的。你每调用一个算子函数,它就会往图里加一个节点。比如:
struct ggml_cgraph* gf = ggml_new_graph(ctx);
// 构建计算图
struct ggml_tensor* a = ggml_new_tensor_1d(ctx, GGML_TYPE_F32, 4);
struct ggml_tensor* b = ggml_new_tensor_1d(ctx, GGML_TYPE_F32, 4);
struct ggml_tensor* sum = ggml_add(ctx, a, b); // 加法节点
struct ggml_tensor* relu = ggml_relu(ctx, sum); // ReLU节点
// 把最后一个节点设为输出
ggml_set_output(relu);
// 前向计算
ggml_graph_compute_with_ctx(ctx, gf, 1);
这里有个细节:ggml_add和ggml_relu并不会立即计算,它们只是往图里注册了操作。真正的计算发生在ggml_graph_compute_with_ctx调用时。
ggml_graph_plan来查看执行计划。我一般会在调试时打印计划,看看有没有不必要的串行依赖。
图优化的常见手段
- 算子融合:把多个连续的小算子合并成一个。比如
add + relu可以融合成add_relu,减少内存读写。 - 内存复用:中间张量的内存可以复用。GGML内部有
ggml_graph_compute的优化版本,会自动做这件事。 - 并行调度:对于没有依赖关系的节点,可以并行执行。GGML使用线程池来实现。
我曾经在优化LLaMA的FFN层时,发现计算图里有大量冗余的reshape操作。删掉之后,推理速度提升了12%。所以说,图优化不是花架子,是真能省时间的。
三、上下文(ggml_context)—— 内存的大管家
上下文是GGML里最容易被忽视的概念。它负责两件事:内存分配和对象管理。
你想想看,如果没有上下文,每个张量都自己去malloc,那内存碎片得多严重?GGML的做法是:一次性从系统申请一大块内存,然后在这个大块里做线性分配。用完一次性释放。
上下文的创建
// 估算所需内存
size_t mem_size = ggml_tensor_overhead() * 10 + 1024 * 1024;
struct ggml_context* ctx = ggml_init({
.mem_size = mem_size,
.mem_buffer = NULL, // NULL表示内部自动分配
});
这里有个技巧:ggml_tensor_overhead()返回每个张量结构体占用的内存。但实际数据内存需要额外计算。我一般会先估算一个较大的值,然后用ggml_used_mem(ctx)查看实际用量,再调整。
ggml_init创建一个临时上下文,把所有张量和操作都注册一遍,然后调用ggml_graph_compute之前,用ggml_graph_overhead()获取实际需要的总内存。这样就不会出现OOM了。
上下文的生命周期管理
上下文一旦创建,你只能往里面加东西,不能删除。所有张量、计算图都依附于上下文。释放时调用ggml_free(ctx),一次性全部回收。
这种设计有什么好处?简单、高效、无碎片。坏处呢?如果你需要动态增删张量,GGML就不太合适了。但推理场景下,模型结构是固定的,所以这种设计非常合适。
四、三者关系图解
下面我用一张SVG图来展示这三个核心概念的关系:
从这张图可以看得很清楚:上下文是老大,它管着内存。张量是数据载体,计算图是执行计划。三者配合,才能完成一次推理。
五、实战中的注意事项
- 上下文复用:如果你要多次执行同一个计算图(比如批量推理),可以复用同一个上下文。只需要在每次执行前重置内存位置即可。
- 张量对齐:GGML要求张量数据地址按32字节对齐。这是为了SIMD指令的兼容性。如果你自己分配内存,记得用
aligned_alloc。 - 图的最大节点数:默认是
GGML_DEFAULT_GRAPH_SIZE(4096)。对于LLaMA-7B来说够用,但更大的模型可能需要调大。 - 不要跨上下文使用张量:每个张量都绑定在创建它的上下文上。跨上下文使用会导致野指针。我见过有人把两个不同上下文的张量做加法,结果程序直接崩了。
好了,这一章的内容就到这里。记住我上面说的那些坑,写代码时能少走不少弯路。
公众号:蓝海资料掘金营,微信deep3321