编译器中的优化

编译器中的优化

8.1 编译器是如何优化的

现代编译器为了提高性能会对代码做大量的修改,知道其做了啥,对程序员很有帮助

函数内联

编译器默认内联小函数

  • 节约调用、返回和传参开销
  • 代码连续,代码缓存效率更高

缺点是,如果对内联函数有多个调用而且函数体很大,每个地方都会展开,代码会膨胀

常量折叠和常数传播

只包含常量的表达式或者子表达式将被计算结果替换,例如:

1
2
3
4
double a, b
a = b + 2.0 / 3.0
// 上式会被替换为下式
a = b + 0.66666666666666666666666666666667;

因此建议为这样子表达式加上括号,确保编译器将其识别为子表达式

常量还可以通过一系列计算来传播,例如:

1
2
3
4
5
6
7
8
9
10
float parabola(float x) {
return x * x + 1.0f;
}
float a, b;
a = parabola(2.0f);
b = a + 1.0f;

// 可能被编译器替换成如下:
a = 5.0f;
b = 6.0f;

当表达式包含不能被内联的函数或者不能在编译器计算时,常量折叠和常量传播就不可能起作用。

消除指针

如果指向的目标已知,则可以消除指针或者引用,例如:

1
2
3
4
5
6
7
8
void Plus2(int *p) {
*p = *p + 2;
}
int a;
Plus2(&a);

//可能被编译器替换为:
a += 2;

消除公共子表达式

如果相同的子表达式出现多次,那么编译器可能只会计算一次,例如:

1
2
3
4
5
6
7
8
9
int a, b, c;
b = (a + 1) * (a + 1);
c = (a + 1) / 4;

// 可能被替换为:
int a, b, c, tmp;
tmp = a + 1;
b = tmp * tmp;
c = tmp / 4;

寄存器变量

最常用的变量会被存储在寄存器中,64位系统中,整数寄存器的最大数量约14个,浮点数寄存器等最大数量为16个。

编译器将最常用的变量作为寄存器变量,包括指针和引用,它们可以存储在整数寄存器中,寄存器变量的典型候选对象是临时中间变量、循环计数器,函数参数,指针、引用、this指针、公共表达式和归纳变量。

如果一个变量的地址被取走,换句话说,如果有指向它的指针或者有引用,那么这个变量不能被存储在寄存器中,因此对于可能受益于寄存器存储的变量,应该避免任何指针或者引用。

活动范围分析

变量的活动范围☞变量被使用的代码范围,对于活动范围不重叠的变量,编译器可优化可以使用相同的寄存器。在寄存器数量有限的前提下非常有用。 例如:

1
2
3
4
5
6
7
8
int func(int a, int x[]) {
int b, c;
x[0] = a;
b = a + 1;
x[1] = b;
c = b + 1;
return c;
}

a,b,c可以共享一个寄存器,因为他们活动范围不重叠,而若c = a + 2那么 a, b就不能使用相同的寄存器,因为他们活动范围重叠了。

合并相同的分支

通过合并相同的代码片段,可以让代码更加紧凑例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
double a, b, c; bool b;
if(b) {
y = sin(x);
z = y + 1;
}
else {
y = cos(x);
z = y + 1
}
//可能被替换为:
if(b) {
y = sin(x);
}
else {
y = cos(x);
}
z = y + 1;

消除跳转

条件永真or永假可以消除分支,如果前后分支条件相同,合并分支

循环展开

高度优化的情况下,部分编译器将展开循环。重复计数非常小的循环可以完全展开,以避免循环开销。

过多的循环展开不是最优的因为会占用太多的代码缓存空间

移动循环中的不变代码

若计算独立于循环计数器,则可以将其移出循环,例如:

1
2
3
4
5
6
7
8
9
10
int a[100], b;
for(int i = 0; i < 100; i ++) {
a[i] = b * b + 1;
}

//可能会被优化:
int tmp = b * b + 1;
for(int i = 0; i < 100; i ++) {
a[i] = tmp;
}

归纳变量

循环计数器的线性表达式可以通过在前一个值上添加一个常数来计算,(等差数列?)

