ARTICLE DETAIL

资讯详情

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

LLVM入门实战:从llvm-project构建到自定义Pass与llvmpipe解析

LLVM入门实战:从llvm-project构建到自定义Pass与llvmpipe解析 我印象里第一次把llvm-project整个仓库克隆下来的时候是有点懵的。这个名字看起来像个项目实际上里面装了一整套编译器生态Clang、LLD、libc、compiler-rt、MLIR、Flang、OpenMP 运行时……全都在一个仓库里。你看热搜词里那个llvmpipe其实也是这个生态的下游产物——Mesa 3D 软件渲染器基于 LLVM 的 JIT 能力做动态代码生成很多没独显的机器上glxinfo里渲染器字符串就是 llvmpipe。这篇文章我打算从实际使用的角度把 llvm-project 摸一遍它是怎么组织代码的、怎么把它构建出来、怎么在源码里加一个自己的 Pass、以及llvmpipe这类下游项目到底从 LLVM 里拿了什么。如果你是第一次接触这套东西希望这篇能帮你少走点弯路。1. LLVM 架构不是分层而是前端-中端-后端三段式流水线很多初学者会把 LLVM 理解成C/C 编译器一打开 llvm-project 发现里面还有 Rust 前端、Swift 前端、甚至 Fortran 前端立刻就会困惑。其实你只要看清它的核心设计这个问题就自然解决了。LLVM 从来不是一个编译器它是一堆可复用编译器组件。1.1 三段式结构解决了什么问题传统编译器比如 GCC的架构本质上是一个巨大的单体程序从词法分析、语法分析、语义分析到中间代码生成、优化、目标代码生成全在一个前端里完成。如果你想支持一门新语言对不起你得把整个流水线重写一遍如果你想支持一个新 CPU 架构你又得把整个优化器和后端牵扯进来。改动一个环节牵一发动全身。LLVM 把这条流水线硬生生切成了三段前端负责把源代码变成一种统一的中间表示也就是 LLVM IRIntermediate Representation。中端拿到 LLVM IR 之后所有的优化工作循环优化、内联、常量传播、死代码消除等都在这个级别上做。后端把优化后的 LLVM IR 降级到具体目标的机器码X86、ARM、RISC-V、NVPTX各管各的。这个结构最大的意义是统一两个字。你给 C 写的优化器Rust 也能用你给 Rust 写的后端 CodeGenSwift 也能用。这就是为什么 llvm-project 仓库里能同时容纳这么多前端项目大家共享的是同一个中端和后端。1.2 用一段 IR 直观感受一下中间表示光说概念太抽象我直接给你看一段实际的 LLVM IR。比如这段 C 代码int add_one(int x) { return x 1; }经过 Clang 编译并输出 IR 后长这样define i32 add_one(i32 %x) #0 { entry: %add add nsw i32 %x, 1 ret i32 %add }你注意几个细节所有的寄存器都叫%开头的东西i32表示 32 位整数nsw表示 no signed wrap带符号回绕未定义便于优化器做更多变换最后一条ret返回结果。这门语言既不是 C也不是汇编它是 LLVM 专门设计的一种静态单赋值SSA形式。我当初第一次接触 IR 时最直观的感受是它比汇编好读太多了。寄存器的使用是有规则的数据流是一目了然的你根本不用关心eax什么时候被调用者保存、什么时候被调用者保存。而且所有前端生成的 IR 都是这个风格所以你写工具链的时候只需要处理一种中间表示。这就是结构红利。1.3 llvm-project 里几个子项目的依赖关系打开仓库目录你第一眼会看到数十个文件夹。对初学者来说先分辨这五个核心的就够了目录作用下游依赖它的项目llvm/LLVM 核心包括 IR、优化器、CodeGen几乎所有其他子项目clang/C/C/Objective-C 前端很多静态分析工具lld/链接器替代系统默认 ldClang、Rust 等libcxx/C 标准库实现所有 C 项目compiler-rt/运行时库包括 sanitizerASan/UBSan/Tsan 用户它们之间的依赖关系基本是单向的clang依赖llvmlld依赖llvmlibcxx理论上不依赖 LLVM 但通常和libcxxabi配合使用。mlir和flang也在仓库里但前者是独立的编译器基础设施后者是 Fortran 前端它们的代码和clang不共享前端逻辑只在 IR 和后端层面共享。我第一次构建时犯的错是把所有子项目全都构建了一遍结果磁盘空间直接吃掉 80GB构建时间直奔两小时。后来才学乖了你只是研究 IR 和优化器dir llvm加dir clang就够了你要做链接器实验再加dir lld。全量构建默认开启所有 target 和工具对新手来说是灾难。2. 从零构建为什么我建议你用 Ninja ccache 而不是默认 Makefile搭建 LLVM 开发环境是劝退不少人的第一关。你能搜到一堆系统没有 CMake或者编译到一半磁盘满了的血泪帖。其实 LLVM 官方给了非常成熟的构建路径只是它的默认配置并不是为快速验证设计的。我把自己在干净 Ubuntu 上跑通的一套流程写下来照抄基本不会出问题。2.1 构建前的依赖准备LLVM 源码本身对系统依赖很少但构建工具链必须要新。我踩过的最经典的坑系统自带的 GCC 版本太老CMake 配置时检查std::filesystem失败整个配置阶段直接报错退出。建议先把这些装齐sudo apt update sudo apt install -y build-essential cmake ninja-build python3 \ git ccache zlib1g-dev libedit-dev libxml2-dev这里说明一下为什么要用 NinjaLLVM 是一个极度庞大的构建图同一时刻会有成百上千个编译任务并行。Ninja 在并行调度上比 Make 快得多尤其是增量构建体验差距非常明显。ccache 则是因为你在开发 LLVM 时会反复修改头文件一个头文件变动可能导致十几个源文件重新编译缓存能显著减少这种重复劳动。我在自己的机器上装了 ccache 之后第二次构建的时间几乎缩到了一半。2.2 推荐的一组 CMake 配置项这是关键一步。进入llvm-project根目录后我建议单独建一个build目录不要把构建产物混进源码cd llvm-project cmake -G Ninja -S llvm -B build \ -DCMAKE_BUILD_TYPERelease \ -DCMAKE_C_COMPILERclang \ -DCMAKE_CXX_COMPILERclang \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDhost \ -DLLVM_USE_LINKERlld \ -DLLVM_CCACHE_BUILDON \ -DLLVM_ENABLE_ASSERTIONSON逐一解释一下我为什么这样配-DLLVM_ENABLE_PROJECTSclang;lld只构建clang和lld这两个前端/工具项目不碰 libcxx、mlir 那些构建时间能省一大截。-DLLVM_TARGETS_TO_BUILDhost只生成当前机器的后端目标。如果你在做的是 X86就不要构建 ARM、AArch64、PowerPC 这些后端。每个 target 都会拖慢构建减少目标是加速最明显的策略。-DLLVM_USE_LINKERlld用 lld 来链接 LLVM 自身的二进制。LLVM 的二进制非常大用系统默认的 GNU ld 链接时又慢又容易 OOMlld 就好很多。-DLLVM_ENABLE_ASSERTIONSON开发 LLVM 时强烈建议开启断言。LLVM 核心代码里大量依赖assert来检查 IR 合法性没有断言的情况下非法 IR 可能导致静默错误或者段错误有了断言问题会在早期暴露。配置完成后直接ninja -C build开始构建。以我的 8 核机器为例Release 模式构建clang加lld大概需要 20 到 30 分钟。第一次构建时你会看到 CPU 占用拉满风扇狂转这很正常。2.3 用 ccache 和 lld 让二次构建飞起来有一次我改了一个llvm/include/llvm/IR/IRBuilder.h的头文件重新构建时发现重新编译了将近 700 个源文件。如果没有 ccache光这次增量构建就得等十几分钟有了 ccache命中率还不错整个构建两分多钟就结束了。后来我习惯性地把ccache -s的输出当成心理安慰检查看到命中率升到 90% 以上就知道这次改动不会太痛苦。另外还有一个细节值得说如果你用的发行版没有预装clang第一遍构建时可以用 GCC 作为引导编译器。CMake 检测到CMAKE_C_COMPILERclang找不到会直接报错所以第一遍老老实实用 GCC 构建后面再用构建出的 clang 重新构建一次自己LLVM 社区管这个叫bootstrap。不过对大多数开发场景GCC 引导就够了不一定非要自举。2.4 构建完怎么验证装好了构建产物默认在build/bin/下。验证方法很简单build/bin/clang --version build/bin/llc --version能看到版本号输出基本就 OK。llc是 LLVM 后端的命令行前端它可以把.ll文件IR 文本格式编译成目标汇编我们后面写 Pass 和做 IR 实验都会频繁用到它。3. 入门实操写一个自己的 LLVM Function Pass当你把 llvm-project 构建出来之后最值得做的第一件事就是写一个自定义 Pass。这既是熟悉 LLVM API 的最佳方式也是后续做插桩、做代码分析、做自定义优化的基础。这一节我用 new Pass Manager 的写法完整走一遍。3.1 Pass 到底是什么Pass这个术语在 LLVM 语境里指的是一遍优化/分析过程。编译器读入 IR然后跑一系列 Pass先做内存到寄存器的提升mem2reg再做内联inline再做死代码消除dce每个 Pass 都是对 IR 的一次变换或者一次信息收集。你可以自己写一个 Pass插在这条流水线的任意位置。LLVM 目前主推的是 new Pass Manager对应头文件是llvm/IR/PassManager.h。老旧的 legacy Pass Manager 已经不建议新代码使用。下面的例子就是一个标准的函数级 Pass它的作用非常幼稚但很直观统计每个函数的基本块数量和指令数然后打印出来。3.2 代码骨架头文件与实现以一个MyPass.cpp为例#include llvm/IR/Function.h #include llvm/IR/IRBuilder.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 MyFunctionInfoPass : public PassInfoMixinMyFunctionInfoPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { unsigned bbCount 0; unsigned instCount 0; for (auto BB : F) { bbCount; instCount BB.size(); } errs() [MyPass] Function: F.getName() , BasicBlocks: bbCount , Instructions: instCount \n; return PreservedAnalyses::all(); } }; } // namespace // 注册 Pass 到 PassBuilder llvm::PassPluginLibraryInfo getMyPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, MyPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name my-function-info) { FPM.addPass(MyFunctionInfoPass()); return true; } return false; }); }}; } extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getMyPassPluginInfo(); }代码里几个关键点说一下PassInfoMixinMyFunctionInfoPass是 CRTP 模式作用是让 LLVM 知道这个 Pass 的类型。run(Function F, FunctionAnalysisManager AM)是 Pass 的主入口。FunctionAnalysisManager可以拿来查依赖的分析结果比如AM.getLoopAnalysis(F)。PreservedAnalyses::all()表示这个 Pass 没有修改任何 IR只是打印信息。这样优化器可以保留之前的分析结果不会因为跑了一个分析型 Pass 就把所有缓存全清掉。如果你写了修改 IR 的 Pass就需要精确返回哪些分析被破坏了。这段代码本身没有修改 IR它只是读取了 IR 的信息所以返回all()是安全的。如果你打算修改一条指令那就得好好想想返回PreservedAnalyses::none()还是手动指定保留下来的分析。3.3 编译与加载用 opt 跑起来opt是 LLVM 核心的命令行工具专门用来跑 Pass 和 IR 转换。你编译这个 Pass 成一个动态库然后用opt加载它即可。写一个简单的CMakeLists.txtcmake_minimum_required(VERSION 3.20) project(MyPass) find_package(LLVM REQUIRED CONFIG) add_library(MyPass MODULE MyPass.cpp) target_include_directories(MyPass PRIVATE ${LLVM_INCLUDE_DIRS}) target_compile_definitions(MyPass PRIVATE ${LLVM_DEFINITIONS}) target_link_libraries(MyPass PRIVATE ${LLVM_LIBRARIES})然后构建cmake -G Ninja -S . -B build -DLLVM_DIR/path/to/llvm-project/build/lib/cmake/llvm ninja -C build这里有个容易踩坑的点find_package(LLVM REQUIRED CONFIG)要求 CMake 能找到 LLVM 的 CMake 配置文件。如果你的 llvm-project 编译出来的构建目录还在直接用-DLLVM_DIR指过去就能用。构建出来的MyPass.so在build/下。接着准备一个测试 IR 文件test.lldefine i32 foo(i32 %a, i32 %b) { entry: %sum add i32 %a, %b %diff sub i32 %a, %b ret i32 %sum }运行opt -load-pass-pluginbuild/MyPass.so -passesmy-function-info test.ll如果一切正常你会在终端看到类似这样的输出[MyPass] Function: foo, BasicBlocks: 1, Instructions: 4这里要解释一下-passesmy-function-info为什么能识别我们注册的 Pass。因为代码里注册了registerPipelineParsingCallback并指定了字符串名字my-function-info。New Pass Manager 就是这个字符串做管道解析的。你完全可以把多个 Pass 用逗号串联起来比如-passesmy-function-info,my-other-pass。3.4 为什么新手建议从 Function Pass 入手LLVM Pass 有好几个级别Module Pass、CGSCC Pass、Function Pass、Loop Pass。我强烈建议新手先写 Function Pass原因很简单它的粒度正好每个函数的 IR 都会独立跑一遍你的代码调试信息清晰打印日志也不会乱成一锅粥。Module Pass 能看全局符号表但对初学者来说处理全局状态和跨函数分析的数据结构往往比较复杂容易先入为主地放弃。如果你想进一步验证 Pass 确实生效了可以做个更疯狂的小实验把函数里所有add指令改成sub修改 IR。这样你就必须处理IRBuilder、指令替换、非局部特性对理解 LLVM 的变换型 Pass非常有帮助。4. llvmpipe 到底从 LLVM 里拿了什么从 IR 到 JIT 的一次跳跃你看到llvmpipe 15.0.7 256 bits这个信息时大概率是从glxinfo或者游戏启动日志里截出来的。它代表的是当前机器没有可用的硬件 GPU 加速图形栈退回到了 Mesa 的软件渲染路径。很多人不理解为什么一个软件渲染器会跟 LLVM 扯上关系又凭什么能跑出256 bits这种听起来很厉害的数字。4.1 软件渲染器为什么要编译器图形渲染里的 shader着色器本质上是运行在 GPU 上的一段小程序。CG 语言OpenGL Shading Language写好后驱动会把它编译成 GPU 的机器码然后再在 GPU 上执行。可如果你的机器没有可用的 GPU或者 GPU 驱动不可用该怎么办另一种思路是把 shader 编译成 CPU 能跑的机器码然后用 CPU 暴力模拟 GPU 的并行计算。问题在于shader 是运行时动态出现的你不可能提前把它编译成可执行文件然后再加载。所以 llvmpipe 要做的是在程序运行时拿到 GLSL/Vulkan 的 shader 源码高层次地把它翻译成 LLVM IR然后调用 LLVM 的 JIT 编译能力在内存里生成 CPU 的机器码最后像调用普通函数一样在 CPU 上执行。这个流程和 Clang 把 C 编译成可执行文件几乎一样区别只是前者是运行时、后者是编译期这正是 LLVM 最擅长的领域之一。4.2 JIT 编译的基本原理LLVM 里做 JIT 的核心 API 分两代。早年的MCJIT已经过时了现在主流是ORCOn Request Compilation框架。ORC做的事可以简化成三步接收 LLVM IR Module可以来自 shader 编译器也可以来自其他前端。通过 LLVM 的 CodeGen 把它编译成目标机器的机器码。提供回调机制让你在运行时按需解析符号地址然后就能像调用普通函数一样call过去。llvmpipe 内部针对不同的 GPU 管线阶段会生成不同的 IR顶点着色器生成一个函数片段着色器生成另一个函数。这些函数内部用了向量化指令比如256 bits通常对应 AVX2 的 8 个 float 向量因为 CPU 虽然没有 GPU 那么多并行核心但现代 CPU 的 SIMD 指令还是能提供不错的吞吐256 bits指的就是这种 SIMD 宽度。4.3 为什么256 bits不是 LLVM 版本的固有属性你看到的llvmpipe (llvm 15.0.7, 256 bits)是两段信息的拼接前面是说 Mesa 工具链自带或系统检测到的 LLVM 版本后面是 llvmpipe 在运行时向渲染 API 报告的我能支持的最大向量位宽。256 bits意味着你的 CPU 支持 AVX2 指令集。如果 CPU 只支持 SSE4这个数字通常就是128 bits。所以在不同机器上看到的数字会不一样它不代表 llvmpipe 有高低配之分只是对 CPU 能力的一次如实汇报。我自己在调试图形栈的时候看到这个字符串的第一反应是去看 CPU 型号和 Mesa 版本而不是恐慌我是不是没装显卡驱动。很多场景下VM 虚拟机、老服务器、无头部署就是没有 GPU 的llvmpipe 反而成了保障图形程序能跑的兜底方案。它的性能没法跟独显比但至少把功能给你跑起来了。4.4 从 llvmpipe 反推 LLVM 的通用价值llvmpipe 使用 LLVM 的方式其实代表了非常大的一类项目需要运行时动态生成高性能机器码。这类项目的典型特征包括代码生成路径发生在运行时没法提前编译好。性能目标要求接近本机最优水平不能走解释器路线。代码形态高度动态取决于用户输入。比如 PostgreSQL 的 JIT 表达式执行、Julia 语言的编译管线、各类数据库的向量化执行引擎走的都是类似的 LLVM JIT 技术路线。你只要掌握一套 前端产出 IR LLVM 优化 后端生成机器码 的思维方式就理解了大量高性能基础设施的底层逻辑。llvmpipe 只是其中名气最大的一个。5. 环境配置与调试我在实战中填过的那些坑到这一步你应该已经知道 LLVM 项目是什么、怎么构建、怎么动手写 Pass、以及下游项目是怎么使用它的。最后一个模块我集中整理一下自己平时开发 LLVM 时踩过的坑和总结的经验这一节的价值不比前面少有些坑搜索引擎都不一定查得到。5.1 构建时的内存上限问题LLVM 的链接阶段是最吃内存的。如果你用 GNU ld 来链接clang最终链接阶段可能吃掉 4GB 到 6GB 内存。当年我在一台只有 8GB 内存的云服务器上编译链接到一半进程被 OOM Killer 杀了当时整个人是崩溃的。后面学乖了优先用lld链接时内存少很多。如果因为某种原因必须用系统 ld可以减小编译并行度ninja -j2降低内存峰值。或者干脆减少构建的 target 数量只构建X86一个后端。5.2LLVM_ENABLE_ASSERTIONS开启后性能下降的问题我在开发调试阶段都是开启断言的。但有读者问过我为什么我编译出的 clang 比我系统自带的 clang 慢那么多 答案大概率就是断言。Release 模式下 LLVM 也保留大量断言检查会使编译器本身慢不少。如果你只是想用 clang 做日常编译不建议自己从源码编译 Release 版去替代发行版自带的 clang但如果你在开发 LLVM 本身断言一定要开。两个需求对应完全不同的构建配置别搞混了。5.3 IR 调试工具链从-print-after-all开始开发 Pass 的时候最常用的调试手段之一就是开启优化日志。用opt或者clang的时候加opt -passes... -print-after-all test.ll它会打印每个 Pass 执行完之后的 IR让你直观地看到优化器的每一次变换。加-filter-print-funcsfoo可以只看某个函数的输出避免在大型 IR 文件上产生海量日志。还有一个经常用的工具是llvm-dis它把.bc位码文件反汇编成可读的.ll文本文件。很多工具链中间产物都是.bc格式比如-emit-llvm编译出来的文件这时候用llvm-dis就能看到对应的 IR 文本。5.4 源码阅读的推荐路径如果你打算深入理解 LLVM 源码我建议的阅读顺序是llvm/include/llvm/IR/先看Instruction.h、BasicBlock.h、Function.h搞清楚核心数据结构。llvm/lib/Transforms/Utils/看几个典型 Pass 的实现比如Local.cpp、LoopUnrollPass.cpp。llvm/lib/CodeGen/等理解 IR 之后再去了解机器码生成流程这里涉及很多目标相关的概念。不要一开始就扎进后端代码。后端涉及寄存器分配、指令选择、调度器复杂度跟中端完全不是一个量级新手进去非常容易迷失。我见过太多人从SelectionDAG开始读源码没几天就放弃了。5.5 关于复现结果的版本锁定建议llvm-project 的 master 分支每天都在变API 接口说改就改。我当初照着旧版本文档写 Pass 报接口过时的情况发生了不止一次。如果你不想天天追新记得用 release 分支例如llvmorg-15.0.7这个 tag。下载源码和构建都指向固定版本问题会少很多。git clone --depth 1 --branch llvmorg-15.0.7 https://github.com/llvm/llvm-project.git6. 写在最后的个人建议说点实际的心得。如果你不是做编译器基础设施的从业者只是想了解 LLVM 技术栈我建议的路径是先把 IR 的基本语法学会然后动手写一个分析型 Pass再尝试修改 IR最后看一眼 llvmpipe 的 JIT 调用过程。你不需要把整个 llvm-project 都读完但抓住前端生成 IR、中端优化 IR、后端消费 IR、JIT 在运行时生成机器码这条主线就会发现很多看似高深的技术都串起来了。我自己最有体会的一件事是真正把 IR 和 Pass 玩熟之后再回去看数据库的向量化引擎、动态语言编译器的 JIT、甚至一些高性能计算库的代码都能更快抓住精髓。因为它们的共同点都是如何把一段动态产生的逻辑快速变成高效机器码而 LLVM 恰恰是这套问题的标准答案。希望这篇文章能帮你把 llvm-project 这个仓库的门推开。
返回列表