多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

LLVM编译器核心解析:IR原理与Pass开发实战

LLVM编译器核心解析:IR原理与Pass开发实战 项目标题就一个llvm-project但做过编译器、搞过性能优化、甚至只是啃过《编译原理》的人看到这个名字应该都会心一笑。这个仓库几乎是现代编译器世界的“第一公民”从苹果的 Clang 到 Rust 官方工具链再到 GPU 厂商的底层编译器底层都有它的影子。这篇文章不是入门导览不是官网文档翻译而是把我自己从零开始追这个项目、读源码、改 IR、自己编 Pass 的整个路径拆开揉碎把那些文档里不会写的坑和门道一次性讲清楚。适合已经能跑通clang hello.c但想真正深入 LLVM 内部、准备往编译器方向深耕的开发者。1. 项目全貌为什么说 llvm-project 是一个“编译器宇宙”1.1 核心需求解析从“一个编译器”到“一套基础设施”最容易被忽视的一点是llvm-project不是“一个编译器”而是一整套编译器基础设施。它解决的核心问题是如何让编程语言的编译成本降到最低让新语言、新硬件、新优化算法都能站在同一个肩膀上复用彼此成果。传统 GCC 的思路是“一个语言前端配一个后端”如果你要支持一种新语言整个优化器和代码生成器都要跟着重写。而 LLVM 的核心抽象是 IR中间表示。前端把源代码翻译成 IR优化器只处理 IR后端的任务是把 IR 变成目标机器码。这样切分之后前端和后端完全解耦理论上给 LLVM 加一门新语言你只需要写一个前端给 LLVM 加一个新 CPU 架构你只需要写一个新后端。这是因为这个设计llvm-project 才能同时滋养 ClangC/C/ObjC、Rustc、Swift、Julia、Zig 等一大票语言生态。我在实际看代码的时候有一个体会这个项目的重心分配极其夸张。你 clone 下来之后会看到clang目录占了将近一半的体积lld和lldb又是两个大块头而纯正的 LLVM 核心llvm/lib反而是很多人没耐心细读的部分。但恰恰是核心 IR 和 Pass 基础架构定义了整个项目的灵魂。1.2 工程目录解剖clang、lld、lldb、mlir、polly 的角色分工刚接触这个项目的人最容易被十来个一级目录搞晕。我建议你把它们按“功能角色”分类目录角色通俗理解llvm/核心基础库整个宇宙的“通用物理法则”包含 IR、Pass 框架、代码生成、优化器clang/C/C/ObjC 前端把高级语言变成 IR 的“翻译官”lld/链接器把编译产物装配成可执行文件的“装配车间”lldb/调试器让开发者能“透视”程序运行状态的“显微镜”mlir/多层 IR 框架专为 AI 计算图、硬件编译而生的“积木系统”polly/多面体优化针对循环嵌套的“数学级”优化器compiler-rt/运行时库提供内存检测、profile 等底层运行时支持libcxx//libcxxabi/C 标准库实现Clang 默认搭配的 C 库openmp/OpenMP 运行时并行编程模型的底层实现实际操作中最容易踩坑的是很多人把llvm当作“完整项目”只构建llvm目标结果后面用clang -O2一切正常但一用lld就找不到一调试就提示没有lldb。这是因为默认构建目标可能不包含所有组件。我的做法是一开始就明确指定LLVM_ENABLE_PROJECTS把真正要用的组件一网打尽。1.3 这套生态能干什么从自定义语言到 AI 编译器理解llvm-project能干什么比“它是什么”更重要。我概括成三类典型场景新语言后端落地你发明了一门语言不想从零写优化器和机器码生成器。你只需要把语法树降级成 LLVM IR立刻得到世界级的优化器、多个 CPU 平台支持和几十种调试/分析工具。海量性能优化实验学术界和工业界要做编译优化研究不必整个重写编译器。你只需要在 Pass 管理器里注册一个自定义 Pass像插拔 U 盘一样对 IR 做变换跑基准测试验证。AI 计算图与异构编译MLIR 的出现让 LLVM 家族进入神经网络编译赛道。DeepMind、Google 的合作以及各类 AI 芯片工具链都基于这一层做计算图优化。这些都是实打实的使用场景不是概念吹嘘。我自己就接过一个自定义 DSL 编译到 WASM 的小项目核心工作量基本都在前端语法处理真正的代码生成和优化直接白嫖 LLVM大概两个月就做出一个能跑的 MVP这个效率靠传统方案根本做不到。2. 核心设计拆解IR 的魔法与 Pass 的流水线2.1 LLVM IR编译器世界的“通用语”LLVM IR 是理解整个项目的钥匙。如果你在终端里执行clang -S -emit-llvm hello.c就能看到它的真面目。它有一个很有趣的性格它既有高级语言的层次结构函数、基本块、指令又有低级语言的干脆直接只有三种指令格式对齐、加载、存储、算术等像是 C 和汇编之间的一个完美折中。这种设计的合理性在于优化器需要能对程序做“语义级”的重构所以 IR 不能像汇编那样琐碎后端又需要能很容易地把 IR 映射成机器指令所以 IR 不能像 C 那样藏着指针和内存模型。最常见的alloca指令就是一个典型它负责在栈上分配内存但优化器往往会在后面把它替换成寄存器这就是 mem2reg Pass 的经典工作。我强烈建议新手花时间阅读llvm/docs/LangRef.rst那是我见过最诚实的语言规范之一。你会看到getelementptr这类指令为何如此“反直觉”——因为它专门为数组/结构体地址计算设计坑多但效率极高。理解了 IR 语法读任何 Pass 源码都不会再像看天书。2.2 前端前端之后Pass 管线的编排逻辑拿到一份 IR 之后llvm-project 的“魔法”就发生在 Pass 管线里。你平时敲的-O2、-O3并不是一个单一优化而是一条精心编排的流水线。每个 Pass 负责一个极小的变换比如Dead Code Elimination死代码消除把计算了但没用到的指令删掉。Loop Unroll循环展开减少循环控制开销提升指令级并行。Inliner内联把函数调用替换为函数体减少调用开销。这些 Pass 之间的顺序非常讲究调换顺序可能直接让最终代码体积暴涨或性能下降。例如mem2reg必须尽早跑因为它把栈变量提升到 SSa 寄存器后面的优化才有更好的分析基础而loop-unroll通常跑得比较靠后此时前面的分析已经积累了良好的上下文。在llvm/lib/Passes/PassBuilder.cpp里有非常详细的管线定义我每次想查某个优化到底在哪个阶段执行、前后是谁就会翻这个文件。它的编译顺序就是新版本基于新 Pass 管理器的核心入口。2.3 新旧 Pass 管理器切换入坑者最容易遇到的版本断层如果你在网上搜索写 Pass 的教程会看到大量基于“legacy Pass manager”的代码比如RegisterPassMyPass这类写法。而在 LLVM 14 之后的正式主流是“new Pass manager”它的 API 和注册方式完全不同。这正是初学者最容易迷惑的地方抄来的代码放在现代版本上根本编译不过。新 Pass 管理器的优势在于Pass 之间的依赖明确化可以并行执行互不干扰的分析结果安全性更高。从FPM (FunctionPassManager)到ModuleAnalysisManager整个设计都更现代。具体往后看我会给一个基于新 Pass 管理器的可用示例。这里先提醒如果你搜到资料里出现头文件llvm/IR/LegacyPassManager.h基本可以判断那是古董教程建议趁早换源。3. 构建实战从 clone 到可用的最快路径3.1 版本选择与代码拉取不要直接 git clone 了事首先要明确llvm-project 主分支永远处于活跃开发状态今天 clone 主分支明天可能就变了。做实际项目一定要用 release 分支或 tag。我一般去 GitHub 的 Releases 页面看最新的稳定版本号然后git clone --depth 1 --branch llvmorg-17.0.6 https://github.com/llvm/llvm-project.git。浅克隆节省网络和时间完整历史对绝大多数人来说毫无必要。选择版本时还注意一下你的依赖环境。比如 LLVM 17 要求 CMake 3.20、GCC 7.1 或 Clang 5.0。如果系统自带的老版本编译器不满足要求老老实实先装新 GCC 或者用包管理器升级不然会在配置阶段撞上一堆摸不着头脑的报错。3.2 CMake 参数要义这些是你真正需要知道的关键开关llvm-project 采用 CMake 构建参数浩如烟海但真正决定命运的没有几个。我通常这样配置cmake -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_ENABLE_ASSERTIONSOn \ -DBUILD_SHARED_LIBSOff \ ../llvmCMAKE_BUILD_TYPERelease生成优化后的二进制速度快。LLVM_ENABLE_PROJECTS决定你要构建哪些子项目按需加载省时间。LLVM_TARGETS_TO_BUILD指定目标架构。不需要把全部架构都编出来只留自己用得到的构建速度提升明显。LLVM_ENABLE_ASSERTIONSOn在开发模式下保持断言开启能更容易定位到 IR 或 Pass 的问题。生产跑性能测试时可以关掉。BUILD_SHARED_LIBSOff默认静态库便于部署如果频繁改动库自己做实验开 On 能大大加速增量编译但产物会散落很多.so文件。实际构建时llvm-project是个巨无霸全量构建核心加 clang 很容易吃掉 60GB 磁盘和几十分钟时间。我自己的建议是第一次构建不要追求“全家桶”先只选clang把核心跑通。后面缺什么再补什么重新 cmake 并构建对应 target。3.3 增量构建的清醒认识Ninja 与 ccache 的组合拳用 Ninja 替代 Makefile 基本是共识-G Ninja的并行度和依赖追踪都优秀得多。更进一步的提速神器是ccache。第一次全量构建时ccache 没什么用但从第二次开始改了 clang 的一个文件而要重新验证整个项目时命中缓存的那部分就能直接跳过。配置方式很简单export CCACHE_MAXSIZE50G cmake -G Ninja \ -DCMAKE_C_COMPILER_LAUNCHERccache \ -DCMAKE_CXX_COMPILER_LAUNCHERccache \ ../llvm这里有个值得注意的事项编译器启动器对默认 GCC 和 Clang 都有效但如果你的宿主编译器本身就是 clang建议直接指定-DCMAKE_C_COMPILERclang -DCMAKE_CXX_COMPILERclang能再快一截。一定要把磁盘空间预算进去完整构建一次 llvm-project 需要的空间远超直觉至少留出 100GB 再开始。构建命令也建议分步执行ninja clang先构建编译器ninja lld再构建链接器ninja check-clang跑测试。不要一开始就ninja全量执行我第一回就是全量执行中间一个组件编译失败排查半天才发现是一个老版本的 Python 模块不兼容 lldb 的脚本文档生成。4. 实操过程亲手写一个真正的 LLVM Pass 并跑通4.1 场景设定与工程结构做一个函数级计数优化实验为了把手真正弄脏我这里选一个最小但完整的场景编写一个模块级 Pass遍历所有函数统计每个函数的基本块数和指令数并将结果打印到标准输出。这个小实验覆盖了 Pass 编写、注册、编译、加载执行的全流程而且完全没有领域前置知识负担。我的工程结构如下MyPass/ ├── CMakeLists.txt ├── MyCountPass.cpp4.2 源代码实现基于 New Pass Manager 的现代写法#include llvm/IR/Function.h #include llvm/IR/Instructions.h #include llvm/IR/Module.h #include llvm/Pass.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { class MyCountPass : public PassInfoMixinMyCountPass { public: PreservedAnalyses run(Module M, ModuleAnalysisManager MAM) { for (auto F : M) { if (F.isDeclaration()) continue; // 跳过声明只统计有函数体的 unsigned bbCount 0; unsigned instrCount 0; for (auto BB : F) { bbCount; instrCount BB.size(); } errs() [MyCountPass] Function: F.getName() , BasicBlocks: bbCount , Instructions: instrCount \n; } return PreservedAnalyses::all(); // 我们没改任何东西全保留 } }; } // namespace // 注册插件入口 extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, MyCountPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, ModulePassManager MPM, ArrayRefPassBuilder::PipelineElement) - bool { if (Name my-count-pass) { MPM.addPass(MyCountPass()); return true; } return false; }); }}; }这里几个关键点值得解释。PassInfoMixinMyCountPass是新 Pass 管理器的基类模板不需要自己去管理getPassName()之类的繁琐接口只需要实现一个run方法。PreservedAnalyses告诉框架你的 Pass 修改了哪些分析如果完全没有改动任何 IR 数据就返回all()让下游分析得以保留进而提升整体编译速度。注册回调里registerPipelineParsingCallback允许我们定义 Pass 在命令行管线中的名字这里的my-count-pass就是后面 opt 命令里传入的名字。插件入口采用 C 接口保证动态链接可识别。4.3 编译链接细节CMake 的写法与坑cmake_minimum_required(VERSION 3.20) project(MyCountPass CXX) find_package(LLVM REQUIRED CONFIG) message(STATUS Found LLVM ${LLVM_PACKAGE_VERSION}) message(STATUS Using LLVMConfig.cmake in: ${LLVM_DIR}) include_directories(${LLVM_INCLUDE_DIRS}) add_definitions(${LLVM_DEFINITIONS}) add_library(MyCountPass MODULE MyCountPass.cpp ) target_link_libraries(MyCountPass PRIVATE LLVMCore LLVMSupport LLVMPasses) set_target_properties(MyCountPass PROPERTIES PREFIX )第一处要注意find_package(LLVM REQUIRED CONFIG)要求你的 LLVM 构建产物里有LLVMConfig.cmake。如果直接在构建目录里用那没问题但如果是自己构建完准备安装到某个 prefix记得在 CMake 命令中加-DCMAKE_INSTALL_PREFIX/your/path并在后续使用该 Pass 的项目里指定-DLLVM_DIR/your/path/lib/cmake/llvm。第二处是链接库的选择。我 min 到只链接LLVMCore、LLVMSupport、LLVMPasses实际运行时还可能需要其他库但插件从opt进程加载时大部分核心符号已经存在所以这里一个最简单的集合就够。第三处最隐蔽模块型插件在 Linux 上需要无前缀的.so所以要设 PREFIX
返回列表