1
2
3
4
5
6
7
8
9
10
11
12
13
int a[100];
for(int i = 0; i < 100; i ++) {
a[i] = i * 9 + 3;
}

//可能被优化成下面形式以避免乘法

int a[100];
tmp = 3;
for(int i = 0; i < 100; i ++) {
a[i] = tmp;
tmp += 9;
}

归纳变量常用于计算数组元素的首地址
当地址可以表示为一个基地址加常数加上索引乘以1,2,4,8(但不能是其它任何因数),CPU中有硬件支持类似的计算,此时不需要归纳变量。

排序

编译器可以为了并行对指令进行重新排序,例如:

1
2
3
float a, b, c, d, e, f, x, y;
x = a + b + c;
y = d + e + f;

编译器可能会交错这个两个公式,先计算a + b然后d + e然后c加到第一个,f再加到第二个。目的是帮助cpu同时进行多个计算。现代cpu实际上可以在没有编译器帮助的情况下对指令进行重新排序,但是编译器可以使cpu更容易地进行重新排序。

代数化简

多数编译器可以使用代数基本定律来化简简单的代数表达式,例如可以将-(-a)更改为a

编译器在化简整数表达式上表现得要比化简浮点表达式更好,因为浮点表达式的代数操作可能因为精度问题产生奇怪的结果

去虚拟化

如果实知道所需要的的虚函数版本,编译器优化可以绕过虚函数表查找,直接调用虚函数,但是很少有编译器能够进行这种优化。

编译器优化的障碍

有以下因素妨碍编译器执行预期的优化,对于程序员来说,了解这些障碍并知道如何避免很重要

无法跨模块优化

编译器除了正在编译的模块外,没有其他模块中的函数信息,这会阻止它函数调用的优化,比如函数内联、常量传播等~

可以通过#include指令将多个.cpp模块组合成一个模块,适用于所有编译器,或者部分编译器有“全程序优化特性”,可以支持跨模块的优化。

指针别名

通过指针或者引用访问变量是,编译器可能无法完全排除所指向的变量与代码中的其他变量相同的可能性,例如:

1
2
3
4
5
6
7
8
9
10
void func1(int a[], int *p) {
for(int i = 0; i < 100; i ++) {
a[i] = *p + 2;
}
}

void func2() {
int list[100];
func1(list, &list[8]);
}

上述代码中,需要重新加载*p并计算*p + 2 100次,因为p指向的值与循环过程中会发生变化的a[]中的一个元素相同。编译器无法假定*p + 2是可以移出循环的循环不变代码。

如果编译器支持,可以使用关键字__restrict__restrict__来告诉编译器某个特定指针不是任何变量的别名。

动态内存分配

动态分配(new或malloc)的任何数组或者对象都需要通过指针访问,对程序员而言,指向不同动态分配的对象的指针没有重叠或者混淆时是明显的,而编译器通常无法得知。
同时还阻止编译器以最佳方式来对其数据。因此最好是在需要的函数中声明对象和固定大小的数组。

纯函数

纯函数是一个没有任何副作用的函数,返回值仅仅取决于参数的值,多次使用相同的参数调用纯函数肯定会得到相同的结果。编译器可以消除包含纯函数调用的常见子表达式,但并且可以移出包含纯函数调用的循环不变代码,但是如果函数在不同的模块或者函数库中定义,编译器无法知道函数是否为纯函数。

因此,当涉及纯函数调用时,有必要进行手动的优化,例如公共子表达式消除,常量传播和移动循环不变代码;

Gnu编译器和用于Linux的intel编译器都有一个属性,可以用于声明函数原型,以告诉编译器这是一个纯函数。

1
2
3
4
5
6
7
8
9
10
#ifdef __GNUC__
#define pure_function __attribute__((const))
#else
#defin pure_function
#endif

double func1(double) pure_function;
double func2(double x) {
return func1(x) * func1(x) + 1;
}

这里,gnu编译器将只调用一次func1,而其他编译器会调用两次
一些编译器知道sqrt、pow、log这样的标准库函数是纯函数,但是无法知道用户定义的函数是纯函数。

虚函数和函数指针

编译器几乎不可能春去额预测将要调用虚函数的哪个版本,或者函数指针指向什么。因此它们无法内联,也无法优化函数调用