讨论一些编译器会做的优化,这些优化一般不需要我们自己来做。
但是我们讨论时从源码角度来模拟,实际上编译器是在中间代码(IR)层进行优化的。
分四部分:
IR optimisations
Basic optimisations
Constant folding
将一些变量转换为常量,这有助于减少内存的访问。 且常量相关的求值是可以在编译阶段就确定,不必等待运行阶段。
转换前:
integer, parameter :: n=100
do i=1,n
....
end do
转换后:
do i=1,100
....
end do
Algebraic simplifications
简化算术运算,包括了运用结合,交换和分配律等:
(i-j)+(i-j)+(i-j) ——> 3*i - 3*i
但是要注意浮点数操作,它不遵循结合律:
1.0 + (MF - MF) = 1.0
(1.0 + MF) - MF = 0.0
这个例子里MF比1.0大,1.0在和MF结合后会被舍去,所以第二个结果出来就是0.0了,这是因为浮点运算会先算出精确值然后舍去多余位,所以结合律失效。
Copy propagation
拷贝出来的变量在后续的使用中可以被原来的变量替代,那就少用一个寄存器。
x = y
c = x + 3
d = x + y
-------->
x = y
c = y + 3
d = y + y
这里x这个变量就不需要了。
Constant propagation
一个常量的值,在后续的使用中可以直接被一个常量替换掉。
x = 4
c = x + 3
d = x + y
---------------->
x = 4
c = 4 + 3
d = 4 + y
Redundancy elimination
Common subexpression elimination (CSE)
有重复使用的表达式可以被替换为一次表达式运算,然后存储他的结果,后续的重复表达式运算直接使用第一次得到的结果就可以。
a = b + (c - 2)
d = 4 * b
e = d + (c - 2)
------------>
t1 = c - 2
a = b + t1
d = 4 * b
e = d + t1
Loop invariant code motion
在所有循环中结果都一样的表达式直接放到循环外来计算。
do i = 1,n
do j = 1,n
a(j,i)=2*b(i+1)+j-(m*5)
end do
end do
------------------->
t1 = m*5
do i = 1,n
t2 = 2*b(i+1)
do j = 1,n
a(j,i)=t2 + j - t1
end do
end do
Algebraic simplification in redundancy elimination
再做一下算术运算的简化。
do i=1,n
a=b+i
c=a-i
d=a
end do
------------->
do i=1,n
a=b+i
c=b
d=a
end do
Dead code elimination
去掉结果从不使用的代码。
loop optimisations
Strength reduction
简化计算,将每次循环的计算强度降低。尽量将基于迭代变量的运算转化为单纯的加法递增。
for (i=0;i<n;i++){
j = 3*i+2;
a[j] = b[i];
}
--------------->
j=2;
for (i=0;i<n;i++){
a[j] = b[i];
j+=3;
}
Induction variable removal
Induction variable归纳变量,指随着迭代连续变化的变量。
有可能的话,使用一个(迭代变量)来表示所有的归纳变量,减少寄存器的使用。
for (i=0;i<n;i++){
j = 3*i+2;
a[j] = b[i];
}
---------->
j=2;
for (i=0;i<n;i++){
a[j] = b[i];
j+=3;
}
Bounds checking elimination
int a[n];
for (i=lo;i<hi;i++){
a[i] = i;
}
拿这个举例子,每次循环都需要检查i是否越界,这很耗费时间,其实只需要在一开始检查一下lo和hi是否满足:
lo < n
hi < n
lo < hi
就行。
Procedure call optimisations
Inlining
内联,即提示编译器去将函数以类似宏定义的形式展开在调用处。
可能使代码量增大,使用寄存器的数量也会增多。
Expansion
Inlining是编译器在IR这里做的事情,但是expansion是在汇编/机器码这里做的事情。
它是将函数调用改为一系列指令的组合,层次更低,可以利用一些特别的指令序列。
Instruction scheduling
这是编译器应该在source level做的优化
指令可以重排序,只要维持寄存器的依赖图不变就行了。
而且有一些指令是不能pipeline的比如sqrt,那就需要在sqrt计算和结果之间插入一些指令来充分利用cpu。
目标:
- 减少执行的指令
- 减少流水线停顿(data hazards, control hazards, structural hazards)
- 使用多发射(VLIW, superscalar)
Branch scheduling
分支会导致流水线停顿,两种方案:
- delayed branches(编译器来做,引入分支延迟槽,流水线中,分支指令执行时因为确定下一条指令的目标地址(紧随其后 or 跳转目标处?)一般要到第 2 级以后,在目标确定前流水线的取指级是不能工作的,即整个流水线就“浪费”(阻塞)了一个时间片,为了利用这个时间片,在体系结构的层面上规定跳转指令后 面的一个时间片为分支延迟槽(branch delay slot)。位于分支延迟槽中的指令总是被执行,与分支发生与否没有关系。这样就有效利用了一个时间片,消除了流水线的一个“气泡”。)
- 分支预测(依赖硬件Reorder Buffer,用于rollback结果)
编译器就找一些可以放到分支延迟槽的指令,把它们放进去就更有效地利用了流水线,消除了stall。当然可能还要尝试找一些可以忽视分支的指令。
Loop unrolling
就是展开循环,因为小的循环中花费在分支上的代价太大,把他们展开,可能耗费更多寄存器但是减少了stall。
同时一些特殊指令也可以被使用进去。
有几点需要注意:
unroll factor选择很重要,大了会用完寄存器,数量的选择可以考虑cache line,提高缓存的命中
Outer loop unrolling: 在unroll的时候对于多层循环,尽量去unroll外层的循环,这有助于提升代码的局部性
就类似内存读取时读取连续的值一样,有助于缓存命中
Variable expansion
用于解除展开循环中的数据依赖:
for (i=0,i<n,i+=2){
b+=a[i];
b+=a[i+1];
}
--------------------->
for (i=0,i<n,i+=2){
b1+=a[i];
b2+=a[i+1];
}
b=b1+b2;
Register renaming
在一个block中寄存器可能被重用,这会带来不必要的依赖。
使用多个寄存器可以提供更多的scheduling flexibility、
有些CPU在硬件上实现了寄存器raname,但是这些都是对编译器不可见的。
add %f2,1,%f1
st [%o1],f1
add %f3,2,%f1
st [%o2],f1
---------------->rename
add %f2,1,%f1
st [%o1],f1
add %f3,2,%f27
st [%o2],f27
---------------->reschedule
add %f2,1,%f1
add %f3,2,%f27
st [%o1],f1
st [%o2],f27
Software pipelining
软件层面实现流水线来解决循环,看例子:
for (i=0;i<n;i++){
a(i) += b;
}
------------------------>
for (i=0;i<n;i++){
t1 = a(i); //L i
t2 = b + t1; //A i
a(i) = t2; //S i
}
--------------------------->
//prologue
t1 = a(0); //L 0
t2 = b + t1; //A 0
t1 = a(1); //L 1
for (i=0;i<n-2;i++){
a(i) = t2; //S i
t2 = b + t1; //A i+1
t1 = a(i+2); //L i+2
}
//epilogue
a(n-2) = t2; //S n-2
t2 = b + t1; //A n-1
a(n-1) = t2; //S n-1
这样搞对向量化的架构很有好处(superscalar and VLIW),因为:
• large loop body • few dependencies between instructions • lots of scope for multiple issue
optimisations for the memory hierarchy
这一层级的优化就很大可能要自己手动实现
Loop interchange
多层循环,交换循环次数,这个很熟悉了,为了cache命中率:
for (j=0;j<n;j++){
for (i=0;i<m;i++){
a[i][j]+=b[i][j];
}
}
------------------------------>
for (i=0;i<m;i++){
for (j=0;j<n;j++){
a[i][j]+=b[i][j];
}
}
Loop reversal
交换循环的次序,从前到后,从后到前,可以利用在array比cache大一点的时候,在循环开始前array末尾刚存进cache,先从末尾开始访问,命中率高
for(i=0;i=<m;i++){
a[i]+=b[i];
}
---------------------->
for(i=m;i>=0;i--){
a[i]+=b[i];
}
Loop skewing
for (j=0;j<n;j++){
for (i=0;i<m;i++){
a[j]+=b[i+j]+1;
}
}
-------------------------->
for (j=0;j<n;j++){
for (i=j;i<m+j;i++){
a[j]+=b[i]+1;
}
}
转化成这种形式,可以惠及其他的转化方式。这种转化本身一般不会带来直接的好处。
Unimodular transformations
Loop skewing,Loop reversal,Loop interchange都是Unimodular transformations
Loop fusion
将俩循环合并:
for(j=0;j<n;j++){
a[j]+=1;
}
for(i=0;i<n;i++){
b[i]=a[i]*2;
}
------------------------------------>
for(j=0;j<n;j++){
a[j]+=1;
b[j]=a[j]*2;
}
可能可以减少内存访问的次数,比如这里a的访问放到一起去了
循环块加大了,对于scheduler的优化也有更大的发挥空间
附上dalao写的:https://zhuanlan.zhihu.com/p/315041435
Loop distribution
Loop fusion的反向操作,一般用来减轻寄存器pressure
Loop tiling
for (i=0;i<n;i++){
for (j=0;j<n;j++){
a[i][j]+=b[i][j];
}
}
------------------------->
for (ii=0;ii<n;ii+=B){
for (jj=0;jj<n;jj+=B){
for (i=ii;i<ii+B;i++){
for (j=jj;j<jj+B;j++){
a[i][j]+=b[i][j];
}
}
}
}
这个比较复杂,直接看dalao写的: https://zhuanlan.zhihu.com/p/292539074
https://zhuanlan.zhihu.com/p/301905385
Array padding
根据cache的映射规则,加上padding的话,连续访问的元素也许就不会造成大量的cache conflict
float a[2][4096];
for (j=0;j<n;j++){
a[1][j]+=1;
a[2][j]*=2;
}
--------------->
float a[2][4096+64];
for (j=0;j<n;j++){
a[1][j]+=1;
a[2][j]*=2;
}