C++性能优化笔记-7-编译器中的优化-1-编译器如何优化代码

编译器如何优化

现代编译器能够对代码做很多修改,以提高性能。了解编译器可以做什么,不可以做什么,对开发者来说是有用的。下边的小节描述了开发者需要了解的一些编译器优化的内容。

函数内联

编译器可以用被调用函数体来替换原来的函数调用。例子:

// Example 8.1a
float square (float a) {
      return a * a;
}

float parabola (float x) {
      return square(x) + 1.0f;
}

编译器可以用square里的代码替换对square的调用:

// Example 8.1b
float parabola (float x) {
      return x * x + 1.0f;
}

函数内联的好处有:

  • 消除了调用与返回以及参数传递的开销。
  • 代码缓存更好,因为代码变得连续。
  • 如果被内联函数仅有一处调用,代码变得更小。
  • 函数内联可以为其他优化创造机会,如下面解释那样。

函数内联的坏处是,如果被内联函数有多处调用且该函数大,代码变得更大。如果函数小且仅从一处或少数几个地方调用,编译器很可能内联它。

常量折叠和常量传播

仅包含常量的表达式或子表达式将被计算后的结果替代。

指针消除

如果指向目标已知,指针或引用可以被消除。例子:
。。。

公共子表达式消除

如果同一个子表达式出现多次,编译器可能仅计算一次。例子:
。。。

寄存器变量

最常用的变量保存在寄存器里(参考寄存器存储)。

在32位系统中,整数寄存器变量最大数量是大约6个,在64位系统中是14个。
在32位系统中,浮点寄存器变量最大数量是8个,在64位系统中是16个(如果AVX512指令集可用,则是32个)。在32位系统中,某些编译器创建浮点寄存器变量有困难,除非启用SSE2(或更新的)指令集。

编译器将选择最常用的变量用作寄存器变量。这包括指针与引用,它们可以保存在整数寄存器里。寄存器变量典型的候选有临时中间结果、循环计数器、函数参数、指针、引用、this指针、公共子表达式以及归纳变量(参见下面)。

如果一个变量有指针或引用援引它,则不能保存在寄存器中。因此,应该避免使用对可从寄存器储存获益的变量使用指针或引用。

生命周期分析

变量的生命期是使用该变量的代码范围。优化编译器可以对多个变量使用相同的寄存器,如果它们的生命期不重叠或者它们确定有相同的值。在可用寄存器数量有限时,这是有用的。例子:
。。。
编译器通常不能对保存在内存里的对象使用这个原则。不能对不同的对象使用相同的内存区域,即使它们的生命期不重叠。如何创建共享相同内存区域的不同对象,后续会讨论。

合并相同的分支

通过合并相同的代码片段,代码可以变得更紧凑。例子:
。。。

消除跳转

通过拷贝要跳转的目标代码,可以避免跳转。例子:
。。。

循环展开

如果要求高度优化,某些编译器将展开循环。参考循环展开。如果循环体非常小或者它开启了进一步优化的可能,这可能是有好处的。重复计数很小的循环可以被完全展开,以避免循环开销。例子:

// Example 8.12a
int i, a[2];
for (i = 0; i < 2; i++) a[i] = i+1;

编译器将这约简为:

// Example 8.12b
int a[2];
a[0] = 1; a[1] = 2;

不幸,某些编译器展开太多。过度的循环展开不是最优的,因为它在代码缓存中占据了太多空间,并填满某些微处理器所具有的循环缓冲。在某些情形里,关闭编译器中循环展开选项可能是有帮助的。

循环不变代码移动

如果一个计算不依赖于循环计数器,可以把它移出循环。例子:

// Example 8.13a
int i, a[100], b;
for (i = 0; i < 100; i++) {
     a[i] = b * b + 1;
}

编译器可能将这改变为:

// Example 8.13b
int i, a[100], b, temp;
temp = b * b + 1;
for (i = 0; i < 100; i++) {
     a[i] = temp;
}

归纳变量

一个循环计数器的线性函数的表达式,可以通过将先前值加上一个常量来计算。例子:
。。。

