5.1 优化编译器的能力和局限性
5.1 优化编译器的能力和局限性
现代编译器运用复杂精细的算法来确定一个程序中计算的是什么值,以及它们是被如何使用的。然后会利用一些机会来简化表达式,在几个不同的地方使用同一个计算,以及降低一个给定的计算必须被执行的次数。大多数编译器,包括 GCC,向用户提供了一些对它们所使用的优化的控制。就像在第 3 章中讨论过的,最简单的控制就是指定优化级别。例如,以命令行选项 -Og 调用 GCC 是让 GCC 使用一组基本的优化。以选项 -O1 或更高(如 -O2 或 -O3)调用 GCC 会让它使用更大量的优化。这样做可以进一步提高程序的性能,但是也可能增加程序的规模,也可能使标准的调试工具更难对程序进行调试。我们的表述,虽然对于大多数使用 GCC 的软件项目来说,优化级别 -O2 已经成为了被接受的标准,但是还是主要考虑以优化级别 -O1 编译出的代码。我们特意限制了优化级别,以展示写 C 语言函数的不同方法如何影响编译器产生代码的效率。我们会发现可以写出的 C 代码,即使用 -O1 选项编译得到的性能,也比用可能的最高的优化等级编译一个更原始的版本得到的性能好。
编译器必须很小心地对程序只使用安全的优化,也就是说对于程序可能遇到的所有可能的情况,在 C 语言标准提供的保证之下,优化后得到的程序和未优化的版本有一样的行为。限制编译器只进行安全的优化,消除了造成不希望的运行时行为的一些可能的原因,但是这也意味着程序员必须花费更大的力气写出编译器能够将之转换成有效机器代码的程序。为了理解决定一种程序转换是否安全的难度,让我们来看看下面这两个过程:
void twiddle1(long *xp, long *yp)
{
*xp += *yp;
*xp += *yp;
}
void twiddle2(long *xp, long *yp)
{
*xp += 2 * *yp;
}乍一看,这两个过程似乎有相同的行为。它们都是将存储在由指针 yp 指示的位置处的值两次加到指针 xp 指示的位置处的值。另一方面,函数 twiddle2 效率更高一些。它只要求 3 次内存引用(读 *xp,读 *yp,写 *xp),而 twiddle1 需要 6 次(2 次读 *xp,2 次读 *yp,2 次写 *xp)。因此,如果要编译器编译过程 twiddle1,我们会认为基于 twiddle2 执行的计算能产生更有效的代码。
不过,考虑 xp 等于 yp 的情况。此时,函数 twiddle1 会执行下面的计算:
*xp += *xp; /* Double value at xp */
*xp += *xp; /* Double value at xp */结果是 xp 的值增加 4 倍。另一方面,函数 twiddle2 会执行下面的计算:
*xp += 2 * *xp; /* Triple value at xp */结果是 xp 的值增加 3 倍。编译器不知道 twiddle1 会如何被调用,因此它必须假设参数 xp 和 yp 可能会相等。因此,它不能产生 twiddle2 风格的代码作为 twiddle1 的优化版本。
这种两个指针可能指向同一个内存位置的情况称为内存别名使用(memory aliasing)。在只执行安全的优化中,编译器必须假设不同的指针可能会指向内存中同一个位置。再看一个例子,对于一个使用指针变量 p 和 q 的程序,考虑下面的代码序列:
x = 1000;
y = 3000;
*q = y; /* 3000 */
*p = x; /* 1000 */
t1 = *q; /* 1000 or 3000 */t1 的计算值依赖于指针 p 和 q 是否指向内存中同一个位置。如果不是,t1 就等于 3000,但如果是,t1 就等于 1000。这造成了一个主要的妨碍优化的因素,这也是可能严重限制编译器产生优化代码机会的程序的一个方面。如果编译器不能确定两个指针是否指向同一个位置,就必须假设什么情况都有可能,这就限制了可能的优化策略。
练习题 5.1
下面的问题说明了内存别名使用可能会导致意想不到的程序行为的方式。考虑下面这个交换两个值的过程:
/* Swap value x at xp with value y at yp */ void swap(long *xp, long *yp) { *xp = *xp + *yp; *yp = *xp - *yp; *xp = *xp - *yp; }如果调用这个过程时
xp等于yp,会有什么样的效果?
第二个妨碍优化的因素是函数调用。作为一个示例,考虑下面这两个过程:
long f();
long func1()
{
return f() + f() + f() + f();
}
long func2()
{
return 4 * f();
}最初看上去两个过程计算的都是相同的结果,但是 func2 只调用 f 一次,而 func1 调用 f 四次。以 func1 作为源代码时,会很想产生 func2 风格的代码。
不过,考虑下面 f 的代码:
long counter = 0;
long f()
{
return counter++;
}这个函数有个副作用——它修改了全局程序状态的一部分。改变调用它的次数会改变程序的行为。特别地,假设开始时全局变量 counter 都设置为 0,对 func1 的调用会返回 0+1+2+3=6,而对 func2 的调用会返回 4·0=0。
大多数编译器不会试图判断一个函数是否没有副作用,如果没有,就可能被优化成像 func2 中的样子。相反,编译器会假设最糟的情况,并保持所有的函数调用不变。
旁注 用内联函数替换优化函数调用
包含函数调用的代码可以用一个称为内联函数替换(inline substitution,或者简称“内联(inlining)”)的过程进行优化,此时,将函数调用替换为函数体。例如,我们可以通过替换掉对函数
f的四次调用,展开func1的代码:/* Result of inlining f in func1 */ long func1in() { long t = counter++; /* +0 */ t += counter++; /* +1 */ t += counter++; /* +2 */ t += counter++; /* +3 */ return t; }这样的转换既减少了函数调用的开销,也允许对展开的代码做进一步优化。例如,编译器可以统一
func1in中对全局变量counter的更新,产生这个函数的一个优化版本:/* Optimization of inlined code */ long func1opt() { long t = 4 * counter + 6; counter += 4; return t; }对于这个特定的函数
f的定义,上述代码忠实地重现了func1的行为。GCC 的最近版本会尝试进行这种形式的优化,要么是被用命令行选项
-finline指示时,要么是使用优化等级-O1或者更高的等级时。遗憾的是,GCC 只尝试在单个文件中定义的函数的内联。这就意味着它将无法应用于常见的情况,即一组库函数在一个文件中被定义,却被其他文件内的函数所调用。在某些情况下,最好能阻止编译器执行内联替换。一种情况是用符号调试器来评估代码,比如 GDB,如 3.10.2 节描述的一样。如果一个函数调用已经用内联替换优化过了,那么任何对这个调用进行追踪或设置断点的尝试都会失败。还有一种情况是用代码剖析的方式来评估程序性能,如 5.14.1 节讨论的一样。用内联替换消除的函数调用是无法被正确剖析的。
在各种编译器中,就优化能力来说,GCC 被认为是胜任的,但是并不是特别突出。它完成基本的优化,但是它不会对程序进行更加“有进取心的”编译器所做的那种激进变换。因此,使用 GCC 的程序员必须花费更多的精力,以一种简化编译器生成高效代码的任务的方式来编写程序。