# 第 5 章 优化程序性能

写程序最主要的目标就是使它在所有可能的情况下都正确工作。一个运行得很快但是给出错误结果的程序没有任何用处。程序员必须写出清晰简洁的代码,这样做不仅是为了自己能够看懂代码,也是为了在检查代码和今后需要修改代码时,其他人能够读懂和理解代码。

另一方面,在很多情况下,让程序运行得快也是一个重要的考虑因素。如果一个程序要实时地处理视频帧或者网络包,一个运行得很慢的程序就不能提供所需的功能。当一个计算任务的计算量非常大,需要执行数日或者数周,那么哪怕只是让它运行得快 20% 也会产生重大的影响。本章会探讨如何使用几种不同类型的程序优化技术,使程序运行得更快。

编写高效程序需要做到以下几点:第一,我们必须选择一组适当的算法和数据结构。第二,我们必须编写出编译器能够有效优化以转换成高效可执行代码的源代码。对于这第二点,理解优化编译器的能力和局限性是很重要的。编写程序方式中看上去只是一点小小的变动,都会引起编译器优化方式很大的变化。有些编程语言比其他语言容易优化。C 语言的有些特性,例如执行指针运算和强制类型转换的能力,使得编译器很难对它进行优化。程序员经常能够以一种使编译器更容易产生高效代码的方式来编写他们的程序。第三项技术针对处理运算量特别大的计算,将一个任务分成多个部分,这些部分可以在多核和多处理器的某种组合上并行地计算。我们会把这种性能改进的方法推迟到第 12 章中去讲。即使是要利用并行性,每个并行的线程都以最高性能执行也是非常重要的,所以无论如何本章所讲的内容也还是有意义的。

在程序开发和优化的过程中,我们必须考虑代码使用的方式,以及影响它的关键因素。通常,程序员必须在实现和维护程序的简单性与它的运行速度之间做出权衡。在算法级上,几分钟就能编写一个简单的插入排序,而一个高效的排序算法程序可能需要一天或更长的时间来实现和优化。在代码级上,许多低级别的优化往往会降低程序的可读性和模块性,使得程序容易出错,并且更难以修改或扩展。对于在性能重要的环境中反复执行的代码,进行大量的优化会比较合适。一个挑战就是尽管做了大量的变化,但还是要维护代码一定程度的简洁和可读性。

我们描述许多提高代码性能的技术。理想的情况是,编译器能够接受我们编写的任何代码,并产生尽可能高效的、具有指定行为的机器级程序。现代编译器采用了复杂的分析和优化形式,而且变得越来越好。然而,即使是最好的编译器也受到妨碍优化的因素(optimization blocker)的阻碍,妨碍优化的因素就是程序行为中那些严重依赖于执行环境的方面。程序员必须编写容易优化的代码,以帮助编译器。

程序优化的第一步就是消除不必要的工作,让代码尽可能有效地执行所期望的任务。这包括消除不必要的函数调用、条件测试和内存引用。这些优化不依赖于目标机器的任何具体属性。

为了使程序性能最大化,程序员和编译器都需要一个目标机器的模型,指明如何处理指令,以及各个操作的时序特性。例如,编译器必须知道时序信息,才能够确定是用一条乘法指令,还是用移位和加法的某种组合。现代计算机用复杂的技术来处理机器级程序,并行地执行许多指令,执行顺序还可能不同于它们在程序中出现的顺序。程序员必须理解这些处理器是如何工作的,从而调整他们的程序以获得最大的速度。基于 Intel 和 AMD 处理器最近的设计,我们提出了这种机器的一个高级模型。我们还设计了一种图形数据流(data-flow)表示法,可以使处理器对指令的执行形象化,我们还可以利用它预测程序的性能。

了解了处理器的运作,我们就可以进行程序优化的第二步,利用处理器提供的指令级并行(instruction-level parallelism)能力,同时执行多条指令。我们会讲述几个对程序的变化,降低一个计算的不同部分之间的数据相关,增加并行度,这样就可以同时执行这些部分了。

我们以对优化大型程序的问题的讨论来结束这一章。我们描述了代码剖析程序(profiler)的使用,代码剖析程序是测量程序各个部分性能的工具。这种分析能够帮助找到代码中低效率的地方,并且确定程序中我们应该着重优化的部分。

在本章的描述中,我们使代码优化看起来像按照某种特殊顺序,对代码进行一系列转换的简单线性过程。实际上,这项工作远非这么简单。需要相当多的试错法试验。当我们进行到后面的优化阶段时,尤其是这样,到那时,看上去很小的变化会导致性能上很大的变化。相反,一些看上去很有希望的技术被证明是无效的。正如后面的例子中会看到的那样,要确切解释为什么某段代码序列具有特定的执行时间,是很困难的。性能可能依赖于处理器设计的许多细节特性,而对此我们所知甚少。这也是为什么要尝试各种技术的变形和组合的另一个原因。

研究程序的汇编代码表示是理解编译器以及产生的代码会如何运行的最有效手段之一。仔细研究内循环的代码是一个很好的开端,识别出降低性能的属性,例如过多的内存引用和对寄存器使用不当。从汇编代码开始,我们还可以预测什么操作会并行执行,以及它们会如何使用处理器资源。正如我们会看到的,常常通过确认关键路径(critical path)来决定执行一个循环所需要的时间(或者说,至少是一个时间下界)。所谓关键路径是在循环的反复执行过程中形成的数据相关链。然后,我们会回过头来修改源代码,试着控制编译器使之产生更有效率的实现。

大多数编译器,包括 GCC,一直都在更新和改进,特别是在优化能力方面。一个很有用的策略是只重写程序到编译器由此就能产生有效代码所需要的程度就好了。这样,能尽量避免损害代码的可读性、模块性和可移植性,就好像我们使用的是具有最低能力的编译器。同样,通过测量值和检查生成的汇编代码,反复修改源代码和分析它的性能是很有帮助的。

对于新手程序员来说,不断修改源代码,试图欺骗编译器产生有效的代码,看起来很奇怪,但这确实是编写很多高性能程序的方式。比较于另一种方法——用汇编语言写代码,这种间接的方法具有的优点是:虽然性能不一定是最好的,但得到的代码仍然能够在其他机器上运行。