Featured image of post 如何写出高效的代码——编译器的优化能力和局限性

如何写出高效的代码——编译器的优化能力和局限性

为了写出更高效的代码,理解编译器的优化能力和局限性是很重要的,毕竟你编写的程序最终交由编译器去编译成机器码,如果编译器能够恰当地帮你优化代码,那么将会提高你的代码运行效率。在本文中,我们将尝试理解编译器的优化能力,并了解GCC中不同优化等级的优化策略,同时也要理解编译器的优化局限性,并不是程序员看上去很容易优化的代码,编译器也会进行优化,编译器的优化是“安全的优化”。

前言

今日技术梳理:《深入理解计算机系统》第五章第一节:优化编译器的能力和局限性。这个标题有一些拗口,根据中文的阅读习惯,我们可能会对标题误解为:“程序员对编译器进行优化,这种能力与这种能力的局限性”。其实不然,本节所讨论的是“编译器自身对程序的优化能力与其优化能力的局限性”。我们知道,编译器本身在将我们的C语言代码转换为机器码时,首先会通过自身复杂且精细的算法来对其进行一定程度上的优化,利用一些机会来简化表达式,而本节中,我们则需要了解编译器的这种优化能力及其局限性,它为什么能进行优化,又在什么情况下无法进行优化,以便我们能够写出让编译器更好优化的代码,即:效率更高的代码。

编译器的优化等级

在现代大多数编译器中,包括GCC,向用户提供了一些对它们所使用的优化的控制,使得我们可以控制编译器对代码的优化程度,例如:以命令行选项 "-Og" 调用GCC会让GCC使用一组基本的优化,以 -O1或更高 -O2-O3等,调用会使得GCC使用更大量的优化,可以提高程序的性能,但相应的被编译器优化后的代码不一定与源代码相同,在某些情况下会使得调试更加困难。

编译器的安全优化

编译器必须只能对程序使用 安全的优化

什么是安全优化

安全优化是指:编译器在保证程序行为不变(遵循C/C++标准中的"as-if"规则)的前提下进行的优化,即使优化后的代码逻辑变化(如删除冗余计算,内联函数的等),程序的可观测行为必须和未优化版本一致。也就是说,对于程序可能遇到的所有情况下,优化后的程序必须保证和未优化的程序运行结果一样。

优化等级与安全性的关系

就本质而言,所有的优化等级都在安全范围内,即符合安全规则,但不同成都的优化等级采取的优化策略激进程度不同。

  • 默认无优化-O0,不进行任何优化,代码直接映射到源码,便于调试,安全性最高,行为完全符合源码,但性能最低
  • 删除未使用的变量:该策略从 -O1 起,更高等级的优化也会使用
  • 常量折叠:该策略从 -O1 起,更高等级的优化也会使用
  • 循环展开-O2 起会采用该策略,较为激进的策略
  • 内联函数-O2 起会采用该策略
  • 向量化,并行化-O3 会采用的策略,更加激进,但仍在安全范围内,但可能暴露源码中隐藏的未定义行为(如越界访问),但这源于代码自身问题,而非优化引入。

总的而言,虽然GCC可以采取不同程度的优化策略,但都是处于安全范围内,遵循语言规范,如若程序因优化出现异常,需检查源码中未定义的行为。同时这也侧面反映出,优化的一大特性,即:优化等级越高,与源码可能越不相同,越难调试。

编译器优化的局限性

请看下面的例子:

指针未知导致的优化阻碍

下面给出两个函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
void twiddle1(long *xp, long *yp)
{
    *xp += *yp;
    *xp += *yp;
}

void twiddle2(long *xp, long *yp)
{
    *xp += 2* *yp;
}

这两个函数似乎在完成相同的行为,即将yp指针所指的值两次加到xp指针所指的值。

但我们从机器运行的角度来考虑这两个代码运行的效率,首先 twiddle1函数会在*xp += *yp;处产生读*xp一次,读*yp一次,写*xp一次,共计3次操作,所以,整个函数将会产生6次对内存的引用操作。而twiddle2仅需要1次读*xp,1次读*yp,1次写*xp,共计3次内存引用操作即可。

由此我们可以看出,twiddle2函数的效率更高一些。因此,如果编译器要编译twiddle1,我们会认为,将其编译成twiddle2的形式会产生更加高效的代码。

但是如果我们考虑到,如果*xp与*yp引用的地址是同一个时,如下的情形:

twiddle1函数会执行以下计算:

1
2
*xp += *xp;  // 此时xp指针所指的值会变为原来的2倍
*xp += *xp;  // 此时xp指针所指的值会变为原来的4倍

twiddle2函数会执行以下计算:

