Skip to main content

Chapter 90: Reading Optimized LLVM IR

读优化后的 LLVM IR,需要把它当作一份证明结果来读:哪些计算已经被证明为常量,哪些内存槽位已经被提升成 SSA value,哪些分支已经被判定为多余,哪些函数边界已经被内联展开,哪些源码变量只能通过 debug metadata 间接追踪。本章要建立的能力,是拿到一份优化后 IR 时,能够定位变化来源、判断优化依据、解释源码形态为什么消失,并给出可复查的阅读顺序。

本章贯穿一个很小的 C 函数。它的源码形态保留了局部变量、临时表达式、条件分支和返回语句;优化后的 IR 会把这些表面形态压缩成更直接的 SSA 运算。这个例子足够小,便于观察 mem2reg、constant folding、dead code elimination、instcombinesimplifycfg 之间的关系。

int score(int input) {
int base = input + 0;
int bonus = 2 * 3;
if (bonus > 5) {
return base + bonus;
}
return base;
}

本文使用 LLVM IR 作为主表示。Clang 的命令行文档说明,-emit-llvm-S 组合会生成 LLVM intermediate language assembly 文件;opt 的命令行文档说明,opt 接收 LLVM IR 或 bitcode,运行指定优化或分析,再输出优化后的文件。版本边界采用 LLVM 官方当前文档中的 LLVM 23.0.0git 文档;具体 pass 名称、默认优化管线和调试记录格式会随 LLVM 版本演进,阅读 IR 时应以本机 clang --versionopt --versionopt -print-passes 为准。官方入口可参考 Clang command guideopt command guideLLVM Analysis and Transform PassesSource Level Debugging with LLVM

90.1 Comparing IR Before and After Optimization

比较优化前后 IR,核心目标是把“源码长什么样”转换成“IR 还保留什么语义证据”。优化前 IR 通常更接近前端降低结果,保留较多局部变量槽位、显式 load / store、直接映射源码语句的分支和调试信息;优化后 IR 更接近中端已经证明过的程序形态,许多临时对象、块边界和分支条件会被删除、合并或改写。

最小可观察材料可以用三组文件建立。第一组是前端在低优化级别下产出的 IR,第二组是较高优化级别下的 IR,第三组是手动运行一段明确 pass pipeline 后的 IR。手动 opt 实验使用 -Xclang -disable-O0-optnone 生成输入,让实验 pass 有机会作用于函数。命令只是观察材料,结论来自 IR 差异本身。

clang -O0 -S -emit-llvm score.c -o score.O0.ll
clang -O2 -S -emit-llvm score.c -o score.O2.ll
clang -O0 -Xclang -disable-O0-optnone -S -emit-llvm score.c -o score.for-opt.ll
opt -S -passes="mem2reg,instcombine,simplifycfg" score.for-opt.ll -o score.manual.ll

-O0 的 IR 往往带有局部栈槽,因为前端需要给调试器和直接 lowering 保留较直观的位置关系。下面是代表性形态,省略了 target、attributes 和大部分 metadata。它展示的重点是 allocastoreload 和条件分支怎样把源码变量与源码控制流显式放进 IR。

define dso_local i32 @score(i32 noundef %input) {
entry:
%input.addr = alloca i32, align 4
%base = alloca i32, align 4
%bonus = alloca i32, align 4
store i32 %input, ptr %input.addr, align 4
%0 = load i32, ptr %input.addr, align 4
%add = add nsw i32 %0, 0
store i32 %add, ptr %base, align 4
store i32 6, ptr %bonus, align 4
%1 = load i32, ptr %bonus, align 4
%cmp = icmp sgt i32 %1, 5
br i1 %cmp, label %then, label %else

then:
%2 = load i32, ptr %base, align 4
%3 = load i32, ptr %bonus, align 4
%add2 = add nsw i32 %2, %3
ret i32 %add2

else:
%4 = load i32, ptr %base, align 4
ret i32 %4
}

