llvm_history

第4章:标量优化 - 局部改进的累积效应

章节大纲

4.1 引言

4.2 经典优化算法在 LLVM 中的实现

4.3 GVN(全局值编号)的演化历程

4.4 SCCP(稀疏条件常量传播)与 IPSCCP

4.5 InstCombine:模式匹配的威力与陷阱

4.6 优化顺序的探索:Phase Ordering Problem

4.7 高级话题:Alive2 项目与优化正确性的形式化验证

4.8 本章小结

4.9 练习题

4.10 常见陷阱与错误

4.11 最佳实践检查清单


4.1 引言

标量优化是编译器优化技术的基石,它们看似简单却蕴含着深刻的工程智慧。在 LLVM 的发展历程中,标量优化不仅是性能提升的重要来源,更是验证编译器正确性和探索优化边界的试验场。从 2003 年 LLVM 项目初创至今,标量优化经历了多次架构重构、算法革新和正确性危机,每一次演进都反映了编译器社区对性能与正确性平衡的深入思考。

本章将深入探讨 LLVM 中标量优化的实现哲学与工程实践。我们不仅关注算法本身,更关注这些优化在 LLVM 框架中的具体实现、相互作用以及演化历程。特别地,我们将剖析几个关键事件:Owen Anderson 在 2010 年对 GVN 的彻底重写、2018 年 InstCombine 的重大正确性 bug,以及 Nuno Lopes 团队开发 Alive2 形式化验证工具的背景与影响。

学习目标

完成本章学习后,你将能够:

  1. 理解标量优化的分类体系:掌握 LLVM 中各类标量优化的职责划分与设计权衡
  2. 追踪关键优化的演化脉络:了解 GVN、SCCP、InstCombine 等核心 Pass 的历史演变及其背后的技术驱动力
  3. 分析优化间的相互作用:深入理解 Phase Ordering Problem 及其在实践中的解决方案
  4. 评估优化的正确性风险:通过具体案例学习优化 bug 的模式识别与预防策略
  5. 掌握形式化验证方法:了解 Alive2 等工具如何保障优化的正确性

4.2 经典优化算法在 LLVM 中的实现

LLVM 的标量优化体系建立在一系列经典算法之上,这些算法在教科书中已有详尽论述,但 LLVM 的实现往往包含大量工程细节和实用改进。让我们从最基础的优化开始,逐步深入到复杂的全程序分析。

死代码消除(DCE)

死代码消除是最直观的优化之一,但 LLVM 的实现远比教科书复杂。LLVM 提供了三个层次的 DCE:

  1. Local DCE:在基本块内删除无用指令
  2. Dead Code Elimination (DCE):函数级的死代码删除
  3. Aggressive DCE (ADCE):考虑控制流的激进删除
算法演进时间线:
2003: 基础 DCE 实现,简单的 use-def 链遍历
2005: 加入 ADCE,引入控制依赖分析
2008: 优化算法复杂度,从 O(n²) 降至 O(n log n)
2015: 与 MemorySSA 集成,提升内存操作的死代码识别

ADCE 的核心创新在于引入了”活跃性传播”的概念:

活跃性判定规则:
1. 有副作用的指令(stores、calls、terminators)标记为活跃
2. 被活跃指令使用的指令递归标记为活跃
3. 控制活跃指令执行的分支标记为活跃
4. 未标记的指令视为死代码

常量折叠与传播

LLVM 的常量折叠不仅处理算术运算,还包括复杂的聚合类型操作和内置函数调用:

折叠层次:
Layer 1: 算术和逻辑运算 (2+3 → 5)
Layer 2: 比较和选择操作 (x < 5 where x=3 → true)
Layer 3: 聚合类型操作 (extractelement <2,3,4>, 1 → 3)
Layer 4: 内置函数 (llvm.ctpop(0x0F) → 4)
Layer 5: 目标相关折叠 (x86_mm_shuffle_ps with constant mask)

