Skip to content

Commit

Permalink
update midend docs
Browse files Browse the repository at this point in the history
  • Loading branch information
Ksitta committed Sep 5, 2024
1 parent ac60bf1 commit 9d18af0
Show file tree
Hide file tree
Showing 7 changed files with 279 additions and 66 deletions.
10 changes: 10 additions & 0 deletions SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,16 @@
* [实验要求](docs/step13/intro.md)
* [实验指导](docs/step13/example.md)

## 大实验参考文档

* 大实验(文档正在编写中)
* [大实验简介](docs/contest/intro.md)
* [中端设计](docs/contest/midend/midend.md)
* [静态单赋值](docs/contest/midend/ssa.md)
* [复写传播](docs/contest/midend/rp.md)
* [常量传播](docs/contest/midend/cp.md)
* [死代码消除](docs/contest/midend/dce.md)

## 参考资料

* [参考资料](REFERENCE.md)
7 changes: 7 additions & 0 deletions docs/contest/intro.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,12 @@

为了简化课程实验,我们的基础实验框架在设计时并未考虑大实验的需求(例如:IR的类型系统简易)

## 参考实现进度及顺序

1. 设计IR
2. 完成前端
3. 完成后端
4. 增加中端优化和后端优化

## 评分方法

36 changes: 36 additions & 0 deletions docs/contest/midend/cp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# 常量传播

## 原理

常量传播的目的在于发掘代码中可能存在的常量,尽量用对常量的引用替代对虚拟寄存器的引用(虚拟寄存器和变量是同一个概念,以下都使用变量),并尽量计算出可以计算的常量表达式。

为了实现常量传播,类比于前两种数据流,不难想象常量传播的数据流是"可用常量分析",找出在一条语句处有哪些对变量赋的常量值可以到达。每一条涉及到赋值的语句处,杀死之前与左端项相关的赋值,如果右端项为变量则生成对左端项的变量赋值;反之如果对左端项赋予了一个常量值或者常量表达式,则生成对左端项的常量赋值。

形式化地,常量传播的值集是$R \rightarrow V$的函数的集合。其中$R$是变量,$V$是集合$变量的所有可能取值 \cup \{NAC, UNDEF\}$,它用来表示变量在一个程序点处的取值。其中$NAC$表示已知它的值不是一个常量;$UNDEF$表示不知道它的取值,出现$UNDEF$是因为这个程序点处这个变量尚未赋值。

> 如果你觉得函数太抽象了,可以认为值集就是一个元素类型为$V$的数组,用变量的id为下标访问,实际上也是这么实现的。
>
> 这个数据流和其他数据流有一个很大的区别:它的值集的大小是无限的。不过这并不影响数据流分析算法的收敛性,感兴趣的同学可以自己查阅相关的资料。
单条语句的传递函数$f_S$定义为:设$m' = f_S(m)$(注意$m$和$m'$都属于值集,所以它们都是函数),对于语句$S$:

- 如果$S$没有给任何变量赋值,则$m' = m$
- 否则,设$S$对变量$x$赋值了(假设一条语句最多只能给一个变量赋值),则$m'(y) = m(y), \forall y \in R, y \ne x$,此外:
- 如果赋值的右端项是常数$c$,则$m'(x) = c$
- 如果赋值的右端项是表达式$y \oplus z$($\oplus$是任意一个运算符,也不仅局限于两个运算数的情况,单目运算和复写语句都可以归于此类,规则是类似的)则:
- 如果$m(y)$和$m(z)$都是常数,则$m'(x) = m(y) \oplus m(z)$
- 如果$m(y)$和$m(z)$中有一个是$NAC$,则$m'(x) = NAC$
- 否则,$m'(x) = UNDEF$
- 否则,$m'(x) = NAC$,这里可能包括赋值的右端项是函数的返回值,是访存的结果之类的,在简单的优化器中都直接认为这些结果不是常数

