本文主要介绍如何从计算机体系结构的角度,利用计算机系统的硬件特点来优化软件代码。本文采用C语言进行样例的描述,但是在大多数其他的编程语言中也能够适用。
首先,我们将阐述为何需要从计算机体系结构的角度进行程序优化。接着,我们抽象出了从计算机体系结构的角度分析性能问题的模式。然后,我们将了解几种被广泛应用在各种体系结构上的,基于体系结构的程序性能优化方式,并将改进前后的性能进行对比。最后,我们将综合所有在文中谈到的程序性能优化方式,一同探讨如何进行进一步的优化。
尽管本文主要阐述程序性能优化,但是我们还是必须首先支出,编写程序的主要目标就是编写出可用的程序,而非高效的程序。我们所做的一切优化,都应该是在写出正确、可交付的程序的基础上。也就是说,可用性是程序性能优化的地基。
在建立好地基后,我们就可以思考让我们的程序运行得更加快速。一般而言,程序性能优化有两个大方向:
- 选择更合适的算法以及数据结构
- 让编译器或者解释器能够更好对你的代码进行优化
在本文中,我们将专注于第二个大方向。并且我们将只关注如何让编译器能够更好对你的代码进行优化,而不谈解释器。
我们知道,一般编译器的职责就是将源程序通过一定步骤转换为二进制代码。在这个过程中,编译器会根据你的代码进行优化。
试着回忆一下,在使用gcc(GNU C Compiler)的过程中,我们可以通过指定-O1
选项,让编译器进行一级性能优化。同样,分别通过指定-O2
、-O3
可以让gcc进行二级、三级性能优化。我们可以通过指定-O0
来禁止gcc进行优化。下面我们通过一个实例来说明编译器对我们的程序进行了优化。
假设我们有如下代码:
// test.c
int mul4(int a)
{
return a + a + a + a;
}
如果我们在编译的时候,禁止编译器进行优化,即执行:
$ gcc -O0 -c test.c
我们将得到一个二进制文件__test.o__,这是链接前的二进制文件。接下来我们采用_gobjdump_工具获取该二进制文件的反汇编码:
$ gobjdump -d test.o
得到的反编码代码如下(IA64体系结构下):
0000000000000000 <_mul4>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 89 7d fc mov %edi,-0x4(%rbp)
7: 8b 7d fc mov -0x4(%rbp),%edi
a: 03 7d fc add -0x4(%rbp),%edi
d: 03 7d fc add -0x4(%rbp),%edi
10: 03 7d fc add -0x4(%rbp),%edi
13: 89 f8 mov %edi,%eax
15: 5d pop %rbp
16: c3 retq
在这里,传入mul4
函数的参数a
被连续加和到%edi
寄存器,总共加和了4次,最后被移动到返回值寄存器%eax
中。我们观察到,其实在这个过程中并没有必要将a
连加4次,而可以直接通过返回a * 4
来优化程序性能。这正是一级优化会为我们做的。我们通过gcc编译这段程序,并且要求它使用一级优化:
$ gcc -O1 -c test.c
获取反编码:
0000000000000000 <_mul4>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 8d 04 bd 00 00 00 00 lea 0x0(,%rdi,4),%eax
b: 5d pop %rbp
c: c3 retq
在这里,从指令规模上看,虽然lea
指令涉及乘法操作,其时间开销比add
指令大。但是在指令规模上,优化后的程序远远优于优化前的程序。如果在一个小小的函数,我们能够节约极小的CPU时间开销。那么对于整个大型的程序,在性能上将有极大的改观。
因此,编译器优化对于提高程序性能有很大的帮助。
编译器优化的前提是要进行安全的优化,即通过编译器进行的优化不能改变原有代码的功能以及可用性。让编译器只做安全的优化,可以避免不必要的运行错误。但是,在此同时也意味着,作为编写程序的人员,需要花更多的心思去编写让编译器能够优化的代码。
例如,有下面两个函数的代码片段:
void tw1(int *xp, int *yp)
{
*xp += *yp;
*xp += *yp;
}
void tw2(int *xp, int *yp)
{
*xp += 2* *yp;
}
我们可以观察到,tw1
与tw2
在功能上是一样的,它们都将*yp
(即yp
指针所指向的内存区域中存储的整数)的值的两倍加到*xp
上。在未优化的情况下,tw2
的性能将比tw1
优。原因是,在tw2
中只发生了三次内存引用(读*xp
,读*yp
,写*xp
),在tw1
中发生了六次内存引用(两次读*xp
,两次读*yp
,两次写*xp
)。
那么我们或许期望编译器能将tw1
优化成跟tw2
等效的代码,就好像之前编译器将a + a + a + a
优化为4 * a
一样。然而,在编译器的层面上,并不会对tw1
进行优化。原因是,编译器并不知道xp
和yp
是否指向同一个内存地址。当xp
和yp
指向同一个内存地址时,上述代码等效于:
void tw1(int *xp)
{
*xp += *xp;
*xp += *xp;
}
void tw2(int *xp)
{
*xp += 2* *xp;
}
在这样的情况下,tw1将把*xp
变为原来的4倍,而tw2
只将*xp
其变为原来的两倍。所以,tw1
与tw2
这两个函数是不等价的。虽然这两个函数在一定的情况下可能等价,但是编译器出于安全起见,是不会将tw1
优化成tw2
的。
因此,编写让编译器有安全感去优化的代码,是我们在编写代码的时候需要考虑的事情。尤其是在进行代码重构的时候,我们会更倾向于着重考虑性能优化。
下图是计算机体系结构的层次图。
我们可以看到,编译器的作用是将高级语言机器所能理解的语言转化成汇编语言机器所能理解的语言,汇编语言机器通过直接的翻译将汇编代码翻译成操作系统机器所能理解的语言。因此,机器指令的调度操作也在此时由编译器大部分完成,这样的调度称作编译时指令调度。
我们在之前提到的编写安全的、易于编译器优化的代码,就是为了让编译器进行更好的编译时指令调度,从而优化程序性能。编译时指令调度将影响汇编后指令的形式、顺序,因此影响编译后机器指令的形式和顺序。这些形式和顺序将决定我们的程序是否能够高效地、充分地利用系统资源(例如cache、内存等)。
由此可见,从计算机体系结构的角度考虑优化程序是编写安全的、易于编译器优化的代码的基础。