特别值得注意的是 LLVM 对浮点常量折叠的谨慎处理。由于浮点运算的非结合性和舍入模式依赖,LLVM 提供了细粒度的控制:

\[\text{FP折叠条件} = \begin{cases} \text{总是折叠} & \text{if fast-math} \\ \text{保守折叠} & \text{if 严格IEEE} \\ \text{依据元数据} & \text{if 有fpmath标记} \end{cases}\]

公共子表达式消除(CSE)

LLVM 实现了多个级别的 CSE:

  1. EarlyCSE:快速的局部 CSE,O(n) 复杂度
  2. GVN:包含完整 CSE 功能的全局值编号
  3. MachineCSE:机器指令级的 CSE

EarlyCSE 的设计哲学体现了 LLVM 的实用主义:

EarlyCSE 特点:
- 使用简单的哈希表,不构建完整的值编号
- 只在单个基本块或简单的菱形结构中工作
- 特别优化了内存操作的 CSE(通过简化的别名分析)
- 编译时间开销极小,适合在优化管线早期运行

强度削减

强度削减在 LLVM 中主要由 LoopStrengthReduce (LSR) Pass 负责,但标量优化中也包含基础的强度削减模式:

标量强度削减模式:
乘法 → 移位: x * 8 → x << 3
除法 → 乘法: x / 3 → x * 0xAAAAAAAB >> 33 (对于32位无符号)
取模 → 位运算: x % 16 → x & 15
幂运算 → 乘法链: pow(x, 3) → x * x * x

这些转换的正确性依赖于精确的溢出分析和目标架构特性。LLVM 使用 TargetTransformInfo (TTI) 接口查询特定转换在目标平台上的收益。

4.3 GVN(全局值编号)的演化历程

全局值编号是 LLVM 中最复杂也是最重要的标量优化之一。它的演化历程反映了 LLVM 社区对优化算法理解的深化和工程实践的成熟。

早期实现(2007-2010)

LLVM 最初的 GVN 实现基于经典的 Alpern-Wegman-Zadeck 算法,由 Owen Anderson 在 2007 年引入:

初代 GVN 架构:
├── 值编号表:将表达式映射到唯一编号
├── 可用表达式分析:跟踪每个程序点的可用值
├── 支配树遍历:确保正确的值替换顺序
└── 简单的 PRE:部分冗余消除的基础实现

早期实现的主要问题:

  1. 内存消耗过大:为每个基本块维护完整的可用表达式集
  2. 编译时间过长:O(n²) 的最坏情况复杂度
  3. 优化机会缺失:无法处理复杂的内存操作和 phi 节点

Owen Anderson 的重写(2010-2012)

2010 年,Owen Anderson 主导了 GVN 的彻底重构,引入了多项创新:

重构后的核心改进:
1. 稀疏值编号:只记录实际使用的值
2. 内存依赖分析集成:精确处理 load/store
3. PRE 的完整实现:包括 Critical Edge Splitting
4. Leader Table 优化:快速查找等价值

关键算法创新 - Leader Table

Leader Table 结构:
ValueNumber → {Leader1, Leader2, ...}

查找算法:
1. 计算表达式的值编号 VN
2. 在 Leader Table 中查找 VN
3. 选择支配当前位置的最近 Leader
4. 如果找到,用 Leader 替换当前表达式

这次重写带来了显著的性能提升:

NewGVN 的尝试与挑战(2016-至今)

2016 年,Daniel Berlin 启动了 NewGVN 项目,试图从根本上解决 GVN 的架构问题:

NewGVN 的设计理念:
1. 基于同余类的值编号(Congruence Class)
2. 使用 sparse-conditional constant propagation
3. 统一的表达式表示(Symbolic Expression)
4. 增量式更新机制

NewGVN 的核心创新 - 同余类合并

