案例1:Pass执行顺序分析——用opt识别并消除冗余Pass
1. 任务定义
本案例解决一个具体问题:给定一段LLVM IR,如何通过opt工具分析其Pass执行顺序,找出其中冗余的Pass,并通过调整顺序来减少编译时间。最终目标是得到一个更精简的Pass序列,同时保证优化后的IR与原始IR在功能上等价(通过opt的-S输出和lli执行结果验证)。
你将学习到:
- 使用
opt的--debug-pass=Structure或--print-after-all来观察Pass执行轨迹。 - 识别常见的冗余Pass模式(例如:两个连续的
instcombine,或gvn后紧跟early-cse但无新优化空间)。 - 通过
opt -O2与自定义Pass序列的对比,验证优化效果。
2. 环境准备与输入IR
本案例需要LLVM 15+(包含opt、lli、clang)。我们使用一个简单的算术IR作为测试输入,它包含冗余的加载和计算,便于观察Pass效果。
创建文件test.ll,内容如下:
; test.ll
define i32 @main() {
entry:
%a = alloca i32, align 4
%b = alloca i32, align 4
store i32 10, i32* %a, align 4
store i32 20, i32* %b, align 4
%0 = load i32, i32* %a, align 4
%1 = load i32, i32* %b, align 4
%add = add nsw i32 %0, %1
%2 = load i32, i32* %a, align 4 ; 冗余加载,可被GVN消除
%3 = load i32, i32* %b, align 4 ; 冗余加载
%mul = mul nsw i32 %2, %3
%sub = sub nsw i32 %add, %mul
ret i32 %sub
}
该IR包含两个冗余的load(%2和%3),它们与%0和%1重复。一个有效的Pass序列应该能消除这些冗余。
3. 分析Pass执行顺序
首先,我们使用opt的--print-after-all来观察标准-O2的Pass执行顺序。运行以下命令:
opt -O2 -S --print-after-all test.ll -o /dev/null 2> trace_O2.txt
然后查看trace_O2.txt中Pass的调用顺序(只关注*** IR Dump After行):
grep "IR Dump After" trace_O2.txt | sed 's/*** IR Dump After //' | sed 's/ \*\*\*//'
预期输出片段(LLVM 15/16)类似:
...
SimplifyCFG
SROA
EarlyCSE
...
InstCombine
...
GVN
...
InstCombine
...
我们注意到EarlyCSE和GVN都出现了,且InstCombine出现了多次。为了识别冗余,我们手动构造一个精简Pass序列,并对比效果。
4. 识别冗余Pass并构造优化序列
通过分析,我们发现-O2中EarlyCSE和GVN都执行了,但GVN可以完全覆盖EarlyCSE的工作(消除冗余load)。此外,InstCombine在GVN后再次运行是必要的,但如果在GVN前运行两次则冗余。
我们构造一个自定义Pass序列,移除重复的EarlyCSE(因为GVN更强大),并保留必要的清理Pass。使用opt的-passes参数:
opt -S -passes="default" -print-after-all test.ll -o /dev/null 2> trace_default.txt
但为了精确控制,我们使用一个最小序列:
opt -S -passes="function(instcombine,simplifycfg,gvn,instcombine,simplifycfg)" test.ll -o optimized.ll
这里我们只使用instcombine、simplifycfg和gvn,并让gvn处理冗余load,之后再做一次instcombine清理。这个序列比-O2更短,且应该能达到相同效果。
5. 验证与对比
我们通过以下步骤验证优化效果和正确性:
5.1 验证IR等价性
使用lli运行原始IR和优化后的IR,比较返回值:
echo "原始IR执行结果:" && lli test.ll; echo $?
echo "优化后IR执行结果:" && lli optimized.ll; echo $?
预期两个结果都是10+20 - (10*20) = 30 - 200 = -170,退出码为256-170=86(因为shell退出码范围0-255,-170 mod 256 = 86)。实际输出应为86。
5.2 检查冗余消除
查看优化后的IR,确认冗余load被消除:
cat optimized.ll
预期optimized.ll中不再有%2 = load和%3 = load,而是直接使用%0和%1。
5.3 编译时间对比
使用time命令对比-O2和自定义序列的编译时间(多次运行取平均):
echo "O2时间:" && time (opt -O2 -S test.ll -o /dev/null)
echo "自定义序列时间:" && time (opt -S -passes="function(instcombine,simplifycfg,gvn,instcombine,simplifycfg)" test.ll -o /dev/null)
由于IR很小,时间差异可能不明显,但可以观察到自定义序列的Pass数量更少。为了更明显,可以增大IR规模,但本案例聚焦方法。
6. 关键代码与命令汇总
以下是本案例的核心命令,可直接复制执行:
# 1. 生成trace
opt -O2 -S --print-after-all test.ll -o /dev/null 2> trace_O2.txt
grep "IR Dump After" trace_O2.txt | sed 's/*** IR Dump After //' | sed 's/ \*\*\*//' > pass_order_O2.txt
# 2. 自定义Pass序列
opt -S -passes="function(instcombine,simplifycfg,gvn,instcombine,simplifycfg)" test.ll -o optimized.ll
# 3. 验证结果
lli test.ll; echo "原始退出码: $?"
lli optimized.ll; echo "优化退出码: $?"
# 4. 查看优化后的IR
cat optimized.ll
预期现象:pass_order_O2.txt中显示EarlyCSE和GVN都出现,而自定义序列只包含gvn。优化后的IR中冗余load消失,且程序执行结果一致。
7. 可视化:Pass执行顺序对比
下图展示了标准-O2与自定义精简Pass序列的执行顺序差异,高亮部分为冗余Pass(EarlyCSE与GVN功能重叠)。
图中红色框标注的EarlyCSE在GVN之前执行,但GVN能够完成同样的冗余消除,因此EarlyCSE是冗余的。自定义序列移除了它,减少了Pass数量。
8. 总结
通过本案例,你掌握了使用opt的--print-after-all分析Pass执行顺序,并通过对比功能重叠的Pass(如EarlyCSE与GVN)来精简Pass序列的方法。实际项目中,你可以使用llvm-mca或time工具量化编译时间的提升,从而更科学地调优Pass顺序。