Llama.cpp 源码解析

本文对 llama.cpp-release-b4371 的版本实现进行分析

llama.cpp 是一个轻量级的、用 C++ 编写的高效大模型推理引擎,旨在通过优化运行时性能来提供快速的推理速度,特别适用于运行大型语言模型(如 Meta 的 LLaMA)在不同硬件平台上的推理任务

具体来说,llama.cpp 是由社区开发并开源的一个项目,设计目标是使得 Llama 这类大型语言模型能够在没有专门硬件加速器(如 GPU)的情况下,在 CPU 上高效运行

llama.cpp 提供了一种统一的推理接口,允许不同的 LLM 遵循相同的 API 规范,同时行业内也大多采用 llama.cpp 的推理逻辑作为标准实现


张量运算

llama.cpp 的张量运算的相关代码位于 ggml/ 文件夹下,完全使用 ggml 作为其底层的张量运算库

GGML 库

GGML 是一个轻量且高效的张量库,主要用于机器学习模型的开发和训练

它在性能优化、资源管理以及跨平台支持方面具有优势,特别适合于需要高效计算但资源受限的场景

相比于大型框架,ggml 更注重高效、轻量和可定制性,使得它在嵌入式设备、移动设备以及高效计算任务中非常有用

llama.cpp 完全使用 ggml 作为其底层的张量运算库,包括但不限于张量加载、计算图构建、张量运算

llama.cpp 甚至没有自己对 ggml 封装一个 Tensor 类,直接拿 ggml 的数据结构 ggml_tensor 来用

llama.cpp 项目直接内置了 ggml 库,而不需要额外的下载或配置

计算后端

ggml 支持 9 种计算后端:CPU、BLAS、SYCL、CUDA、MUSA、HIP、Vulkan、CANN、Android

  • CPU:通用的处理器
  • BLAS:加速基础线性代数运算
  • SYCL:跨平台的异构计算
  • CUDA:NVIDIA GPU 加速
  • MUSA:优化稀疏计算任务的多线程计算
  • HIP:AMD GPU 加速
  • Vulkan:图形和计算混合加速
  • CANN:华为 Ascend 处理器的加速
  • Android:专为 Android 设备优化

llama.cpp 这个项目在编译时默认只选择了 CPUBLAS 这两个计算后端,如果需要支持额外的计算后端,需要在编译的时候勾选特定的选项

比如想要额外支持 CUDA 设备的计算,需要在编译时加上 -DGGML_CUDA=ON

1
2
cmake -B build -DGGML_CUDA=ON
cmake --build build --config Release

llama.cpp 项目通过 cmake 来组织编译,具体的信息可以参考官方的编译文档

不同的计算后端的代码实现位于 ggml/ 文件夹下,这里不对算子的代码实现做过多解释

下方展示了 llama.cpp/ggml/iuclude/ 文件夹下的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ggml/include
├── ggml-alloc.h
├── ggml-backend.h
├── ggml-blas.h
├── ggml-cann.h
├── ggml-cpp.h
├── ggml-cpu.h
├── ggml-cuda.h
├── ggml-kompute.h
├── ggml-metal.h
├── ggml-opencl.h
├── ggml-opt.h
├── ggml-rpc.h
├── ggml-sycl.h
├── ggml-vulkan.h
└── ggml.h

分词器

词典和分词器的相关代码位于 src/llama_vocab.hsrc/llama_vocab.cpp

词典 Vocab

词典没啥好说的,把 .json 文件读取后,再初始化分词器 llm_tokenizer 就结束了

分词器 Tokenizer

llama.cpp 内置的分词器 llm_tokenizer 一共有五种类型:spmbpewpmugmrwkv

  1. SPM (SentencePiece Model)

    SPM 是一个基于无监督学习的子词分词器,使用自编码器或 unigram 语言模型将文本分割为子词单元,适用于多语言模型

    通过无监督学习,分析大量文本数据并统计字符或子词频率。使用最大似然估计(MLE)来学习最优的子词划分。生成一个词汇表,其中每个项代表一个子词单元

  2. BPE (Byte Pair Encoding)

    BPE 是一种基于字符对的合并算法,通过频率分析,合并文本中最常见的字符对,逐步构建词汇表

    从字符级别开始构建词汇。在每次迭代中,合并频率最高的字符对。重复合并直到词汇表大小达到预定值

  3. WPM (WordPiece Model)

    WPM 是一种基于最大似然估计的子词分割方法,通过计算每个子词的出现概率来构建词汇表

    初始词汇表由单个字符构成。通过最大化似然估计,合并最常见的子词对。继续合并直到词汇表达到指定大小

  4. UGM (Unigram Model)

    UGM 是基于概率模型的分词方法,假设每个子词是独立的,通过最大化每个子词出现的概率来划分文本

    对文本进行词频分析,学习每个子词的出现概率。根据概率选择合适的子词划分

  5. RWKV (Recurrent Weighted Key-Value Model)

    RWKV 结合了 RNN 和 Transformer 的优点,通过加权的键值存储来处理长距离依赖,适合序列建模任务

    使用递归的方式处理文本中的每个子词。通过加权的键值对存储和计算上下文信息,进行序列学习

分词器在使用时进一步根据类型封装为 llm_tokenizer_xxx_session 类,此类拥有成员函数 tokenize()

成员函数 tokenize() 作为分词器的统一使用接口,可以将 std::string 类型的字符串分词为 std::vector<token>

Unicode 支持