\[\text{Congruence}(e_1, e_2) = \begin{cases} \text{true} & \text{if } \phi(e_1) = \phi(e_2) \\ \text{true} & \text{if } e_1 \equiv_{\text{sym}} e_2 \\ \text{false} & \text{otherwise} \end{cases}\]

其中 $\phi$ 是值编号函数,$\equiv_{\text{sym}}$ 表示符号等价。

然而,NewGVN 至今未能完全替代旧 GVN,主要原因:

  1. 正确性问题:发现多个难以修复的 bug
  2. 性能退化:某些情况下比旧 GVN 慢
  3. 维护困难:代码复杂度过高

GVN 与 MemorySSA 的集成

2017 年开始,LLVM 将 MemorySSA 集成到 GVN 中,这是一个重要的架构改进:

MemorySSA 在 GVN 中的应用:
1. 精确的内存依赖查询
2. 快速的别名分析缓存
3. 支持更激进的 load/store 优化
4. 改进的 dead store elimination

集成后的性能数据(2019年测量):

4.4 SCCP(稀疏条件常量传播)与 IPSCCP

SCCP 是 LLVM 中最优雅的优化之一,它将数据流分析与符号执行结合,实现了强大的常量传播能力。

算法原理:格值理论基础

SCCP 基于格(Lattice)理论,使用三值逻辑表示变量状态:

格值定义:
    ⊤ (Top/Unknown)
         |
    Constants: {..., -1, 0, 1, 2, ...}
         |
    ⊥ (Bottom/Overdefined)

状态转换规则: \(\text{meet}(v_1, v_2) = \begin{cases} v_1 & \text{if } v_2 = \top \\ v_2 & \text{if } v_1 = \top \\ v_1 & \text{if } v_1 = v_2 \\ \bot & \text{otherwise} \end{cases}\)

SCCP 的实现细节

LLVM 的 SCCP 实现采用工作列表算法:

算法流程:
1. 初始化:所有值设为 ⊤,可达块设为 false
2. 从入口块开始,标记为可达
3. 迭代处理工作列表:
   a. 处理 PHI 节点:合并来自可达前驱的值
   b. 模拟指令执行:根据操作数的格值计算结果
   c. 处理分支:根据条件值决定后继块的可达性
4. 重复直到不动点

关键优化 - 条件常量传播

if (x == 5) {
    y = x + 1;  // SCCP 能推导出 y = 6
}

SCCP 通过跟踪分支条件,在不同路径上维护不同的常量信息。

IPSCCP:跨函数的常量传播

IPSCCP(Interprocedural SCCP)将 SCCP 扩展到全程序级别:

IPSCCP 的额外能力:
1. 函数参数常量化
2. 返回值常量传播
3. 间接调用去虚化
4. 全局变量常量折叠

实现挑战与解决方案:

挑战1:递归函数的处理
解决:使用 SCC (强连通分量) 分析,迭代求解

挑战2:不完整的调用图
解决:保守假设外部调用,使用函数属性标注

挑战3:编译时间爆炸
解决:设置迭代上限,使用增量更新

与其他优化的协同

SCCP 的独特之处在于它能与其他优化产生协同效应:

协同效应示例:
SCCP → DCE: 常量条件导致不可达代码
SCCP → Inlining: 常量参数使内联更有价值
SCCP → Devirtualization: 常量对象类型实现去虚化
SCCP → Loop Unrolling: 常量循环边界支持完全展开

4.5 InstCombine:模式匹配的威力与陷阱

InstCombine 是 LLVM 中最大、最复杂,也是最具争议的优化 Pass。它通过模式匹配和局部转换,实现了数千种优化规则。

InstCombine 的设计哲学

InstCombine 的核心理念是”规范化”而非”优化”:

规范化 vs 优化:
规范化目标:将 IR 转换为标准形式,便于后续优化
优化目标:直接改进代码性能

示例:
原始: (x + 1) + 2
规范化: x + 3  // 常量合并
原始: x * 2 * 2
规范化: x * 4  // 结合律应用

