🦙 llama.cpp 图解教程llama.cpp Visual Guide 第四部分 · llama 推理内部Part 4 · Inside llama inference 16 / 40
第四部分 · llama 推理内部Part 4 · Inside llama inference

构建计算图Building the compute graph

有了加载好的权重(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 是怎么把一堆权重变成"能跑"的。

🔌 生活类比
建图像照着图纸(L15 架构)用标准件搭模型build_attn(搭一套注意力)、build_ffn(搭一套前馈)、build_norm(搭一层归一化)就是预制好的标准件; src/models/<arch>.cpp 是"这种楼的拼装说明书",告诉你这些件按什么顺序、用哪些权重拼。而且拼出来的不是结果,而是一张待执行的图——就像搭好的不是已通电的电路,而是一张电路图,真正通电(计算)是 L10 的事。

谁来建图:从 build_graph 说起

建图的总入口是 llama_model::build_graph。它本身很薄,主要做一件事:把活派发给当前架构。因为不同架构(llama、qwen2…)的前向流程不一样,所以真正的建图逻辑放在每个架构各自的文件 src/models/<arch>.cpp 里。

llama_model::build_graph
总入口(薄)
->
build_arch_graph
虚函数 -> src/models/<arch>.cpp
->
llm_graph_context 的积木
build_attn / build_ffn ...
->
get_gf()
= ggml_cgraph(L09)

落到源码(简化自 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 和 ggml 的关系:它不是 ggml 的一部分,而是 llama 层站在 ggml 之上写的"组装逻辑"。ggml(L08-L11)提供张量、算子、建图原语;build_graph 用这些原语,按 transformer 的结构拼出一张具体的图。所以这一课本质是在讲"怎么用 ggml 这套积木,搭出一个真正的大模型"。

build_graph 什么时候被调用?答案是每一步推理都调一次——下一课会讲的 llama_decode,内部第一件事就是为这一步搭出计算图。听起来很费:每生成一个 token 都要重搭一次图? 其实不然,图的"结构"很轻(只是一串算子的声明、不含计算),搭起来很快;而且对形状相同的步骤,这张图还能被复用,不必每次从头来。

传给 build_graphllm_graph_params 里,装着搭这一步图所需的上下文:这一步要处理哪些 token(来自 L18 的 batch)、KV cache 当前状态、用哪个后端等等。换句话说,build_graph 不是凭空搭图,而是针对"这一步要算什么"搭出恰好够用的图。 这也是为什么 prefill(一次算整段 prompt)和 decode(一次算一个 token)虽然用同一套建图代码,搭出的图大小却不同。

代码里那行 build_pooling / build_dense_out 注释也值得一提:除了主体的 N 层 block,建图还会按需接上一些"收尾"步骤——比如做 embedding 任务时的池化、某些模型额外的输出层。这些是可选的尾巴,普通文本生成多半用不到,但它们和主体共用同一套建图框架,按架构和任务接进同一张图。

一层 transformer 怎么搭

模型的主体是 N 个一模一样的 transformer block 叠起来。看清一层怎么搭,就看懂了整个前向。一层的骨架很固定:归一化 -> 注意力 -> 残差 -> 归一化 -> 前馈 -> 残差。

1

build_norm

对输入做 RMSNorm,把数值稳到合理范围(L11 的归一化算子)。

2

build_attn

一整套注意力:Q/K/V 投影 + rope 注入位置 + 读写 KV cache + softmax + 输出投影(L11/L04/L19)。

3

残差相加

把注意力输出加回输入(残差连接),让信息和梯度都好流动。

4

build_norm + build_ffn

再归一化一次,然后前馈网络(gate/up/down 三个矩阵乘,L11)。

5

残差相加

把前馈输出加回去,得到这一层的输出,喂给下一层。

把这套骨架写成伪代码,几乎就是 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 加法算子,但其意义远不止一次加法。

🔬 细节 / 源码对应
整个前向并非只有重复的层。开头有一步把 token id 查成词向量(token_embd 那张表,图输入之一);结尾在最后一层之后,还有一次 output_norm 归一化、和一次投影到词表大小的 output(算出 logits,L17)。所以完整的图是"输入嵌入 -> N 层 block -> 输出归一 -> 投影出 logits",中间那 N 层才是我们重点拆的对象。

值得点明 prefill 和 decode 在建图上的关系:两者用同一套建图代码,区别只在喂进去的 batch(L18)——prefill 一次喂整段 prompt 的多个 token、decode 一次只喂一个新 token。图的"形状"随 token 数变,但"结构"(每层怎么拼)完全一样。 这正是统一建图的好处:一套逻辑,既管"首次把 prompt 过一遍",又管"之后逐字生成"。

还要留意一点:建图代码里看不到具体的数值build_attnbuild_ffn 操作的全是"还没算的张量"——它们只是在说"把这个权重和那个输入做矩阵乘,结果叫 cur"。真正的浮点数要等 L10 执行时才填进去。 所以读建图代码,你读到的是数据流的形状,而不是数据本身——这也是 L09 惰性建图最直观的体感。

退一步看,这一整张前向图其实就是一个有向无环图(DAG):token 向量从输入叶子流入,经过一层层 block 的算子变换,最后流到 logits。每个算子是图上一个节点、箭头表示"谁喂给谁"。 L09 已经讲过这种图的本质,这一课只是让你看到:原来一个真实大模型的前向,落到图上就是这么一张结构清晰、层层堆叠的 DAG。

复用积木与图输入

支撑这套拼装的,是 llm_graph_context 上的一批复用积木图输入。积木是 build_attn/build_ffn/build_norm 这些方法;图输入是把"外部数据"接进图的入口。

图输入llm_graph_input_*
把外部数据接进图:embd(词向量)· pos(位置)· attn_kv(KV cache)
积木build_attn / build_ffn / build_norm
把权重 + 输入拼成一段子图,内部都是 L11 的算子
产物llm_graph_result -> ggml_cgraph
所有积木串起来,get_gf() 交出最终的图(L09)

图输入(llm_graph_input_*)是个容易被忽略却很关键的概念。一张计算图光有"算子"还不够,还得有"入口"——token 的词向量从哪进来、每个 token 的位置从哪来、KV cache 接在哪。 这些就是各种 build_inp_embd/build_inp_pos 建出来的输入节点。它们是图的"叶子"(L09 讲过的 leafs),每步推理把新数据填进去,图就能算出新结果。

而所有积木产出的,都是 L11 讲的 ggml 张量(mul_matsoft_max_ext 等算子的输出)。一个 build_attn 调用,内部就是十几个 ggml 算子按注意力数学(L04)串成的一小段子图。 把许多这样的子图首尾相连,就长成了整个模型的前向图——这正是 L09 说的"算子串成图",只不过这里是站在 llama 层、按 transformer 结构有组织地串。

🔬 细节 / 源码对应
顺带看一眼 build_ffn 内部:现代 llama 类模型的前馈不是简单的"一升一降",而是 SwiGLU 式的——gateup 两个矩阵各把输入投影一次,gate 那路过一个激活函数后与 up 逐元素相乘,再由 down 投影回去。这就是为什么一层 FFN 有 gate/up/down 三个权重矩阵(L15 命名约定里见过)。build_ffn 把这套固定套路封好,建图时一句话搞定。

再说说图输入和"叶子"的关系。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 后端的事。

建图(L16)

build_graph 只填 op/src,产出一张 ggml_cgraph 结构——不碰数据、不做计算。写一遍,跨硬件通用。

执行(L10)

后端 sched 拿这张图逐节点算,CPU / CUDA / Metal 各自把它跑快。换后端,不动建图。

这种"建图归建图、执行归执行"的分离,回报是巨大的:同一张图,能原封不动地跑在 CPU、CUDA、Metal 等天差地别的硬件上(L10 的后端调度),上层的模型逻辑只写一遍。也正因为建图不碰具体计算, 换一个后端、加一种新硬件,都不用动建图代码。L16 负责"拼出正确的图",L10 负责"在某种硬件上把图算快",两者各司其职,合起来才是完整的推理。

正因为建图只产出"结构"、不含数据,这张图在很多情况下还能被缓存复用:连续的 decode 步骤,每步都是"一个新 token",图的结构一模一样,于是引擎可以复用上一张图的骨架、只换喂进去的输入,省下反复搭图的开销。这是"惰性建图 + 结构与数据分离"带来的又一个红利。

🌍 宏观理解
把这一课放回整个推理循环里看:每生成一个 token,llama_decode(L17)大致就是"建图(L16)-> 后端执行(L10)-> 得到 logits -> 采样(L21)出下一个 token"这么一圈。L16 是这圈里"把模型变成可算的图"那一环。理解了它,你就把"加载好的模型"和"真正跑起来的推理"接上了。下一课,我们就进到 llama_context,看这一圈是怎么转起来的。

所以这一课真正要带走的,是一个心智模型:模型推理 = 按架构把权重拼成一张计算图(L16)+ 在某后端上执行这张图(L10)。拼图的逻辑写一遍、能跑遍所有硬件;这就是 llama.cpp 既轻便又通用的根。把这句话记牢,第四部分后面几课其实都是在它的脉络上继续展开。

1 为什么每个架构要单独一个 src/models/<arch>.cpp? 点击展开

为了差异隔离。各架构的前向流程多少有别:有的注意力带偏置、有的 FFN 用不同激活、有的层间还插了别的东西。把每种架构的"拼法"放进各自的文件,改一个不会波及另一个,读起来也清爽——一个文件就是一种模型的完整前向。

而真正干活的积木(build_attn 等)是共享的,住在基类 llm_graph_context 里。所以这些架构文件大多很短:无非是"按这个架构的顺序,调几次共享积木、喂上对的权重"。共性进基类、差异进文件,是这套设计能容纳几十种架构还不乱的关键。

这也呼应了 L15:加一个新架构,建图这步往往就是新写一个不长的 src/models/<arch>.cpp,复用现成积木。只有遇到真正新颖的结构,才需要往基类加一两个新积木、甚至往 ggml 加一两个新算子(L11)。

2 build_attn 内部到底做了什么? 点击展开

它把 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 就是把这三者按正确顺序焊到一起的那个"组装工"。

3 建图和执行是怎么彻底分开的? 点击展开

靠 L09 的惰性建图。build_* 这些积木不计算,只创建张量、填好它的 op(我是哪种算子)和 src(我的输入是谁)。一圈拼下来,得到的是一张只描述了"算什么、依赖谁"的图,里面一个数都还没算。

然后 get_gf() 把这张 ggml_cgraph 交出去,由 L10 的后端按拓扑序逐节点真正计算。建图侧只关心"逻辑结构对不对",执行侧只关心"在这块硬件上怎么算得快"——两边的关注点完全分开。

好处是解耦带来的自由:模型逻辑(建图)写一遍,就能跑在所有后端上;要支持新硬件,只在执行侧加一个后端,建图代码一行不改。这正是 ggml/llama 这套分层最值钱的地方,也是它能同时跑在你的笔记本 CPU 和数据中心 GPU 上的根本原因。

✅ 关键要点
  • llama_model::build_graph 派发到每架构自己的 build_arch_graphsrc/models/<arch>.cpp),返回一张 ggml_cgraph(经 res->get_gf())。
  • 积木 build_norm/build_attn/build_ffn 是基类 llm_graph_context 的方法,被各架构复用。
  • 一层 = norm -> attn(QKV+rope+KV+softmax)-> 残差 -> norm -> ffn -> 残差;循环 n_layer() 层,按名字取权重(L15)。
  • 图输入 llm_graph_input_* 把词向量/位置/KV 接进图,是图的"叶子"(L09)。
  • 只建不算(L09 惰性):build_graph 拼出图,L10 后端才执行;同一图可换后端跑。
💡 设计洞察
把"每种架构怎么前向"写成一份 src/models/<arch>.cpp,把"怎么算注意力/前馈"沉淀成 llm_graph_context 的可复用积木——于是新架构只是"用标准件换个拼法"。更妙的是,底层 ggml(L08-L12)根本不知道上面跑的是 llama 还是 qwen, 它只看到一张普通的计算图、照样执行(L10)。模型的多样性收在建图层、计算的通用性留在 ggml 层——这道干净的分界,正是 llama.cpp 既能海纳百川、又能一套引擎跑天下的底层秘密。下一课,我们看这张图被装进 llama_context 后,怎么真正跑起一步推理。

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

1. llama 层怎么为不同架构建出不同的前向图?
  1. 每个架构一个独立引擎
  2. llama_model::build_graph 派发到每架构自己的 build_arch_graph(src/models/<arch>.cpp),复用 llm_graph_context 的 build_* 积木
  3. 运行时编译整个模型
  4. 一个巨型 if-else
看答案与解析 点击展开
答案:B。build_graph 是稳定入口,调虚函数 build_arch_graph 派发到各架构的 src/models/<arch>.cpp;真正干活的 build_attn/build_ffn/build_norm 是基类 llm_graph_context 的共享积木。
2. build_graph 产出什么、交给谁?
  1. 立即算出结果
  2. 一个 .gguf 文件
  3. 直接产出文本
  4. 一张 ggml_cgraph(只建不算),交给后端执行(L10)
看答案与解析 点击展开
答案:D。build_* 只填算子的 op/src(L09 惰性建图),get_gf() 交出一张 ggml_cgraph;真正逐节点执行是 L10 后端的事。所以同一张图能换后端跑。
3. 一层 transformer 在图里大致是什么顺序?
  1. 先 ffn 后 attn 且无 norm
  2. norm -> attn(QKV+rope+KV+softmax)-> 残差 -> norm -> ffn -> 残差
  3. 完全随机
  4. 只有一个 mul_mat
看答案与解析 点击展开
答案:B。一个 block 的骨架固定:build_norm -> build_attn -> 残差 -> build_norm -> build_ffn -> 残差;循环 n_layer() 层,每层按名字取权重(L15)。
💭 发散思考(没有标准答案,动手或动脑想想)
  • build_attn 这种积木被复用、加新架构只写一份 src/models/<arch>.cpp——这种结构对“支持很多模型”有什么好处?

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".

🔌 Analogy
Building the graph is like assembling a model from standard parts per the blueprint (L15 architecture): build_attn (one attention set), build_ffn (one feed-forward set), build_norm (one normalization) are the prefab parts; src/models/<arch>.cpp is "the assembly manual for this kind of building", saying in what order and with which weights to assemble them. And what comes out is not a result but a graph waiting to run - like building not a powered circuit but a circuit diagram; actually powering it (computing) is L10's job.

Who builds the graph: starting from build_graph

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.

llama_model::build_graph
main entry (thin)
->
build_arch_graph
virtual -> src/models/<arch>.cpp
->
llm_graph_context blocks
build_attn / build_ffn ...
->
get_gf()
= ggml_cgraph(L09)

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.

🌍 Big picture
Let us also clarify build_graph's relation to ggml: it is not part of ggml, but the "assembly logic" the llama layer writes on top of ggml. ggml (L08-L11) provides tensors, operators, and graph-building primitives; build_graph uses these primitives to assemble a concrete graph by the transformer structure. So this lesson is essentially about "how to use ggml's building blocks to assemble a real large model".

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.

How one transformer layer is built

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.

1

build_norm

RMSNorm the input, stabilizing values into a sane range (L11's normalization operator).

2

build_attn

A whole attention set: Q/K/V projection + rope position + read/write KV cache + softmax + output projection (L11/L04/L19).

3

residual add

Add the attention output back to the input (residual connection), letting information and gradients flow well.

4

build_norm + build_ffn

Normalize again, then the feed-forward network (gate/up/down, three matmuls, L11).

5

residual add

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.

🔬 Details / source
The whole forward is not only repeated layers. At the start, one step looks up token ids into token vectors (the token_embd table, one of the graph inputs); at the end, after the last layer, there is an output_norm and a projection to vocab size output (computing logits, L17). So the full graph is "input embedding -> N blocks -> output norm -> project to logits", with those N middle layers being what we focus on dissecting.

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.

Reusable blocks and graph inputs

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 inputsllm_graph_input_*
wire external data into the graph: embd (token vectors) - pos (positions) - attn_kv (KV cache)
blocksbuild_attn / build_ffn / build_norm
assemble weights + inputs into a subgraph, internally L11 operators
productllm_graph_result -> ggml_cgraph
all blocks chained; get_gf() hands out the final graph(L09)

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.

🔬 Details / source
A glance inside build_ffn: modern llama-style models' feed-forward is not a simple "up then down" but SwiGLU-style - the gate and up matrices each project the input, the gate path passes an activation and is multiplied element-wise with up, then down projects back. This is why one FFN layer has three weight matrices gate/up/down (seen in L15's naming convention). build_ffn wraps this fixed routine, done in one line at graph time.

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.

Build and execute, sharply separated

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 (L16)

build_graph only fills op/src, yielding a ggml_cgraph structure - touching no data, doing no compute. Written once, universal across hardware.

Execute (L10)

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".

🌍 Big picture
Put this lesson back into the whole inference loop: per generated token, llama_decode (L17) is roughly the round "build graph (L16) -> backend execute (L10) -> get logits -> sample (L21) the next token". L16 is the "turn the model into a computable graph" link in that round. Understand it and you have joined "the loaded model" to "inference actually running". Next lesson, we enter llama_context to see how this round turns.

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.

1 Why a separate src/models/<arch>.cpp per architecture? Click to expand

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).

