ARTICLE DETAIL

资讯详情

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

LLVM架构与实战:从IR到工具链再到llvmpipe的底层解析

LLVM架构与实战:从IR到工具链再到llvmpipe的底层解析 LLVM 这个项目说实话搞编译器和底层开发的人几乎天天跟它打交道。但如果你只是听说过它的大名想搞明白它到底是什么、能干什么、为什么这么火那今天这篇内容应该能帮你把它的底子摸个大概。我会从 LLVM 的架构设计讲起再结合我在实际项目中编译、改代码、甚至折腾 llvmpipe 软件渲染器的经验把那些文档里不会细说的坑和心得都翻出来聊聊。1. LLVM 到底解决了什么问题一套让你“少写编译器”的基础设施很多初学者第一次打开 llvm-project 这个仓库看到里面一堆子项目会直接懵掉。Clang、LLD、libc、compiler-rt、lldb、llvmpipe……这哪是一个编译器简直是一个软件开发工具全家桶。没错这恰恰是 LLVM 项目最核心的设计理念它不是一个简单的编译器而是一整套可复用的编译器基础设施。1.1 编译器不只是一个“翻译工具”传统的 GCC 是一个整体式编译器前端解析 C/C 语法中端做优化后端生成目标平台汇编全部绑死在一个进程里。虽然这样效率高、内部接口可以随便改但有个非常大的痛点你想复用它某个环节基本等于重写整个编译器。LLVM 从一开始就走了一条完全不同的路。它的核心思想可以概括成“三段式架构”前端把源代码解析成中间表示优化器对中间表示做各种变换后端把优化后的中间表示生成目标机器码。也就是说无论你写的是 C、C、Rust、Swift还是 Julia、Kotlin只要你有能力把源代码翻译成 LLVM 的中间表示你就能免费获得 LLVM 后端所有的优化和代码生成能力。反过来如果你想支持一个新的 CPU 架构你只需要实现一个新的后端所有使用 LLVM 前端的语言就都能跑在你的新架构上。这个思路听起来简单但影响极其深远。它把“编译器”从一个黑盒变成了一个可以任意拆解、自由组合的积木系统。很多语言和项目之所以敢于快速迭代、跨平台靠的就是 LLVM 这套基础设施。1.2 中间表示IR是 LLVM 的灵魂要理解 LLVM 的架构绕不开它的中间表示简称 IR。LLVM IR 是一种类似于 RISC 汇编的底层语言但它比汇编多了类型系统、显式的数据流信息以及无限数量的临时寄存器这让它非常适合被各种优化算法处理。为什么中间表示这么重要因为没有 IR前端的产出和后端的输入就得直接对接每换一种语言支持一个新架构工作量就是前端数乘以后端数。有了 IR 之后前端只负责产出 IR后端只负责消费 IR复杂度从乘法变成了加法。从项目实战的角度看IR 还有个好处它在优化和代码生成阶段是相对稳定不变的。比如我在做自定义指令集的时候只需要在 LLVM 后端里定义指令选择规则把 IR 节点映射到我的新指令上不用关心前端怎么把 C 语言转换成 IR。这极大降低了适配成本也是 LLVM 项目在工业界和学术界都备受青睐的原因。1.3 为什么几乎所有新的编程语言都“长”在 LLVM 上翻一下现在主流的编程语言实现Rust 用的是 LLVM 后端Swift 用的是 LLVMJulia 用 LLVM 做 JIT甚至很多新兴的 DSL 也选择 LLVM 作为代码生成后端。原因很简单你只需要写一个前端把你的语法翻译成 LLVM IR就能立刻获得世界顶级的优化能力以及 X86、ARM、RISC-V、PowerPC 这些主流架构的支持。对比之下你自己从头写一个优化器做到 O2 级别的优化可能需要十多年功力。LLVM 相当于把几十年的编译器优化经验封装成了一个库让你可以直接调用。对于我这种经常需要做性能分析和自研工具的人来说LLVM 就像是一个工具箱你可以精准地只拿你需要的那个扳手而不是被迫接受整个工具箱的捆绑。2. LLVM 工具链全景从 Clang 到 lld再到神奇的 llvmpipellvm-project 这个仓库里除了核心的 LLVM 库和 clang 编译器还打包了大量配套工具。很多人不知道这些工具之间是怎么协作的这里我用实际运行时的链路给大家串一遍。2.1 前端编译器 Clang不仅仅是 C/C 的替代品Clang 是 LLVM 官方的 C/C/Objective-C 前端它和 GCC 相比最直观的感受是编译速度更快、报错信息更友好。Clang 把 C/C 源码解析成 AST然后降级成 LLVM IR。但 Clang 的价值不止于此。它的前端架构是库化的你可以直接调用 Clang 的 API 写静态分析工具、代码格式化工具甚至自己开发 IDE 插件。我记得第一次用 libclang 写工具的时候我只用了不到两百行代码就实现了一个跨文件的函数调用关系提取器。要是用 GCC 的内部 API光是搞懂它们那些数据结构就要花掉好几天。Clang 还自带了很多实用的子工具比如 clang-tidy 做代码规范检查、clang-format 做代码格式化、clangd 做语言服务器协议。基本上你日常开发中用到的代码分析、智能提示、自动化重构Clang 全家桶都能覆盖。2.2 lld 链接器快到你几乎感觉不到它在工作在大型 C 项目的构建中链接阶段常常是时间瓶颈。传统的 GNU ld 链接一个大型二进制动辄就是两三分钟但 lld 通常能把时间压到十几秒甚至几秒。lld 之所以快关键是它从设计之初就采用了并行化的思路同时积极利用现代操作系统的特性来做优化。比如它的归档文件解析、符号解析、重定位处理都是高度并行的这让它在多核机器上的表现几乎可以说是碾压式的。实际用 lld 替换系统链接器也很简单在构建命令里加一个参数就行比如 Clang 下用-fuse-ldlld。我在做大型项目的 CI 优化时只改了这一个参数整个流水线的构建时间就缩短了将近四成。这种性价比极高的优化强烈推荐大家先尝试。2.3 一个出乎意料的子项目llvmpipe 软件渲染器聊到 llvmpipe就要结合热词里的“llvmpipe (llvm 15.0.7, 256 bits)”来说了。llvmpipe 是 Mesa 3D 图形库中的一个软件渲染器后端它利用 LLVM 的 JIT 能力让 CPU 来模拟 GPU 的图形渲染管线。为什么软件渲染器还需要 LLVM因为图形渲染的顶点变换、片段着色器这些计算如果直接解释执行会慢得没法用。llvmpipe 的做法是它在运行时把着色器代码通过 LLVM 动态编译成机器码直接用 CPU 的 SIMD 指令集去并行计算多个像素或顶点。热词里提到的“256 bits”其实就对应着 AVX2 指令集的向量寄存器宽度。LLVM 在做代码生成的时候会根据当前 CPU 的能力自动选择最合适的 SIMD 指令集把 8 个单精度浮点数打包进一个 256 位寄存器里一次算完。这就是为什么 llvmpipe 虽然是一个纯 CPU 渲染器但性能依然能看的核心原因。你可能会问都什么年代了还需要软件渲染器其实应用场景非常广。比如在没有独立显卡的服务器上跑 OpenGL 应用、做离屏渲染测试、跑 CI 图形测试、甚至一些云游戏的 GPU 虚拟化兜底都会用到 llvmpipe。还有 Mesa 自带的测试套件也用它来做参考输出因为它的行为是完全确定性的方便做自动对比。2.4 这些工具是如何协同工作的一条完整的编译链路讲完各个子工具我们再串一条真实的编译命令看看它们怎么协同工作。假设你输入一条clang -O2 -fuse-ldlld main.c -o main实际发生的过程是Clang 前端解析 main.c生成 AST最终降级成 LLVM IR。LLVM 优化器对 IR 执行几十个 pass比如内联、常量传播、循环展开、向量化。LLVM 后端根据目标架构把优化好的 IR 转换成汇编代码再由汇编器转成目标文件。lld 链接器把目标文件和系统库文件组合在一起完成符号解析和重定位最终生成可执行文件。这个过程每一步都有着清晰的边界你可以随时在中间插一脚。比如用clang -emit-llvm导出 IR 文件用opt单独跑某个优化 pass用llc单独做代码生成。正是这种模块化的设计让 LLVM 成为了我日常工作中最常用的“研究工具”。3. 实操记录从源码构建 LLVM 到跑通自己的第一个 Pass理论讲了太多这里必须来点硬核的。我自己踩过不少坑从源码构建 LLVM 到开发自定义 pass整个过程还是有很多值得记录的细节这里尽可能完整地拿出来分享。3.1 环境准备与版本选择LLVM 15.0.7 是一个相当稳的版本先说版本热词里出现的 15.0.7我非常推荐新手从这个版本开始。LLVM 的开发节奏非常快每年发布一个大版本v15 属于相对早一点的版本但它已经具备完整的 C17 支持API 也不像最新版本那么频繁变动适配问题会少很多。操作系统的选择上Linux 是最顺手的Ubuntu 22.04 或 Debian 都是不错的选择。如果你用的是 macOS也差不多Windows 上构建 LLVM 会比较折腾建议优先用 WSL 或者直接上 Linux 虚拟机。同时把基础依赖装好主要是 CMake、Ninja、GCC 或 Clang。Ninja 的并行构建能力非常强这是必需品。sudo apt update sudo apt install cmake ninja-build gcc g python3 python3-pip3.2 配置与构建这次我们只编 Release 版本LLVM 的构建选项非常多但新手不用全部搞懂。第一个原则是不要用 Debug 版本那个编译出来的二进制体积大、跑得慢而且依赖很多调试符号纯属给自己找麻烦。用 Release 版本就能获得很好的性能也别上来就开-DLLVM_ENABLE_ASSERTIONSON那个会拖累构建速度。我平时构建的时候常用的配置是这样的git clone --branch llvmorg-15.0.7 https://github.com/llvm/llvm-project.git mkdir build cd build cmake -G Ninja ../llvm-project/llvm \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;AArch64;RISCV \ -DLLVM_ENABLE_ASSERTIONSON \ -DBUILD_SHARED_LIBSON这里简单解释下几个选项-DLLVM_ENABLE_PROJECTS指定要构建哪些子项目clang和lld是目前最常用的两个其他的先不用装。-DLLVM_TARGETS_TO_BUILD控制要生成哪些后端的指令选择代码目标平台越多构建耗时越长。如果只是研究和应用选自己实际用到的几个架构就够了。-DBUILD_SHARED_LIBSON会把 LLVM 各组件编译成动态库能大幅减少最终二进制体积也能加快链接速度。缺点是部署时需要带上这些动态库。对日常开发非常友好。构建命令很简单ninja如果你机器配置还行比如 8 核以上大概等个 20 到 40 分钟就能完成。如果等到一个多小时还没好大概率是配置出了问题往下看排查部分。3.3 快速上手让 LLVM 告诉你一个函数有多“热”构建完成后可以用一个简单例子验证一下。写一个test.c#include stdio.h int add(int a, int b) { return a b; } int main() { printf(%d\n, add(1, 2)); return 0; }先转成 LLVM IR 看看生成什么样的中间表示./bin/clang -S -emit-llvm test.c -o test.ll打开 test.ll你会看到类似这样的内容define i32 add(i32 %a, i32 %b) { %1 add i32 %a, %b ret i32 %1 }到这一步你已经能看到 IR 的长相了i32表示 32 位整数add是一个函数符号%a、%b是虚拟寄存器。然后我们再体验一下 LLVM 优化器的能力./bin/opt -S -O2 test.ll -o test_opt.llO2 优化会把函数内联到 main 里面这就很好地演示了 LLVM 优化器的基本工作流程读入 IR做变换输出新的 IR。3.4 写一个自定义 Pass给所有函数加一行日志静态分析中经常需要给函数插桩我们来手写一个最基础的 LLVM Function Pass它的功能很简单遍历每个函数的每条指令碰到加法操作就给插一条 printf 调用输出 “add called”。核心代码如下#include llvm/IR/Function.h #include llvm/IR/IRBuilder.h #include llvm/IR/Instructions.h #include llvm/Pass.h #include llvm/IR/LegacyPassManager.h #include llvm/Transforms/IPO/PassManagerBuilder.h using namespace llvm; namespace { struct AddCallLogger : public FunctionPass { static char ID; AddCallLogger() : FunctionPass(ID) {} bool runOnFunction(Function F) override { LLVMContext Ctx F.getContext(); Module *M F.getParent(); // 获取或创建 printf 函数声明 FunctionCallee PrintfFunc M-getOrInsertFunction( printf, FunctionType::get(IntegerType::getInt32Ty(Ctx), PointerType::getUnqual(Type::getInt8Ty(Ctx)), true)); bool Changed false; for (BasicBlock BB : F) { for (Instruction I : BB) { if (auto *BinOp dyn_castBinaryOperator(I)) { if (BinOp-getOpcode() Instruction::Add) { IRBuilder Builder(BinOp); Value *FmtStr Builder.CreateGlobalStringPtr(add called\n); Builder.CreateCall(PrintfFunc, {FmtStr}); Changed true; } } } } return Changed; } }; } char AddCallLogger::ID 0; static RegisterPassAddCallLogger X(add-logger, Log all add instructions);编译这个 Pass 需要用到 LLVM 的头文件和库文件最简单的做法是用llvm-config拿到编译参数./bin/llvm-config --cxxflags --ldflags --libs然后用类似下面的命令编译g -fPIC -shared add_logger.cpp -o AddCallLogger.so \ $(./bin/llvm-config --cxxflags --ldflags --libs)再用opt加载它./bin/opt -load ./AddCallLogger.so -add-logger test.ll -S -o test_logged.ll整个过程虽然工具链略繁琐但当你看到生成的 IR 里真的插入了 printf 调用时那种“我改写了编译器”的成就感还是挺强的。这也是理解 LLVM 如何做静态分析、插桩改代码、自定义优化最简单直接的方式。3.5 给 llvmpipe 做一次性能验证前面我们是把 LLVM 当编译器研究这里再去看看它在图形渲染里的表现。如果你装了 Mesa 和 llvmpipe可以用环境变量强制任意 OpenGL 应用走软件渲染路径完全绕开 GPU。export GALLIUM_DRIVERllvmpipe export LIBGL_ALWAYS_SOFTWAREtrue glxinfo | grep OpenGL renderer正常情况下你会看到类似llvmpipe (LLVM 15.0.7, 256 bits)的输出。这里的 “256 bits” 说明 llvmpipe 检测到了你的 CPU 支持 AVX2会把 8 个浮点打包到一个向量里并行算。如果想看看不同 SIMD 级别对性能的影响可以用lp_env相关变量强制禁用 AVX2再跑一遍同样的图形负载对比帧率就会看到显著的差距。这个操作对于做图形性能调优、理解 CPU 向量化收益非常有帮助也别小看这个软件渲染器很多无 GPU 的云渲染和 CI 环境可都靠它跑测试。4. 常见问题与排查技巧实录任何项目只要深入到编译构建这个环节都会踩坑。这里整理一些我在实操中遇到的高频问题以及对应的排查思路。4.1 构建过程中内存不足怎么办LLVM 是多语言、多架构的大型项目编译时的内存消耗相当惊人尤其是某些包含大量模板展开的文件像X86ISelLowering.cpp、ARMISelLowering.cpp这种单文件编译内存轻松超过 2GB。如果内存不足会直接触发internal compiler error或者 OOM killed。解决思路有几种限制并行任务数。Ninja 默认会吃掉所有核心可以用来限制ninja -j 4调低优化等级来编译 LLVM 自身构建 LLVM 的编译选项和 LLVM 的生产优化选项可以分开。比如用-DLLVM_OPTIMIZED_TABLEGENON来减少构建时的内存。实在不行就加 swap虽然慢但总比编不过去强。4.2 API 版本不匹配LLVM 15 与最新文档的差异LLVM 的 API 变动非常频繁很多网上教程的代码在新的 LLVM 版本里就编译不过了或者是一些老的Pass接口已经被弃用。比如我上面写的是 legacy PassManager 风格的 pass到了 LLVM 17 之后新出的 New Pass Manager 已经是主流接口差别很大。所以遇到编译错误先别急着改代码先确认你的 LLVM 版本。用llvm-config --version查版本再去查对应版本的官方文档或历史示例。对于一些项目里用了较老的 API 的情况最稳妥的方式是锁定 LLVM 版本比如就锁 15.0.7跟着离线 API 文档走这样可以避免大量无意义的适配工作。4.3 llvmpipe 渲染结果不正确的排查思路如果你用 llvmpipe 渲染时画面出现异常比如花屏、错位、颜色不对可以分几步排查先看 llvmpipe 的实际版本用glxinfo确认它在用哪个 LLVM 版本编译着色器。如果版本过旧可能是代码生成有旧 bug。调整 gallium 的调试环境变量比如GALLIUM_LOG_FILE、LP_DEBUGmesa把内部信息打出来。看看是不是 SIMD 路径的问题可以强制关闭某些指令集路径来逐一排查。export LP_FORCE_RASTERIZERsoftpipe这可以强制使用不依赖 LLVM JIT 的软渲染路径用于对比是不是 LLVM 代码生成导致的问题。4.4 编译通过但运行时链接报错的通用排查法LLVM 相关的程序经常在运行时报告找不到共享库尤其是用BUILD_SHARED_LIBSON构建之后。原因就是这个库是动态编译的运行时需要去找这些.so文件。解决办法有几种把 build 目录下的lib路径加入动态库搜索路径export LD_LIBRARY_PATH/path/to/llvm/build/lib:$LD_LIBRARY_PATH或者直接用 lld 把链接选项改成静态链接到 LLVM 库。在 CMake 项目里一般会通过find_package(LLVM REQUIRED CONFIG)拿到对应的路径配置好include_directories和target_link_libraries就没这个问题。4.5 构建太慢的优化技巧如果每次改动都需要等很久才能重新构建可以试试这些优化尽量使用 ccache第一次全量构建之后后续构建会快很多sudo apt install ccache export CCACHE_DIR/path/to/ccache使用 lld 作为 LLVM 自身的链接器这个可以显著减少链接耗时-DCMAKE_EXE_LINKER_FLAGS-fuse-ldlld增量构建时只编自己需要的子项目。经常只改 clang 前端代码就不需要重新链接整个 LLVM 库尽量让改动范围最小化。5. LLVM 生态的延伸从代码生成到编程语言的“新基建”项目实操的部分讲完了再聊聊 LLVM 生态里更广泛的应用以及它对我个人项目思维方式的影响。5.1 从编译器到异构计算与 GPU 编程LLVM 的后端不只是 CPU。在 GPU 领域LLVM 也扮演了核心角色。AMD 的 ROCm 栈、NVIDIA 的 CUDA 编译路径、Intel 的 oneAPI 里都有 LLVM 的身影。它让你用 C 写的代码能够在多种异构设备上运行并针对不同硬件的特性自动做指令选择。这带来了一个巨大的优势如果你在做 AI 推理引擎、科学计算库或图像处理当你遇到新硬件时你不用重新实现一遍算法层只需要借助 LLVM 把同一套 IR 映射到新架构。这种跨平台能力在算力硬件百花齐放的今天极其宝贵。5.2 LLVM 在静态分析和程序验证中的价值前面我们写了插桩 pass这只是 LLVM 的冰山一角。在程序分析和安全领域LLVM 也提供了很多基础设施比如 AddressSanitizer、MemorySanitizer、ThreadSanitizer 这些内存检测工具都是编译器层面的插桩实现。我做过一个内部的内存泄漏检测工具思路就是利用 LLVM 的 pass 在每次内存分配和释放的位置插入跟踪代码然后结合运行时的回调函数把分配堆栈和释放堆栈对齐。如果不用 LLVM我可能要直接对二进制做插桩或者靠调试器单步跟踪效率天差地别。这就是为什么我说 LLVM 是所有做底层基础设施研究的人必备的武器库。5.3 LLVM 对编程语言设计者的启发如果你有设计一门新语言的念头LLVM 绝对是最重要的朋友之一。你只专注设计好语法和语义写好前端就能免费获得优化、debugger、链接器支持、跨平台代码生成。很多优雅的编程思想比如值语义、纯函数、数据不可变都需要好的编译器支持才能高效落地而 LLVM 恰好提供了这个实验场。我有一个朋友用 LLVM 做了个小众的 DSL专用于转译他们公司内部的配置逻辑。原本这种配置解析、校验、下发需要写一堆解释器代码现在直接编译成机器码性能飞升维护成本还极低。这种场景放在十多年前是不可想象的。5.4 社区和版本节奏给我们的启示最后聊一点社区方法论。LLVM 的版本迭代非常快每年一个主版本主版本之间保持 API 兼容性的压力很大所以社区发展出了一套成熟的演进策略先在主分支上标记弃用再在一个大版本周期后移除。这种渐进式淘汰机制值得所有长期维护的开源项目学习。对我们普通开发者来说在选型时建议跟随 LTS 或者次新版本用最新版本前一定要看 release notes尤其关注 Breaking Changes 部分。我在项目里就吃过一次亏从 LLVM 13 跳到 15因为某个 API 签名变了排查了半天才定位到问题。从那以后我都会先检查版本差异再动手迁移。这些年的实践下来我最大的感受是LLVM 已经不只是“又一个编译器”它正在成为整个底层软件生态的基础设施。无论是编程语言设计、代码优化、跨平台适配、GPU 计算、程序分析还是软件渲染你都能看到它以各种方式在背后发挥作用。对开发者来说早一点理解 LLVM 的架构思想和工具链转型做底层基础设施研究时能少走很多弯路。如果你手上正好有编译性能、语言设计、指令集适配或者渲染优化相关的需求不妨从构建一个最新稳定版 LLVM 开始装上 Clang试着看几个 IR 文件再跑一个简单的自定义 Pass相信你会有非常直接的体感。
返回列表