llama.cpp 为支持 Unicode 做了非常多的额外工作,相关工具函数位于 src/unicode.hsrc/unicode.cppsrc/unicode-data.hsrc/unicode-data.cpp

下方是 src/unicode.h 的所有工具函数,其作用看个函数名都非常的通俗易懂

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
size_t unicode_len_utf8(char src);

std::string unicode_cpt_to_utf8 (uint32_t cpt);
uint32_t unicode_cpt_from_utf8(const std::string & utf8, size_t & offset);

std::vector<uint32_t> unicode_cpts_from_utf8(const std::string & utf8);

std::vector<uint32_t> unicode_cpts_normalize_nfd(const std::vector<uint32_t> & cpts);

unicode_cpt_flags unicode_cpt_flags_from_cpt (uint32_t cpt);
unicode_cpt_flags unicode_cpt_flags_from_utf8(const std::string & utf8);

std::string unicode_byte_to_utf8(uint8_t byte);
uint8_t unicode_utf8_to_byte(const std::string & utf8);

uint32_t unicode_tolower(uint32_t cpt);

std::vector<std::string> unicode_regex_split(const std::string & text, const std::vector<std::string> & regex_exprs);

文件加载

llama.cpp 的文件加载过程分为四步,依次为加载配置文件、构建模型架构、加载词典和分词器、加载模型权重

  • 此部分的相关代码位于 src/llama.hsrc/llama.cpp,相关函数和类型的关键词为 llm_load

此部分的主要入口函数为 llama_model_load() 函数,模型加载器 llama_model_loader 做核心的加载工作

加载配置文件

通过函数 llama_load_model_from_file() 来加载模型,根据配置文件提前为模型加载做准备

llama.cpp 使用两个类来管控模型的配置信息,分别是 llama_model_paramsllama_context_params

llama_model_params 的定义位于 src/llama.h:278,主要管控了模型的权重加载设备信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
struct llama_model_params {
// NULL-terminated list of devices to use for offloading (if NULL, all available devices are used)
ggml_backend_dev_t * devices;

int32_t n_gpu_layers; // number of layers to store in VRAM
enum llama_split_mode split_mode; // how to split the model across multiple GPUs

// the GPU that is used for the entire model when split_mode is LLAMA_SPLIT_MODE_NONE
int32_t main_gpu;

// proportion of the model (layers or rows) to offload to each GPU, size: llama_max_devices()
const float * tensor_split;

// comma separated list of RPC servers to use for offloading
const char * rpc_servers;

// Called with a progress value between 0.0 and 1.0. Pass NULL to disable.
// If the provided progress_callback returns true, model loading continues.
// If it returns false, model loading is immediately aborted.
llama_progress_callback progress_callback;

// context pointer passed to the progress callback
void * progress_callback_user_data;

// override key-value pairs of the model meta data
const struct llama_model_kv_override * kv_overrides;

// Keep the booleans together to avoid misalignment during copy-by-value.
bool vocab_only; // only load the vocabulary, no weights
bool use_mmap; // use mmap if possible
bool use_mlock; // force system to keep model in RAM
bool check_tensors; // validate model tensor data
};

llama_model_params 的关键变量及其作用解释如下

  • devices: 指定用于加载模型的设备列表
  • n_gpu_layers: 控制每个 GPU 显存中存储的模型层数
  • split_mode: 定义模型如何在多个 GPU 上分割
  • main_gpu: 单进程下指定用于加载完整模型的 GPU
  • tensor_split: 每个 GPU 分担模型的比例
  • vocab_only: 控制只加载词汇表,不加载权重
  • use_mmap: 启用内存映射来加载模型文件
  • use_mlock: 强制模型数据保持在内存中

llama_context_params 的定义位于 src/llama.cpp:314,主要管控了模型的推理设置信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
struct llama_context_params {
uint32_t n_ctx; // text context, 0 = from model
uint32_t n_batch; // logical maximum batch size that can be submitted to llama_decode
uint32_t n_ubatch; // physical maximum batch size
uint32_t n_seq_max; // max number of sequences (i.e. distinct states for recurrent models)
int32_t n_threads; // number of threads to use for generation
int32_t n_threads_batch; // number of threads to use for batch processing

enum llama_rope_scaling_type rope_scaling_type; // RoPE scaling type, from `enum llama_rope_scaling_type`
enum llama_pooling_type pooling_type; // whether to pool (sum) embedding results by sequence id
enum llama_attention_type attention_type; // attention type to use for embeddings

// ref: https://github.com/ggerganov/llama.cpp/pull/2054
float rope_freq_base; // RoPE base frequency, 0 = from model
float rope_freq_scale; // RoPE frequency scaling factor, 0 = from model
float yarn_ext_factor; // YaRN extrapolation mix factor, negative = from model
float yarn_attn_factor; // YaRN magnitude scaling factor
float yarn_beta_fast; // YaRN low correction dim
float yarn_beta_slow; // YaRN high correction dim
uint32_t yarn_orig_ctx; // YaRN original context size
float defrag_thold; // defragment the KV cache if holes/size > thold, < 0 disabled (default)

ggml_backend_sched_eval_callback cb_eval;
void * cb_eval_user_data;

enum ggml_type type_k; // data type for K cache [EXPERIMENTAL]
enum ggml_type type_v; // data type for V cache [EXPERIMENTAL]

// Keep the booleans together and at the end of the struct to avoid misalignment during copy-by-value.
// TODO: move at the end of the struct
bool logits_all; // the llama_decode() call computes all logits, not just the last one (DEPRECATED - set llama_batch.logits instead)
bool embeddings; // if true, extract embeddings (together with logits)
bool offload_kqv; // whether to offload the KQV ops (including the KV cache) to GPU
bool flash_attn; // whether to use flash attention [EXPERIMENTAL]
bool no_perf; // whether to measure performance timings

// Abort callback
// if it returns true, execution of llama_decode() will be aborted
// currently works only with CPU execution
ggml_abort_callback abort_callback;
void * abort_callback_data;
};

