🦙 llama.cpp 图解教程llama.cpp Visual Guide 第三部分 · ggml 引擎Part 3 · The ggml engine 11 / 40
第三部分 · ggml 引擎Part 3 · The ggml engine

核心算子Core operators

计算图是由算子搭起来的。这一课,我们挑出 transformer 里最核心的几个算子——矩阵乘 mul_mat、归一化 rms_norm、位置编码 rope、带掩码的 soft_max_ext,看清它们各算什么、 输入输出的形状怎么对上,以及一个算子在 CPU 上"真正落地计算"的地方在哪。这一课把 L04 的注意力数学和 L09/L10 的图与执行, 用具体的算子串到了一起。

前三课我们一直在讲"容器和流程"——内存怎么放(L08)、图怎么搭(L09)、图怎么跑(L10),但始终没碰"每个算子具体在算什么"。 这一课补上这一块。不过要说明:我们逐行去抠某个矩阵乘的循环(那是第六部分内核课的事),而是站在"会读、会搭网络"的高度,搞清楚四件事—— 这些算子各自做什么、形状怎么对、注意力怎么由它们拼成、以及它们在哪真正落地算。学完这一课,你再看 llama.cpp 里那些建图代码,就能大致读懂每一行在拼什么。

🔌 生活类比
算子就像一块块乐高积木:每块都有固定的凸点和凹槽(输入、输出的形状),只有形状对得上,两块才能拼在一起。 建一个模型,就是按形状把这些积木拼成一座塔;要是哪两块形状对不上,拼装(建图时的断言检查)当场就会失败、提醒你拼错了。 懂了每块积木的"接口形状",你就懂了怎么读、怎么搭一个网络。而所有积木里,矩阵乘是那块最大、最关键的底座,所以我们从它讲起。

头号算子:矩阵乘 mul_mat

神经网络里绝大部分的计算量,都花在矩阵乘上——注意力、前馈层、输出投影,本质都是矩阵乘。所以 ggml_mul_mat 是当之无愧的头号算子。它的形状规则,是这一课最该记牢的一条:

先用一个具体的小例子看清这条规则:结果里的每个数,都是 A 的一行配 B 的一列、对应位置相乘再求和——于是内维 k 消失,只剩下两边各自的"另一维"。

追踪一次矩阵乘:[k=3,m=2] x [k=3,n=2],看内维 k 怎么被"乘加求和"吃掉,只剩 [m,n](数字为示意)。
A ne=[3,2] B ne=[3,2] C = A·B ne=[2,2] 对 k=3 求和 1 0 2 -1 3 1 2 1 1 0 0 2 2 5 1 1 C[0,0] = 1·2 + 0·1 + 2·0 = 2
mul_mat 形状推导:内维 ne[0] 必须相等(被消去),结果取两者的"另一维"
akmne=[k, m]
bknne=[k, n]
结果mnne=[m, n],k 被消去

看那两个高亮的 ka 和 b 在 ne[0](内维)上必须相等,这个相等的维在相乘时被"消去",结果的形状由两者各自的"另一维"拼成。 落到源码(ggml/src/ggml.cggml_mul_mat / ggml_can_mul_mat):

// 断言: a 的内维 == b 的内维 (ne[0] 相等)
GGML_ASSERT(a->ne[0] == b->ne[0]);          // k 必须对上, 否则建图就报错
// 结果形状: 取 a 的"另一维"、b 的"另一维", 高两维来自 b
ne = { a->ne[1], b->ne[1], b->ne[2], b->ne[3] };  // 结果类型固定 F32
⚠ 注意
这里有个容易栽跟头的点:ggml 的形状规则,读起来和你数学课上学的"行 × 列"方向是反的。原因是 L05 讲过的——ggml 行优先、ne[0] 是最内维,所以"内维相等"对应的其实是数学里"左矩阵的列数 == 右矩阵的行数"。只要牢记 L05 那句口诀"ne[0] 永远是最贴着内存、变化最快的那一维",就不会把行当成列。此外,结果的高两维(ne[2]ne[3])来自 b,且支持广播(a 的对应维可以是 b 的整数分之一),这正是多头注意力里"一组权重作用于多个头"的实现方式。

为什么矩阵乘这么重要,值得单拎出来讲?因为它又重又频繁。一个 7B 模型,每生成一个 token,要做几百次矩阵乘,每次都是几千乘几千的大矩阵相乘—— 模型的绝大部分参数(那几个 GB 的权重)都是以"矩阵乘里的那个权重矩阵"的身份存在的。所以你之前学的所有东西,到头来几乎都在为矩阵乘服务:量化(L06)是为了让权重矩阵更小、搬得更快, 后端(L07)是为了让矩阵乘算得更快,内存复用(L10)是为了腾地方装矩阵乘的中间结果。看懂 mul_mat,就看懂了推理的主战场。

