ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

深入llvm-project:理解LLVM IR、构建与Pass开发

深入llvm-project:理解LLVM IR、构建与Pass开发 如果你是在搜索引擎里敲下“llvm-project”这个词才点进来的我猜你大概率正面临三种情况之一要么是编译某个开源项目时看到一长串 LLVM 依赖手足无措要么是在 glxinfo 输出里看到llvmpipe (LLVM 15.0.7, 256 bits)这样的字符串想知道它到底什么意思要么是打算往编译器方向走想从源码层面搞明白 LLVM 究竟是个什么东西。这三种情况我都经历过所以这篇不打算写成文档翻译也不打算写成功劳簿就是想从 llvm-project 这个仓库本身出发把它到底是什么、核心概念怎么理解、源码怎么构建、实际应用里的 llvmpipe 又是怎么回事、以及想动手改造该从哪下手一次讲清楚。读完你应该能建立一张完整的脑内地图不再被那一堆子目录吓到。1. LLVM 不是编译器是一套编译器工厂1.1 从“编译器三段式”说起先说一个最容易被误解的地方。很多人把 llvm-project 当成“一个 C/C 编译器”这个说法不算错但会严重限制你对它后续所有设计的理解。真正的 LLVM 是一个编译器基础设施换句话说它不是某一种编译器的成品而是生产编译器的“机床和零件库”。传统编译器的结构大体上是三段式前端负责把源代码解析成中间表示中端负责对中间表示做各种与机器无关的优化后端负责把优化完的中间表示翻译成目标机器的汇编或机器码。GCC 也是这个架构但它的前端、中端、后端是强绑定的你想给 GCC 加一个新语言前端或者加一个新 CPU 后端工作量大到足以劝退绝大多数团队。LLVM 做的事情是把中间表示IR和后端CodeGen抽出来做成完全语言无关的公共设施。前端只需要把任何语言翻译成 LLVM IR后端只需要把 LLVM IR 翻译成目标指令剩下大约百分之六十的优化逻辑完全不用重写。这就是所谓“编译器工厂”的含义Clang 只是在工厂里用一套模具造出来的 C/C 编译器Rust 的 rustc 后端、Swift 编译器、各种 DSL 编译器都是从同一个工厂里出来的不同产品。1.2 llvm-project 仓库里到底躺了些什么我第一次打开 llvm-project 仓库时整个人是懵的因为根目录下不是一个叫 llvm 的文件夹而是一大堆并行目录。你不需要每个都懂但至少要分清它们的角色目录角色简单理解llvm核心库IR 定义、Pass 优化框架、CodeGen、各目标后端clangC/C/Objective-C 前端把 C/C 变成 LLVM IRclang-tools-extra基于 Clang 的工具clangd、clang-tidy、clang-format 等lld链接器用 LLVM 库重写的高性能链接器lldb调试器LLVM 生态的调试器libcxx / libcxxabi / libunwindC 标准库实现对应标准库、ABI、栈展开compiler-rt运行时库sanitizer、builtins 等底层支持flangFortran 前端LLVM 官方 Fortran 编译器mlir多级 IR 基础设施面向 AI/芯片方向的编译器框架polly循环优化多面体模型优化器看到这个列表你就明白了llvm-project 是一个 monorepo把完整工具链全家桶放在同一个仓库里统一管理和构建。平时你可能只用到 clang 和 lld但如果你要自己定制编译器这些目录都是你的素材。1.3 为什么整个行业都在围绕它转业内选择 LLVM 不是因为它性能碾压其余工具而是因为它把“编译”这件事的复用性做到了极致。几条典型的例子iOS 生态很早就用 Clang 替代了旧的编译器前端Rust 的 rustc 官方后端最初就构建在 LLVM 之上Rust 社区并行开发过的若干替代后端都没能撼动 LLVM 的地位GPU 厂商、FPGA 厂商也都在基于 LLVM 做芯片编译器。原因惊人地一致他们只需要写一个新后端或者借用 TableGen 描述指令集就能获得一套经过十余年打磨的优化器、寄存器分配器、指令调度器。你想想如果每做一个芯片都要从零写一遍优化器那这个行业根本转不动。所以当你面对 llvm-project 时先不要问“它怎么编译我的代码”而要问“我能利用它的哪一层”。这是衔接整个项目知识体系的第一把钥匙。2. LLVM IR 与 Pass 管线先搞懂优化到底动的是什么东西2.1 SSA 与无限虚拟寄存器不那么“显然”的设计LLVM 的整个中端构建在一个叫静态单赋值SSA的形式之上核心约束是每个变量只能被赋值一次。你可能觉得这很反直觉毕竟正常代码里x x 1到处都是。但正是这个“只能赋值一次”的限制让很多优化变得非常简单。举个例子一条a b c的指令如果后面的代码修改了 c普通编译器得做数据流分析才能确认 a 的旧值是否还能用。SSA 形式下c 只有在定义处才有那个值后来再给 c 赋值实际是创建了一个新的 SSA 名字老名字所代表的数值永远不变。这种不可变性让全局值编号、公共子表达式消除、死代码消除都从“需要复杂分析和证明”变成了“局部模式匹配就能处理”。更妙的是 LLVM 还用“无限虚拟寄存器”。中端优化时你根本不用关心目标机器到底有几个物理寄存器可以认为要多少临时寄存器就有多少。寄存器分配这件事被彻底推迟到了后端由后端的寄存器分配器把虚拟寄存器映射到真实寄存器。这个分层让中端代码与目标机器完全解耦再奇怪的新架构也不用改中端逻辑。2.2 一份真实的 LLVM IR 长什么样光说不练是空的。下面这段 C 代码int clamp_add(int a, int b) { int s a b; if (s 100) s 100; return s 0 ? 0 : s; }用clang -S -emit-llvm -O2 clamp.c编译得到的核心 IR 大概是这样define i32 clamp_add(i32 %a, i32 %b) { %s add i32 %a, %b %cmp icmp sgt i32 %s, 100 %val select i1 %cmp, i32 100, i32 %s %cmp2 icmp slt i32 %val, 0 %ret select i1 %cmp2, i32 0, i32 %val ret i32 %ret }你需要掌握三个最基本的元素。一是类型系统i32表示 32 位整数每条指令都带完整的类型信息二是强类型指令集add、icmp、select都是语义非常明确的指令不存在“读内存通用指令”这种模糊操作三是函数、基本块、指令三层结构函数由若干基本块组成每个基本块是一个直线指令序列块之间通过跳转指令连接。理解这三根支柱你读 IR 的速度会立刻上来。2.3 Pass 管线优化是一条 IR 到 IR 的加工流水线LLVM 的优化并不是一个巨型函数把所有事情一次做完而是拆分成几十个独立的 Pass。每个 Pass 只干一件事要么做分析比如统计循环信息、计算别名关系要么做变换比如把一段代码替换成等价的更优形式然后 IR 在流水线上被逐站加工。所谓-O2本质上就是用opt工具加载一大串固定顺序的 Pass。几个你迟早会接触到的 Pass 名字InstCombine 负责各种指令级恒等式化简s 0 ? 0 : s这类 clamps 就有专门的简化规则GVN 做全局值编号消除冗余计算Inliner 决定哪些小函数要被内联LoopVectorize 会把标量循环改写为向量循环。记住一个重点这些 Pass 全部工作在 IR 层输入输出都是 IR机器码生成是后面后端的事。如果你以后想优化自己的 DSL 编译器大部分时间其实都在写这样的 Pass而不是去碰汇编生成。3. 手搓一份 LLVM 15CMake 配置、编译时长与三个真实翻车点3.1 构建前的硬性条件内存、磁盘和基础工具源码构建 LLVM 不是一件“./configure make”就能轻松搞定的事它是我见过对构建机器要求最苛刻的开源项目之一。LLVM 15 的最低要求大致是这样项目建议配置说明内存8GB 以上推荐 16GB链接 clang 时单个 C 链接进程可能吃掉 5GB磁盘至少 50GB 可用空间ReleaseDebug 构建很容易膨胀到 30-60GBCMake3.20 以上太老版本会直接报错编译器GCC 7.1 或 Clang 5推荐用 Clang 自举产物性能更好构建工具Ninja并行度和增量构建都远胜 Make其他Python 3、zlib、zstd测试系统和压缩组件需要这里有个容易忽略的点构建 LLVM 自身需要的是一个“能编译现代 C17 代码”的编译器。如果你的系统 GCC 版本偏老构建过程中会爆出各种莫名其妙的模板错误那通常不是源码问题而是你的编译器该升级了。3.2 一份可以用到生产环境的 CMake 配置LLVM 15 的 CMake 配置项非常多但真正每次都要调的也就那么几个。我常用的配置是这样cmake -G Ninja -B build \ -DCMAKE_BUILD_TYPERelease \ -DCMAKE_C_COMPILERclang \ -DCMAKE_CXX_COMPILERclang \ -DLLVM_ENABLE_PROJECTSclang;lld;clang-tools-extra \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_ASSERTIONSON \ -DLLVM_PARALLEL_LINK_JOBS2 \ -DLLVM_CCACHE_BUILDON逐个解释为什么这么设。CMAKE_BUILD_TYPERelease控制生成的 LLVM 工具自身的优化级别Release 模式编译最快、运行也最快是你日常使用最合适的如果你要做 LLVM 开发调试可能需要RelWithDebInfo但千万别默认用Debug否则编译会慢到怀疑人生。LLVM_ENABLE_ASSERTIONSON打开内部断言检查虽然会让构建产物变慢一些但对于开发阶段排查问题是无价的。LLVM_TARGETS_TO_BUILDX86是加速神器它告诉 LLVM 只需要生成 X86 后端否则默认会编译 ARM、RISCV、PowerPC 等一大堆你根本用不上的 Target。配置完之后不需要 ninja 全量构建按需构建目标就行ninja -C build clang lld opt clang-format你只需要 clang 和 lld那就只构建这两个目标速度会快很多。3.3 三个我实际撞过的坑链接 OOM、断言过慢、目标过多第一个坑是链接 OOM。早期我在一台 8GB 内存的笔记本上构建 clang全核并行跑到了链接阶段机器直接卡死。原因就是 clang 这个 C 程序本身太庞大链接单个可执行文件需要数个 GB 内存并行链接会瞬间把内存打爆。解决方法是-DLLVM_PARALLEL_LINK_JOBS1把链接任务串行化同时如果系统里有 lld还可以加-DLLVM_USE_LINKERlld用 lld 做宿主链接器链接速度快一大截。第二个坑是 Debug 模式慢成幻灯片。我早期想看优化流程图省事直接用了默认的 Debug 构建结果连opt --help都等了好几秒跑一遍 O2 流水线的时间够我泡三杯咖啡。后来我学乖了平时分析用 ReleaseAssertions真需要调试某个 Pass 时才单独用 RelWithDebInfo 重编那一个目标。经验教训就是不要把整个工具链都放在 Debug 模式里调试是点状行为全量 Debug 是自虐。第三个坑是目标架构默认全开。如果你完全不设置LLVM_TARGETS_TO_BUILDLLVM 会默认生成所有官方支持的后端代码编译时间直接乘好几倍。很多新手第一次构建失败其实不是报错而是构建了一晚上还没建完。只需要按需开 X86 或你实际部署的架构省下的都是实打实的时间。还有一个针对 LLVM 15 版本特有的小知识clang、lld 这类粒子项目用LLVM_ENABLE_PROJECTS配置而 compiler-rt、libcxx 这类运行时组件在 15 里更推荐用LLVM_ENABLE_RUNTIMES单独配置。两个变量写反或者混用CMake 会给出比较磨人的警告第一次见容易懵。4. glxinfo 里的 llvmpipe从“LLVM 15.0.7256 bits”看 JIT 与向量化4.1 llvmpipe 是什么没有 GPU 时谁在帮你画界面如果你在 Linux 服务器、虚拟机或者没有安装 GPU 驱动的机器上跑过glxinfo | grep renderer大概率见过这样一串输出OpenGL renderer string: llvmpipe (LLVM 15.0.7, 256 bits)很多人看到这行字会误以为“系统装了个叫 llvmpipe 的软件”。准确地说llvmpipe 是 Mesa 图形库中的一个软件光栅化驱动。Mesa 是 Linux 图形栈里 OpenGL/Vulkan 的开源实现当你的机器没有可用的硬件 GPU 驱动时Mesa 会退回到软件渲染路径用 CPU 去执行本来该由 GPU 完成的图形计算。而 llvmpipe 就是这个软件渲染路径里性能最强的那个驱动。那 LLVM 在里面扮演什么角色简单说shader着色器是一段小型的并行计算程序硬件 GPU 里有成千上万个核心来跑它。可你没有 GPU 驱动时CPU 是通用的标量处理器要让 CPU 效率足够高地跑 shader就必须做两件事把着色器语言编译成 CPU 能执行的机器码并且尽量利用 CPU 的 SIMD 向量指令来模拟 GPU 的并行宽度。这两件事恰好都是 LLVM 的看家本领。4.2 从 shader 到机器码Gallivm 的 JIT 流程Mesa 内部做了一个叫 Gallivm 的模块专门负责把 shader 编译成 CPU 指令。流程大体是这样开发者写 GLSLMesa 前端把它翻译成自己的中间表示 NIRGallivm 拿到 NIR 之后逐条翻译成等价的 LLVM IR比如一个fma运算就映射成 LLVM 的llvm.fma内建函数紧接着 LLVM 用一套经过裁剪的优化流水线对这个 IR 做优化再进行向量化最后通过 LLVM 的 JIT 执行引擎把 IR 变成当前 CPU 的机器码放入内存直接调用。这里最妙的地方是llvmpipe 不是一个解释器而是一个真正的 JIT 编译器。它生成的代码质量直接决定了软件渲染的流畅度。你玩游戏卡不卡很大程度取决于 LLVM 有没有把那几条最核心的内层循环向量化到位。这也解释了为什么 Mesa 的 llvmpipe 版本高度绑定特定的 LLVM 版本因为一旦 LLVM 升级带来更好的代码生成整个软件渲染器的性能都会跟着受益。4.3 “256 bits”是怎么来的以及它为什么重要渲染器字符串里的256 bits指的是 llvmpipe 在当前 CPU 上选用的原生向量宽度单位是 bit。x86-64 平台上一共有三档常见向量宽度SSE2 是 128 位AVX/AVX2 是 256 位AVX-512 是 512 位。llvmpipe 在初始化时会探测 CPU 支持哪些指令集如果发现支持 AVX2就会把 256 位作为默认向量宽度于是 glxinfo 里就出现256 bits这个字样。这意味着它可以一次把 8 个单精度浮点数打包成一个向量并行计算。假设你在做逐像素颜色计算一个像素要算 4 个 float256 位向量一次能处理 8 个 float相当于两条 SIMD 指令就能覆盖两个像素的核心运算。相比之下纯标量代码得一条一条地算性能差距是数量级的。反过来你也可以通过这个字符串判断 llvmpipe 当前用了哪档向量宽度看到 256 说明 AVX2 生效如果看到 128 那大概率是运行在只支持 SSE2 的旧 CPU或者环境变量/编译参数限制了向量宽度。如果你真想给一个无 GPU 的服务器配软件 OpenGL记得确认 CPU 支持 AVX2这比任何软件层面的调优都更直接。5. 15 分钟跑通一个自定义 LLVM Pass插件化开发的起点5.1 llvm-project 源码目录的快速定位法真要动手改 LLVM第一件事不是写代码而是学会在源码里找路。llvm-project 的llvm/子目录下有两大块核心include/llvm/放头文件和 API 声明lib/放实现。对我这种靠搜索活命的人来说经常用的路径就几个include/llvm/IR/Instruction、BasicBlock、Function 等核心类定义几乎所有 Pass 都要用到。lib/Transforms/中端优化 Pass 的源代码比如InstCombine/、Scalar/、Vectorize/。lib/CodeGen/后端代码生成相关包括寄存器分配、指令选择。lib/Passes/Pass 管线的注册和编排。读源码顺序推荐从 IR 模块开始先搞懂Instruction有哪些常见子类再到FunctionPass的文档和示例。不要一上来就啃 CodeGen那是整座冰山的最底层新手容易被淹死。5.2 开发一个 Pass 插件并编译加载它LLVM 15 里最推荐的起步方式是写插件plugin不需要改 llvm-project 源码也不需要重新编译整个工具链。新建一个demo-pass.cpp#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 { struct DemoPass : public PassInfoMixinDemoPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { for (BasicBlock BB : F) { for (Instruction I : BB) { if (auto *Call dyn_castCallInst(I)) { errs() callsite: Call-getCalledFunction()-getName() in F.getName() \n; } } } return PreservedAnalyses::all(); } }; } // namespace extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, demo-pass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name demo-pass) { FPM.addPass(DemoPass()); return true; } return false; }); }}; }这段代码用 LLVM 15 默认的新 Pass Manager 接口定义了一个“打印所有函数调用点”的 Pass。编译它不需要整个 LLVM 构建产物只需要已安装的 LLVM 头文件和库clang -fPIC -shared -stdc17 \ -I/path/to/llvm-project/llvm/include \ -o demo-pass.so demo-pass.cpp然后对一份 IR 执行opt -load-pass-plugin./demo-pass.so -passesdemo-pass input.ll -S看到每个函数里的调用点被打印出来你的第一个 LLVM Pass 就跑通了。这个最小闭环的意义在于你理解了新 PM 下 Pass 的注册、运行、返回PreservedAnalyses的模式。再往深里走无非就是在这个函数体里写更复杂的分析和变换逻辑。需要注意 LLVM 15 的opt默认就是新 Pass Manager你上网搜旧教程时如果看到-load ./xxx.so而没有-load-pass-plugin那多半是讲老 PM 的文章接口对不上时别慌查一下你的 LLVM 主版本对应的文档。5.3 用 lit 与 FileCheck 做测试自己写 Pass 不写测试跟没写差不多。LLVM 官方测试框架是 lit FileCheck思路极其直接测试文件里用RUN:行描述要执行的命令然后让 FileCheck 去匹配输出。举个例子为上面的 DemoPass 建一个测试文件; RUN: opt -load-pass-plugin%T/demo-pass.so -passesdemo-pass -S %s | FileCheck %s define i32 main(i32 %x) { %r call i32 helper(i32 %x) ret i32 %r } declare i32 helper(i32) ; CHECK: callsite: helper in main%T会指向测试输出的临时目录%s是当前测试文件自身。FileCheck 逐行寻找匹配CHECK那行指定了我们期望看到的输出。把测试文件放进llvm/test/对应的子目录里然后用ninja -C build check-llvm跑全量测试或者用llvm-lit单跑这一个文件。这套流程看起来简单但它是你今后提交代码的基本门槛LLVM 社区对测试覆盖率的要求非常高。6. 什么情况该自编译 LLVM什么情况该直接用发行版6.1 什么时候“apt install llvm-15”就够了如果你的头号目标是“用 clang 编译我的 C/C 项目”那完全没有必要源码构建。各主流发行版都有打包好的 LLVM 版本比如 Debian/Ubuntu 上的llvm-15、clang-15、lld-15等软件包装完直接用版本配套、补丁也打好了。这个场景下的核心诉求是稳定可用的编译器工具链而不是自己折腾环境。类似的还有 macOS 用户直接使用系统自带的工具链或者 Homebrew 的 llvm 包即可。很多人在这一步纠结“我要不要为了学 LLVM 源码而自编译全套”我建议不要混为一谈学源码可以只读仓库代码不必先全量构建再开始学只有当你确实需要修改编译器的行为时构建才变得必要。6.2 需要自编译的三种典型场景第一种是需要精确版本控制。发行版里的 LLVM 往往不是你想要的版本比如 Mesa 的 llvmpipe 链接的 LLVM 版本和系统提供的不一致或者某个显卡驱动的编译器后端要求特定 minor 版本。这种情况自编译一个带LLVM_VERSION_MAJOR匹配的版本会比强行给发行版打包补丁省心得多。第二种是你要给 LLVM 打补丁或者扩展新功能。比如给目标后端添加一个自定义指令支持或者想改某个 Pass 的优化策略。这种场景下你不仅要构建还必须构建得能被你增量修改所以会用 ccache 缓存编译产物用LLVM_CCACHE_BUILDON配合ninja实现很快的增量构建。第三种是你要把 LLVM 作为库嵌入自己的产品。比如你做一个编程语言要用 LLVM 做 JIT 后端那你链接的是libLLVM库需要自己定制构建选项还要注意把不需要的目标架构裁掉以减小体积。这时候发行版的包通常就不够灵活了自己构建是一种刚需。6.3 如何判断自己编译出的库版本是否真的生效很多人在这一步踩坑自己编译了一个 LLVM但运行程序时发现系统还是调用了旧版本。判断方法很简单先确认你安装的前缀/path/to/llvm/build/bin/llvm-config --version /path/to/llvm/build/bin/clang --version如果你写代码链接的是源码构建出的libLLVM.so建议直接用绝对路径引用库或者在 Linux 下临时把LD_LIBRARY_PATH指到构建输出目录避免动态链接器捡到系统旧货。至于 llvmpipe 那种场景glxinfo 里显示的是 Mesa 编译时检测到的 LLVM 版本通常也是运行时链接的版本但如果你想验证实际加载的是哪个库可以用ldd看 Mesa 驱动文件里 LLVM 的解析路径把“编译期版本”和“运行库版本”对一下不一致时优先解决库加载路径。最后分享一个我自己的习惯每当我在陌生环境拿到一份 llvm-project不会急着全量构建而是先跑一遍llvm-config --host-target --assertion-mode检查默认配置再用ninja -t targets | grep clang看看有哪些可用构建目标随后按需构建最小集。先把“最小可用闭环”跑通再往里加需求这个思路能帮你省下大把无意义的编译等待。编译器这个领域看着吓人其实门槛就藏在那些没人愿意讲清楚的构建细节里迈过去之后你会发现自己读任何开源编译器代码都从容得多。
返回列表