llama_context_params 的关键变量及其作用解释如下

  • n_ctx: 模型上下文的大小
  • n_batch: 逻辑上的最大批次大小
  • n_ubatch: 物理上的最大批次大小
  • n_seq_max: 最大序列数
  • n_threads: 生成过程使用的线程数
  • n_threads_batch: 批量处理时使用的线程数
  • attention_type: 用于嵌入的注意力机制类型
  • type_k: K 缓存的数据类型
  • type_v: V 缓存的数据类型
  • embeddings: 是否提取嵌入
  • offload_kqv: 是否将 KQV 操作迁移到 GPU
  • flash_attn: 是否使用 Flash Attention

真正加载模型权重到设备前llama.cpp 会先设置好 llama_model_paramsllama_context_params,配置各权重的所处主机和设备 id,为分布式推理做准备

有趣的是,llama.cpp 默认采用分布式推理,先按照分布式推理的逻辑对模型进行划分,配置各个权重的设备信息,直到最后才会对单进程推理做配置

这部分的配置实现逻辑在 src/llama.cpp:20251llama_load_model_from_file() 函数中,下面是一些源代码的片段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
struct llama_model * llama_load_model_from_file(
const char * path_model,
struct llama_model_params params
) {
ggml_time_init();

llama_model * model = new llama_model;

<...>

if (params.rpc_servers != nullptr && params.rpc_servers[0] != '\0') {
// split the servers set them into model->rpc_servers
<...>
model->rpc_servers.push_back(servers);
}

// add RPC devices
if (!model->rpc_servers.empty()) {
ggml_backend_reg_t rpc_reg = ggml_backend_reg_by_name("RPC");
<...>

typedef ggml_backend_dev_t (*ggml_backend_rpc_add_device_t)(const char * endpoint);
ggml_backend_rpc_add_device_t ggml_backend_rpc_add_device_fn = (ggml_backend_rpc_add_device_t) ggml_backend_reg_get_proc_address(rpc_reg, "ggml_backend_rpc_add_device");
<...>

for (const std::string & server : model->rpc_servers) {
ggml_backend_dev_t dev = ggml_backend_rpc_add_device_fn(server.c_str());
<...>
}
}

// create list of devices to use with this model
if (params.devices) {
for (ggml_backend_dev_t * dev = params.devices; *dev; ++dev) {
model->devices.push_back(*dev);
}
} else {
// use all available devices
for (size_t i = 0; i < ggml_backend_dev_count(); ++i) {
ggml_backend_dev_t dev = ggml_backend_dev_get(i);
<...>
}
}

// if using single GPU mode, remove all except the main GPU
if (params.split_mode == LLAMA_SPLIT_MODE_NONE) {
<...>
ggml_backend_dev_t main_gpu = model->devices[params.main_gpu];
model->devices.clear();
model->devices.push_back(main_gpu);
}

for (auto * dev : model->devices) {
<...>
}

int status = llama_model_load(path_model, *model, params);
<...>

return model;
}

构建模型架构

llama.cpp 里的模型架构都是写死在 cpp 代码里的,在 src/llama.cpp:7878 处出现了一堆大模型的架构实现,对于任何 llama.cpp 支持的 LLM,都需要把模型的架构写死在此处

下面是 Qwen 的模型架构,相关代码位于 src/llama.cpp:8362

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
case LLM_ARCH_QWEN:
{
model.tok_embd = create_tensor(tn(LLM_TENSOR_TOKEN_EMBD, "weight"), {n_embd, n_vocab}, 0);

// output
model.output_norm = create_tensor(tn(LLM_TENSOR_OUTPUT_NORM, "weight"), {n_embd}, 0);
model.output = create_tensor(tn(LLM_TENSOR_OUTPUT, "weight"), {n_embd, n_vocab}, 0);

for (int i = 0; i < n_layer; ++i) {
auto & layer = model.layers[i];

layer.attn_norm = create_tensor(tn(LLM_TENSOR_ATTN_NORM, "weight", i), {n_embd}, 0);

layer.wqkv = create_tensor(tn(LLM_TENSOR_ATTN_QKV, "weight", i), {n_embd, n_embd*3}, 0);
layer.bqkv = create_tensor(tn(LLM_TENSOR_ATTN_QKV, "bias", i), {n_embd*3}, 0);
layer.wo = create_tensor(tn(LLM_TENSOR_ATTN_OUT, "weight", i), {n_embd, n_embd}, 0);

layer.ffn_norm = create_tensor(tn(LLM_TENSOR_FFN_NORM, "weight", i), {n_embd}, 0);

layer.ffn_gate = create_tensor(tn(LLM_TENSOR_FFN_GATE, "weight", i), {n_embd, n_ff/2}, 0);
layer.ffn_down = create_tensor(tn(LLM_TENSOR_FFN_DOWN, "weight", i), {n_ff/2, n_embd}, 0);
layer.ffn_up = create_tensor(tn(LLM_TENSOR_FFN_UP, "weight", i), {n_embd, n_ff/2}, 0);
}
} break;