🔬 细节 / 源码对应
再多说一句形状里那个"消去"。为什么内维相等、还被消去?因为矩阵乘的本质,就是拿 a 的一行和 b 的一行(在 ggml 的布局下)逐元素相乘再求和——那条被"相乘求和"吃掉的维,就是内维 k。它在结果里不复存在,只留下 a、b 各自的"另一维"组成结果的形状。理解了"k 被求和吃掉",你就明白为什么两个 [k, ...] 的张量乘出来是 [m, n],而不是别的——这不是死记的规则,而是"求和把一维压没了"的自然结果。

把矩阵乘的形状这条线收个尾:在 ggml 里你会反复看到形如 cur = ggml_mul_mat(ctx, model.layers[i].wq, cur) 的代码——拿这一层的 Q 权重矩阵去乘当前的隐藏状态, 得到 Query。整座 transformer 的建图,骨架上就是一串这样的 mul_mat,中间穿插着归一化、rope、softmax。所以只要你能对着权重的形状,推出每个 mul_mat 的输出形状, 你就能顺着代码把整个模型的数据流"走"一遍。这正是这一课开头说的"会读、会搭网络"的具体含义——而它的核心,就是 mul_mat 这条形状规则。

🌍 宏观理解
这里也顺势点明 ggml 的一个取舍:它的算子不像有些框架那样"什么都能广播、什么形状都自动对齐",而是把形状约束定得相当严格,对不上就当场断言失败。为什么宁可严格、也不要"智能地自动适配"?因为推理引擎最怕悄悄出错——一个被自动广播"凑合"过去的形状错误,可能让模型输出一堆看似正常实则错误的结果,极难排查。严格的断言把错误挡在建图阶段、暴露在第一现场,反而让整个系统更可靠。这是性能工程里常见的态度:宁可早失败、响亮地失败,也不要带病运行。

三个常客:rms_norm / rope / soft_max_ext

除了矩阵乘,注意力层里还反复出现三个算子。把 L04 讲的注意力,用 ggml 算子串起来,大致是这样一条流水线:

1

rms_norm(x)

先把输入归一化,稳住数值尺度(L04 说的"训练稳定"就靠它)。

2

mul_mat(Wq/Wk/Wv, ·)

投影出 Query / Key / Value 三个张量。

3

rope(q, k)

给 Q、K 注入位置信息(L04 说的 RoPE 旋转)。

4

soft_max_ext(scores, mask)

算注意力分数、施加因果掩码、归一成权重(L04 的 -inf 掩码就在这)。

5

mul_mat(V, weights)

按权重把 Value 加权汇总,得到注意力输出。

注意上面流水线里 mul_mat 出现了好几次——投影 Q/K/V 是三次 mul_mat、最后按权重汇总 Value 又是一次。这正印证了前面说的"矩阵乘是主战场":一个注意力层里, 真正吃算力的几乎全是这些 mul_mat,而 rms_norm、rope、softmax 更像是穿插其间的"调味"步骤,单独看都不重,但少了谁注意力都不对。把这条流水线和 L04 的注意力数学对照着看, 你会发现"数学公式"和"ggml 算子序列"几乎是一一对应的——这也是为什么说看懂算子,就看懂了模型怎么落地成代码。

这三个常客的签名都很直白(ggml/include/ggml.h):

ggml_rms_norm(ctx, a, eps);                       // 按最后一维做 RMS 归一化, eps 防止除零
ggml_rope_ext(ctx, a, pos, ff, n_dims, mode, ...); // 按位置 pos 旋转, 注入相对位置信息
ggml_soft_max_ext(ctx, a, mask, scale, max_bias); // 融合: softmax(a*scale + mask)

逐个一句话:rms_norm 把一行向量按其均方根缩放到稳定范围,比 LayerNorm 更省(不用算均值);rope 不是给位置加一个"序号向量", 而是按位置旋转 Q、K,让注意力分数自带"两个 token 相距多远"的信息;soft_max_ext 是个融合算子,把"乘缩放系数 + 加掩码 + softmax"三步并成一次, 省内存又省带宽。注意 scale 通常是 1/sqrt(d)(防止分数过大)、mask 装的就是因果掩码(未来位置为 -inf)。

🔬 细节 / 源码对应
为什么要把这三个算子单独点出来?因为它们体现了 ggml 算子设计的两个常见手法。一是融合soft_max_ext 把本可以拆成三四个算子的事(缩放、加掩码、求指数、归一)压成一个,少建几个中间张量、少搬几趟数据——在 decode 这种"带宽比算力更紧张"的场景(L04 说过),融合的收益尤其明显。二是专用化:transformer 几乎离不开归一化和位置编码,ggml 干脆为它们提供 rms_normrope_ext 这样的专用算子,而不是让你用一堆基础算子拼。专用算子既好读、又给了后端"整段优化"的机会。这两手——该融合的融合、该专用的专用——贯穿 ggml 的算子库。

