《AI编译器Pass调优实战100例》

案例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功能重叠)。

标准 -O2 序列 SimplifyCFG SROA EarlyCSE InstCombine GVN InstCombine SimplifyCFG 自定义精简序列 InstCombine SimplifyCFG GVN InstCombine SimplifyCFG 冗余:EarlyCSE与GVN重叠

图中红色框标注的EarlyCSE在GVN之前执行,但GVN能够完成同样的冗余消除,因此EarlyCSE是冗余的。自定义序列移除了它,减少了Pass数量。

8. 总结

通过本案例,你掌握了使用opt的--print-after-all分析Pass执行顺序,并通过对比功能重叠的Pass(如EarlyCSE与GVN)来精简Pass序列的方法。实际项目中,你可以使用llvm-mca或time工具量化编译时间的提升,从而更科学地调优Pass顺序。