ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LLVM实战指南:从IR到自定义Pass与llvmpipe软渲染

LLVM实战指南:从IR到自定义Pass与llvmpipe软渲染 LLVM 这个项目我最早接触是在做编译器后端的时候当时只觉得它是个“能生成各种架构机器码的工具链”后来用得越深越发现它已经成了整个软件生态里绕不开的基建。无论是 Clang 把 C/C 转成机器码还是 Rust、Swift、Julia 这些语言拿它当前端和优化层甚至图形栈里 Mesa 的 llvmpipe 软渲染背后都是同一套 LLVM 中间表示IR在起作用。这个项目名字叫 llvm-project但它远不止“一个编译器”那么简单。这篇文章我就围绕 llvm-project 本身从架构思路、核心组件、构建方式到写 IR 优化 Pass 的完整流程把这些年踩过的坑和积累的经验一次说清楚。想搞懂 LLVM 到底在做什么的人、准备上手改 LLVM 源码的人或者只是好奇 llvmpipe 这种“用 CPU 模拟 GPU”是怎么实现的人这篇都应该对你有用。1. 内容整体设计与思路拆解1.1 为什么说 LLVM 不只是一个编译器很多人第一次听到 LLVM第一反应是“哦就是那个比 GCC 新的编译器”。这个说法不算错但格局小了。LLVM 的定位从来不是一个完整的编译器而是一套编译器基础设施。它把传统编译器的流程拆成了三个独立阶段前端负责解析源代码优化器负责对中间表示做变换后端负责把优化后的 IR 变成某种目标架构的机器码。这三段式拆法最大的好处是可以自由组合。今天你想写一门新语言不用从零开始写代码生成和寄存器分配只要写一个前端把源码翻译成 LLVM IR之后所有的优化和后端支持全部白嫖。Rust 的 rustc 就是这么干的早期的 Swift 也是这么干的Julia 更是直接把 LLVM 当成了 JIT 引擎。那 llvmpipe 是啥它就是这套基础设施在图形学方向的典型应用。Mesa 里的 llvmpipe 是一个纯软件光栅化驱动它把 OpenGL 的着色器编译成 LLVM IR然后 JIT 成 CPU 上跑的机器码。也就是说你的机器没有独立显卡或者显卡驱动出了问题它也能用 CPU 把 3D 画面给你渲染出来。我见过不少开发者在无头服务器上跑 EGL 程序就是靠 llvmpipe 顶着把 OpenGL 环境跑起来的。1.2 模块化设计的核心价值llvm-project 这个仓库之所以用一个“project”而不是“compiler”来命名就是因为它在仓库层面就是一个多组件集合。核心的 LLVM 库在llvm/目录下Clang 前端在clang/调试器 LLDB 在lldb/链接器 LLD 在lld/C 标准库实现 libc 也在里面。这种布局意味着你可以按需选择要构建的组件。我记得第一次从源码构建 LLVM 的时候不知道有 CMake 开关这回事直接把所有东西全编译了结果在只有 8GB 内存的机器上硬生生等了一个多小时还一度把内存吃到 swap。后来才学会用LLVM_ENABLE_PROJECTS指定只构建需要的组件。LLVM 的设计哲学就是宁可让你多花点时间去理解构建配置也不让你在运行时承受一坨用不上的代码。这种模块化设计的另一个好处是生态的繁荣。因为 IR 是公开的稳定接口各路第三方工具可以随意接入。Rust 编译器可以选择 LLVM 的某个版本固化成自己的后端Swift 可以在 LLVM 之上再做一层自己的 SIL 优化连 Python 生态里的 Numba 都是把 Python 字节码翻译成 LLVM IR 再做 JIT。整个编译工具链的发展节奏其实是跟着 LLVM 这个大齿轮转的。2. 核心架构与关键原理解析2.1 三段式编译流程前端、中端、后端传统编译器像是 GCC它在内部把源代码解析成自己的中间表示GIMPLE然后在 GIMPLE 上做优化最后生成后端指令。这套流程所有环节揉在一起如果你想复用它的优化器去支持一门新语言基本不可能。LLVM 的设计在整个流程上就清晰得多前端阶段Clang 负责把 C/C/Objective-C 解析成抽象语法树AST再降级为 LLVM IR。rustc 也是在这个环节把 Rust 的 MIR 翻译成 LLVM IR。这个阶段只关心“语言无关的语法和语义”不关心目标 CPU。中端优化优化器拿到 LLVM IR 之后做一系列 pass比如内联、常量传播、循环优化、死代码消除。这一阶段既有与目标无关的优化也有带目标特征的向量化等。优化结果还是 LLVM IR。后端阶段后端把优化后的 IR 逐层下降到 SelectionDAG、指令选择、寄存器分配最终生成目标机器的汇编和机器码。一套 IR 贯穿三个阶段这就让“写一次优化所有硬件受益”成为可能。新架构支持比如 RISC-V 的迅猛发展只需写好后端的指令描述前面所有前端的语言支持都能无缝覆盖。这个特点让 LLVM 在嵌入式、AI 芯片等新兴领域成了默认选择——芯片厂商想支持新指令集通常最省力的路径就是往 LLVM 后端的 TableGen 描述文件里加指令。2.2 LLVM IR 到底长什么样理解了架构之后真正的门槛就是对 LLVM IR 的熟悉程度。IR 是一种静态单赋值SSA形式的强类型中间语言。SSA 的意思是每个变量只能被赋值一次这听起来有点绕但它让数据流分析变得极其简单。比如define i32 add(i32 %a, i32 %b) { entry: %result add i32 %a, %b ret i32 %result }这段 IR 定义了一个函数add接受两个 32 位整数返回一个 32 位整数。%result是这条add指令的结果在 SSA 约束下它不可能被重新赋值。你如果想做“这个变量被谁用了”这类分析顺着 SSA 的 use-def 链一遍就能找出所有使用点。这种设计对优化 pass 极其友好。编译器优化本质就是对 IR 做图算法SSA 让图的边非常干净没有“别名”带来的不确定性。当然实际的 IR 比这个复杂得多还有 phi 节点、load/store 指令、block 标签、metadata 等但核心思想就是这一套强调类型、强调数据流、强调可验证性。2.3 llvmpipe 与软件渲染里的 LLVM回到开头的 llvmpipe。它名字直译就是“LLVM pipe”这个 pipe 指的是为 OpenGL 光栅化管线实现软件渲染。常规 CPU 上实现图形渲染的思路是把显式循环一个个像素去填充llvmpipe 的做法是把顶点着色器和片段着色器的 GLSL 代码通过 Mesa 的编译器前端翻译成 LLVM IR再像 JIT 一样生成针对当前 CPU 的机器码。而且 llvmpipe 非常聪明地利用了 LLVM 的矢量化能力。现代 CPU 都有 SSE、AVX 这类 SIMD 指令一次能算 4 个或 8 个 float。llvmpipe 会对 IR 做向量化 transform让 CPU 一次处理多个像素。热词里提到的“llvm 15.0.7, 256 bits”指的就是 AVX2 的 256 位向量宽度——这条信息就是 llvmpipe 在运行时打印出来的渲染器信息。你如果跑过glxinfo | grep renderer大概率见过类似输出。所以 llvmpipe 不是“凑合能跑”的软件渲染它在 LLVM 的加持下拥有一条真正的深度优化管线。我甚至见过有人拿 llvmpipe 跑机器学习推理因为 C 推理框架在 CPU 上做算子融合的思路跟它在渲染管线上做 shader 融合的思路如出一辙。3. 实操过程与核心环节实现3.1 从源码构建 llvm-project上手 LLVM 最快的方式是直接拿官方发布的 release 分支来构建。我以 llvmorg-15.0.7 为例完整走一遍这个过程。你需要先满足基础依赖CMake 3.20 以上、支持 C17 的编译器GCC 7.1 或 Clang 5、Ninja 构建工具。git clone --depth 1 --branch llvmorg-15.0.7 https://github.com/llvm/llvm-project.git cd llvm-project cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_ENABLE_ASSERTIONSON ninja -C build构建时长取决于机器配置。Release 模式、只编 X86 和 AArch64 两个 target四核机器大概 20~40 分钟。如果你把所有 target 都编上时间会翻好几倍。这里有几个关键参数值得解释一下CMAKE_BUILD_TYPERelease开启优化并去掉调试信息。编译 LLVM 本身太吃资源Debug 模式的速度慢到怀疑人生没特殊需求不要选。LLVM_ENABLE_PROJECTSclang;lld这是你要额外构建的顶层项目。构建 Clang 用来测试自己的 IR pass 非常有用。LLVM_TARGETS_TO_BUILD控制后端支持哪些架构。默认是全部支持但如果你只在 X86 上跑把范围缩到 X86 能砍掉不少构建时间和二进制体积。LLVM_ENABLE_ASSERTIONSON开启内部断言。虽然会影响性能但开发 pass 时它能帮你尽早暴露 IR 构造错误强烈建议开。构建完成之后build/bin/下会有一堆工具日常最常用的是clang、opt、llc、lli和llvm-dis。clang负责把 C/C 翻译成 IRopt负责跑优化 passllc负责把 IR 转成汇编或目标文件lli可以直接解释执行 IRllvm-dis则能把 bitcode 反汇编成可读的文本 IR。3.2 用 Clang 和 opt 跑通第一个优化拿到工具链之后先做一个最直观的验证写一段 C 代码看它变成 IR 是什么样。新建一个add.cint add(int a, int b) { return a b; }执行clang -S -emit-llvm add.c -o add.ll cat add.ll你会看到类似之前展示的 IR 输出。注意-S -emit-llvm的意思是输出人类可读的 IR 文本如果不加-S默认会生成二进制的.bcbitcode 文件。接着试试优化opt -S -passesmem2reg add.ll -o add.opt.llmem2reg是最经典的优化 pass它的作用是把内存操作提升为 SSA 寄存器变量。如果源代码里有局部变量优化前你会看到大量的alloca和load/store指令跑完mem2reg之后这些冗余操作会被消除IR 清晰得多。这里有个版本差异的坑需要特别说明LLVM 15 默认使用的是New Pass Manager优化 pass 的写法是-passes...而 LLVM 14 之前常用的-mem2reg这种旧语法在老版本里有效到 15 你会收到 deprecation 警告或者报错。网上的很多教程还停留在旧语法照着抄就踩坑。确认版本后统一用-passes的写法比较省心。3.3 动手写一个简单的自定义 Pass对很多人来说真正上手 LLVM 的标志性动作是写一个自己的 pass。这里我写一个非常简单的 FunctionPass功能是统计每个函数里基本块BasicBlock的数量然后打印到标准输出。基于 LLVM 15 的 New Pass Manager 来写。新建BlockCounter.cpp#include llvm/IR/Function.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 BlockCounter : public PassInfoMixinBlockCounter { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { errs() Function: F.getName() has F.size() basic blocks\n; return PreservedAnalyses::all(); } }; } // namespace extern C ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, BlockCounter, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name block-counter) { FPM.addPass(BlockCounter()); return true; } return false; }); }}; }这个 pass 的核心逻辑在run方法里F.size()返回的就是一个函数里基本块的个数。我们把它编译成动态库然后让opt在跑 pipeline 的时候加载它。编译和运行命令如下clang -shared -fPIC -fno-rtti BlockCounter.cpp \ -o BlockCounter.so \ llvm-config --cxxflags --ldflags --libs opt -load-pass-plugin./BlockCounter.so \ -passesblock-counter add.ll -o /dev/null如果一切顺利终端会打印出Function: add has 1 basic blocks。这个例子虽然简单但它把整个 pass 开发的框架走通了你写 Transform 或 Analysis pass、通过 plugin 动态加载、在编译管线里按名字挂载之后的功能都是在这个骨架上长出来的。3.4 从 IR 到机器码llc 的完整流程优化过的 IR 最终要变成机器码这是 LLVM 后端的活。你可以用一个非常短的命令完成llc add.opt.ll -o add.s cat add.s如果目标机器是 X86_64你会看到生成的汇编里面可能包含addl %esi, %edi这种指令。llc还支持通过-march参数指定目标架构比如-marchaarch64生成 ARM64 汇编。这正是 LLVM 跨架构的典型用法同一份 IR可以交叉编译到不同的 CPU 指令集。后端流程里最重要的一个开关是-mcpu和-mattr。比如你想针对-mcpuskylake做指令调度和向量化或者通过-mattravx2显式启用 AVX2 指令。llvmpipe 那个“256 bits”的打印就是它在运行时探测了当前 CPU 的向量能力之后选择使用 AVX2 指令集做 JIT 的结果。了解这些之后你就明白为什么 LLVM 在所有高性能计算场景都有存在感——它把“针对具体硬件做代码生成”这件事做到了极致的可配置。4. 常见问题与排查技巧实录4.1 构建阶段的高频报错构建 LLVM 期间遇到最多的问题基本集中在资源不足和依赖版本不匹配。下面这几条是我和身边同事都踩过的内存或磁盘不足Release 模式下链接clang本身需要几个 GB 内存如果你在云主机上构建建议至少配 4GB swap。磁盘方面整个构建目录很可能占用超过 30GBdf -h先看一眼再动手。遇到 OOM 时可以用-j2甚至-j1限制并行度慢一点总比中断强。CMake 版本过低LLVM 15 要求 CMake 3.20 以上Ubuntu 20.04 自带的 3.16 就不行。解决方案就是自己装新版 CMake我建议直接用 pip 安装pip install cmake版本既新又不污染系统。链接器内存爆掉在 macOS 上构建 LLVM默认 ld 常常在链接期直接 OOM甚至报错退出。解决办法是在 CMake 配置里加一行-DLLVM_USE_LINKERlld让 LLVM 用自带的 lld 做链接。Linux 上如果内存不太够也可以加上这个参数lld 的链接速度比 GNU ld 快一倍以上内存占用也更低。4.2 理解 opt 的 pass 命名与语义差异在自己写 pass 的时候最容易困惑的是 Legacy Pass Manager 和 New Pass Manager 的 API 差异。LLVM 15 虽然默认新 Pass Manager但llvm/IR/LegacyPassManager.h这类头文件依然存在很多旧教程的代码是给旧 API 写的。两者的核心区别是前者用FunctionPass继承后者用PassInfoMixinrun方法。具体写的时候如果你看到runOnFunction这样的方法名说明是旧 API如果你看到run(Function F, FunctionAnalysisManager AM)这是新 API。一定不要混用。另外新 Pass Manager 还要求你在llvmGetPassPluginInfo里注册好构建回调否则opt -load-pass-plugin加载了也找不到 pass。我见过不少人卡在这一步报错信息又不够直观最后才发现是注册函数写漏了。4.3 llvmpipe 软渲染环境怎么确认如果你需要在无 GPU 环境跑 OpenGL确认 llvmpipe 是否生效很重要。命令行里跑glxinfo | grep renderer输出类似llvmpipe (LLVM 15.0.7, 256 bits)就说明用的是 llvmpipe 软渲染。这个 256 bits 表示当前 SIMD 宽度是 256 位对应 AVX2。如果是比较老的 CPU可能会显示128 bits对应 SSE 指令集性能会差一些。有时候你已经装了 Mesa却发现程序报错找不到 OpenGL 驱动。这时可以手动指定软件渲染export LIBGL_ALWAYS_SOFTWAREtrue export GALLIUM_DRIVERllvmpipe第一行强制 OpenGL 走软件渲染第二行指定直接使用 llvmpipe 驱动。这个技巧在 CI/CD 环境跑图形测试时特别常用也适合调试那些必须依赖 OpenGL 但机器又没有独立显卡的程序。5. 优化技巧与实战经验5.1 善用 llvm-config 和工具链组合日常开发中我强烈建议把llvm-config学好。它就是你安装的 LLVM 的“参数查询器”。比如你想知道编译器和链接器需要什么 flag可以用llvm-config --cxxflags --ldflags --libs它输出的参数可以直接拼进clang编译命令省去手写一堆-I和-L的麻烦。我在写插件、写自定义工具时基本都会用它生成编译参数。lli也是被忽视的工具。它可以直接解释执行或者 JIT 执行一份 IR 文件不用经过汇编链接成可执行文件的步骤。在做 IR 层面的快速原型验证时特别高效echo int main() { return 42; } test.c clang -S -emit-llvm test.c -o test.ll lli test.ll echo $?直接输出 42。像这样把验证周期缩到最短对理解 IR 语义非常有帮助。5.2 用-time-passes定位性能瓶颈当你开始做复杂的 IR transform可能会发现某段代码经过优化后性能没提升甚至变差。这时候不要瞎猜LLVM 自带的 pass 计时工具能帮你快速定位opt -time-passes -passesinline,constprop,gvn,mem2reg add.ll -o add.opt.ll它会输出每个 pass 的耗时让你知道热点在哪。另一个常用技巧是跑完 pass 之后用llvm-opt-report或者llc -stats查看优化前后指令数的变化。定位问题的时候有个原则先确认你添加的 pass 有没有真正改变 IR再谈性能收益。很多时候问题出在 pass 没跑上而不是优化策略不对。5.3 利用 TableGen 学习指令集描述如果你想深入了解 LLVM 怎么生成一条指令可以进入llvm/lib/Target/X86/X86InstrInfo.td这类 TableGen 文件去看看。TableGen 是 LLVM 用于描述指令集、寄存器文件、调用约定等领域特定语言。它看起来有点吓人其实本质就是声明式配置。新架构的移植工作里指令描述占了很大比例写好 TableGen代码生成器基本就完成了一大半。我的建议是不必一开始就去啃 TableGen而是先通过一个具体的后端问题切入比如“为什么我写的 IR 编译出来会有多余的 move 指令”然后回溯到指令选择阶段看看如何通过 TableGen 约束指令模式。边查边学比通读文档效率高得多。写 pass 还有个小经验值得分享在 IR 里打日志用errs()它会输出到标准错误流避免和 opt 的输出文件混淆。千万不要用std::cout在 LLVM 的工具链里混合stdout的输出可能把输出文件弄乱尤其当输出文件时通过重定向到文件的方式实现时更容易踩这坑。LLVM 的上手曲线确实陡但它的每一层设计都有迹可循。从 IR 到 pass从 TableGen 到 JIT每一个环节都是这块庞大生态里的可替换积木。我当初从“照着教程写 pass 报错”到“能按需改 IR 做自定义优化”中间最大的坎其实是心态——面对纷繁的 API最好的方式不是试图通读全部文档而是带着具体问题去源码里翻答案。llvm-project 的代码结构非常规整每个 pass 都是一个独立目录相近功能放在一起这大概就是这个项目最让人舒服的地方。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进