顺带澄清一个容易混的点:ggml_ropeggml_rope_ext 是同一族,后者多了一串参数(freq_basefreq_scale 等), 用来支持 YaRN 这类长上下文扩展技术——简单说,就是通过调整旋转的"频率",让一个原本只在 4K 上下文训练的模型,也能在几万 token 的长上下文上工作。你现在不必深究这些参数, 只要知道"位置编码也是可以调的,调它能换来更长的上下文",这个认识就够了。

🌍 宏观理解
把这三个算子和 mul_mat 放在一起,你就掌握了读懂任何 transformer 建图代码所需的"核心词汇表":mul_mat(投影、注意力打分、汇总)、rms_norm(每个子层前的归一化)、rope(位置)、soft_max_ext(注意力权重)。再加上加法(残差)、逐元素乘(门控)这几个基础算子,一个 transformer block 的建图代码,九成的行你都能认出来在干什么。剩下的一成是各家模型的小花样,但万变不离这套核心算子——这正是这一课最实在的收获。

一个算子,两处代码

最后破除一个常见困惑:一个算子在 ggml 里其实有两处代码,分工明确。一处负责"建图"(定义形状、填 op/src,L09),另一处负责"真正算"(在某个后端上跑数):

建图侧:ggml.c

ggml_mul_mat(ctx, a, b):只定义结果张量的形状、填好 op 和 src,不算。每个算子在这里都有一个"构造函数"。

计算侧:ggml-cpu / ggml-cuda …

ggml_compute_forward_mul_mat(...)真正把矩阵乘算出来。CPU 用 SIMD、CUDA 用 GPU kernel,各后端各写一份。

这两处通过 enum ggml_op 这个"算子编号"对接:建图时把编号记在 tensor->op 里,执行时后端用一个大 switch(op) 把每个节点派发到对应的 ggml_compute_forward_*(CPU 端在 ggml/src/ggml-cpu/)。所以"算子很多"并不可怕——它们共享同一套建图与派发框架, 加一个新算子,主要就是加一个 enum 值 + 写一份 forward 实现。这种"声明与实现分离"的设计,正是同一张图能在 CPU、CUDA、Metal 上各自高效跑起来的根本。

这个"两处代码"的分工,回头看也解释了前面几课的很多设计。L09 说算子函数"只填 op/src 不计算"——那是因为它只是建图侧,计算侧的代码根本不在那儿。 L10 说后端"逐节点 compute"——那个 compute,就是在计算侧按 op 派发、逐个调 forward。所以建图侧和计算侧,恰好对应了 L09 的"建图"和 L10 的"执行"两个阶段; 一个算子横跨这两个阶段,在建图侧露个脸(定形状)、在计算侧出全力(真算)。把这条线理顺,ggml 的整个执行流程在你脑子里就串成一根完整的链了。

这也是为什么 ggml 能把模型逻辑和硬件加速彻底分开:写一个新模型,你只在建图侧用现成算子拼一拼,完全不碰任何 CPU/GPU 的计算代码; 而优化某个算子在某种硬件上的速度,你只改计算侧那一份 forward,不影响任何模型。这种"模型作者和内核作者各管一摊、互不打扰"的分工,是 ggml 这类引擎能被广泛复用、又能持续优化的组织学基础。

深入一点(选读)

下面三个问题,想深究的同学点开看;只想抓主线的可以先跳过。

1 为什么 mul_mat 的形状规则看起来和数学反着来? 点击展开

因为 ggml 是行优先ne[0] 是最内(最贴内存、变化最快)的维。数学里我们写"A(m×k) · B(k×n) = C(m×n)", 要求"A 的列数 k == B 的行数 k"。但在 ggml 里,那个连续的 k 维被放在了 ne[0],于是规则写成"a.ne[0] == b.ne[0]"。

换句话说,数学的"行/列"和 ggml 的 ne 维度顺序是反过来的——这正是 L05 那个"维度顺序和 PyTorch 相反"的坑在算子层的体现。 记住 L05 的口诀"ne[0] 最贴内存",再看任何 ggml 算子的形状约束,都不会再绕晕。

给个实操建议:读 ggml 建图代码时,把每个张量的 ne 在草稿纸上标出来,顺着算子一个个推导形状,遇到 mul_mat 就检查"两个内维对上没有"。 这是 ggml 编程最有效的排错法——大多数建图 bug,都是某处形状对不上、被那句 GGML_ASSERT 当场拦下。形状推导手熟了,你读再复杂的模型建图代码也不慌。

2 soft_max_ext 的 mask 和 scale 到底干嘛? 点击展开