这种设计带来的好处:

  1. 简化后续 Pass:标准形式降低模式匹配复杂度
  2. 提高优化机会:规范化后更容易发现优化机会
  3. 减少 Pass 间依赖:各 Pass 可假定输入已规范化

规范化 vs 优化的权衡

然而,过度的规范化也带来问题:

权衡示例:
情况1:指针算术
原始: gep (gep p, i), j
规范化: gep p, (i+j)
问题:可能破坏地址计算的局部性

情况2:比较操作
原始: (x < 5) && (x < 10)
规范化: x < 5
问题:可能影响分支预测

情况3:位操作
原始: (x & 0xFF) | (y & 0xFF00)
规范化: 取决于具体模式
问题:可能破坏 SIMD 向量化机会

2018 年重大 bug 事件分析

2018 年 6 月,InstCombine 中发现了一个存在多年的严重 bug,这个事件深刻影响了 LLVM 的开发流程:

Bug 描述:
错误模式: (X << C1) >>u C2 → X << (C1-C2)
问题:未检查 C1 < C2 的情况
影响:错误的代码生成,安全漏洞

Bug 产生的根本原因:

  1. 复杂度失控:InstCombine 包含超过 50,000 行代码
  2. 测试不足:边界情况覆盖不全
  3. 缺乏形式化验证:依赖人工审查

社区响应:

短期措施:
1. 紧急修复并发布补丁
2. 增加回归测试
3. 代码审查流程加强

长期改进:
1. 引入 Alive2 形式化验证
2. 重构 InstCombine 架构
3. 建立模式描述语言

维护成本与收益的平衡

InstCombine 的维护是 LLVM 社区的重大挑战:

维护成本分析:
代码规模:50,000+ 行
月均提交:30-50 个
Bug 修复时间:平均 2-3 天
新规则添加:需要 3-5 轮审查

收益评估(基于 SPEC2017):

未来方向:

  1. TableGen 化:使用声明式描述替代手写代码
  2. 分层架构:将 InstCombine 拆分为多个专门 Pass
  3. 机器学习辅助:自动发现和验证优化模式

4.6 优化顺序的探索:Phase Ordering Problem

Phase Ordering Problem 是编译器优化中的经典难题:不同的优化顺序可能导致截然不同的结果。

优化 Pass 的相互依赖

LLVM 中的优化 Pass 存在复杂的依赖关系:

依赖关系图(简化):
        Inlining
           ↓
      InstCombine ←→ SimplifyCFG
           ↓              ↑
         SCCP    →    DCE
           ↓              ↑
          GVN     →    DSE
           ↓
    Loop Optimizations

依赖类型分析:

  1. 使能依赖:Pass A 创造 Pass B 的优化机会
  2. 破坏依赖:Pass A 破坏 Pass B 的前提条件
  3. 协同依赖:Pass A 和 B 相互增强

-O0 到 -O3 的 Pipeline 设计

LLVM 的优化级别设计经历了多次演化:

优化级别演进(2024年版本):

-O0: 无优化
  → 仅运行必要的规范化 Pass

-O1: 基础优化
  → SimplifyCFG
  → InstCombine
  → 基础 DCE

-O2: 平衡优化(默认)
  → 早期优化:Inlining, SimplifyCFG
  → 标量优化:SCCP, GVN, InstCombine
  → 循环优化:LoopRotate, LICM, LoopUnroll
  → 后期清理:DCE, SimplifyCFG

-O3: 激进优化
  → 更激进的 Inlining(阈值提高 2.5 倍)
  → 完整的循环向量化
  → 更多的循环展开
  → Polly(如果启用)

-Os/-Oz: 大小优化
  → 禁用循环展开
  → 限制内联
  → 优先选择代码大小优化

固定顺序 vs 自适应调度

传统的固定顺序方法面临诸多限制:

