
做编译器和性能优化相关工作的同学大概率都跟 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/libcabi/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 的测试会依赖 clanglld 的测试会依赖 LLVM 提供的测试工具链compiler-rt 直接由 clang 驱动。所以当你用LLVM_ENABLE_PROJECTS指定要构建的项目时CMake 会自动处理一部分依赖但不会“好心”地把你需要但没指定的项目一起加进来。这就容易出现“我只启用了 clang结果 lld 可用但用 lld 链接时版本不匹配”的尴尬局面。我常用的稳定组合是LLVM_ENABLE_PROJECTSclang;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 比较有标志性的改动包括默认启用 C17 标准构建自身、新的 AMDPAL 后端改进、OpenMP 支持的增强、以及一些向量化能力的完善。对使用层面来说最直观的感受是构建本身更快了对部分新 CPU 的调度模型支持更准确了。不过我更想强调一个实用观点如果你的项目已经稳定跑在某个 LLVM/Clang 版本上尽量不要因为“出新版本了”就立刻升级工具链除非确实需要某个新特性或新架构支持。编译器是构建链路的地基地基一动上层所有构建脚本、链接参数、优化选项都可能受影响。我一般在生产环境用 x.y.z 系列的最后几个补丁版本比如 15.0.7既稳定又有 bug 修复。3. 从零构建 LLVMClang一次完整的实操记录3.1 环境准备与依赖清单构建其实没有想象中那么玄乎但准备工作做不好会一路踩坑。我以 Linux x86_64 环境为例先说一下我实测下来最顺手的依赖组合系统Ubuntu 22.04 或更新的发行版C/C 编译器GCC 11 或者 Clang 14用来编译 LLVM 自身CMake3.20 以上推荐 3.24Ninja1.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 会直接编译失败报错往往出现在模板或者 C17 标准库头文件上。如果系统自带编译器版本太老建议先用apt install clang装一个新的 clang 来编译 LLVM也就是“用 clang 编译 clang”。我在旧版本的 Ubuntu 上就遇到过一次这种问题后来加装 clang 当引导编译器就顺利通过了。3.2 CMake 配置参数逐项拆解构建 LLVM 的 CMake 命令看似参数很多但真正关键的其实就五六个。下面这个命令是我在 x86_64 Linux 上构建 Release 版 LLVMClanglld 实际跑过的完整版本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_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_ASSERTIONSOFF \ -DCMAKE_INSTALL_PREFIX/opt/llvm-15.0.7逐个说一下我为什么这么配置。-G Ninja指定生成 Ninja 构建文件Ninja 比 Make 更擅长并行调度这在 LLVM 这种日均几十万行编译任务的项目上是质变。CMAKE_BUILD_TYPERelease会用-O3优化编译 LLVM 自身出来的二进制性能最好缺点是编译时间会变长。想做开发调试就改成RelWithDebInfo带调试信息但仍有优化比纯 Debug 版快不少也能下断点。LLVM_ENABLE_PROJECTS前面已经说过按需选择要构建的上层项目。LLVM_TARGETS_TO_BUILDX86是最容易忽略性能的优化如果不指定LLVM 默认把 X86、ARM、AArch64、PowerPC、MIPS、RISCV 十几个后端全部编进去编译时间和二进制体积都会翻好几倍。像我只在 x86 上做开发和测试就只留 X86其他都砍掉。等真正需要交叉编译某个目标平台再加对应后端然后重新构建一劳永逸。LLVM_ENABLE_ASSERTIONSOFF也很重要。默认情况下 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 硬盘只构建 clanglld大约 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-ldlld选项重新编译一次对比链接时间就能直观感受到 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:STRINGall那恭喜你踩了最常见的坑。修改它不需要重新克隆仓库直接重新跑一次 CMake 配置即可但增量构建可能仍然会重新编译之前没有被限制掉的代码。最优方案还是从一开始就限制好范围。另外如果编译时间已经爆炸别硬等。CtrlC 中断然后重新配置成只构建自己需要的子项目再ninja增量编译这种操作并不会浪费之前编译的产物太多反而能节省大量等待时间。5.2 链接阶段内存不足直接被 kill这是一个非常典型的失败模式前面编译了好几个小时到链接 clang 二进制时系统直接 OOM进程被杀。原因在于 clang 是一个特大号的二进制所有对象文件在链接阶段需要同时加载进内存再加上 LLD/GNU ld 的符号表处理内存峰值能到 4~6GB。我的处理方案分三步按优先级排列降低并行度ninja -j1或者-j2让多个链接任务不要同时跑换用 lld 来链接 LLVM 自身在 cmake 配置时加-DLLVM_USE_LINKERlldlld 的内存占用远低于 GNU ld大部分场景都能缓解 OOM加交换空间但只作为最后兜底手段因为换页会大幅拖慢链接速度我个人的经验是把LLVM_USE_LINKERlld和-j2搭配起来8GB 内存的老机器也能顺利完成 clang 的链接。5.3 测试报错与回归定位构建成功不等于功能没问题。LLVM 自带庞大的测试套件用 lit 框架组织。第一次跑测试时很多人会被它庞大的测试数量吓到有几千个用例但实际上手只需要关注你修改或依赖的模块。常用命令是ninja -C build check-llvm ninja -C build check-clang如果想只跑某一个测试文件可以直接用 litpython3 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做静态性能分析它能不运行程序就评估出指令流水线表现调试性能问题时比反复跑基准测试高效得多。这些都得在真实项目里反复折腾才能积累出感觉希望这篇博文能帮你少走一些弯路。