优化后 IR 的读法要从结果形态反推证明链。下面的代表性结果中,input + 0 被化简为 input2 * 3 被折叠为 6bonus > 5 被证明恒为真,else 块失去可达性,局部栈槽被提升或删除,函数剩下一个 SSA 运算和一个返回。

define dso_local i32 @score(i32 noundef %input) local_unnamed_addr {
entry:
%add = add nsw i32 %input, 6
ret i32 %add
}

这两份 IR 的差异应按证据顺序阅读。先看函数签名是否变化,包括参数、返回类型、属性和调用约定;再看 basic block 数量是否变化;接着看 allocaloadstore 是否减少;然后看分支条件是否变成常量或消失;最后看核心计算是否变成更少的 SSA 指令。这个顺序能把“代码变短”拆成可解释的优化事实。

优化等级对比也要控制输入边界。相同源码在不同目标 triple、不同 ABI、不同 Clang 版本、不同 -fno-builtin-fwrapv-ffast-math-g 和 sanitizer 选项下可能生成不同 IR。比较时应记录命令、版本、目标平台和关键选项,因为这些信息会改变编译器可用的语义前提。

90.2 Recognizing Common Optimization Patterns

识别优化模式时,应从 IR 的可观察变化出发,再把变化归入某类证明。pass 名称是定位线索,真正要读的是优化前提:编译器依赖哪些常量事实、SSA 使用关系、控制流可达性、别名关系、函数属性或循环事实完成转换。

constant folding 的可见特征是指令结果直接变成常量。贯穿例子里的 2 * 3 在 IR 中表现为 store i32 6 或直接进入后续 add i32 %input, 6。它依赖的证明很简单:两个操作数都是编译期常量,操作语义在当前 IR 规则和语言前提下可确定。阅读这类变化时,先找常量来源,再确认该运算是否受溢出、浮点舍入、未定义行为或目标相关语义影响。

instcombine 的典型特征是表达式形态被规范化。input + 0 变成 input,连续 cast 被合并,比较与算术被改写成更容易被后续 pass 识别的形式。它通常节省的成本是指令数量和后续分析复杂度,同时也把语义写成 LLVM IR 更偏好的 canonical form。阅读这类变化时,重点看改写前后的 value 是否代表同一组可能结果。

DCE,也就是 dead code elimination,清理的是没有可观察用途的计算。若一个 SSA value 没有使用者,并且产生它的指令没有副作用,它可以从 IR 中删除。贯穿例子里的 else 块在分支条件恒真后失去可达路径,块内的 loadret 随控制流一起消失。阅读 DCE 时要先区分“没有用户的纯计算”和“带副作用的操作”。store、volatile memory access、atomic operation、可能抛出异常的调用、I/O 调用和同步操作都需要单独判断。

mem2reg 的可见特征是局部 allocaloadstore 被替换成 SSA value。它通常处理函数 entry block 中可提升的栈槽,把局部变量的读写转换成 SSA 定义和使用;遇到多条控制流汇合时,会插入 phi。贯穿例子里没有真正需要 phi 的合流值,因为常量分支被删除后只剩一条路径。对于存在真实分支的代码,读者应在合流 block 中寻找 phi,并根据 incoming block 判断每个来源值。

simplifycfg 的典型特征是 basic block 被合并、空跳转被删除、恒真恒假的条件分支变成直接跳转、多个返回路径被整理。贯穿例子中的 if (bonus > 5) 在常量传播之后变成恒真条件,CFG 从 entry → then/else 变成单个 entry。阅读这类变化时,应先画出优化前后 CFG,再判断哪些边被证明不可达,哪些 block 只是跳转中转,哪些分支仍然携带运行时条件。

