编写高效程序需要做到以下几点:第一,必须选择一组适当的算法和数据结构。第二,必须编写出编译器能够有效优化以转换为高效可执行代码的源代码。对于这第二点,理解优化编译器的能力和局限性是很重要的。程序员经常能够以一种使编译器更容易产生高效代码的方式来编写他们的程序。第三项技术针对处理运算量特别大的计算,将一个任务分成多个部分,这些部分可以在多核和多处理器的某种组合上并行地计算。
优化编译器的能力和局限性
大多数编译器,包括GCC,向用户提供了一些对它们所使用的优化的控制。最简单的控制就是指定优化级别。例如,以命令行选项“-Og”调用GCC是让GCC使用一组基本的优化。以选项“-O1”或更高(如“-O2”或“-O3”)调用GCC会让它使用更大量的优化。这样做可以进一步提高程序的性能,但是也可能增加程序的规模,也可能使标准的调试工具更难对程序进行调试。我们会发现可以写出的C代码,即使用-O1选项编译得到的性能,也比用可能的最高的优化等级编译一个更原始的版本得到的性能好。
编译器必须很小心地对程序只使用安全的优化,也就是说对于程序可能遇到的所有可能的情况,在C语言标准提供的保证之下,优化后得到的程序和未优化的版本有一样的行为。限制编译器只进行安全的优化,消除了造成不希望的运行时行为的一些可能的原因,但是这也意味着程序员必须花费更大的力气写出编译器能够将之转换成有效机器代码的程序。
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的值的4倍,函数twiddle2结果是xp的值的3倍。编译器不知道twiddle1会被如何调用,因此它必须假设参数xp和yp可能会相等。因此,它不能产生twiddle2风格的代码作为twiddle1的优化版本。
这种两个指针可能指向同一个内存位置的情况称为内存别名使用(memory aliasing)。在只执行安全的优化中,编译器必须假设不同的指针可能会指向内存中同一个位置。
第二个妨碍优化的因素是函数调用。作为一个实例,考虑下面这两个过程:
C++ long f(); long func1() { return f() + f() + f() + f(); } long func2() { return 4*f(); } |
最初看上去两个过程计算的都是相同的结果,但是func2只调用f一次,而func1调用f四次。以func1作为源代码时,会很想产生func2风格的代码。不过,考虑下面f的代码:
C++ long counter = 0; long f() { return counter++; } |
这个函数有个副作用——它修改了全局程序状态的一部分。改变调用它的次数会改变程序的行为。特别地,假设开始时全局变量counter都设置为0,对func1的调用会返回0+1+2+3=6,而对func2的调用会返回4*0=0。
大多数编译器不会试图判断一个函数是否没有副作用,如果没有,就可能被优化成像func2中的样子。相反,编译器会假设最糟的情况,并保持所有的函数调用不变。
表示程序性能
我们引入度量标准每元素的周期数(Cycles Per Element,CPE),作为一种表示程序性能并指导我们改进代码的方法。
未经优化的代码是从C语言代码到机器代码的直接翻译,通常效率明显降低。简单地使用命令行选项“-O1”,就会进行一些基本的优化。
消除循环的低效率
C++ /* Convert string to lowercase: slow */ void lower1(char *s) { long i;
for(i = 0; i < strlen(s); i++) if(s[i] >= 'A' && s[i] <= 'Z') s[i] -= ('A' - 'a'); } /* Convert string to lowercase: faster */ void lower2(char *s) { long i; long len = strlen(s);
for(i = 0; i < len; i++) if(s[i] >= 'A' && s[i] <= 'Z') s[i] -= ('A' - 'a'); } /* Sample implementation of library function strlen */ /* Compute length of string */ size_t strlen(const char *s) { long length = 0; while(*s != '\0') { s++; length++; } return length; } |
这个优化是一类常见的优化的一个例子,称为代码移动(code motion)。这类优化包括识别要执行多次(例如在循环里)但是计算结果不会改变的计算。这个实例说明了编程时一个常见的问题,一个看上去无足轻重的代码片段有隐藏的渐进低效率(asymptotic inefficiency)。
C++ void |
理解现代处理器
在代码级上,看上去似乎是一次执行一条指令,每条指令都包括从寄存器或内存取值,执行一个操作,并把结果存回到一个寄存器或内存位置。在实际的处理器中,是同时对多条指令求值的,这个现象称为指令级并行。
我们会发现两种下界描述了程序的最大性能。当一系列操作必须按照严格顺序执行时,就会遇到延迟界限(latency bound),因为在下一条指令开始之前,这条指令必须结束。当代码中的数据相关限制了处理器利用指令级并行的能力时,延迟界限能够限制程序性能。吞吐量界限(throughput bound)刻画了处理器功能单元的原始计算能力。这个界限是程序性能的终极限制。
整体操作
我们假想的处理器设计是不太严格地基于近期的Intel处理器的结构。这些处理器在工业界称为超标量(superscalar),意思是它可以在每个时钟周期执行多个操作,而且是乱序的(out-of-order)。整个设计有两个主要部分:指令控制单元(Instruction Control Unit,ICU)和执行单元(Execution Unit,EU)。

指令高速缓存(instruction cache)
分支预测(branch prediction)
投机执行(speculative execution)
数据高速缓存(data cache)
退役单元(retirement unit)
功能单元的性能
每个运算都是由以下这些数值来刻画的:一个是延迟(latency),它表示完成运算所需要的总时间;另一个是发射时间(issue time),它表示两个连续的同类型的运算之间需要的最小时钟周期数;还有一个是容量(capacity),它表示能够执行该运算的功能单元的数量。

发射时间为1的功能单元被称为完全流水线化的(fully pipelined):每个时钟周期可以开始一个新的运算。
对一个容量为C,发射时间为I的操作来说,处理器可能获得的吞吐量为每时钟周期C/I个操作。
应用:性能提高技术
优化程序性能的基本策略:
高级设计。为遇到的问题选择适当的算法和数据结构。要特别警觉,避免使用那些会渐进地产生糟糕性能的算法或编码技术。
基本编码原则。避免限制优化的因素,这样编译器就能产生高效的代码。
消除连续的函数调用。在可能时,将计算移到循环外。考虑有选择地妥协程序的模块性以获得更大的效率。
消除不必要的内存引用。引入临时变量来保存中间结果。只有在最后的值计算出来时,才将结果存放到数组或全局变量中。
低级优化。结构化代码以利用硬件功能。
展开循环,降低开销,并且使得进一步的优化成为可能。
通过使用例如多个累计变量和重新结合等技术,找到方法提高指令级并行。
用功能性的风格重写条件操作,使得编译采用条件数据传送。