下面是 Qwen2Qwen2VL 的模型架构,这两个模型在文本生成上采用了相同的架构,相关代码位于 src/llama.cpp:8386

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
case LLM_ARCH_QWEN2:
case LLM_ARCH_QWEN2VL:
{
model.tok_embd = create_tensor(tn(LLM_TENSOR_TOKEN_EMBD, "weight"), {n_embd, n_vocab}, 0);

// output
model.output_norm = create_tensor(tn(LLM_TENSOR_OUTPUT_NORM, "weight"), {n_embd}, 0);
model.output = create_tensor(tn(LLM_TENSOR_OUTPUT, "weight"), {n_embd, n_vocab}, llama_model_loader::TENSOR_NOT_REQUIRED);
// if output is NULL, init from the input tok embed
if (model.output == NULL) {
model.output = create_tensor(tn(LLM_TENSOR_TOKEN_EMBD, "weight"), {n_embd, n_vocab}, llama_model_loader::TENSOR_DUPLICATED);
}

for (int i = 0; i < n_layer; ++i) {
auto & layer = model.layers[i];

layer.attn_norm = create_tensor(tn(LLM_TENSOR_ATTN_NORM, "weight", i), {n_embd}, 0);

layer.wq = create_tensor(tn(LLM_TENSOR_ATTN_Q, "weight", i), {n_embd, n_embd}, 0);
layer.wk = create_tensor(tn(LLM_TENSOR_ATTN_K, "weight", i), {n_embd, n_embd_gqa}, 0);
layer.wv = create_tensor(tn(LLM_TENSOR_ATTN_V, "weight", i), {n_embd, n_embd_gqa}, 0);
layer.wo = create_tensor(tn(LLM_TENSOR_ATTN_OUT, "weight", i), {n_embd, n_embd}, 0);

// optional bias tensors
layer.bq = create_tensor(tn(LLM_TENSOR_ATTN_Q, "bias", i), {n_embd}, 0);
layer.bk = create_tensor(tn(LLM_TENSOR_ATTN_K, "bias", i), {n_embd_gqa}, 0);
layer.bv = create_tensor(tn(LLM_TENSOR_ATTN_V, "bias", i), {n_embd_gqa}, 0);

layer.ffn_norm = create_tensor(tn(LLM_TENSOR_FFN_NORM, "weight", i), {n_embd}, 0);

layer.ffn_gate = create_tensor(tn(LLM_TENSOR_FFN_GATE, "weight", i), {n_embd, n_ff}, 0);
layer.ffn_down = create_tensor(tn(LLM_TENSOR_FFN_DOWN, "weight", i), { n_ff, n_embd}, 0);
layer.ffn_up = create_tensor(tn(LLM_TENSOR_FFN_UP, "weight", i), {n_embd, n_ff}, 0);
}
} break;

对于任何 llama.cpp 支持的 LLM,都需要把模型的架构写死在此处

换言之,对于没有提前在 llama.cpp 里写好的模型架构,llama.cpp 无法构建对应的模型(乐)

加载词典和分词器

llama_load_model_from_file() 函数会调用 llama_model_load(),进而调用 llm_load_vocab() 用来加载词典和分词器

这部分的逻辑不算复杂,首先依旧是加载了一堆词典和分词器的默认配置,然后再根据配置文件去修改默认配置得到用户配置

涉及到的配置有分词器类型、特殊 token 对应的 id

最后调用 vocab.init_tokenizer() 来初始化分词器

加载权重

设置好模型架构后,此时 llama.cpp 已经确定好各个模型权重的加载信息

通过位于 src/llama.cpp:7647llm_load_tensors() 函数来逐一加载模型的权重到指定的设备上

通过 create_tensor() 函数来创建张量,这是一个闭包(lambda)函数,最终调用 ggml 库函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
auto create_tensor = [&](const LLM_TN_IMPL & tn, const std::initializer_list<int64_t> & ne, int flags) -> ggml_tensor * {
ggml_tensor * t_meta = ml.get_tensor_meta(tn.str().c_str());
<...>

// some models use the token embedding tensor as the output, but since these are used in different layers and with different ops
// the tensor is duplicated
// to handle this, we check if the tensor is duplicated, and if so, we assume that it is being loaded as the output tensor
llm_tensor tn_tensor = tn.tensor;
<...>

// tensors with "bias" suffix are always used with GGML_OP_ADD
ggml_op op;
bool bias = tn.suffix != nullptr && strcmp(tn.suffix, "bias") == 0;
<...>

// sanity checks
<...>

// select the buffer type for this tensor
<...>

ggml_backend_buffer_type_t buft = select_weight_buft(model, t_meta, op, *buft_list);
<...>

// avoid using a host buffer when using mmap
<...>

ggml_context * ctx = ctx_for_buft(buft);

// if duplicated, check if the original tensor was allocated in the same buffer type context and avoid creating a new one
if (flags & llama_model_loader::TENSOR_DUPLICATED) {
ggml_tensor * t = ggml_get_tensor(ctx, tn.str().c_str());
if (t) {
return t;
}
}
return ml.create_tensor(ctx, tn, ne, flags);
};

create_tensor() 函数则主要做了这么几件事:

  1. 初始化张量的名字,根据名字在本地权重文件里查找对应的张量权重
  2. 根据张量名字来检查模型架构内是否有此张量的位置
  3. 初始化张量的信息,为张量分配内存,设置此张量的设备信息
  4. 使用 ggml 库的 ggml_get_tensor() 函数来加载张量权重

