第4章:llama.cpp源码获取与结构分析

4.1 克隆源码——第一关就踩坑

拿到llama.cpp的第一步当然是把它拉到本地。你可能会想,不就是git clone吗?别急,这事儿其实有讲究。

我个人习惯用这条命令:

git clone --recurse-submodules https://github.com/ggerganov/llama.cpp.git

为什么要加--recurse-submodules?因为llama.cpp依赖了ggml这个子模块,如果不带这个参数,你拉下来会发现ggml目录是空的。编译的时候会报一堆符号未定义的错误。我以前第一次踩过这个坑,折腾了半小时才反应过来——嗯,你不会想重蹈覆辙。

如果你已经克隆完了才发现没拉子模块,也别慌,手动补上:

cd llama.cpp
git submodule update --init --recursive
小技巧: 嵌入式Linux环境如果没有git,可以用wget下载压缩包,但子模块得单独处理。我建议还是在开发机上完成克隆,然后通过NFS或scp同步到目标板。

4.2 看一眼目录——东西放得挺清楚

拉下来之后,先看看顶层结构长什么样。我列出最核心的那几项:

目录/文件作用我的重点关注
llama.h主头文件,定义推理API全量接口声明,必须仔细读
llama.cpp核心实现,模型加载、推理逻辑理解函数调用链的关键
common/工具函数:参数解析、采样、日志快速跑demo的必备模块
ggml/底层张量计算库,子模块算子的实现,性能调优要盯这里
examples/示例程序:main, server, batched等直接拿来改,省时间
CMakeLists.txtCMake构建入口控制编译选项,交叉编译靠它

说实话,ggml这个子模块才是真正的计算引擎。我在做嵌入式移植时,有90%的精力都花在理解ggml的量化操作上——因为它直接决定了模型能不能在只有256MB内存的开发板上跑起来。

4.3 核心模块深度解码

llama.h —— 你能调用的所有东西都在这里

打开llama.h,你会看到一套干净利落的C接口。我重点提几个:

  • llama_init():加载模型和分词器,返回一个上下文句柄。坑点:嵌入式上得手动指定n_threads,否则默认开满CPU核,内存会炸。
  • llama_eval():执行一次前向推理。输入token ID序列,输出logits。我在项目中遇到过一个问题——输入长度超过上下文窗口时,这个函数不会报错,只是默默丢掉前面的token,导致结果不对。所以调用前一定要检查token数。
  • llama_token_to_piece():把token ID转回字符串,用于解码输出。这个函数在中文场景下特别容易产生乱码,我建议你每次都先调用llama_tokenize()验证一下。

llama.cpp —— 五脏六腑都在里面

这个文件超过8000行,但核心逻辑其实可以分成几块:

  • 模型加载:解析GGUF格式的权重,填充内部结构体。我刚开始看的时候被llama_model_load里的各种fread绕晕了。后来自己写了个迷你loader才搞明白——其实就是按magic number、头部信息、张量数据顺序读。
  • 前向推理:把token输入组装成batch,调用ggml计算图。这部分大量用了回调函数,你如果不懂C函数指针,看起来会有点吃力。
  • 采样:llama_sample_*系列函数,从logits中选出下一个token。默认是贪心,也可以选top_k、top_p等。

说实话,刚接触时不建议深入逐行读llama.cpp,先把接口用起来,遇到性能瓶颈再回来抠细节。我自己的做法是先跑通main示例,然后慢慢往llama.h里加自己的日志。

common/ —— 给开发者的“救生圈”

这个目录里的代码,说白了就是帮你省去重复劳动。最常用的是:

  • common.h / common.cpp:定义了gpt_params结构体,把模型路径、上下文大小、线程数、采样参数等全打包在一起。你在嵌入式上需要固定参数时,直接修改这个结构体,省得每次传一堆命令行参数。
  • sampling.h / sampling.cpp:封装了采样策略,包括重复惩罚、温度缩放等。我建议不要直接用里面的默认值,嵌入式场景下温度0.6~0.8比较稳。
  • log.h:日志系统,默认输出到stderr。在嵌入式上我习惯把它重定向到串口或者syslog,不然DEBUG信息很容易跟模型输出混在一起。
注意: common模块依赖了C++标准库的<filesystem>。如果你用的是老旧交叉编译器(比如gcc 7以前),可能会编译不过。解决办法是把路径处理换成realpath()或者纯字符串操作,我在树莓派Zero上就这么干过。

4.4 构建系统——CMake别乱选

llama.cpp的CMake配置写得很完善,支持多种后端。在嵌入式Linux上,我个人推荐这样配置:

mkdir build && cd build
cmake .. \
  -DCMAKE_BUILD_TYPE=Release \
  -DLLAMA_OPENBLAS=ON \            # 板子上有BLAS库就开
  -DLLAMA_STATIC=ON \              # 静态链接,减少运行时依赖
  -DLLAMA_NATIVE=OFF \             # 交叉编译时必须关掉
  -DCMAKE_C_COMPILER=arm-linux-gnueabihf-gcc \
  -DCMAKE_CXX_COMPILER=arm-linux-gnueabihf-g++

你想想看,如果LLAMA_NATIVE开着,cmake会探测当前机器的CPU指令集,编译出来的二进制在ARM板上跑直接就非法指令了。我刚开始交叉编译时就忘了关这个选项,查了三天才发现……真是血的教训。

另外几个常用选项:

宏开关作用嵌入式上要注意的
LLAMA_METAL启用Apple Metal加速嵌入式Linux不可用,别开
LLAMA_CUBLAS启用NVIDIA CUDA加速如果你有Jetson之类带GPU的板子,可以考虑
LLAMA_CLBLAST启用OpenCL加速部分ARM Mali GPU支持,但效率一般
LLAMA_NO_ACCELERATE禁用所有加速库嵌入式神器——纯CPU模式,兼容性最好

构建成功后,你会在build/bin/下看到main、server等可执行文件。拿main跑一个最简单的推理例子:

./bin/main -m /path/to/model.gguf -p "Hello, how are you?" -n 64

如果它正常输出了回复,恭喜,你已经成功在嵌入式Linux上把llama.cpp跑起来了!

4.5 一图读懂源码结构

下面这张SVG图帮你把整个关系串起来——从你克隆仓库到最终的可执行文件,每一步对应什么模块、什么文件,一目了然。

llama.cpp 源码结构概览 llama.cpp 仓库 llama.h(API头文件) llama.cpp(核心实现) common/(工具模块) 模型加载器 前向推理引擎 采样策略 ggml(张量计算库) CMakeLists.txt bin/main, server 等 说明: - 箭头表示“依赖/包含”关系,从仓库顶层到底层库再到可执行文件。 - common/模块被 examples/ 中的 main/server 直接引用,也间接被 llama.cpp 使用。

图中可以看出,整个项目的骨架其实不复杂:上层API(llama.h)声明了你能调用的函数,核心(llama.cpp)实现了推理逻辑,底层依赖ggml做计算,而common和examples让你能快速跑起来。构建系统则通过CMake把这一切串成可执行文件。