ARTICLE DETAIL

资讯详情

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

llvmpipe背后的LLVM编译器:从IR到JIT的实践解析

llvmpipe背后的LLVM编译器:从IR到JIT的实践解析 如果你在 Linux 桌面环境里装过 Mesa、跑过某些跨平台游戏或图形程序大概率见过这样一行日志llvmpipe (LLVM 15.0.7, 256 bits)第一次看到这行字的人通常会愣一下这到底是个渲染器还是个编译器怎么看都像是一台虚拟机的版本号实际上llvmpipe是 Mesa 里的一个纯软件渲染器而它背后真正干重活的就是 LLVM。换句话说这行日志是 LLVM 编译器基础设施在图形渲染场景里存在感最强的一次露面。聊llvm-project绕不开这行日志。它既是 LLVM 官方仓库的名字也是一整套编译器生态的总称。这篇文章我会从你会在各种运行日志、构建记录里碰到的这些碎片线索入手把llvm-project是什么、里面装了哪些东西、为什么值得你手动构建一次以及我在折腾过程中踩过的坑全部串起来讲清楚。适合还没系统接触过 LLVM、但想真正搞懂它的人也适合已经用它编译过几个项目、想进一步摸清内部结构的人。1. llvm-project 到底是什么先拆掉虚拟机这个误解1.1 一句话理解 LLVM 的核心架构很多初学者第一次听到 LLVM脑子里会浮现出Low Level Virtual Machine的全称然后下意识以为它是一个虚拟机。这个理解不能说全错但放在今天已经严重过时。现在的 LLVM 是一整套编译器基础设施名字里的 Virtual Machine 更多是历史遗留。它的核心思想可以简化成三段流水线前端把源代码变成一种统一的中间表示中间表示不关心你写的是 C、C、Rust 还是 Swift后端把中间表示变成目标机器的机器码中间表示同样不关心目标机器是 x86、ARM、RISC-V 还是 GPU。前端、后端之间的这个中间层就是 LLVM 最值钱的地方也就是 LLVM IR。用生活化一点的类比LLVM IR 相当于一份编辑部统一排版稿。各个前端是不同语言的记者他们用各自的语言写新闻但交稿时必须统一成同一种排版格式后端是不同印刷厂的机器只要拿到这份统一排版稿就能印出各自风格的报纸。有了这一层如果你想发明一门新语言不需要为几十种架构分别写编译器只需写一个能产出 LLVM IR 的前端其余全部交给 LLVM。这也是 LLVM 能从一个学术项目成长为整个行业基础设施的根本原因。它把编译器这个原本像手工作坊一样的领域拆成了可以各自演进的标准零件。1.2 llvm-project 仓库里到底装了什么你从 GitHub 上 clone 下来的llvm-project不是单个程序而是一整个 monorepo单仓库多项目。我在本地构建时第一次看到目录列表也被数量惊到了里面既有大名鼎鼎的clang也有很多人根本没听说过的mlir、flang、polly。几个最核心的目录我列一下这样你对它的体积和职责会更有概念llvm/LLVM 核心库包括 IR 定义、pass 优化框架、代码生成后端、目标描述等。整个仓库的心脏。clang/C、C、Objective-C 的前端。也是绝大多数人日常真正用到的部分clang命令就是这里构建出来的。lld/链接器。号称比系统自带链接器快很多尤其是在链接 C 大型项目时速度和内存占用优势非常明显。libcxx / libcxxabi / libunwindC 标准库实现及配套的 ABI 层、栈展开库。compiler-rt/运行时库包含 sanitizer地址消毒、内存消毒等、profile 支持等。mlir/多层级中间表示框架目前 AI 编译器领域的热点很多芯片厂商都在用它做深度学习编译器。flang/Fortran 前端经典科学计算语言在 LLVM 生态里的现代实现。lldb/调试器对标 GDB 的新一代选择。polly/基于多面体模型的循环优化器。openmp/OpenMP 运行时实现。这还只是按功能划分的一级目录。每个目录内部又各自包含庞大的库和工具集。所以当你看到某篇文章写LLVM 15.0.7 发布了其实指的是整个llvm-project这个集合体的版本号而不是某一个单独工具。1.3 为什么说它是编译器的编译器LLVM 有一个很微妙的设计它的核心库做的是把 IR 优化成更好的 IR以及把 IR 变成目标机器指令这两件事都不关心你原始代码到底是用什么语言写的。因此所有语言前端可以共享一套优化器和代码生成器。这就带来了一个显眼的结果无论你写的是 C 的clang、Rust 的rustc、Swift 的swiftc还是 Julia 的 JIT 编译器它们底层生成的机器码质量都受 LLVM 在优化和指令选择上功力深浅的影响。换句话说LLVM 的每一次优化升级会让整个行业里几十种语言同时受益。我刚开始接触时总把llvm-project当成一个普通的 C 语言编译器项目后来才意识到它其实是一套元编译器工具链。你可以不写一行 C/C 代码仅仅是调用它的库就让自己的解释器获得即时编译能力或者让自己的 DSL 直接生成高性能的 AVX-512 汇编。这一点在后面讲 OrcJIT 时会更具体。2. 从 llvmpipe 热词看 LLVM 的真实面目2.1 llvmpipe 是什么为什么日志里常看到它回到开头那行日志。llvmpipe是 Mesa 3D 图形库中的一个软件光栅化渲染器。它没有 GPU 驱动纯靠 CPU 计算三角形、纹理、着色器等图形操作。为什么它叫llvmpipe因为它的着色器编译后端直接使用 LLVM把 GLSL 之类的着色器语言转成 IR再经过 LLVM 的 JIT 变成当前 CPU 能执行的机器码。普通用户什么时候会碰到它最常见的情况是你运行一个需要 OpenGL 的程序但系统里没有可用的 GPU 驱动或者处于远程桌面、虚拟机等无 GPU 环境Mesa 就会自动把渲染任务交给llvmpipe。此时跑glxinfo之类的工具渲染器字符串里就会出现类似开头的日志。它在容器里出现的概率尤其高。很多人用 Docker 跑图形程序、跑 headless 浏览器渲染、跑科学可视化脚本时容器里根本没有/dev/dri设备也装不了显卡驱动最后全靠llvmpipe在 CPU 上慢慢画。所以它也算得上是容器渲染兜底方案。2.2 llvm 15.0.7, 256 bits日志里到底透露了哪些信息这一行看起来不起眼的日志其实压缩了好几层信息llvm 15.0.7构建这个 Mesa/LLVMpipe 时链接的 LLVM 主版本号。15.0.7 属于 LLVM 15 系列的维护版本2022 年底到 2023 年初发布。256 bits这是当前 llvmpipe 在你这台机器上启用的 SIMD 向量宽度。256 比特意味着它正在使用 AVX2/AVX 指令集CPU 每次可以并行处理 8 个 32 位浮点数或者 4 个 64 位浮点数。如果机器只支持 128 位的 SSE日志里就会变成 128 bits。如果你在日志里看到 256 bits说明你的 CPU 支持 AVX2而且 llvmpipe 检测到并启用了它。这个位宽直接影响软件渲染的吞吐量256 bits 的渲染性能通常比 128 bits 高出一截因为关键路径上的向量运算宽度直接翻倍。顺带说一个排查经验如果某个图形程序在别的机器上看起来流畅在你这里却卡成幻灯片可以先看这行日志。如果是128 bits那多半是 CPU 比较老或者虚拟化环境屏蔽了 AVX 指令如果同样是 256 bits 但还是很卡那就要去查 Mesa 版本、帧缓冲配置、有没有启用LIBGL_ALWAYS_SOFTWARE之类的环境变量了。2.3 LLVM 与图形渲染的交叉点JIT 与 IR 的威力llvmpipe的例子很好地说明了 LLVM 的 JIT即时编译能力。传统编译器通常走的是编译完部署的静态路线程序启动时已经是机器码而 JIT 模式下代码在运行期间才被编译成机器码。着色器天然适合 JIT因为 GPU 程序在渲染之前才知道当前需要什么样的具体着色阶段。llvmpipe 的工作流程大致是应用传入 GLSL 着色器Mesa 先把它转换成内部的中间表示然后调用 LLVM 的 API 生成 LLVM IR最后在运行时通过 LLVM 的 JIT 引擎把 IR 编译成 CPU 指令。整个链路对使用者完全透明你感知到的只是一个画得很慢的软件渲染器但它内部其实跑了一整套现代编译器。这也是 LLVM 影响力深入行业毛细血管的典型体现。它不仅仅存在于clang这种天天在命令行里被调用的工具中还隐身在你桌面上的每个软件渲染帧里、每个数据库运行时表达式编译里、每个游戏引擎的运行时着色器编译里。3. 最值得亲手跑一遍的入门实验构建 llvm-project3.1 版本选择与构建参数别一上来就全量编译如果你准备动手构建一次llvm-project我的第一条建议是别克隆最新主干也别一上来就全量编译。主干分支每天都在变构建到一半遇到新问题排查起来很浪费时间。选择一个明确的 release 版本更可控比如llvmorg-15.0.7也是我本文反复提到的版本。构建前先想清楚你要什么。如果只是体验一下编译过程建议只构建clang和lld不要碰lldb、mlir、flang。lldb会额外拉取很多依赖mlir和flang的构建时间又长又占内存。构建指令大致长这样git clone --depth 1 --branch llvmorg-15.0.7 https://github.com/llvm/llvm-project.git cd llvm-project cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_PARALLEL_LINK_JOBS2 cmake --build build -j 8这里的几个参数我展开说一下-DLLVM_ENABLE_PROJECTS控制你要构建哪些子项目按分号分隔。-DLLVM_TARGETS_TO_BUILDX86只生成 x86 目标后端的代码。默认会生成一大堆架构后端构建时间会明显变长。如果只是学习只留 X86 就够了。-DLLVM_PARALLEL_LINK_JOBS2限制同时链接的任务数。链接是最吃内存的环节全速链接时 16GB 内存很容易被吃满限制成 2 个任务能显著降低 OOM 概率。使用 Ninja 而不是默认的 Unix Makefiles是因为 Ninja 在增量构建时明显更快而且错误输出更清晰。我第一次构建时没加LLVM_PARALLEL_LINK_JOBS结果链接阶段机器直接卡死Swap 狂写最后只能强制重启。这条参数堪称救命稻草。3.2 用 clang 跑通第一个自定义 IR构建完成后clang就在build/bin目录下。你可以先用它编译一段简单 C 代码重点看它生成的中间表示cat hello.c EOF #include stdio.h int add(int a, int b) { return a b; } int main(void) { printf(%d\n, add(1, 2)); return 0; } EOF build/bin/clang -O2 -S -emit-llvm hello.c -o hello.ll跑完以后打开hello.ll会看到define i32 add(i32 %a, i32 %b)这样的函数定义里面的指令类似于define i32 add(i32 %a, i32 %b) { %1 add i32 %a, %b ret i32 %1 }这就是 LLVM IR 的文本形式。注意它有几个明显特征每个变量以%开头类型标注在操作数旁边用的是i32而不是int而且没有寄存器分配、没有栈指令这些具体架构相关的东西。这正是 LLVM 能跨架构的原因它先在 IR 层面完成优化再拿去生成目标架构机器码。第一次看到这个文件你可能会觉得它像某种通用汇编。这个直觉是对的。掌握 LLVM IR 的语法是深入理解整个 LLVM 生态的必经之路。3.3 用 opt 看优化 pass 的魔法有了 IR 文件之后你可以用opt工具手动跑一轮优化直观感受 LLVM 的优化框架。LLVM 的优化器核心是几百个 pass每个 pass 负责一种特定的优化或分析。opt工具就是用来单独或组合执行这些 pass 的。举个例子你可以在hello.ll上运行mem2reg。这个 pass 负责把内存中的临时变量提升为寄存器的 SSA 形式。如果之前 C 代码是没开优化编译出来的IR 里会有大量alloca指令运行这个 pass 后它们会大幅减少。build/bin/opt -passesmem2reg hello.ll -S -o hello_m2r.llLLVM 15 的opt推荐用-passes语法来指定 pass 名称这一点需要注意。旧版本常见的是-mem2reg这种短横线写法新版对两种形式兼容性不一。我在 15.x 上已经习惯了-passes写法建议你也直接用这个。再看一个更有意思的对add函数传常量参数并开启内联优化对比优化前后的差异。优化 pass 的效果在简单例子上可能让你觉得就这但当你在一个几千行的 C 文件上执行完整 O2 流水线时观察 IR 如何一点点变得精简、循环如何被展开、向量化如何插入那是相当奇妙的体验。4. 进阶用法不止是编译器而是通用代码生成后端4.1 入门代码生成从自定义 AST 到 LLVM IRllvm-project真正让人上头的是你可以不写任何机器相关代码就完成一套小语言编译器。最简单的方式是用 LLVM 的 C API 直接构建 IR。你要做的是把自己的语法树翻译成 LLVM 的Function、BasicBlock、Instruction对象。我建议初学别直接手搓 API先看官方例子llvm/examples/Kaleidoscope/。这个案例一步一步教你实现一个名为 Kaleidoscope 的小语言编译器覆盖变量、函数定义、if/else、循环最终能生成可执行机器码。它是 LLVM 文档里最出名的教学案例也是很多人的入门之路。核心思路简单说词法分析得到 token语法分析得到 AST然后在CodeGen阶段遍历 AST为每个节点调用对应的 LLVM API。例如整数常量对应ConstantInt::get加法对应Builder.CreateAdd函数定义对应Function::Create。整个过程中你完全不需要知道 x86 有什么寄存器、ARM 有什么调用约定。LLVM 后端会自动做寄存器分配、指令选择、栈帧布局。你能把注意力全放在语言语义上这就是 LLVM 带来的最大解放。4.2 用 OrcJIT 做运行时编译静态编译只是 LLVM 的一半能力。另一个方向是 JIT通过 LLVM 提供的 OrcJIT 框架程序可以在自己运行过程中动态生成和编译代码。数据库里的表达式引擎、游戏引擎的着色器系统、动态语言里的类型特化都可能踩在这条路上。使用 OrcJIT 的最小流程大概是初始化LLJITBuilder创建LLJIT实例把 IR 模块添加进 JIT 的编译层再通过查找符号拿到函数指针最后直接调用。代码用 C 写核心调用几步就能跑通#include llvm/ExecutionEngine/Orc/LLJIT.h #include llvm/IR/Module.h using namespace llvm; int main() { auto JIT orc::LLJITBuilder().create(); auto M std::make_uniqueModule(demo, *JIT-getDataLayout()); // ... 构建 IR 模块 ... cantFail(JIT-addIRModule(orc::ThreadSafeModule(std::move(M), ...))); auto Addr cantFail(JIT-lookup(run)); auto *Fn Addr.toPtrint()(); return Fn(); }这段代码里我略去了 IR 的具体构建但它已经是完整的骨架了。OrcJIT 的接口在 LLVM 各版本之间变化较大如果你看的是老博客很可能会跑到 API 对不上。最简单的办法是直接参考你本地源码树的llvm/examples/OrcV2Examples/目录那里有针对当前版本的可用代码。我的体感是JIT 方向的学习曲线比静态编译陡不少但一旦跑通你就能理解为什么很多现代语言和数据库敢声称表达式的速度接近手写 C本质就是运行时把表达式编译成了机器码。4.3 MLIR、Flang 与 LLVM 生态的未来llvm-project里还有两个我认为值得留意的新势力MLIR和Flang。MLIR 的目标是提供多层级中间表示它不像 LLVM IR 那样直接面对机器码而是允许你定义适合自己领域的 DSL 层级再一层层降到 LLVM IR 或直接降到底层硬件。现在不少 AI 芯片编译器、GPU 编译器都在用 MLIR 做底层可以说是编译器基础建设里的新基建。Flang则是 Fortran 语言的现代前端。虽然 Fortran 听起来古老但科学计算和数值模拟领域它仍然活跃而且flang让 Fortran 代码能和 LLVM 生态无缝协作享受和 C/C 一致的优化和工具链能力。对于普通开发者不必急着深入这两个子项目但了解它们的存在有助于你看懂行业趋势。比如当你看到某条新闻说某厂商发布了基于 MLIR 的编译器框架时你就能立刻反应过来它背后本质上还是llvm-project这个大仓库里的技术扩散。5. 常见问题与排查技巧实录5.1 构建太慢或内存爆掉:先学会这几个参数llvm-project的构建体积和资源消耗新手通常没有心理准备。全量构建 Debug 类型 全目标架构在没有 ccache 的情况下第一次构建花 1 到 2 小时很正常。内存方面Debug 构建比 Release 更可怕链接几个大工具时 32GB 也未必舒坦。我建议按这个顺序优化构建类型选Release而不是Debug日常学习用不到调试符号Release 构建更快、链接产物更小。用-DLLVM_TARGETS_TO_BUILDX86削减目标后端。用-DLLVM_ENABLE_PROJECTSclang削减子项目。用-DLLVM_PARALLEL_LINK_JOBS2控制内存尖峰。有条件就用ccache配置方法是在 CMake 参数里加-DLLVM_CCACHE_BUILDON。做完这些在一个 8 核 16G 的机器上构建一个基础 clang时间通常能压到 20 到 40 分钟。加上ccache后后来的增量构建往往只要几分钟。5.2 IR 无法解析版本不匹配是第一大坑LLVM IR 在每个版本之间并不保证完全兼容尤其是不稳定的 pass 名称和指令格式。你在网上找到的.ll文件如果是用 LLVM 14 生成的用 LLVM 15 的opt去读经常会出现类似expected instruction opcode的报错。这方面的教训是遇到opt解析 IR 失败先别怀疑自己的代码有没有写错先确认 IR 的生成工具和读取工具是不是同一个 LLVM 版本。我的惯例是在本地构建目录里固定一套clang和opt不再去用系统自带的那些。如果你经常做跨版本实验可以在文件名里标上版本号比如hello_llvm15.ll省得日后混淆。5.3 llvmpipe 渲染异常时的排查思路碰上 llvmpipe 相关问题时很多人第一反应是重装 Mesa但通常没用。正确顺序是先确认是不是真的在用软件渲染再查环境变量最后再考虑组件升级。优先检查glxinfo | grep OpenGL renderer输出里如果出现llvmpipe说明当前确实是软件渲染。然后看看有没有不慎设置了LIBGL_ALWAYS_SOFTWARE1这是强制走软件渲染的开关如果不需要就unset掉。再确认虚拟化或容器环境是否真的暴露了 GPU 设备比如/dev/dri是否存在、权限是否够。如果确认需要软件渲染并且性能特别差再来看 3.3 节说的llvmpipe (LLVM 15.0.7, 256 bits)日志。位宽是否达预期、LLVM 版本是否过旧都会直接影响渲染速度。比如你在新 CPU 上还看到 128 bits就要检查 Mesa 本身是否开启了足够新的架构支持或者容器镜像里的 Mesa/LLVM 是不是太旧了。5.4 常见问题速查表现象大概率原因处理建议构建到链接阶段机器卡死内存不足链接任务并发过高加-DLLVM_PARALLEL_LINK_JOBS2降低-j参数用opt -passes...报错pass 名不存在或版本不匹配确认 opt 和生成 IR 的 clang 同为 LLVM 15llvmpipe日志显示 128 bitsCPU 不支持 AVX2或虚拟化屏蔽了相关指令检查 CPU 型号和虚拟化配置或接受较低性能容器里图形程序黑屏缺少显示设备或 DRI 权限检查/dev/dri添加设备映射和必要权限clang提示找不到头文件没有配置--gcc-toolchain或系统里缺少 libc 头文件安装基础编译依赖或用发行版提供的 clang 包对照使用 C API 编译报错API 与你查看的文档版本不一致以llvm/include/llvm/下头文件注释为准6. 实操总结与个人经验偏方我个人的建议是学习llvm-project不要贪多一次只做一件事。先跑通构建再跑 IR再试opt再试着写一小段 CodeGen。每件事之间隔上一天或者更久也没关系让知识自然发酵。因为我见过太多人一上来就奔着写编译器去结果卡在 CMake 配置上耗光了所有热情。另外一个经验是有问题优先看官方文档和源码目录里的示例。LLVM 的文档质量在开源项目里算不错但更可靠的其实是llvm/examples、clang/examples和头文件注释。网络上的博客很多已经过时版本一换API 和参数就变了参考价值大幅缩水。最后分享一个小技巧把build/bin添加到 PATH 之后一定要分清楚你调用的clang是系统自带还是自己构建的。两个版本的 clang 混用最典型的表现就是链接时找不到符号或者头文件路径错乱。我的习惯是在~/.bashrc里用单独的环境变量指向自建工具链只在实验 shell 里export PATH/path/to/build/bin:$PATH。这样既方便又不会污染日常开发环境。LLVM 这个项目越往里走越觉得它像一片海。llvmpipe那行日志只是海面上一朵浪花但顺着它潜下去你会看到一个由 IR、pass、后端、JIT 组成的庞大而有序的水下世界。希望这篇文章能帮你往前迈出第一步。
返回列表