3. 中间表示(IR)优化
聊到编译器优化,中间表示(IR)是个绕不开的话题。我个人觉得,IR 就像是编译器的“通用语言”——前端把源代码翻译成 IR,后端再从 IR 生成机器码。而 LLVM IR,可以说是目前最流行的中间表示之一。今天我们就来聊聊它的基础、优化 Pass,以及如何自己动手写一个 Pass。
LLVM IR 基础:三把钥匙
LLVM IR 有三种形式:内存中的数据结构、可读的文本格式(.ll 文件),以及二进制位码(.bc 文件)。我们平时调试或学习时,主要接触的是文本格式。它有几个核心概念,我习惯称之为“三把钥匙”。
1. 静态单赋值(SSA)形式
LLVM IR 强制使用 SSA 形式。什么意思呢?就是每个变量只能被赋值一次。这听起来有点反直觉——你想想看,我们写代码时经常给同一个变量赋不同的值。但在 SSA 形式下,编译器通过引入 phi 指令来解决这个问题。
举个例子:
; 原始 C 代码
int x = 1;
if (cond) x = 2;
return x;
; 对应的 LLVM IR
entry:
br i1 %cond, label %then, label %else
then:
br label %merge
else:
br label %merge
merge:
%x = phi i32 [ 2, %then ], [ 1, %else ]
ret i32 %x
这里的 phi 指令会根据控制流选择正确的值。嗯,刚开始看可能有点绕,但习惯了就会发现它让数据流分析变得异常简单。
2. 类型系统
LLVM IR 是强类型的。每个值都有明确的类型,比如 i32 表示 32 位整数,float 表示单精度浮点数。还有指针类型 ptr(LLVM 14 之后统一用 ptr 代替了 i32* 这种写法)。
我在项目中遇到过一个问题:有人把 i32 和 i32* 搞混了,结果在内存访问时出了奇怪的 bug。说白了,类型就是 IR 的“安全带”,系好了能避免很多低级错误。
3. 指令集
LLVM IR 的指令集是 RISC 风格的,操作都很基础。比如加法 add、加载 load、存储 store、分支 br 等。每个指令都有明确的语义,没有隐式的副作用。
| 指令类别 | 示例 | 说明 |
|---|---|---|
| 算术运算 | %r = add i32 %a, %b |
整数加法,结果无溢出检查 |
| 内存访问 | %v = load i32, ptr %p |
从指针指向的地址加载 32 位整数 |
| 控制流 | br i1 %c, label %t, label %f |
条件分支,根据 %c 跳转 |
| 类型转换 | %r = zext i8 %a to i32 |
零扩展,将 8 位整数扩展到 32 位 |
IR 优化 Pass:编译器的“魔法”
优化 Pass 是 LLVM 的核心。它们像流水线上的工人,一个个对 IR 进行变换,让代码跑得更快、体积更小。我按功能把它们分成几类。
标量优化 Pass
这类 Pass 主要针对单个基本块或函数内的操作。比如:
- 死代码消除(DCE):删掉那些计算结果从未被使用的指令。我曾经见过一个项目,因为历史遗留问题,有 30% 的代码是死代码,跑一遍 DCE 后性能提升了 15%。
- 常量传播:如果某个变量的值在编译期就能确定,直接替换成常量。比如
int x = 2 * 3;会被优化成int x = 6;。 - 指令合并:把多个简单指令合并成一个。比如
a = b + 1; c = a + 1;可以合并成c = b + 2;。
循环优化 Pass
循环是程序的热点,也是优化的重点。常用的循环 Pass 有:
- 循环不变代码外提(LICM):把循环内每次迭代都不变的计算提到循环外面。比如循环内的
int len = strlen(s);应该只计算一次。 - 循环展开:减少循环控制开销,增加指令级并行度。但要注意,展开太狠会导致代码膨胀,反而影响指令缓存。
- 循环向量化:把标量操作转换成 SIMD 指令。我建议在开启向量化前,先确认目标 CPU 是否支持相应的指令集。
小技巧: 使用 opt -O2 -debug-pass=Arguments 可以查看默认启用了哪些 Pass。我个人习惯先跑一遍 -O2,再根据热点手动调整 Pass 顺序。
内联与函数优化
内联(Inlining)是把函数调用替换成函数体本身。这能消除调用开销,但也可能增加代码体积。LLVM 的内联 Pass 会基于启发式算法做决策——比如函数太小就内联,函数太大就跳过。
我曾经在嵌入式项目中遇到过一个问题:内联了一个看似很小的函数,结果因为该函数被调用了上千次,最终二进制体积暴涨了 40%。嗯,这里要注意,内联不是万能的,需要根据场景权衡。
自定义 Pass 开发:从零开始
有时候,LLVM 自带的 Pass 满足不了我们的需求。比如你想实现一个针对特定硬件的优化,或者想插入一些调试信息。这时候就需要自己写 Pass 了。
Pass 的两种类型
LLVM 支持两种 Pass:
- FunctionPass:每次处理一个函数。适合大多数标量优化。
- ModulePass:处理整个编译单元(一个 .ll 文件)。适合全局分析,比如查找所有函数中的某个模式。
写一个简单的 FunctionPass
假设我们要写一个 Pass,用来统计每个函数中 add 指令的数量。代码大概长这样:
#include "llvm/Pass.h"
#include "llvm/IR/Function.h"
#include "llvm/IR/Instructions.h"
#include "llvm/Support/raw_ostream.h"
using namespace llvm;
namespace {
struct CountAddPass : public FunctionPass {
static char ID;
CountAddPass() : FunctionPass(ID) {}
bool runOnFunction(Function &F) override {
int addCount = 0;
for (BasicBlock &BB : F) {
for (Instruction &I : BB) {
if (isa<BinaryOperator>(&I) &&
cast<BinaryOperator>(&I)->getOpcode() == Instruction::Add) {
addCount++;
}
}
}
errs() << "Function: " << F.getName() << " has "
<< addCount << " add instructions\n";
return false; // 我们没有修改 IR,所以返回 false
}
};
}
char CountAddPass::ID = 0;
static RegisterPass<CountAddPass> X("count-add", "Count Add Instructions Pass");
这段代码做了几件事:
- 继承
FunctionPass并实现runOnFunction方法。 - 遍历函数内的所有基本块和指令,统计
add指令的数量。 - 通过
RegisterPass宏注册 Pass,并给它一个命令行名称count-add。
避坑指南: 我曾经在写 Pass 时忘记在 runOnFunction 末尾返回 false,结果 LLVM 以为我修改了 IR,导致后续 Pass 做了很多不必要的重新计算。记住:如果你没有修改 IR,一定要返回 false。
编译与运行自定义 Pass
编译这个 Pass 需要链接 LLVM 库。假设你的 LLVM 安装在 /usr/local/llvm,可以这样编译:
clang++ -shared -fPIC -o CountAddPass.so CountAddPass.cpp \
`llvm-config --cxxflags --ldflags --libs`
然后通过 opt 工具加载它:
opt -load ./CountAddPass.so -count-add -disable-output input.ll
这里的 -disable-output 表示不输出优化后的 IR,因为我们只是做统计。
知识体系总览
下面这张图展示了本章的核心逻辑:从 LLVM IR 基础出发,经过各类优化 Pass,最终通过自定义 Pass 实现特定需求。
这张图把 IR 基础、优化 Pass 和自定义 Pass 串联了起来。说白了,基础是地基,Pass 是工具,自定义 Pass 就是你自己造的专属工具。三者缺一不可。
个人建议: 刚开始学自定义 Pass 时,别急着写复杂的逻辑。先写一个只打印函数名的 Pass,跑通了再逐步增加功能。这样能快速验证你的开发环境是否配置正确。
好了,关于 LLVM IR 优化就聊这么多。记住,优化不是一蹴而就的,需要结合具体场景反复试验。你想想看,同样的 Pass 在不同硬件上效果可能天差地别——这就是为什么我们需要深入理解 IR,而不是盲目套用优化选项。