LLVM编译器基础设施解析:从IR、Pass到向量化与JIT实践

LLVM编译器基础设施解析:从IR、Pass到向量化与JIT实践 1. LLVM项目到底是什么为什么它改变了编译器格局第一次接触LLVM这个名字的时候我还在折腾GCC和GDB的搭配写C项目。说实话很多人刚开始会把LLVM和Clang搞混——当时有人跟我说“你用Clang试试”我还反问了一句“这跟LLVM不是一回事吗”。先把概念捋清楚LLVM是一套模块化的编译器基础设施项目而Clang只是其中一个用于C/C/Objective-C的前端。LLVM真正牛逼的地方不在于它又多了一个编译器而在于它把传统编译器的“前端-优化-后端”三层结构彻底解耦让任何人都能复用中间的优化框架和后端代码生成这是它地位这么高的根本原因。我最早对LLVM产生兴趣是看了Chris Lattner写的那篇关于LLVM架构的博士论文思路。传统GCC把前端、优化器、后端耦合得很深你没法单独拿其中的某个环节来做事情。而LLVM设计了一套稳定的中间表示IR前端负责把源代码翻译成IR优化器只对IR做变换后端把IR再翻译成目标机器码。就像一家餐馆把“买菜”、“洗菜”、“炒菜”三个环节彻底分开每个环节都可以换成别的人来做哪怕你不想吃包菜了你只需要换一个洗菜切菜的工序炒菜的人完全不受影响。另外要注意LLVM项目发展到今天已经不是“一个编译器”或者“一堆库”那么简单了。它实际上是整个编译工具链生态的代名词有处理C/C/Rust/Swift的Clang前端有LLD链接器有LLDB调试器有libc标准库实现有放置各类代码生成目标的TableGen描述语言还有支撑软件渲染的LLVMPipe后端等等。就我自己用下来的体会LLVM还在不断扩张自己的边界从编译器逐步进化成了一套“可编程的代码分析、生成与优化基础设施”。这篇东西适合谁来读如果你只是想把C代码用Clang编译一下那点帮助不大但如果你是做编译原理课程作业、搞性能分析、想写自己的代码优化Pass、或者想搞清楚编译器后端为什么能生成那么高效的汇编那这篇文章应该能帮你省几周的绕路时间。我也想把我在实际项目里踩过的坑穿插进来尤其是关于LLVM 15.0.7这个版本的一些细节以及LLVMPipe在软件渲染场景下依赖256位向量寄存器的事情——这些在官方文档里可不会有人告诉你。2. 为什么要理解LLVM的模块化设计这对你意味着什么2.1 LLVM的三层架构到底解耦了什么LLVM最核心的设计理念就是“中间表示”的存在。我们拿一个简单的C代码举例int add(int a, int b) { return a b; }这段代码经过Clang前端翻译成LLVM IR之后大概是这个样子define i32 add(i32 %a, i32 %b) { entry: %sum add i32 %a, %b ret i32 %sum }注意看这里的i32意思是32位整数。它不绑定任何具体的CPU架构既不是x86的寄存器也不是ARM的寄存器就是一种抽象的描述我有个32位整数的加法运算结果返回给调用者。这层IR就是整个LLVM体系的“通用语言”前端都往这里翻译后端都从这里开始优化器在这里自由发挥。对开发者来说这个设计带来的好处非常实际如果你只是想做一个针对某个领域语言的编译器你不需要从头写代码生成。你只需要把源代码翻译成LLVM IR然后把IR交给LLVM让LLVM帮你搞定后续的优化、寄存器分配、指令选择、汇编输出。这相当于你把编译器里面最难也最繁琐的后端部分外包了出去自己只需要关注语言语义。我再强调一次这才是LLVM项目对编译器工业界最深远的影响它让“写一个高效编译器”的门槛从“精通各平台汇编”降到了“能生成IR”。2.2 用生命周期来理解IR、优化和目标代码我把一个源码文件从输入到输出的生命周期给你串一遍源码进入Clang前端经过词法分析、语法分析、语义分析生成AST。AST被降级为未优化的LLVM IR这个过程叫CodeGen。未优化的IR进入优化管线经过几十上百个Pass优化遍变成优化后的IR。优化后的IR进入后端如X86后端通过指令选择、指令调度、寄存器分配等步骤变成目标平台的目标代码。目标代码被汇编器转成机器码再由链接器合并库文件最终生成可执行文件。你可以在实践中亲眼观察这些环节。把上面那个add.c用Clang编译把中间产物展开看clang -S -emit-llvm add.c -o add.ll cat add.ll这条命令输出的就是IR文本。再进一步你可以用opt工具单独跑某一个优化Pass比如opt -passesmem2reg add.ll -S -o add.opt.ll这样你就能看到编译器在你的代码上“逐遍做手脚”的过程。有时候你写了一个优化Pass想看看它到底有没有生效用这种方式单步验看IR的变化比直接看汇编要高效得多因为IR比汇编抽象变量和类型信息都还在。3. 实战从零搭建LLVM 15.0.7环境并完成第一个自定义Pass3.1 为什么选LLVM 15.0.7如何不浪费时间地获得它很多新手上来就clone最新主干结果编译到一半发现依赖API变了或者文档跟代码对不上心态直接崩了。我的建议是如果你是为了学习和在真实项目里稳定使用优先选一个明确的release版本LLVM 15.0.7就是一个典型例子。这个版本发布于2023年初左右在稳定性、新特性、社区支持之间处于一个比较平衡的位置2023年到2024年间很多Linux发行版和第三方项目都采用或参考了这一代代码。它支持了不少重要特性默认使用opt -passes这种新的Pass管线语法、包含较稳定的Clang 15工具链、对C17/20的编译器支持已经相当成熟。获取源码有两种方案。一种是直接从官方GitHub仓库拉release taggit clone --depth 1 --branch llvmorg-15.0.7 https://github.com/llvm/llvm-project.git另一种是下载官方发布的源码压缩包。我实际经验里网络条件一般的情况下压缩包往往比深度克隆还快遇到断点续传工具还更好用。有些人喜欢直接拿发行版仓库里打包好的llvm源码包比如Debian/Ubuntu的apt source llvm-toolchain-15这样能顺便拿到发行版的补丁不过依赖关系会多一点新手我还是推荐官方release包。3.2 编译LLVM的正确姿势和关键参数LLVM这东西的编译耗时是出了名的全量构建在你只有8核16G内存的机器上轻松跑上一个多小时。但你不用真的去全量编掌握按需编译的技巧能帮你省大量时间。你需要先建一个独立的build目录Lattner团队一直推荐out-of-source构建也就是不要把build和src混在一起。我的习惯命令是这样cd llvm-project cmake -S llvm -B build \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_ASSERTIONSON cmake --build build -j$(nproc)这里每个参数都是有讲究的。-DLLVM_ENABLE_PROJECTSclang表示除了LLVM核心我还需要Clang前端所以在构建工具链时会一起构建。-DLLVM_TARGETS_TO_BUILDX86是很多人容易忽略的加速项如果全平台目标代码都生成编译时间和磁盘占用会大幅上升我只针对X86开发那就只构建X86目标这一项直接能把编译时间缩短将近一半。-DLLVM_ENABLE_ASSERTIONSON则是在LLVM内部启用断言检查对开发和调试自定义Pass很重要因为编译期断言能帮你尽早发现IR构造错误而不是到了运行时生成坏代码再排查。编译完成后你会在build/bin目录看到一堆可执行文件clang、llvm-as、llvm-dis、opt、llc等等。我最常用的组合是clang生成IR、opt跑Pass、llc生成汇编这三个工具构成了日常开发和调试的闭环。3.3 编写你的第一个LLVM Pass一个能做常量折叠的示例Pass是LLVM优化框架里“每一次变换”的载体所谓写Pass就是继承一个Pass类实现它的run方法。我这里用一个非常直观的示例写一个Pass把IR里add i32 2, 3这样的常量加法直接折叠成i32 5。新版的New Pass ManagerNPM接口是当前的主流LLVM 15里已经完全推荐这种写法了。我先把关键的实现代码列出来让你对结构有个直观认识#include llvm/IR/Function.h #include llvm/IR/IRBuilder.h #include llvm/IR/InstrTypes.h #include llvm/IR/LegacyPassManager.h #include llvm/Pass.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Transforms/Utils/Local.h #include vector using namespace llvm; namespace { struct ConstFoldPass : public PassInfoMixinConstFoldPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { bool changed false; std::vectorInstruction * deadInsts; for (auto BB : F) { for (auto I : BB) { if (auto *BO dyn_castBinaryOperator(I)) { Value *lhs BO-getOperand(0); Value *rhs BO-getOperand(1); if (isaConstantInt(lhs) isaConstantInt(rhs)) { ConstantExpr *folded ConstantExpr::get( BO-getOpcode(), castConstant(lhs), castConstant(rhs)); if (folded folded-getType() BO-getType()) { BO-replaceAllUsesWith(folded); deadInsts.push_back(BO); changed true; } } } } } for (Instruction *DI : deadInsts) { DI-eraseFromParent(); } return changed ? PreservedAnalyses::none() : PreservedAnalyses::all(); } }; } // namespace PassPluginLibraryInfo getPassPluginInfo() { const auto callback [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name const-fold) { FPM.addPass(ConstFoldPass()); return true; } return false; }); }; return {LLVM_PLUGIN_API_VERSION, ConstFoldPass, LLVM_VERSION_STRING, callback}; } extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getPassPluginInfo(); }别被这么多代码吓到你只需要抓住几个核心动作就行。第一个核心动作是遍历函数里的每一条指令找到BinaryOperator也就是add、sub、mul这类二元运算。第二个核心动作是检查它的两个操作数是不是ConstantInt也就是是不是常量。如果是就直接调用ConstantExpr::get生成一个折叠后的常量表达式然后用replaceAllUsesWith把原来的二元运算指令的所有引用替换成这个常量最后把原指令标记为dead并删除。这样IR里原来的%sum add i32 2, 3就变成了%sum add i32 5, 3不过严格说这个示例还很粗略完整的常量折叠还需要处理add nsw等标记这里只是为了把Pass的结构和写法讲清楚。关于为什么要显式处理deadInsts而不是在遍历的同时直接eraseFromParent这里有个新手必踩的坑在遍历Instructions的时候如果你直接删除当前迭代器指向的指令会使迭代器失效之后的操作就是未定义行为。正确做法是先记录哪些指令需要删除遍历结束后统一删除。别看这个问题小我在帮同事看代码的时候至少遇到了三次——全都是因为直接在循环里删指令导致崩溃。写完之后用CMake或者直接clang把它编译成动态库。以我常用的编译命令为例clang -shared -fPIC -fno-rtti \ $(llvm-config --cxxflags) \ const_fold.cpp \ -o ConstFoldPass.so \ $(llvm-config --ldflags --libs)注意LLVM本身默认不开RTTI所以你的Pass代码也要加-fno-rtti保持一致否则链接阶段经常冒出奇怪的“unexpected type name”错误。编译出来一个.so之后就是要用opt加载它跑一下opt -load-pass-plugin./ConstFoldPass.so -passesconst-fold add.ll -S -o add.folded.ll如果你看到IR里的常量加法被折叠成了立即数说明这个Pass已经生效了。我第一次跑通自己写的Pass时那种兴奋感确实有点难以形容——你从“会用编译器”走到了“能给编译器加功能”这一步。这一步对理解编译原理的重要性怎么强调都不为过。4. 深入LLVM的向量化与256位向量支持顺便聊聊LLVMPipe4.1 LLVM 15里的循环向量化是怎么工作的现在我们来聊一个性能敏感场景下大家肯定躲不开的话题自动向量化。CPU从最早的MMX到SSE再到AVX2向量宽度从64位一直扩展到256位速度翻倍背后的核心就来自于“一次指令同时操作多个数据”。LLVM的优化管线里有一个很重要的Pass叫做LoopVectorize它负责把循环体里的标量运算转换成向量运算。比如你写了一个for循环对数组每个元素做a[i] b[i] * 2标量版本是逐元素循环向量化之后可能变成一条vpmulld的AVX2指令一次处理8个32位整数。LLVM是否执行这个转换取决于它能否证明循环间的迭代彼此独立、内存访问没有重叠、以及目标平台是否有对应的向量指令集。你可以手动开启向量化opt -passesloop-vectorize -S add.ll -o add.vec.ll我在实际项目中检查向量化是否成功时最常用的就是-Rpassloop-vectorize这套诊断输出它能让编译器把“哪一行循环被向量化了”打印到屏幕上clang -O3 -mavx2 -Rpassloop-vectorize -Rpass-analysisloop-vectorize test.c有的循环它不向量化会明确告诉你原因“cannot vectorize: unsafe dependent memory operations in loop”等等。看到这种提示你就知道该去检查内存别名问题了。对性能敏感的程序员来说这种对话式的编译器反馈比盲猜快得多。4.2 为什么“256 bits”这个宽度值得注意在相关热词里出现的“256 bits”放在LLVM的语境下最典型的含义就是AVX2指令集的向量寄存器宽度。AVX2的YMM寄存器是256位宽可以一次放8个float或者4个double正好是x86平台上通用向量计算的主流宽度之一。在LLVM的IR层面它对应的是8 x float或4 x double这样的向量类型。选目标特性时很多人不清楚-mavx2和-mavx512f到底能带来多大收益。以加法为例一条AVX2指令一次处理8个float而一条标量指令只处理1个float理论上向量化后的峰值性能最多能提升8倍。但是AVX2不是免费的午餐它有一个频率限制的坑在里面运行AVX2密集计算时CPU会降低核心频率所以实际收益需要实测。而且LLVM后端做的事情不是简单地把每8条标量指令换成1条向量指令它还要处理数据布局、内存对齐、循环尾块等一堆问题。我自己在给图像处理库做优化时就经历过向量化之后反而更慢的尴尬局面原因就是数据没有对齐编译器不得不在每次循环前额外插入一堆数据整理指令。LLVMPipe是Mesa里基于软件渲染的驱动后端它本身对接的就是LLVM的JIT接口。当你的机器上金属驱动如Intel的ANV或AMD的RADV不可用或者环境需要软渲染时LLVMPipe会把图形着色器编译成宿主机的指令。这个过程中LLVM会尽可能利用宿主CPU的向量能力所以当检测到CPU支持AVX2时LLVMPipe内部生成的代码就会用上256位向量寄存器来并行处理像素和顶点数据。这也就解释了为什么软件渲染在弱鸡CPU上表现感人而在有AVX2的高性能CPU上还能勉强跑一些简单的图形程序。测试图形栈或者做CI验证的时候很多人就是靠它来跑一跑功能正确性的毕竟不用依赖物理GPU。4.3 JIT引擎LLVM不只是静态编译工具大多数人对LLVM的认知停留在静态编译器上但LLVM还有一个同样重要的运行时光辉——MCJIT和OrcJIT。OrcJIT是现在主流的JIT编译引擎它的思想是让程序在运行时把IR编译成机器码并直接执行。这跟LLVMPipe其实是一个逻辑运行时需要一段代码喂给LLVM去优化和生成然后把生成好的函数指针拿回来直接调用。用LLVM的C API写一个极简JIT的原型大概是这个样子#include llvm-c/Core.h #include llvm-c/ExecutionEngine.h int main() { LLVMInitializeNativeTarget(); LLVMInitializeNativeAsmPrinter(); LLVMContextRef ctx LLVMContextCreate(); LLVMModuleRef mod LLVMModuleCreateWithNameInContext(jit_test, ctx); // 在这里构造 IR... // 创建 ExecutionEngine 并执行 LLVMExecutionEngineRef engine; LLVMLinkInMCJIT(); LLVMCreateExecutionEngineForModule(engine, mod, err); // 拿到函数指针后强制转成 int(*)(void) 并调用 }当然这个示例掐头去尾只是为了表达JIT的基本骨架。真正用起来OrcJIT的API要复杂得多但思想不变源码 - 语言前端 - LLVM IR - JIT编译 - 可调用函数。很多脚本语言比如Julia就是靠这个策略在运行时把动态生成的IR快速编译成高性能原生代码。JIT场景下有个很值得关注的实践技巧LLVM在JIT编译时通常会主动降低优化等级比如从O3降到O2甚至O1因为JIT注重的是“编译延迟”而不是纯峰值性能。你要是在你的程序里做即时编译发现每调一次都要卡几百毫秒那多半是没限制Pass数量。用clang -O2和-O0交错的性能对比很容易就能看到“编译时间”和“运行时间”的取舍线在哪里。5. 常见问题与排查技巧实录5.1 “opt: unknown pass name”这种提示到底怎么解我在带新手时遇到最多的报错就是opt: unknown pass name some-pass。这个提示的字面意思非常清楚你想跑的Pass在注册表里找不到。最常见的原因有两种。一种是Pass名称写错了比如新PM下有些Pass名字带点有些又不带拿loop-vectorize跟loopvectorize去比细看差一个连字符也认不出来。另一种是你自己写的Pass插件没有正确加载或者插件注册的回调返回了false这时候opt也不会报“加载失败”而是直接说“unknown pass name”。排查方式很简单先用opt -load-pass-plugin./ConstFoldPass.so --print-passes看看插件里的Pass有没有出现在输出列表里。如果出现了但你还说不认识那就是命令行里-passes的字符串和注册名不匹配。注意新PM的-passes参数是一个管道描述比如-passesmem2reg,const-fold这个逗号用来分隔多个Pass别错写成空格。5.2 编译期Assertion错误真的是坏事吗Assertion failed: (isaX(Val) castTy() argument of incompatible type!)。这条报错我敢说写过LLVM代码的人都见过而且十有八九是在自己刚写完Pass的时候刷出来的。它的本质是你用castT()强行转换一个类型但运行时发现这个值根本不是T类型。比如你尝试把一个ConstantInt当成ConstantExpr去访问。这真的不是坏事反而是LLVM在保护你如果你的Pass构造出了类型不匹配的IR编译器会在运行到后端之前就中断不至于生成错误机器码。遇到这种断言正确的处理顺序是先定位栈回溯看一下是哪一行触发的然后确认你在触发点之前的操作数到底属于什么类型必要时在你自己的代码里加isa判断或者用dyn_cast代替cast。dyn_cast在转换失败时返回nullptr不会崩溃代价是你得多写一个判空分支。在Pass里养成“先判断类型再转换”的习惯能帮你省掉大量调试时间。5.3 从IR到汇编我该在哪个层面排查问题有时候你觉得优化结果不对但又分不清是Pass的问题还是后端的问题。这里我建议你用三个层面逐步锁定第一层看IRclang -S -emit-llvm确认源码到IR这一步有没有问题主要看语义有没有被错误改写。第二层看优化后的IRopt -passes... -S确认优化Pass有没有贪婪地把错误的东西折叠掉。第三层看汇编llc -o -确认后端选指令、寄存器分配有没有出问题。我在实际工作中最喜欢的工具是llvm-mca——它是个机器码分析器可以静态地对一段汇编做性能分析模拟指令的发射周期、端口占用情况不用真正跑在CPU上就能大致估算指令吞吐。用它来分析你的热循环比凭经验猜快得多。写出一个热点汇编片段扔给llvm-mca看到它输出的每周期指令数IPC就能快速判断这里的指令调度是否健康。5.4 构建时间太长、磁盘占用太大怎么办这是每一个玩LLVM的人都绕不开的血泪话题。LLVM的构建极其吃资源Release版还需要额外时间做优化。我自己常用的提速策略有几条不用-DLLVM_ENABLE_PROJECTS带上太多项目只需要clang就只写clang不带lld、clang-tools-extra这些构建时间差距巨大。只构建自己需要的target比如在x86上开发就设置-DLLVM_TARGETS_TO_BUILDX86这一下能把代码生成部分的编译量砍掉一大半。控制-DCMAKE_BUILD_TYPERelease而不是Debug。Debug版保留的符号信息和未优化的断言会让二进制体积爆炸编译也更慢。如果机器内存够可以尝试-DLLVM_PARALLEL_LINK_JOBS2来降低链接时的内存爆掉风险因为LLVM的工具链在链接阶段经常把内存吃满。还有一个我踩过的大坑做增量编译时如果改了LLVM头文件它会导致大面积重新编译因为LLVM内部的头文件依赖非常重。所以尽量别轻易动核心头文件和TableGen定义改自己写的Pass源文件时增量编译很顺畅一旦动到llvm/IR/下的头文件编译时间直接爆炸。5.5 写好Pass之后如何保证它真的安全从写Pass到Pass上线正式使用中间最好过三个检查。首先新的Pass必须返回正确的PreservedAnalyses。如果你什么都没改但返回了none意味着你告诉LLVM“所有分析结果都被破坏了”下一轮其他Pass会强制重新计算分析代价是性能下降如果你改了IR却返回all等于告诉LLVM“分析都没变”其他Pass会基于过期信息做错误决策这是相当危险的坑。其次你要跑一遍LLVM自带的测试集起码保证你改过的那条IR路径在clang -O2全套回归测试下不崩溃。最后在DebugAssertions模式下跑一遍你的测试用例让断言把你可能的错误暴露出来。我见过很多人在自己的Pass里修改了指令然后忘了更新DominatorTree或LoopInfo这些分析结果最后导致后续优化直接误判。处理方式是确保你修改后调用了对应的AU.addPreservedDominatorTreeAnalysis()或者返回合适的PreservedAnalyses。在新Pass Manager下你需要对“哪些分析被保存了、哪些被破坏了”保持清楚的意识这是从“能写Pass”进阶到“能写正确且高效的Pass”的关键门槛。6. 写在最后以及一点个人体会回头看一下LLVM的项目旅程从博士论文里的研究原型到如今支撑起Rust、Swift、现代C工具链、GPU驱动甚至PlayStation和Xbox游戏编译的半壁江山这套基础设施之所以强大核心就是那个“一切都围绕IR展开”的设计哲学。无论是静态编译、优化分析还是JIT代码生成或者LLVMPipe在软件层对256位向量寄存器的压榨底层用的都是同一套经过大量实战检验的组件。我个人在实际操作中最深的感受是不要被LLVM庞大的代码量和复杂的API劝退。给它一次编译的机会跑通一个最简单的IR分析Pass你就能在寄存器分配和指令选择这些黑盒之间看到一条清楚的线索。那些看似高深的概念——中间表示、Pass管线、向量化、JIT——其实都只是围绕“分析代码、变换代码、生成代码”这三个动作展开的实现方案而已。最后再分享一个小技巧不管你是初学者还是已经在生产环境里维护定制编译器随时保留一份llvm-project的release源码在本地并学会用git log --oneline -5定位当前commit这样遇到“为什么行为跟之前不一样”这类问题时你能快速判断到底是LLVM更新了行为还是你自己的代码有bug。版本之间API变更真的是常有的事从15到16再到17哪怕只是隔一个版本一些Pass的注册方式和参数也会发生变化。把版本锁死在某个稳定release凡事照着对应版本的文档来能帮你少走不少弯路。