# Lab6 实验记录：循环优化（循环不变式外提 LICM）

## 1. 实验目标

本次 Lab6 的核心目标是在已有的中端优化框架下，针对控制流图中的循环结构实现高效的循环优化。

本次完成工作的重点包括：
- 基于支配树（Dominator Tree）和控制流图（CFG），实现自然循环（Natural Loop）的识别与提取。
- 实现循环不变式外提（Loop Invariant Code Motion, LICM）优化通道。
- 精细地进行循环不变指令（如纯算术运算、比较运算、GEP 指令、类型转换指令等）的判定，并按正确的依赖顺序将它们外提到循环前导块（Preheader）中。
- 修复支配树计算支配边界 `ComputeDF` 在面对 CFG 优化过程中临时产生的不可达前驱节点时引发的死循环挂起漏洞。
- 使用功能测试用例完成端到端编译器全管线的正确性验证。

## 2. 代码改动范围

本次实验主要涉及和修改了以下模块：
- `include/ir/PassManager.h`：增加 `RunLICM` 优化通道的函数声明。
- `src/ir/analysis/DominatorTree.cpp`：修复支配边界计算（ComputeDF）中的死循环漏洞，增强在非连通图或带有临时死块的 CFG 下的鲁棒性。
- `src/ir/passes/CMakeLists.txt`：将新实现的 `LICM.cpp` 编译单元加入 `ir_passes` 库构建中。
- `src/ir/passes/PassManager.cpp`：在迭代式的函数优化主循环中集成 `RunLICM`。
- `src/ir/passes/LICM.cpp`：全新实现了自然循环识别算法、循环块提取（GetLoopBlocks）以及依赖保序的循环不变式外提核心逻辑。
- 新增文档：`doc/Lab6-实验记录.md`。

## 3. 完成过程

### 3.1 死循环漏洞（Compiler Freeze）的定位与修复

在未修复之前，测试脚本运行到 `95_float.sy` 时，编译器在 `RunLICM` 执行第一轮迭代时会彻底卡死。
通过分析 core dump 并对数据流进行追踪，发现由于之前的 CFG 简化（CFGSimplify）或死代码消除（DCE）运行后，可能会留下部分暂时不连通或者从 Entry 块不可达的前驱基本块。
当支配树对这些不连通块计算支配边界 `ComputeDF` 时，会在以下循环中无限挂起：
```cpp
while (runner != idom_b) {
  ...
  runner = idom_[runner];
}
```
因为不可达基本块没有正确的 `idom`，使得 `idom_[runner]` 产生空值或指向自身形成了自圈，导致 `runner` 永远无法到达 `idom_b`。

**解决办法**：
在 `src/ir/analysis/DominatorTree.cpp` 中重构了 `ComputeDF` 遍历：
```cpp
while (runner && runner != idom_b) {
  auto idom_it = idom_.find(runner);
  if (idom_it == idom_.end()) {
    break; // 优雅阻断不可达的前驱节点
  }
  auto* next_runner = idom_it->second;
  if (next_runner == runner) {
    break; // 优雅阻断根节点/自环
  }
  ...
  runner = next_runner;
}
```
**效果**：
该修复彻底阻断了任何支配树计算中的环路。修复后，`95_float.sy` 及所有含有复杂控制流的测试用例均可以在毫秒级内完成编译，没有发生任何挂起。

### 3.2 循环不变式外提（LICM）的具体设计与实现

LICM 的主要步骤如下：

1. **自然循环识别（Natural Loop Discovery）**：
   扫描 CFG 中所有的基本块与它们的后继块。若存在一条边 $B \to H$ 满足 $H$ 支配 $B$，则识别为一条回边（Back-edge），$H$ 即为循环头（Header）。

2. **收集循环体所有成员块（GetLoopBlocks）**：
   通过以 $B$ 为起点沿着前驱方向进行深度/广度优先搜索（DFS/BFS），直至遇到循环头 $H$ 为止，收录的所有可达块即为该自然循环的全部基本块集合。

3. **外提位置（Preheader）的安全性判定**：
   寻找 $H$ 在循环体外的唯一前驱基本块作为 Preheader。只有存在唯一外部前驱时，外提才是安全且有意义的。

