十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

LLVM 15.0.7核心架构与工程实践:从IR到llvmpipe

LLVM 15.0.7核心架构与工程实践:从IR到llvmpipe 1. llvm-project到底是个什么项目先说一个可能让新手懵掉的事实当你打开GitHub上的llvm-project仓库第一眼看到的是一堆名字奇怪的目录——llvm、clang、lld、libcxx、compiler-rt、mlir、flang——你很难相信这些东西居然是同一个项目的组成部分。但恰恰是这种大杂烩式的结构让LLVM成为了过去十几年里编译技术领域影响力最大的基础设施。我在刚开始接触LLVM的时候也走过不少弯路。当时我只想用clang编译C代码结果被一堆术语——IR、pass、opt、SelectionDAG、TableGen——搞得晕头转向。后来花了很多时间把llvm-project的源码结构、编译流程、背后设计逻辑搞明白之后才真正体会到这个项目的价值。现在回头看LLVM能火起来不是因为某个编译器写得多好而是因为它把编译器这件事拆成了一个可组合、可复用、可扩展的组件库。你不需要每次都从零开始写一个编译器你只需要在这个框架上接上自己的优化逻辑、目标后端或者语言前端就行。这篇文章我会基于LLVM 15.0.7版本目前很多发行版和SDK默认带的就是这个版本从整体架构、核心抽象、源码构建、实际使用到踩坑记录把llvm-project从里到外梳理一遍。如果你正在学习编译器原理、想给项目引入新的编译能力、或者只是对llvmpipe这类基于LLVM的软件渲染实现感到好奇这篇文章都值得你花时间读一读。1.1 为什么LLVM不是又一个编译器主流的GCC走的是传统编译器的路子前端把源码变成ASTAST直接转成后端依赖的中间表示然后做优化、生成汇编。这导致前端和后端是强耦合的。你想支持一种新语言得从头写全套你想让编译器支持一种新CPU架构也得动整个编译器的核心。LLVM把编译过程拆成了三段式结构前端、优化器、后端。前端负责把源码解析成统一的中间表示IR优化器在IR上做各种优化后端负责把IR转换成目标平台的机器码。三者的接口都是标准化的这也意味着前端、优化器、后端可以独立演进。你今天写的Rust代码用的优化器和机器码生成部分和C、Swift是同一套基础设施。这就是llvm-project最大的价值——它不只是clang这个C/C编译器而是一个完整的技术栈。再加一个很多人容易忽略的点LLVM的核心库被设计成可以嵌入其他程序中。你不是只能在命令行里敲clang或者opt你可以在自己的工具链中链接LLVM的库调用它的API来实现代码生成、即时编译JIT、静态分析等功能。llvmpipe这个软件渲染器原理就是在CPU上通过LLVM的JIT把图形着色器编译成高效的机器码执行所以它的SIMD代码生成质量直接决定渲染性能。文章后面我会专门讲这个概念。1.2 LLVM 15.0.7的源码目录里都藏了什么拿到llvm-project的源码包你会看到顶层有这么几个关键目录目录名作用我的理解llvmLLVM核心库包含IR定义、优化pass、代码生成、目标描述整个项目的发动机clangC/C/Objective-C前端把源码解析成AST和IR我们最常用的编译器入口lld链接器号称比系统默认ld快好几倍链接阶段的神器libcxx / libcxxabiC标准库和ABI库的实现用clang编译现代C时的基础库compiler-rt运行时库包括sanitizer、builtin函数做内存检测、性能采样都离不开它mlir机器学习领域的中间表示框架LLVM向AI编译器方向扩展的关键flangFortran前端老式科学计算和HPC场景还是有大量Fortran代码一开始我建议你重点看llvm和clang两边就够用了。llvm目录下的include/llvm/IR、lib/Transforms、lib/Target是理解抽象层和代码生成的关键clang目录下则能学到如何把一个真实语言接到LLVM上面。顶层的CMakeLists.txt定义了整个项目的构建方式。你如果只想构建clang可以用LLVM_ENABLE_PROJECTSclang只想构建compiler-rt就用LLVM_ENABLE_RUNTIMEScompiler-rt。LLVM项目支持非常灵活的裁剪这一设计也让它在不同场景下都能保持较快的构建速度。2. LLVM IR理解一切优化与代码生成的核心所有进入LLVM的前端最终输出的都是同一套中间表示。你可以把IR理解成LLVM世界里的字节码或者汇编语言。它不是机器码但又不像C源代码那样高层。IR使用静态单赋值SSA形式有明确的类型系统理论上可以表达所有主流高级语言的特性和所有目标平台的机器指令。2.1 IR的三种形态内存中、文本格式、二进制格式LLVM IR有三种表现形式容易混淆但本质上是同一个东西在不同阶段的产物内存中的IR用C对象表示的指令、基本块、函数、模块是优化器操作的核心形态。可读的文本IR后缀为.ll给人看的。你写自定义pass时经常会dump这种格式的IR来调试。二进制位码IR后缀为.bc给机器高效读取的。从clang得到IR的方法很简单clang -S -emit-llvm hello.c -o hello.ll生成的hello.ll文件就是文本IR。你可以直接打开看里面包含全局变量、函数定义、每条指令的操作码和操作数。举个例子一个简单的加法函数int add(int a, int b) { return a b; }对应的IR大概长这样define i32 add(i32 %a, i32 %b) { entry: %add add nsw i32 %a, %b ret i32 %add }这里的i32表示32位整数类型add就是加法指令nsw表示无符号回绕才算合法这样优化器才能做更多算术推导。IR里每条指令都显式标出类型这让类型推导变得非常直接。我强烈建议新手养成阅读IR的习惯。你能从这个过程中看到编译器视角下的代码长什么样哪些优化生效了、哪些没生效以及为什么有些看似合理的优化会出错。这对调试编译器问题和性能瓶颈都有很大帮助。2.2 Pass流水线llvm-project里最核心的架构设计优化器做的事情是由一大串优化pass组成的流水线来完成的。每个pass只做一件事比如死代码消除、循环展开、内联函数、常量传播。这种设计让优化逻辑可以独立编写和测试也允许你根据编译阶段和编译目标自由组合。在LLVM 15.0.7中大部分优化跑在所谓的新PMNew Pass Manager框架下。新PM相比旧PM最大的改进是更好的缓存机制和更精确的分析结果复用。你可以在命令行中指定passopt -passesmem2reg,instcombine,deadargelim hello.ll -S -o hello.opt.ll参数里的mem2reg能够把对栈上变量的读写提升为SSA寄存器操作这是很多优化的基础instcombine负责简化冗余的指令模式deadargelim会删除永远不会使用的函数参数。这些pass组合起来能让IR变得更紧凑、更高效。自己写pass时你需要继承llvm::PassInfoMixin然后实现一个run方法。比如下面是一个简单的pass它统计每个函数的指令数量#include llvm/IR/Function.h #include llvm/IR/Instructions.h #include llvm/Pass.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h using namespace llvm; namespace { class CountInstructionsPass : public PassInfoMixinCountInstructionsPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { unsigned count 0; for (auto BB : F) { for (auto I : BB) { count; } } errs() Function F.getName() has count instructions\n; return PreservedAnalyses::all(); } }; } // namespace extern C ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, CountInstructions, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name count-instructions) { FPM.addPass(CountInstructionsPass()); return true; } return false; }); }}; }编译成动态库之后用命令加载clang -shared -fPIC -fno-rtti CountInstructions.cpp -o count.so $(llvm-config --cxxflags --ldflags --libs) opt -load-pass-plugin./count.so -passescount-instructions hello.ll -S这是理解LLVM架构很有成就感的一条路径你写完pass它就能在优化流水线里跑起来参与真实项目的编译过程。我建议想做编译优化的朋友都从这个练习入门。2.3 256 bits与SIMDllvmpipe背后的性能密码热词里提到的llvm 15.0.7, 256 bits其实点到了一个非常具体的场景LLVM的向量化能力以及它在llvmpipe这类软件渲染器里如何利用现代CPU的SIMD指令集。现代x86 CPU从Intel Haswell和AMD Excavator开始普遍支持AVX2指令集一次可以处理256位的向量数据也就是8个32位浮点数同时运算。llvmpipe是Mesa项目中的软件渲染后端它不依赖GPU而是在CPU上完成光栅化、着色等渲染工作。它把图形着色器编译成IR再通过LLVM的JIT生成针对当前CPU微架构优化的机器码。如果检测到CPU支持AVX2LLVM会尽可能把计算循环向量化用一条256位的指令完成以前8条标量指令的工作。你可以观察一个向量化的例子。写一个简单的循环求和float sum(float *arr, int n) { float acc 0; for (int i 0; i n; i) { acc arr[i]; } return acc; }用clang开启自动向量化clang -O2 -mavx2 -S sum.c -o sum.s你会看到生成汇编里有vmovups、vaddps这类AVX指令它们操作的寄存器是ymm系列宽度正是256位。LLVM在IR层面会把循环体转成向量类型的操作比如8 x float表示8个float打包在一起。理解了这个链路你就明白为什么llvmpipe在纯CPU环境下依然能有不错的性能——它和直接在CPU上逐像素算的简单实现完全是两个量级。同时也明白为什么LLVM版本、目标CPU特性-march/-mtune对性能影响那么大。这些参数选择直接影响JIT生成代码的质量。3. 从源码构建llvm-project的完整实操记录很多人直接装发行版自带的clang就觉得够了但如果你要开发自定义pass、调试LLVM内部逻辑、或者做交叉编译工具链从源码构建llvm-project几乎是必须的。我用的版本是LLVM 15.0.7下面把构建过程完整走一遍。3.1 环境准备与CMake基础参数构建LLVM需要的内存和磁盘空间都不小。我的建议是内存至少16GB磁盘预留50GB以上。CPU核数越多越好编译时间能从一两个小时压缩到十几分钟。操作系统上需要装好cmake、ninja、gcc、python3等基础工具。CMake是LLVM构建系统的核心。最简配置如下git clone --depth 1 --branch llvmorg-15.0.7 https://github.com/llvm/llvm-project.git cd llvm-project mkdir build cd build cmake -G Ninja ../llvm \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_ASSERTIONSON ninja解释几个关键参数CMAKE_BUILD_TYPERelease关掉调试信息编译速度快产物体积小。做性能测试务必用Release否则结果完全没参考价值。LLVM_ENABLE_PROJECTSclang;lld指定要额外构建的子项目。这里选了clang前端和lld链接器。LLVM_TARGETS_TO_BUILDX86只针对X86目标生成后端代码。这样能显著缩短编译时间。如果你需要ARM交叉编译再加ARM。LLVM_ENABLE_ASSERTIONSON开启断言。调试期建议打开能抓到不少内存和逻辑错误发布构建时可以关掉。构建完成后二进制文件在build/bin目录下库文件在build/lib。你可以设置环境变量export PATH$PWD/bin:$PATH然后验证clang --version如果显示clang version 15.0.7说明构建成功。3.2 调试版构建和测试套件如果你想深入阅读LLVM源码或者调自己的pass我建议再花点时间配置一个Debug版本。Debug版本的编译时间长很多但你会得到大量符号和断言信息。可以用同一个源码目录另开一个build-debug目录避免和Release构建互相干扰mkdir build-debug cd build-debug cmake -G Ninja ../llvm \ -DCMAKE_BUILD_TYPEDebug \ -DLLVM_ENABLE_PROJECTSclang \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_ASSERTIONSON ninja跑LLVM自带测试的方式是ninja check-llvm它会执行llvm/test目录下大量的lit测试包括IR解析、优化pass、代码生成等各个环节。这些测试既是回归检查也是绝佳的学习样例。我读代码时经常直接翻test目录看某个pass的预期输入输出比看论文和slides高效得多。3.3 llvmpipe构建路径与JIT链路llvmpipe不是llvm-project仓库里的代码它属于Mesa项目。但你构建LLVM时的一些选项直接决定了llvmpipe能不能跑得起来。llvmpipe会在运行时动态生成代码所以它链接的是libLLVM和libLLVMJIT等库。构建Mesa时需要通过MESA_LLVM_VERSION之类的变量指向你构建好的LLVM。如果你是做图形相关事情的可以关注这几个点llvmpipe会检测LLVM的JIT能力所以构建LLVM时不要禁用JIT。如果你要调试llvmpipe里生成的机器码可以用环境变量LP_FORCE_RASTERIZER或MESA_LOADER_DRIVER_OVERRIDE等方式指定软件渲染。查看llvmpipe实际用了多少向量宽度可以观察它生成的汇编或者用perf工具看SIMD指令占比。简而言之llvmpipe是LLVM JIT能力在真实世界中的典型应用。理解这条链路比单纯会写pass更能帮助你掌握LLVM的底层执行模型。4. 用llvm-project工具链做一次完整的代码优化实验这部分我用一个真实的小实验带你走一遍LLVM工具链的经典流程从C源码到IR从IR到优化后IR再看最终的汇编输出。这个过程能帮你把前面讲的概念串起来。4.1 clang与opt的配合使用先准备一个测试文件test.c#include stdio.h int compute(int n) { int sum 0; for (int i 0; i n; i) { if (i % 2 0) { sum i * 2; } else { sum - i; } } return sum; } int main() { printf(%d\n, compute(100)); return 0; }第一步生成未经优化的IRclang -S -emit-llvm test.c -o test.ll打开test.ll你会看到compute函数里包含完整的循环结构、条件分支和加减运算。指令比较啰嗦实际上很多都是可以简化的。第二步我们用opt做一轮优化opt -passesmem2reg,instcombine,loop-unroll -S test.ll -o test.opt.ll这里我会额外解释一下loop-unroll的作用它会把小循环体复制展开几次减少循环控制指令的跳转开销。在IR层面你会看到循环体里的指令重复了多次这就是展开的结果。对于这种小函数展开之后往往能让CPU流水线跑得更顺。第三步生成目标汇编llc test.opt.ll -o test.s打开test.s对照原来的C代码你能直观看到编译器做的大量变换。对于compute函数LLVM甚至可能把整个循环都计算成了常量——因为在main里传给compute的实参是100编译器可以通过内联和常量传播得到最终结果这就是常量折叠。4.2 自己写一个优化Pass并跑起来前面我给了Pass代码示例这里把编译和运行的坑讲清楚。首先LLVM官方推荐的Pass开发方式是基于插件Plugin。插件的好处是不需要重新编译整个LLVM只需要编译你的Pass代码然后让opt加载即可。我前面给CountInstructionsPass示例时已经包含了plugins注册逻辑编译命令我再细说一下clang -shared -fPIC -fno-rtti \ CountInstructions.cpp \ -o count.so \ $(llvm-config --cxxflags --ldflags --libs --system-libs)注意一定要加-fno-rtti因为LLVM是关闭RTTI的。如果不加编译会报一堆关于dynamic_cast的错误。运行时opt -load-pass-plugin./count.so -passescount-instructions test.ll -S正常会看到输出Function compute has 14 instructions Function main has 5 instructions从写Pass到跑起来整个流程不超过五分钟。这条路一旦打通后面你可以尝试更复杂的功能比如分析循环信息、修改IR指令、插入函数调用等。LLVM的能力边界几乎就由你的想象力决定。4.3 clang前端背后做了哪些事我再举一个稍微进阶一点的例子。C的模板、类继承、lambda表达式在进入IR之前都会由clang前端进行大量语义分析。你可以用下面的命令看到前端在IR层面留下了什么#include vector int main() { std::vectorint v {1, 2, 3}; return v.size(); }clang -S -emit-llvm -stdc17 test_vec.cpp -o test_vec.ll打开生成的IR文件你会看到很多和std::vector相关的函数调用比如std::vector ::size()但IR层面上这些已经变成了直接的成员访问和计算。这个文件会很长因为标准库的很多东西都被实例化了。这里最值得学习的是前端的目的它把复杂语言特性归约成有限的IR指令让后续优化和后端只需要面对统一、简单的中间表示。这也是LLVM支持语言扩展成本低的原因之一。你不需要为每种语言写一份优化器只需要把语言特性翻译清楚。5. 常见问题与排查技法速查llvm-project体量庞大使用中遇到的问题千奇百怪。我把自己踩过的、以及身边同事常遇到的几类高频问题整理在这里给你做参考。5.1 编译报错与版本不匹配最常见的坑就是头文件用15.0.7、库文件却指向14或者16。用llvm-config检查一下当前路径下的LLVM版本llvm-config --version如果与你期望的不符检查PATH环境变量确认没有旧版本的LLVM二进制被优先解析。也可以用以下命令查看动态库搜索路径ldd $(which clang) | grep llvm如果出现多个路径的libLLVM很可能运行时会加载到错误版本。另外自己写Pass时头文件与库版本不一致会导致链接错误比如找不到某个符号。解决方法就是确保编译Pass用的llvm-config和运行opt用的llvm-config是同一个位置。5.2 JIT相关的问题如果你在做llvmpipe或者其他JIT项目遇到崩溃、段错误先排查一下是不是JIT没有正确设置目标机器特性。LLVM的JIT需要知道当前CPU支持哪些指令集比如是否有AVX2。这个信息通过TargetMachine的TargetOptions传入。如果设置不正确生成代码可能会调用不存在的指令或者无法使用最优指令。在llvmpipe场景下你可以通过环境变量强制指定一些行为帮助定位问题。例如LP_NUM_THREADS1这会强制单线程光栅化降低并发导致的调试难度。再用gdb抓崩溃现场看是JIT生成的代码出错还是llvmpipe自身的逻辑出错。5.3 性能不达预期时如何定位在llvmpipe或自定义编译器场景中性能不达预期很多人第一反应是调优化等级但经常忽略目标CPU特性的设置。同样是-O2-mavx2和-mavx512f比-mssse3上跑出来效果差别很大。你先在代码里确认TargetMachine确实启用了对应特性TargetOptions Opt; Opt.MCpu x86-64-v3; // 或者具体型号比如 skylake然后用perf stat看指令数、分支预测失败率、向量指令占比判断瓶颈是在代码生成质量、内存访问还是并发放置上。这些数据能帮你把问题从玄学变成科学。5.4 IR优化后结果不对的排查思路如果你发现某个pass优化出的结果与预期不符怀疑是pass本身的问题可以这样逐步排查先用-O0生成基础IR确认输入正确。再单独跑一个pass用-print-after-all或者-print-before-all看每个pass前后的IR变化。用llvm-reduce之类的工具最小化触发问题的测试用例方便提交bug或自己定位。我在调试自定义pass时经常把pass序列一个个关掉看是哪一个优化导致行为改变。虽然过程烦琐但基本都能定位到具体问题。不要指望一口气看懂所有IR用工具、用二分法效率会高很多。6. 把llvm-project用好路径建议与个人体会说了这么多最后聊聊学习方法。llvm-project的学习曲线确实陡峭但也没夸张到让人绝望。我自己是从三条路径同时入手的一条路径是读官方文档里的LLVM Language Reference Manual把IR语法和语义搞清楚另一条路径是找一个简单后端或者一个简单的pass跟着代码走一遍完整流程还有一条路径是盯住一个实际项目比如llvmpipe或者某个基于LLVM的语言编译器看它怎么把IR用起来。三个建议给你不要一开始就想着改后端代码生成规则。先把前端的IR输出看明白把优化pass的机制搞清楚后端的事情以后再说。善用opt和llc的命令行工具。很多分析不需要写代码用现成工具就能做。遇到问题先在工具层面复现再决定要改代码。多看llvm-project的test目录。那些小而精的测试用例是最佳学习样例比任何教程都直接。我个人在实际操作中体会到llvm-project最神奇的地方在于它把编译器和底层架构的复杂性隐藏在一套清晰的接口后面。你不需要知道所有细节只要理解主线——源码进、IR中转、机器码出——就能在需要的时候找到自己该改的地方。等你在里面跑通第一个自定义pass、第一次让JIT生成出可运行的代码那种掌控感是其他项目很难给的。如果你以后想往编译器、编程语言、高性能计算或者图形底层方向发展llvm-project值得你持续投入时间。工具链会迭代版本会升级但LLVM这套抽象模型和设计哲学大概率会长期存在并且会继续影响未来十几年的软件基础设施。
返回列表