scale缩放系数,通常取 1/sqrt(d)(d 是每个头的维度)。注意力分数是 Q 和 K 的点积,维度越高、点积越容易变得很大, softmax 后会过于"尖锐"(几乎一边倒);先乘一个 1/sqrt(d) 把分数压回合理范围,梯度和数值都更稳。

mask 则是把因果掩码加进分数:未来位置加上 -inf,softmax 后权重就变成 0(L04 讲过)。 max_bias 控制 ALiBi 这类相对位置偏置,不用时为 0。soft_max_ext 把"乘 scale、加 mask、做 softmax"融合成一个算子, 避免了生成多个庞大的中间张量——这是推理引擎里很常见的"算子融合"优化。

顺带把融合这件事说透一点。不融合的话,softmax 这一步要先建一个"乘了 scale 的张量"、再建一个"加了 mask 的张量"、再建一个"算了指数的张量"……每一步都要在内存里 实打实地写出一个和分数矩阵一样大的中间结果,既占内存又费带宽。融合算子则把这几步在一个循环里一气呵成,中间值只在寄存器/缓存里转一圈,根本不落地成大张量。 对注意力这种"分数矩阵随上下文长度平方增长"的算子,融合省下的内存和带宽相当可观——这也是为什么 llama.cpp 还有 flash-attention 这类更激进的融合实现。

3 算子这么多,ggml 怎么管得过来? 点击展开

靠一个统一的枚举 + 派发机制。每个算子是 enum ggml_op 里的一个值(GGML_OP_MUL_MAT、GGML_OP_SOFT_MAX……); 建图时这个值被记进 tensor->op。执行时,后端遍历每个节点,用一个大 switch(node->op) 跳到对应的 ggml_compute_forward_* 实现。

所以加一个新算子的工作量是可控的:① 在 enum 里加一个值;② 写一个建图构造函数(定形状、填 src);③ 在每个你关心的后端里写一份 forward 实现,并接进那个 switch。 框架的其它部分(建图、内存规划、调度)完全不用动。这种"开放扩展、封闭修改"的结构,是 ggml 能持续长出几百个算子、还不乱套的原因。

这套机制也解释了为什么 ggml 能支持那么多不同架构的模型。Llama、Qwen、Mistral、Gemma…… 这些模型的差异,本质上就是"用哪些算子、按什么顺序拼"—— 而它们用到的算子,绝大多数是共享的同一批(矩阵乘、归一化、注意力那几样)。所以新增一个模型架构,往往一个新算子都不用加,只是在建图侧换个拼法; 偶尔遇到某个架构有独特设计,才补一两个新算子。正是这个共享的算子库,让 llama.cpp 能跟上层出不穷的新模型,而不必每来一个就大改一遍引擎。

✅ 关键要点
  • mul_mat 要求内维 ne[0] 相等(被消去),结果 ne={a.ne[1], b.ne[1], ...},类型 F32,高维支持广播。
  • 形状规则读起来和数学"行×列"方向相反,因为 ggml 行优先、ne[0] 最内(L05 的坑)。
  • rms_norm 稳数值、rope 注入位置、soft_max_ext 融合"缩放+掩码+softmax"成注意力权重。
  • 每个算子两处代码:建图侧(ggml.c 定形状/填 op/src)+ 计算侧(后端的 ggml_compute_forward_* 真算)。
  • 执行靠 enum ggml_op + 大 switch(op) 派发;加新算子 = 加 enum + 写 forward。
💡 设计洞察
把一个算子拆成"声明形状"和"各后端各自实现"两半——前者让建图轻量、还能在拼装时当场查错,后者让同一个算子在 CPU/CUDA/Metal 上各有最优实现。 模型逻辑只写一遍、硬件加速写多份,正是这种声明与实现解耦的红利。下一课,我们钻进这些算子真正吃下去的"料"——量化格式的字节细节。

🧪 自测 · 想一想为什么这么设计

1. ggml_mul_mat(a, b) 对形状的核心要求是?
  1. 没有任何要求
  2. a 和 b 的内维 ne[0] 必须相等(这一维在相乘时被消去)
  3. a 和 b 形状必须完全相同
  4. b 必须是方阵
看答案与解析 点击展开
答案:B。mul_mat 要求 a->ne[0]==b->ne[0];结果 ne={a.ne[1], b.ne[1], ...}。因 ggml 行优先、ne[0] 最内,这条规则方向和数学“行×列”相反。
2. soft_max_ext 里的 mask 起什么作用?
  1. 缩放学习率
  2. 把权重量化
  3. 给分数加上掩码,例如把未来位置设为 -inf 以实现因果掩码
  4. 对输入做归一化
看答案与解析 点击展开
答案:C。soft_max_ext 融合 softmax(a*scale + mask):scale 通常 1/sqrt(d) 防止分数过大,mask 加因果掩码(未来位 -inf,softmax 后权重为 0)。
3. 为什么说一个 ggml 算子有“两处代码”?
  1. 训练和推理
  2. 调试版和发布版
  3. 一处在 ggml.c 建图、定 op/src 与输出形状;另一处在后端(如 ggml-cpu 的 compute_forward)真正计算
  4. 前端网页和后端服务器