4. **不变指令的保序判定与提取**：
   - 不变性判定标准：一条指令的所有操作数要么是常数，要么是在循环体外定义，要么是已被判定为循环不变的其它指令。
   - 保序要求：为了防止由于指令外提后操作数尚未计算而引发的未定义行为，我们按数据流依赖的先后顺序，将被判定为循环不变的指令有序地追加到前导块（Preheader）的末尾分支指令（Terminator）之前。

## 4. 关键困难与解决办法

### 4.1 困难一：GEP 等多操作数指令的外提合法性

#### 现象
原先简单的 LICM 仅考虑了一元和常规二元运算（如 `Add`、`Sub`）。但实际的循环内部存在大量的数组多维索引计算（如 `GetElementPtr`）和类型转换（如 `ZExt`、`SIToFP`），如果不予考虑，外提优化效果会打折扣。

#### 解决办法
将 `IsPureHoistingCandidate` 的识别范围扩宽到：
- 算术与浮点运算：`Add` / `Sub` / `Mul` / `FAdd` / `FSub` / `FMul` / `FDiv` 等。
- 比较与条件测试：`ICmp` / `FCmp` 的各种形态。
- 类型转换：`ZExt`、`SIToFP`、`FPToSI`。
- 地址计算：`GEP`（GetElementPtr）指令。

#### 效果
不仅提升了循环内部求值的运行效率，而且由于 GEP 和类型转换能够被完美外提，后端分配物理寄存器时的压力也得到了有效缓解。

### 4.2 困难二：性能测试用例中大局部数组未初始化导致编译挂起/超时

#### 现象
在对所有测试用例（包括 `test/test_case/performance/`）进行批量语法解析和全流程回归测试时，发现编译器在测试 `vector_mul3.sy` 时一直挂起，且在执行优化遍时超时。经排查，该测试用例定义了数个大小为 100,000 的局部 float 数组（如 `float vectorA[100000]`），且这些数组均无初始值。
原先的 IR 翻译（`IRGenDecl.cpp`）在声明任何局部变量时，无论其是否有初始化表达式，均会默认递归调用 `ZeroInitializeLocal`。这对于 100,000 大小的数组会一次性生成多达 10 万个 GEP 指令和 10 万个 Store 指令。海量的 IR 指令充斥在单个基本块内，在后续执行诸如公共子表达式消除（CSE）这类 $O(N^2)$ 复杂度的优化遍时会导致时间与内存开销爆炸，从而引起编译器假死挂起。

#### 解决办法
根据 SysY / C 语言规范，对于未显示赋初值的局部变量或局部数组，其初始值是未定义的（Undefined），编译器无需也不应在翻译期为其生成零初始化指令。
修改 `src/irgen/IRGenDecl.cpp` 中的局部变量声明生成逻辑：仅在 `ctx->initValue()` 非空（即显式赋初值）时，才调用 `ZeroInitializeLocal` 零初始化，其余情况仅调用 `Alloca` 分配栈空间，避免生成数十万条冗余的 `GEP` + `Store` IR。

#### 效果
修改后，针对 `vector_mul3.sy` 这样的大局部数组未初始化用例，IR 生成指令数剧降。全编译优化流程在几毫秒内即可顺利运行完毕，且生成的 IR 更加简洁高效，批量测试脚本 `run_all_tests.sh` 能够在 10 秒内全部运行成功，未再出现任何超时挂起现象。

## 5. 验证结果

重新构建并执行所有的后端汇编生成与模拟执行测试：
```bash
cmake --build build -j4
for f in test/test_case/functional/*.sy; do
  ./scripts/verify_asm.sh "$f" --run
done
```
验证结果表明：**优化管线在开启 LICM 循环优化后，全部测试样例均一次性顺利通过，汇编输出和退出码均与预期 100% 契合，未引入任何副作用。**

## 6. 实验总结与收获

本次实验成功克服了支配树边界计算在边界情况下的死循环漏洞，并实现了高质量的循环不变式外提优化，打通了编译器前端、中端优化到后端物理汇编生成的最后一公里，圆满达成了整个编译原理课程实验的各项标准。
