ARTICLE DETAIL

资讯详情

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

深入LLVM核心:从IR、Pass到源码构建与软件渲染实践

深入LLVM核心:从IR、Pass到源码构建与软件渲染实践 如果你用过clang编译过东西那你其实已经和 LLVM 打过交道了。我第一次真正打开llvm-project这个仓库不是因为好奇而是在一次给图形栈做软件渲染调试时发现llvmpipe打出的渲染器字符串里带着LLVM 15.0.7, 256 bits。不搞清楚这套工程是怎么把着色器变成机器码的后面根本没法继续。后来我把整个项目源码拉下来从构建、IR、优化 Pass 到后端生成完整跑了一遍才感觉“LLVM”这三个字母终于变成了一个可以操作的系统。这篇东西我不想写成官方文档的复述而是按我实际摸过的路线把llvm-project里最值得理解的部分拆开项目结构、IR 与 Pass 体系、15.0.7 的源码构建、如何写一个自带插件 Pass以及 llvmpipe 里那个 256 bit 向量宽度到底是怎么回事。适合两类人一类是刚接触编译器、想找个真实项目入门的同学另一类是在工作中被 LLVM 树里的目录和各种 Pass 名字劝退的开发者。1. 为什么说LLVM项目值得每一个工具链开发者仔细读一遍1.1 它已经不是一个“虚拟机”llvm-project这个名字很容易误导人。早年 LLVM 确实是 Low Level Virtual Machine 的缩写但现在的项目早就不是传统意义上的虚拟机而是一套编译基础设施全家桶。仓库顶层通常能看到这些子目录llvm是核心库和工具链优化器、clang是 C/C/Objective-C 前端、lld是链接器、compiler-rt是运行时库、libcxx/libcxxabi是 C 标准库实现还有mlir、polly、flang这些更垂直的组件。很多人第一次 clone 完会懵我到底该读哪块我的建议是先分清“前端、中端、后端”这条主链。前端负责把源代码变成中间表示中端就是 LLVM 的优化器后端负责把优化后的中间表示变成目标机器汇编或 object 文件。你在终端敲clang的时候实际操作的是“前端 中端 后端”这条流水线所以 LLVM 项目里真正最核心的代码反而不是某个工具目录而是llvm/lib/IR、llvm/lib/Transforms、llvm/lib/CodeGen这几个库目录。1.2 从一段C语言代码进入llvm-project的完整旅程用一段最简单的代码体会整条链路int add(int a, int b) { return a b; }保存为add.c然后依次执行clang -O1 -S -emit-llvm add.c -o add.ll opt -passesdefaultO2 add.ll -S -o add.o2.ll llc add.o2.ll -o add.s第一步得到的是 LLVM IR 文本第二步把 IR 放进优化管线跑一遍第三步由后端生成汇编。很多同学一上来就盯着llvm/lib/CodeGen里的汇编指令表其实容易迷路。更聪明的做法是先读 IR 和 Pass 的 Test 文件比如llvm/test/Transforms下面一堆.ll文件用opt一跑就能看到变换前后差异。我当时就是这么入门的把测试文件里的RUN:指令复制到命令行改参数观察输出比直接读源码快得多。1.3 仓库目录的阅读顺序建议如果你是从零开始读llvm-project建议按llvm/docs/GettingStarted.rst之外的个人经验来排顺序先看llvm/lib/IR理解Module、Function、BasicBlock、Instruction这几个核心类的关系。再看llvm/lib/Transforms/Utils和llvm/lib/Transforms/Scalar感受一个简单的优化是如何遍历 IR 并修改指令的。接着看llvm/lib/CodeGen/SelectionDAG或llvm/lib/CodeGen/GlobalISel了解指令选择的大思路。最后再看clang/lib/CodeGen你会惊讶地发现Clang 的 CodeGen 和 LLVM 核心的关系其实没有想象中那么神秘它把一个 C 的 AST 翻译成对应的 IR 指令。这个顺序的核心逻辑是“先学会读 IR再学改 IR最后才关心如何把 IR 变成机器码”。直接从后端看容易陷入 TableGen 的海洋怀疑人生。2. LLVM IR与Pass体系读懂优化器的工作原理2.1 IR的三种形态与文本阅读LLVM IR 有三种表示方式内存中的对象结构、bitcode.bc、可读文本.ll。三者的关系很像代码的“内存对象、编译产物、源代码”。日常调试用文本形态最多clang -S -emit-llvm add.c -o add.ll llvm-as add.ll -o add.bc llvm-dis add.bc -o add.back.llllvm-as把文本变成 bitcodellvm-dis是逆向过程。用clang -O1生成的add.ll大概是这样的define i32 add(i32 %a, i32 %b) { %add add nsw i32 %b, %a ret i32 %add }注意到%a和%b是虚拟寄存器%add是一次性赋值的结果。这就是 SSA静态单赋值形式每个变量只被赋值一次后续所有使用都直接引用这次定义。这种形式的好处是 use-def 链非常清晰一个值从哪里来、被谁消费分析时不用像看命令式代码那样追踪到变量被覆盖的每一处。2.2 SSA和phi需要跨过的第一道门槛理解 SSA 之后大部分人的下一个坎是phi指令。想象一个if-else合并后的值同一个变量在不同分支里被赋了不同的值但返回时只有一次使用。SSA 要求每个变量只能定义一次于是合并点需要一个特殊节点来处理分支来源。实际生成的 IR 里会有phientry: %cond icmp slt i32 %a, 0 br i1 %cond, label %then, label %else then: %x.0 add i32 %a, 1 br label %merge else: %x.1 sub i32 %a, 1 br label %merge merge: %x phi i32 [ %x.0, %then ], [ %x.1, %else ] ret i32 %xphi的意思是如果控制流是从%then块进来的当前值取%x.0如果是从%else块进来的取%x.1。不要被这个名字吓到它只是 SSA 规则下的“必经之路”。写 Pass 的时候经常要处理 phi尤其是做指令删除或下一条指令插入时必须注意phi节点的位置只能在基本块开头。2.3 新的Pass管理器优化管线是怎么串起来的LLVM 15 已经默认使用 New Pass Manager。Pass 大体分两种分析 Pass 和变换 Pass。分析 Pass 只负责收集信息比如LoopAnalysis会告诉你一个循环有哪些基本块变换 Pass 会修改 IR比如InstCombine、GVN、LoopUnroll。变换 Pass 内部通常也会调用分析 Pass拿到信息后决定改还是不改。命令行里最常用的就是opt -passesdefaultO2 add.ll -S -o add.o2.ll这里的defaultO2是一个预先定义好的 Pass 流水线。它内部包括函数内联、循环展开、公共子表达式消除、死代码删除等一长串优化顺序不是随便排的。比如InstCombine通常放在循环变换之前因为先做局部简化能减少后续 Pass 的工作量。如果自定义 Pass一个很常见的坑是把手写 Pass 插到了错误位置导致和后续优化产生冲突。更稳妥的做法是先用opt -print-after-all观察管线里每个 Pass 前后的 IR再把自定义 Pass 插入到合适位置。3. 在真实机器上从源码构建LLVM 15.0.7完整过程与踩坑3.1 构建前必须想清楚的三件事第一次从源码构建 LLVM不要像无头苍蝇一样直接敲cmake。先想清楚三件事第一构建目标是什么。如果只是为了搞懂 IR 和写 Pass只需要opt、llc、clang这几个二进制如果需要玩链接器再在LLVM_ENABLE_PROJECTS里加lld。全量构建llvm-project需要非常长的时间尤其在没有足够内存的机器上链接阶段很容易被 OOM 杀掉。第二要不要启用 Assertions。我建议第一次构建打开-DLLVM_ENABLE_ASSERTIONSON因为编译器代码里的断言能帮你提前发现很多“看起来能跑但实际不对”的问题尤其写自定义 Pass 时断言能在 IR 损坏时及时报警。第三哪些后端目标要包含进去。如果只是本机实验-DLLVM_TARGETS_TO_BUILDX86就够了。如果想交叉编译玩 RISC-V 或 WebAssembly再加对应 target。这里的坑在于目标太多会导致构建时间翻倍而且大多数初学阶段根本用不到。3.2 CMake参数说明与构建命令我当时的构建命令是这样的git clone --depth 1 --branch llvmorg-15.0.7 https://github.com/llvm/llvm-project.git cmake -S llvm-project/llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;AArch64;ARM;RISCV;WebAssembly \ -DLLVM_ENABLE_ASSERTIONSON \ -DLLVM_INCLUDE_TESTSOFF \ -DLLVM_PARALLEL_LINK_JOBS2 ninja -C build clang lld opt llc几个参数的作用-G Ninja用 Ninja 构建系统比 Makefile 快而且默认支持并行。-DCMAKE_BUILD_TYPERelease编译优化后的发布版。如果想调试 Pass可以改成Debug但编译产物会大很多、链接更慢。折中是RelWithDebInfo。-DLLVM_PARALLEL_LINK_JOBS2限制并行链接任务数。这个我强烈建议加上否则ninja默认按核数并行链接内存不够时直接被杀。-DLLVM_INCLUDE_TESTSOFF第一次构建不需要跑测试能省不少时间。构建完后工具都在build/bin下。用build/bin/clang --version应该能看到clang version 15.0.7build/bin/llvm-config --version会输出15.0.7。3.3 我踩过的三个构建坑第一个坑是内存不足导致链接失败。早期我图省事直接ninja -C build没加LLVM_PARALLEL_LINK_JOBS机器 16G 内存跑了一会儿clang链接阶段就被 Linux OOM Killer 杀掉。解决办法就是限并行链接任务数或者先编译opt、llc这种小工具不急着编全量。第二个坑是LLVM_ENABLE_PROJECTS里的项目相互依赖。比如想在 15.x 里启用flang需要先启用mlir并注意版本匹配。如果只是做工具链老老实实只放clang;lld能少踩很多依赖坑。第三个坑是 CMake 缓存。第一次配参数时漏了某个 target第二次想补上直接在 build 目录里重新跑cmake有时候因为缓存导致新参数没生效。我的经验是大版本间换参数宁可删掉 build 目录重新配置也不要贪图省事。毕竟编译基础工具链本来就是一次性的成本不值得在缓存问题上反复折腾。3.4 构建产物的正确打开方式很多人构建完只记得clang其实build/bin/下的opt、llc、llvm-config、llvm-dis每一个都是实验好帮手。在构建目录外需要用到库路径时可以用build/bin/llvm-config --cxxflags --ldflags --libs core拿到编译参数。这个命令在写 Pass 插件时尤其有用后面会频繁用到。一个小建议把build/bin加入PATH时最好使用绝对路径或显式前缀不要和系统自带的旧版本 LLVM 工具混在一起。有一次我在 shell 里直接把 build/bin 排到最前结果系统里某个依赖旧 LLVM ABI 的软件莫名其妙崩了后来一查是LD_LIBRARY_PATH被我污染了。环境变量的坑比代码本身的坑更难排查。4. 自定义优化Pass用llvm-project扩展自己的编译器4.1 从零写一个function pass仅仅读 Pass 和跑opt还不够真正让我对llvm-project有掌控感的是写了一个自定义 Pass 并通过插件的方式加载。这个流程能验证你对 IR、PassManager 和工具链构建的理解是不是真的到位。先准备一个最简单的 Function Pass目的是统计每个函数里的指令数量这里不修改 IR所以分析后返回PreservedAnalyses::all()是合理的#include llvm/IR/Function.h #include llvm/IR/Instructions.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { struct InstructionCounterPass : public PassInfoMixinInstructionCounterPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { unsigned Count 0; for (auto BB : F) for (auto I : BB) Count; errs() Function F.getName() has Count instructions\n; return PreservedAnalyses::all(); } }; } // namespace PassPluginLibraryInfo getPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, InstructionCounterPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name count-instructions) { FPM.addPass(InstructionCounterPass()); return true; } return false; }); }}; } extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getPassPluginInfo(); }这段代码的核心是两件事一个实现 Pass 逻辑的run方法以及一个导出llvmGetPassPluginInfo符号的入口函数。opt在加载插件的时候会找到这个入口拿到注册信息然后把count-instructions这个命令行名称和我们的 Pass 关联起来。4.2 接入录制Pass管线的两种方式写完后用llvm-config提供的参数编译成动态库clang -shared -fPIC -fno-rtti \ $(llvm-config --cxxflags) \ InstructionCounterPass.cpp -o InstructionCounterPass.so \ $(llvm-config --ldflags --libs core)这里-fno-rtti是因为 LLVM 默认关闭 RTTI插件也要保持一致否则类型信息可能对不上。接着用opt加载opt -load-pass-plugin./InstructionCounterPass.so \ -passescount-instructions \ add.ll -S -o add.count.ll如果LLVM_LIBRARY_DIR没有加到动态库搜索路径可能会出现 “libLLVM-15.so 找不到” 的错误用export LD_LIBRARY_PATH$(llvm-config --libdir)可以解决。另一种接入方式是直接把源文件放到llvm/lib/Transforms/Utils目录里改对应 CMakeLists重新编译opt。这种静态集成适合做深度改造因为你的 Pass 能访问内部更多接口缺点是每次改动都要重新编译整个opt实验成本较高。我个人的习惯是实验阶段用插件确定逻辑要长期保留时再迁到源码树里。4.3 验证优化结果与调试技巧自定义 Pass 最大的敌人不是编译错误而是“看起来跑通了但 IR 被改坏”。LLVM 有一系列的调试工具opt -print-before-all -print-after-all打印每个 Pass 运行前后的 IR适合观察自定义 Pass 本身和后续 Pass 的真实效果。opt -verify-each在每个 Pass 运行后调用verifyModuleIR 一旦非法会立刻报错。llvm-diff比较两个.ll文件的差异适合验证一个 Pass 是否只是做了预期的修改。还有一点需要特别注意如果你在 Pass 里修改了 IR但错误地返回了PreservedAnalyses::all()分析 Pass 的结果可能被当成仍然有效后续优化拿到过期信息后产生错误代码。判断准则很简单完全没有动任何 IR才能返回all()只要修改了指令或基本块保守起见返回PreservedAnalyses::none()让 PassManager 重新计算分析结果。写 Pass 时我也建议用最终会出现在 Test 里的场景来验证而不是只用一个手写的add.ll。因为真实 IR 里会有 phi、多条边、未合并的基本块Pass 很容易在这些边界上翻车。我早期写过一个小试穿的“把函数名打印出来”的实验看起来没问题但放到真实项目里一跑遇到空函数、declare 声明、弱符号等情况就乱了。逐步用FileCheck写回归测试能帮你把这类问题提前暴露。5. llvmpipe与256 bit向量LLVM在软件渲染里的实际落地5.1 软件渲染器为什么会把LLVM版本打在脸上如果你在虚拟机、云服务器或者没有独立显卡的机器上运行glxinfo大概率会看到类似这样的输出OpenGL renderer string: llvmpipe (LLVM 15.0.7, 256 bits)我第一次看到LLVM 15.0.7, 256 bits这串东西时第一反应是某个驱动把版本信息写在了渲染器名称里。实际上这是 Mesa 的llvmpipe软件渲染器在初始化时打印的识别字符串。llvmpipe是 Gallium 架构里的一个软件渲染驱动它不做硬件的光栅化而是把 GLSL/Vulkan 着色器编译成宿主机的 CPU 指令靠多线程和 SIMD 指令来模拟 GPU 管线。这个编译动作底层就是 LLVM 的 JIT。括号里的LLVM 15.0.7是 Mesa 在运行时链接到的 LLVM 版本256 bits是 llvmpipe 当时选择的 SIMD 向量宽度。为什么是 256 而不是 128 或 512因为这取决于运行机器的 CPU 特性。如果 CPU 支持 AVX2寄存器宽度是 256 bitllvmpipe 就会选择这个宽度来生成向量化代码如果只支持 SSE2宽度就会退到 128 bit。这串字符串等于把 LLVM 后端的能力直接暴露在了用户面前。5.2 LLVM如何生成256 bit的SIMD代码llvmpipe 的工作方式简单说就是把一段高级着色语言翻译到 LLVM IR再让 LLVM 选择目标机器上的最佳向量指令。你不用真的去读 Mesa 源码用llc就能感受这个过程。比如一个简单函数输入四个float返回它们的和define float sum4(4 x float %v) { %v1 extractelement 4 x float %v, i32 0 %v2 extractelement 4 x float %v, i32 1 %v3 extractelement 4 x float %v, i32 2 %v4 extractelement 4 x float %v, i32 3 %s1 fadd float %v1, %v2 %s2 fadd float %s1, %v3 %s3 fadd float %s2, %v4 ret float %s3 }用llc -mattravx2生成 X86 汇编你会看到它把4 x float放进了xmm或ymm寄存器。这里的-mattravx2相当于告诉后端“目标 CPU 支持 AVX2 特性”。如果去掉这个参数后端只能用 SSE 指令生成的向量宽度就是 128 bit。实际项目中CPU 特性不是靠手写参数而是由TargetMachine初始化时传入的MCSubtargetInfo决定。llvmpipe 检测到本机支持 AVX2就在创建 LLVM TargetMachine 时把这个特性加进去LLVM 后端在指令选择阶段就会倾向使用 256 bit 的 YMM 指令。所以那个256 bits不是 LLVM 的固定值而是“LLVM CPU 特性 目标代码生成策略”共同作用的结果。5.3 从后端视角看TargetDescription的几份关键文件如果你对“LLVM 怎么知道 AVX2 是 256 bit”这个问题好奇可以去看llvm/lib/Target/X86目录。里面最劝退的是.td文件它们是 TableGen 写的目标描述。但不要慌核心只需要抓住几条线X86.td定义 X86 这一整个 target 的总体结构包括有哪些子目标特性。X86InstrInfo.td描述指令的语义和机器编码。X86Subtarget.cpp根据 CPU 或-mattr参数决定哪些特性开关被打开。-mattravx2里的avx2在 X86 后端里会映射到一块对应的SubtargetFeature。指令选择器看到 IR 里的向量类型和avx2特性开启就会匹配到 AVX2 指令而不是 SSE 指令。这个过程听起来玄实际就是查表匹配IR 的4 x float加一个fadd在X86InstrInfo.td里可以匹配到VADDPS这类指令。想深入了解的读者不妨自己跑一句llc -mattrhelp运行后LLVM 会列出该 target 支持的所有特性开关。这是理解后端能力边界最快的方式比直接读.td文件轻松得多。5.4 对想做JIT/软渲染的团队的建议llvmpipe 这种用 LLVM JIT 做软件渲染的思路对其他需要“运行时生成高性能代码”的团队很有参考价值。我自己在类似场景里总结过几个经验第一不要把 LLVM Context 当成便宜资源。每次创建 Context 和 Module 都有不小开销JIT 场景下要尽量复用。llvm::orc::LLJIT这类高层 API 是为了这种场景设计的。第二向量宽度选择和 CPU 特性检测要提前做好。不要在运行到热点路径时才临时去查 CPU 特性应该在 JIT 编译前就确定TargetOptions和SubtargetFeatures否则同一份着色器代码在不同机器上会生成差异很大的机器码。第三调试 JIT 生成的代码时打开-debug-only...或者保留 bitcode/汇编输出往往比在生成的机器码里打断点更高效。LLVM 有llvm-objdump、llc -filetypeasm这些工具配合-print-after-all能看到每一层优化做了什么。如果你当初也是因为llvmpipe (LLVM 15.0.7, 256 bits)这行字对 LLVM 产生了好奇我建议不要停在glxinfo这一步。花一个周末把llvm-project构建出来亲手写一个 Pass再用llc看看向量代码是怎么生成的。读源码和跑实验是完全两回事尤其是你一边看 IR 一边刷后端.td文件的时候很多概念根本不用背自然就进脑子了。最后再分享一个小技巧在实验目录里固定好llvm-config的路径别真的依赖系统 PATH 里的老版本工具否则你辛苦写的插件在 ABI 不兼容问题上折腾到崩溃。那是另一个很长的故事了。
返回列表