计算图构建与架构分发
更新于 2026-04-15
系列定位:本文是 llama.cpp 源码精读系列 #5。llama.cpp 的计算图基于 GGML 的惰性求值模型。如果你对这个模型还不熟悉,建议先阅读计算图与推理引擎。本文直接进入
build_graph()的 C++ 实现。
每个 ubatch 被送入计算之前,需要先构建一张计算图(computation graph)——它描述了所有 tensor 操作及其依赖关系。llama.cpp 支持 125 种不同的模型架构,每种架构有自己的计算图拓扑,但它们共享一套通用的”积木”接口。
Part A:125 种架构,一个入口
llm_arch 枚举
src/llama-arch.h 定义了 llm_arch 枚举,列举了所有支持的模型架构:
enum llm_arch {
LLM_ARCH_CLIP,
LLM_ARCH_LLAMA,
LLM_ARCH_LLAMA4,
LLM_ARCH_FALCON,
LLM_ARCH_GPT2,
LLM_ARCH_GPTJ,
LLM_ARCH_GPTNEOX,
LLM_ARCH_BERT,
LLM_ARCH_QWEN2,
LLM_ARCH_GEMMA3,
// ... 共 125 种已知架构 ...
LLM_ARCH_UNKNOWN,
};
每种架构对应 src/models/ 目录下的一个独立实现文件(共 113 个 .cpp 文件),例如 llama.cpp、gpt2.cpp、qwen2.cpp 等。
build_graph():巨大的 switch 分发
所有架构汇聚到一个入口——llama_model::build_graph()(位于 src/llama-model.cpp):
ggml_cgraph * llama_model::build_graph(const llm_graph_params & params) const {
std::unique_ptr<llm_graph_context> llm;
switch (arch) {
case LLM_ARCH_LLAMA:
llm = std::make_unique<llm_build_llama<false>>(*this, params);
break;
case LLM_ARCH_GPT2:
llm = std::make_unique<llm_build_gpt2>(*this, params);
break;
case LLM_ARCH_QWEN2:
llm = std::make_unique<llm_build_qwen2>(*this, params);
break;
// ... 125 个 case ...
default:
GGML_ABORT("fatal error");
}
// 所有架构共享的后处理
llm->build_pooling(cls, cls_b, cls_out, cls_out_b, cls_norm);
llm->build_sampling();
llm->build_dense_out(dense_2_out_layers, ...);
llm->res->set_outputs();
return llm->res->get_gf();
}
关键点:每个 case 创建一个架构专用的图构建器(如 llm_build_llama、llm_build_gpt2),在其构造函数中完成计算图的构建。构建完成后,build_graph() 再统一追加 pooling、sampling 等后处理节点。
Part B:llm_graph_context 积木接口
所有架构的图构建器都继承自 llm_graph_context 基类(定义在 src/llama-graph.h)。这个基类提供了一组可复用的”积木”方法。继承关系如下:
核心积木方法
| 方法 | 功能 | 支持的变体 |
|---|---|---|
build_norm() | 归一化 | LLM_NORM(标准 LayerNorm)、LLM_NORM_RMS(RMSNorm) |
build_attn() | 注意力 | KV cache、无 cache(encoder)、ISWA(滑动窗口)、cross-attention |
build_ffn() | 前馈网络 | SiLU、GELU、ReLU 等激活函数;并行/顺序 gate |
build_moe_ffn() | MoE FFN | 支持 softmax/top-k gating,按需加载 expert |
每种架构的图构建器在构造函数中调用这些积木方法,按照该架构的 transformer 结构组合成完整的计算图。积木方法通过枚举参数(如 LLM_NORM_RMS、LLM_FFN_SILU、LLM_FFN_PAR)来选择变体,不同架构只需传不同的参数即可复用同一套实现。
Part C:实例对比——Llama vs GPT-2
通过对比两种经典架构的图构建,可以直观理解积木的组合方式。
Llama 架构
src/models/llama.cpp 中的 Llama 图构建器:
llm_build_llama::llm_build_llama(const llama_model & model,
const llm_graph_params & params)
: llm_graph_context(params) {
// 输入层
inpL = build_inp_embd(model.tok_embd);
auto * inp_pos = build_inp_pos();
auto * inp_attn = build_attn_inp_kv(); // KV cache 注意力
for (int il = 0; il < n_layer; ++il) {
// 注意力分支
cur = build_norm(inpL, model.layers[il].attn_norm, NULL,
LLM_NORM_RMS, il); // ← RMSNorm
Qcur = build_lora_mm(model.layers[il].wq, cur, ...); // Q = Wx + LoRA
Kcur = build_lora_mm(model.layers[il].wk, cur, ...);
Vcur = build_lora_mm(model.layers[il].wv, cur, ...);
Qcur = ggml_rope_ext(ctx0, Qcur, inp_pos, ...); // ← RoPE 位置编码
Kcur = ggml_rope_ext(ctx0, Kcur, inp_pos, ...);
cur = build_attn(inp_attn, model.layers[il].wo, NULL,
Qcur, Kcur, Vcur, ..., kq_scale, il);
// 残差连接 + FFN
ffn_inp = ggml_add(ctx0, cur, inpL);
cur = build_norm(ffn_inp, model.layers[il].ffn_norm, NULL,
LLM_NORM_RMS, il);
if (model.layers[il].ffn_gate_inp == nullptr) {
cur = build_ffn(cur, ..., LLM_FFN_SILU, LLM_FFN_PAR, il); // ← SiLU + 并行 gate
} else {
cur = build_moe_ffn(cur, ..., LLM_FFN_SILU, ...); // MoE 变体
}
inpL = ggml_add(ctx0, cur, ffn_inp); // 残差
}
// 输出
cur = build_norm(inpL, model.output_norm, NULL, LLM_NORM_RMS, -1);
cur = build_lora_mm(model.output, cur); // logits
res->t_logits = cur;
ggml_build_forward_expand(gf, cur);
}
Llama 的特征:RMSNorm 归一化、RoPE 旋转位置编码(应用于 Q 和 K)、分别投影 Q/K/V(三次矩阵乘法)、SiLU + 并行 gate 的 FFN,以及支持 MoE 变体。
GPT-2 架构
src/models/gpt2.cpp 中的 GPT-2 图构建器——同样的积木,不同的组合:
llm_build_gpt2::llm_build_gpt2(const llama_model & model,
const llm_graph_params & params)
: llm_graph_context(params) {
inpL = build_inp_embd(model.tok_embd);
auto * inp_pos = build_inp_pos();
// GPT-2 用可学习的位置 embedding,不用 RoPE
pos = ggml_get_rows(ctx0, model.pos_embd, inp_pos);
inpL = ggml_add(ctx0, inpL, pos);
auto * inp_attn = build_attn_inp_kv();
for (int il = 0; il < n_layer; ++il) {
cur = build_norm(inpL, model.layers[il].attn_norm,
model.layers[il].attn_norm_b,
LLM_NORM, il); // ← 标准 LayerNorm(非 RMSNorm)
// GPT-2 用合并的 QKV 投影(一次矩阵乘法)
cur = build_lora_mm(model.layers[il].wqkv, cur);
cur = ggml_add(ctx0, cur, model.layers[il].bqkv);
// 用 view 把 QKV 拆出来(零拷贝)
Qcur = ggml_view_3d(ctx0, cur, n_embd_head, n_head, n_tokens, ...);
Kcur = ggml_view_3d(ctx0, cur, ...);
Vcur = ggml_view_3d(ctx0, cur, ...);
cur = build_attn(inp_attn, ..., 1.0f/sqrtf(float(n_embd_head)), il);
ffn_inp = ggml_add(ctx0, cur, inpL);
cur = build_norm(ffn_inp, ..., LLM_NORM, il); // ← 标准 LayerNorm
cur = build_ffn(cur, ..., LLM_FFN_GELU, LLM_FFN_SEQ, il); // ← GELU + 顺序 gate
inpL = ggml_add(ctx0, cur, ffn_inp);
}
cur = build_norm(inpL, model.output_norm, model.output_norm_b, LLM_NORM, -1);
cur = build_lora_mm(model.output, cur);
res->t_logits = cur;
ggml_build_forward_expand(gf, cur);
}
GPT-2 的特征:标准 LayerNorm(带偏置参数)、可学习位置 embedding(直接加到输入上)、合并 QKV 一次投影(用 ggml_view_3d 零拷贝拆分)、GELU + 顺序 FFN。
关键差异
| 特性 | Llama | GPT-2 |
|---|---|---|
| 归一化 | RMSNorm | 标准 LayerNorm |
| 位置编码 | RoPE(旋转位置编码) | 可学习位置 embedding |
| QKV 投影 | 分别投影 Q、K、V | 合并 QKV 一次投影 |
| FFN 激活 | SiLU + 并行 gate | GELU + 顺序 |
| 偏置 | 通常无偏置 | 有偏置 |
但从代码结构来看,两者的框架完全一致:输入 → 层循环(norm → attn → residual → norm → ffn → residual)→ 输出。这就是积木设计的威力。
交互对比
下面的交互组件并排展示了 Llama 和 GPT-2 的构建流程,高亮显示两者的差异步骤:
架构对比:Llama vs GPT-2
| 特性 | Llama | GPT-2 |
|---|---|---|
| 归一化 | RMSNorm | 标准 LayerNorm |
| 位置编码 | RoPE(旋转位置编码) | 可学习位置 embedding |
| QKV 投影 | 分别投影 Q、K、V | 合并 QKV 一次投影 |
| FFN 激活 | SiLU + 并行 gate | GELU + 顺序 |
| 偏置 | 通常无偏置 | 有偏置 |
Part D:图复用机制
构建计算图有一定开销(创建 ggml tensor 对象、建立依赖关系),尤其在自回归解码阶段,每个 ubatch 可能只包含几个 token,图拓扑完全一样。llama.cpp 通过图复用机制来避免重复构建。
process_ubatch() 中的复用逻辑
在 process_ubatch() 中:
// src/llama-context.cpp(简化)
llm_graph_result * llama_context::process_ubatch(
const llama_ubatch & ubatch, ...) {
auto * res = gf_res_prev.get(); // 上一次的图结果
const auto gparams = graph_params(res, ubatch, mctx, gtype);
if (!graph_reuse_disable && res->can_reuse(gparams)) {
// 图拓扑不变,直接复用!只需更新输入数据
n_reused++;
} else {
// 需要重建图
res->reset();
ggml_backend_sched_reset(sched.get());
gf = model.build_graph(gparams); // 重新构建
ggml_backend_sched_alloc_graph(sched.get(), gf); // 重新分配
}
res->set_inputs(&ubatch); // 无论复用与否,都要设置新的输入数据
graph_compute(res->get_gf(), ubatch.n_tokens > 1); // 执行
return res;
}
复用条件
can_reuse() 检查的条件包括:
- ubatch 形状相同:
n_tokens、n_seq_tokens、n_seqs一致 - 输入模式相同:都是 token 或都是 embedding
- 序列 ID 不变:在 equal_seqs 模式下
- 输出数量相同
- 模型配置不变:
causal_attn、embeddings、LoRA 等
在典型的自回归解码中(每次生成 1 个 token),连续的 ubatch 通常形状完全一致,因此图复用率非常高。
小结
本文走完了 llama.cpp 计算图构建的完整路径:
- 一个入口:
build_graph()通过巨大的 switch 将 125 种架构分发到各自的图构建器 - 共享积木:所有图构建器继承
llm_graph_context,复用build_norm/build_attn/build_ffn等方法 - 灵活组合:不同架构通过传递不同枚举参数选择归一化类型、激活函数、投影方式等变体
- 图复用:自回归解码阶段,形状不变的 ubatch 可以跳过图构建,直接复用上次的计算图
下一篇将进入 #6 后端调度与内存管理,探索构建好的计算图如何被分配到 CPU/GPU 后端并执行。