inline 的可见特征是被调用函数的 IR 进入调用者,call 消失或减少,形参被实参替换,返回值变成调用点附近的 SSA value。inline 的收益通常来自消除调用开销、暴露跨函数常量和类型事实、触发后续 DCE 与 instcombine。阅读 inline 结果时,应检查调用是否仍保留语义边界,例如异常路径、函数属性、内存读写、递归限制和调试 inline stack。

loop optimization 的可见特征更依赖循环结构。常见变化包括循环 invariant 被移到循环外,归纳变量被规范化,边界检查被合并,循环被展开,空循环被删除,向量化 IR 出现宽向量类型和 masked 操作。读循环优化时,先定位 loop header、latch、exit block 和 induction variable,再看循环体内哪些指令依赖迭代变量,哪些值在每次迭代中保持不变。这个顺序比直接搜索 pass 名称更稳定。

这些模式经常串联出现。constant folding 让分支条件变成常量,simplifycfg 删除不可达分支,DCE 清理失去用户的指令,instcombine 再把剩余表达式整理成简短形态。优化后 IR 展示的是一组 pass 共同达成的固定点,单条指令的消失通常来自多步证明链。

90.3 Understanding Lost Source Shape in Optimized IR

优化后 IR 丢失源码形态,是语义保持与执行约束共同作用的结果。源码变量、临时表达式、函数边界和控制结构服务于程序员表达;优化器关注的是哪些值会影响可观察行为,哪些路径可达,哪些内存操作必须保留,哪些计算可以被重写成成本更低的等价形式。

局部变量最容易消失。basebonus 在源码中有名字,但在优化后 IR 中不一定有独立存储位置。base 的值等于 inputbonus 的值等于常量 6,二者都可以直接进入最终 add。读优化后 IR 时,源码变量名只能作为查找线索,SSA value 与内存对象才是实际表示。变量名消失并不表示程序意义缺失,它表示变量作为单独存储对象已经不再承担执行责任。

临时表达式也会被合并。源码里先计算 input + 0,再计算 base + bonus;优化后 IR 直接保留 %input + 6。这种合并依赖代数恒等式、常量传播和语言层面的未定义行为规则。以有符号整数为例,Clang 常会在满足前提时给 add 标注 nsw,表示 signed wrap 不属于这条指令允许的语义结果。阅读这种标记时,要把它当作优化器可使用的前提证据。

控制结构会被控制流图吸收。源码中的 if 是结构化语法,LLVM IR 中对应的是 basic block 与 br 指令。优化器证明条件恒真后,结构化 if 形态失去存在理由,IR 可以直接返回计算结果。更复杂的源码控制结构也会出现类似变化:switch 可能变成查表、二分比较或跳转表;短路逻辑可能变成分支,也可能变成 select;多个 return 可能合并成统一出口。

函数边界在 inline 后会变弱。源码中一个函数调用代表清晰边界,优化后调用点附近可能出现被调用函数体的指令。此时读 IR 要根据 call 是否残留、callee 属性是否传播、参数是否常量化、返回值是否被后续折叠来判断 inline 的影响。若调试信息开启,inline stack 可能保留“这条指令来自哪个内联函数”的位置关系;若缺少调试信息,只看优化后 IR 很难恢复原始源码调用层次。

源码顺序也可能改变。优化器可以移动纯计算、合并块、调整分支布局或提前计算循环不变量,只要可观察行为保持一致。阅读时应把顺序分成三类:必须保持顺序的副作用,受依赖约束的值计算,可以重排的纯计算。这个分类能解释为什么某些指令位置变化不会改变程序意义。

lost source shape 的关键判断是:源码形态丢失,IR 语义证据会以另一种形式保留。变量可能变成 SSA value,分支可能变成常量选择,函数调用可能变成内联指令,循环语法可能变成 header、latch 和 exit 的 CFG 结构。读者要寻找这些替代证据,而非按源码逐行匹配 IR。

90.4 Debug Metadata and Optimized IR Observability