固定顺序的问题:
1. 无法适应不同代码特征
2. 某些优化机会被永久错过
3. 重复运行 Pass 导致编译时间增加

自适应调度的探索:

研究方向:
1. 基于启发式的调度
   - 分析代码特征(循环密度、调用图复杂度)
   - 动态调整 Pass 顺序
   
2. 迭代优化
   - 重复运行 Pass 直到达到不动点
   - 使用增量分析减少开销
   
3. 机器学习方法
   - 学习最优 Pass 序列
   - 预测 Pass 的效果

实证研究与性能分析

2020 年的一项研究分析了不同 Pass 顺序对性能的影响:

实验设置:
基准测试:SPEC2017, LLVM Test Suite
测试配置:100 种随机 Pass 序列
测量指标:性能、代码大小、编译时间

关键发现:
1. 性能差异:最好与最差序列相差可达 25%
2. 关键 Pass:Inlining 和 Loop 优化的位置最关键
3. 局部最优:90% 的序列陷入局部最优
4. 通用序列:不存在对所有程序都最优的序列

LLVM 社区的实践经验:

经验规则:
1. Early Inlining:尽早内联以暴露优化机会
2. Canonicalization First:先规范化再优化
3. Loop Before Scalar:循环优化优先于标量优化
4. Cleanup Last:最后进行死代码清理

4.7 高级话题:Alive2 项目与优化正确性的形式化验证

Alive2 是由 Nuno Lopes 领导开发的形式化验证工具,它从根本上改变了 LLVM 优化的开发和验证方式。

Alive2 的诞生背景

2014 年,随着 LLVM 优化 Pass 的复杂度不断增加,正确性问题日益突出:

问题统计(2014-2018):
- 报告的优化 bug:年均 150+
- 导致误编译的 bug:年均 30+
- 平均修复时间:2-4 周
- 回归 bug 比例:15%

传统测试方法的局限:

  1. 覆盖率不足:无法测试所有输入组合
  2. 语义gap:测试用例与实际代码存在差异
  3. 维护成本高:大量回归测试拖慢开发

SMT 求解器在验证中的应用

Alive2 的核心是将优化正确性问题转化为 SMT(Satisfiability Modulo Theories)问题:

验证流程:
1. 解析优化前后的 LLVM IR
2. 转换为 SMT 公式
3. 使用 Z3 求解器验证等价性
4. 如果不等价,生成反例

形式化模型:

\[\text{Correct}(T) \iff \forall i \in \text{Input}, \text{Sem}(T(P))(i) = \text{Sem}(P)(i)\]

其中 $T$ 是转换,$P$ 是程序,$\text{Sem}$ 是语义函数。

关键技术创新:

  1. Undefined Behavior 建模:精确处理 C/C++ 的 UB
  2. 内存模型:支持指针算术和别名分析
  3. 浮点语义:IEEE 754 完整支持
  4. 并发语义:原子操作和内存序

已发现的重要 bug 案例

Alive2 发现了大量隐藏多年的 bug:

案例1:Select 优化错误(2019)
错误转换:
  select i1 %c, i32 undef, i32 %x
  →
  i32 %x
问题:忽略了 undef 的语义

案例2:GEP 折叠错误(2020)
错误转换:
  gep [0 x i8], [0 x i8]* %p, i64 %idx
  →
  gep i8, i8* %p, i64 %idx
问题:零大小数组的特殊语义

案例3:NSW 标志传播错误(2021)
错误转换:
  %r = add nsw i32 %x, %y
  %s = sub i32 0, %r
  →
  %s = sub nsw i32 0, %r
问题:NSW 标志不能简单传播

形式化方法的局限性与未来

尽管 Alive2 取得了巨大成功,但仍存在局限:

当前局限:
1. 可扩展性:大函数验证时间过长
2. 完整性:某些 IR 特性未完全支持
3. 性能模型:只验证正确性,不考虑性能
4. 并发支持:并发程序验证仍不完善