最后 llama.cpp 会根据模型架构逐一加载张量的权重,至此模型加载的工作也就完成了


计算图的概述

计算图是深度学习框架中描述运算流程的一个重要数据结构,实际上就是模型的前向传播过程

llama.cpp 中,计算图的相关代码位于 src/llama.hsrc/llama.cpp 文件中。相关的关键词包括 llm_build 和与图构建相关的函数

计算图的构建和执行是 llama.cpp 模型推理的核心

构建计算图

llama.cpp 中,llama_build_graph() 是构建计算图的主要函数

它会根据不同的模型架构,调用相应的 build_xxx() 函数来生成模型计算图

具体来说,每个模型架构(例如 Llama2)都会有一个对应的 build_xxx() 函数,这个函数负责根据模型的结构生成一个 ggml_cgraph

ggml_cgraphggml 库中一个存储计算图节点和操作的类,它将模型中的每一层、每个算子(例如矩阵乘法、加法、归一化等)封装为图的一个节点

计算图中的每个节点表示一个具体的计算操作,而节点之间的边表示这些操作之间的数据流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
static struct ggml_cgraph * llama_build_graph(
llama_context & lctx,
const llama_ubatch & ubatch,
bool worst_case
) {
const auto & model = lctx.model;

<...>

struct ggml_cgraph * result = NULL;

struct llm_build_context llm(lctx, ubatch, cb, worst_case);

llm.init();

switch (model.arch) {
case <...>:
<...>
break;
case LLM_ARCH_GPT2:
{
result = llm.build_gpt2();
} break;
case <...>:
<...>
break;
default:
GGML_ABORT("fatal error");
}

// add on pooling layer
if (lctx.cparams.embeddings) {
result = llm.append_pooling(result);
}

llm.free();

return result;
}

对于每种不同的模型架构,llama.cpp 提供了一个特定的 build_xxx() 函数来构建计算图

例如,对于 GPT-2 模型,会有 build_gpt2() 函数

不同的模型架构会有不同的计算图,直接导致了不同的前向传播流程,下面是 GPT-2 模型的计算图实现,相关代码位于 src/llama.cpp:13559

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
struct ggml_cgraph * build_gpt2() {
struct ggml_cgraph * gf = ggml_new_graph_custom(ctx0, llama_model_max_nodes(model), false);

const int64_t n_embd_head = hparams.n_embd_head_v;
const int64_t n_embd_gqa = hparams.n_embd_v_gqa();
GGML_ASSERT(n_embd_head == hparams.n_embd_head_k);

struct ggml_tensor * cur;
struct ggml_tensor * pos;
struct ggml_tensor * inpL;

inpL = llm_build_inp_embd(ctx0, lctx, hparams, ubatch, model.tok_embd, cb);

// inp_pos - contains the positions
struct ggml_tensor * inp_pos = build_inp_pos();

// KQ_mask (mask for 1 head, it will be broadcasted to all heads)
struct ggml_tensor * KQ_mask = build_inp_KQ_mask();

pos = ggml_get_rows(ctx0, model.pos_embd, inp_pos);
cb(pos, "pos_embd", -1);

inpL = ggml_add(ctx0, inpL, pos);
cb(inpL, "inpL", -1);

for (int il = 0; il < n_layer; ++il) {
cur = llm_build_norm(ctx0, inpL, hparams,
model.layers[il].attn_norm,
model.layers[il].attn_norm_b,
LLM_NORM, cb, il);
cb(cur, "attn_norm", il);

// self-attention
{
cur = llm_build_lora_mm(lctx, ctx0, model.layers[il].wqkv, cur);
cb(cur, "wqkv", il);

cur = ggml_add(ctx0, cur, model.layers[il].bqkv);
cb(cur, "bqkv", il);

struct ggml_tensor * Qcur = ggml_cont(ctx0, ggml_view_2d(ctx0, cur, n_embd, n_tokens, cur->nb[1], 0*sizeof(float)*(n_embd)));
struct ggml_tensor * Kcur = ggml_cont(ctx0, ggml_view_2d(ctx0, cur, n_embd_gqa, n_tokens, cur->nb[1], 1*sizeof(float)*(n_embd)));
struct ggml_tensor * Vcur = ggml_cont(ctx0, ggml_view_2d(ctx0, cur, n_embd_gqa, n_tokens, cur->nb[1], 1*sizeof(float)*(n_embd + n_embd_gqa)));

cb(Qcur, "Qcur", il);
cb(Kcur, "Kcur", il);
cb(Vcur, "Vcur", il);

Qcur = ggml_reshape_3d(ctx0, Qcur, n_embd_head, n_head, n_tokens);

cur = llm_build_kv(ctx0, lctx, kv_self, gf,
model.layers[il].wo, model.layers[il].bo,
Kcur, Vcur, Qcur, KQ_mask, n_tokens, kv_head, n_kv, 1.0f/sqrtf(float(n_embd_head)), cb, il);
}

if (il == n_layer - 1) {
// skip computing output for unused tokens
struct ggml_tensor * inp_out_ids = build_inp_out_ids();
cur = ggml_get_rows(ctx0, cur, inp_out_ids);
inpL = ggml_get_rows(ctx0, inpL, inp_out_ids);
}

// add the input
struct ggml_tensor * ffn_inp = ggml_add(ctx0, cur, inpL);
cb(ffn_inp, "ffn_inp", il);

// FF
{
cur = llm_build_norm(ctx0, ffn_inp, hparams,
model.layers[il].ffn_norm,
model.layers[il].ffn_norm_b,
LLM_NORM, cb, il);
cb(cur, "ffn_norm", il);

cur = llm_build_ffn(ctx0, lctx, cur,
model.layers[il].ffn_up, model.layers[il].ffn_up_b, NULL,
NULL, NULL, NULL,
model.layers[il].ffn_down, model.layers[il].ffn_down_b, NULL,
NULL,
LLM_FFN_GELU, LLM_FFN_SEQ, cb, il);
cb(cur, "ffn_out", il);
}

cur = ggml_add(ctx0, cur, ffn_inp);
cur = lctx.cvec.apply_to(ctx0, cur, il);
cb(cur, "l_out", il);

// input for next layer
inpL = cur;
}

cur = llm_build_norm(ctx0, inpL, hparams,
model.output_norm,
model.output_norm_b,
LLM_NORM, cb, -1);
cb(cur, "result_norm", -1);

cur = llm_build_lora_mm(lctx, ctx0, model.output, cur);
cb(cur, "result_output", -1);

ggml_build_forward_expand(gf, cur);

return gf;
}