当两个基本块交汇的时候,对于每个变量,需要考虑以下几种情况:

- 如果两个基本块末尾处它都是常量,且两个常量值相等,则交汇结果为这个常量
- 如果一个基本块末尾处它是常量,另一个基本块结尾处它是$UNDEF$,则交汇结果为**这个常量**
- 一旦出现了这样的情形,可能意味着后续程序中有使用未赋初值就使用一个变量的行为,优化器可以依据"未赋初值的变量可以有任何取值",认为这个取值就是另一个基本块的常量值,从而就回到了前一种情形
- 如果两个基本块末尾处它都是$UNDEF$,则交汇结果为$UNDEF$
- 否则,交汇结果为$NAC$

这个交汇操作用格图表示如下:

![int_lattice](./pic/int_lattice.png)
37 changes: 37 additions & 0 deletions docs/contest/midend/dce.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# 死代码消除

死代码消除即无用代码消除,死代码和不可达代码是两个概念。前者指的是执行之后没有任何作用的代码(例如:多余的计算),后者指的是永远无法被执行到的代码。

活跃变量分析为每个程序点计算出所有变量的集合的子集,用于表示该点处活跃的变量,所以数据流分析的值集为所有变量的集合的幂集。"活跃"的含义是在程序执行过这一点**之后**,这个变量**当前的值**会被使用到,所以数据流分析是后向的。对于单个语句$S$,传递函数要根据$S$之后活跃的变量计算$S$之前活跃的变量,计算方法为:所有$S$用到的变量在$S$之前都是活跃的,所有$S$之后活跃的变量,如果没有在$S$中被定值,证明未来的那次使用用的还是$S$之前的值,所以也是活跃的。

综合得,传递函数定义为:$f_S(x) = (x - def_S) \cup use_S$。其中$def_S$是$S$中定值的所有变量的集合,$use_S$是$S$中使用的所有变量的集合。

基本块$B$的传递函数定义为:

$$
f_B(x) = f_{S_1}(...f_{S_{n - 1}}(f_{S_n}(x))) \\
= (((((x - def_{S_n}) \cup use_{S_n}) - def_{S_{n - 1}}) \cup use_{S_{n - 1}} ...) - def_{S_1}) \cup use_{S_1}\\
\overset{\mathrm{数学归纳法}}{=} (x - \bigcup_{i = 1}^n def_{S_i}) \cup \bigcup_{i = 1}^n (use_{S_i} - \bigcup_{j = 1}^{i - 1} def_{S_j})
$$

最后一个等号使用数学归纳法来证明,读者自证不难。

定义$def_B = \bigcup_{i = 1}^n def_{S_i}$,$use_B = \bigcup_{i = 1}^n (use_{S_i} - \bigcup_{j = 1}^{i - 1} def_{S_j})$,这样上面的式子就是大家熟悉的形式了。那么这个形式和课堂上定义的$LiveUse$和$Def$是一致的吗?

![](pic/aliveness.png)

先看$use_B$和$LiveUse$,$LiveUse$为$B$中定值之前被引用的变量的集合,而$use_B$定义中每一个求并项都是一条语句的$use$集合减去在这条语句前面的所有$def$集,也就是说如果一个变量在某条语句中被使用了,而且没有在这条语句之前的任何一条语句被定值,那么它属于$use_B$,此外都不属于。显然,这与$LiveUse$的定义是符合的。

再看$def_B$和$Def$,其实很容易可以看出这两个集合并不相同,$def_B$包含了被定值的所有变量,而$Def$要求定值之前没有引用过,所以$Def \subseteq def_B$。然而这个区别不会影响任何计算结果:如果变量$x$满足$x \in def_B, x \notin Def$,则意味着它定值之前被引用过,则$x \in use_B, x \in LiveUse$,则它一定在这一步的结果集合中。