编译器不需要归纳变量来计算简单类型的数组元素的地址,因为对数组元素地址的计算,如果该地址可以表示为基址加上常量加上索引乘以因子1、2、4或8,CPU有硬件支持;其他非基础类型则不可以。如果例子8.15a中的abfloat,而不是doublesizeof(S1)将是8,将不需要归纳变量,因为CPU对索引乘8有硬件支持。

常见的编译器不会对浮点表达式或更复杂的整数表达式创建归纳变量。如何对多项式计算使用归纳变量,后续再讨论。

调度

为了并行执行的目的,编译器可以重排指令。例子:

// Example 8.16
float a, b, c, d, e, f, x, y;
x = a + b + c;
y = d + e + f;

编译器可以交错这个例子中的两个公式,首先计算a+b,然后d+e,然后第一个和加上c,第二个和加上f,然后第一个结果保存在x,最后第二个结果保存在y中。这样的目的是帮助CPU并行进行多个计算。现代CPU实际上能够无需编译器的帮助重排指令,但编译器可以使重排对CPU更容易。

代数化简

大多数编译器可以使用基本代数规则化简简单的代数表达式。例如,编译器会将表达式-(-a)改为a

我不认为程序员会常常写像-(-a)的表达式,但这样的表达式可能作为其他优化的一个结果,比如函数内联。作为宏展开的结果,可约简表达式也相当常见。

不过,程序员确实经常写出可以约简的表达式。这是因为非约简表达式更好地解释了程序背后的逻辑,或者因为程序员没有想过代数约简。例如,程序员倾向于写if (!a && !b),而不是等价的if (!(a||b)),即使后者少一个操作符。幸好,所有的编译器都能够进行这个情形的约简。

你不能期待编译器约简复杂的代数表达式。例如,一个编译器能够将(a*b*c)+(c*b*a)约简为a*b*c*2。在编译器中实现许多代数规则是相当困难的。在布尔代数的情形里,实现一个可以约简任何表达式的通用算法(比如Quine-McCluskey或Espresso)是可能的,但我测试过的编译器看起来没有这样做的。

相比浮点表达式,编译器更擅长约简整数表达式,即使两者的代数规则是相同的。这是因为浮点表达式的代数操作可能有预期外的效应。这个效应可由下面的例子展示:

// Example 8.17
char a = -100, b = 100, c = 100, y;
y = a + b + c;

根据代数规则,可以写:

y = c + b + a;

如果子表达式c+b可以在别处重用,这可能是有益的。现在设想:a是一个大的负数,bc是大的正数,c+b产生了溢出。整数溢出会产生值回绕,从而产生一个负值。幸运地,c+b的溢出被后续加上a后的另一个溢出抵消了。a+b+c产生了和c+b+a相同的结果,尽管后者涉及了上溢和下溢,而前者没有。这就是整数表达式的代数操作是安全的原因(除了<<=>>=操作符)。

相同的结论不适用于浮点表达式。在上溢与下溢时,浮点变量不会回绕。浮点变量的范围是如此大,我们无需太担心上溢与下溢,除了在特殊的数学应用中。但我们必须考虑精度损失。让我们使用浮点值重复上面的例子:

// Example 8.18
float a = -1.0E8, b = 1.0E8, c = 1.23456, y;
y = a + b + c;

这里的计算给出a+b=0,然后0+1.23456 = 1.23456。但如果我们改变操作数的次序,首先加bc,将不会得到相同的结果。b+c = 100000001.23456float类型大约能保存7位有效数字,因此b+c的值将被取整到10000000。在向这个值加上a时,我们得到0,而不是1.23456

结论是,改变浮点操作数的次序有损失精度的风险。编译器将不会这样做,除非你指定允许浮点计算损失精度的选项。即使打开所有相关的优化选项,编译器将不会进行这样明显的约简,如0/a = 0,因为如果a0或无穷或NAN(不是一个数字),表达式将是无效的。不同的编译器行为不同,因为它们允许或不允许精度损失的不同选项。

你不能依赖编译器在浮点代码上进行任何代数约简。手动进行约简更安全。

去虚拟化

若已知需要哪个版本的虚函数,优化编译器可以绕过用于虚函数调用的虚表查找。例子:
不幸的是,只有少数编译器能够进行这个优化。

欢迎交流
在这里插入图片描述

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值