聊聊 LLVM 后端:从 IR 到机器码的优化与 Pass 开发

聊聊 LLVM 后端:从 IR 到机器码的优化与 Pass 开发 聊聊 LLVM 后端从 IR 到机器码的优化与 Pass 开发1. IR 优化与机器码的“断层”我们常把 LLVM 编译管线简单概括为“前端—优化器—后端”。前端翻译 IR优化器在 IR 层面做与目标无关的变换后端负责生成机器码。但 IR 层的优化往往无法感知寄存器压力、指令延迟和流水线冒险而这些恰恰决定了最终代码的性能。举个实际的例子IR 层的循环展开Loop Unroll理论上能减少分支开销但如果目标架构寄存器有限比如 ARM Cortex-A53 只有 16 个通用寄存器展开后增加的活跃变量会导致寄存器溢出Spill。溢出带来的访存延迟完全可能抵消甚至超过展开带来的收益性能回退可达 20% 以上。要解决这种“优化断层”就得深入 LLVM 后端的 Pass 调度机制。后端 Pass 不仅负责指令选择和寄存器分配还能在机器码层面执行 IR 层做不了的微架构优化。下面我们就拆解一下 LLVM 后端的管线并看看怎么开发自定义 Pass 来做针对性优化。2. 后端 Pass 的调度与分类2.1 执行顺序LLVM 后端的 Pass 大致分为 IR 层优化、指令选择、机器码层优化和代码生成几个阶段。graph TD subgraph IR层[IR层优化] IR1[常量传播/死代码消除] IR2[循环优化/向量化] IR3[内联/函数简化] end subgraph 指令选择[指令选择阶段] ISel[DAG指令选择] ISelDAG[SelectionDAG构建与合法化] end subgraph 机器码层[机器码层优化] MI1[机器指令级PEI] MI2[寄存器分配] MI3[Prologue/Epilogue插入] MI4[机器码级调度] MI5[分支松弛/常量池优化] end subgraph 输出[代码生成] MC[MC层汇编/目标文件输出] end IR1 -- IR2 -- IR3 -- ISel -- ISelDAG ISelDAG -- MI1 -- MI2 -- MI3 -- MI4 -- MI5 -- MC style IR层 fill:#e3f2fd,stroke:#1565c0 style 指令选择 fill:#fff3e0,stroke:#e65100 style 机器码层 fill:#fce4ec,stroke:#c62828 style 输出 fill:#e8f5e9,stroke:#2e7d32几个关键阶段指令选择ISel把 LLVM IR 的虚拟操作映射成目标架构的机器指令。LLVM 用 SelectionDAG 做模式匹配通过 TableGen 描述的指令模式来选指令。寄存器分配把虚拟寄存器映射到物理寄存器。物理寄存器不够时就得把部分虚拟寄存器溢出到栈槽。这是性能损失的主要来源之一。机器码级调度寄存器分配后根据目标架构的流水线延迟表重排指令减少流水线停顿。2.2 New Pass ManagerNPMLLVM 15 起默认启用 New Pass ManagerNPM。NPM 的核心变化是 Pass 不再以模块为单位全局注册而是通过 Pass Builder 按管线配置组合。自定义 Pass 的注册方式也因此变了——从RegisterPass宏改为在 Pass Builder 的扩展点中注入。NPM 的扩展点包括PipelineEarlySimplificationIR 层早期简化后、PostOrderFunctionPass函数级优化后、MachineFunctionPass机器码层等。选对扩展点决定了你的 Pass 能访问到哪一层 IR 和哪些分析结果。3. 自定义 Pass 实战消除冗余溢出下面是一个机器码级的自定义 Pass用于检测并消除冗余的栈溢出Spill指令。它在寄存器分配后执行分析溢出/重装Spill/Reload指令对识别可以消除的冗余溢出。// 自定义MachineFunctionPass冗余溢出消除 // 执行时机寄存器分配后PostRA此时溢出指令已插入但尚未最终调度 // 核心思路若某虚拟寄存器的溢出值在重装前未被覆盖则重装可直接使用栈槽中的值 // 无需再次溢出 #include llvm/CodeGen/MachineFunctionPass.h #include llvm/CodeGen/MachineInstrBuilder.h #include llvm/CodeGen/SlotIndexes.h #include llvm/CodeGen/RegisterClassInfo.h using namespace llvm; namespace { struct RedundantSpillElim : public MachineFunctionPass { static char ID; RedundantSpillElim() : MachineFunctionPass(ID) {} void getAnalysisUsage(AnalysisUsage AU) const override { AU.addRequiredSlotIndexes(); AU.setPreservesAll(); MachineFunctionPass::getAnalysisUsage(AU); } bool runOnMachineFunction(MachineFunction MF) override { auto *Indexes getAnalysisSlotIndexes(); bool Changed false; DenseMapRegister, MachineInstr * LastSpill; for (auto MBB : MF) { for (auto MI : MBB) { // 检测溢出指令 if (MI.getOpcode() MF.getSubtarget() .getInstrInfo()-getSpillOpcode()) { Register VReg MI.getOperand(0).getReg(); LastSpill[VReg] MI; } // 检测重装指令 if (MI.getOpcode() MF.getSubtarget() .getInstrInfo()-getReloadOpcode()) { Register VReg MI.getOperand(0).getReg(); auto It LastSpill.find(VReg); if (It ! LastSpill.end()) { MachineInstr *Spill It-second; // 验证溢出和重装之间该寄存器未被重新定义 if (!isDefBetween(Spill, MI, VReg, *Indexes)) { MI.eraseFromParent(); Changed true; } } } // 若该指令定义了VReg则之前的溢出值已过期 for (auto MO : MI.operands()) { if (MO.isReg() MO.isDef()) { LastSpill.erase(MO.getReg()); } } } } return Changed; } private: /// 检查两条指令之间是否存在对指定寄存器的定义 bool isDefBetween(MachineInstr *From, MachineInstr *To, Register Reg, SlotIndexes Indexes) { for (auto It std::next(From-getIterator()); It ! To-getIterator() It ! From-getParent()-end(); It) { for (auto MO : It-operands()) { if (MO.isReg() MO.isDef() MO.getReg() Reg) { return true; } } } return false; } }; char RedundantSpillElim::ID 0; } // anonymous namespace // 通过NPM注册Pass llvm::PassPluginLibraryInfo getRedundantSpillElimPluginInfo() { return {LLVM_PLUGIN_API_VERSION, RedundantSpillElim, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPostMachineFunctionCallback( [](MachineFunctionPassManager MFPM) { MFPM.addPass(RedundantSpillElim()); }); }}; }3.1 编译与集成把自定义 Pass 编译为 LLVM 插件.so文件通过clang -fpass-plugin加载# 编译Pass为共享库 clang -shared -fPIC -o libRedundantSpillElim.so \ RedundantSpillElim.cpp \ $(llvm-config --cxxflags --ldflags --libs) # 使用自定义Pass编译目标程序 clang -fpass-plugin./libRedundantSpillElim.so -O2 target.c -o target3.2 调试与验证LLVM 自带不少调试工具-debug-onlyregalloc打印寄存器分配日志-print-after-all在每个 Pass 后打印 IR 状态llc -view-dag-combine1-dags可视化 SelectionDAG。对于自定义 Pass建议在runOnMachineFunction入口处打印函数名和指令数方便定位性能回归。4. Pass 开发的工程代价4.1 编译时间的叠加每个后端 Pass 都要遍历整个 MachineFunction。Pass 数量增加编译时间线性增长。在大型项目如 Chromium中每加一个后端 Pass全量编译时间可能增加数分钟。所以 Pass 设计要遵循“最小必要分析”原则——只拿必要的分析结果避免触发不必要的依赖链。4.2 正确性与顺序后端 Pass 的执行顺序直接影响正确性。比如冗余溢出消除 Pass 必须在寄存器分配之后执行溢出指令是寄存器分配器插入的但必须在指令调度之前执行调度可能改变指令顺序导致溢出值被覆盖。Pass 间的依赖关系通过getAnalysisUsage声明但隐式的语义依赖如“必须在某 Pass 之后”往往无法通过类型系统表达只能靠文档和测试。4.3 架构碎片化LLVM 支持几十种目标架构指令集、寄存器文件和流水线特性各不相同。一个在 x86-64 上验证通过的 Pass在 AArch64 或 RISC-V 上可能产生错误代码。涉及指令语义假设的 Pass比如“某指令不会修改标志寄存器”必须通过 TableGen 的指令描述验证不能硬编码。4.4 什么时候不该写 Pass以下情况建议别折腾自定义 Pass优化目标能通过 Clang 编译选项如-marchnative、-ffast-math实现目标架构已有成熟的调度模型和指令选择策略团队缺乏 LLVM 源码级调试能力。自定义 Pass 应该是“针对性优化”的工具而不是通用编译优化的首选。5. 总结LLVM 后端是从 IR 到机器码的核心翻译过程包含指令选择、寄存器分配和机器码级调度。IR 层优化无法感知的微架构约束如寄存器压力、指令延迟需要在后端 Pass 中弥补。做自定义 Pass 时选对扩展点、利用现有分析结果如 SlotIndexes、LiveIntervals、通过插件机制热加载、做多架构回归测试这些都是基本功。编译器后端优化是性能优化的“最后一公里”理解其内部机制对系统级性能工程师来说确实是个硬功夫。