GGML:张量模型、计算图与惰性执行

GGML:张量模型、计算图与惰性执行

「本系列第 2/17 章」

上一章把本地栈切成训练、转换、推理、RAG 四段。从本章起进入推理引擎的底部:GGML 0.15.3 如何用一张最多四维的张量,加上惰性计算图,把一次前向表示出来。读完应能解释:为什么 ggml_mul_mat 返回时矩阵还没乘,以及 ggml_build_forward_expand 之后三条执行路径各自给谁用。

张量是五元组,不是「一块连续 float」

ggml_tensor 用五个要素就能完整描述一次计算节点:

字段 含义
ne[4] 各维元素个数,上限 GGML_MAX_DIMS=4
nb[4] 各维 stride,单位是字节
type F32、F16、Q4_K、Q8_0 等
op + src[] 产生该张量的算子及其输入
buffer + data 数据落在哪块 Backend 内存

LLM 权重大多是 [rows, cols, 1, 1]。第四维不是浪费:Attention 与 KV 会用到 batch、头数、序列长。nb 允许非连续布局,所以 view / permute / transpose 常常只改元数据。所有 Backend 必须尊重 stride;GPU kernel 若假定连续,调度器或算子实现会先 ggml_cont,否则结果 silently 错。

type 决定后面怎么算,而不是「先变成 F32 再算」。量化权重保持 block 布局,和 Q8 激活做 vec_dot。这一点下一章会展开;本章只需记住:type 是图的一部分,不是加载后的临时标签。

Context 只分配元数据

ggml_init 创建一个 Context。它内部是 bump allocator:张量对象、图对象、名字字符串从一块 mem_buffer 顺序切出去。Context 不能单独 free 某个 tensor。ggml_reset 只重置对象链表,不把整块内存还给系统;ggml_free 才拆掉整个池。

加载 GGUF 时必须把 no_alloc 设为 true。此时 Context 只建 ne / nb / type / 名字,data 为空,随后由 Backend buffer 绑定。llama.cpp 正是这条路径。若 no_alloc=false 且把 7B 权重量进 Context,元数据池会和权重抢同一块 bump 内存,既慢又容易一次就爆。

1
2
3
4
5
6
struct ggml_init_params params = {
.mem_size = 16 * 1024 * 1024,
.mem_buffer = NULL,
.no_alloc = true,
};
struct ggml_context * ctx = ggml_init(params);

这里的 16MB 是「图上有多少节点」的预算,不是「模型有多少 GB」。权重大小由 GGUF 与 gallocr 管,不要混为一谈。

算子 API 只建图

调用 ggml_mul_mat(ctx, w, x) 时,GGML 做的事情是:新分配一个结果张量,把它的 op 设成矩阵乘,把 src[0]src[1] 指到 wx,必要时在 op_params 里写入少量整数参数。没有 cuBLAS 调用,也没有 CPU 点积。ggml_ropeggml_rms_normggml_flash_attn_extggml_get_rowsggml_gluggml_mul_mat_id 同样如此。

这就是惰性执行。好处有三。第一,同一套 Python 或 C++ 建图代码可以在 CPU 调试、在 CUDA 跑生产,不必为每个设备写一套表达式。第二,调度器能在执行前看见整张图,才能做三遍 Backend 分配和跨设备 copy。第三,gallocr 需要拓扑序与引用计数,才能决定哪块中间激活可以原地复用。

对应的心智模型是:建图阶段在构造 DAG;ggml_build_forward_expand 把 DAG 拉成可执行的节点数组;graph_compute 一类 API 才沿着数组开火。

1
2
3
struct ggml_tensor * out = ggml_mul_mat(ctx, w, x);
struct ggml_cgraph * gf = ggml_new_graph(ctx);
ggml_build_forward_expand(gf, out);

build_forward_expand 会从根做 DFS,访问父节点,写入 nodes[] / leafs[],并维护 use_counts。叶子通常是权重与输入;中间节点才有 op。同一张量被两个下游使用时,use_counts 为 2,分配器就不能在第一个下游算完后立刻回收——这是下一章 in-place 条件的前置知识。

图还有 uid。调度器靠它判断「这次 decode 的图和上次是不是同一结构」。结构没变就可以复用已分配的 buffer;变了才 realloc。llama.cpp 之所以能在逐 token 路径上避免每步重新 malloc,前提就是建图稳定、uid 可复用。

三条执行路径

展开之后,计算仍不会自动开始。GGML 提供三条入口,不要混用:

1
2
3
4
5
6
7
8
ggml_graph_compute(ctx, cgraph, n_threads)
→ 纯 CPU,走 ggml-cpu 主循环

ggml_backend_graph_compute(backend, cgraph)
→ 单个 Backend,例如一张 CUDA 卡

ggml_backend_sched_graph_compute_async(sched, cgraph)
→ 多 Backend 调度,llama.cpp 生产路径

