Files
nudt-compiler-cpp/doc/Lab6-实验记录.md

119 lines
7.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. 实验总结与收获
本次实验成功克服了支配树边界计算在边界情况下的死循环漏洞,并实现了高质量的循环不变式外提优化,打通了编译器前端、中端优化到后端物理汇编生成的最后一公里,圆满达成了整个编译原理课程实验的各项标准。