未来发展方向:

研究方向:
1. 增量验证:只验证改动部分
2. 概率验证:使用统计方法加速
3. 性能验证:形式化性能模型
4. 自动修复:从反例生成修复补丁

对 LLVM 开发流程的影响:

流程改进:
1. CI 集成:所有 InstCombine 改动必须通过 Alive2
2. 预提交验证:开发者本地运行 Alive2
3. 规范文档:基于 Alive2 模型编写规范
4. 教育培训:新贡献者学习形式化思维

4.8 本章小结

本章深入探讨了 LLVM 标量优化的设计、实现和演化。我们从经典算法的工程实现开始,追踪了 GVN、SCCP、InstCombine 等核心优化的历史演变,分析了 Phase Ordering Problem 这一编译器优化的根本挑战,最后介绍了 Alive2 形式化验证工具如何革新优化开发。

关键要点

  1. 工程与理论的平衡:LLVM 的标量优化不是教科书算法的简单实现,而是在理论正确性、工程可行性和实用效果之间的精妙平衡。

  2. 演化驱动的设计:每个优化 Pass 都经历了多次重大重构,这些演化反映了社区对问题理解的深化和工程经验的积累。

  3. 正确性的代价:2018 年 InstCombine bug 事件表明,即使是成熟的优化也可能存在严重缺陷,形式化验证是保障正确性的必要手段。

  4. 相互作用的复杂性:Phase Ordering Problem 揭示了优化间相互作用的复杂性,没有普适的最优解,只有基于经验的工程权衡。

  5. 形式化方法的价值:Alive2 的成功证明了形式化方法在实际工程中的价值,它不仅发现了大量 bug,更改变了开发者的思维方式。

历史视角

标量优化演化时间线:
2003-2005: 基础框架建立,简单优化实现
2006-2010: 算法改进期,性能大幅提升
2011-2015: 架构重构期,New PM 等基础设施改进
2016-2020: 正确性危机,Alive2 等验证工具兴起
2021-现在: 智能化探索,ML 辅助优化决策

未来展望

标量优化的未来发展将聚焦于:

  1. 自动化:使用机器学习自动发现优化模式
  2. 正确性:更完善的形式化验证覆盖
  3. 可维护性:声明式优化描述语言
  4. 自适应:根据代码特征动态调整优化策略

4.9 练习题

练习 4.1:理解 DCE 的控制依赖

考虑以下 LLVM IR 代码:

define i32 @foo(i32 %x) {
entry:
  %cmp = icmp eq i32 %x, 0
  br i1 %cmp, label %then, label %else
then:
  %add = add i32 %x, 1
  br label %end
else:
  %sub = sub i32 %x, 1
  br label %end
end:
  ret i32 %x
}

问:ADCE 能删除哪些指令?为什么?

提示:考虑哪些指令对返回值有贡献。

答案 ADCE 可以删除 `%add` 和 `%sub` 指令,因为它们的结果没有被使用。虽然这些指令在可达的基本块中,但它们对函数的返回值和任何副作用都没有贡献。`%cmp` 指令和分支指令需要保留,因为它们控制程序的执行流程。 关键理解:ADCE 不仅考虑指令是否可达,还考虑指令是否对程序的可观察行为有贡献。

练习 4.2:GVN 的值编号计算

给定以下代码片段:

%a = add i32 %x, %y
%b = add i32 %y, %x
%c = mul i32 %a, 2
%d = mul i32 %b, 2

问:GVN 如何处理这段代码?最终会保留几条指令?

提示:考虑交换律和值编号的传播。

答案 GVN 会识别出 `%a` 和 `%b` 是等价的(加法交换律),因此它们会被赋予相同的值编号。随后,`%c` 和 `%d` 也会被识别为等价。 最终代码: ```llvm %a = add i32 %x, %y %c = mul i32 %a, 2 ``` 只保留 2 条指令,`%b` 的使用会被替换为 `%a`,`%d` 的使用会被替换为 `%c`。

