有了加载好的权重(L14)和架构超参(L15),终于可以把它们接成一张真正能算的前向计算图了。这一课讲 llama-graph:它提供 build_attn/build_ffn/build_norm 这些"标准件",每个架构在 src/models/<arch>.cpp 里决定"按什么顺序把它们拼起来",最终产出一张第三部分讲过的 ggml_cgraph(L09)。
这一课是承上启下的枢纽:上面(L14/L15)把模型整理成了"有名有姓、有形有状"的张量集合,下面(L09/L10/L11)是 ggml 怎么建图、怎么执行、怎么算每个算子。这一课正是把两端接起来的那道桥—— 它把"一个具体模型"翻译成"一张 ggml 计算图"。读懂它,你就看清了 llama.cpp 是怎么把一堆权重变成"能跑"的。
建图的总入口是 llama_model::build_graph。它本身很薄,主要做一件事:把活派发给当前架构。因为不同架构(llama、qwen2…)的前向流程不一样,所以真正的建图逻辑放在每个架构各自的文件 src/models/<arch>.cpp 里。
落到源码(简化自 src/llama-model.cpp):
// 简化自 src/llama-model.cpp ggml_cgraph * llama_model::build_graph(const llm_graph_params & p) const { auto llm = build_arch_graph(p); // 虚: 派发到 src/models/<arch>.cpp // ... build_pooling / build_dense_out ... return llm->res->get_gf(); // ggml_cgraph(L09) }
这里有两层抽象要分清:build_graph 是稳定的总入口(不管什么架构,外面都调它);build_arch_graph 是虚函数,每个架构重写自己那一份。这正是 L15 表驱动思路的延续—— "哪种架构"决定调哪份建图代码。而所有架构的建图,都基于一个共同的基类 llm_graph_context,它身上挂着 build_attn/build_ffn/build_norm 这些人人可用的积木方法。
为什么把建图按架构拆成一个个文件,而不是写成一个巨大的 switch?因为每种架构的前向多少有点不同,分开写各自清爽、互不干扰;而共性(注意力、前馈、归一化怎么搭)则抽到基类的积木里复用。 这种"差异分文件、共性进基类"的组织,让加一个新架构基本只用新写一个 src/models/<arch>.cpp,不必碰别人。
那 build_graph 什么时候被调用?答案是每一步推理都调一次——下一课会讲的 llama_decode,内部第一件事就是为这一步搭出计算图。听起来很费:每生成一个 token 都要重搭一次图? 其实不然,图的"结构"很轻(只是一串算子的声明、不含计算),搭起来很快;而且对形状相同的步骤,这张图还能被复用,不必每次从头来。
传给 build_graph 的 llm_graph_params 里,装着搭这一步图所需的上下文:这一步要处理哪些 token(来自 L18 的 batch)、KV cache 当前状态、用哪个后端等等。换句话说,build_graph 不是凭空搭图,而是针对"这一步要算什么"搭出恰好够用的图。 这也是为什么 prefill(一次算整段 prompt)和 decode(一次算一个 token)虽然用同一套建图代码,搭出的图大小却不同。
代码里那行 build_pooling / build_dense_out 注释也值得一提:除了主体的 N 层 block,建图还会按需接上一些"收尾"步骤——比如做 embedding 任务时的池化、某些模型额外的输出层。这些是可选的尾巴,普通文本生成多半用不到,但它们和主体共用同一套建图框架,按架构和任务接进同一张图。
模型的主体是 N 个一模一样的 transformer block 叠起来。看清一层怎么搭,就看懂了整个前向。一层的骨架很固定:归一化 -> 注意力 -> 残差 -> 归一化 -> 前馈 -> 残差。
对输入做 RMSNorm,把数值稳到合理范围(L11 的归一化算子)。
一整套注意力:Q/K/V 投影 + rope 注入位置 + 读写 KV cache + softmax + 输出投影(L11/L04/L19)。
把注意力输出加回输入(残差连接),让信息和梯度都好流动。
再归一化一次,然后前馈网络(gate/up/down 三个矩阵乘,L11)。
把前馈输出加回去,得到这一层的输出,喂给下一层。
把这套骨架写成伪代码,几乎就是 src/models/llama.cpp 里循环体的样子:
# 伪代码: 一层的拼法(对应 src/models/llama.cpp 的循环体) cur = build_norm(inpL, attn_norm_w) # RMSNorm(L11) cur = build_attn(inp_attn, cur, wq,wk,wv,wo) # Q/K/V + rope + KV + softmax(L11/L04/L19) inpL = cur + inpL # 残差 cur = build_norm(inpL, ffn_norm_w) cur = build_ffn(cur, w_gate, w_up, w_down) # 前馈(L11 mul_mat) inpL = cur + inpL # 残差 -> 下一层
循环 0 到 n_layer()(L15 那个访问器方法),每层都拼这么一套,权重就用 L15 的命名约定按名字取(blk.il.attn_q.weight 等)。可以看到,"建图"在这一层非常机械——就是把固定的积木按固定顺序、喂以每层各自的权重,连成一长串算子。
这也把前面三课漂亮地收束了:L14 按名字备好张量、L15 给出每层该取哪些权重和多少层,L16 在这里把它们按 transformer 的结构真正拼起来。三课合起来,回答的就是"一个模型怎么从一堆权重变成一张能算的图"。
那两处残差相加(cur + inpL)别看简单,却是深层 transformer 能训练、能工作的关键之一:它让每一层在"原始输入"的基础上只学一个"增量",信息和梯度都能顺着这条捷径直通到底,不至于在几十层里衰减殆尽。建图时它就是一个普通的 ggml 加法算子,但其意义远不止一次加法。
值得点明 prefill 和 decode 在建图上的关系:两者用同一套建图代码,区别只在喂进去的 batch(L18)——prefill 一次喂整段 prompt 的多个 token、decode 一次只喂一个新 token。图的"形状"随 token 数变,但"结构"(每层怎么拼)完全一样。 这正是统一建图的好处:一套逻辑,既管"首次把 prompt 过一遍",又管"之后逐字生成"。
还要留意一点:建图代码里看不到具体的数值。build_attn、build_ffn 操作的全是"还没算的张量"——它们只是在说"把这个权重和那个输入做矩阵乘,结果叫 cur"。真正的浮点数要等 L10 执行时才填进去。 所以读建图代码,你读到的是数据流的形状,而不是数据本身——这也是 L09 惰性建图最直观的体感。
退一步看,这一整张前向图其实就是一个有向无环图(DAG):token 向量从输入叶子流入,经过一层层 block 的算子变换,最后流到 logits。每个算子是图上一个节点、箭头表示"谁喂给谁"。 L09 已经讲过这种图的本质,这一课只是让你看到:原来一个真实大模型的前向,落到图上就是这么一张结构清晰、层层堆叠的 DAG。
支撑这套拼装的,是 llm_graph_context 上的一批复用积木和图输入。积木是 build_attn/build_ffn/build_norm 这些方法;图输入是把"外部数据"接进图的入口。
图输入(llm_graph_input_*)是个容易被忽略却很关键的概念。一张计算图光有"算子"还不够,还得有"入口"——token 的词向量从哪进来、每个 token 的位置从哪来、KV cache 接在哪。 这些就是各种 build_inp_embd/build_inp_pos 建出来的输入节点。它们是图的"叶子"(L09 讲过的 leafs),每步推理把新数据填进去,图就能算出新结果。
而所有积木产出的,都是 L11 讲的 ggml 张量(mul_mat、soft_max_ext 等算子的输出)。一个 build_attn 调用,内部就是十几个 ggml 算子按注意力数学(L04)串成的一小段子图。 把许多这样的子图首尾相连,就长成了整个模型的前向图——这正是 L09 说的"算子串成图",只不过这里是站在 llama 层、按 transformer 结构有组织地串。
再说说图输入和"叶子"的关系。L09 讲过,图里分两类节点:算出来的节点和不计算、只被读取的叶子。权重是叶子(加载时就备好了,L14),而图输入(词向量、位置)也是叶子——只不过它们的数据是每步填新的。 建图时把这些叶子的位置占好,执行时把当前这一步的数据填进去,同一张图就能算出不同的结果。
你会注意到 build_attn 有好几个重载。为什么?因为注意力有不少变体:要不要用 KV cache(prefill 的某些路径不用、decode 必用)、是标准多头还是 GQA、用不用滑动窗口……与其每种各写一遍完整注意力, 不如把"公共骨架 + 可选差异"做成几个重载,让各架构按需挑用。这又是一处"把差异收进可选项、把共性沉淀成积木"的体现。
图输入被做成一族类(llm_graph_input_* 都派生自一个共同接口)也有讲究:不同的输入有不同的"填法"——词向量要按 token id 查表、位置要按当前进度生成、KV 掩码要按因果规则算。 把每种输入的"怎么填"封进各自的类,执行前统一调一遍,图就准备好了。这让"图里需要哪些外部输入"变得可扩展、可组合。
最后强调这一课最重要的一点:build_graph 只建、不算。它把算子的 op 和 src 填好(L09 的惰性建图),最后 get_gf() 交出一张 ggml_cgraph,至于真正逐节点执行,是 L10 后端的事。
build_graph 只填 op/src,产出一张 ggml_cgraph 结构——不碰数据、不做计算。写一遍,跨硬件通用。
后端 sched 拿这张图逐节点算,CPU / CUDA / Metal 各自把它跑快。换后端,不动建图。
这种"建图归建图、执行归执行"的分离,回报是巨大的:同一张图,能原封不动地跑在 CPU、CUDA、Metal 等天差地别的硬件上(L10 的后端调度),上层的模型逻辑只写一遍。也正因为建图不碰具体计算, 换一个后端、加一种新硬件,都不用动建图代码。L16 负责"拼出正确的图",L10 负责"在某种硬件上把图算快",两者各司其职,合起来才是完整的推理。
正因为建图只产出"结构"、不含数据,这张图在很多情况下还能被缓存复用:连续的 decode 步骤,每步都是"一个新 token",图的结构一模一样,于是引擎可以复用上一张图的骨架、只换喂进去的输入,省下反复搭图的开销。这是"惰性建图 + 结构与数据分离"带来的又一个红利。
所以这一课真正要带走的,是一个心智模型:模型推理 = 按架构把权重拼成一张计算图(L16)+ 在某后端上执行这张图(L10)。拼图的逻辑写一遍、能跑遍所有硬件;这就是 llama.cpp 既轻便又通用的根。把这句话记牢,第四部分后面几课其实都是在它的脉络上继续展开。
为了差异隔离。各架构的前向流程多少有别:有的注意力带偏置、有的 FFN 用不同激活、有的层间还插了别的东西。把每种架构的"拼法"放进各自的文件,改一个不会波及另一个,读起来也清爽——一个文件就是一种模型的完整前向。
而真正干活的积木(build_attn 等)是共享的,住在基类 llm_graph_context 里。所以这些架构文件大多很短:无非是"按这个架构的顺序,调几次共享积木、喂上对的权重"。共性进基类、差异进文件,是这套设计能容纳几十种架构还不乱的关键。
这也呼应了 L15:加一个新架构,建图这步往往就是新写一个不长的 src/models/<arch>.cpp,复用现成积木。只有遇到真正新颖的结构,才需要往基类加一两个新积木、甚至往 ggml 加一两个新算子(L11)。
它把 L04 的注意力数学,翻译成 L11 的一串算子。大致是:先用三次 mul_mat 把输入投影成 Q、K、V;给 Q、K 施加 rope 注入位置;把这一步的 K、V 写进 KV cache、再把历史的 K、V 读回来(L19)。
接着算注意力分数(Q 和 K 的矩阵乘)、用 soft_max_ext 加因果掩码并归一成权重、再用一次 mul_mat 按权重把 V 汇总;最后一次输出投影。一个 build_attn 调用,就这样把一整套注意力拼成了一段子图。
所以你之前学的东西在这里全用上了:L04 的数学是蓝本、L11 的算子是砖块、L19 的 KV cache 是让它每步只算新 token 的关键。build_attn 就是把这三者按正确顺序焊到一起的那个"组装工"。
靠 L09 的惰性建图。build_* 这些积木不计算,只创建张量、填好它的 op(我是哪种算子)和 src(我的输入是谁)。一圈拼下来,得到的是一张只描述了"算什么、依赖谁"的图,里面一个数都还没算。
然后 get_gf() 把这张 ggml_cgraph 交出去,由 L10 的后端按拓扑序逐节点真正计算。建图侧只关心"逻辑结构对不对",执行侧只关心"在这块硬件上怎么算得快"——两边的关注点完全分开。
好处是解耦带来的自由:模型逻辑(建图)写一遍,就能跑在所有后端上;要支持新硬件,只在执行侧加一个后端,建图代码一行不改。这正是 ggml/llama 这套分层最值钱的地方,也是它能同时跑在你的笔记本 CPU 和数据中心 GPU 上的根本原因。
With the loaded weights (L14) and the architecture hyperparameters (L15), we can finally wire them into a forward compute graph that actually computes. This lesson covers llama-graph: it provides "standard parts" like build_attn/build_ffn/build_norm, each architecture decides "in what order to assemble them" in src/models/<arch>.cpp, and the result is a ggml_cgraph from Part 3 (L09).
This lesson is the pivot connecting both sides: above (L14/L15) the model became a set of tensors "with names, shapes, and sizes"; below (L09/L10/L11) is how ggml builds graphs, executes, and computes each operator. This lesson is the bridge joining the two - it translates "a concrete model" into "a ggml compute graph". Read it and you see how llama.cpp turns a pile of weights into something "runnable".
The main entry for graph-building is llama_model::build_graph. It is itself thin, doing mainly one thing: dispatching the work to the current architecture. Because different architectures (llama, qwen2...) have different forward flows, the real graph-building logic lives in each architecture's own file src/models/<arch>.cpp.
In source (simplified from src/llama-model.cpp):
// simplified from src/llama-model.cpp ggml_cgraph * llama_model::build_graph(const llm_graph_params & p) const { auto llm = build_arch_graph(p); // virtual: dispatch to src/models/<arch>.cpp // ... build_pooling / build_dense_out ... return llm->res->get_gf(); // ggml_cgraph(L09) }
Two layers of abstraction to separate here: build_graph is the stable main entry (whatever the architecture, outside code calls it); build_arch_graph is a virtual function each architecture overrides. This continues L15's table-driven idea - "which architecture" decides which graph code runs. And every architecture's graph-building is based on a shared base class llm_graph_context, which carries the reusable block methods build_attn/build_ffn/build_norm.
Why split graph-building into files per architecture rather than one giant switch? Because each architecture's forward differs somewhat; writing them separately keeps each clean and non-interfering, while the commonality (how attention/FFN/norm are built) is lifted into reusable base-class blocks. This "differences per file, commonality in the base" organization means adding a new architecture is basically writing one new src/models/<arch>.cpp without touching others.
When is build_graph called? The answer is once per inference step - llama_decode (next lesson) builds this step's compute graph as its first act. Sounds costly: rebuild a graph for every generated token? Not really - the graph's "structure" is light (just a chain of operator declarations, no computation), so it builds fast; and for steps of the same shape the graph can be reused, no need to start from scratch each time.
The llm_graph_params passed to build_graph carries the context needed to build this step's graph: which tokens this step processes (from L18's batch), the KV cache's current state, which backend, and so on. In other words, build_graph does not build out of thin air but builds just enough graph for "what this step computes". This is why prefill (computing a whole prompt at once) and decode (one token at a time), though sharing the same graph code, build graphs of different sizes.
That build_pooling / build_dense_out comment in the code is worth a mention: beyond the main N blocks, graph-building also appends some "wrap-up" steps as needed - pooling for embedding tasks, extra output layers for certain models. These are optional tails, usually unused in plain text generation, but they share the same graph framework, attached to the same graph by architecture and task.
The model's body is N identical transformer blocks stacked. See clearly how one layer is built and you understand the whole forward. A layer's skeleton is fixed: norm -> attention -> residual -> norm -> feed-forward -> residual.
RMSNorm the input, stabilizing values into a sane range (L11's normalization operator).
A whole attention set: Q/K/V projection + rope position + read/write KV cache + softmax + output projection (L11/L04/L19).
Add the attention output back to the input (residual connection), letting information and gradients flow well.
Normalize again, then the feed-forward network (gate/up/down, three matmuls, L11).
Add the FFN output back, producing this layer's output, fed to the next layer.
Written as pseudocode, this skeleton is nearly the loop body in src/models/llama.cpp:
# pseudocode: one layer (mirrors the loop body in src/models/llama.cpp) cur = build_norm(inpL, attn_norm_w) # RMSNorm(L11) cur = build_attn(inp_attn, cur, wq,wk,wv,wo) # Q/K/V + rope + KV + softmax(L11/L04/L19) inpL = cur + inpL # residual cur = build_norm(inpL, ffn_norm_w) cur = build_ffn(cur, w_gate, w_up, w_down) # feed-forward(L11 mul_mat) inpL = cur + inpL # residual -> next layer
Loop 0 to n_layer() (L15's accessor method), assembling this set per layer, fetching weights by L15's naming convention (blk.il.attn_q.weight etc.). As you can see, "building the graph" at this level is very mechanical - feeding fixed blocks in a fixed order with each layer's own weights, chaining a long run of operators.
This nicely closes the last three lessons: L14 prepared tensors by name, L15 said which weights each layer takes and how many layers, and here L16 actually assembles them by the transformer structure. The three together answer "how a model goes from a pile of weights to a runnable graph".
Those two residual adds (cur + inpL) look trivial but are one key to deep transformers training and working: they let each layer learn only an "increment" on top of the "original input", so information and gradients flow straight down this shortcut without decaying away across dozens of layers. At graph time it is just an ordinary ggml add operator, but its meaning is far more than one addition.
Worth noting the relation between prefill and decode at graph time: both use the same graph code, differing only in the batch fed in (L18) - prefill feeds a whole prompt's many tokens at once, decode feeds one new token at a time. The graph's "shape" varies with token count, but its "structure" (how each layer is assembled) is identical. This is the benefit of unified graph-building: one logic handles both "passing the prompt through once" and "generating word by word afterward".
One more thing: no concrete numbers appear in the graph code. build_attn, build_ffn operate entirely on "not-yet-computed tensors" - they merely say "matmul this weight with that input, call the result cur". The actual floats are filled in only when L10 executes. So reading graph code, what you read is the shape of the data flow, not the data itself - the most intuitive feel of L09's lazy build.
Step back and this whole forward graph is really a directed acyclic graph (DAG): token vectors flow in from input leaves, transform through layer after layer of block operators, and finally flow to logits. Each operator is a node, arrows mean "who feeds whom". L09 covered the essence of such graphs; this lesson just lets you see that a real large model's forward, landed on a graph, is exactly such a clearly-structured, layer-stacked DAG.
Underpinning this assembly is a set of reusable blocks and graph inputs on llm_graph_context. The blocks are methods like build_attn/build_ffn/build_norm; the graph inputs are the entry points that wire "external data" into the graph.
Graph inputs (llm_graph_input_*) are an easily-overlooked but crucial concept. A compute graph needs more than "operators" - it needs "entry points": where token vectors enter, where each token's position comes from, where the KV cache attaches. These are the input nodes built by build_inp_embd/build_inp_pos and friends. They are the graph's "leaves" (the leafs from L09); each inference step fills new data into them, and the graph computes a new result.
And everything the blocks produce are the ggml tensors from L11 (outputs of operators like mul_mat, soft_max_ext). One build_attn call is internally a small subgraph of a dozen-odd ggml operators chained by the attention math (L04). Connect many such subgraphs end to end and you grow the model's entire forward graph - exactly L09's "operators chained into a graph", only here from the llama layer, organized by the transformer structure.
More on graph inputs and "leaves". L09 covered two kinds of nodes: computed nodes and non-computed, only-read leaves. Weights are leaves (prepared at load, L14), and graph inputs (token vectors, positions) are leaves too - except their data is filled fresh each step. Graph-building reserves these leaves' positions, execution fills in this step's data, and the same graph computes different results.
You will notice build_attn has several overloads. Why? Because attention has many variants: with or without KV cache (some prefill paths skip it, decode always uses it), standard multi-head or GQA, with or without a sliding window... Rather than write a full attention for each, "a common skeleton + optional differences" is made into a few overloads each architecture picks from. Another instance of "fold differences into options, distill commonality into blocks".
Making graph inputs a family of classes (the llm_graph_input_* all derive from a common interface) is deliberate too: different inputs have different "fill methods" - token vectors look up by token id, positions are generated by current progress, the KV mask is computed by the causal rule. Wrapping each input's "how to fill" into its own class, called uniformly before execution, readies the graph. This makes "which external inputs the graph needs" extensible and composable.
Finally, this lesson's most important point: build_graph only builds, never computes. It fills in the operators' op and src (L09's lazy build), then get_gf() hands out a ggml_cgraph; the actual node-by-node execution is the L10 backend's job.
build_graph only fills op/src, yielding a ggml_cgraph structure - touching no data, doing no compute. Written once, universal across hardware.
The backend sched takes the graph and computes node by node; CPU / CUDA / Metal each run it fast. Swap backends, leave build untouched.
This "build is build, execute is execute" separation pays off enormously: the same graph runs unchanged on wildly different hardware - CPU, CUDA, Metal (L10's backend scheduling) - with the upper model logic written once. And precisely because graph-building touches no concrete computation, switching backends or adding new hardware needs no change to graph-building code. L16 "assembles the correct graph", L10 "computes the graph fast on some hardware" - each to its job, together making complete inference.
Precisely because graph-building yields only "structure", not data, this graph can in many cases be cached and reused: in consecutive decode steps each is "one new token", the graph's structure is identical, so the engine can reuse the previous graph's skeleton and only swap the inputs, saving the cost of rebuilding. Another dividend of "lazy build + structure-data separation".
So what to truly take from this lesson is a mental model: model inference = assemble weights into a compute graph by architecture (L16) + execute that graph on some backend (L10). The assembly logic, written once, runs across all hardware; that is the root of llama.cpp being both lightweight and universal. Hold onto this, and the rest of Part 4 really unfolds along its thread.
For difference isolation. Architectures' forward flows differ somewhat: some attentions carry a bias, some FFNs use a different activation, some insert other things between layers. Putting each architecture's "assembly" in its own file means changing one does not ripple to another, and it reads cleanly - one file is one model's complete forward.
Meanwhile the blocks doing the real work (build_attn etc.) are shared, living in the base class llm_graph_context. So these architecture files are mostly short: just "in this architecture's order, call a few shared blocks and feed the right weights". Commonality in the base, differences in files, is the key to hosting dozens of architectures without chaos.
This also echoes L15: adding a new architecture, the graph-building step is usually writing one not-long src/models/<arch>.cpp, reusing existing blocks. Only a truly novel structure needs one or two new blocks in the base, or even one or two new ggml operators (L11).
It translates L04's attention math into a chain of L11 operators. Roughly: three mul_mats project the input into Q, K, V; rope injects position into Q, K; this step's K, V are written into the KV cache, and the historical K, V are read back (L19).
Then compute attention scores (Q-by-K matmul), soft_max_ext adds the causal mask and normalizes to weights, another mul_mat weight-sums V; finally an output projection. One build_attn call thus assembles a whole attention set into a subgraph.
So everything you learned earlier is used here: L04's math is the blueprint, L11's operators are the bricks, L19's KV cache is what lets each step compute only the new token. build_attn is the "assembler" welding these three together in the right order.
Via L09's lazy build. The build_* blocks do not compute; they only create a tensor and fill its op (which operator I am) and src (who my inputs are). One pass of assembly yields a graph that only describes "what to compute and what depends on what", with not a number computed yet.
Then get_gf() hands out this ggml_cgraph, and the L10 backend computes it node by node in topological order. The build side cares only about "is the logical structure correct"; the execute side only about "how to compute fast on this hardware" - their concerns fully separated.
The payoff is the freedom of decoupling: write the model logic (graph) once, and it runs on all backends; to support new hardware, just add a backend on the execute side, with not one line of graph code changed. This is the most valuable part of the ggml/llama layering, and the root reason it runs on your laptop CPU and a datacenter GPU alike.