第一条适合 examples 和把某个 op 在 CPU 上对拍。第二条适合「所有张量已经在同一块设备内存」。第三条才会 split_graph、插入 copy 节点、按 n_copies=4 做 pipeline,并在返回后仍可能异步。异步路径必须 ggml_backend_sched_synchronize 之后才能读输出;pipeline 复用同一块 input 时尤其如此,否则下一 token 的写入会覆盖还没算完的上一批。

生产代码几乎总是:sched_reset → 建图 → build_forward_expandsched_alloc_graph → 填 input → sched_graph_compute_asyncsynchronize → 读带 OUTPUT 标志的 logits。

标志位如何改变图的命运

flags 不是调试装饰。GGML_TENSOR_FLAG_INPUT 提示调度器:这是用户写入的入口,通常落在优先级最低的 Backend(注册表里最后的 CPU),再按需拷到 GPU。GGML_TENSOR_FLAG_OUTPUT 告诉 gallocr:这块地址不能当中间激活回收,也不能被 in-place 算子覆盖。PARAMLOSS 给 ggml-opt 训练路径用;本系列推理主线用不到,但要知道反向图是 build_backward_expand,与前向 expand 成对,llama.cpp 解码不会走它。

一次最小前向长什么样

把上面拼成一条可读的时间线:

1
2
3
4
5
6
7
ggml_init(no_alloc=true)
→ 创建 weight / input 张量元数据
→ 绑定 GGUF 或 Backend buffer
→ out = ggml_mul_mat / rope / rms_norm / ...
→ ggml_build_forward_expand(gf, out)
→ 三条路径之一执行
→ 读取 OUTPUT 张量

中间任何一步都可能让人误以为「已经算完了」。最常见的错觉是打印 out->data 发生在 build_forward_expand 之后、graph_compute 之前:此时 data 要么是空,要么是上一次图留下的旧值。惰性库的调试顺序必须是「先问图有没有展开,再问 Backend 有没有 synchronize」。

和本栈其他层的接口

LLaMA-Factory 不会调用这些 API。它停在 HF。convert_hf_to_gguf 只负责把权重写成 GGUF,也不建推理图。真正逐层调用 ggml_mul_mat 的是 llama.cpp 的模型架构文件。LlamaIndex 连 GGML 头文件都看不到。因此本章的对象模型只在「推理进程内部」有效;对外仍然是 llama-server 的 HTTP。

若你从 PyTorch 过来,需要丢掉两个习惯。第一,x @ w 在 Python 里往往立刻算或至少立刻规划 GPU kernel;在 GGML 里它只是长出一条边。第二,PyTorch 的 tensor 自己拥有 storage 且可单独释放;GGML 的 tensor 元数据属于 Context,data 属于 Backend buffer,生命周期由 use_countsgallocr 共同决定。把这两点记住,下一章的内存复用和 GGUF no_alloc 才不会显得突兀。

建图时常见的三种误读

第一种误读是把 src[] 当成「函数参数的值」。src 存的是指针,指向图上已经存在的张量。改变某个叶子的 data 再重新 graph_compute,下游节点会看到新输入,不必重建整张图。llama.cpp 的 decode 正是靠这一点:权重叶子不变,input 与位置编码每步改内容,图结构复用。若每步都 ggml_new_graph 却忘记稳定 uid,调度器会认为来了一张新图,分配器跟着 realloc,延迟会从微秒跳到毫秒。

第二种误读是以为 GGML_MAX_DIMS=4 限制了「只能表示四维神经网络」。它限制的是单个张量的轴数。MoE 的专家维、Flash Attention 的头维,都挤在这四个轴里,靠 nenb 的约定来解释。超过四维的逻辑结构必须在建图代码里拆成多个张量或塞进 op_params,而不是幻想有第五个 ne[4]op_params 是一段固定长度的 int32 数组,ROPE 的 mode、归一化的近似开关都编码在这里,不是 C 结构体字段。读源码时不要去找 struct rope_params

第三种误读是把 ggml_build_forward_expand 当成「编译成某种字节码」。它不做算子融合,也不生成 PTX。它只做 DFS、去重、拓扑排序和引用计数。融合与 kernel 选择发生在各 Backend 的 graph_compute 内部:CUDA 在那里决定 mmvq 还是 mmq,CPU 在那里选 vec_dot 函数指针。因此「图长得一样」不等于「跑得一样快」——快慢属于下一章之后的 Backend 问题;本章只保证图是可调度的 DAG。

把三种误读排除后,惰性模型可以收成一句话:张量描述形状与来源,图描述依赖,执行器才碰硬件。本系列后面所有「为什么先 expand 再 compute」的句子,都建立在这一句上。调试时若结果不对,先数 cgraphn_nodesn_leafs 是否符合你手写的层数,再去怀疑 Backend。图都没展开完整,加速器再快也只是在算一张残缺的 DAG。

下一章讨论 GGML 的内存分配、量化体系与 GGUF。

文章互动

阅读 --

留言

0 条留言

正在加载留言…