练习 4.3:SCCP 的格值传播

分析以下函数的 SCCP 过程:

define i32 @test(i1 %cond) {
entry:
  br i1 %cond, label %then, label %else
then:
  br label %merge
else:
  br label %merge
merge:
  %phi = phi i32 [5, %then], [5, %else]
  %result = add i32 %phi, 10
  ret i32 %result
}

问:SCCP 能推导出什么常量?最终代码是什么样的?

提示:注意 phi 节点的两个输入值。

答案 SCCP 分析过程: 1. `%phi` 的两个输入都是常量 5,因此 `%phi = 5` 2. `%result = 5 + 10 = 15` 最终优化后的代码: ```llvm define i32 @test(i1 %cond) { entry: ret i32 15 } ``` 所有的控制流和计算都被消除,函数直接返回常量 15。这展示了 SCCP 的强大能力:它能够跨基本块传播常量,并通过 phi 节点合并信息。

练习 4.4:InstCombine 的规范化陷阱

考虑以下转换是否正确:

原始: %r = udiv i32 %x, 4
转换: %r = lshr i32 %x, 2

问:这个转换总是正确的吗?如果不是,需要什么前提条件?

提示:考虑无符号除法和逻辑右移的语义差异。

答案 这个转换是正确的。对于无符号整数,除以 2 的幂次可以安全地转换为逻辑右移: - `udiv %x, 4` 等价于 `lshr %x, 2` 但如果是有符号除法,情况会不同: ```llvm 错误: %r = sdiv i32 %x, 4 → %r = ashr i32 %x, 2 // 对负数不正确! ``` 对于负数,有符号除法向零舍入,而算术右移向负无穷舍入。例如: - `-3 / 4 = 0`(向零舍入) - `-3 >> 2 = -1`(向负无穷舍入) 这是 InstCombine 中常见的正确性陷阱之一。

练习 4.5:Phase Ordering 实例

给定以下代码序列:

%a = mul i32 %x, 0
%b = add i32 %a, %y
%c = mul i32 %b, 2

问:不同的优化顺序会产生什么结果?

  1. 先 InstCombine 后 DCE
  2. 先 DCE 后 InstCombine

提示:考虑每个 Pass 能识别的模式。

答案 顺序 1: InstCombine → DCE ```llvm InstCombine: %a = mul i32 %x, 0 → %a = 0 %b = add i32 0, %y → %b = %y %c = mul i32 %y, 2 → %c = shl i32 %y, 1 DCE: %a = 0 // 删除(未使用) 最终: %c = shl i32 %y, 1 ``` 顺序 2: DCE → InstCombine ```llvm DCE: 所有指令都被使用,无法删除 InstCombine: 同顺序 1,但 %a 保留 最终: %a = 0, %c = shl i32 %y, 1 ``` 这个例子展示了 Phase Ordering 的影响:先运行 InstCombine 能暴露更多死代码,使 DCE 更有效。

练习 4.6:Alive2 验证实践

使用 Alive2 的思维方式,判断以下优化是否正确:

转换: select i1 %c, i32 %x, i32 %x
   → i32 %x

问:这个转换是否总是正确的?考虑所有可能的情况。

提示:考虑 %c 可能是 poison 或 undef。

答案 这个转换在大多数情况下是正确的,但需要考虑特殊值: 1. **正常情况**:如果 %c 是普通布尔值(true/false),转换正确 2. **Undef 情况**:如果 %c 是 undef,原始 select 可能选择任一分支,但两个分支相同,所以结果仍是 %x 3. **Poison 情况**:如果 %c 是 poison,原始 select 产生 poison,但优化后只返回 %x **问题**:当 %c 是 poison 时,原始代码产生 poison,优化后代码产生 %x,这改变了语义! **正确的转换**:需要 freeze 指令 ```llvm select i1 %c, i32 %x, i32 %x → %c.frozen = freeze i1 %c select i1 %c.frozen, i32 %x, i32 %x → i32 %x ``` 这个例子展示了 LLVM 中 poison/undef 语义的微妙之处,也是 Alive2 经常发现 bug 的地方。

