拓冰建站拓冰建站
首页 / 资讯中心 / 正文

从llvmpipe到自定义Pass:llvm-project构建与编译器开发实战

如果你最近在编译某些开源图形软件或者看过glxinfo的输出大概率见过这么一行字OpenGL renderer string: llvmpipe (LLVM 15.0.7, 256 bits)再往上一层的构建日志里往往还有一个目录叫llvm-project。很多人在这一步就被劝退了这个仓库动辄几个GB编译又慢到底在干什么其实llvm-project是整个LLVM生态的代码总仓Clang、LLD、libc、compiler-rt这些名字全在这里。它解决的问题非常底层又非常通用不管是C/C编译器、Rust编译器还是各种GPU驱动和软件渲染器都在用它把“人能读懂的代码”变成“机器能跑的指令”。这篇文章我会从llvm-project的仓库结构说起重点拆解llvmpipe和256 bits这两个看起来神秘的词汇到底是什么然后给出一套本地构建llvm-project和编写自定义Pass的完整实操流程。适合那些想真正走进编译器开发、或者被渲染管线日志里的“llvmpipe”困扰过的朋友读完你应该能自己上手动手验证一遍。1. 从llvm-project开始认识这个仓库的真实规模1.1 名字是历史内核是模块化LLVM这个名字来自 Low Level Virtual Machine也就是底层虚拟机但这个称呼在今天已经不太准确了。它起源于十多年前的一个博士项目最初确实是想做虚拟机相关的技术后来逐渐演化成了一套编译器基础框架核心词不是“虚拟机”而是“基础设施”。现在大家提起LLVM更多指的是这套用来构建编译器的工具链和库。llvm-project这个仓库就是把全套东西打包在一起的代码库。你可能会问为什么叫project而不是简单的llvm因为LLVM早就不是一个单一组件了。它现在至少包含下面这些部分llvm/核心仓库包含优化器、目标代码生成、汇编器、链接器等clang/C、C、Objective-C的编译器前端lld/一个非常快的链接器libc/C标准库实现compiler-rt/运行时库比如地址消毒器AddressSanitizerflang/Fortran编译器前端polly/多面体优化框架这些子项目放在同一个仓库里管理叫 monorepo单一代码仓库。很多人以为llvm-project只是一个“大一点的Git仓库”实际动手之后才明白它是一整套可以自由组合的乐高积木。你编译C代码可以用clang链接可以用lld支撑它们的是同一个核心库libLLVM。这就是为什么很多语言编译器都选择基于LLVM做后端因为不用重复造轮子后端代码生成直接复用整套成熟方案。1.2 Monorepo结构下各目录在干什么我第一次克隆llvm-project的时候光看目录名就懵了。这里我根据自己的经验整理一个最简单的心智模型可以把LLVM看成一条生产流水线。源代码C/C, Rust, Swift... ↓ 前端解析Clang等 LLVM IR中间表示 ↓ 优化器opt Pass 优化后的LLVM IR ↓ 后端SelectionDAG/GlobalISel 目标机器码x86, ARM, RISC-V...llvm-project里的每个目录其实对应这条流水线上的一道工序。clang/是前端负责把源码翻译成LLVM IRllvm/lib/Transforms是各种优化Pass负责分析、改写IRllvm/lib/Target是后端负责生成具体CPU架构的机器码。理解了这个分工遇到问题就不会一头扎进源码里乱翻至少能判断问题出在前端、优化器还是后端。从LLVM 15.0.7这个版本来看它的发布时间在2023年初属于一个比较稳定但不算新的版本。很多发行版自带的都是这个系列所以你在日志里看到这个版本号非常正常。对学习来说版本不是越新越好15版本资料多、踩坑贴多遇到问题反而好查。2. 读懂“llvmpipe 15.0.7, 256 bits”这行字2.1 当软件也要渲染llvmpipe到底是什么如果你手头没有独立显卡或者跑在一台云服务器上执行glxinfo常常会看到llvmpipe这个渲染器名。它到底是什么简单说llvmpipe是 Mesa 3D 库里的一个软件渲染驱动。显卡能快速画三角形是因为GPU里有大量并行计算单元在同时处理顶点和像素。但没有独显的机器也必须有个备选方案于是就用CPU去模拟这部分工作。CPU的计算能力虽然不像GPU那样专为图形设计但在LLVM的加持下它能做到“把图形着色程序在运行时编译成CPU原生的高效指令”。关键在于Mesa 在运行时会拿到一份着色器代码比如顶点着色器、片段着色器。这份代码不能直接在CPU上跑需要翻译成机器码。这个翻译工作就是由LLVM完成的。llvmpipe把着色器交给LLVM做JIT编译每一次渲染调用执行的其实是动态生成的机器码而不是一个简单的解释器循环。这也是它叫“llvm”pipe的原因——底层用的是LLVM的JIT能力。2.2 256 bits意味着什么从SIMD到自动向量化日志里的256 bits指的是LLVM在生成代码时使用的向量宽度。这背后是CPU的SIMD指令集也就是单指令多数据。普通指令一次处理一个数据比如两个数相加SIMD指令一次可以处理一批数据。x86-64平台上一代代的SIMD指令集演进如下指令集寄存器宽度一次能处理的数据量SSE128 bits4个32位浮点数AVX256 bits8个32位浮点数AVX-512512 bits16个32位浮点数所以llvmpipe (LLVM 15.0.7, 256 bits)的意思就是在这台机器上llvmpipe 检测到CPU支持 AVX2 指令集于是让 LLVM 生成256位向量的机器码一次操作能同时算8个浮点数据。在软件渲染的像素片段处理场景里这一步提升非常巨大。同样是混合8个像素的颜色用SSE需要分两组指令用AVX一条就能干完。这就是LLVM自动向量化的一个直观应用。普通开发者不需要手写SIMD专门指令只要告诉LLVM“你可以在哪些代码里大胆地用向量指令”它会在编译时根据目标CPU特性自动做向量化展开。我们在llvmpipe里看到的256 bits正是这套能力在实际图形渲染中的体现。2.3 LLVM 15.0.7在项目里的定位很多不了解的人以为llvmpipe和LLVM 15.0.7绑得很死其实它们是松耦合的关系。Mesa在构建时链接了某个版本的LLVM库版本号跟着LLVM走。如果你自己从源码构建Mesa并且链接了LLVM 16那么这里的日志就会变成LLVM 16.x.x。为什么会有人关心这个版本号因为llvmpipe在不同LLVM版本上的渲染性能差异可能非常明显。新版LLVM的向量化优化更强同一个着色器程序用新版本生成的机器码可能更快。而且某些OpenGL特性是否支持和软件渲染器后端的能力也挂钩。所以看到版本号至少能判断出当前环境的软件栈新旧程度。3. 本地构建llvm-project的完整实操3.1 改好CMake参数再动手很多新手第一次构建llvm-project会直接执行默认的cmake ..结果等到编译到一半才发现缺这个少那个或者内存直接爆了。构建前一定要先把CMake参数想清楚。我用的是一套非常保守但适合日常开发学习的配置专门构建核心框架加Clanggit clone --depth 1 --branch llvmorg-15.0.7 https://github.com/llvm/llvm-project.git cd llvm-project mkdir build cd build cmake -G Ninja ../llvm \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_ASSERTIONSON \ -DLLVM_INCLUDE_TESTSOFF \ -DLLVM_INCLUDE_EXAMPLESOFF这里每个参数都是有讲究的-DCMAKE_BUILD_TYPERelease生产Release版本没有调试信息编译出的二进制更快更小。如果要做LLVM源码调试则需要用Debug或RelWithDebInfo但耗时和磁盘占用会成倍增加。-DLLVM_ENABLE_PROJECTSclang表示除了核心库之外还要构建Clang前端。这是最常用的搭配。如果只研究优化器甚至可以只要核心不加任何子项目。-DLLVM_TARGETS_TO_BUILDX86只生成x86后端。默认会构建所有架构的后端如ARM、AArch64、RISC-V、PowerPC磁盘和编译时间会暴涨。只在x86上实验就只保留X86能省下一大笔时间。-DLLVM_ENABLE_ASSERTIONSON开启断言。对开发调试很有用因为很多问题会在断言里提早暴露而不是到运行的时候莫名其妙崩溃。构建之前建议你确认一下磁盘空间Release版全量构建大概需要15到25GB如果开了Debug翻倍都有可能。内存方面只要不是特别老旧的机器8GB内存配合Ninja多任务编译基本可以接受。3.2 构建、验证、运行参数配好之后开始编译ninja -j$(nproc)-j$(nproc)表示使用所有CPU核心并行编译。这一步是真正考验机器的时候。我当时在8核16线程的机器上只构建clang加核心库大约用了20多分钟。如果构建所有后端轻松一小时起步所以裁剪目标架构真的很值。编译完成后可以在build/bin/下看到一堆可执行文件。验证安装是否正常./bin/clang --version如果能输出clang version 15.0.7之类的内容说明你已经拥有了一套自己从源码构建的完整工具链。再看一眼核心库./bin/llc --versionllc是LLVM的静态编译器它专门负责把LLVM IR变成目标机器码。这是一个非常常用的底层工具写后端或者分析汇编时经常用到。验证一个最简单的编译流程echo int main() { return 0; } hello.c ./bin/clang hello.c -o hello ./hello echo $?一个能编译并运行的标准C程序就这样跑通了。你可能会问这和系统自带的gcc有什么区别区别在于你手里拿着的这套工具链每个环节都可以被替换、被分析、被调试这才是自己做编译器开发的基础。3.3 资源占用与构建耗时的心里预期所有第一次构建llvm-project的人都会踩同一个坑低估了资源消耗。我整理了一个常见情况表供你对照参考配置项Release X86 ClangRelease 全架构Debug X86 Clang源码大小约1.5GB约1.5GB约1.5GB磁盘占用15GB左右30GB以上25GB以上编译时间8核20-30分钟1小时以上40分钟以上链接内存峰值约3GB4GB以上4GB以上真正的瓶颈往往在最后的链接阶段。LLVM的大量库会生成巨大的共享库或者可执行文件这一步对内存的消耗很大。所以我总是建议开Ninja而不是MakeNinja能更好地并行调度任务而且增量编译速度也快得多。另一个建议不要用-j直接设成核心数的两三倍内存小的话会直接被多个链接进程打爆。可以用-j$(nproc)稳妥起步如果内存只有8GB手动改成-j4或-j6反而更快因为不会因为内存交换而拖慢整体速度。4. 写一个自己的Pass进入LLVM修改的第一课4.1 Pass机制和优化管道说到真正有门槛的部分就是给LLVM写自定义Pass了。什么叫Pass简单理解它是一段“遍历程序中间表示并做某种变换或分析的代码”。优化器里跑的各种优化比如死代码消除、常量传播、循环展开全都是一个个Pass。Pass的运行顺序叫优化管道Pipeline。在LLVM 15时代默认用的是新PMNew Pass Manager。管道由很多Pass组成它们一个接一个地处理LLVM IR。你可以在管道里插入自己的Pass让它和内置优化一起跑也可以单独运行。为什么要学习写Pass因为LLVM的整个优化框架就是围绕Pass机制设计的。不管你想做自定义的代码分析、性能优化还是学术上的编译实验最终都要落到写一个Pass上。理解了Pass也就理解了LLVM优化器的灵魂。4.2 用新PM写一个插件这里我分享一个最小可运行的Pass插件功能很简单统计每个函数里的基本块数量然后输出到dbgs()调试流。代码逻辑短小但整个插件框架是完整的包括Pass定义、插件入口、PassBuilder回调注册。#include llvm/IR/Function.h #include llvm/IR/BasicBlock.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { struct BlockCounterPass : public PassInfoMixinBlockCounterPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { unsigned Count 0; for (const BasicBlock BB : F) { Count; } dbgs() [block-counter] F.getName() : Count basic blocks\n; return PreservedAnalyses::all(); } }; } // namespace extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, BlockCounterPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name block-counter) { FPM.addPass(BlockCounterPass()); return true; } return false; }); }}; }有几个点需要解释不然你抄代码也不知道在干什么PassInfoMixinBlockCounterPass是新PM的基类模板通过静态多态实现了Pass接口。run(Function F, FunctionAnalysisManager AM)是Pass真正干活的地方入参是函数的IR表示。所有需要分析的内容比如循环结构、支配关系都得通过AM去请求。PreservedAnalyses::all()表示这个Pass不会改变任何分析结果。因为我们只是读取数据不做任何修改所以告诉优化器“你不用重新计算分析信息”。registerPipelineParsingCallback做的事情是当你在命令行指定-passesblock-counter时LLVM能识别到这个自定义Pass名。llvmGetPassPluginInfo是插件动态库的导出入口相当于告诉LLVM“这里有一个插件可以加载”。4.3 编译插件并验证有了源码下一步是编译成动态库。我这里假设你构建出来的工具链里有llvm-config它在build/bin/下clang -shared -fPIC -fno-rtti -stdc17 \ BlockCounterPass.cpp \ -o BlockCounterPass.so \ $(./build/bin/llvm-config --cxxflags --ldflags --libs)如果llvm-config不在系统路径就显式指定这个构建目录里的路径。命令中的-fno-rtti是必须的因为LLVM库本身不带RTTI插件也必须保持一致否则链接和运行都会报错。接着我们生成一个测试用的IR文件cat test.c EOF int add(int a, int b) { if (a 0 b 0) return a b; return a - b; } EOF ./build/bin/clang -S -emit-llvm test.c -o test.ll然后加载插件运行Pass./build/bin/opt -load-pass-plugin./BlockCounterPass.so \ -passesblock-counter \ test.ll -o /dev/null如果一切正常你能在输出里看到类似[block-counter] add: 4 basic blocks因为会产生短路求值所以在IR层面函数add会形成4个基本块。这个例子虽然简单但它完整跑通了一个Pass从编写、编译、加载到执行的全过程。有了这套环境你可以尝试加一些更复杂的分析逻辑比如统计指令条数、识别死循环这些都是定制优化器的入门练习题。5. 构建与开发中常见的四个坑5.1 磁盘空间和内存不够这是出现频率最高的问题而且通常都是在编译了十几分钟之后才爆非常难受。我自己的习惯是构建前先检查df -h free -h如果根目录剩余空间不到30GB就先去做清理。另一个技巧是别把build目录放在/tmp下面很多Linux发行版的/tmp是内存文件系统或最小磁盘分区放在那里几乎必炸。构建目录最好和源码放在同一个大分区并且遵循“源码一个目录构建一个目录安装一个目录”的三目录原则方便日后彻底删除重建。5.2 git clone llvm-project 太慢llvm-project仓库体积巨大完整clone可能要几个GB网络不好的时候非常痛苦。好在这个需求通常不需要完整历史记录用--depth 1浅克隆就能解决大部分问题git clone --depth 1 --branch llvmorg-15.0.7 \ https://github.com/llvm/llvm-project.git这样只会拉到最新版本的代码。如果后续想更新到新版本再git fetch --depth 1 --branch llvmorg-16.0.0然后git checkout即可。只有在你自己要提交代码时才需要考虑拉全历史。5.3 Pass插件加载失败这是写Pass时必然会遇到的坑。用opt加载插件时如果报Could not load library首先要区分两种情况一是路径写错或权限不对二是LLVM版本不匹配。前者好排查把路径换成绝对路径试试即可。后者更隐蔽插件是用一个版本的LLVM头文件编译的加载它的opt是另一个版本ABI对不上就会加载失败。我试过用系统自带的LLVM 14头文件编译插件然后用自己构建的LLVM 15opt去加载结果直接报错。解决办法很干脆插件必须用同一个llvm-config对应的工具链和头文件来编译。也就是说你在用哪个opt就尽量用哪个构建目录里的clang和llvm-config。5.4 符号和命令版本不匹配构建完成后如果系统里有另一个版本的LLVM很容易出现clang命令指向多个版本的问题。比如你系统里装了llvm-14本地构建的是llvm-15终端输入clang --version显示的可能是系统老版本。这种问题最好的规避方式是不要直接使用裸命令而是用构建目录里的绝对路径。需要长期使用就把路径导出到环境变量里export PATH/path/to/llvm-project/build/bin:$PATH另外留意动态库的加载位置。LLVM工具在运行时依赖libLLVM-15.so如果系统环境的LD_LIBRARY_PATH指向了老版本库新工具启动时可能加载错库。可以用ldd ./build/bin/clang检查实际加载了哪些动态库确认路径没有指向错误的地方。编译器开发这条路上构建问题是第一道门槛Pass开发是第一道技术门槛越过去之后整个体系就豁然开朗了。我自己第一次跑通自定义Pass的时候其实就改了一行代码但那种“整个编译流程可以被自己控制”的兴奋感到现在都还记得。如果你也正在编译llvm-project或者对着llvmpipe日志犯迷糊希望这篇实操记录能帮你省下几个小时的折腾时间。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门