编译器优化 pass 速查表:30 个名字背后的概念

编译器优化 pass 的名字一大堆——DCE、GVN、LICM、SCCP、SROA……每个都是一个独立的算法 + 一段术语。本文按'本地 / 函数级 / 循环 / 跨函数 / 后端 / 特殊'六类组织,每个 pass 给出英文全称、中文意思、做什么、为什么有用——读 LLVM 源码或自己写 pass 时当词典查。

📚 系列 compiler · 第 9 篇

读 LLVM 源码、读 paper、追编译器 pipeline 时,经常碰到 DCE GVN LICM SCCP 这种缩写炸弹。每个名字背后都是一个独立的算法 + 一个独立的术语圈。本文不是教你怎么实现这些 pass,而是给你一张速查表——每个 pass 给出英文全称、中文意思、做什么、为什么有用。

0. 阅读说明

按”优化作用的范围”分六类:

本地(单 op / 单 block) → 函数级 → 循环 → 跨函数 → 后端 → 特殊
    简单                                              复杂
    分析便宜                                          分析昂贵

后面所有 pass 都按这个顺序展开。每个 pass 一段:

缩写 / 英文全称 / 中文
  做什么:一句话
  为什么有用:对后续优化的影响
  典型例子:简化的前后对比

Part 1:本地级(peephole / within basic block)

最便宜的一类——只看几条相邻指令,看到模式就改。

1. Constant Folding(常量折叠)

做什么编译期把已知常量的运算算掉
为什么有用减少运行时计算 + 暴露更多优化机会
例子add 2, 35

2. Algebraic Simplification(代数化简)

做什么利用代数恒等式化简
为什么有用把”复杂的写法”压成”简单的写法”
例子add x, 0xmul x, 1xxor x, x0

LLVM 里这部分叫 InstCombine(Instruction Combining,指令组合),一个超大杂烩 pass,几千行模式匹配规则。

3. Strength Reduction(强度降低)

做什么把昂贵操作换成便宜操作
为什么有用乘法/除法很慢,移位很快
例子mul x, 8shl x, 3div x, 2shr x, 1

4. CSE(Common Subexpression Elimination,公共子表达式消除)

做什么同一个表达式算了多次,只算一次
为什么有用省重复计算
例子t1 = a+b; ...; t2 = a+bt1 = a+b; ...; t2 = t1

5. DCE(Dead Code Elimination,死代码消除)

做什么计算结果没人用 → 整条指令删掉
为什么有用缩小代码体积 + 配合其它优化级联触发
例子t = a + b; (t 后面没人用) → 删掉

6. ADCE(Aggressive DCE,激进死代码消除)

做什么DCE 的强化版,反向工作——只有”有副作用 / 影响 return”的指令才保留
为什么有用能消掉只有指令之间互相用、但整体没有”对外”作用的循环

Part 2:函数级(intra-procedural)

看整个函数的数据流和控制流,需要 SSA 形式。

7. mem2reg(Memory to Register,内存升寄存器)

做什么把”alloca + load/store”模式提升成 SSA 寄存器值
为什么有用打开所有 SSA 级优化的钥匙
例子mem2reg 那篇博客为什么有效

8. SCCP(Sparse Conditional Constant Propagation,稀疏条件常量传播)

做什么沿 SSA 边传播常量,同时考虑控制流可达性
为什么有用比朴素常量传播强——能识别”if (false) 里的分支不可达”
例子if (false) { x = 5 } else { x = 10 }; return xreturn 10

9. GVN(Global Value Numbering,全局值编号)

做什么给每个 SSA value 算一个”值编号”,值相等的合并
为什么有用CSE 的全局加强版——跨 basic block 工作
例子不同 block 算出来的 a+b 能识别成同一个值

10. SROA(Scalar Replacement of Aggregates,聚合分解为标量)

做什么把局部 struct / array 拆成多个独立 scalar 变量
为什么有用拆完之后 mem2reg 能 promote 它们
例子struct {a, b} x → 两个独立的 x.ax.b 变量

