做编译器和性能优化相关工作的同学,大概率都跟 llvm-project 这个仓库打过交道。我最早接触它是为了给一个 MIPS 嵌入式平台做交叉编译工具链,当时只把它当成 GCC 的替代品来用,后来一步步深入源码、把优化器 pass 和指令选择器的代码翻了个遍,才真正明白这个项目的分量——它早已不只是“另一个编译器”,而是整个现代编译生态的地基。今天这篇博文,我想结合自己从源码构建 LLVM 15.0.7 的实际经历,聊聊 llvm-project 的架构设计、仓库里各个子项目的关系、CMake 构建参数的取舍,顺便把跟它同版本号出现在图形栈里的 llvmpipe 软件渲染器也讲清楚。内容更适合打算自己动手编译工具链、或者刚接触 LLVM 想系统了解它工作方式的同学,已经有基础的读者也可以重点看后面的构建参数与问题排查部分。
1. LLVM 到底是什么,为什么它能长盛不衰
1.1 从“低级虚拟机”到编译器基础设施
很多人第一次听到 LLVM,以为它是一个像 JVM 那样的虚拟机。这个误解有历史原因——LLVM 这个缩写最初确实代表 Low Level Virtual Machine,也就是“低级虚拟机”。但后来项目发展得太快,官方索性不再给 LLVM 展开全称,它就是一个独立的名字,指代一整条开源编译工具链,以及它背后那一整套编译基础设施。
我在实际使用中的理解是:LLVM 的核心价值不在于它“编译”某个具体语言,而在于它提供了一套可以自由组合的编译能力。你用 clang 能把 C/C++ 编译到 x86 机器码,也能把同一份代码交叉编译到 ARM 或者 RISC-V;你用 opt 工具可以对一段中间代码做几十种不同的优化;你用 llc 可以单独把 IR 翻译成汇编文件。每一个环节单独拿出去都能当一个独立工具用,组合起来又能构成完整的编译器。这种模块化设计,是它和 GCC“一个二进制干所有事”的最大区别。
从工程角度看,llvm-project 是一个 monorepo,也就是把几十个子项目放在同一个 Git 仓库里统一管理。这样做的好处非常明显:所有子项目共享同一套 CMake 配置、同一个提交历史、同一次版本发布,避免了“编译器前端 14.0 配优化器 15.0 再用一个老掉牙的后端”这种版本错配的噩梦。我刚开始构建的时候以为要分别拉 clang 仓库和 llvm 仓库,后来才发现一条 git clone 命令把https://github.com/llvm/llvm-project.git拉下来,所有东西都在里面。
1.2 三段式架构:前端、优化器、后端
要理解为什么 LLVM 能做到“一套平台通吃所有语言”,需要看经典的编译器三段式架构。LLVM 把传统编译器的内部逻辑严格切成了三层,中间用统一的 LLVM IR(中间表示)来衔接。
拿 clang 编译一段 C 代码举例。clang 作为前端,把源代码解析成抽象语法树,再做语义分析,最后降级成 LLVM IR。IR 是一种接近底层的中间语言,有无限数量的虚拟寄存器,有显式的控制流图,但它不依赖任何具体 CPU 的指令集。第二层是优化器,也就是 opt 工具做的工作,它拿到 IR 以后,跑几十个优化 pass,比如死代码消除、循环展开、函数内联、自动向量化。这些 pass 不知道也不关心你最终要跑在什么 CPU 上,它们只操作 IR 本身。第三层是后端,llc 把优化后的 IR 翻译成具体平台的汇编代码和机器指令,这时候才真正涉及寄存器分配、指令调度、指令选择这些硬核问题。
用一个生活化的类比:前端相当于把一本外文书翻译成“人类通用语”的初稿;优化器相当于编辑在初稿上做删改润色,让它更精炼、更有条理;后端相当于再把通用语翻译成某个特定国家的方言,让当地读者一看就懂。方言有很多种,但通用语只有一种,这就是为什么新的编程语言只需要写一个前端就能获得 LLVM 所有后端支持。Rust 最初选择 LLVM 作为代码生成后端,Swift 也是,这个决策看中的就是“只写一次前端,得到全平台支持”。
1.3 为什么值得花时间研究 LLVM
我的切身体会是,LLVM 的学习价值远超“会用 clang 编译程序”这个层面。它是现代编译技术最完整的开源样本,源码里能同时看到经典的龙书算法和工业界最新的工程实践。比如想看寄存器分配,有基础的线性扫描算法,也有新的贪心算法;想看自动向量化,有循环向量化器、SLP 向量化器,还支持显式 SIMD 编程模型。
而且 LLVM 的应用场景早就超出了传统编译器。我后来在做 GPU 相关开发时发现,很多图形驱动和 AI 芯片编译器都把 LLVM 当作代码生成后端来用。比如 Mesa 里的 llvmpipe 软件渲染器,会把着色器编译成当前 CPU 的 SIMD 机器码,这背后就是 LLVM 的能力。再比如 OpenCL 的离线编译器、CUDA 的前端编译流程,大量项目都建立在 LLVM 之上。掌握 LLVM 的构建、使用和调试方法,等于掌握了一整套可以复用的底层代码生成能力,这在性能敏感领域是极其值钱的技能。
2. llvm-project 仓库里到底有什么
2.1 仓库结构总览
第一次进到 llvm-project 目录里,很多人会愣住,因为里面不是“一堆编译器源码”,而是几十个顶层目录。这里我把自己实际经常用到、构建时也最常碰到的几个子项目整理一下:
| 子项目目录 | 作用 | 什么时候用 |
|---|---|---|
| llvm/ | 核心代码,包括 IR、优化器、后端、llc/opt/llvm-as 等工具 | 必选 |
| clang/ | C/C++/Objective-C 编译器前端 | C/C++ 开发者必选 |
| lld/ | 高性能链接器,速度远快于 GNU ld | 替代系统链接器时选 |
| libc++/libc++abi/ | C++ 标准库实现 | 需要新标准库时选 |
| compiler-rt/ | 运行时库,包含 sanitizer、builtins、profile | 做安全检测和覆盖率时选 |
| lldb/ | 调试器,类似 GDB | 调试 LLVM 生成代码时选 |
| polly/ | 多面体优化器,做循环嵌套优化 | 研究高级优化时选 |
| mlir/ | 面向编译器与硬件加速的多级中间表示框架 | AI/芯片/算子开发时选 |
| flang/ | Fortran 前端 | Fortran 用户选 |
| clang-tools-extra/ | clang-tidy、clang-format 等工具 | 工程化必选 |
| openmp/ | OpenMP 运行时与编译支持 | 多线程 / HPC 场景选 |
| libunwind/ | 栈回溯库,用于异常处理 | 配合 libc++ 时选 |
这个表看起来列了很多项目,但实际构建时不需要全部编译。我最开始在构建时图省事,直接把整个仓库丢给 CMake 让它全量编,结果不仅编译时间翻了几倍,中间还因为个别子项目依赖问题报错。后来学乖了,只启用自己真正需要的项目,构建速度快了很多,磁盘占用也大幅降低。
2.2 子项目之间的依赖关系与版本配套
很多人不知道的是,这几个子项目之间存在依赖关系。比如 libc++ 的测试会依赖 clang,lld 的测试会依赖 LLVM 提供的测试工具链,compiler-rt 直接由 clang 驱动。所以当你用LLVM_ENABLE_PROJECTS指定要构建的项目时,CMake 会自动处理一部分依赖,但不会“好心”地把你需要但没指定的项目一起加进来。这就容易出现“我只启用了 clang,结果 lld 可用,但用 lld 链接时版本不匹配”的尴尬局面。
我常用的稳定组合是LLVM_ENABLE_PROJECTS="clang;lld;clang-tools-extra"。日常做 C/C++ 开发、代码静态检查、链接加速都够了。如果要跑 sanitizer,就把compiler-rt也加进来。但这里要提醒一下:每增加一个子项目,构建时间不是线性增长,而是接近二次增长,因为每个项目都要各自生成一遍中间文件和测试工具。选项目之前先想清楚,装一个巨大的工具链却只用到 10% 的功能,性价比不高。
2.3 LLVM 15.0.7 这个版本号意味着什么
版本号里藏着工程节奏的秘密。LLVM 采用固定发布周期,每年一个大版本推送,15.0.0 发布于 2022 年 9 月。x.0.0 是大版本,带新特性和破坏性 API 变更;x.y.z 里的 y 是次版本,z 是补丁版本。15.0.7 属于 5.x 系列的补丁版本,主要修 bug,不引入新特性,所以它对应的功能要点基本都能从 15.0.0 的 release notes 里找到。
我记得 LLVM 15 比较有标志性的改动包括:默认启用 C++17 标准构建自身、新的 AMDPAL 后端改进、OpenMP 支持的增强、以及一些向量化能力的完善。对使用层面来说,最直观的感受是构建本身更快了,对部分新 CPU 的调度模型支持更准确了。不过我更想强调一个实用观点:如果你的项目已经稳定跑在某个 LLVM/Clang 版本上,尽量不要因为“出新版本了”就立刻升级工具链,除非确实需要某个新特性或新架构支持。编译器是构建链路的地基,地基一动,上层所有构建脚本、链接参数、优化选项都可能受影响。我一般在生产环境用 x.y.z 系列的最后几个补丁版本,比如 15.0.7,既稳定又有 bug 修复。
3. 从零构建 LLVM+Clang:一次完整的实操记录
3.1 环境准备与依赖清单
构建其实没有想象中那么玄乎,但准备工作做不好会一路踩坑。我以 Linux x86_64 环境为例,先说一下我实测下来最顺手的依赖组合:
- 系统:Ubuntu 22.04 或更新的发行版
- C/C++ 编译器:GCC 11+ 或者 Clang 14+,用来编译 LLVM 自身
- CMake:3.20 以上,推荐 3.24+
- Ninja:1.10+,如果你之前一直用 make,强烈建议切到 Ninja,构建并行度更好
- Python 3.8+:构建系统脚本用
- zlib、libxml2、ncurses:部分工具链运行时依赖
Ubuntu 下一条命令装齐全:
sudo apt update sudo apt install build-essential cmake ninja-build python3 python3-pip zlib1g-dev libxml2-dev libncurses-dev这里有个容易忽略的细节:LLVM 自身对构建它的编译器版本也有要求。用太老的 GCC 编译新版本 LLVM 会直接编译失败,报错往往出现在模板或者 C++17 标准库头文件上。如果系统自带编译器版本太老,建议先用apt install clang装一个新的 clang 来编译 LLVM,也就是“用 clang 编译 clang”。我在旧版本的 Ubuntu 上就遇到过一次这种问题,后来加装 clang 当引导编译器就顺利通过了。
3.2 CMake 配置参数逐项拆解
构建 LLVM 的 CMake 命令看似参数很多,但真正关键的其实就五六个。下面这个命令是我在 x86_64 Linux 上构建 Release 版 LLVM+Clang+lld 实际跑过的完整版本:
git clone --depth 1 --branch llvmorg-15.0.7 https://github.com/llvm/llvm-project.git cd llvm-project cmake -G Ninja llvm \ -B build \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_PROJECTS="clang;lld" \ -DLLVM_TARGETS_TO_BUILD="X86" \ -DLLVM_ENABLE_ASSERTIONS=OFF \ -DCMAKE_INSTALL_PREFIX=/opt/llvm-15.0.7逐个说一下我为什么这么配置。-G Ninja指定生成 Ninja 构建文件,Ninja 比 Make 更擅长并行调度,这在 LLVM 这种日均几十万行编译任务的项目上是质变。CMAKE_BUILD_TYPE=Release会用-O3优化编译 LLVM 自身,出来的二进制性能最好;缺点是编译时间会变长。想做开发调试就改成RelWithDebInfo,带调试信息但仍有优化,比纯 Debug 版快不少,也能下断点。
LLVM_ENABLE_PROJECTS前面已经说过,按需选择要构建的上层项目。LLVM_TARGETS_TO_BUILD="X86"是最容易忽略性能的优化:如果不指定,LLVM 默认把 X86、ARM、AArch64、PowerPC、MIPS、RISCV 十几个后端全部编进去,编译时间和二进制体积都会翻好几倍。像我只在 x86 上做开发和测试,就只留 X86,其他都砍掉。等真正需要交叉编译某个目标平台,再加对应后端然后重新构建,一劳永逸。
LLVM_ENABLE_ASSERTIONS=OFF也很重要。默认情况下 LLVM 内部带大量断言检查,主要用于开发调试时提前发现问题。但这些断言会显著拖慢运行速度,而且会引入额外依赖。如果是装到服务器上做日常编译用,一定要关掉。如果是自己啃 LLVM 源码做二次开发,再打开,断言能帮你尽早发现算法的逻辑错误。CMAKE_INSTALL_PREFIX则是指定安装路径,推荐装到独立的/opt/llvm-15.0.7这种目录,便于多个版本共存切换。
3.3 资源预算与并发控制策略
构建 LLVM 是一个重资源活,我见过不少初学者没预估好资源,编译到一半直接被 OOM kill。这里把我实测的资源预算写出来:
- 磁盘空间:llvm-project 源码大约 1.5GB,构建目录至少要留 30GB,如果要构建全部项目和测试,建议留 60GB 以上
- 内存:并发
-jN下,N 个编译任务大约每个占 2~3GB 内存,链接阶段更需要 3~5GB。也就是说 4 核 8GB 的小机器,-j4编 Release 版本都比较勉强 - 时间:8 核 CPU + NVMe 硬盘,只构建 clang+lld,大约 20 到 40 分钟;16 核大约 10 到 15 分钟
我之前在一台 4 核 8GB 的老服务器上全量构建过,跑到链接阶段内存直接打满,系统卡到无法操作。后来把并发调低成-j2,又加了 zram 交换空间,才顺利跑完。如果你用 Ninja,直接执行ninja -j2覆盖编译并发数即可。这里的核心思路是:宁愿让 CPU 等内存,也不要让内存等 CPU,否则频繁换页反而更慢。
另外强烈建议装 ccache,它能缓存 C/C++ 编译产物。LLVM 这种庞大且经常改源码的项目,有了 ccache 之后每次增量编译能省下大量时间。我的做法是:
sudo apt install ccache export CCACHE_DIR=/mnt/ssd/ccache # 放到大容量快速磁盘上 ccache -M 20G然后给 CMake 配上 CC/CXX 包装器,增量构建速度提升非常明显。平时改几个 pass 源码再重编,大多直接命中缓存,链接时间才是主要开销。
3.4 构建安装与功能验证
配置完成后,正式构建就一句话:
ninja -C build想要安装到指定目录,再执行ninja -C build install。安装完成后,检查一下版本信息:
/opt/llvm-15.0.7/bin/clang --version /opt/llvm-15.0.7/bin/llc --version如果输出了clang version 15.0.7和LLVM version 15.0.7,说明核心链路已经可用了。我还会习惯性写一个小的 C 程序做端到端验证:
#include <stdio.h> int main() { printf("hello, llvm\n"); return 0; }然后执行:
/opt/llvm-15.0.7/bin/clang -O2 hello.c -o hello ./hello确认输出hello, llvm。再执行readelf -p .comment hello查看注释段,能看到GCC: (Ubuntu 11.x)或者clang 15.0.7,这能确认链接是否用的新 lld。想验证 lld 的话,加上-fuse-ld=lld选项重新编译一次,对比链接时间就能直观感受到 lld 和 GNU ld 的速度差异,实测小项目可能差别不大,但大项目会差出好几倍。
4. llvmpipe:为什么图形渲染也要编译技术
4.1 llvmpipe 是什么,它和 LLVM 是什么关系
看到 llvmpipe 这个名字的时候,很多人会困惑:一个软件渲染器为什么挂着 LLVM 的名字?事情要从 Mesa 3D 图形库说起。Mesa 是 Linux 上 OpenGL/Vulkan 驱动程序的开源实现,正常情况下它调用 GPU 的硬件加速接口。但当系统里没有合适的 GPU、或者虚拟机环境没有浮点 GPU 直通能力时,就需要一个纯 CPU 渲染的兜底方案,这个兜底方案就是 llvmpipe。
llvmpipe 的特点是“用 LLVM 做实时代码生成”。它会把 OpenGL/Vulkan 的着色器(GLSL/SPIR-V)在运行时动态编译成当前 CPU 的机器码,然后针对大量像素数据做并行计算,从而在纯 CPU 环境下跑出 OpenGL 4.5 级别的效果。由于 Mesa 项目长期跟 LLVM 同步发布版本,所以你在apt里看到的库版本往往带着15.0.7这类和 LLVM 一致的标记。我之前在云服务器上做无 GPU 的离屏渲染测试,就是靠它跑起整个 OpenGL 上下文的,虽然帧率不高,但功能完整性极强。
4.2 编译器在渲染管线里扮演的角色
传统图形 API 管线大致是:CPU 提交绘制命令和顶点数据,GPU 执行顶点着色器、几何处理、光栅化、片段着色器。在纯软件渲染的 llvmpipe 里,这些逻辑全都得用 CPU 指令模拟出来,只靠解释执行着色器代码是不可能满足性能要求的,所以 LLVM 的 JIT 能力就成了关键。
我用一句话总结它的核心流程:llvmpipe 把着色器从 API 无关的中间表示翻译成 LLVM IR,再用 LLVM 的优化器对 IR 做针对当前 CPU 的调优,最后调用后端生成 x86/ARM 的机器码,然后以一个“函数指针”的形式缓存下来,之后每一帧渲染直接调用这段机器码,再也不走解释执行。这种做法本质上就是“把着色器当成编译器的源码,把 GPU 当成目标硬件,把 CPU 当成编译执行引擎”,LLVM 在这里充当的是即时编译器角色,作用跟 Java 的 JIT 编译器很相似。
这个设计最妙的地方在于:LLVM 优化器本身就很懂怎么把循环、向量化做好,而光栅化和像素处理天然是数据并行任务。所以 LLVM 不需要为图形领域做特殊适配,只靠通用的优化能力就足以让软件渲染性能做到可用级别。我在调试 llvmpipe 源码时发现,gallivm模块几乎全部工作都是在拼接 LLVM IR 构建指令,然后用LLVMGetTargetMachine和LLVMTargetMachineEmitToMemoryBuffer这类 API 完成机器码生成,代码结构非常清晰,很适合作为学习 LLVM JIT 接口的活教材。
4.3 “256 bits”到底在说什么
你看到的 15.0.7 后面的 “256 bits”,指的是 SIMD 指令宽度。现代 x86 处理器从 Haswell 开始普遍支持 AVX2,它的向量寄存器 YMM 宽度是 256 位,可以一次对 8 个 float 或者 4 个 double 做运算。GPU 像素着色天然适合做这种“同一份代码处理大量数据”的运算,所以 llvmpipe 在生成代码时非常依赖 LLVM 的向量化能力。
LLVM 内部用VectorType来表示这类数据并行计算,比如<8 x float>就是一个包含 8 个 float 的向量类型。优化器负责把标量循环改写成向量计算,后端再根据当前 CPU 支持的 SIMD 指令集做选择和拆分。如果目标 CPU 是 AVX2,它会尽量生成 256 位向量指令;如果是 AVX-512,就会进一步生成 512 位向量指令。这里有个关键匹配点:llvmpipe 在运行时会读取 CPU 的特性标志,询问 LLVM 当前机器支持哪些扩展,然后设定 Target Machine 的属性,LLVM 在指令选择阶段就能生成对应宽度的代码。
我对这个“256 bits”的直观感受,是用 clang 写一段循环做数组求和,然后分别用-mavx2和-mno-avx2编译,对照生成的汇编代码。加-mavx2以后明显能看到vmovups、vaddps这类操作 256 位数据的 YMM 指令,寄存器带宽翻倍,循环迭代次数减半。对于 llvmpipe 这种纯软件渲染器来说,能用上 256 位向量指令是非常大的性能红利。
5. 构建和开发中的常见问题与排查实录
5.1 构建慢到怀疑人生怎么办
我在社区里看到不少第一次构建 LLVM 的人发帖抱怨:编了一两个小时还没完。这种情况十有八九是没有限制目标平台和项目范围。默认全平台后端编译,等价于多编了好几遍代码。我的排查思路是先用以下命令确认到底编了哪些目标:
grep "LLVM_TARGETS_TO_BUILD" build/CMakeCache.txt grep "LLVM_ENABLE_PROJECTS" build/CMakeCache.txt如果显示的是LLVM_TARGETS_TO_BUILD:STRING=all,那恭喜你踩了最常见的坑。修改它不需要重新克隆仓库,直接重新跑一次 CMake 配置即可,但增量构建可能仍然会重新编译之前没有被限制掉的代码。最优方案还是从一开始就限制好范围。
另外,如果编译时间已经爆炸,别硬等。Ctrl+C 中断,然后重新配置成只构建自己需要的子项目,再ninja增量编译,这种操作并不会浪费之前编译的产物太多,反而能节省大量等待时间。
5.2 链接阶段内存不足直接被 kill
这是一个非常典型的失败模式:前面编译了好几个小时,到链接 clang 二进制时系统直接 OOM,进程被杀。原因在于 clang 是一个特大号的二进制,所有对象文件在链接阶段需要同时加载进内存,再加上 LLD/GNU ld 的符号表处理,内存峰值能到 4~6GB。
我的处理方案分三步,按优先级排列:
- 降低并行度
ninja -j1或者-j2,让多个链接任务不要同时跑 - 换用 lld 来链接 LLVM 自身,在 cmake 配置时加
-DLLVM_USE_LINKER=lld,lld 的内存占用远低于 GNU ld,大部分场景都能缓解 OOM - 加交换空间,但只作为最后兜底手段,因为换页会大幅拖慢链接速度
我个人的经验是,把LLVM_USE_LINKER=lld和-j2搭配起来,8GB 内存的老机器也能顺利完成 clang 的链接。
5.3 测试报错与回归定位
构建成功不等于功能没问题。LLVM 自带庞大的测试套件,用 lit 框架组织。第一次跑测试时很多人会被它庞大的测试数量吓到,有几千个用例,但实际上手只需要关注你修改或依赖的模块。
常用命令是:
ninja -C build check-llvm ninja -C build check-clang如果想只跑某一个测试文件,可以直接用 lit:
python3 llvm/utils/lit/lit.py -sv build/test/CodeGen/X86/vec_add.ll日常做开发时,我习惯每次改动后先跑当前模块的测试,全部通过再跑全量测试。全量测试非常耗时,即使是很小的改动也可能触发大量回归用例。遇到失败用例时,先看是不是环境问题——比如缺少系统依赖、文件路径硬编码、CPU 特性差异。再考虑是不是自己改动破坏了某个 pass 的假定。比如我在调试一个循环向量化 pass 时,发现某条 X86 测试用例结果变了,最后定位到是因为我改了默认的最小循环迭代次数参数,导致向量化阈值变化。回归用例的存在就是在帮你提前暴露这种影响面。
5.4 版本升级后的 API 变更与源码适配
LLVM 的 API 稳定性策略跟很多项目不太一样,它允许在大版本间做破坏性的重构,开发者每次升级 15 到 16 甚至跨大版本,都会遇到 API 迁移。刚开始时我吃过不少亏,比如llvm::FunctionPass在 16 里被明确标记为过时,推荐迁移到新的 pass manager,而新老 pass manager 的接口差异非常大,一旦写错直接编译不过。
我建议的做法是,若长期以 LLVM 源码为依赖,盯紧两个窗口:一个是llvm/include/llvm/里的头文件改动,一个是官方每年发布的迁移指南。遇到编译错误先不要硬改自己的代码,看看是不是头文件里的接口已经变了,搜索一下新版本里有没有同名替代接口。比如 15 到 16 中createLegacyPMFunctionPassManager这类接口的移除,就需要先查 release notes 再决定怎么改。久而久之,这种迁移也变成一种能力,在社区里提问时,别人也会根据你用的版本号给你精确建议,而不是丢一句含糊的“升级吧”。
写在最后的小经验
构建和使用 LLVM 本身并不玄学,但它的学习曲线确实比一般开源项目陡峭。我在几个项目里反复用过它之后,最大的体会是“把 LLVM 当作一个可以编程的编译器库”来理解,远比当作一个命令行工具来使用更重要。llvm-project 这套代码值得有针对性地深挖,比如你关心性能优化,就去看llvm/lib/Transforms/Vectorize;你关心后端代码生成,就去看llvm/lib/Target/X86。源码本身组织得相当清晰,配合opt -passes=... -S看 IR 变化,观察效果非常直观。还有一个实用的小技巧:多利用llvm-mca做静态性能分析,它能不运行程序就评估出指令流水线表现,调试性能问题时比反复跑基准测试高效得多。这些都得在真实项目里反复折腾才能积累出感觉,希望这篇博文能帮你少走一些弯路。