> 所以,$Def$这样的定义是冗余的,只会加大计算$Def$时的计算量,使用$def_B$的定义就会简单一些,而且不会影响最终结果。
这里讲一下几个常见的需要注意的点:

进行死代码删除的时候,如果一条语句**没有副作用**,而且它的赋值目标(如果有的话)不在$out_S$中,那么这条语句就可以删去
- 所谓的副作用,其实就是除了"改变赋值目标变量"之外,其他所有的作用。显然,tac中没有既没有副作用,同时也没有赋值目标的语句
- 你在实现的时候可以认为除了`a = call b`之外的所有有赋值目标的语句都是没有副作用的,对于`a = call b`,如果$a \notin out_S$,要求将它优化为`call b`

其实还有一些语句的"副作用"不是很明确,比如除0,有符号整数溢出等(依平台而定),可能会导致程序崩溃,但是**优化的时候可以不把这当成是副作用**:按照c/c++常用的说法这叫未定义行为,这可以减轻编译器作者的负担,编译器可以假定程序永远没有未定义行为,并以此为依据来优化。

我们的测试样例中不会出现未定义行为,所以可以放心地忽略掉这些副作用。
98 changes: 98 additions & 0 deletions docs/contest/midend/midend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# 中端介绍

中端的设计包括两个部分:中间表示的设计和中端优化。

## 中间表示

前端的解析和中端设计密不可分,通常,我们需要设计一个中间表示(Intermediate Representation, IR)来连接前端和后端。也只有我们定义好了中间表示,前端才知道怎么处理AST。

### 什么是中间表示?

中间表示(也称中间代码,intermediate representation / IR)是介于语法树和汇编代码之间的一种程序表示。 它不像语法树一样保留了那么多源程序的结构,也不至于像汇编一样底层。

由于源语言(MiniDecaf)和目标语言(RISC-V 汇编)一般存在较大的差别,因此直接把源语言翻译为目标语言中的合法程序通常是比较困难的。大多数编译器实现中所采取的做法,是首先把源语言的程序翻译成一种相对接近目标语言的中间表示形式,然后再从这种中间表示翻译成目标代码。中间表示(IR)的所带来的优势如下:

- 缩小调试范围,通过把 AST 到汇编的步骤一分为二。如果目标代码有误,通过检查 IR 是否正确就可以知道:是AST 到 IR 翻译有误,还是 IR 到汇编翻译有误。 将 AST 转换到汇编的过程分成两个步骤,每个步骤代码更精简,更易于调试。
- 适配不同指令集(RISC-V, x86, MIPS, ARM...)和源语言(MiniDecaf, C, Java...)。由于不同源语言的 AST 不同,直接从 AST 生成汇编的话,为了支持 N 个源语言和 M 个目标指令集,需要写 N * M 个目标代码生成模块。如果有了 IR,只需要写 N 个 IR 生成器和 M 个汇编生成器,只有 N + M 个模块。

- 便于优化,中间表示可以附带一些额外信息,比如类型信息、控制流信息等,这些信息辅助编译器进行优化。

例如以下是一个IR代码的例子:

```assembly
_main:
_T1 = 0
_T2 = 100
_T3 = 0
_L0:
_T4 = _T1 < _T2
beqz _T4, _L1, _L2
_L1:
_T3 = _T1 + _T3
_T1 = _T3 + 1
jump _L0
_L2:
_T5 = 2
ret _T5
```

从这个IR例子中,我们可以看到,相对于c语言,IR中没有了while、for这样的循环语句,而是通过标签和jump、branch指令来实现循环。高级语言的许多特性在IR中都被抹去了,让代码更加简洁,便于优化。而相对于汇编代码,IR中无需关注寄存器、函数调用的上下文切换等信息,与具体的硬件架构解耦。

### 中间表示的设计

#### 三地址码

进一步地,你可以实现符合[静态单赋值](./ssa.md)要求的IR,静态单赋值的IR在编译器中有着广泛的应用,比如 LLVM 的 IR 就是一种静态单赋值的 IR。在静态单赋值的IR中,每个变量只被赋值一次,这使得编译器可以更容易地进行优化。

