ARTICLE DETAIL

资讯详情

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

LLVM实战指南:从仓库构建到Pass编写与向量化优化

LLVM实战指南:从仓库构建到Pass编写与向量化优化 很多人的电脑里其实每天都在跑 LLVM但自己根本不知道。比如你打开系统的渲染信息看到一行llvmpipe (llvm 15.0.7, 256 bits)这个 llvmpipe 背后就是 LLVM 在干活——它是一个纯软件渲染器用 CPU 模拟 GPU 管线而 LLVM 负责把着色器代码编译成高效的机器码也就是那 “256 bits” 的来源。问题来了llvm-project这个仓库到底该怎么看动辄几个 GB 的源码一堆陌生的模块名CMake 配置几百个开关第一次接触的人很容易直接被劝退。我自己也是从一脸懵的状态走过来的中间踩了不少坑后来慢慢摸清楚了这套东西的脉络。这篇内容就围绕 llvm-project 这个仓库结合 15.0.7 这个稳定版本从仓库结构、构建配置、编译流水线、Pass 编写到底层代码生成把我的实战经验完整梳理一遍。1. 先别急着 clone 全量代码llvm-project 的仓库布局与最小检出1.1 这个仓库为什么会这么大llvm-project是 LLVM 的 monorepo 仓库也就是说 LLVM、Clang、LLD、libc、compiler-rt、MLIR、Flang、Polly、LLDB、OpenMP、libclc、clang-tools-extra 这些项目全部放在同一个仓库里管理。这是 2019 年之后 LLVM 社区从独立的 SVN 仓库迁移到 Git monorepo 的结果目的是解决多项目版本同步的问题。很多人第一次 clone 这个仓库看到下载体积就慌了。实际上完整历史记录加上所有子模块轻松超过 2GB如果还带着全部 Git 历史那更是慢得离谱。但绝大多数场景下你根本不需要全量历史也不需要全部模块。这里有个关键认知llvm-project根目录下的llvm/才是真正的“核心项目”——编译器基础设施本身。你平时说的 “LLVM”如果指的不是整个社区项目而是那个能生成机器码的框架那说的就是这里面的llvm/。Clang 在clang/链接器在lld/标准库实现分别在libcxx/和libcxxabi/。1.2 我建议的最小可用检出策略如果你只是想基于 LLVM 15.0.7 做开发或者单纯想读源码完全没必要全量 clone。我推荐这样操作git clone --depth1 --branchllvmorg-15.0.7 https://github.com/llvm/llvm-project.git--depth1表示浅克隆只拉最新一次的提交--branchllvmorg-15.0.7直接切到目标版本 tag。这样源码体积大概在 600MB 到 800MB 之间绝大部分场景够用了。如果你只想研究某个子项目比如只关心 Clang 前端或者只关心llvm/核心可以用 Git sparse-checkout 做目录级过滤git clone --depth1 --branchllvmorg-15.0.7 --filterblob:none --sparse https://github.com/llvm/llvm-project.git cd llvm-project git sparse-checkout set llvm clang lld--filterblob:none是 Git 的 partial clone 特性先把目录结构和 commit 拉下来文件内容按需下载sparse-checkout set指定你实际要用哪些目录。这样最终磁盘占用会小很多。不过我得提醒一句如果你未来打算给 LLVM 提交 patch 或者做完整调试--depth1的浅克隆会缺历史git blame和git log基本不可用那时候还是老老实实全量 clone 比较好。另外构建目录和源码目录一定要分开不要在源码目录里直接 build。我见过有人直接在llvm-project/llvm/下面执行 cmake结果把源码目录搞得乱七八糟后面想切分支都费劲。2. 构建 15.0.7 的 CMake 配置哪些开关值得认真对待2.1 Release 与 Assertions 的搭配默认配置是给库开发者用的LLVM 的 CMake 配置项非常多但真正影响你日常使用的就那么几个。第一个让你迷惑的通常是CMAKE_BUILD_TYPE和LLVM_ENABLE_ASSERTIONS的关系。Debug 构建会强制开启断言速度极慢一个简单的opt二进制体积几个 GB跑起来像老牛拉车。Release 构建默认关闭断言速度快很多。真正的坑在于LLVM 官方默认的 CMake 配置看起来像 Release但如果你不做任何设置CMAKE_BUILD_TYPE为空构建出来的东西性能很差。我一开始就是直接 cmake 后构建跑了整整一晚上出来的clang编译速度还不如系统自带的旧版。我现在的推荐组合很简单cmake -G Ninja -S llvm-project/llvm -B build \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_ASSERTIONSON \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_PROJECTSclang;lldRelease LLVM_ENABLE_ASSERTIONSON是我最常用的搭配。断言能帮你在开发 Pass 时提前发现很多内存越界、类型不匹配的问题而且不会像 Debug 那样慢到无法接受。如果是纯粹部署使用不想被断言拖累那就把LLVM_ENABLE_ASSERTIONS设成OFF。2.2 只构建你需要的 target 和 runtime很多人的第一次构建失败不是因为代码有问题而是因为默认配置要构建的东西太多了。LLVM_TARGETS_TO_BUILD默认是构建所有 CPU 后端包括 X86、ARM、AArch64、RISC-V、PowerPC、MIPS、SystemZ 等所有架构。如果你只是想在 x86 机器上做开发这些全部构建纯粹浪费时间而且容易触发奇怪的编译错误。我一般直接指定X86偶尔加上AArch64做交叉编译实验-DLLVM_TARGETS_TO_BUILDX86;AArch64另一个容易混淆的是LLVM_ENABLE_PROJECTS和LLVM_ENABLE_RUNTIMES。在 15.0.7 这个版本Clang、LLD、Polly 这类“工具链组件”通过LLVM_ENABLE_PROJECTS启用而 libc、libcabi、compiler-rt 这类“运行时库”应该通过LLVM_ENABLE_RUNTIMES启用。我刚上手时看到网上老的教程都在用LLVM_ENABLE_PROJECTSlibcxx;libcxxabi结果在 15 上反复报错后来才发现新的 CMake 逻辑已经把这部分拆到了LLVM_ENABLE_RUNTIMES里。这是版本演进带来的坑看资料时一定要留意对方用的 LLVM 版本。如果你只要 LLVM 核心不需要 Clang那-DLLVM_ENABLE_PROJECTS那一行都可以去掉只构建llvm/下的工具就行比如opt、llc、llvm-as这些构建速度会快很多。2.3 ccache 和并行构建的正确姿势LLVM 是全社区公认的重型 C 项目15.0.7 在单机上全量构建哪怕只构建核心 Clang也动辄需要半小时到一小时以上。所以ccache几乎是必需品。配置方式-DLLVM_CCACHE_BUILDON或者直接在环境变量里设置CCACHE_DIRLLVM 的 CMake 会自动检测到 ccache 并用上。我习惯把 ccache 大小调大一点ccache -M 20G这样多次切换构建配置时能显著提速。并行构建方面Ninja 比 Make 对并行的控制好很多ninja -j 16-j数量不是越大越好LLVM 链接阶段特别吃内存。每个链接任务能吃掉 2GB 到 4GB 内存如果你机器只有 16GB 内存-j 16很容易直接 OOM。我自己的经验是内存容量除以 2 左右的并行度比较稳比如 32GB 内存开-j 16基本没问题。还有一个小技巧如果只是改了一个文件想重新编译不要全部重建直接用 Ninja 的自动依赖追踪它会自己判断哪些需要重编。我见过很多人只要改一行代码就ninja -j 16全量重来白白等十几分钟。3. 从 llvmpipe (256 bits) 反推 LLVM 的编译流水线3.1 llvmpipe 是怎么把 CPU 当成 GPU 用的回到文章开头那个llvmpipe (llvm 15.0.7, 256 bits)。llvmpipe 是 Mesa 里的软件渲染器它没有图形加速硬件全靠 CPU 执行指令来模拟 GPU 管线。为了让像素填充、顶点变换这些运算尽量快llvmpipe 把大量浮点运算组织成 SIMD 向量运算加载到 CPU 的向量寄存器里一次算多个数据。这里的256 bits指的就是 AVX2 的 256 位向量寄存器。一条指令可以同时处理 8 个 32 位浮点数。如果用 SSE 的 128 位寄存器一次只能处理 4 个。LLVM 在这套体系里的工作就是把这些向量计算从一种中间描述翻译成真正的 x86 指令。这个过程本质上和普通 C/C 编译没有任何区别核心都是前端生成中间表示优化然后后端做指令选择、寄存器分配、指令调度。llvmpipe 之所以选择 LLVM正是因为它不需要自己维护一套指令选择器只需要把着色器逻辑翻译成 LLVM IR剩下的事情全部交给 LLVM 后端处理。这套思路在 Mesa 生态里非常常见除了 llvmpipe还有 radeonsi、nvc0 这些硬件驱动也用 LLVM 做着色器编译。3.2 IR 为什么是编译器的通用中间语言如果不用 LLVM你要为每个 CPU 架构写一套编译器后端用了 LLVM你只需要把源码翻译成 LLVM IR之后所有优化和目标代码生成都由 LLVM 帮你搞定。这就好比一个跨国公司在每个国家都要说法语于是大家约定统一说英语——IR 就是编译器领域的“英语”。一个最简单的 C 函数int add(int a, int b) { return a b; }经过 Clang 转成 LLVM IR 后长这样define i32 add(i32 %a, i32 %b) { entry: %add add nsw i32 %a, %b ret i32 %add }你可以看到define、ret这种可读性很强的文本形式。这就是 LLVM IR 的重要特性它既能被人阅读也能被机器快速解析。整个编译流程中所有的优化 Pass 都作用在这层 IR 上这也意味着同一套优化逻辑可以服务于任何前端、任何后端。理解 IR 还有一层实践上的意义你在写调试脚本、观察工具输出、分析编译问题时看到的绝大多数内容都是 IR 级别的而不是汇编级别。如果你连 IR 都看不懂后面无从谈起。我建议阅读 IR 时先抓住三个核心概念BasicBlock基本块、Instruction指令、Function函数理解它们之间的包含关系就能看懂大部分 IR dump 了。3.3 后端从 SelectionDAG 到指令选择的门道IR 只是编译流程的前半段。LLVM 后端拿到 IR 后要先经过一个叫 SelectionDAG 的中间步骤把 IR 指令转换成目标架构的指令选择 DAG再做指令选择、寄存器分配、指令调度、基本块布局最后输出汇编。这个过程对刚接触 LLVM 的人来说非常抽象。我用一个类比IR 是“我想把 A 和 B 加起来”SelectionDAG 是“把这件事拆成更底层的原子操作图”指令选择是“根据目标 CPU 的指令表选一条实际能用的加法指令”。比如在 x86 上add指令可能不止一条有addl、addq还要考虑操作数是寄存器还是内存地址指令选择器就是从这些候选中挑一个代价最小的。对于 llvmpipe 这种软件渲染器来说后端生成向量指令的质量直接决定了性能。同样是加法循环如果没有向量化它可能生成一串标量addss向量化之后变成vaddpsAVX 的 256 位浮点加法一条指令完成 8 个浮点相加。LLVM 的循环向量化 Pass 负责做这种转换而后端的 SelectionDAG 负责保证这些向量操作能在目标架构上找到对应的真实指令。很多人在这一步会想那我是不是要把整个后端代码都读一遍没必要。实际开发中你更多是写一个优化 Pass或者改一个 Target 特性极少需要从头到尾架设一个后端。理解整体流程知道哪一层负责什么遇到问题能定位到对应源码模块这就足够了。4. 写第一个 LLVM Pass新旧 Pass Manager 的兼容与坑4.1 为什么需要 PassPass 是 LLVM 优化和转换的基本单元。你写的每一个编译优化本质上都是一个 Pass它遍历 IR找到某种模式然后做某种变换。LLVM 的优化流水线由几十个 Pass 串联而成常见的-O2就是一组 Pass 的组合。对于想往 LLVM 里加自定义逻辑的人来说写 Pass 是必经之路。比如你想做一个针对某个特定循环结构的优化或者想在编译过程中注入自己的检查逻辑都需要写一个 Pass 然后把它挂到 Pass 流水线上。这里最大的坑是LLVM 在 14 版本之后已经默认使用 New Pass Manager但网上一搜还是大量老教程在用 Legacy Pass Manager。Legacy PM 已经处于半废弃状态新代码不应该再基于它写。4.2 用 New PM 写一个最简单 FunctionPass我来演示一个最简单的 New PM Pass功能是数一下一个函数里有多少条add指令。新建一个源文件比如CountAdd.cpp#include llvm/IR/Function.h #include llvm/IR/Instructions.h #include llvm/Pass.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h using namespace llvm; namespace { class CountAddPass : public PassInfoMixinCountAddPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { int Count 0; for (BasicBlock BB : F) { for (Instruction I : BB) { if (auto *BinOp dyn_castBinaryOperator(I)) { if (BinOp-getOpcode() Instruction::Add) { Count; } } } } errs() Function F.getName() has Count add instructions\n; return PreservedAnalyses::all(); } }; } // namespace然后需要把它注册成 plugin这样opt工具就能加载它extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, CountAdd, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name count-add) { FPM.addPass(CountAddPass()); return true; } return false; }); }}; }把这段代码编成共享库clang -shared -fPIC -stdc17 CountAdd.cpp \ $(llvm-config --cxxflags --ldflags --libs) \ -o CountAdd.so然后用opt加载运行opt -load-pass-plugin./CountAdd.so -passescount-add input.ll -disable-output输出类似Function main has 3 add instructions这里面最容易被忽略的细节是-passescount-add这个字符串必须和registerPipelineParsingCallback里注册的名字完全一致而且 New PM 的-passes参数是分号分隔的 Pass 名称列表比如-passescount-add,instcombine表示先跑count-add再跑instcombine。4.3 新旧 Pass Manager 之间的取舍与常见错误我在从 Legacy PM 迁移到 New PM 时踩过几个具体的坑这里总结一下头文件路径不同Legacy PM 写 Pass 要包含llvm/Pass.hNew PM 需要包含llvm/Passes/PassBuilder.h和llvm/Passes/PassPlugin.h漏了后者会出现链接错误。run 方法签名不同Legacy PM 的 run 接受Function F返回bool表示是否修改了 IRNew PM 返回PreservedAnalyses而且需要额外维护一个“我修改了什么”的集合。如果 Pass 什么都没改要返回PreservedAnalyses::all()如果改了要返回PreservedAnalyses::none()让依赖分析重新计算。依赖分析的方式不同Legacy PM 直接在getAnalysis...()里拿分析结果New PM 必须通过FunctionAnalysisManager AM传入的分析结果接口来获取。如果 Pass 声明了依赖某个分析但流水线上没跑这个分析运行时会直接崩溃。另外很多人一开始直接用opts自带的 Pass 列表跑自定义 Pass结果报错说找不到。原因是opt默认的内建 Pass 不全需要先用-passescount-add显式注册。我建议调试阶段用opt -print-after-all -passescount-add把每个 Pass 执行后的 IR 打印出来能非常直观地看到 Pass 的修改效果。5. 用 opt llc 把编译过程拆开一次向量化实测5.1 一次 clang 背后到底经历了什么大多数人用编译器都是直接clang hello.c -o hello看到可执行文件出来了就完事。但编译器内部其实经历了多级转换。如果你想深入理解 LLVM必须学会把这些阶段拆开看。先准备一个简单的测试文件vec_test.cvoid add_vec(float *a, float *b, float *c, int n) { for (int i 0; i n; i) { c[i] a[i] b[i]; } }第一步用 Clang 生成 LLVM IRclang -S -emit-llvm vec_test.c -o vec_test.ll生成的 IR 里会看到add指令、load、store但还没有向量计算因为优化还没跑。第二步用opt跑优化opt -S -O2 vec_test.ll -o vec_test.opt.ll这时候再看 IR循环大概率已经被向量化了能看到类似4 x float这样的向量类型load也从标量变成了4 x float load。第三步用llc生成目标汇编llc vec_test.opt.ll -o vec_test.s这时候汇编里能看到vaddps这类 AVX 向量指令。整个过程前端Clang负责生成 IR优化器opt负责变换 IR后端llc负责把 IR 变汇编。这三个工具分别对应 LLVM 三个阶段把它们分开用你就能精准定位问题是在哪一层产生的。5.2 256 bits 向量化在 llvmpipe 场景下的实测效果还是用上面这个add_vec函数我实际跑一下opt -O2之后生成的 IR 片段vector.body: %wide.load load 8 x float, ptr %a, align 4 %wide.load17 load 8 x float, ptr %b, align 4 %add fadd 8 x float %wide.load, %wide.load17 store 8 x float %add, ptr %c, align 4这里8 x float就是 8 个 32 位浮点数正好对应 256 位寄存器。这说明循环向量化 Pass 成功地把原来的标量循环转成了每轮处理 8 个元素的向量循环。llvmpipe 的工作方式与此类似把着色器中的像素计算组织成 8 个像素一组交给 LLVM 向量化生成 AVX2 指令实现软件渲染加速。如果你机器不支持 AVX2或者没开对应 target 特性向量宽度会退化成4 x float也就是 128 位 SSE。可以用这个命令强制指定不同的架构特性对比llc vec_test.opt.ll -mattravx2 -o vec_test_avx2.s llc vec_test.opt.ll -mattrsse2 -o vec_test_sse2.s对比两个汇编文件_avx2.s里是vaddps256 位_sse2.s里是addps128 位。这就是llvmpipe (llvm 15.0.7, 256 bits)这行输出背后真正的技术含义Mesa 在运行时检测到 LLVM 15.0.7 可用并且当前 CPU 支持 256 位向量于是选择生成 AVX2 指令来提升软件渲染性能。5.3 调试验证时的几个实用命令很多人卡在“我知道优化原理但不知道怎么验证”这个阶段。我把我平时调试 LLVM Pass 和编译流程最常用的几个命令列出来opt -print-after-all每个 Pass 执行完都把 IR 打印出来适合看优化有没有生效、在哪一步失效。opt -debug-onlyloop-vectorize只看 loop-vectorize 这一个 Pass 的调试信息。-debug-only需要 LLVM 在 Debug 或带断言模式下构建才有完整输出。llc -print-machineinstrsIR 被选成目标指令后的每个阶段都会打印 MachineInstr看后端指令选择情况。llvm-dis把 bitcode 转回 IR 文本llvm-as是反向操作这两个工具在检查.bc文件内容时非常有用。这些工具配合使用基本能覆盖从 IR 到汇编的全链路调试需求。我自己在排查一个向量化未生效的问题时就是用opt -debug-onlyloop-vectorize看到了循环被判定为“指针可能 alias无法安全向量化”然后修改代码加restrict关键字解决的。这类信息如果不看 debug 输出光靠猜能猜一天。6. 源码阅读顺序与我的学习路径建议6.1 推荐的源码阅读起点从小模块开始很多初学者拿到 llvm-project 源码后第一反应是从llvm/lib/IR/开始读因为 IR 是核心。但我的实际经验是一上来啃 IR 文档和 IR 实现非常容易劝退。IR 相关的代码抽象层次很高涉及很多模板和设计模式没有一定基础直接读基本上是看天书。我更推荐的路径是从工具层开始从外向内看。先看llvm/tools/下面那些命令行工具的实现比如opt.cpp、llc.cpp这些文件不长但能看到整个工具的入口和各阶段的调用关系。看完这些你就知道一个 IR 文件是怎么被加载、优化、输出的。接下来可以看llvm/lib/Support/里的基础工具库比如命令行参数解析、文件系统操作、字符串处理。这个模块不涉及复杂的编译算法但能让你熟悉 LLVM 的代码风格和常用数据结构。然后才轮到llvm/lib/AsmParser/这个目录负责把.ll文本解析成内存中的 IR 对象。读一遍这个模块你相当于顺着解析器的思路把 IR 的完整语法和数据结构过了一遍比直接读 IR 定义要直观得多。6.2 结合 llvmpipe 与 Mesa 源码做交叉验证如果你想深入理解 LLVM 在真实项目里怎么被使用我强烈建议结合 Mesa 里的 llvmpipe 源码交叉看。Mesa 的src/gallium/drivers/llvmpipe/目录下有lp_state_fs.c片段着色器状态、lp_bld_arit.c算术运算生成 IR等文件里面就是调用 LLVM C API 生成 IR 的真实例子。这种交叉阅读的好处是LLVM 自身的文档和源码更多是“框架视角”告诉你“可以这样用”而 llvmpipe 的代码是“应用视角”告诉你“实际项目里就是这样调用的”。比如你会看到 llvmpipe 如何在运行时动态生成一个针对当前图形状态的专门函数然后用 JIT 引擎编译并执行这个过程涉及到 LLVM 的ExecutionEngine和MCJIT或ORC接口是纯源码阅读很难理解的部分。我自己在写项目需要用到 LLVM JIT 时就是照着 llvmpipe 的代码一步步仿写出来的。官方示例llvm/examples/HowToUseJIT.cpp太简单实际的 JIT 使用场景里大量细节都在真实项目里才能看到比如模块所有权管理、符号解析、内存权限设置这些坑Mesa 源码里都有答案。6.3 留意版本差异llvm 15.0.7 与周边生态的配套最后提一个重要建议如果你是因为 llvmpipe 或者 Mesa 相关需求来学习 LLVM一定要留意 llvm 15.0.7 与 Mesa 版本的配套关系。不同的 Mesa 版本可能依赖不同的 LLVM 版本LLVM 每年发布一次大版本API 变化剧烈特别是 Pass 接口和新的后端框架跨一个大版本经常直接编译不过。我的习惯是先用系统包管理器装好的 LLVM 版本跑通基础实验确认整个流程没问题之后再考虑自己源码构建指定版本。这样能先把“我的代码逻辑对不对”和“LLVM 构建配置对不对”这两个问题拆开避免同时出错时不知道问题出在哪。在你自己的学习项目里建议把LLVM_VERSION_STRING打印出来或者用llvm-config --version确认当前使用的版本。很多编译错误、链接错误最后查下来都是版本不匹配造成的——你按 LLVM 15 的新接口写代码系统里装的却是旧版本然后各种符号找不到排查半天才发现是版本问题这种经历我相信每个做过 LLVM 开发的人都刻骨铭心。
返回列表