看答案与解析 点击展开
答案:C。建图侧(ggml.c)只定形状、填 op/src,不算;计算侧(各后端的 ggml_compute_forward_*)真算。两者靠 enum ggml_op + switch(op) 对接。
4. 执行时,后端怎么知道每个节点该调哪个算子实现?
  1. 用一个大 switch(node->op) 按算子编号派发到对应的 ggml_compute_forward_*
  2. 靠文件名匹配
  3. 随机选一个
  4. 每次都重新编译
看答案与解析 点击展开
答案:A。建图时算子编号记在 tensor->op;执行时后端用 switch(op) 跳到对应实现。加新算子 = 加一个 enum 值 + 写一份 forward 并接进 switch。
💭 发散思考(没有标准答案,动手或动脑想想)
  • ggml 的 mul_mat 形状规则为什么读起来和你在数学课学的“行×列”方向相反?(提示:L05 的维度顺序)

A compute graph is built from operators. This lesson picks the core few in a transformer - matmul mul_mat, normalization rms_norm, position encoding rope, masked soft_max_ext - to see what each computes, how input and output shapes line up, and where an operator "actually lands and computes" on the CPU. This lesson strings L04's attention math and L09/L10's graph and execution together with concrete operators.

The last three lessons were all about "containers and flow" - how memory is placed (L08), how the graph is built (L09), how the graph runs (L10) - but never touched "what each operator actually computes". This lesson fills that in. To be clear: we will not pore over the loop of some matmul line by line (that is Part 6's kernel lesson); instead, from the height of "being able to read and build networks", we nail down four things - what these operators do, how shapes line up, how attention is assembled from them, and where they actually land and compute. After this lesson, the graph-building code in llama.cpp becomes mostly readable - you can tell what each line is assembling.

🔌 Analogy
Operators are like Lego bricks: each has fixed studs and sockets (input and output shapes), and only when shapes match can two bricks join. Building a model is assembling these bricks into a tower by shape; if two bricks' shapes do not match, assembly (the assertion check at graph-build time) fails on the spot, telling you the fit is wrong. Understand each brick's "interface shape" and you understand how to read and build a network. And of all the bricks, matmul is the biggest, most crucial base, so we start there.

The number-one operator: matmul mul_mat

In a neural network, the vast majority of compute goes into matrix multiplication - attention, feed-forward, output projection are all essentially matmuls. So ggml_mul_mat is the undisputed number-one operator. Its shape rule is the one thing this lesson is most worth memorizing:

See the rule in action with a small concrete example first: each output number is one row of A times one column of B - multiplied position by position, then summed - so the inner dim k vanishes and only each side's other dim is left.

Tracing one matmul: [k=3,m=2] x [k=3,n=2] - watch the inner dim k get eaten by multiply-and-sum, leaving [m,n] (numbers illustrative).
A ne=[3,2] B ne=[3,2] C = A.B ne=[2,2] sum over k=3 1 0 2 -1 3 1 2 1 1 0 0 2 2 5 1 1 C[0,0] = 1*2 + 0*1 + 2*0 = 2
mul_mat shape inference: the inner dim ne[0] must be equal (eliminated); the result takes each one's "other dim"
akmne=[k, m]
bknne=[k, n]
resultmnne=[m, n]; k eliminated

Look at the two highlighted k: a and b must be equal on ne[0] (the inner dim), this equal dim is "eliminated" in the multiply, and the result's shape is formed from each one's "other dim". In source (ggml_mul_mat / ggml_can_mul_mat in ggml/src/ggml.c):

// assert: a's inner dim == b's inner dim (ne[0] equal)
GGML_ASSERT(a->ne[0] == b->ne[0]);          // k must match, else graph-build errors
// result shape: take a's "other dim", b's "other dim", high dims from b
ne = { a->ne[1], b->ne[1], b->ne[2], b->ne[3] };  // result type is always F32
⚠ Heads-up
There is a tripping point here: ggml's shape rule reads in the opposite direction from the "rows x columns" you learned in math class. The reason is from L05 - ggml is row-major, ne[0] is the innermost dim, so "inner dims equal" actually corresponds to math's "left matrix's columns == right matrix's rows". Just keep L05's mnemonic "ne[0] is always the memory-adjacent, fastest-changing dim" and you will not mistake rows for columns. Also, the result's high dims (ne[2], ne[3]) come from b and support broadcasting (a's matching dim can be an integer fraction of b's) - exactly how "one set of weights applied to multiple heads" is implemented in multi-head attention.

Why is matmul so important it deserves its own section? Because it is both heavy and frequent. A 7B model does hundreds of matmuls per generated token, each a thousands-by-thousands matrix multiply - the vast majority of the model's parameters (those several GB of weights) exist as "the weight matrix in a matmul". So almost everything you have learned ultimately serves matmul: quantization (L06) to make weight matrices smaller and faster to move, backends (L07) to compute matmuls faster, memory reuse (L10) to make room for matmul intermediates. Understand mul_mat and you understand the main battlefield of inference.

🔬 Details / source
One more word on that "elimination" in the shape. Why is the inner dim equal and then eliminated? Because matrix multiply is essentially taking a row of a and a row of b (under ggml's layout) and multiplying element-wise then summing - the dim eaten by that "multiply-and-sum" is the inner dim k. It is gone in the result, leaving only a's and b's "other dim" to form the result shape. Once you get "k is eaten by the sum", you see why two [k, ...] tensors multiply into [m, n] and nothing else - not a rule to memorize but the natural result of "summing collapses one dim".

To wrap up the matmul-shape thread: in ggml you will repeatedly see code like cur = ggml_mul_mat(ctx, model.layers[i].wq, cur) - multiplying this layer's Q weight matrix by the current hidden state to get the Query. The whole transformer's graph build is, skeletally, a string of such mul_mats interleaved with normalization, rope, softmax. So as long as you can derive each mul_mat's output shape from the weight shapes, you can "walk" the entire model's data flow through the code. This is the concrete meaning of "reading and building networks" from the lesson's opening - and at its core is this one mul_mat shape rule.

🌍 Big picture
This is also a good moment to note a ggml trade-off: its operators do not "broadcast anything, auto-align any shape" like some frameworks; instead it sets shape constraints quite strictly, asserting failure on the spot when things do not match. Why prefer strict over "smart auto-adaptation"? Because an inference engine fears silent errors most - a shape error papered over by auto-broadcast could make the model output a pile of plausible-looking but wrong results, extremely hard to trace. Strict assertions block errors at graph-build, exposing them at the first scene, making the whole system more reliable. This is a common attitude in performance engineering: fail early and loudly rather than run sick.

Three regulars: rms_norm / rope / soft_max_ext

Besides matmul, three operators recur in the attention layer. Stringing L04's attention with ggml operators gives roughly this pipeline:

1

rms_norm(x)

normalize the input first, stabilizing the numeric scale (L04's "training stability" rests on it).

2

mul_mat(Wq/Wk/Wv, x)

project out the Query / Key / Value tensors.

3

rope(q, k)

inject position info into Q, K (L04's RoPE rotation).

4

soft_max_ext(scores, mask)

compute attention scores, apply the causal mask, normalize to weights (L04's -inf mask is here).

5

mul_mat(V, weights)

weight-sum the Values by the weights to get the attention output.

Note that mul_mat appears several times in the pipeline above - projecting Q/K/V is three mul_mats, and the final weight-sum of Values is another. This confirms the earlier "matmul is the main battlefield": in one attention layer, almost all the real compute is these mul_mats, while rms_norm, rope, softmax are more like "seasoning" steps interspersed - each light on its own, yet attention is wrong without any of them. Compare this pipeline with L04's attention math and you find "the formula" and "the ggml operator sequence" map almost one-to-one - which is why understanding operators means understanding how a model lands as code.

These three regulars have plain signatures (ggml/include/ggml.h):

ggml_rms_norm(ctx, a, eps);                       // RMS-normalize over the last dim, eps avoids divide-by-zero
ggml_rope_ext(ctx, a, pos, ff, n_dims, mode, ...); // rotate by position pos, injecting relative position
ggml_soft_max_ext(ctx, a, mask, scale, max_bias); // fused: softmax(a*scale + mask)

One line each: rms_norm scales a row vector by its root-mean-square into a stable range, cheaper than LayerNorm (no mean to compute); rope does not add an "index vector" per position but rotates Q, K by position, so attention scores carry "how far apart two tokens are"; soft_max_ext is a fused operator merging "multiply scale + add mask + softmax" into one, saving memory and bandwidth. Note scale is usually 1/sqrt(d) (to keep scores from getting too large), and mask holds the causal mask (future positions at -inf).

🔬 Details / source
Why single out these three operators? Because they exemplify two common techniques of ggml operator design. One is fusion: soft_max_ext compresses what could be three or four operators (scale, add mask, exponentiate, normalize) into one, building fewer intermediate tensors and moving data fewer times - in decode, where "bandwidth is tighter than compute" (from L04), fusion's payoff is especially clear. The other is specialization: transformers can hardly do without normalization and position encoding, so ggml just provides dedicated operators like rms_norm and rope_ext rather than making you assemble them from basic ops. Dedicated operators are both readable and give the backend a chance to "optimize the whole segment". These two moves - fuse what should be fused, specialize what should be specialized - run through ggml's operator library.

A clarification in passing: ggml_rope and ggml_rope_ext are the same family, the latter with a string of extra parameters (freq_base, freq_scale, etc.) supporting long-context extension techniques like YaRN - in short, by adjusting the rotation "frequency", a model originally trained at 4K context can work at tens of thousands of tokens. You need not study these parameters now; just knowing "position encoding is tunable, and tuning it buys longer context" is enough.

🌍 Big picture
Put these three operators together with mul_mat and you have the "core vocabulary" needed to read any transformer's graph-build code: mul_mat (projection, attention scoring, summing), rms_norm (normalization before each sub-layer), rope (position), soft_max_ext (attention weights). Add a few basic operators like add (residual) and element-wise multiply (gating), and you can recognize ninety percent of the lines in a transformer block's graph-build code. The remaining ten percent are each model's little tweaks, but they never stray from this core operator set - exactly this lesson's most practical takeaway.

One operator, two pieces of code

Finally, dispel a common confusion: an operator in ggml actually has two pieces of code, with a clear division. One does "graph building" (define the shape, fill op/src, L09), the other does "actually compute" (run the numbers on some backend):

build side: ggml.c

ggml_mul_mat(ctx, a, b): only defines the result tensor's shape, fills op and src, no compute. Every operator has a "constructor" here.

compute side: ggml-cpu / ggml-cuda ...

ggml_compute_forward_mul_mat(...): actually computes the matmul. CPU with SIMD, CUDA with GPU kernels, one per backend.

These two meet through enum ggml_op, the "operator number": graph-build records the number in tensor->op, and at execution the backend uses one big switch(op) to dispatch each node to the matching ggml_compute_forward_* (CPU side in ggml/src/ggml-cpu/). So "many operators" is not scary - they share one graph-build and dispatch framework, and adding a new operator is mainly adding an enum value + writing a forward implementation. This "declaration separated from implementation" design is the very reason the same graph can run efficiently on CPU, CUDA, and Metal each.

This "two pieces of code" division, in hindsight, explains much of the earlier lessons' design. L09 said operator functions "only fill op/src, no compute" - that is because they are only the build side; the compute-side code is simply not there. L10 said the backend "computes node by node" - that compute is the compute side dispatching by op and calling each forward. So the build side and compute side correspond exactly to L09's "build" and L10's "execute" phases; one operator spans both phases, showing its face on the build side (define shape) and going all-out on the compute side (actually compute). Straighten this thread and ggml's whole execution flow strings into one complete chain in your head.

This is also why ggml can fully separate model logic from hardware acceleration: writing a new model, you only assemble ready-made operators on the build side, touching no CPU/GPU compute code at all; optimizing some operator's speed on some hardware, you only change that one forward on the compute side, affecting no model. This division - "model authors and kernel authors each mind their own patch, without disturbing each other" - is the organizational basis for why engines like ggml can be widely reused and continuously optimized.

Going deeper (optional)

Three questions below; open them if you want depth, skip them if you only want the main line.

1 Why does mul_mat's shape rule look reversed from math? click to expand

Because ggml is row-major and ne[0] is the innermost (memory-adjacent, fastest-changing) dim. In math we write "A(m x k) . B(k x n) = C(m x n)", requiring "A's columns k == B's rows k". But in ggml that contiguous k dim sits at ne[0], so the rule is written "a.ne[0] == b.ne[0]".

In other words, math's "rows/columns" and ggml's ne dimension order are reversed - exactly L05's "dimension order opposite to PyTorch" trap, surfacing at the operator level. Keep L05's mnemonic "ne[0] is memory-adjacent" and any ggml operator's shape constraint stops being confusing.

A practical tip: when reading ggml graph-build code, jot each tensor's ne on scratch paper and derive shapes operator by operator, checking at each mul_mat "do the two inner dims match". This is the most effective debugging method in ggml programming - most graph-build bugs are a shape mismatch somewhere, caught on the spot by that GGML_ASSERT. Once shape inference is second nature, you read even the most complex model graph-build code without panic.

2 What exactly do soft_max_ext's mask and scale do? click to expand

scale is a scaling factor, usually 1/sqrt(d) (d is the per-head dimension). Attention scores are dot products of Q and K; the higher the dimension, the larger dot products tend to get, making softmax too "sharp" (nearly one-sided); multiplying by 1/sqrt(d) first pulls scores back to a reasonable range, stabilizing gradients and values.

mask adds the causal mask into the scores: future positions get -inf, so their weights become 0 after softmax (from L04). max_bias controls ALiBi-style relative-position bias, 0 when unused. soft_max_ext fuses "multiply scale, add mask, softmax" into one operator, avoiding several large intermediate tensors - a very common "operator fusion" optimization in inference engines.

Let me spell out fusion a bit more. Without fusion, the softmax step would build a "scaled tensor", then a "mask-added tensor", then an "exponentiated tensor"... each step writing out, for real in memory, an intermediate as big as the score matrix - costing memory and bandwidth. A fused operator does these steps in one loop, all at once, with intermediates only circling through registers/cache, never materializing as big tensors. For attention, whose "score matrix grows with the square of context length", the memory and bandwidth fusion saves are considerable - which is also why llama.cpp has even more aggressive fused implementations like flash-attention.

3 So many operators - how does ggml manage them all? click to expand

With a uniform enum + dispatch mechanism. Each operator is a value in enum ggml_op (GGML_OP_MUL_MAT, GGML_OP_SOFT_MAX...); at graph build this value is recorded in tensor->op. At execution the backend walks each node and a big switch(node->op) jumps to the matching ggml_compute_forward_* implementation.

So adding a new operator is contained work: 1. add a value to the enum; 2. write a graph-build constructor (define the shape, fill src); 3. write a forward implementation in each backend you care about and wire it into that switch. The rest of the framework (graph build, memory planning, scheduling) needs no change at all. This "open for extension, closed for modification" structure is why ggml can keep growing hundreds of operators without falling apart.

This mechanism also explains why ggml can support so many different model architectures. Llama, Qwen, Mistral, Gemma... the differences among these models are essentially "which operators, assembled in what order" - and the operators they use are mostly the same shared set (matmul, normalization, the attention pieces). So adding a new model architecture often needs not a single new operator, just a different assembly on the build side; only occasionally, when an architecture has a unique design, do you add one or two new operators. It is this shared operator library that lets llama.cpp keep up with the endless stream of new models without overhauling the engine for each one.

✅ Key points
  • mul_mat requires equal inner dim ne[0] (eliminated); result ne={a.ne[1], b.ne[1], ...}, type F32, high dims broadcast.
  • The shape rule reads reversed from math's "rows x columns", because ggml is row-major and ne[0] is innermost (L05's trap).
  • rms_norm stabilizes values, rope injects position, soft_max_ext fuses "scale + mask + softmax" into attention weights.
  • Each operator has two pieces of code: build side (ggml.c defines shape / fills op/src) + compute side (the backend's ggml_compute_forward_* actually computes).
  • Execution dispatches via enum ggml_op + big switch(op); adding an operator = add an enum + write a forward.
💡 Design insight
Splitting an operator into "declare the shape" and "each backend implements its own" - the former keeps graph-building light and catches errors right at assembly, the latter lets the same operator have an optimal implementation on CPU/CUDA/Metal. Model logic written once, hardware acceleration written several times - exactly the dividend of this declaration-implementation decoupling. Next lesson, we dig into the "material" these operators actually consume - the byte details of quantization formats.

🧪 Self-test - think about the design

1. What is the core shape requirement of ggml_mul_mat(a, b)?
  1. There is no requirement
  2. a and b must have equal inner dim ne[0] (this dim is eliminated in the multiply)
  3. a and b must have identical shapes
  4. b must be square
Show answer & explanation click to expand
Answer: B. mul_mat requires a->ne[0]==b->ne[0]; result ne={a.ne[1], b.ne[1], ...}. Since ggml is row-major with ne[0] innermost, this reads reversed from math's rows x columns.
2. What does the mask in soft_max_ext do?
  1. Scales the learning rate
  2. Quantizes the weights
  3. Adds a mask to the scores, e.g. setting future positions to -inf to implement the causal mask
  4. Normalizes the input
Show answer & explanation click to expand
Answer: C. soft_max_ext fuses softmax(a*scale + mask): scale is usually 1/sqrt(d) to keep scores in range, mask adds the causal mask (future at -inf, weight 0 after softmax).
3. Why is a ggml operator said to have "two pieces of code"?
  1. Training and inference
  2. Debug and release builds
  3. One in ggml.c builds the graph, defining op/src and output shape; the other in a backend (e.g. ggml-cpu's compute_forward) actually computes
  4. Frontend web page and backend server
Show answer & explanation click to expand
Answer: C. The build side (ggml.c) only defines the shape and fills op/src, no compute; the compute side (each backend's ggml_compute_forward_*) actually computes. They meet via enum ggml_op + switch(op).
4. At execution, how does the backend know which operator implementation to call for each node?
  1. A big switch(node->op) dispatches by operator number to the matching ggml_compute_forward_*
  2. By matching file names
  3. It picks one at random
  4. It recompiles each time
Show answer & explanation click to expand
Answer: A. The operator number is recorded in tensor->op at build; at execution the backend uses switch(op) to jump to the implementation. Adding an operator = add an enum value + write a forward wired into the switch.
💭 Open questions (no single right answer - just think or try)
  • Why does ggml's mul_mat shape rule read reversed from the "rows x columns" you learned in math? (hint: L05's dimension order)