## 中端优化

中端的优化是编译器的一个重要组成部分,它可以在保持程序功能不变的前提下,提高程序的性能。中端优化的目标是提高程序的性能,减少程序的运行时间和资源消耗。中端优化的方法有很多,比如常量传播、死代码消除、循环不变量外提、循环展开、函数内联等。

一个经典的例子是常量传播。常量传播是指将一个常量值替换为它的值,以便于在中端直接完成一些计算以降低运行时开销。比如,对于下面的 IR 代码:

```asm
_T1 = 5
_T2 = _T1 + 6
_T3 = _T2 + 7
_T4 = _T3 + 8
_T5 = _T4 + 9
ret _T5
```

经过常量传播优化后,可以得到:

```asm
_T1 = 5
_T2 = 11
_T3 = 18
_T4 = 26
_T5 = 35
ret _T5
```

进一步如果我们进行死代码消除,可以得到:
> 什么是死代码消除?
> 死代码消除是指删除程序中没有用到的代码,以减少程序的运行时间和资源消耗。
```asm
_T5 = 35
ret _T5
```

我们在文档中对三个优化进行简单介绍,详见[复写传播](./rp.md)[常量传播](./cp.md)[死代码消除](./dce.md)

## 中端参考资料

本章中我们以几个简单的例子介绍了什么是中间表示、中端优化以及如何做中端优化。此外我们也将会在这里给出一些中端优化的参考资料,供大家学习。

- [GCM & GVM](https://courses.cs.washington.edu/courses/cse501/06wi/reading/click-pldi95.pdf)

- [LLVM IR](https://llvm.org/docs/LangRef.html)

- [SSA book](https://pfalcon.github.io/ssabook/latest/book-full.pdf)


## 预期目标

完成这部分内容后,你的编译器应该能将 MiniDecaf 程序翻译成 IR,并能够输出 IR。进一步地,如果你希望参加大实验,你还需要实现一些中端优化。

36 changes: 36 additions & 0 deletions docs/contest/midend/rp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# 复写传播

复写(复制,赋值)传播的目的是借助复写语句,尽可能将 IR 中对复写左端项的引用替换成对右端项的引用。虽然表面上没有减少指令的条数,但这样做的好处在于优化后复写左端项有可能不再活跃,从而让死代码消除可以删除这条复写语句。

复写传播的实现方法与公共表达式提取是类似的,与可用表达式对应,它考虑的是**可用复写语句**。一条复写语句生成一条可用复写语句,一条带有定值的语句可以杀死与被定值的虚拟寄存器**相关**的所有复写语句(包括它出现在左端项或者右端项的)。除此之外,复写传播的数据流方向,交汇运算和初始值均与可用表达式的数据流相同。

按照老套路计算出每条语句处所有的可用复写语句后,考察一条语句使用的虚拟寄存器a,如果存在一条可用的复写语句,它的左端项是a,那么a可以被替换成它的右端项。这个过程还可以递归地进行下去:设这个右端项为b,如果存在一条可用的复写语句,它的左端项是b,则a可以进一步被替换成它的右端项c。这样就可以一步传播多层。

一个例子如下:

```
b = a
c = b
d = c
x = y + d
```

计算可用复写语句,每个语句后的方括号内的内容是这条语句执行****的可用复写语句集合:

```
b = a []
c = b [b = a]
a = x [b = a, c = b]
d = c [c = b, a = x] // 上一条语句杀死了b = a
x = y + d [c = b, a = x, d = c]
```

执行转换:

```
b = a
c = a # 因为b = a可用,所以b被替换成a
a = x
d = b # 因为c = b可用,所以c被替换成b
x = y + b # 因为d = c,c = b可用,所以d被替换成b
```
Loading

0 comments on commit 9d18af0

Please sign in to comment.