2 What does build_attn actually do inside? Click to expand

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.

3 How are build and execute fully separated? Click to expand

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.

✅ Key points
  • llama_model::build_graph dispatches to each architecture's build_arch_graph (src/models/<arch>.cpp), returning a ggml_cgraph (via res->get_gf()).
  • Blocks build_norm/build_attn/build_ffn are methods on the base llm_graph_context, reused by every architecture.
  • One layer = norm -> attn (QKV+rope+KV+softmax) -> residual -> norm -> ffn -> residual; looped n_layer() times, fetching weights by name (L15).
  • Graph inputs llm_graph_input_* wire token vectors/positions/KV into the graph as its "leaves" (L09).
  • Build only, no compute (L09 lazy): build_graph assembles the graph, the L10 backend executes; the same graph runs on any backend.
💡 Design insight
Writing "how each architecture does its forward" as a single src/models/<arch>.cpp, and distilling "how to compute attention/FFN" into llm_graph_context's reusable blocks - so a new architecture is just "a different arrangement of standard parts". Better still, the underlying ggml (L08-L12) has no idea whether llama or qwen runs above; it sees just an ordinary compute graph and executes it (L10). Model diversity gathered in the graph layer, computational generality kept in the ggml layer - this clean boundary is the underlying secret to llama.cpp taking in all rivers while one engine runs them all. Next lesson, we see how this graph, packed into a llama_context, actually runs one inference step.