debug metadata 的作用,是在优化后的 IR 与源码位置、变量、作用域和 inline stack 之间建立可供调试器、性能分析工具和开发者使用的关联。它不能让优化后 IR 重新长回源码形态,但能为“这条指令对应哪一行”“这个值代表哪个源码变量”“这个指令是否来自内联函数”提供证据。

LLVM 的 Source Level Debugging 文档把调试信息描述为 LLVM program objects 与 source-level objects 之间的映射。当前文档也说明,调试信息需要与优化和分析配合,优化可以更新调试信息,并且调试信息本身不阻止 inline、basic block reorder、merge、cleanup 和 tail duplication 等优化。这个边界很关键:-O2 -g 产生的是优化后程序加调试映射,调试体验会受变量删除、函数内联和控制流重排影响。

在 IR 中,源码位置通常通过 !dbg 附着到指令上,并指向 DILocation。下面是简化形态,重点是 !dbg 把优化后指令连接到源码行列和作用域。

%add = add nsw i32 %input, 6, !dbg !18
ret i32 %add, !dbg !19

!18 = !DILocation(line: 4, column: 21, scope: !7)
!19 = !DILocation(line: 4, column: 9, scope: !7)

变量位置在 LLVM IR 中有版本边界。较新的 LLVM 文档强调 debug records,例如 #dbg_value#dbg_declare#dbg_assign;旧形态或 intrinsic-mode 中常见 llvm.dbg.valuellvm.dbg.declarellvm.dbg.assign。阅读第三方 IR、历史测试用例或不同工具输出时,应先确认当前文件使用哪一种表示,再解释变量位置。下面的两个片段表达的是同一类信息:某个源码变量在某个位置由某个 IR value 描述。

#dbg_value(i32 %add, !23, !DIExpression(), !18)
call void @llvm.dbg.value(metadata i32 %add, metadata !23, metadata !DIExpression()), !dbg !18

debug metadata 的可观察性有三层。第一层是位置映射,帮助定位指令来自哪个源码行列;第二层是变量映射,帮助判断源码变量当前是否还有可读位置或可计算值;第三层是 inline scope,帮助把优化后的单一函数体映射回源码调用栈。三层证据都可能受优化影响,因此调试器显示的变量可能是 optimized out,某些行号可能合并到同一条机器指令,inline 函数可能显示为虚拟栈帧。

读带调试信息的优化 IR,应先把 !dbg 当作辅助证据,再回到指令本身判断语义。!dbg 说明来源关系,addloadstorecallbrret 说明实际程序表示。若二者看起来冲突,优先确认优化是否移动、合并或删除了源码结构,再检查 debug metadata 是否在转换中被正确更新。

-g 不改变优化合法性。它提供额外 metadata,帮助工具解释优化后的代码。debug metadata 的高质量更新需要 pass 作者在改写 IR 时维护映射,这也是 LLVM pass 工程中需要测试的对象。对于读者来说,调试信息可以帮助追踪源码证据,但优化 IR 的语义判断仍然要落在 IR 指令、控制流、数据流和内存副作用上。

90.5 Engineering Insight: Optimized IR Shows What the Compiler Proved

优化后的 IR 展示的是编译器已经接受的一组证明结果。它证明某些表达式可折叠,某些路径不可达,某些变量无需独立存储,某些函数边界可以展开,某些副作用必须保留。读优化 IR 的目标,是从这些结果中还原证明依据和语义边界。

贯穿例子的最终 IR %add = add nsw i32 %input, 6 可以分解成多条结论:bonus 被证明为常量 6bonus > 5 被证明恒真,base 被证明等价于 input,局部栈槽被证明可提升或删除,最终可观察行为被压缩成返回 input + 6。这些结论都来自源码语义、IR 规则和 pass 分析结果的组合。

