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

资讯详情

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

LLVM实战指南:从仓库构建到自定义Pass开发

LLVM实战指南:从仓库构建到自定义Pass开发 很多人第一次听说 LLVM第一反应是哦一个编译器。等真正打开 llvm-project 这个仓库看到那一长串子项目目录才发现事情远没有那么简单——Clang 只是冰山一角里面有优化器、链接器、标准库、调试器组件甚至还有一套叫 MLIR 的编译器基础框架。这篇文章不打算做概念复述我会从实际使用的角度把这个仓库的结构、构建、扩展方式以及容易踩的坑一次性讲清楚。不管你是想深入编译器底层还是想用 LLVM 给某个 DSL 做前端这篇文章都值得你在动工之前先过一遍。1. LLVM 不是一个编译器而是一整套编译基础设施1.1 为什么 LLVM 能碾压传统编译器的设计在 LLVM 出现之前多数编译器的架构是前端直接怼到后端C 语言前端产生中间表示然后立即翻译成 x86 汇编中间几乎没有可复用的边界。GCC 虽然也有中间表示 GIMPLE但它的设计目标主要还是为一门语言服务。这意味着你想给一种新语言写编译器基本要从零搭烟囱语言无关的优化、目标代码生成全部重来。LLVM 的核心突破是把编译流程切成三段并让中间那一段变成一个可编程的中转站前端Clang 负责把 C/C/Objective-C/OpenCL 等语言解析成 AST再降级成 LLVM IR中端优化器opt对 LLVM IR 做跨语言、跨平台优化所有优化 Pass 都作用在这个统一的 IR 上后端LLVM 后端把优化后的 IR 转成目标机器码支持 x86、ARM、RISC-V、WebAssembly 等多种架构这个三段式的价值在于你想支持一门新语言只要写前端产出 LLVM IR 即可后面所有优化器和目标平台支持全部白嫖你想支持一个新芯片架构只要写后端接收 LLVM IR 即可前面所有语言前端和优化器又成了现成的。这正是 LLVM 生态越来越壮大的底层原因。1.2 仓库里到底装了什么核心子项目逐个拆当用户执行git clone https://github.com/llvm/llvm-project.git之后会看到一个巨大的 monorepo。很多人第一次看到这个仓库结构会心里发怵其实每个子目录都对应一个可独立使用的组件子项目目录功能定位典型使用场景clangC/C/Objective-C 语言前端编写 C/C生成可执行文件lld官方的链接器比系统自带 ld 更快的链接速度libc/libcabiC 标准库实现及其 ABI 层想换掉 libstdc 时使用compiler-rt运行时支持库sanitizerASan/UBSan、profile 工具llvm核心优化器和后端跑自定义 Pass、生成目标代码mlir多层级中间表示框架做机器学习编译器、硬件加速 DSLclang-tools-extra额外工具clang-tidy、clangd、clang-formatlldb调试器替换/补充 gdb 的场景openmpOpenMP 运行时实现并行计算程序需要注意llvm-project是 2019 年之后 LLVM 官方从多个独立仓库合并而来的 monorepo。在合并之前你需要分别克隆 llvm、clang、lld 再对目录做符号链接配置过程既琐碎又容易出问题。monorepo 化之后版本对齐问题基本消失所有组件共享一个版本号这对从源码构建的用户来说是巨大的体验改善。1.3 你是哪种使用者决定你要不要全量构建很多人听别人说 LLVM 很大、很难编于是望而却步。实际情况取决于你的使用方式如果你是C/C 开发者不一定要自己编。直接下载发行版二进制或者通过包管理器安装即可如果你是编译器学习者或研究者不需要构建全部工具链构建llvm和clang两个目标就够如果你是新语言实现者需要完整的 clang 吗不需要只要 LLVM 核心库 头文件我在早期就犯过一个错误不管三七二十一全量构建结果在磁盘只有 50GB 的虚拟机上编到一半磁盘爆满构建日志都打不开。那个过程极其痛苦。所以拿到仓库后先想清楚自己的目标后面所有 CMake 配置都围绕这个目标来设定。提示在 clone 时建议使用--depth1只拉取最新版本llvm-project 完整历史非常庞大全量 clone 非常耗时。2. 从源码构建 llvm-project完整步骤与 CMake 参数背后的逻辑2.1 首先选对版本和构建工具链LLVM 官方每 6 个月发布一个新版本如 18.x、19.x主版本号是偶数时代表功能冻结和稳定发布奇数版本通常是开发版本。除非你要追踪最新特性否则首选偶数版本。另外要注意某个版本的 LLVM 对构建它的编译器版本有最低要求。例如 LLVM 18 要求 GCC 7.4 或 Clang 7如果你的系统 GCC 太老会直接编译失败报的错还很隐晦。我的建议是使用尽量新的稳定版例如 19.x 或 20.x因为旧版本在某些平台上有已知的构建问题而且新版对 C 标准支持更完整构建速度也更快。在构建 LLVM 之前确认以下依赖已经装好CMake版本要高于 LLVM 要求的最低版本建议 3.20Ninja推荐比 Makefile 并行度更好增量编译更快一个可用的 C/C 编译器GCC 或 ClangPython 3构建脚本依赖zlib、libxml2部分源码工具链需要在 Ubuntu 上最快的安装方式sudo apt update sudo apt install cmake ninja-build build-essential python3 zlib1g-dev libxml2-dev2.2 一份可复制的 CMake 配置模板与参数解读进入仓库根目录后官方推荐的out-of-source构建方式是新建一个build目录在目录里跑 CMake配置完成后再编译。cd llvm-project mkdir build cd build cmake ../llvm \ -DCMAKE_BUILD_TYPERelease \ -DCMAKE_INSTALL_PREFIX/opt/llvm \ -DLLVM_ENABLE_PROJECTSclang;lld;compiler-rt \ -DLLVM_TARGETS_TO_BUILDX86;AArch64;RISCV \ -DLLVM_ENABLE_ASSERTIONSON \ -G Ninja逐个解释这些参数它们都是实践中的关键决策点CMAKE_BUILD_TYPERelease这是最重要的参数。Release 模式下开启优化编译出的编译器速度快很多。如果用 Debug 模式LLVM 自身会有大量数据结构和调试信息性能和内存占用都会让人崩溃。编译器领域的人常说 Debug 构建的编译器像蜗牛这句话不是玩笑跑大型 C 工程时会明显感觉到差距LLVM_ENABLE_PROJECTS这个参数决定要构建哪些子项目。注意是从llvm目录配置而不是从llvm-project根目录配置。如果你还要 MLIR需要追加mlir到分号列表中LLVM_TARGETS_TO_BUILD默认会编几十个目标平台后端但实际上大多数人用不到。只保留你需要的架构能显著减少编译时间。默认的 X86;AArch64 已覆盖大多数开发场景LLVM_ENABLE_ASSERTIONSON开启 LLVM 内部的断言检查。这个选项在开发和调试 Pass 时非常有用能在早期暴露很多错误。如果纯粹是要安装一个生产环境编译器可以关掉以提升运行时性能配置完成后执行ninja -j$(nproc)如果你只想构建 clang 和 lld 而不编其它目标可以用ninja clang lld来精准控制。这样第一次构建可能只需要十几分钟到半小时视机器性能而定而不是全量构建的数小时。2.3 构建时间与磁盘空间的现实预期以及减负技巧这里分享一个我实测的参考数据在 8 核 16GB 内存的笔记本上只构建 clang、lld目标架构只选 X86Release 模式Ninja 并行构建约 15-25 分钟。把目标架构扩展到 ARM 和 X86 后时间会增加到约 40 分钟。而如果默认全量构建所有目标和所有工具加 Testing 模块中高配机器也至少需要数小时磁盘占用可能超过 60GB。如果你想进一步降低时间成本可以加两个参数-DLLVM_BUILD_TOOLSOFF如果你不需要llvm-objdump、llvm-nm等工具可以关掉省掉大量小工具编译时间-DLLVM_INCLUDE_TESTSOFF跳过测试套件包括 LLVM 单元测试同样显著减少构建时间另外ccache 是 LLVM 开发者的强力武器。第一次构建后之后每次改动重编的速度会有数量级提升sudo apt install ccache export CCACHE_DIR/path/to/.ccache构建完成的安装比较简单执行ninja install即可。安装时注意CMAKE_INSTALL_PREFIX指定的目录要是可写的建议用/opt/llvm这种独立前缀这样不会污染系统目录。3. 写一个自定义 Passllvm-project 最容易上手的扩展点3.1 Pass 到底是什么LLVM 的优化器本质是一组经过巧妙编排的Pass每个 Pass 对 IR 执行一次遍历或变换。例如InstCombine把常数折叠掉DCE删除不会被执行的死代码LoopUnroll展开循环减少跳转开销。你也可以写自己的 Pass 来满足特定需求——比如给某段数学运算做自动向量化或者把自定义指令模式的识别插到后端之前。理解 Pass 最简单的方式是IR 是一棵带类型的指令树Pass 就是拿着剪刀对这棵树做修剪和嫁接的工人。工人只关心树的形态并不关心树是从什么语言长出来的。这就是优化一次到处使用的根本原因。很多初学者有一个严重误解以为写 Pass 就必须重新编译整个 LLVM。实际上LLVM 提供了动态加载 Pass 的机制你可以利用opt工具在运行时加载你编译出的.so文件完全不需要重新构建 LLVM。这对学习和调试来说实在太方便了。3.2 在 llvm-project 仓库内创建你的第一个 Function Pass这里假设你已经成功构建了 LLVM 和 Clang接下来我完整走一遍创建一个最简 Pass 的流程。LLVM 19 之后已经全面切换到新 Pass Manager 架构建议新写的 Pass 使用llvm::PassInfoMixin而不是老式的FunctionPass。在llvm/lib/Transforms/下创建目录和文件// llvm/lib/Transforms/HelloWorld/HelloWorld.cpp #include llvm/IR/Function.h #include llvm/IR/IRBuilder.h #include llvm/IR/Instructions.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 { struct HelloWorld : public PassInfoMixinHelloWorld { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { errs() Hello from: F.getName() \n; for (auto BB : F) { for (auto I : BB) { if (auto *Store dyn_castStoreInst(I)) { errs() Found a store instruction\n; } } } return PreservedAnalyses::all(); } }; } // namespace // 这里是 Pass 插件的注册入口 extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, HelloWorld, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name hello-world) { FPM.addPass(HelloWorld()); return true; } return false; }); }}; }把这个 Pass 写成独立的.so不修改 LLVM 自身的构建系统是最为灵活的扩展方式。下面是一个 CMakeLists.txt放在与源文件同级的目录中cmake_minimum_required(VERSION 3.20) project(HelloWorldPass) find_package(LLVM REQUIRED CONFIG) find_package(Clang REQUIRED CONFIG) message(STATUS Found LLVM ${LLVM_PACKAGE_VERSION}) message(STATUS Using LLVM include dir: ${LLVM_INCLUDE_DIRS}) add_library(HelloWorldPass MODULE HelloWorld.cpp) target_include_directories(HelloWorldPass PRIVATE ${LLVM_INCLUDE_DIRS}) target_compile_definitions(HelloWorldPass PRIVATE ${LLVM_DEFINITIONS}) target_link_libraries(HelloWorldPass PRIVATE LLVMCore LLVMSupport) set_target_properties(HelloWorldPass PROPERTIES PREFIX )编译cmake -S . -B build -DLLVM_DIR/opt/llvm/lib/cmake/llvm cmake --build buildLLVM_DIR要指向你构建或安装 LLVM 的路径。该环境变量是为了让 CMake 能找到 LLVMConfig.cmake如果你构建时设置了CMAKE_INSTALL_PREFIX/opt/llvm则路径应该是/opt/llvm/lib/cmake/llvm。3.3 用 opt 加载 Pass 的验证流程与完整示例先将一段 C 代码编译成 IR 文件再使用opt动态加载插件。# 生成测试文件 cat test.c EOF int g 0; void foo() { int a 1; g a 2; } EOF clang -S -emit-llvm test.c -o test.ll # 加载 Pass 并运行 opt -load-pass-plugin./build/libHelloWorldPass.so \ -passeshello-world \ -disable-output test.ll正常输出如下Hello from: foo Found a store instruction要注意一点在运行opt时如果在-passes列表中写了一个未注册的 Pass 名称opt会直接报错。我在第一次写 Pass 时就碰到unknown pass name hello-world原因就是llvmGetPassPluginInfo中没有正确注册解析回调。确认注册函数干上活之后这个报错会立刻消失。3.4 新 Pass Manager 迁移期踩过的几个坑LLVM 14 之后新 Pass Manager 默认启用但网上大量老文章仍在使用旧 API这对初学者是严重的干扰源。最大的坑是注册方式。旧版用llvm::RegisterPass...和registerFunctionAnalysis静态注册新版则要求在llvmGetPassPluginInfo中挂接回调和管道解析。我有一段时间沿用网上旧代码写 Pass编译能过但opt始终提示Pass is not registered折腾了很久才注意到LLVM_PLUGIN_API_VERSION这类新接口。第二个坑是PreservedAnalyses的使用。很多新手写 Pass 时直接返回PreservedAnalyses::none()虽然结果看起来没问题但它告诉优化器所有分析结果都失效了这会引发级联重算显著拖慢后续优化。正确做法是如果你的 Pass 没有改动 IR就返回PreservedAnalyses::all()如果只改动了一部分用getInvalidated()精确指定哪些分析需要失效。第三个坑与分析管理相关。新版 Pass 通过FunctionAnalysisManager AM获取分析结果比如auto DT AM.getResultDominatorTreeAnalysis(F);如果对同一函数在遍历中多次获取分析结果要避免在有写操作之后继续使用旧引用否则可能导致use-after-invalidate。4. 从 LLVM IR 到 MLIR理解 llvm-project 里的层次递进4.1 LLVM IR 的局限性与 MLIR 的诞生动机LLVM IR 很适合做经典优化和代码生成但你让它直接表示深度学习计算图、GPU Kernel 专用方言这种东西就显得力不从心。因为 LLVM IR 是面向指令级的低级语言循环嵌套结构、张量形状、内存布局这些信息在 IR 中早已散佚或者需要大量侧面信息才能恢复。如果直接在 LLVM IR 上做张量融合优化几乎等于把打碎的玻璃拼回原状。MLIRMulti-Level Intermediate Representation在 llvm-project 里就像一套乐高积木的多级图纸系统你可以在高层用 Tensor 语义表达计算图逐步 lower 到 Linalg、Affine再 lower 到 LLVM IR。每一层只损失一点点抽象每一步变换都清晰可控。我用鸡蛋做类比传统编译器前端面对的是鸡蛋炒饭——语言特性已经混合在一起MLIR 则是一颗颗鸡蛋——你可以按照自己的节奏打散、分离、重组。在为特定硬件快速生成代码时这种精细度很重要。4.2 在 llvm-project 中构建 MLIR 与一个 Astra 示例要先确认你在 CMake 配置阶段将mlir加入了LLVM_ENABLE_PROJECTS。例如cmake ../llvm \ -DLLVM_ENABLE_PROJECTSclang;mlir;lld \ -DLLVM_TARGETS_TO_BUILDX86 \ ...构建完成后build/bin里会出现mlir-opt、mlir-translate等工具。先看一段最简单的 MLIR// test.mlir func.func main() - i32 { %c arith.constant 42 : i32 return %c : i32 }运行mlir-opt test.mlir这会做合法性检查并输出规范化后的 IR。进一步你可以把它 lower 到 LLVM IRmlir-translate --mlir-to-llvmir test.mlir对编译器新手来说理解mlir-opt的一个绝佳练习是加--convert-arith-to-llvm、--convert-func-to-llvm等 pass观察 IR 一步一步从高层语言变成接近底层的形态。这个过程比阅读任何文档都更能理解多层 IR的意义。4.3 什么时候你才真正需要 MLIR有不少朋友跑过来问我要用 MLIR 吗 我的回答通常看场景你已经能用 LLVM IR 解决 90% 问题不需要主动引入 MLIRLLVM IR 更简单直接你在做领域专用编译器、异构计算/DSA必须用 MLIR否则从 DSL 到 LLVM IR 的降低过程会非常痛苦你想做机器学习编译MLIR 生态里的stablehlo、tosa、linalg等方言已是事实标准MLIR 的原始概念复杂但 llvm-project 里自带的示例非常宝贵mlir/examples/toy/目录包含一个完整的 Kaleidoscope 式语言编译教程——从词法分析、语法分析、AST 构建到方言定义、优化 Pass、lower 到 LLVM IR 的全流程。这大概是我见过最好的编译器入门教学代码没有之一。5. 面向实际开发lld、libc、clang-tidy 为工程带来的收益5.1 lld 链接速度提升的实测感受与链接器选择建议很多 C 项目只是把clang当作 gcc 的替换却忽略了一个关键组件lld。在大型 C 项目中系统默认链接器GNU ld可能要花 30-60 秒链接而lld通常能把这个时间压缩到 5-8 秒。不需要改代码只需要在 CMake 中指定-DCMAKE_EXE_LINKER_FLAGS-fuse-ldlld如果你已经构建了 lld安装时会生成ld.lld或lld可执行文件。CMake 在-fuse-ldlld时会在 PATH 中查找ld.lld。Clang 也提供了-fuse-ldlld选项直接作用于编译命令。我个人的实测感受是把大型 C 工程的链接器换为 lld 后每轮增量构建能节省几十秒一天下来累积节省的时间相当可观。在 CI 环境中收益更明显因为链接通常是最耗时的一步。5.2 libc / libcabi 的切换与适时要避开的坑libc是 LLVM 官方的 C 标准库实现相比 GCC 自带的标准库libstdc有更干净的分层设计和更强的 C 新标准支持。切换到你构建的 libc 很简单clang -stdliblibc -nostdinc -I/opt/llvm/include/c/v1 \ -L/opt/llvm/lib -Wl,-rpath,/opt/llvm/lib test.cpp -o test但这里有几个隐藏的坑要先避开libc需要libcabi提供 ABI 支撑两者必须版本匹配务必用-Wl,-rpath指定运行时库路径否则可执行文件在另一台机器上找不到libc的动态库不要混用libstdc和libc编译出的链接单元否则 ODR 冲突和 ABI 不一致问题会极其隐蔽不过目前在生产 C 项目中如果你没有强需求直接使用系统默认的libstdc依然是最稳妥的选择。libc 更多的是面向嵌入式/特殊平台或者在新特性追赶研究中使用。5.3 clang-tidy 与 clangd 的日常配合llvm-project 里clang-tools-extra提供的clang-tidy是静态检查利器配置正确的 clang-tidy 可以禁止一堆容易踩的坑未初始化变量、异常安全问题、移动语义遗漏、一致性问题等。VS Code 中的 clangd 插件本质就是通过 Clang 的语言服务clangd做语义补全和跳转。它在 C 开发体验上比很多插件好得多。在实际项目中我建议把 clang-tidy 配置文件和.clang-format提交到仓库并接入 CI在代码合入前进行配置检查。借助 llvm-project 的现有工具链不出意外你就能在一个稳定规范的基础上做自己的扩展。6. 调试 LLVM 与常见问题排查开发者必须具备的技能6.1 日志系统与选项开关从黑盒到半透明LLVM 提供了非常细的调试接口。最常见的调试方法是-debug-only。比如opt -passesloop-unroll -debug-onlyloop-unroll test.ll这会输出 loop-unroll Pass 的详细 debug 日志前提是在编译 LLVM 时开启了LLVM_ENABLE_ASSERTIONS。如果没有开这个宏这些调试信息会被编译掉-debug-only参数会静静忽略。如果你的代码中要打日志可以使用LLVM_DEBUG宏LLVM_DEBUG(dbgs() Entering my pass\n);然后再用-debug-onlymy-pass控制开关。这比直接在源码里写errs()要优雅得多因为发布时的性能开销可以被完全消除。6.2 断点调试与日常 Debug 构建技巧在开发 Pass 时opt的执行流程非常适合用调试器逐步观察。比较高效的做法是用 list 模式减小编译产物但保留-g调试信息和断言当你在写 Pass 时我建议不直接用 Release 构建的opt来调试否则优化后的代码和源码行号对不上单步调试会非常痛苦。我个人的建议有两种方案配置一个 Debug 模式的 LLVM 构建专门用来调试 Pass。缺点是构建慢、运行时内存大给 Release 构建加-DLLVM_ENABLE_ASSERTIONSON和-g结合日志输出排查逻辑问题。大多数逻辑问题可以靠日志解决只有极少数很难定位的问题才用断点我在实际开发中的经验是先日志、后断点先用LLVM_DEBUG定出问题的 Pass 和基本位置再用调试器单步确认数据流。直接开调试器逐步走通常会在庞大的指令树里迷路。6.3 两类最常见的构建问题内存不足与头文件缺失构建 LLVM 对内存的需求比很多人预想的高。在 4GB 内存的机器上编译整个项目Ninja 会因内存不足 OOM。解决思路是降低并行度ninja -j2另一个常见问题是缺失 zlib 或者头文件。LLVM 编译器会在链接时用到 zlib缺少时就报找不到符号。安装zlib1g-dev后重新跑 cmake 配置即可。有时候还会碰到Clang 找不到头文件——明明构建成功了clang -v也能跑但编译一个最简单的 hello world 却提示找不到stdio.h。这不是 llvm-project 本身的问题而是你的 Clang 没有正确配置内置头文件搜索路径。如果使用-nostdinc或系统头文件路径被改动就可能出现。我这里给一个最简排查方案echo #include stdio.h int main(){return 0;} | clang -x c -E -v 21 | grep search starts here查看搜索路径是否包含系统的/usr/include和/usr/local/include。如果没有检查 /opt/llvm 的前缀和编译器的CMAKE_SYSROOT设置。7. 我在 llvm-project 上积累的实战经验与学习路线参考7.1 从会用到能改的进阶路径很多人以为学会 LLVM 意味着通读源码不现实。llvm-project 是一个持续演进的大型代码库全量阅读既不可能也没必要。更现实的路径是先用起来能用 Clang 写代码能看懂clang -S -emit-llvm生成的 IR理解优化用opt跑各种 Pass观察同一个 IR 在不同 Pass 前后的变化自己写 Pass从一个最简插件开始逐步加深探索数据流和依赖分析选一个方向深入编解码、Sanitizer、MLIR 方言、GPU 后端等都是独立的子领域回馈生态给 LLVM 提交 bug report 和 patch这是学习效率最高的方式每一步都争取产出一个成果一张 IR 对比图、一个可运行的 Pass 插件、一篇笔记都可以。走完前两步之后你再看 LLVM 的设计文档会有完全不同的体感。7.2 少走弯路的实用建议几个印象很深的教训最后分享几个自身踩过的坑希望能帮你节省时间不要直接用系统包管理器安装的 LLVM 来写 Pass 插件。系统自带的版本通常比较老和 llvm-project 最新的头文件布局差异极大往往导致 CMake 能找到头文件但编译不过优先使用LLVM_ENABLE_PROJECTSclang;mlir;lld控制构建范围而不要把llvm当作一个整体项目全量编。每次只编你需要的目标认真阅读每个新版本发布时的LLVM Release Notes其中专门列出Breaking changes。最典型的例子就是旧 Pass Manager 的移除不了解版本变化很容易浪费时间给变量和目录命名时不要用build这个关键词作为前缀的测试源码目录名。有一些工具会混淆目录结构虽然听起来很离谱但我确实因为目录名导致 CMake 缓存出过问题遇到难懂的 IR 数据流善用-print-after-all参数它会输出每个 Pass 执行后的 IR 快照我能快速定位到底哪个 Pass 把我的指令改掉了LLVM 生态庞大刚开始接触时的挫败感几乎无法避免但它的设计思想相当清晰每跨过一个坎你对现代编译器的理解就会上一个台阶。只要你能在正确版本的搭配下把一个 Pass 跑通、把一次 lower 走完后续的方向就完全是你自己掌控的了。
返回列表