计算图是由算子搭起来的。这一课,我们挑出 transformer 里最核心的几个算子——矩阵乘 mul_mat、归一化 rms_norm、位置编码 rope、带掩码的 soft_max_ext,看清它们各算什么、 输入输出的形状怎么对上,以及一个算子在 CPU 上"真正落地计算"的地方在哪。这一课把 L04 的注意力数学和 L09/L10 的图与执行, 用具体的算子串到了一起。
前三课我们一直在讲"容器和流程"——内存怎么放(L08)、图怎么搭(L09)、图怎么跑(L10),但始终没碰"每个算子具体在算什么"。 这一课补上这一块。不过要说明:我们不逐行去抠某个矩阵乘的循环(那是第六部分内核课的事),而是站在"会读、会搭网络"的高度,搞清楚四件事—— 这些算子各自做什么、形状怎么对、注意力怎么由它们拼成、以及它们在哪真正落地算。学完这一课,你再看 llama.cpp 里那些建图代码,就能大致读懂每一行在拼什么。
神经网络里绝大部分的计算量,都花在矩阵乘上——注意力、前馈层、输出投影,本质都是矩阵乘。所以 ggml_mul_mat 是当之无愧的头号算子。它的形状规则,是这一课最该记牢的一条:
先用一个具体的小例子看清这条规则:结果里的每个数,都是 A 的一行配 B 的一列、对应位置相乘再求和——于是内维 k 消失,只剩下两边各自的"另一维"。
看那两个高亮的 k:a 和 b 在 ne[0](内维)上必须相等,这个相等的维在相乘时被"消去",结果的形状由两者各自的"另一维"拼成。 落到源码(ggml/src/ggml.c 的 ggml_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
为什么矩阵乘这么重要,值得单拎出来讲?因为它又重又频繁。一个 7B 模型,每生成一个 token,要做几百次矩阵乘,每次都是几千乘几千的大矩阵相乘—— 模型的绝大部分参数(那几个 GB 的权重)都是以"矩阵乘里的那个权重矩阵"的身份存在的。所以你之前学的所有东西,到头来几乎都在为矩阵乘服务:量化(L06)是为了让权重矩阵更小、搬得更快, 后端(L07)是为了让矩阵乘算得更快,内存复用(L10)是为了腾地方装矩阵乘的中间结果。看懂 mul_mat,就看懂了推理的主战场。
把矩阵乘的形状这条线收个尾:在 ggml 里你会反复看到形如 cur = ggml_mul_mat(ctx, model.layers[i].wq, cur) 的代码——拿这一层的 Q 权重矩阵去乘当前的隐藏状态, 得到 Query。整座 transformer 的建图,骨架上就是一串这样的 mul_mat,中间穿插着归一化、rope、softmax。所以只要你能对着权重的形状,推出每个 mul_mat 的输出形状, 你就能顺着代码把整个模型的数据流"走"一遍。这正是这一课开头说的"会读、会搭网络"的具体含义——而它的核心,就是 mul_mat 这条形状规则。
除了矩阵乘,注意力层里还反复出现三个算子。把 L04 讲的注意力,用 ggml 算子串起来,大致是这样一条流水线:
先把输入归一化,稳住数值尺度(L04 说的"训练稳定"就靠它)。
投影出 Query / Key / Value 三个张量。
给 Q、K 注入位置信息(L04 说的 RoPE 旋转)。
算注意力分数、施加因果掩码、归一成权重(L04 的 -inf 掩码就在这)。
按权重把 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_rope 和 ggml_rope_ext 是同一族,后者多了一串参数(freq_base、freq_scale 等), 用来支持 YaRN 这类长上下文扩展技术——简单说,就是通过调整旋转的"频率",让一个原本只在 4K 上下文训练的模型,也能在几万 token 的长上下文上工作。你现在不必深究这些参数, 只要知道"位置编码也是可以调的,调它能换来更长的上下文",这个认识就够了。
最后破除一个常见困惑:一个算子在 ggml 里其实有两处代码,分工明确。一处负责"建图"(定义形状、填 op/src,L09),另一处负责"真正算"(在某个后端上跑数):
ggml_mul_mat(ctx, a, b):只定义结果张量的形状、填好 op 和 src,不算。每个算子在这里都有一个"构造函数"。
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 这类引擎能被广泛复用、又能持续优化的组织学基础。
下面三个问题,想深究的同学点开看;只想抓主线的可以先跳过。
因为 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 当场拦下。形状推导手熟了,你读再复杂的模型建图代码也不慌。
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 这类更激进的融合实现。
靠一个统一的枚举 + 派发机制。每个算子是 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 能跟上层出不穷的新模型,而不必每来一个就大改一遍引擎。
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.
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.
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
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.
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.
Besides matmul, three operators recur in the attention layer. Stringing L04's attention with ggml operators gives roughly this pipeline:
normalize the input first, stabilizing the numeric scale (L04's "training stability" rests on it).
project out the Query / Key / Value tensors.
inject position info into Q, K (L04's RoPE rotation).
compute attention scores, apply the causal mask, normalize to weights (L04's -inf mask is here).
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).
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.
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):
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.
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.
Three questions below; open them if you want depth, skip them if you only want the main line.
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.
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.
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.