工程阅读顺序可以固定为六步。第一步记录版本、目标平台和命令选项;第二步比较函数签名、属性和可见符号;第三步比较 CFG,确认 block、edge 和 terminator 的变化;第四步比较数据流,追踪 SSA value、phi、constant 和 use-def 链;第五步比较内存行为,区分被删除的普通栈槽与必须保留的副作用;第六步查看 debug metadata,把优化后证据映射回源码位置和变量。

这个顺序也能定位优化疑问。若某个分支消失,先查条件是否变成常量,再查控制流是否被 simplifycfg 合并;若某个变量消失,先查它是否被 mem2reg 提升,再查是否被 instcombine 和 DCE 清理;若某个调用消失,先查 inline、函数属性和后续常量传播;若某个循环变化很大,先画 CFG,再查归纳变量、退出条件和循环体副作用。

优化 IR 的价值在于它比源码更接近后端,又比机器码保留更多类型、控制流和 SSA 证据。它适合回答“编译器在中端已经知道了什么”“哪些优化前提支撑了代码形态变化”“为什么调试视图偏离源码逐行结构”。当读者能把优化结果拆成证明链,就能用 IR 解释性能变化、调试异常、pass 测试失败和后端输入差异。

最小自检任务

阅读下面的源码和代表性优化后 IR,判断优化器已经证明了哪些事实,并说明哪些源码形态在 IR 中被替代。

int clamp_bonus(int value) {
int zero = 0;
int limit = 10;
if (limit > zero) {
return value + limit;
}
return value;
}
define dso_local i32 @clamp_bonus(i32 noundef %value) local_unnamed_addr {
entry:
%add = add nsw i32 %value, 10
ret i32 %add
}

答案要点

输入源码中有两个局部变量、一个条件分支和两个返回路径。优化后表示只保留一个 entry block、一个 add 和一个 ret,说明局部变量槽位、条件分支和备用返回路径都被其它 IR 证据替代。

关键判断步骤是先看常量事实:zero 等于 0limit 等于 10,所以 limit > zero 恒真。再看控制流变化:恒真条件让备用返回路径失去可达性,CFG 被合并成单一 block。然后看数据流变化:value + limit 被改写成 %value + 10,源码变量 limit 通过常量操作数保留语义,zero 只参与已被证明的条件。

不变量是函数对外可观察行为保持为“返回输入值加 10”。最终结论是,优化后 IR 展示的核心证明为常量传播、控制流简化、死代码删除和局部变量表示消除;源码变量名和 if 结构消失后,语义通过 SSA value、常量和返回指令继续保留。

本章知识点总结

  • 优化 IR:优化后的 LLVM IR 应作为证明结果阅读,重点关注保留的语义证据和新增的执行约束。
  • 对比边界:比较 IR 前后必须记录版本、目标平台、优化等级和关键编译选项。
  • 函数签名:函数参数、返回类型、属性和可见性是阅读优化结果的第一层边界。
  • CFG 变化:basic block、edge 和 terminator 的变化能说明分支、合流和不可达路径如何被处理。
  • 常量折叠:constant folding 把编译期可确定的运算结果直接写成常量。
  • 表达式规范instcombine 常把等价表达式改写成更利于后续优化的 canonical form。
  • 死代码删除:DCE 删除没有可观察用途且没有副作用的计算,控制流简化后常触发更多清理。
  • 栈槽提升mem2reg 把可提升的局部 alloca 读写转换成 SSA value,控制流汇合处可能产生 phi
  • 源码形态:变量名、临时表达式、函数边界和结构化控制语法可能在优化后由 SSA、常量、CFG 和 metadata 替代。
  • 调试映射:debug metadata 为优化后指令提供源码位置、变量和 inline stack 证据,但语义判断仍以 IR 指令和数据流为准。
  • 阅读顺序:稳定顺序是版本与选项、函数边界、CFG、SSA 数据流、内存副作用、debug metadata。
  • 工程结论:优化 IR 展示编译器已经证明的事实,读懂这些事实就能解释性能变化、调试偏差和 pass 行为。