
大概在两三年前我第一次真正打开 llvm-project 的主仓库时不是因为它多有名而是在一份性能分析报告里看到 llvmpipe 的 CPU 占用高得离谱。当时我以为是图形栈哪里出了 bug一路追进去才发现驱动里竟然藏着一个 JIT 编译器——它正拿着 LLVM 生成的机器码在拼命算着色器。那一刻我才意识到LLVM 的“本体”从来不是某个编译器前端而是一整套让其他人能快速造出编译器和代码生成器的基建。这也是我决定认真啃一遍 llvm-project 的起点。如果你也曾经被“LLVM 是一个编译器”这句话误导或者遇到过 llvmpipe 这类依赖 LLVM 的运行时组件却说不清它到底怎么工作的情况这篇文章就是写给你的。我会结合 llvmpipe 和 LLVM 15.0.7 时代的技术细节把 llvm-project 到底是什么、它和 llvmpipe 的关系、怎么读源码、怎么构建跑通一次性说清楚。内容会比较多但每一步都是可以直接落地的实操经验。1. 当“LLVM”被装上机器时大家装的到底是什么1.1 名字叫 LLVM本体却是编译器基础设施我在社区看到很多朋友的第一个困惑永远是“LLVM 和 Clang 到底是什么关系”。尤其是搜索“llvm”时最常见的结果要么是 apt 里的llvm-15包要么是构建日志里那一长串 clang 命令。结果就形成了一个很常见的误解LLVM 等于 Clang。实际上把时间线拉回到 2000 年Chris Lattner 在 UIUC 启动这个项目时LLVM 的全称是 Low Level Virtual Machine目标是做一套跨平台的、基于静态单赋值SSA形式的编译器基础设施。所谓基础设施就是说它提供的不是“一个直接能用的编程语言编译器”而是很多可组合的库前端负责把源代码变成中间表示IR中端负责对 IR 做优化后端负责把优化后的 IR 变成目标机器码。后来这个组件越来越多名字里的 Virtual Machine 含义也变了干脆就叫 LLVM不再去展开那个全称。你从 GitHub 拉下来 llvm-project 仓库会看到一大排目录这才是项目全貌llvm/核心库、优化器、目标后端、汇编器、链接器以及大多数命令行工具opt、llc、llvm-dis等。clang/C/C/Objective-C 前端也是大多数人最早接触的入口。lld/一个高性能链接器几乎所有大型构建系统里都能看到它的身影。libc/libcabi/C 标准库及其 ABI 层的实现。compiler-rt/运行时支持库包括 sanitizer 和 profiling 组件。mlir/面向编译器、AI 框架和高性能计算的编译器框架。flang/Fortran 前端。polly/基于多面体模型的循环优化器。lldb/调试器。所以你说“装了 LLVM”最严谨的表述是“装了 LLVM 项目中的一套组件”。从发行版视角看Debian/Ubuntu 会把最新的几个 release 版本连同 clang 一起打成一个整体名字叫llvm-toolchain这也是很多人以为 LLVM 就是 Clang 的来源。1.2 从 LLVM 15.0.7 与 256 bits 看 llvmpipe 热词背后的弦外之音搜索引擎里和“llvm”一起出现的热词是llvmpipe (llvm 15.0.7, 256 bits正好是一个信息量很大的组合。llvmpipe 是 Mesa 图形驱动栈里的软件渲染器它使用 LLVM 做运行时代码生成而且会利用机器支持的 SIMD 宽度。这里 256 bits 意味着它检测到 CPU 支持 AVX/AVX2 向量指令于是把 8 个 32 位浮点float打包成一个 256 位的向量去并行计算。LLVM 15.0.7 则是 2023 年初左右的补丁版本功能与 15.0.0 一致修了一批稳定性问题。为什么 llvmpipe 非要和 LLVM 绑在一起如果读者接触过老的软渲染实现可能会想起来早期的软件渲染器是手写大量整数/浮点运算循环把所有顶点变换、片段着色全部写在 C 代码里。这样的实现虽然可移植但效率上不去尤其是现代 GPU 着色动辄几十条指令软渲染要一条条解释执行性能会非常难看。llvmpipe 的做法是把 GLSL 或 SPIR-V 着色器翻译成 LLVM IR然后让 LLVM 的优化和代码生成后端针对当前 CPU 生成机器码。相当于你把“画一个三角形”这个过程变成了一个在 CPU 上原生执行的二进制函数。这样一来编译器后端里所有针对不同微架构的向量化、指令调度、寄存器分配策略都被复用到了图形场景里。一个可以类比的场景是 OpenGL 在 GPU 上的流水线GPU 驱动会把着色器源码编译成 GPU ISAllvmpipe 则是把它编译成 x86/ARM 的 SIMD ISA。差一步之遥但思路一致。1.3 从“装了个命令”到“使用一个平台”的认知转换真正开始使用 llvm-project 时你会发现它对你的价值取决于你的身份。如果你只写 C/C 应用那 LLVM 的价值主要在 Clang 的命令行体验和编译诊断信息上。如果你做编译器或运行时相关开发那你会频繁调用它的库和 Pass。拿我自己来说我现在把它当做一个“编译技术工具箱”要用 CI 覆盖率分析用llvm-cov要看二进制指令流用llvm-objdump要在运行时生成代码用 LLVM ORC JIT要写自定义代码分析就用 LLVM Pass 读取 IR 里的每一条指令。它在这些场景下的角色有点像标准库之于 C 程序是底层的、不可替代的一套支撑。所以我的建议是不要再把 llvm-project 想象成一个简单的开源软件应该把它想象成一个编译器领域的 Linux 内核资源丰富、模块复杂、坑也多但一旦你摸清它的组织方式收获会远超预期。2. 从 llvmpipe 到 LLVM JIT软渲染器为什么需要“即时汇编”2.1 llvmpipe 的渲染管线与 LLVM 的接合点在 Mesa 的架构里llvmpipe 属于 Gallium3D 驱动模型下的一个软件 pipe。它会接收经过状态跟踪器state tracker处理后的绘制命令再自行实现光栅化和着色计算。对每一类着色器顶点着色器、片段着色器、几何着色器等llvmpipe 会调用lp_build_tgsi_llvm之类的模块把 TGSI 表示转换成 LLVM IR随后触发 LLVM 的优化与代码生成。这个过程里最关键的接合点是“函数”的粒度。llvmpipe 并不像 GPU 驱动那样生成一个大而全的着色器二进制而是把片段处理拆分成多个回调函数如lp_jit_linear、lp_jit_align等每个回调负责一块特定形式的光栅化输出。LLVM JIT 生成了这些函数的机器码后llvmpipe 就可以像调用普通函数指针一样调用它们。我自己第一次用 GDB 去打断点看这个过程时最震惊的一点是一切都被 JIT 到了内存里。你在进程里看到的是一段段带调试信息的原生指令却根本没有对应的 .so 文件。这就是 LLVM JIT 的典型体验。2.2 256 bits 意味着什么一路从 SSE 到 AVX2搜索热词里的“256 bits”指的是 llvmpipe 向 LLVM 请求的向量位宽。现代 CPU 一般提供 128 位 SSE/NEON、256 位 AVX/AVX2以及服务器端的 512 位 AVX-512。LLVM 后端会依据-mattr或 TTITargetTransformInfo提供的 CPU 特性来决定每个向量运算能塞进多少数据。llvmpipe 在像素着色时会做所谓的“向量化”把相邻的多个像素通常 4 个或 8 个当作一个向量整体计算。每个像素可能包含 RGBA 四个通道每个通道一个 float那么一个像素就是 128 位。四个像素放在一起就是 512 位如果是两个像素则是 256 位。llvmpipe 15.x 时代更多看到的是 256 位路径这就是为什么日志里会打印llvmpipe (LLVM 15.0.7, 256 bits)。需要特别注意向量宽度不是越大越好。LLVM 后端做矢量化和数据对齐时越宽的向量对访存的约束也越高。llvmpipe 内部有一套启发式逻辑会在lp_build_set_optimization里决定默认使用 4 像素还是 8 像素宽度。如果没检测到 AVX它会退到 128 位如果检测到 AVX2则会尝试 256 位。这种决策逻辑不是 llvmpipe 自己拍脑袋定的而是它查询 LLVM 的TargetTransformInfo接口拿到的数据。所以当你看到256 bits时代表 LLVM 认为当前处理器的 ALU 宽度可以安全支持这个向量长度。2.3 一条着色器在 LLVM 里经历了什么理论描述可能有点抽象我们直接看一次简化版的代码生成流程。llvmpipe 把一个片段着色器的 TGSI 指令转成 IR大致会产生类似这样的结构我用文字描述实际 IR 会复杂很多入口参数是片段的像素坐标、内插后的 varying 值。每个 varying 通常被 load 成一个向量。接着是一串浮点运算如乘加、点积、pow、sqrt。最后把gl_FragColor写到一个内存缓冲区。如果把这段 IR 交给llc会发现 LLVM 对pow这类指令的处理非常灵活在存在快速近似指令时可生成近似指令在不满足条件时则调用__pow_finite之类的库函数。这个决策体现在后端指令选择阶段是 llvmpipe 性能直观受益的地方。我做过一个小测试用一个本地 glmark 场景的片段着色器在 llvmpipe 下分别开启不同的向量化等级。结果在 256 位 AVX2 路径下帧耗时比 128 位 SSE 路径低了大约 40%——这正是 LLVM 向量化和后端调度优化的结果。如果 llvmpipe 不用 LLVM而是用 C 手写软渲染很难凭空实现出这种针对当前 CPU 的适配能力。2.4 MCJIT 和 ORC两代 JIT 引擎的分工聊到 LLVM JIT一定会碰到 MCJIT 和 ORC 这两个术语。llvmpipe 在较老时代使用 MCJIT后来迁移到了 ORC。二者的本质区别是特性MCJITORC模块生命周期一次编译整个 Module编译后难独立卸载支持模块级添加、卸载、重用惰性编译不支持必须一次性编译支持通过LazyCallThrough等机制线程安全性编译时对同一个 JIT 实例有竞争风险内置并发设计适合多线程API 复杂度相对简单更灵活但需要更多配置对 llvmpipe 这类场景ORC 的好处主要在动态性和隔离性上。比如在虚拟化场景里一个 llvmpipe 进程可能同时持有多个上下文context每个需要自己的生成代码块ORC 能把每个着色器的编译单元纳入独立线程管理不会互相卡死。如果你只想通过 API 在代码里“临时生成一个函数”ORC 的llvm::orc::LLJIT类是最直接的入口。它的基本用法是#include llvm/ExecutionEngine/Orc/LLJIT.h #include llvm/IR/IRBuilder.h #include llvm/IR/Module.h using namespace llvm; using namespace llvm::orc; void jitAddFunction(LLJIT J, Module M) { auto ThreadSafeM std::make_uniqueThreadSafeModule( std::move(M), std::make_uniqueLLVMContext()); cantFail(J.addIRModule(std::move(ThreadSafeM))); }这段代码的含义是先创建一个线程安全模块里面装着 IR再把整个模块提交给 LLJIT它就是帮你完成代码生成的“编译器劳务派遣公司”。对应到 llvmpipe那就是它把每个着色器编译单元变成一个 ThinLTO 模块或单个模块然后丢给 JIT 生成原生函数。3. 啃 llvm-project 源码前的目录地图与基础设施3.1 顶层目录的作用域llvm/ 才是“核心中的核心”很多人拉下仓库后直接打开llvm/就开始漫无目的地找文件容易被include/llvm、lib/CodeGen、lib/Target这些一层层套娃目录搞晕。这里有一个高效的解读顺序llvm/include/llvm/IR/所有 IR 相关接口包括Function、BasicBlock、Instruction、Module等类。这是理解一切优化的前提。llvm/lib/IR/IR 的实现包括Verifier.cpp等。想看指令如何被创建、验证来这里。llvm/lib/Transforms/各种 IR 级优化与分析的实现如 InstCombine、GVN、LoopVectorize。llvm/lib/CodeGen/从 LLVM IR 到机器指令的完整后端流程包括 SelectionDAG、指令调度、寄存器分配。llvm/lib/Target/针对具体 CPU 架构的后端实现。X86、AArch64、RISCV 等都在这里。如果想理解 llvmpipe 从 LLVM 得到的帮助至少要把llvm/lib/Transforms/Vectorize/和llvm/lib/Target/X86/两处过一遍。前者决定了 llvmpipe 的向量化优化是否有效后者决定了最终的 256 位指令能否正确选出来。3.2 TableGenLLVM 的“生成器生成器”阅读后端代码时会接触到大量.td文件。TableGen 是 LLVM 特有的一套声明式语言用于描述指令、寄存器、调用约定等结构化信息再由llvm-tblgen工具自动生成对应的 C 代码。以 X86 后端的X86InstrAVX.td为例里面会定义类似这样的伪代码声明一条VADDPS指令它接受两个向量操作数并产生一个结果还附带一个匹配 pattern。代码生成器在指令选择阶段就是根据这个 pattern 去匹配 IR 的。对专业读者来说TableGen 是“元编程”能力的重要体现。你改一条指令定义生成的 C 代码会跟着变你加一个新 CPU 特性相关的调度模型、寄存器约束都会自动刷新。这种“减少重复劳动”的设计在 llvm-project 里几乎随处可见。llvmpipe 之所以能快速跟上新 CPU 指令集也因为 Mesa 侧只需要调用 LLVM不需要自己重写一遍指令选择逻辑。3.3 Pass 基础设施不只是 Clang 在看JIT 也在看LLVM 的 Pass 系统是优化器的核心。你在命令行里执行opt -passesloop-unroll它会加载对应 Pass 并处理 IR。在 llvmpipe 运行时这个优化过程并不在命令行而是通过 PassBuilder 在 JIT 初始化阶段配置好。你可能使用过llvm::PassBuilder它的常规用法大致如下llvm::LoopAnalysisManager LAM; llvm::FunctionAnalysisManager FAM; llvm::CGSCCAnalysisManager CGAM; llvm::ModuleAnalysisManager MAM; llvm::PassBuilder PB; PB.registerModuleAnalyses(MAM); PB.registerCGSCCAnalyses(CGAM); PB.registerFunctionAnalyses(FAM); PB.registerLoopAnalyses(LAM); PB.crossRegisterProxies(LAM, FAM, CGAM, MAM); llvm::ModulePassManager MPM PB.buildPerModuleDefaultPipeline( llvm::OptimizationLevel::Os);这段代码做完之后MPM就是一个带有一整套默认优化的模块 Pass 管理器。OptimizationLevel::Os在 llvmpipe 中很常见因为软渲染对函数体积比较敏感降低代码体积有利于缓存命中。理解了这套机制你就能明白为什么 LLVM 是“框架”所有优化器都是可插拔的。llvmpipe 只需要决定用哪套编译选项剩下的一切由PassBuilder自动装配。4. 从零构建 llvm-project 的实操记录与避坑清单4.1 构建前的硬件与磁盘规划不要一开始就 defaultLLVM 是全方向优化编出来的巨型项目构建时的内存和磁盘消耗相当可观。我试过一次在 16GB 内存的机器上执行cmake -DCMAKE_BUILD_TYPEDebug结果链接器因为并行任务过多直接 OOM。这是新手最容易踩的第一个坑。根据我的经验一个 Release 版全量构建 llvm-project含 clang、lld、compiler-rt的磁盘占用在 30~60GB 之间Debug 版会更大所以至少准备 80GB 空余空间。内存方面16GB 内存建议限制并行任务数比如-j4不要用-j$(nproc)。32GB 以上可以放开到-j8左右。构建时还要注意不要把build目录放在网络盘或加密文件系统上否则配置阶段的 CMake 探测会非常慢链接阶段也容易出诡异错误。4.2 值得抄作业的构建配置这里分享一份我常用的 CMake 配置适合追求“占空间合理调试体验好”的读者比如想读 llvmpipe 相关 JIT 代码cmake -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld;compiler-rt \ -DLLVM_ENABLE_RUNTIMESlibcxx;libcxxabi \ -DLLVM_TARGETS_TO_BUILDX86;AArch64;RISCV \ -DLLVM_USE_LINKERlld \ -DLLVM_PARALLEL_COMPILE_JOBS8 \ -DLLVM_PARALLEL_LINK_JOBS2 \ ../llvm-project/llvm几个参数的解释LLVM_USE_LINKERlld用 lld 做链接可以减少大量内存消耗尤其是 Debug 版。LLVM_TARGETS_TO_BUILD只构建你关心的目标架构能大幅缩短构建时间。像我关注类 llvmpipe 在 X86 上的表现就只需要 X86保留 AArch64 和 RISCV 是出于后端对比研究。LLVM_PARALLEL_LINK_JOBS链接是内存大头限制成 2 可以有效防止 OOM。配置完成后ninja -j8 clang lld llc opt llvm-dis如果只想先跑通 llvmpipe 相关的环境其实只需要clang和opt/llc这几个工具。4.3 构建失败的常见原因与处理手段我整理了当时踩过的最典型的几类坑基本都能对号入座症状原因对策undefined reference to llvm::createInstructionCombiningPass目标工具没有链接对应 Pass 库检查 CMakeLLVM_ENABLE_PROJECTS是否包含相关组件或在代码里正确#include和链接ld.lld: error: undefined symbol: main链接顺序问题检查llvm/lib的库依赖顺序通常用llvm-config --libs获取标准顺序fatal error: llvm/IR/IRBuilder.h file not found没把llvm/build/include加入头文件搜索路径用-I$(llvm-config --includedir)或 CMakeinclude_directoriesCMake 卡在下载 LLVM test suite网络或 Git submodule 异常使用-DLLVM_INCLUDE_TESTSOFF跳过测试生成Debug 构建还有一个比较隐蔽的坑LLVM 官方默认启用了断言Debug 模式下运行任何 pass 都会检查 IR 合法性一旦你写的 pass 构造了非法的 IR比如插入指令的位置不对会直接触发Assertion failed。这个报错信息对定位问题帮助很大但很多人第一反应会误以为是自己的代码编译错误。其实这是 LLVM 的自我检查机制属于设计意图。4.4 Debug 与验证如何确认 llvmpipe 确实用了你构建的 LLVM如果你希望调试 llvmpipe 和 LLVM 的交互不能在系统自带的 llvmpipe 运行环境里做因为系统 Mesa 链接的是发行版自带的 LLVM。正确做法是自行编译 Mesa并链接到你新构建的 LLVM。配置 Mesa 时典型命令是meson setup build \ -Dllvmenabled \ -Dshared-llvmfalse \ -Dtoolsdri,llvmpipe \ -Dllvm-path/path/to/your/llvm/build/bin/llvm-config关键在于shared-llvmfalse这会让 Mesa 静态链接到你指定的 LLVM而不去加载系统共享库。否则调试器里经常出现“断点落在libLLVM-15.so里但你编译的源码版本对不上”的情况。然后你可以用lp_rast相关断点或者设置LP_DEBUG环境变量来观察 llvmpipe 的 JIT 行为。LP_DEBUG1会打印一些调试信息LP_NUM_THREADS1可以强制单线程这些在排查并行 bug 时非常有用。5. 沿着 llvmpipe 进 LLVM 代码生成一条完整的阅读路径5.1 从 llvmpipe 源码到 LLVM API顺着调用关系追前面提到 llvmpipe 把 TGSI 转成 LLVM IR这部分代码在 Mesa 目录src/gallium/drivers/llvmpipe/下的lp_*系列文件里。你搜索LLVMBuildCall、LLVMBuildRet、LLVMPositionBuilderAtEnd这些接口就能看到 IR 是如何逐条被创建的。在 llvmpipe 里核心头文件是lp_bld_const.h和lp_bld_tgsi.h其中封装了大量创建 IR 的工具函数。如果读者想理解怎么组合 LLVM 的IRBuilder产生复杂控制流这些代码是最好的教材因为是生产级 C 代码不是玩具示例。从LLVMBuildCall2这类 C API 进入你会发现它最终会走到llvm-project/llvm/include/llvm/IR/IRBuilder.h里的 C 方法。这个对应关系非常干净C APIllvmpipe 调用C 对应类/方法LLVMContextCreatellvm::LLVMContextLLVMModuleCreateWithNamellvm::ModuleLLVMBuildLoad2IRBuilder::CreateLoadLLVMBuildCall2IRBuilder::CreateCallLLVMGetInsertBlockIRBuilder::GetInsertBlock当你读到这里时建议把llvmpipe和llvm-project两个仓库同时打开一个看调用方一个看被调用方。这种交叉阅读的方式比单纯啃任何一边都要高效。5.2 值得精读的三个模块Vectorize、X86 backend、CodeGen如果你已经了解了 JIT 流程我建议把精力集中在下面三块第一llvm/lib/Transforms/Vectorize/。llvmpipe 最大的性能来源是数据并行所以这里的 LoopVectorize 与 SLPVectorizer 值得仔细看。你可以在opt -passesloop-vectorize下把一段普通循环转成向量代码对照 llvmpipe 的着色器 IR 去理解 LLVM 如何决定一个循环能否向量化比如检查循环不变量、内存依赖、reduction 模式。第二llvm/lib/Target/X86/。这里能看到 256 位指令的取舍。重点是X86ISelLowering.cpp、X86InstrAVX.td、X86TargetTransformInfo.cpp。当你看到TTI.getRegisterBitWidth(true)返回 256 时就明白 llvmpipe 里的256 bits到底来自哪里。第三llvm/lib/CodeGen/SelectionDAG/。JIT 过程中指令选择的基本算法在这里。SelectionDAGISel会把合法 IR 节点组合成 target dag再通过匹配 pattern 映射到实际指令。如果你想知道 llvmpipe 里那个fadd 8 x float是怎么变成vaddps的答案就在这一层。5.3 想要二次开发从哪里“插一脚”最合适很多读者看完会有个冲动我也想给 llvmpipe 或 LLVM 加个功能。从哪里入手比较好如果你的兴趣在 LLVM 后端和指令选择我建议先写个简单的llvm::TargetTransformInfo子类在某个架构上自定义向量成本模型然后观察 llvmpipe 的向量化行为会不会改变。因为 TTI 接口和 pass 基础设施是解耦的你不需要改动大量后端代码就能看到 IR 的变化。如果你的兴趣在 llvmpipe 本身可以关注给 llvmpipe 增加一个新 intrinsic。这需要在llvmpipe的lp_bld_tgsi_action.cpp里注册 action把 TGSI opcode 映射到对应 LLVM intrinsic再保证后端有对应指令选择规则。读到这里你会对“前端-中端-后端”的分工理解得更深刻。对不做编译器的人来说这段阅读路径最大的收获是你以后再遇到任何“软件渲染、动态代码生成、SIMD 优化”的应用都能看懂它到底调用了 LLVM 的哪一层能力。6. 我实际跑通 llvmpipe LLVM JIT 之后的几点体会一个模拟器或软渲染应用的性能瓶颈往往不在 C 业务逻辑而在编译器后端有没有帮你把热点函数变成真正高效的机器码。所以我建议所有准备深入 llvmpipe 和 llvm-project 的读者先学会看 IR再看机器码最后才看 C 源码。这个顺序找对了后面路会顺很多。还有一个经验是调试时善用环境变量和日志。像我前面提到的LP_DEBUG、LP_NUM_THREADS还有LLVM_DEBUG宏都是生产环境中隐藏的“金钥匙”。不要一上来就开 GDB 单步走因为 JIT 时代代码不存在很多断点会扑空。先用日志确定是哪个阶段优化、指令选择、还是寄存器分配出了问题再去针对性动态分析效率高得多。