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

5.8 KiB
Raw Blame History

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 时,会在以下循环中无限挂起:

while (runner != idom_b) {
  ...
  runner = idom_[runner];
}

因为不可达基本块没有正确的 idom,使得 idom_[runner] 产生空值或指向自身形成了自圈,导致 runner 永远无法到达 idom_b

解决办法src/ir/analysis/DominatorTree.cpp 中重构了 ComputeDF 遍历:

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-edgeH 即为循环头Header

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

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

  4. 不变指令的保序判定与提取

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

4. 关键困难与解决办法

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

现象

原先简单的 LICM 仅考虑了一元和常规二元运算(如 AddSub)。但实际的循环内部存在大量的数组多维索引计算(如 GetElementPtr)和类型转换(如 ZExtSIToFP),如果不予考虑,外提优化效果会打折扣。

解决办法

IsPureHoistingCandidate 的识别范围扩宽到:

  • 算术与浮点运算:Add / Sub / Mul / FAdd / FSub / FMul / FDiv 等。
  • 比较与条件测试:ICmp / FCmp 的各种形态。
  • 类型转换:ZExtSIToFPFPToSI
  • 地址计算:GEPGetElementPtr指令。

效果

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

5. 验证结果

重新构建并执行所有的后端汇编生成与模拟执行测试:

cmake --build build -j4
for f in test/test_case/functional/*.sy; do
  ./scripts/verify_asm.sh "$f" --run
done

验证结果表明:优化管线在开启 LICM 循环优化后,全部测试样例均一次性顺利通过,汇编输出和退出码均与预期 100% 契合,未引入任何副作用。

6. 实验总结与收获

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