练习 4.7:优化复合效果分析

考虑以下函数,分析完整优化流程:

define i32 @compound(i32 %n) {
entry:
  %cmp = icmp eq i32 %n, 10
  br i1 %cmp, label %then, label %else
then:
  %add = add i32 %n, 5
  br label %merge
else:
  %mul = mul i32 %n, 0
  br label %merge
merge:
  %phi = phi i32 [%add, %then], [%mul, %else]
  ret i32 %phi
}

问:经过 SCCP、InstCombine、SimplifyCFG 后,最终代码是什么?

提示:逐步应用每个优化,注意它们的相互作用。

答案 优化步骤: 1. **SCCP**: - then 块:%n = 10(从条件推导),所以 %add = 15 - else 块:%mul = %n * 0 = 0 2. **InstCombine**: - 识别常量并替换 3. **SimplifyCFG**: - 简化 phi 节点为 select 最终代码: ```llvm define i32 @compound(i32 %n) { entry: %cmp = icmp eq i32 %n, 10 %result = select i1 %cmp, i32 15, i32 0 ret i32 %result } ``` 这个例子展示了多个优化 Pass 的协同效果:SCCP 的条件常量传播、InstCombine 的模式识别和 SimplifyCFG 的控制流简化共同作用,将复杂的控制流转换为简单的 select 指令。

4.10 常见陷阱与错误

陷阱 1:忽视 Undefined Behavior

错误模式:
int x = INT_MAX;
x + 1;  // UB in C/C++

LLVM IR:
%add = add nsw i32 %x, 1  // nsw 表示有符号溢出是 UB

优化器可能的假设:
- %x 不可能是 INT_MAX
- 可以删除溢出检查
- 可以重排涉及 %add 的计算

正确做法:理解并正确使用 nsw/nuw 标志,必要时使用 saturating 或 wrapping 算术。

陷阱 2:Phase Ordering 依赖

问题代码:
void optimize_function(Function *F) {
  runDCE(F);        // 先死代码消除
  runInlining(F);   // 后内联
}

问题:内联可能引入新的死代码,但 DCE 已经运行过了

正确做法:在关键优化后重复运行清理 Pass。

陷阱 3:过度优化导致的性能退化

案例:循环内的除法优化
原始: for(i=0; i<n; i++) result += x / 7;
优化: magic = 0x24924925; // 除以7的魔数
      for(i=0; i<n; i++) result += (x * magic) >> 35;

问题:
- 现代 CPU 的除法单元可能更快
- 乘法+移位占用更多寄存器
- 破坏了指令级并行

正确做法:使用 TargetTransformInfo 查询目标架构特性。

陷阱 4:错误的代数变换

错误: (x / y) * y → x
反例: x = 5, y = 2
      (5 / 2) * 2 = 2 * 2 = 4 ≠ 5

错误: x * 2.0 / 2.0 → x
反例: x = INF
      INF * 2.0 = INF
      INF / 2.0 = INF
      但编译器可能优化为 x = INF
      如果 x 原本很大但不是 INF,结果可能不同

正确做法:严格遵守语言标准的算术规则。

陷阱 5:Poison 值传播

危险模式:
%a = add nsw i32 %x, 1  // 可能产生 poison
%b = add i32 %a, %y     // poison 传播
%c = select i1 %cond, i32 %b, i32 0  // 即使不选择 %b,整个 select 也是 poison!

正确做法:使用 freeze 指令阻止 poison 传播。

4.11 最佳实践检查清单

设计新优化时

实现优化时

测试优化时

调试优化问题时

代码审查时

维护优化代码时