对于一些没有在 llama.cpp 中明确实现的模型架构,llama.cpp 无法构建对应的计算图

算子实现

llama.cpp 中,算子的实现完全是通过 ggml 库的基础操作自行实现完成的,而没有使用其他的算子库

下面以 self-attentionFeed Forward 这两个算子的源代码实现来进行说明

  1. Self-Attention

以下代码片段展示了 llama.cpp 如何实现 self-attention 算子,相关代码位于 src/llama.cpp:13592

自注意力的计算公式如下

$
\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{Q K^T}{\sqrt{d_k}}\right) V
$

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// self-attention
{
cur = llm_build_lora_mm(lctx, ctx0, model.layers[il].wqkv, cur);
cb(cur, "wqkv", il);

cur = ggml_add(ctx0, cur, model.layers[il].bqkv);
cb(cur, "bqkv", il);

struct ggml_tensor * Qcur = ggml_cont(ctx0, ggml_view_2d(ctx0, cur, n_embd, n_tokens, cur->nb[1], 0*sizeof(float)*(n_embd)));
struct ggml_tensor * Kcur = ggml_cont(ctx0, ggml_view_2d(ctx0, cur, n_embd_gqa, n_tokens, cur->nb[1], 1*sizeof(float)*(n_embd)));
struct ggml_tensor * Vcur = ggml_cont(ctx0, ggml_view_2d(ctx0, cur, n_embd_gqa, n_tokens, cur->nb[1], 1*sizeof(float)*(n_embd + n_embd_gqa)));

cb(Qcur, "Qcur", il);
cb(Kcur, "Kcur", il);
cb(Vcur, "Vcur", il);

// Reshape Q, K, V tensors into 3D
Qcur = ggml_reshape_3d(ctx0, Qcur, n_embd_head, n_head, n_tokens);

cur = llm_build_kv(ctx0, lctx, kv_self, gf,
model.layers[il].wo, model.layers[il].bo,
Kcur, Vcur, Qcur, KQ_mask, n_tokens, kv_head, n_kv, 1.0f/sqrtf(float(n_embd_head)), cb, il);
}
  • llm_build_lora_mm(lctx, ctx0, model.layers[il].wqkv, cur):执行矩阵乘法计算,wqkv 是包含查询、键和值的权重矩阵,cur 是输入张量
  • cur = ggml_add(ctx0, cur, model.layers[il].bqkv):将偏置 bqkv 加到当前张量 cur 上,形成查询、键和值的最终输入
  • ggml_view_2d 用于从当前张量中提取子张量,并为每个子张量(Q、K、V)创建视图
  • ggml_reshape_3d(ctx0, Qcur, n_embd_head, n_head, n_tokens):将查询张量 Qcur 从 2D 重塑为 3D 张量
  • llm_build_kv 注意力掩码(KQ_mask)和缩放因子
  1. Feed Forward

以下代码片段展示了 llama.cpp 如何实现 Feed Forward 算子,相关代码位于 src/llama.cpp:13625

前馈神经网络的计算公式如下

$
\text{FFN}(x) = \text{GELU}(x W_1 + b_1) W_2 + b_2
$

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// FF
{
cur = llm_build_norm(ctx0, ffn_inp, hparams,
model.layers[il].ffn_norm,
model.layers[il].ffn_norm_b,
LLM_NORM, cb, il);
cb(cur, "ffn_norm", il);

cur = llm_build_ffn(ctx0, lctx, cur,
model.layers[il].ffn_up, model.layers[il].ffn_up_b, NULL,
NULL, NULL, NULL,
model.layers[il].ffn_down, model.layers[il].ffn_down_b, NULL,
NULL,
LLM_FFN_GELU, LLM_FFN_SEQ, cb, il);
cb(cur, "ffn_out", il);
}
  • llm_build_norm 函数实现了层归一化操作
  • llm_build_ffn 函数执行了前馈神经网络的实际计算
  • LLM_FFN_GELU:GELU 激活函数
  • LLM_FFN_SEQ:表示前馈神经网络的顺序处理方式

采样器

采样器的相关代码位于 src/llama-sampling.hsrc/llama-sampling.cpp

采样过程

