
llvmpipe (LLVM 15.0.7, 256 bits)如果你在系统信息或者OpenGL扩展报告里见过这行字大概和我一样第一反应是“这是个啥显卡驱动坏了”其实它既不是故障也不是什么杂牌显卡而是Mesa项目中的软渲染驱动llvmpipe在向你汇报它的工作状态它正在用LLVM 15.0.7作为JIT引擎并且你的CPU支持256位SIMD指令也就是AVX2这一档。换句话说你没有GPU也能跑起图形应用靠的就是这套纯CPU软件渲染栈。我最近花了大半个月把llvm-project源码翻了一遍越看越觉得这行字是理解整个LLVM生态的绝佳入口。llvmpipe的工作方式几乎把LLVM最核心的几块能力全用上了中间表示、优化管道、后端代码生成、运行时JIT编译。把这套链路搞清楚你不仅看得懂软渲染也能看懂为什么那么多语言编译器都要接LLVM后端。这篇文章我会从LLVM的整体架构讲起落到llvmpipe为什么非要跟“256 bits”绑定再分享我自己构建llvm-project、写第一个Pass、以及踩坑排错的完整过程希望对想入门LLVM源码的同学有点用。1. 先摘掉“编译器”帽子LLVM其实是叫“虚拟机”的基础设施很多初学者一看到“编译器”三个字就以为LLVM就是个类似GCC的东西用来把C代码变成可执行文件。这么理解不算全错但会严重限制你对它的认知。LLVM的全称是Low Level Virtual Machine——底层虚拟机虽然这个名字已经不太准确但“虚拟机”三个字恰恰说明它的本质它提供的是构建编译器所需的积木而不是一个成品编译器。1.1 三段式架构NM而不是N×MLLVM架构最核心的设计是把传统编译器的流水线拆成三段中间用统一的中间表示IR连接前端负责把源代码C/C/Rust/Fortran等解析、语义分析最终生成LLVM IR优化器对IR做各种与具体CPU无关的优化变换后端把优化后的IR转换成特定目标平台的机器码这个设计最大的价值在于“语言”和“硬件平台”两个维度被彻底解耦了。在没有LLVM之前要支持M种语言和N种目标平台理论上得做M×N个编译器前端/后端的配对组合工作量爆炸。而LLVM只需要M个前端、N个后端中间全靠同一种IR对接。这也是为什么Rust敢在早期只有几个人的团队情况下快速获得大量目标平台支持——它只需要把rustc的前端接上LLVM后端剩下的AArch64、RISC-V、WebAssembly等平台支持全都由LLVM社区负责。我翻源码时看到llvm-project的顶层目录结构就对这种解耦有了很直观的感受clang是前端llvm下面是核心优化器和后端lld是链接器lldb是调试器compiler-rt是运行时库MLIR是另一个面向编译器的基础设施项目。它们全都围绕同一个IR来做文章但各自管好各自的环节。1.2 那层“中间表示”IR到底是什么IR是LLVM的枢纽。LLVM官方文档里对IR有很明确的定义它介于高级语言和汇编之间既有高级语言的类型信息和控制流结构又像汇编一样使用显式的寄存器操作和基本块。IR有三种形式彼此等价内存中的IR以C对象形式存在是优化器和后端操作的形态Bitcode.bc二进制序列化形式可用于增量编译和跨模块优化文本IR.ll人能读懂的汇编风格文本调试和学习时最常用举个例子一段简单的C代码int add(int a, int b) { return a b; }用clang转成文本IR就是define i32 add(i32 %a, i32 %b) { entry: %add add nsw i32 %a, %b ret i32 %add }这种形式看着像汇编但它不针对任何具体CPU。i32这种类型是抽象宽度add就是一条与目标无关的运算指令。后面无论是x86还是ARM都从这个IR开始做各自的指令选择。理解IR之后再看llvmpipe的工作就非常顺畅了llvmpipe其实是一个“另类前端”它把shader指令一条一条翻译成LLVM IR然后交给优化器和后端让LLVM替它搞定接下来的事情。那层“256 bits”其实是后端针对SIMD向量宽度做代码生成时的一个体现后面我会具体展开。2. 从.c到.o的旅程Clang、优化器、后端各干了什么活把LLVM的流水线具象化最好的方式就是跟踪一个C文件从源码到目标文件的完整旅程。很多人用Clang很久但不知道它在背后经历了多少道工序。我建议你在自己的机器上亲手跑一遍会特别有感觉。2.1 Clang前端从源码到IR的“解码器”Clang的工作可以粗分成两个阶段先把源代码变成抽象语法树AST再把AST下降成IR。AST阶段做的是传统的编译前端工作词法分析把字符流切成token语法分析按C/C的语法规则把这些token组织成树形结构语义分析负责检查类型、作用域、变量声明关系这类需要跨语句判断的问题。Clang的AST设计得极其工整这也是为什么clang-tidy、clangd这类基于AST做分析的工具能这么好用。等AST构建完成Clang会把AST“下降”成IR。这一步有个很多新手没注意的细节即使你编译时不加任何优化选项这中间也会跑一些非常基础的IR优化比如常量折叠。所以-O0和-O2的差异不只是优化Pass跑没跑前端本身就参与了一部分代码瘦身。一个比较重要的概念是SSA静态单赋值。在LLVM IR里每个变量只被赋值一次如果需要更新值就生成一个新变量。比如%x1 add i32 %a, 1 %x2 add i32 %x1, 2 %x3 add i32 %x2, 3这种形式看起来有点啰嗦但它让优化器变得极其轻松每个值的定义点和使用点都非常清晰改一个值不会影响其他不相关的代码数据流分析变得像查表一样简单。循环里的情况会用到phi节点那是另外一个话题但核心都是为了同一个目标——让程序用不可变变量的方式来描述计算。2.2 中端优化Pass管道和PassManager优化器是LLVM里代码量最大、逻辑最复杂的部分。它不是一个巨大的函数而是一系列小变换的集合每个变换叫一个Pass各自负责一件很具体的事——删除死代码、合并重复计算、循环展开、循环不变量外提等等。这些Pass按固定顺序组成管道不同优化级别选择不同管道序列。LLVM里有两代Pass框架。老一代叫Legacy Pass Manager是按Pass类型做顺序执行的新一代叫New Pass ManagerNPM核心特点是按需分析——一个Pass需要什么分析结果框架会先创建并缓存这个分析Pass执行完还要声明自己保留了哪些分析结果没被破坏。LLVM 15.0.7里NPM已经是默认这对写Pass的人影响很大你搜网上老教程时会看到opt -foo这种写法在新框架里已经改成opt -passesfoo了。我用一个很简单的例子说明Pass管道长什么样。假设你写了个int dead_code(int a, int b) { int x a b; return a b; }后面会有Pass负责发现那个x变量计算完根本没用把它删掉这就叫死代码消除。还有的Pass负责把结构完全一样的子表达式合并计算避免重复执行这属于公共子表达式消除GVN。一个看似简单的“return a b”在优化管道里会被无数个Pass来回检查。实际生产级的优化效果是几十个Pass协同工作的结果没有任何一个Pass能单独扛起性能优化。2.3 后端CodeGen从IR到机器码的最后一公里IR通过中端优化后就进入后端了。后端要回答的问题是这条抽象的加法指令怎么用具体的x86汇编实现LLVM的后端流程比前端还要长核心阶段包括把IR转成一个叫SelectionDAG的节点图每个节点对应一个计算操作对DAG做合法性检查和变换把不适合目标平台的节点拆成目标平台能处理的子节点指令选择把DAG节点匹配到具体的、目标平台专属的机器指令寄存器分配把虚拟寄存器映射到物理寄存器寄存器不够就先spill到内存指令调度调整指令顺序以利用CPU流水线发射阶段生成汇编或直接编码成目标文件在LLVM 15.0.7里x86和ARM等主流后端默认还是用的SelectionDAG这套流程GlobalISel这种更新的框架在AArch64等后端已经比较成熟但还不是所有平台默认。后端最考验耐心的地方是寄存器分配尤其是x86这种寄存器数量可怜、还有各种隐式约束的架构一个函数能自然放进寄存器但复杂函数经常要跟内存打交道这就很考验代码生成器的调度能力。看完这段你就明白LLVM里“编译一个程序”这件事本质上是一条明确的流水线前端产出IR优化器打磨IR后端把IR翻译成机器动作。每一段都有明确边界这也正是它能被llvmpipe这类项目拿来当底层引擎的原因。3. llvmpipe为什么会和“256 bits”绑定在一起系统信息里出现“llvmpipe (LLVM 15.0.7, 256 bits)”时说明图形栈没有找到可用的硬件GPU回退到了Mesa的软件渲染驱动。但llvmpipe不是那种逐像素慢慢画的古老软渲染器它的性能逻辑非常现代甚至可以说它是LLVM JIT能力最成功的应用之一。3.1 llvmpipe的工作前提把shader变成机器码而不是解释执行传统软件渲染器处理shader时经常是拿到一个GLSL/Vulkan shader然后逐指令解释执行。这种方案正确性没问题但性能惨不忍睹因为每个像素、每个顶点都要重复做一次解释循环。llvmpipe换了个思路它把shader当作一份“运行时才出现的源代码”然后在绘制调用发生后用LLVM把它JIT编译成宿主CPU的机器码。这样每个shader只需要编译一次后续再画就全是原生机器码在跑。具体流程是这样的应用传入GLSL或SPIR-V格式的shaderllvmpipe经过一系列上层中间表示NIR等转换它的后端代码生成模块把shader指令逐条翻译成LLVM IRLLVM针对目标CPU做优化和向量化生成的机器码被缓存供后续绘制调用反复使用这个模式相当于为每一次图形程序定制了一个专属的编译器。LLVM在这里扮演的角色不是“生成可执行文件”而是运行时高性能代码生成引擎。3.2 为什么256位SIMD宽度是关键理解“256 bits”要从SIMD单指令多数据说起。现代CPU几乎都有SIMD指令集比如x86的SSE是128位AVX2是256位AVX-512是512位。一条指令可以同时处理多个数据。以256位为例如果数据是32位float一条指令就能同时算8个float如果是双精度则是4个。llvmpipe充分利用了这个特性。它在代码生成时会刻意把shader的运算组织成“一次处理多个像素/顶点”的形式这样生成的LLVM IR里的向量宽度往往就是4、8甚至16个float。当LLVM后端在支持AVX2的CPU上做指令选择时就能直接用ymm寄存器一次算8份数据这就是那行“256 bits”的来历。一个画面上几百万像素每个像素都要执行shader如果一次能并行算8个像素性能就能直接提升一个数量级——这是软渲染还能勉强使用的核心原因。如果CPU不支持AVX2只支持SSE那么llvmpipe的代码生成就会退化成128位一次算4个float性能降一档系统信息里也会显示128 bits而不是256 bits。3.3 为什么LLVM是shader编译器的理想底座shader编译对LLVM来说是个非常独特的场景编译延迟和生成代码质量之间需要平衡而且没有“编译期”和“运行期”的明确界限。一边是JIT编译的实时性用户在玩游戏时不能让编译卡顿太明显另一边是编译出来的代码必须足够好否则性能永远上不去。LLVM的优化管道可以按级别灵活配置llvmpipe可以选择较低的优化级别来做快速编译也可以对热点shader用更激进的优化。LLVM提供了一套可裁剪的工具链这是那些专用的小型JIT引擎很难给的。更重要的是LLVM天然支持跨平台。llvmpipe同一份IR到了x86上有AVX2可用到了ARMv8上能用NEON到了RISC-V上则有对应的向量扩展支持。它把CPU平台适配的工作量全部压缩到了LLVM后端里Mesa团队只需要维护一份llvmpipe抽象逻辑就行。这也是开源项目之间协作非常典型的范式我提供一个架构你来接我的后端。4. 从零构建llvm-project并动手改一个Pass如果看完前面只是了解概念那还远远不够。我建议你亲自把llvm-project克隆下来构建一遍然后写一个最简单的“统计函数里加法指令数量”的Pass用opt跑通。这个过程能帮你把整个LLVM的工具链串起来学到的实战经验比读十篇文档都管用。4.1 构建前要定的三件事首先要明确完整的llvm-project构建非常吃资源。官方文档推荐至少要有几十GB的磁盘空间和足够的物理内存建议内存不低于8GBCPU核数越多越好。我自己在一台8核16GB内存的机器上全量构建连带Clang一起编大概跑了四十分钟到一个小时。如果你只想做核心IR和Pass实验不需要Clang和所有后端可以这样配置git clone https://github.com/llvm/llvm-project.git cd llvm-project git checkout llvmorg-15.0.7 cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_ASSERTIONSON cmake --build build -j 8有几个参数我解释一下新手经常在这里踩坑LLVM_ENABLE_PROJECTS指定要同时构建哪些上游子项目。如果只填clang就是clang加上核心llvm如果留空也能编但就没有clang调IR时不太方便。LLVM_TARGETS_TO_BUILD指定要支持的后端列表。默认会生成所有后端的代码编译时间会多很多。只填X86能大幅缩短构建时间。如果你之后要调试AArch64后端再补上就行。LLVM_ENABLE_ASSERTIONS这个强烈建议设成ON尤其做源码研究时。LLVM社区自己的断言遍布各处能帮你提前发现很多隐藏问题。我对Release版开启断言后跑了自己瞎写的Pass时就逮到过几处越界访问如果没有断言这些错误可能静默污染数据排查起来更难。构建完成后工具都在build/bin目录下opt、llc、llvm-dis这些常用命令都在那里。建议把build/bin加进PATH后面用起来方便。4.2 第一个最小Pass跑通New Pass Manager写LLVM Pass是深入理解这套体系的必经之路。我直接给出一个能用的最小例子它定义了一个名为count-op的Pass遍历当前函数里的每条指令统计二元运算中的加法指令数把结果打印出来。#include llvm/IR/Function.h #include llvm/IR/Instructions.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { class CountBinaryAddPass : public PassInfoMixinCountBinaryAddPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { unsigned AddCount 0; for (BasicBlock BB : F) { for (Instruction I : BB) { if (auto *BO dyn_castBinaryOperator(I)) { if (BO-getOpcode() Instruction::Add) { AddCount; } } } } errs() [CountBinaryAdd] Function: F.getName() | add instructions: AddCount \n; return PreservedAnalyses::all(); } }; } // namespace extern C ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, CountBinaryAddPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name count-op) { FPM.addPass(CountBinaryAddPass()); return true; } return false; }); }}; }这段代码里有几个值得说的地方。第一PassInfoMixinCountBinaryAddPass是New Pass Manager下函数Pass的标准写法提供CRTP基类。这个类的关键是run方法参数是当前函数和一个函数分析管理器后者可以让你按需请求其他分析结果。第二PreservedAnalyses::all()表示我这个Pass没有修改任何IR所以框架可以放心保留所有已经计算好的分析结果。如果你真的改了IR就得更精确地声明哪些分析失效了否则后续Pass可能用到过期的分析后果很难排查。这是NPM跟legacy框架差异最大也最值得理解的地方。第三llvmGetPassPluginInfo是Pass插件的动态库入口。opt加载.so文件时会去查这个符号通过它注册的PipelineParsingCallback让opt能在命令行解析-passescount-op时认出我定义的Pass。如果你看到插件加载成功但opt提示不认识的Pass名大概率是这里注册的名字跟命令行对不上。编译这个插件有两种方式。简单粗暴的方法是直接用构建好的clang把LLVM头文件路径和库路径指给它。如果用build目录里的工具可以这样编译build/bin/clang -shared -fPIC -fno-rtti \ -I llvm-project/llvm/include -I build/include \ CountBinaryAddPass.cpp -o CountBinaryAddPass.so但如果你装了完整的LLVM直接llvm-config --cxxflags --ldflags拿编译参数会更规范也避免路径少配导致头文件找不到。我实际用下来最重要的一点是插件必须跟你运行的opt严格同源都用同一个版本构建否则很容易出现奇怪的ABI不兼容问题。4.3 用opt验证Pass是否真的在跑先写一个测试用的C文件int demo(int x, int y) { int a x y; int b a 1; int c a b; return c; }然后用clang生成IR文本clang -emit-llvm -S demo.c -o demo.ll打开demo.ll能看到三个add指令。接着运行我们的插件opt -load-pass-plugin./CountBinaryAddPass.so -passescount-op -S demo.ll -o /dev/null如果一切正常你会看到类似这样的输出[CountBinaryAdd] Function: demo | add instructions: 3说明Pass确实跑起来了能看到函数里的三条add。如果你把-S参数去掉opt会直接输出bitcode而不是文本IR拿给llvm-dis转回文本又是一种玩法。这个流程虽然简单但已经把“生成IR、加载插件、运行Pass、观察输出”这条研究LLVM的标准路径完整走了一遍。之后再想去改Pass行为、看更多分析结果都可以在这个骨架上扩展。顺便提一个查看优化效果的技巧opt -passesdefaultO2可以模拟-O2的默认管道配合-print-after-all能打印每个Pass执行后的IR变化。这个对学习Pass顺序和效果非常有用相当于把编译器的内裤翻出来给你看谁先跑谁后跑、每条优化把代码改成了什么样全都一目了然。输出会非常长建议用-filter-print-funcsdemo限定只看某个函数否则眼睛会被淹没。5. 源码阅读的一些真实经验与排错记录这半个月下来最大的感受就是LLVM不是不能读而是得有方法。它的代码量太大直接从头读到尾肯定放弃但它的模块边界非常清晰你完全可以带着问题去读。这里我把自己的经验整理一下顺便把踩过的几个典型的坑讲出来免得后面的人再重复走。5.1 LLVM版本间的API变动网上代码不是不能用是要先看版本LLVM的API变动非常激进社区对“破坏性变更”的容忍度很高每个大版本release notes里都会列出一堆breaking changes。LLVM 15.0.7里的很多写法在LLVM 16、17里可能已经不适用网上随手搜到的教程代码往往来自四五年前编译能过才怪。我遇到最典型的是Legacy Pass写法。很多教程还是这样的class MyPass : public FunctionPass { public: static char ID; MyPass() : FunctionPass(ID) {} bool runOnFunction(Function F) override { ... } };这种写法在LLVM 15里虽然还能编译但它走的是Legacy PassManager和新的opt -passes体系不是一回事。你真的想让插件在新的命令框里跑起来必须用PassInfoMixin那套写法。我在刚开始时就死磕了很久最后翻官方示例llvm/examples/Bye/Bye.cpp才彻底弄明白。我的建议是搜索任何LLVM代码之前先确认你本地的版本然后优先参考你手里这份源码自带的examples目录。LLVM官方在llvm/examples/里维护了一些非常基础的示例虽然不多但绝对是版本匹配最准的。5.2 建议优先阅读的文件与代码路径如果你是第一次系统性读LLVM源码别从llvm/lib/CodeGen开始那里面的SelectionDAG代码能让人瞬间失去信心。我建议按这条线走第一步读IR层。核心头文件在llvm/include/llvm/IR/下重点看Module.h、Function.h、BasicBlock.h、Instructions.h。这几个文件定义了IR的基本结构读完后你对“IR长什么样”就有完整概念。第二步读Pass框架。看llvm/include/llvm/Passes/PassBuilder.h和llvm/lib/Passes/PassBuilder.cpp。后者定义了默认优化管道里到底有哪些Pass、按什么顺序执行。这部分读完后你对“-O2具体做了什么”的理解就不抽象了。第三步如果想关注后端可以先看llvm/include/llvm/CodeGen/下的一些目标无关接口然后选一个简单后端比如RISCV去具体分析。x86后端是出了名的复杂一上来就看容易被细节淹没。至于llvmpipe它其实不在llvm-project仓库里而是在Mesa源码的src/gallium/drivers/llvmpipe/目录。里面lp_bld_*系列文件是核心代码生成逻辑。如果你对“GPU驱动怎么用LLVM做代码生成”感兴趣这个目录相当有启发性。5.3 调试LLVM的常见手段与几个容易忽略的坑我在实验过程中积累了几个很实用的调试手段建议收藏一下opt -debug-pass-manager打印New Pass Manager的每个Pass运行情况包括是否跳过、运行时长、是否修改了IRopt -print-after-all -filter-print-funcsdemo打印每个Pass运行后的IR限定只看指定函数build/bin/llvm-config --cxxflags --ldflags编译插件时拿标准编译参数比手动拼接头文件路径可靠如果怀疑LLVM内部断言触发用-debug-onlypass开启指定调试类别但前提是构建时开了LLVM_ENABLE_ASSERTIONS后面还有几个我觉得特别容易坑到人的点提一下一个是插件与opt版本必须同源。我试过用系统带的LLVM 15开发包编译插件去加载我自己构建的15.0.7 opt结果加载后直接报段错误。LLVM的内部类没有稳定的ABI承诺混用版本风险很高。另一个是LLVM_ENABLE_PROJECTS和LLVM_TARGETS_TO_BUILD的选择。如果全量构建所有后端时间会翻好几倍而且CMake配置里如果开启了一些和Polly、OpenMP相关的选项生成阶段就会更久。建议最开始只保留一个后端把核心逻辑打通再加其他目标。再一个是调试构建和发行版构建的取舍。Release版适合日常跑但Debug版能保留大量IR验证和断言信息方便你做源码级调试。研究中后期我更建议开一个RelWithDebInfo或Debug构建专门用于调试再保留一个Release版用于跑性能测试两边各司其职。5.4 识别自己机器的llvmpipe状态回到开头的“llvmpipe (LLVM 15.0.7, 256 bits)”这行字其实藏了不少信息除了能看出软渲染引擎和LLVM版本括号里那个256位一并告诉了你当前CPU的SIMD能力。如果在虚拟机或云服务器上看到这个说明宿主机或虚拟机CPU已经暴露了AVX2指令集支持如果显示128 bits说明只有SSE级别llvmpipe会退化成一次处理4个float渲染性能会明显下降。我最近在调一个无GPU的容器环境光是靠解释这一行字就能预判软件渲染性能省了我不少事。最后的操作体会看完这些llvm-project在你眼里应该不再是那个看一眼就想退出的巨型仓库了。从理解IR的三段式架构到追踪llvmpipe如何让软渲染吃上SIMD再到自己动手构建并跑通第一个Pass这条路径走下来你已经握着LLVM最核心的地图了。要真说有什么心得就是别怕它大但要带着问题去读代码。我是从“为什么系统信息里会有一行llvmpipe”这个具体问题切入的结果不知不觉就走过了IR、Pass、后端、JIT这一整条链路。反过来如果你一开始就想把llvm-project全部看完那大概率坚持不了几天。llvmpipe驱动的实现里还藏着大量值得研究的细节比如它如何处理不同shader阶段的接口、如何组织SIMD通道的layout、怎么设计缓存避免重复编译。这些内容足够再写好几篇长文我以后有空会继续拆解。希望这篇能帮你把LLVM源码阅读的第一步踩实后面就顺了。