11. Reassociation(重结合)

做什么调整算术表达式顺序,让相关计算靠近
为什么有用暴露 CSE / 常量折叠机会
例子(a + 5) + (b + 7)(a + b) + 12

12. Sink(代码下沉)

做什么把指令往后挪到真正用它的地方
为什么有用减少”虽然算了但走不到”的指令
例子算了个值,然后 if 里才用 → 把算的指令挪进 if

13. Hoist / LICM 之外的代码外提

类似 Sink 的反向,见 LICM(下面循环类)。


Part 3:循环级(loop optimizations)

循环里的指令重复执行 N 次,值得花更多分析成本。需要先做 LCSSA / 循环识别。

14. LCSSA(Loop Closed SSA Form,闭环 SSA 形式)

做什么把循环内定义、循环外使用的 SSA value 用 phi 节点封装
为什么有用后续循环优化前提条件——保证循环边界处的数据流清晰

15. LICM(Loop Invariant Code Motion,循环不变代码外提)

做什么把循环里”每次值都一样”的计算挪到循环外
为什么有用一次计算代替 N 次
例子for (i=0; i<n; i++) x = a*b; arr[i] = xx = a*b; for (...) arr[i] = x

16. Loop Unrolling(循环展开)

做什么把循环体复制多份,减少迭代次数
为什么有用省 branch + 暴露更多 ILP(指令级并行)
例子for (i=0; i<4; i++) a[i]=0a[0]=0; a[1]=0; a[2]=0; a[3]=0

17. Loop Vectorization(循环向量化)

做什么把”对每个元素做一样的事”的循环编成 SIMD 指令
为什么有用用一条指令处理 4/8/16 个数据
例子for (i) c[i]=a[i]+b[i] → 用 SSE/AVX _mm_add_ps 一次算 4 个

18. SLP Vectorization(Superword-Level Parallelism,超字并行向量化)

做什么在直线代码里找可向量化的相邻操作
为什么有用不需要循环就能向量化
例子x.r=a; x.g=b; x.b=c; x.a=d → 一条 SIMD store

19. Loop Fusion(循环融合)

做什么两个相邻循环合并成一个
为什么有用减少循环开销 + 改善缓存局部性
例子for (i) a[i]=0; for (i) b[i]=0for (i) { a[i]=0; b[i]=0 }

20. Loop Distribution(循环分裂)

做什么Fusion 的反向——把一个循环拆成两个
为什么有用拆出来的某半可能能向量化

21. Loop Interchange(循环交换)

做什么嵌套循环的内外层调换
为什么有用改善缓存访问模式(列主序 → 行主序)
例子for i for j a[i][j]=...for j for i a[i][j]=...(取决于布局)

22. IndVarSimplify(Induction Variable Simplify,归纳变量简化)

做什么把循环计数器规范化(类型统一、初值简化)
为什么有用后续循环 pass 的预处理

Part 4:跨函数(interprocedural)

把多个函数放一起看,需要”全程序”视图。

23. Inlining(函数内联)

做什么把被调用函数的函数体复制到调用点
为什么有用省 call 开销 + 在更大上下文里跑其它优化
例子f() { return 2*x; } g() { y = f(); }g() { y = 2*x; }

关键挑战是启发式——什么时候内联划算?太多内联代码爆炸,太少错过优化。LLVM 用 cost-model + inline-threshold

24. IPSCCP(Interprocedural SCCP,跨函数稀疏常量传播)

做什么SCCP 的跨函数版本——参数 / 返回值都参与常量传播
例子全局只有一处调用 f(5),可以把 f 里的参数当成 5 优化

25. Escape Analysis(逃逸分析)

做什么判断一个对象的生命周期是否”逃出”当前函数 / 线程
为什么有用不逃逸的对象可以栈上分配(省 GC)/ 寄存器分配
例子Java 里 new SmallObj() 没逃出方法 → JIT 栈上分配