在模型的前向传播过程中,每个 token 的概率值会存放在 llama_context 类的 logits 成员变量中,这是一个 float 数组,其中包含了所有可能 token 的概率分布

为了进行采样,使用统一的入口函数 llama_sampler_sample()。这个函数的流程如下

首先,函数通过 llama_get_logits_ith() 获取当前时间步(idx)下的 logits 值。logits 是一个包含了所有 token 概率分布的数组。接着,通过 llama_n_vocab() 获取模型词汇表的大小 n_vocab,即当前模型支持的最大 token 数量

1
2
3
llama_token llama_sampler_sample(struct llama_sampler * smpl, struct llama_context * ctx, int32_t idx) {
const auto * logits = llama_get_logits_ith(ctx, idx);
const int n_vocab = llama_n_vocab(llama_get_model(ctx));

在进行采样之前,首先需要准备一个临时数组 cur,这个数组将存储每个 token 的概率。数组的大小是 n_vocab,并且每个元素都存储了一个 llama_token_data 结构体,包含 token_idlogit(即每个 token 的原始概率值)和一个默认的 prob 值(此时设为 0.0f)。接下来,利用一个循环,将所有 token 的数据放入 cur 数组中

1
2
3
4
5
6
// TODO: do not allocate each time
std::vector<llama_token_data> cur;
cur.reserve(n_vocab);
for (llama_token token_id = 0; token_id < n_vocab; token_id++) {
cur.emplace_back(llama_token_data{token_id, logits[token_id], 0.0f});
}

接着,cur 数组会被封装成一个 llama_token_data_array 结构体 cur_p,这个结构体存储了 cur 数组的指针和数组的大小等信息。通过 cur_p 结构体,采样算法可以访问到每个 token 的 logitprob 数据,并对其进行进一步处理

1
2
3
4
5
6
llama_token_data_array cur_p = {
/* .data = */ cur.data(),
/* .size = */ cur.size(),
/* .selected = */ -1,
/* .sorted = */ false,
};

在这个步骤之后,采样过程会调用 llama_sampler_apply() 函数,执行特定的采样算法。该函数会根据某些策略(如 Top-k, Top-p 等)对 token 数据进行操作,计算最终的概率值并选择一个 token。采样的策略会影响 cur_p.selected 的值,它指示了最终选择的 token 的索引

1
llama_sampler_apply(smpl, &cur_p);

一旦采样完成,函数通过 GGML_ASSERT 进行安全检查,确保选中的 token 索引有效。然后,提取出选中的 token,并将其存储到 token 变量中

1
2
3
GGML_ASSERT(cur_p.selected >= 0 && cur_p.selected < (int32_t) cur_p.size);

auto token = cur_p.data[cur_p.selected].id;

最后,函数调用 llama_sampler_accept() 来接受这个采样的 token,并返回最终选中的 token。这个 token 将作为模型输出,用于生成下一个 token 或作为对话的回复

1
2
3
    llama_sampler_accept(smpl, token);
return token;
}

采样算法

根据不同的采样算法,llama.cpp 一共实现了 16 种采样器:greedydistsoftmaxtop-ktop-pmin-ptypicaltemptemp-extxtcmirostatmirostat v2grammarpenaltiesDRYlogit-bias

  1. greedy: greedy:select_max_prob_token

    选择具有最高概率的 token 作为下一个生成的 token

  2. dist: dist:sample_from_distribution

    根据概率分布采样生成 token

  3. softmax: softmax:apply_softmax_temperature

    基于 Softmax 函数对概率进行归一化,并可通过调整温度值来控制输出的多样性

  4. top-k: top-k:sample_top_k

    从概率分布中选择前 k 个概率最大的候选 token,并从中随机采样

  5. top-p: top-p:sample_top_p

    又叫 nucleus sampling,选择累计概率不超过 p 的最小集合内的 token,然后从中进行随机采样

  6. min-p: min-p:sample_min_p

    选择概率值大于某个最小阈值的 token 进行采样

  7. typical: typical:sample_typical

    基于典型性(即选择那些具有适中概率的 token)进行采样

  8. temp: temp:apply_temperature

    在 Softmax 采样基础上,通过温度调节生成的多样性

  9. temp-ext: temp-ext:apply_extended_temperature

    在标准温度调节的基础上加入额外的调整参数,更精细地控制生成的随机性

  10. xtc: xtc:apply_xtc

一种基于注意力的采样方法,通过不同层次的注意力机制动态调整生成过程中的样本选择

  1. mirostat: mirostat:mirostat_sampling

一种自适应采样方法,通过动态调整采样温度来控制生成内容的多样性

  1. mirostat v2: mirostat-v2:mirostat_v2_sampling

Mirostat 的改进版,通过引入更多的反馈机制和自适应调整策略,提供更精细的采样控制

  1. grammar: grammar:apply_grammar_constraint

在采样过程中加入语法约束,确保生成的 token 遵循特定的语法规则

  1. penalties: penalties:apply_token_penalties

通过为特定的 token 或 token 序列应用惩罚,减少生成过程中某些不希望出现的输出

  1. DRY: DRY:apply_dry_sampling

通过避免生成重复内容,优化文本生成过程

  1. logit-bias: logit-bias:apply_logit_bias

在采样过程中对特定 token 施加偏置,通过修改 token 的 logit 值(生成概率的对数)来提高或降低某些 token 被选中的可能性

llama.cpp 额外实现了一个 llama_sampler_chain 类,用来综合组合使用多种采样器

