
干编译器这一行的或者跟底层性能打交道的工程师这几年几乎绕不开一个名字——LLVM。我最早接触 llvm-project 这个仓库还是因为工作中要换掉一套老旧工具链当时被 GCC 的耦合架构折腾得够呛后来切到 LLVM 体系才算真正体会到什么叫“模块化”和“可控”。这仓库不是某一个单一项目而是 LLVM 核心库、Clang、LLD、libc、Compiler-RT、MLIR 等一系列子项目的集合体说白了就是一套完整的编译器生态底座。本文我想从 llvm-project 这个标题出发把它拆开揉碎讲清楚这个仓库到底解决了什么问题、里面每个子项目是干嘛的、新手怎么从零构建并跑通第一个自定义 Pass以及热词里那个 llvmpipe 背后的向量化性能真相。这篇文章适合三类人看一是刚接触 LLVM 想建立整体认知的开发者二是需要基于 LLVM 做工具链改造或定制优化的工程师三是对 llvmpipe 这类软件渲染实现好奇、想理解 SIMD 向量化在实际工程中怎么发挥作用的同学。1. LLVM 项目到底在解决什么问题1.1 传统编译器架构的痛点在 LLVM 出现之前主流编译器的架构基本是 GCC 那种“前端后端强耦合”的模式。一个语言的前端比如 C 的解析器直接对接特定 CPU 的后端比如 x86 的代码生成器两层之间的接口是私有且不稳定的。这意味着你想给一种新语言搭编译器或者想支持一款新的芯片架构几乎要从零开始把前端和后端之间的所有适配工作全部重做一遍。这种耦合有多痛苦我举个实际例子当年我们团队想基于 GCC 做一个面向内部 DSP 芯片的编译工具链光是理解 GCC 的 GIMPLE 中间表示和 RTL 后端那套抽象就花掉了一个季度。这还只是学习成本后面每次上游 GCC 升级所有补丁都要重新适配维护成本高得吓人。1.2 LLVM 的破局思路三层设计LLVM 的架构把这个痛点彻底拆掉了。它把编译器分成三个清晰的阶段前端负责把源代码解析成中间表示中端优化器只对中间表示做变换后端再把优化后的中间表示翻译成目标机器的汇编代码。这个中间表示就是 LLVM IR它是整个架构的基石。IR 有严格的静态单赋值形式每个变量只被赋值一次这让优化器在分析数据流和控制流时能拿到很规整的信息很多优化算法写起来比在源码级做要干净得多。这个三层设计带来一个非常直接的好处新增一种编程语言只需要写一个新的前端把语法树降级成 IR就能直接复用中端优化器和所有后端——包括 x86、ARM、RISC-V、NVPTXGPU等。反过来想支持一个新 CPU 架构只要实现一个从 IR 到该架构指令集的后端所有前端语言就都能在这块新芯片上跑起来。我当年从 GCC 体系切到 LLVM 之后最深的感觉就是接口是公开的、文档是齐的、IR 规则是确定的做定制的幸福指数高了一个数量级。这里值得多提一句 IR 的三个层级。LLVM IR 分为内存中的表示、字节码bitcode和可读文本三者可以互相转换。实际工程里我经常用可读文本去定位问题因为可以用llvm-dis把 bitcode 反汇编成有语义的文本再用opt一个 Pass 一个 Pass 地验证优化效果。这种“可读、可测、可停靠”的性质在 GCC 里很难找到对应体验也是 LLVM 作为编译器基础设施特别吸引人的地方。1.3 llvm-project 仓库的组织形式llvm-project 采用单仓库多项目的组织方式所有子项目共享同一套构建系统和发布节奏。这样做的好处是很明显的各子项目之间有稳定的 API 对齐比如 Clang 生成 IR 的版本一定和同仓库里的 LLVM 核心优化器匹配。以前用独立维护的 LLVM 和 Clang 包时经常遇到库版本冲突现在拉取同一个 tag 构建这类问题几乎绝迹。仓库主要在 GitHub 上维护master 分支是滚动开发版稳定版用 release/15.x 这样的分支管理比如热词里提到的 LLVM 15.0.7就属于 15.x 这条发布分支。2. llvm-project 仓库里的核心组件拆解2.1 LLVM 核心库与 opt 工具LLVM 核心库是整个项目的发动机。它实现了 IR 的定义、Pass 优化框架、目标描述TableGen、指令选择、寄存器分配、指令调度和代码生成等一整套编译器后端基础设施。平时我们用opt工具来加载并运行单个 Pass用llc做代码生成。Pass 框架尤其值得了解优化器里每一个优化步骤都封装成一个 Pass比如死代码消除、内联、循环展开它们之间可以通过依赖关系组成 pipeline。这种“小 Pass 拼装”的设计和乐高积木一样你既可以用默认的 pipeline也可以自定义顺序专门调试某个优化对性能的影响。我在实际工作中最常用的一个排查手段是先用clang -O2 -emit-llvm -c生成 bitcode再用opt -passesinline,mem2reg这种指定 Pass 的写法验证单个优化是否生效。这个流程在 GCC 里对应的是看 GIMPLE dump但 LLVM 的 dump 信息粒度更细、更可控改完一个 Pass 能立刻看到 IR 的变化对做性能回归定位特别有帮助。2.2 ClangC/C/Objective-C 前端Clang 是 llvm-project 里用户量最大的一个子项目。它把 C、C、Objective-C 源码解析成语义完整的 AST再降级成 LLVM IR。相比 GCC 的前端Clang 有几个特点在工程里特别受用编译错误信息极其友好能直接指出代码的精确行列和上下文模块化设计允许第三方直接调用 Clang 的库来做代码分析和重构很多 lint 工具和自动补全引擎就是基于它的 LibTooling 做的编译速度在同场景下通常也有优势尤其是在 debug 构建和增量编译时。Clang 还支持-fpass-plugin这种加载动态 Pass 插件的机制不需要重新编译整个工具链就能把自定义优化插到 Clang 的优化流程里。这个特性对做企业级工具链定制来说价值极大我后面写第一个 Pass 示例时就会用这种机制演示。2.3 LLD高性能链接器LLD 是 LLVM 体系的链接器它的核心卖点就是快。传统 GNU ld 在链接大型 C 项目时经常要几秒钟甚至十几秒LLD 在同等场景下往往能做到几百毫秒。它的实现思路是并行化把所有输入文件的符号表和重定位信息并行读取再用高效的数据结构做符号解析DDLR分布式动态链接重定位等算法也设计得很精巧。实际项目里接入 LLD 很简单给 Clang 传-fuse-ldlld即可。我在一个几百万行 C 的服务端工程里实测过链接时间从 38 秒降到了 6 秒左右增量开发的体感提升非常明显。如果你还在用 GNU ld看完这个数据应该会很动心。2.4 libc、Compiler-RT、LLDB 与 MLIRlibc 是 LLVM 推出的 C 标准库实现配对使用的还有 libcabi提供 ABI 支持。在需要完全掌控标准库行为、或者使用较新 C 标准特性的场景下很多团队会启用 libc。Compiler-RT 提供编译器运行时的底层支持涵盖各类 sanitizerASan、UBSan、TSan 等和覆盖率钩子被称为工程调试三件套后面第 4 节里会展开讲。LLDB 是 LLVM 的调试器复用 Clang 的表达式求值能力在 macOS/Xcode 生态里是默认调试器在 Linux 上用 LLDB 的体验也正在快速接近 GDB。MLIR 则是一个更上层的东西它允许你在 LLVM IR 之上搭建多级中间表示适合做深度学习编译器、领域特定语言编译器等但新手可以后置了解不必一上来就啃。这些子项目之间的关系可以这样理解LLVM 核心是基础设施Clang 是把高级语言翻译到基础设施上的入口LLD 负责把翻译出来的目标文件链接成可执行文件libc 提供标准库服务Compiler-RT 提供运行时辅助LLDB 负责事后排查。它们组合在一起构成了一条从源码到二进制再到调试的完整工具链流水线。3. 从零上手构建环境与第一个 Pass 开发3.1 硬件与磁盘规划llvm-project 全量构建对机器是有要求的别指望一台 4GB 内存的笔记本能轻松跑完。我的建议是内存至少 16GB磁盘至少留 50GB 空间CPU 核心数越多越好。Release 模式下编译 LLVM 全套组件非常吃资源实测一个全量构建可能会消耗 20 到 30GB 磁盘tmp 目录也要留意。当然如果你只是想体验一下不构建 Clang 和全部后端只构建核心和 opt 工具磁盘开销能降不少。重要经验永远在独立的 build 目录下构建不要在源码目录里直接生成产物。mkdir build cd build这步操作应该成为习惯。原因很简单LLVM 的 CMake 构建系统会生成大量缓存文件和中间文件混在源码目录里一旦想切分支或者清理缓存非常容易出问题。同时候选编译器建议用 GCC 或 Clang 的高版本避免因编译器自身缺陷触发 LLVM 构建失败。3.2 构建命令的精简与优化构建 llvm-project 最核心的步骤就是配置和编译两步。CMake 配置命令可以这样写cmake -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_ENABLE_RUNTIMESlibcxx;libcxxabi \ -DCMAKE_INSTALL_PREFIX/opt/llvm-15 \ ../llvm这里LLVM_ENABLE_PROJECTS用于指定要构建的前端工具链项目LLVM_ENABLE_RUNTIMES用于构建运行时库。注意../llvm这段它指向的是源码树中的 llvm 子目录这是 llvm-project 仓库布局的特点所有的构建入口都走 llvm 工程那一套 CMakeLists。如果你的机器内存有限可以在配置时加上-DLLVM_PARALLEL_LINK_JOBS2限制链接并行度防止因内存不足 OOM。配置完成后执行ninja sudo ninja installNinja 是 LLVM 官方主推的构建系统相比 make它在增量构建和并行调度上的表现好得多。我强烈建议不要用 make直接上 Ninja能省很多时间。3.3 开发第一个自定义 Pass很多新手学 LLVM梦想就是写一个自己的 Pass。网上教程很多但入口路径各不相同有的建议改 Clang 源码有的建议用旧版 Pass 注册接口这其实很容易把人带偏。我推荐的标准路径是绕开 Clang 改动用 LLVM 15 默认的 new pass manager 接口写一个独立的 Pass通过opt -passes加载。完整的最小实现可以这样做。先建一个目录写一个CMakeLists.txtcmake_minimum_required(VERSION 3.20) project(MyPass) find_package(LLVM REQUIRED CONFIG) message(STATUS Found LLVM ${LLVM_PACKAGE_VERSION}) include_directories(${LLVM_INCLUDE_DIRS}) separate_arguments(LLVM_DEFINITIONS_LIST NATIVE_COMMAND ${LLVM_DEFINITIONS}) add_definitions(${LLVM_DEFINITIONS_LIST}) add_library(MyPass MODULE MyPass.cpp) target_link_libraries(MyPass PRIVATE LLVMCore LLVMSupport)然后写MyPass.cpp。下面这个 Pass 的功能很简单统计一个模块里的函数总数并打印出来#include llvm/IR/Function.h #include llvm/IR/Module.h #include llvm/IR/LegacyPassManager.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { struct FuncCountPass : public PassInfoMixinFuncCountPass { PreservedAnalyses run(Module M, ModuleAnalysisManager MAM) { unsigned count 0; for (Function F : M) { if (!F.isDeclaration()) count; } errs() [[MyPass]] Function count: count \n; return PreservedAnalyses::all(); } }; } // namespace extern C ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, MyPass, 0.1, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, ModulePassManager MPM, ArrayRefPassBuilder::PipelineElement) { if (Name func-count) { MPM.addPass(FuncCountPass()); return true; } return false; }); }}; }编译时把构建目录里的lib路径加入LD_LIBRARY_PATH然后加载验证clang -O2 -emit-llvm -c test.c -o test.bc opt -load-pass-plugin ./MyPass.so -passesfunc-count test.bc -o /dev/null跑通之后你可以在run函数里随便加自己的分析逻辑IR 上几乎所有信息都能拿到指令类型、操作数、基本块数量、循环深度等等。这就是你的个人优化分析工具。常见误区是照着老版本的FunctionPass写法抄结果在 LLVM 15 上报一堆 API 不存在的错误。核心原因就是 LLVM 在 14 到 15 之间做了 old pass manager 到 new pass manager 的全面切换教材和网上资料更新速度没跟上这个坑几乎每个新手都会踩一遍。3.4 开发一个简单代码变换 Pass统计信息只是热身真正的 Pass 往往要做代码变换。我再举一个更具体的例子把函数内的add指令替换为等价的sub形式这纯属恶意演示但是可以展示指令修改的基本套路。for (BasicBlock BB : F) { for (Instruction I : BB) { if (auto *Op dyn_castBinaryOperator(I)) { if (Op-getOpcode() Instruction::Add) { IRBuilder Builder(I); Value *Sub Builder.CreateSub(Op-getOperand(0), Builder.CreateNeg(Op-getOperand(1))); Op-replaceAllUsesWith(Sub); Op-eraseFromParent(); } } } }这段代码里最关键的是replaceAllUsesWith和eraseFromParent的顺序不能反。你要先把所有使用旧指令的地方替换成新指令再删除旧指令否则会有野指针或者指令还留在基本块里导致运行时崩溃。这也是 LLVM Pass 开发里最常见的写过必踩的错误之一。4. 热词延展llvmpipe 背后的向量化与性能真相4.1 llvmpipe 是什么热搜词里出现的 llvmpipe出现在类似llvmpipe (llvm 15.0.7, 256 bits)这样的字符串里可能很多不接触图形栈的人看到它有点懵。简单说llvmpipe 是 Mesa 图形库里的一个软件渲染驱动它不依赖任何 GPU纯粹靠 CPU 来执行图形渲染管线。传统的 CPU 渲染器是按像素逐个处理效率低且扩展性差llvmpipe 的做法则是借助 LLVM 的 JIT 能力把渲染管线里的着色器编译成宿主 CPU 的机器码并且自动生成 SIMD 向量化代码。这里的256 bits指的就是 SIMD 向量宽度对应 AVX2 指令集体系下 256 位寄存器可以一次打包 8 个 float 或 4 个 double 进行运算。llvmpipe 的实际价值主要体现在这些场景没有独立显卡的云服务器跑 OpenGL 应用、CI 环境里做离屏渲染测试、临时调试图形程序而不想被驱动问题干扰、以及需要稳定可复现渲染结果的自动化回归测试。它的存在让“没有 GPU 也能跑图形程序”变成了一件非常可靠的事。4.2 256 bits 向量宽度的意义用数据说话可以这样理解 SIMD 加速如果有一段代码要计算 8 个 float 的和普通标量代码要一条一条指令执行256 位 SIMD 指令可以一次把 8 个 float 全部加载、全部相加、全部存回。理论上这是 8 倍的计算吞吐提升当然实际上受内存带宽、指令延迟和编译器生成质量影响很难达到完美的 8 倍但即使是 3 到 5 倍对于图形渲染这种数据密集型负载来说收益也极其可观。我拿 LLVM 的向量化能力做过一个很小的实验代码是循环内累计求和并乘系数编译时分别用-O2和-O2 -mavx2生成两版可执行文件跑 1 亿次迭代。前者耗时约 230ms后者约 65ms提升约 3.5 倍。这个例子说明同一份 IR在 LLVM 不同后端和不同 CPU 特性开关下能够生成完全不同的机器码。这也是 LLVM 在 llvmpipe 这类项目里被当作 JIT 引擎的原因它完美匹配“运行时生成最优代码”的需求。4.3 LLVM 的向量化能力如何支撑软件渲染llvmpipe 的工作流程大致是先把 GLSL 着色器源码翻译成 TGSI 或 NIR 中间表示再翻译成 LLVM IR最后在运行时通过 LLVM 的 ORC JIT 编译为当前 CPU 的机器码。关键点是它会对 IR 做自动向量化例如原本逐个像素执行的颜色计算会被合并成同时对多个像素执行的 SIMD 代码。这个过程中LLVM 的 Loop Vectorizer 和 SLP Vectorizer 起着决定性作用。前者负责把循环体内的标量操作变成向量操作后者负责把基本块内无依赖关系的多个相同操作合并成一条向量指令。这种模式代表了 LLVM 一个非常核心的能力同一套优化和代码生成框架既能服务于传统的 AOT 静态编译比如 Clang 编译 C 可执行文件也能服务于 JIT 场景比如 llvmpipe 运行时动态生成渲染代码还能服务于 GPU 编译器NVPTX 后端。作为开发者当你掌握了 IR 和 Pass 体系的思维后这些场景里的套路基本都是相通的。5. 常见问题与排查技巧实录5.1 构建阶段的典型问题配置构建 llvm-project 时我遇到最多的几类问题可以整理成一个速查表问题现象可能原因解决思路CMake 配置时报找不到 LLVMTableGen源码路径指向错误没指到 llvm 子目录检查../llvm路径是否正确ninja 编译时内存溢出链接并行任务过多设置LLVM_PARALLEL_LINK_JOBS2降低并行度编译报错unknown type name StringRef头文件路径或 LLVM 定义宏缺失确认include_directories已加入 LLVM include 路径加载 Pass 时报unknown pass name新版本 pass 命名与旧教程不一致确认使用的 Pass 注册接口和 pipeline 写法匹配当前 LLVM 版本链接错误符号未定义LLVM 库版本不统一确认find_package(LLVM)找到的版本和你 opt 程序一致这里要特别强调LLVM 的 API 在版本之间破坏性变更非常频繁LLVM 15 的代码放到 LLVM 17 往往编译不过。所以使用 LLVM 开发时第一原则是锁定版本尽量与生产工具链保持一致不要盲目追新。遇到看不懂的报错先看当前版本的 release notes很多坑官方其实写清楚了。5.2 调试 Pass 的实用技巧调试 Pass 最需要的工具是LLVM_DEBUG宏。它允许你在代码里这样写LLVM_DEBUG(dbgs() Processing function: F.getName() \n);然后运行 opt 时加上-debug-onlymy-pass-name就能只打印你关注的调试信息。如果要更细粒度地查看 IR 变化可以用print-after-all配合-filter-print-funcs来观察指定函数的优化过程。需要提醒一句不要在根目录下把opt的输出重定向到/dev/null却还开着全部 debug 选项输出量会大到你怀疑人生。正确的做法是先用小测试文件定位范围再逐步放开 debug 过滤条件。5.3 sanitizer 与性能调优的配合经验前面提到 Compiler-RT 里的 sanitizer 是调试神器。地址消毒器ASan可以检测内存越界、释放后使用等类型的问题未定义行为消毒器UBSan可以检测除零、整数溢出等未定义行为。在 LLVM 工具链开发中我通常的做法是先用 ASan 构建自己的 Pass跑测试用例确认无内存错误再用 UBSan 跑一遍排除未定义行为确认逻辑正确后再切到 Release 构建做性能对比。这样逐步构建起“正确性验证 性能验证”的双层防线做出来的 Pass 质量会稳很多。如果你开发 Pass 时发现优化没有生效优先确认两个点一是 Pass 是否真的被 pipeline 调用到了可以打印一个标记字符串验证二是 Pass 里的变换是否被后续 Pass 合法地简化掉这需要用print-after-all逐层看 IR。很多“优化不生效”其实是因为前面的 Pass 已经把代码形态改变了你的 Pass 没匹配上期望模式。6. 我对 LLVM 生态的几点切身体会与建议6.1 不要被体量吓倒从最小闭环开始llvm-project 的代码量极其庞大源代码拉下来就是好几 GB看头文件都能看懵。但我想说的是你完全不需要一开始就掌握所有东西。我的入门路径是先构建好工具链然后用opt做黑盒实验看各种 Pass 对 IR 的影响再慢慢去看某个优化的源码实现最后再自己动手写一个简单的分析 Pass。整个过程前置条件很少只需要掌握 IR 基本语法和 Pass 的注册机制就能形成不错的正反馈。很多人卡在第一周是因为想一口气把编译原理和 LLVM 源码全学完结果被复杂度劝退。完全可以先做一个小目标比如“看懂一个循环展开 Pass”两周内就能搞定信心建立起来之后再往深了走。6.2 把 LLVM 当成一个可编程的基础设施来用在我的理解里LLVM 真正的价值不只是“编译器”而是一个可重编程的基础设施。你可以用它的库来造静态分析工具、代码格式化工具、性能剖析工具、JIT 引擎、领域特定语言的编译器雏形等。理解到这一层后llvm-project 对你的意义就不再是一个需要“学习”的庞大项目而是一套随时可以调用的底层能力。我身边不少同事从编译团队跳到数据库内核团队、甚至图形团队都在用 LLVM 的思维和库来解决不同领域的问题这种迁移能力本身就是巨大的职业积累。6.3 再分享一个提升效率的小技巧最后说一个能显著减少迭代时间的操作在你自己的 Pass 开发目录里用脚本封装编译和测试流程类似build.shrun_test.sh。每次改完代码只执行一个命令就能完成编译、生成 IR、跑 Pass、比对输出的完整流程。不要小看这件事真实的 LLVM 开发迭代里每次手动敲一堆 cmake 和 opt 命令会大量消耗耐心。把流程脚本化是我个人觉得最划算的投入。如果你刚准备接触 llvm-project我的建议是先装好合适的版本跑通一个最小 Pass再逐步扩大范围这个生态给你的回报绝对值得前期的那些折腾。