聊聊 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 Manager(NPM)
LLVM 15 起默认启用 New Pass Manager(NPM)。NPM 的核心变化是 Pass 不再以模块为单位全局注册,而是通过 Pass Builder 按管线配置组合。自定义 Pass 的注册方式也因此变了——从RegisterPass宏改为在 Pass Builder 的扩展点中注入。
NPM 的扩展点包括PipelineEarlySimplification(IR 层早期简化后)、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.addRequired<SlotIndexes>(); AU.setPreservesAll(); MachineFunctionPass::getAnalysisUsage(AU); } bool runOnMachineFunction(MachineFunction &MF) override { auto *Indexes = &getAnalysis<SlotIndexes>(); bool Changed = false; DenseMap<Register, 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-only=regalloc打印寄存器分配日志;-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 编译选项(如
-march=native、-ffast-math)实现; - 目标架构已有成熟的调度模型和指令选择策略;
- 团队缺乏 LLVM 源码级调试能力。
自定义 Pass 应该是“针对性优化”的工具,而不是通用编译优化的首选。
5. 总结
LLVM 后端是从 IR 到机器码的核心翻译过程,包含指令选择、寄存器分配和机器码级调度。IR 层优化无法感知的微架构约束(如寄存器压力、指令延迟),需要在后端 Pass 中弥补。
做自定义 Pass 时,选对扩展点、利用现有分析结果(如 SlotIndexes、LiveIntervals)、通过插件机制热加载、做多架构回归测试,这些都是基本功。编译器后端优化是性能优化的“最后一公里”,理解其内部机制,对系统级性能工程师来说,确实是个硬功夫。