每个采样器需要实现 apply() 成员函数,作为统一的采样算法入口,llama_sampler_sample() 最终会返回一个 int32_t 类型,即 LLM 生成的下一个 token id


案例分析:LLM 对话流水线

相关代码位于 examples/simple/simple-chat.cpp

初始化

先通过 ggml_backend_load_all() 函数初始化计算后端,设置计算设备。该步骤确保可以使用 CPU 或 GPU 等设备进行后续计算,根据硬件配置加载相应的计算库和资源

1
2
// load dynamic backends
ggml_backend_load_all();

加载模型

使用 llama_load_model_from_file() 函数根据配置文件构建模型架构,加载模型参数,并配置相关设置。该函数会根据预训练的模型文件生成相应的计算图,并为后续的推理做好准备。接着使用 llama_new_context_with_model() 函数来加载模型权重、并构建计算图。这个函数初始化模型的计算上下文,确保所有的模型参数被正确加载,并且可以与后端进行交互

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// initialize the model
llama_model_params model_params = llama_model_default_params();
model_params.n_gpu_layers = ngl;

llama_model * model = llama_load_model_from_file(model_path.c_str(), model_params);
if (!model) {
fprintf(stderr , "%s: error: unable to load model\n" , __func__);
return 1;
}

// initialize the context
llama_context_params ctx_params = llama_context_default_params();
ctx_params.n_ctx = n_ctx;
ctx_params.n_batch = n_ctx;

llama_context * ctx = llama_new_context_with_model(model, ctx_params);
if (!ctx) {
fprintf(stderr , "%s: error: failed to create the llama_context\n" , __func__);
return 1;
}

加载采样器

使用 llama_sampler_chain_init() 函数来初始化一个 llama_sampler_chain 采样器,该采样器会负责得到下一个预测的 token。随后,使用 llama_sampler_chain_add() 函数向流水线中添加不同的采样步骤和策略,以便处理文本生成的过程

1
2
3
4
5
// initialize the sampler
llama_sampler * smpl = llama_sampler_chain_init(llama_sampler_chain_default_params());
llama_sampler_chain_add(smpl, llama_sampler_init_min_p(0.05f, 1));
llama_sampler_chain_add(smpl, llama_sampler_init_temp(0.8f));
llama_sampler_chain_add(smpl, llama_sampler_init_dist(LLAMA_DEFAULT_SEED));

文本生成

封装了一个闭包函数 generate() 用于对当前 prompt 进行文本生成。该函数会根据当前的上下文和输入的提示,生成相应的回复内容。在生成过程中,会动态调整生成策略,以便更好地适应对话场景

进行文本生成前,通过 llama_n_ctx()llama_get_kv_cache_used_cells() 函数来判断本次文本生成是否会超出最大上下文长度。如果生成的文本和现有上下文的总长度超过了预设的最大上下文长度限制,则会提前终止进程,避免出现内存溢出或计算错误

使用 llama_decode() 函数进行前向传播,得到每个 token 的预测概率值。该步骤会根据模型权重和上下文,计算出每个 token 的概率分布,并为下一个 token 的采样提供依据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
auto generate = [&](const std::string & prompt) {
std::string response;

// tokenize the prompt
const int n_prompt_tokens = -llama_tokenize(model, prompt.c_str(), prompt.size(), NULL, 0, true, true);
std::vector<llama_token> prompt_tokens(n_prompt_tokens);
<...>

// prepare a batch for the prompt
llama_batch batch = llama_batch_get_one(prompt_tokens.data(), prompt_tokens.size());
llama_token new_token_id;
while (true) {
// check if we have enough space in the context to evaluate this batch
<...>

// sample the next token
new_token_id = llama_sampler_sample(smpl, ctx, -1);

// is it an end of generation?
if (llama_token_is_eog(model, new_token_id)) {
break;
}

// convert the token to a string, print it and add it to the response
<...>
response += piece;

// prepare the next batch with the sampled token
batch = llama_batch_get_one(&new_token_id, 1);
}

return response;
};

上下文维护

使用 llama_sampler_sample() 函数采样得到下一个预测的 token。通过采样过程,生成模型输出的下一个 token,并通过这个 token 更新上下文,形成新的对话内容.对话期间维护一个上下文变量 messages,该变量保存着当前对话中的所有消息。每次进行文本生成前,都会通过 llama_chat_apply_template() 函数将 message 转换成一个 std::string 类型的 prompt,确保输入格式符合模型要求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
std::string user;
std::getline(std::cin, user);

if (user.empty()) {
break;
}

// add the user input to the message list and format it
messages.push_back({"user", strdup(user.c_str())});
int new_len = llama_chat_apply_template(model, nullptr, messages.data(), messages.size(), true, formatted.data(), formatted.size());
<...>

// remove previous messages to obtain the prompt to generate the response
std::string prompt(formatted.begin() + prev_len, formatted.begin() + new_len);

将得到的 prompt 传入上文实现的闭包函数 generate() 中,得到模型的回复文本,并将回复文本存储上下文变量 messages 中。这个回复文本将与当前对话中的其他消息共同构成一个完整的对话历史,为下一次生成提供上下文支持

1
2
3
4
5
6
7
// generate a response
std::string response = generate(prompt);

// add the response to the messages
messages.push_back({"assistant", strdup(response.c_str())});
prev_len = llama_chat_apply_template(model, nullptr, messages.data(), messages.size(), false, nullptr, 0);
<...>

Reference