llama.cpp 学习笔记(一):一次推理的流程

从一个两百多行的示例出发,看清一次文本生成的全过程。

llama.cpp 是用 C++ 写的 LLM 推理引擎,代码量不小,直接扎进 src/ 容易迷路。笔者打算从最小的可运行示例 examples/simple 开始读:它只有两百多行,但加载模型、分词、前向计算、采样这几步一个不少,正好把一次推理的轮廓勾勒出来。

给定一段提示词,模型要一个 token 一个 token 地把回答写出来。这个过程在推理引擎里被拆成两个阶段:prefill 处理提示词,decode 根据已经生成的内容继续往下写。

一次推理的流程

diagram

图中箭头表示执行顺序。第一个输出 token 来自 prefill 的 logits,此后每步 decode 只处理上一步刚生成的 token,循环往复,直到遇到结束符或达到长度上限。

Prefill 与 Decode 的分工

Prefill:一次处理整个提示词,写入 KV Decode:每次处理一个新 token,读取历史 KV

两个阶段跑的是同一套权重、同一段前向代码,区别在输入形状:

Prefill Decode
输入 提示词的 n 个 token 1 个 token
前向次数 通常一次(长提示词会被切批) 每生成一个 token 一次
KV cache 写入 n 个位置 读取全部历史,追加 1 个位置
计算特征 矩阵又宽又满,计算密集 权重只服务 1 个 token,带宽压力大

Prefill 的 tokens/s 远高于 decode,「长提示词的首字延迟」也主要花在 prefill 上,根子就在这张表里:prefill 把整段提示词拼成一个大矩阵,算力能跑满;decode 每步只有一个 token,却要把全部历史 KV 读回来,瓶颈从算力换成了带宽。

KV cache 是两阶段的交汇点,它的大小可以直接估算:

$$
\text{KV 占用} \approx 2 \times L \times H_{kv} \times d \times n_{ctx} \times \text{sizeof(dtype)}
$$

其中 2 表示 K 和 V 两份,$L$ 是层数,$H_{kv}$ 是 KV 头数(GQA 下小于 Q 头数),$d$ 是每个头的维度。上下文越长、层数越多,这块显存涨得越快,后面的文章再展开。

在 llama.cpp 里是怎么写的

示例没有把 prefill 和 decode 拆成两个接口,而是先把提示词填进一个 batch 提交,然后进入生成循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
batch_set_tokens(batch, prompt_tokens.data(), n_prompt, 0);   // 提示词整体作为一个 batch

// 简化版主循环,省去错误检查、打印与性能统计
for (int n_pos = 0; n_pos + n_tokens < n_prompt + n_predict; ) {
llama_process(ctx, LLAMA_PROCESS_TYPE_DECODE, batch);
n_pos += n_tokens;

new_token_id = llama_sampler_sample(smpl, ctx, -1); // 从 logits 采样
if (llama_vocab_is_eog(vocab, new_token_id)) break; // 遇到结束 token

batch_set_tokens(batch, &new_token_id, 1, n_pos); // 下一步只喂这一个 token
n_tokens = 1;
}

对照上面的流程,几处细节值得记下来:

  • batch_set_tokens 做的事情:清空 batch,逐个加入 token,并用 llama_batch_ext_set_pos() 显式指定每个 token 的绝对位置。
  • 输出位置只有一个:辅助函数里只对 batch 的最后一个位置调 llama_batch_ext_set_output_logits()。提示词前 n-1 个位置照样要过完整的前向(否则 KV 不完整),但不需要最后的 logits 投影,这部分开销被省掉了。
  • 位置从 n_pos 接着数:decode 阶段的 token 位置必须紧接在已经处理过的 token 之后,KV cache 里的位置才是连续的。
1
2
# 编译后直接跑(默认生成 32 个 token)
./build/bin/llama-simple -m model.gguf "Hello my name is"

示例打开了性能统计(ctx_params.no_perf = false),运行结束会把两段耗时分开打印:prompt eval time 对应 prefill,eval time 对应 decode,单位都是每 token 毫秒和每秒 token 数,很适合用来验证上面那张表。

调用顺序

diagram

几个容易混淆的点

  • **prefill 的 type 也是 LLAMA_PROCESS_TYPE_DECODE**:llama_process() 只有 encode / decode 两种类型,提示词和单 token 走的是同一条路径,区别只在 batch 里的 token 数。

  • n_batch 与 n_ubatch 不是一回事:

n_batch: logical maximum batch size that can be submitted to llama_decode
n_ubatch: physical maximum batch size

—— 摘自 include/llama.h

提交超过 n_ubatch 的 batch 时,引擎内部会切成多个 ubatch 分批计算。所以「prefill 只有一次前向」是简化说法,精确地说是一次 llama_process() 调用,内部可能分成多批。

  • 上下文位置要预留够:示例把 n_ctx 设成 n_prompt + n_predict - 1。提示词占 n_prompt 个位置,之后每生成一个 token 再占一个;位置不够时最直接的报错就是上下文超限。

小结

  • 一次推理 = 1 次 prefill + N 次 decode,共用同一段前向代码,差别只在输入形状与访存模式
  • KV cache 连接两个阶段:prefill 写入,decode 读取并追加
  • decode 每步的位置必须紧接历史位置,logits 只需要最后一个位置

下一篇再往下钻:提示词是怎么被切成 token 的,以及 KV cache 在内存里长什么样。

参考