26. DSE(Dead Store Elimination,死存储消除)

做什么一个 store 写进去的值之后被另一个 store 覆盖(中间没人读)→ 第一个删
为什么有用省内存写
例子*p = 1; *p = 2*p = 2

27. Tail Call Optimization(尾调用优化)

做什么函数末尾的 call 用 jump 代替(复用当前栈帧)
为什么有用递归不会栈溢出 + 省 push/pop
做什么链接时再做一遍跨编译单元的优化(普通编译是 per-file 的)
为什么有用跨文件的 inline / DCE / IPSCCP 都能做
ThinLTOLTO 的轻量版——并行化更好,工业界主流

Part 5:后端 / 代码生成(codegen)

LLVM IR → 目标机器码这一段。

29. Instruction Selection(指令选择)

做什么给每条 LLVM IR 指令选一条目标平台机器指令
实现LLVM 有两套:SelectionDAG(老,DAG-based 模式匹配,用 TableGen)+ GlobalISel(新,逐渐替换)
例子LLVM add nsw i32 a, b → x86 add eax, ebx

30. Register Allocation(寄存器分配)

做什么把无限 SSA value 映射到有限物理寄存器,超的部分 spill 到栈
三大家族Linear Scan(快,代码质量中等)/ Graph Coloring(慢,质量高)/ SSA-based(PBQP)
实现LLVM 默认 Greedy(混合算法),-O0 用 fast,-O2/3 用 greedy

31. Instruction Scheduling(指令调度)

做什么重排指令顺序,避免流水线停顿(填充延迟槽 / hazard)
为什么有用现代 CPU 乱序执行已经做了大部分,但编译期调度还有价值(尤其 in-order GPU 上)

32. Peephole Optimization(窥孔优化)

做什么后端的”InstCombine”——看几条相邻机器指令,识别模式简化
例子x86 mov eax, 0xor eax, eax(更短 + 不影响 flag)

Part 6:特殊技术

33. PGO(Profile-Guided Optimization,基于剖析的优化)

做什么先跑一次插桩版,收集运行时数据(哪些分支走得多、哪些函数热),再用这些数据指导优化
为什么有用让 inline / 块布局 / 分支预测都用真实数据

34. AutoFDO / CSSPGO

PGO 的分支:AutoFDO 用 Linux perf 数据(不用插桩),CSSPGO 加 call stack 上下文。

35. Constant Propagation(常量传播)

做什么知道某个变量在某点的值是常量 → 把后面所有用替换成那个常量
跟 SCCP 区别朴素版,不考虑控制流可达性

36. Bounds Check Elimination(边界检查消除)

做什么数组访问编译期能证明在界内 → 删掉运行时边界检查
谁需要安全语言(Rust / Java / Swift)

Part 7:LLVM Pass Manager 速记

LLVM 把 pass 组织成 PassManager,Old(Legacy)和 New 两套。

# 用新版 PM 跑指定 pass
opt -passes='mem2reg,instcombine,sccp,gvn,dce' input.ll -o output.ll

# -O0 / -O1 / -O2 / -O3 / -Os / -Oz 各自打包了一组 pass
opt -O2 input.ll -o output.ll

# 看 -O2 实际包含哪些 pass
opt -O2 --print-pipeline-passes input.ll

记住这个 --print-pipeline-passes ——做 LLVM 工作时经常用,看 pass 顺序。

38. 一句话总结

30+ 个优化 pass 的名字看起来吓人,但本质上是 6 类工具——本地清扫、函数级 SSA 上的分析、循环上的重塑、跨函数协调、目标机器码生成、剖析驱动的微调。每个 pass 做的事都很局部、很简单,但级联起来就是现代编译器从一行 C / Rust / Swift 到一段紧凑机器码的全过程。读 LLVM 源码或自己写优化 pass 时把这张表当词典查就够,不需要会实现每一个

系列上一篇: mem2reg 为什么有效:从’看得见所有读写’说起

评论区
评论功能即将上线, 敬请期待。