1
*xp += 2 * *xp;  // 此时xp所指的值会变为原来的3倍

这种两个指针可能指向同一个内存位置的情况被称为 内存别名使用(memory aliasing)

因此,在某种特殊的情况下,twiddle1twiddle2可能会产生不同的值,因此,根据我们上面所说:编译器必须只能对程序进行 安全的优化 ,在只执行安全的优化中,编译器必须假设不同的指针可能会只想内存中的同一位置,因而编译器不会将twiddle1优化成twiddle2的形式,即使在大部分情况下,twiddle2的效率要比twiddle1要高。

我们再来考虑下面一个例子:

1
2
3
4
x = 1000; y = 3000;
*q = y;
*p = x;
t1 = *q;

此程序看上去十分明确,但实际上,t1的计算值我们目前是无法直接确定的,t1的计算值依赖于指针p和q是否指向同一块内存区域,如果指针p和指针q不指向同一块内存,则显然t1的值就是3000,但如果指针p和指针q指向的内存区域相同,那么指针q一开始赋值的3000就会被后面指针p的赋值给覆盖掉,因此此时t1的值为1000

这就造成了一个主要的妨碍优化的因素,这也是可能严重限制编译器产生优化代码机会的程序的一个方面,如果编译器不能确定两个指针是否指向同一个位置,就必须假设它们可能会指向同一个位置,这就限制了可能的优化策略。

函数调用时产生的优化阻碍

考虑下面两个函数:

1
2
3
4
5
6
7
8
9
long f();

ling func1(){
    return f() + f() + f() + f();
}

long func2(){
    return 4 * f();
}

这两个函数看上去计算的都是相同的结果,但是 func2 只调用 f 1次,而 func1 调用了 f 4次,这显然 func1 的开销要大于 func2 。如果以 func1 作为源代码时,编译器很想产生 func2风格的代码,但考虑以下 f 函数的代码:

1
2
3
4
5
long counter = 0;

long f(){
    return counter++;
}

聪明的程序员很快就反应过来了,这个 f 函数修改了一个全局变量 counter,并将其状态返回,改变调用 f 函数的次数会改变程序的行为,即返回值。假设开始时全局变量counter都设置为0,那么对 func1 的调用会返回 0+1+2+3=6,而对于 func2 的调用会返回 4*0=0。

大多数编译器都会假设最糟糕的情况,因此不会产生 func2 的样子,而是保持函数调用不变。

用内联函数替换优化函数的调用

包含函数调用的代码可以用一个称为内联函数替换(inline substitution, 简称为内联)的过程进行优化,此时,将函数调用替换为函数体。

例如,对上面的例子,我们可以通过替换掉函数 f 的四次调用,展开 func1 的代码:

1
2
3
4
5
6
7
long funclin(){
    long t = counter++;
    t += counter++:
    t += counter++:
    t += counter++:
    return t;
}

这样的转换减少了函数调用的开销,也允许对展开的代码做进一步优化,比如编译器可以统一 funclin 中对全局变量counter的更新,产生这个函数的一个优化版本:

1
2
3
4
5
long funclopt(){
    long t = 4 * counter + 6;
    counter += 4;
        return t;
}

上面的函数 funclopt 忠实地重现了 f 的行为。

在GCC中,如果你使用 -finline或者优化等级 -o1或更高时,编译器就会尝试进行这种形式的优化。但遗憾的是,GCC只尝试在单个文件中定义的函数进行内联。这就意味着它将无法应用于更常见的情况。

在某些情况下,最好能阻止编译器执行内联替换。一种情况是用符号调试器来评估代码,比如GDB,如果一个函数被内联优化过了,那么任何对这个调用进行追踪或设置断点的尝试都会失败。还有一种情况是用代码剖析得方式来评估程序性能,用内联替换的函数调用是无法被正确剖析的。

总结

在本节中,我们指出了GCC本身具有对程序的优化能力以提升程序效率,但根据安全规定,编译器仅愿意对程序进行“安全的优化”,在考虑各种情况下,若程序的行为因为优化而发生改变,编译器便会放弃优化。就单论优化能力而言,GCC被认为是胜任的,但并不突出,原因是编译器会考虑各种最糟糕的情况而不会进行优化,或采取一些较为“离奇”的手段进行优化,保证提高效率的同时忠实地重现行为,但代价是牺牲了程序的可读性和调试能力。

因此,在了解编译器的优化局限性后,希望同学们能够注意本文中提到的可能会给编译器带来的“困惑”,在后续的章节中,我们再来讨论如何编写出优秀的高效的代码。

written by LyricalWander
使用 Hugo 构建
主题 StackJimmy 设计