🧪 Self-test - think about the design

1. How does the llama layer build different forward graphs for different architectures?
  1. a separate engine per architecture
  2. llama_model::build_graph dispatches to each architecture's build_arch_graph (src/models/<arch>.cpp), reusing llm_graph_context's build_* blocks
  3. compiling the whole model at runtime
  4. one giant if-else
Show answer & explanation click to expand
Answer: B. build_graph is the stable entry; it calls the virtual build_arch_graph, dispatching to each architecture's src/models/<arch>.cpp, while the real workers build_attn/build_ffn/build_norm are shared blocks on the base llm_graph_context.
2. What does build_graph produce, and hand to whom?
  1. the computed result immediately
  2. a .gguf file
  3. text output directly
  4. a ggml_cgraph (built, not computed), handed to the backend to execute (L10)
Show answer & explanation click to expand
Answer: D. build_* only fills operators' op/src (L09 lazy build); get_gf() hands out a ggml_cgraph; actual node-by-node execution is the L10 backend's job. So the same graph runs on any backend.
3. Roughly what order is one transformer layer in the graph?
  1. ffn before attn, with no norm
  2. norm -> attn (QKV+rope+KV+softmax) -> residual -> norm -> ffn -> residual
  3. completely random
  4. just one mul_mat
Show answer & explanation click to expand
Answer: B. A block's skeleton is fixed: build_norm -> build_attn -> residual -> build_norm -> build_ffn -> residual; looped n_layer() times, fetching weights by name per layer (L15).
💭 Open questions (no single right answer - just think or try)
  • build_attn-style blocks are reused, and a new architecture only writes one src/models/<arch>.cpp - what are the benefits of this structure for 'supporting many models'?