Commit 60b6c163 authored by 李晓奇's avatar 李晓奇

finish report!

parent 0afbec5c
......@@ -2,3 +2,4 @@
2-ir-gen-warmup/*.pdf
3-ir-gen/*.pdf
4.1-ssa/*.pdf
4.2-gvn/*.pdf
# Lab4.2 实验报告
姓名 学号
> 李晓奇 PB20111654
[TOC]
## 实验要求
请按照自己的理解,写明本次实验需要做什么
依照伪代码,填充框架实现GVN.
## 实验难点
......@@ -34,37 +36,31 @@ detectEquivalences(G)
- 怎么设计top?
尽管算法伪代码中,top是对每一条语句的赋值的,但是任意一条语句的都有所属基本块,只要对基本块的pout标注top、就能保证基本块的pin合乎逻辑,逐条执行转移函数后,每条语句的pin都合法。``
尽管算法伪代码中,top是对每一条语句的赋值的,但是任意一条语句都有所属基本块,只要对基本块的pout标注top、就能保证基本块的pin合乎逻辑,逐条执行转移函数后,每条语句的pin都合法。
- `while changes to any POUT occur`
这句话如此轻松,但是在实现中出现了问题:这涉及到怎么判断分区相等,做不好会出现死循环。
如果单纯比较index恐怕不行:因为我目前的`transferFunction`会生成一个新的等价类,赋予一个新的编号。
所以我使用对等价类中的`members`做相等的判断,如果两个分区中的等价类都能找到相等的,那么就判断分区相等。
> 但是这样依赖于set的排序,
>
> - 对于分区,这个排序依赖于等价类中index的值,侥幸来说每条指令按顺序执行,即使index不一致(每次转移函数分配最新的index),大小顺序也是一致的?
>
> - 对于等价类,里边是`Value*`指针,这个指针的等价就是语义上的等价,没问题。
分区中的同一个等价类,集合中元素也可能发生了增减,在这里的语义下是不相等的。所以我使用对等价类中的`members`做相等的判断,如果两个分区中的等价类都能找到相等的,那么就判断分区相等。
改过来后发现还是死循环,DEBUG很久意识到我的分区相等写的有问题:`return members_ == other.members_`,这样会挨个儿比较`members_`指针的值,而不是我预想的比较指针指向的等价类。所以要手动遍历一下。
这个遍历也不是很直观的,因为要做两层解引用:
```cpp
```cpp
for (; it1 != p1.end(); ++it1, ++it2)
if (not(**it1 == **it2))
return false;
```
```
`*it`是等价类的指针,`**it`才是等价类。
除此之外,我还忘了`++it2`,淦。
改完这些,终于能跑通第一版本了……
> 这里不用逐对比较依赖于set的排序:
>
> 对于分区(等价类指针的set),小于号根据index重载,所以排序依赖于等价类中index的值。
>
> 如果分区结果已经收敛,那么由于遍历顺序一致,会有每条分区中的等价类具有相同的index排布,比较时正确true。
### 对于函数`Intersect(Ci, Cj )`
......@@ -102,13 +98,22 @@ struct CongruenceClass {
- 额外设计:赋予**精准的**值编号
在我的设计中,每条指令都有一个潜在的值编号,如果要为这条指令新建等价类,就使用这个值编号。所以需要`Intersect`确定新的值编号时,应该选用靠前的以为后边指令腾出编号空间。
在我的[设计](#值编号的维护)中,每条指令都有一个潜在的值编号,如果要为这条指令新建等价类,就使用这个值编号。所以需要`Intersect`确定新的值编号时,应该选用靠前的以为后边指令腾出编号空间。(详见`Intersect`中的`exact_idx`变量)
### 对于函数`valuePhiFunc(ve, P)`
- 输入就一个分区,实际上要用到两个前驱的分区,怎么处理?
传入基本块做参数。
传入基本块做参数,函数声明改成:
```cpp
std::shared_ptr<GVNExpression::PhiExpression> valuePhiFunc(
std::shared_ptr<GVNExpression::Expression> ve,
BasicBlock *bb,
Instruction *instr);
```
bb是当前块,用来找到两个前驱,instr用来做常量折叠,只有其op会被用到
- 二元运算的顺序颠倒怎么办?
......@@ -116,11 +121,13 @@ struct CongruenceClass {
>
> 对于第二种,单纯寻找x1+y2和y1+x2肯定不合逻辑
论文中有详细的版本:
吐槽这个论文离谱之时(省去诸多细节),突然发现论文其实有详细的版本:
![](figures/value_phi_func.png)
### 等价类设计
在这个基础上,我又加入了一些常量折叠的内容,见[后边的讨论](#常量传播的实现)
### 等价类
> ```cpp
> shared_ptr<Expression>
......@@ -129,71 +136,115 @@ struct CongruenceClass {
等价类涉及到针对指令设计、常量折叠、递归计算等,很复杂,需要拎出来单独讨论。
首先注意到传给传进去的参数是指令类型(指针)。需要`transferFunction`处理的指令,一次赋值的右式自然可以由指令类型得到,所以没有毛病。
首先注意到传进去的参数是指令类型(指针)。需要`transferFunction`处理的指令,一次赋值的右式自然可以由指令类型得到,所以没有毛病。
指令有多种类型,也即lightIR中有多种赋值方式,而非单纯的伪代码中的二元运算。但是即使是已经在算法中讨论过的二元运算,写起来也不容易。在此之外,还有一些自己设计的等价类。
实验达到最后,所有等价类可以从`Expression::gvn_expr_t`中窥见:
```cpp
enum gvn_expr_t {
e_constant,
e_bin,
e_cmp,
e_phi,
e_cast,
e_gep,
e_unique,
e_global,
e_argument,
e_call
};
```
新增加的等价类及其含义如下:
| 等价类 | 含义 |
| --------------- | ------------------------ |
| cmp | 整形/浮点比较 |
| cast | 类型转换 |
| gep | GEP指令 |
| unique | 不做特殊处理但会用到的,如load、alloca |
| global、argument | 全局变量和形参 |
| call | 函数调用 |
#### 等价类设计
首先讨论这些等价类设计,在实验中发现,这些设计主要是要保存足够的信息,能够支持我们做正确的相等判断,而这些信息又和lighit的设计/接口紧密关联:
- e_constant
> 常量折叠会在后边详细探讨
常量等价类,这个在常量折叠中起关键作用,其中保存一个`Constant*`变量。
- e_bin
二元运算的构成:左右操作数+操作符。
- e_cmp
lightir中,比较指令的操作符并不是单一的,他是一个两层的信息:op+cmpop,所以设计中`CmpExpression`继承自`BinaryExpression`后再加入记录cmpop的信息。
- e_phi
phi表达式的构成只需要两个操作数即可。
- e_cast
操作符是三层的信息:op+源类型+目的类型。
此外需要记录(源)操作数的expression。
指令有多种类型,也即lightIR中有多种赋值方式,而非单纯的伪代码中的二元运算。但是即使是已经在算法中讨论过的二元运算,写起来也不容易。
- e_gep
#### 生成等价类的逻辑
GEP表达式的结构:基地址(expression)+索引(vector\<expression\>)
首先讨论生成等价类的逻辑:
- e_unique
- 二元运算`y op z`
这个表达式是为了对应load和alloca,其左值都不需特别处理,但是又应该有表达式。
如果y、z都是常量,那么可以进行常量折叠,得到**一个**`ConstantExpression`类型
使用一个`Instruction*`变量保存左值,做比较用
否则返回的是一个`BinaryExpression`类型,这个类型需要左右操作数,都是`Expression`类型,如果操作数是常量,使用`ConstantExpression`创建新的值表达式,否则递归调用`valueExpr`
还加入了index,这个表达式是树的叶子节点,我希望它输出是v~index~这样
> 这里为什么递归调用不会走到未定义?
- e_global和e_argument
- phi函数
全局变量、形参类型也要有归属的表达式,但是和上述的unique不兼容,所以各自新建等价类,直接保存对应的指针。这样分开也方便控制打印输出。
我们逻辑上把其改变成了copy,`transferFunction`应该维护这一点,所以认为没有phi函数传入。
- e_call
具体是`transferFunction`函数中的这几句话:
函数调用算是最复杂的一个,涉及到纯函数分析,还有函数类型、实参比较等很多需要考虑的。在这个表达式中我记录了这些信息:
- `pure_`:是否是纯函数
- `f_``Function*`类型,用来判断是否是同一个函数
- `args_`:实参的`expression`容器。
再来看看比较的函数:
```cpp
GVN::partitions
GVN::transferFunction(Instruction *x, Value *e, partitions pin)
{
auto e_instr = dynamic_cast<Instruction *>(e);
auto e_const = dynamic_cast<Constant *>(e);
assert((not e or e_instr or e_const) &&
"A value must be from an instruction or constant");
// ……
if (e) {
if (e_const)
ve = ConstantExpression::create(e_const);
else
ve = valueExpr(e_instr, &pin);
} else
ve = valueExpr(x, &pin);
bool equiv(const CallExpression *other) const {
// single instruction, should be same
if (instr_ == other->instr_)
return true;
if (not(pure_ and f_ == other->f_))
return false;
for (int i = 0; i < args_.size(); i++)
if (not(*args_[i] == *other->args_[i]))
return false;
return true;
}
```
- 比较函数:和二元运算一致。
- 类型转换:添加新的表达式类型`CastExpression`,处理和二元操作类似。
这里总有bug,最后的逻辑是:
- 其他产生赋值的指令,也会由`transferFunction`传递给`ValueExpr`,这些指令是:`load`、`alloca`和返回值非空的`call`。
- 如果是同一个调用(同一条指令),那么认为是相等的
这里设计一种值表达式类型:`UniqueExpression`,这个表达式和任何其他都不等价,表现为`equiv`直接返回false。
- 如果是同一个纯函数的等价调用,认为是相等的
> 看到有同学在的表达式打印都是`add v1 v2`这种,而我是`add %op1=... %op2=...`,好整洁好漂亮,心动了,就搞他。
>
> 因为等价类树的叶子节点就两个:`constant`和这个`Unique`
>
> 常量打印对应的数字即可,而我想实现Unique打印v~i~的效果,所以在其中保存了等价类的值编号`index`,看起来值编号是等价类的属性,似乎不应该和表达式掺和,但是正如其名:UNIQUE,不应该与其他相同的,所以他一定是单独一个等价类,不受其他影响,index是恒定的。
>
> 改好后,打印有如下效果:
>
> ```
> [INFO] (GVN.cpp:820L run)
> index: 9
> leader: %op13 = call i32 @input()
> value phi: nullptr
> value expr: v9
> members: {%op13 = call i32 @input(); }
> ```
- 否则不等
#### 若干需要梳理的问题
......@@ -201,13 +252,13 @@ struct CongruenceClass {
- 等价类中的ve一定存在吗?
根据等价类的设计,ve一定是存在的,且等价类中`members_`中一定存在元素
根据等价类的设计,ve一定是存在的,且等价类中`members_`中一定存在元素(如果删除某一个元素后,等价类为空,要连带着从分区删掉这个等价类)
- ve一定唯一吗?
从逻辑上,等价类集合中的元素享有共同的ve产生的结果,ve是唯一的
从逻辑上,等价类集合中的元素享有共同的ve,所以ve是唯一的
从实现上,添加
从实现上,添加元素,添加元素的ve和等价类的ve是相等的,有种归纳法的感觉,总之ve应该是唯一的。
- ve或者vpf能够作为代表吗?
......@@ -215,19 +266,21 @@ struct CongruenceClass {
> if ve or vpf is in a class Ci in POUTs
可以的,等价类中的元素都有唯一的表达式,其他等价类即使形式相同,操作数也不会相同,所以等价类和表达式其实是一一对应的关系。
可以的,等价类中的元素都有唯一的表达式,其他等价类即使形式相同,操作数也不会相同/等价,所以等价类和表达式其实是一一对应的关系。
(这也解释了UNIQUE中传入index的合理性)
- 代表元如何选取?什么时候选取?
目前只对常量传播的Constant特别设置了主元,负责设置为`members_`的第一个元素。
目前只对常量传播的Constant特别设置了主元,
否则设置为`members_`的第一个元素。
- 怎么判断等价类相等
根据论坛上这个[例子](http://cscourse.ustc.edu.cn/forum/thread.jsp?forum=91&thread=68),我发现值编号会有非必要的增长,即最终收敛时用到的编号是1、5、6,其中2、3、4都是迭代中用到的但是最后都没有体现
在我的值编号还没有得到解决时(还在一直递增),值编号显然不可以拿来作比较,所以选择比较`members_`,直到最后
从这个例子中可以发现,迭代收敛时,是pout中对应分区的`members_`不变,值编号还是会变化的,所以判断等价类相等,应该比较`members_`集合
而在经过了值编号设计后,再来分析,发现的确不能比较值编号,即使值编号。这在[后边](#值编号的目的)有详细讨论
- 等价类为空的判定,可以用`members_`做判断吗?
......@@ -247,9 +300,58 @@ struct CongruenceClass {
BB3: ... ; preds = BB1, BB2
```
进入BB3时,对BB1和BB2的pout中等价类分别取交,执行`Intersect(Ci={v3, x1, v1+v2}, Cj={v4, x2, v1+v2}`,得到的是`{v1+v2}`,`members_`中没有成员,但是ve存在,我认为这种情况也应该判定结果为空集。
进入BB3时,对BB1和BB2的pout中等价类分别取交,
执行`Intersect(Ci={v3, x1, v1+v2}, Cj={v4, x2, v1+v2}`
得到的是`{v1+v2}`,注意`members_`中没有成员,但是ve存在,我认为这种情况也应该判定结果为空集。
`Intersect`函数中以`members`非空作后续生成phifunc的前提条件,也是来源于此。
### copy statement
对于IR中的phi指令(非phi函数),我们逻辑上将phi函数转换成了copy statement,这在实现中体现在`transferFunction(x, e, pin)`的调用上:当copy statement发生时,传入的x是一个phi指令,而e是等式的右值,相关调用:
```cpp
// in transferFunction(BB)
if (instr.is_phi()) {
if ((res = pretend_copy_stmt(&instr, bb)) == -1)
continue;
part = transferFunction(&instr, instr.get_operand(res), part);
}
```
其中`pretend_copy_stmt(instr, bb)`负责找到instr(phi指令)中的哪个操作数是来自基本块bb的,如果没有就返回-1。
因为最终反映到IR上,我们直接关心的都是`members_`中的成员,所以用`members_`的空代表等价类的空我觉得是合适的。
`transferFunction(x, e, pin)`中对应的处理(如何生成相关的等价类):
```cpp
GVN::partitions
GVN::transferFunction(Instruction *x, Value *e, partitions pin)
{
auto e_instr = dynamic_cast<Instruction *>(e);
auto e_const = dynamic_cast<Constant *>(e);
// auto e_global = dynamic_cast<GlobalVariable *>(e);
auto e_argument = dynamic_cast<Argument *>(e);
assert((not e or e_instr or e_const or e_argument) &&
"A value must be from instruction, constant or argument");
// ……
if (e) {
if (e_const)
ve = ConstantExpression::create(e_const);
else if (e_instr)
ve = valueExpr(e_instr, pout);
else if (e_argument) {
ve = search_ve(e_argument, pout);
assert(ve && "argument-expression should be there");
} else {
abort();
}
} else
ve = valueExpr(instr, pout);
}
```
### 值编号的维护
......@@ -269,7 +371,7 @@ struct CongruenceClass {
>
> 关键在于:index不应该递增,对于`%op12`,第二次迭代明明是同一个位置的定值,应该享有相同的值编号。
一个目标清晰起来:我要保证,不同迭代中,同一位置生成的等价类要享有相同的值编号,以标示同一个等价类。这里的同一个不是指完全相同,举例如下:
一个目标清晰起来:我要保证,不同迭代中,同一位置生成(这里指新生成)的等价类要享有相同的值编号,以标示同一个等价类。这里的同一个不是指完全相同,举例如下:
```
// partition: {}
......@@ -308,7 +410,7 @@ y = 1
实现上要注意两点:
- 每次调用针对指令的转移函数之前,都要重新设置值编号
- 每次调用针对指令的转移函数之前,都要归位值编号
- `Intersect(Ci, Cj)`中可能发现两个等价类不是相同的值编号,这时要新建phi等价类,并赋予一个精准的值编号:两个前驱中的定值,分别对应一个值编号,选择遍历顺序靠前的那个。
......@@ -316,15 +418,13 @@ y = 1
### 常量传播的实现
### 所见非所得
在函数`constFold_core(count, partition, instr, oprands)`中,程序会检查逐个检查oprands的类型,如果全部是`Constant*`类型,或者所在的等价类是`ConstantExpression`类型,那么就可以进行常量折叠,函数会返回一个`Constant*`变量。
- 论文中伪代码对于基本块的关注不是很多,但是我们的实现一定要围绕基本块进行,这个的解决对策说到了,其实和单条语句没有本质差异
上述函数是在`valueExpr`中调用,此外在`valuePhiFunc`中也有进行常量折叠的代码:例如,一种情况下,对于`phi(a,b) op phi (c,d)`,程序会分别在左分支寻找`a op c`,常量折叠的部分在于,程序会试图合并`a op c`再去做分支寻找,如果合并不成功再使用`OP_expresion`寻找
- 我们针对lightir优化,经常深入API中陷入到实现细节,还容易被C++的语言特性绊住,但是应该尝试从直观上理解:我们究竟要实现什么样的效果。这个就得去看ll文件找灵感。然后这还不够,回过头来看代码又呆住了,因为ll语句和那些类型对应不上,这个就是容易忽视的一点:看lightir类型的print函数,这个能帮我们直观地串联起实现细节和表象的ll文件。
### 纯函数分析的实现
- 到了实验的后半阶段,调试信息的解读就变得越来越重要了。感谢某位同学劝我不要在用gdb debug,而是改用vscode,用gdb确实太慢了。
这里调试的困难其实不在于调试的技巧,更多是信息解读,很多时候,用了半个小时甚至一上午才明白错误的原因在哪里,这也来源于对算法的理解不足。
`CallExpression`的设计使得只要`equiv`返回true,就是同一条指令或者等价的纯函数调用,因此可以看作正常的冗余去除。
## 实验设计
......@@ -332,16 +432,155 @@ y = 1
实现思路,相应代码,优化前后的IR对比(举一个例子)并辅以简单说明
### 思考题
举一个例子,涉及到基本二元运算的荣誉、常量传播和纯函数分析:
```cpp
int f(int i) { return i + 1;}
int main(void)
{
int a; int b; int c; int d;
/* 纯函数冗余和常量传播 */
a = f(1+0);
b = f(2-1);
c = a;
d = b;
/* 二元元算冗余 */
output(a+b);
output(c+d);
return 0;
}
```
如下经过`mem2reg``main`函数的IR如下,简单地举一下会有的优化:
- 常量传播:`%op0``%op2`
- 纯函数分析:`%op1``%op3`
- 二元运算冗余:`%op4``%op5`
```cpp
define i32 @main() {
label_entry:
%op0 = add i32 1, 0
%op1 = call i32 @f(i32 %op0)
%op2 = sub i32 2, 1
%op3 = call i32 @f(i32 %op2)
%op4 = add i32 %op1, %op3
call void @output(i32 %op4)
%op5 = add i32 %op1, %op3
call void @output(i32 %op5)
ret i32 0
}
```
经过gvn后,
```cpp
define i32 @main() {
label_entry:
%op1 = call i32 @f(i32 1)
%op4 = add i32 %op1, %op1
call void @output(i32 %op4)
call void @output(i32 %op4)
ret i32 0
}
```
## 思考题
1. 请简要分析你的算法复杂度
假设:
- n为等价类个数
- v为指令总数
- j为汇合点个数
先分析`join`的复杂度:因为对等价类逐对比较,所以有O(n^2^)次求交,每次求交假设是线性的,即O(v),则有`join`的复杂度为O(n^2^v)
分析`valueExpr`的复杂度:主要是搜索子表达式O(n)
分析`valuePhiFunc`的复杂度:`getVN` O(n)+递归调用O(nj)
再分析每条指令的`transferFunction`复杂度:克隆分区O(n)+寻找并擦除x O(n)+`valueExpr`O(n)+`valuePhiFunc`O(nj)+遍历寻找相同表达式O(n)
回到最顶层的函数`detectEquivalences`,在依次迭代中:
- 初始化值编号:遍历全部指令,O(v)
- 对所有块分析partition,包括:
- j个汇合点的`join`,O(n^2^vj)
- v条指令的`transferFunction`,O(nvj)
而至多迭代v次,即一个超大等价类拆分为每条指令一个等价类,所以总的时间复杂度是:O(n^2^v^2^j)
2. `std::shared_ptr`如果存在环形引用,则无法正确释放内存,你的 Expression 类是否存在 circular reference?
3. 尽管本次实验已经写了很多代码,但是在算法上和工程上仍然可以对 GVN 进行改进,请简述你的 GVN 实现可以改进的地方
无。
首先从最终效果来看,打印的过程会递归调用print,环形引用会导致print死循环,从结果看是不存在环形引用的。
其次分析程序运行时,expression的形成:
- 一般的情况下,SSA格式会使得操作数都来自于前边的指令(大部分是单向的连线),包括copy statement
- 可能出现问题的只是求交时,
3. 尽管本次实验已经写了很多代码,但是在算法上和工程上仍然可以对 GVN 进行改进,请简述你的GVN实现可以改进的地方
确实呀……
- 代码可以优化逻辑,使用模板、类继承等,提高实现的优美感
- 我理解最差的函数主要是Intersect,所以这里存在bug的可能性最大
- 关于全局变量、形参的相关的逻辑比较暴力,复杂度比较高。
比如实验要求分区中存在全局变量和形参,我的方法是把对应的分区赋值给pin[Entry],但是也完全可以在结束时在追加,这样省去了每次求交时的对这两项的冗余遍历。
## 实验总结
此次实验有什么收获
实验很难,体现在:
- 算法本身就不理解(做完了也不是完全理解)
- lightir的接口设计为了实现的简介,使得有些地方乍看起来就不是直观的,如op+cmpop才构成比较指令类型的充分信息
- 还有找bug难、理解bug难、去除bug也难
但还是做完了,做实验的过程中,有些地方看似不可逾越,但还是搞定了,我觉得值的总结一下:
- 我们针对lightir优化,经常深入API和实现细节中,还容易被C++的语言特性绊住,但是应该尝试从直观上理解:我们究竟要实现什么样的效果。这个就得**去看ll文件找灵感**。然后这还不够,回过头来看代码又呆住了,因为ll语句和那些类型对应不上,这个就是容易忽视的一点:**看lightir类型的print函数**,这个能帮我们直观地串联起实现细节和表象的ll文件。
- 到了实验的后半阶段,调试信息的解读就变得越来越重要了。感谢某位同学劝我不要在用gdb debug,而是改用vscode,用gdb确实太慢了。
这里调试的困难其实不在于调试的技巧,更多是信息解读,很多时候,用了半个小时甚至一上午才明白错误的原因在哪里,这也来源于对算法的理解不足。
关于报告,我每次编译的实验都会边写边记录(除了lab1和lab4-1),
一方面,每次开始实验真的都是一头雾水,会有很多思绪乱糟糟的,记下来会帮我理清很多,
另一方面,很多被折磨的地方,在结束实验时反而记不起来了,怎么搞定各种困难,怎么针对性提出方案,这才是实验中有价值的点。
所以我每次交报告都是又臭又长,不好意思嘿嘿:laughing::shushing_face:我加了目录的,希望助教不烦我~
## 实验反馈(可选 不会评分)
对本次实验的建议
搞个这么个大实验,我觉得很好啊,我很享受整个过程。
但是课程组应该压力很大的,尤其是当有同学提到:“实验时长已经超出课程设计”、“这学期其他所有课程实验加起来,都没编译耗时多"……
他们的批评有道理,但是我不认可拿这个给老师、助教施加压力,我看到的是实力强、负责任的老师和助教,实验任务量大,但不应该成为喷斥的集中点。这样认真设计的实验可不多呀!!
站在我的角度,看print函数帮助很大,能帮我们把效果和实现对应起来,
因此建议在lab4-1或者lab3中加入相关阅读任务,包括但不限于print,比如一条ll指令是怎么打印出来的,给定一个`Value*`变量,如何判断其是不是一个常量类型等。
......@@ -634,7 +634,7 @@ GVN::transferFunction(Instruction *instr, Value *e, partitions pin) {
// auto e_global = dynamic_cast<GlobalVariable *>(e);
auto e_argument = dynamic_cast<Argument *>(e);
assert((not e or e_instr or e_const or e_argument) &&
"A value must be from an instruction or constant");
"A value must be from instruction, constant or argument");
// erase the old record for x
std::set<Value *>::iterator it;
for (auto c : pout)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment