在这个硬件性能日新月异的年代里,我们充分的享受着硬件带给我们的 “ 免费午餐 ” ,似忽软件的效率问题已经不再是一种被开发者关心的问题,但是,随着 CPU 的主频提高步伐越来越慢,高速缓存也不是可以无限制增加,新技术的使用逐渐被用来提高硬件的性能,这时候,要充分发挥这些新特性,软件的支持就显得很重要了,于是,程序的运行效率再一次映入我们的视野,新时代的软件优化,我们该何去何从?
看见这篇文章的标题,你可能会说:噢,有必要吗,现在的电脑速度越来越快,何苦去费力写高性能的程序呢?如果程序性能实在无法恭维,满足不了需求,那大不了去换一块更快的 CPU ,问题就可以轻松解决了。
或许曾经如此,但种种迹象表明,硬件业为软件业提供的免费 “ 性能提升 ” 午餐已经快要到头了, CPU 的主频提高步伐越来越慢,高速缓存也不是可以无限制增加,现在 CPU 更多地用新指令集、并行执行、乱序执行、多处理内核等技术来提高速度,而这些技术都需要软件的配合才能发挥作用。如果软件没有使用 MMX 、 3DNow 、 SSE 等指令集,那么就无法享受这些指令集带来的性能提升;如果程序内部并行性不好(没有使用多线程构架、执行流中条件转移过多),那么就无法高效利用 CPU 的并行处理功能(多内核、乱序并行执行)。所以, “ 如何编写高性能程序 ” 还是一门需要仔细钻研的学问。
幸运的是,这些 “ 需要软件的配合 ” 的工作大多数可以由编译器来自动完成。所以绝大多数情况下我们不需要也不应该牺牲程序的可读性去做局部优化。本文的 “ 何时不应该做优化 ” 一节对此进行了详细讨论。
但是,也有很多优化,特别是较大粒度、较高抽象层次的优化,至少在目前,编译器是无法做到的。比如,把单线程构架改成多线程构架(这点非常重要,否则哪怕运行在四内核的处理器上你的程序也只能占用一个内核,而现在 Intel 、 AMD 、 IBM 都把在处理器上集成多内核当作了主要的提升处理器性能的手段),使用较低时间复杂性的算法,等等。本文的 “ 何时应该做优化 ” 以及 “ 优化的步骤 ” 两个小节对此做了展开叙述。
涉及到较高层次的优化,以及系统构架对性能的影响,其实有很多规律可行。本文的 “ 设计构架时要注意的一些原则 ” 和 “ 性能模式与反模式 ” 两小节对此进行了叙述。
在开发中,我们最经常遇到的优化问题是:内存分配(在大多数操作系统中,从堆上分配内存是开销比较大的操作),线程同步(不同的同步原语性能差距很大),字符串操作( STL 中的 string 真的要比 char* 慢吗, Copy-on-Write 的 string 是提高了还是降低了性能),以及这 3 个问题的组合(在多线程环境下进行涉及到内存分配的字符串操作)。
本文也比较详尽地探讨了这些常见的问题。
何时不应该做优化
用 3 句话来概括:如果没有数据证明 “ 性能没有达到指标 ” ,就不要做优化;如果待优化部分不是在关键路径上,就不要做优化;如果没有数据证明 “ 优化确实起作用了 ” ,就不要使用优化的代码。
这 3 句话中的 “ 数据 ” 指的主要是用 profiler 模拟各种真实或者极限情况下跑出的数据。背后的理念是,要有的放矢,功夫花在刀刃上(注意 80/20 法则),用数据说话,不要想当然。
比如,若你参与开发的一个软件,测试时发现启动和退出花的时间都比较长,在代码复审时也确实发现,启动和退出部分的代码不够高效。于是,放弃休假时间,终于把这部分优化好了。嗯,且慢,再看一下需求。噢,这个软件会被这样用:启动之后就会 24x7 地不停机地运行,直到两年后被新版本代替。那么就是说,启动和退出代码只会被执行一次。你放弃休假时间所做的优化值得吗?优化之前请先检查代码是否在关键路径上。
再比如,你发现代码中有一些小函数,还有一些循环体,想优化一下,就把小函数都内联了,把循环体展开了。这时你就必须用数据来证明,你的优化确实起作用了,而没有 “ 好心帮倒忙 ” 地让优化起反作用。你可能会惊讶, “ 怎么可能 ” ?噢,完全有可能的,因为可能原来的这部分代码比较小,正好够放在 CPU 的一级高速缓存中,或者够放在物理内存中。你优化过后代码体积增加了,于是缓存中放不下了,或者甚至内存都放不下要用虚存了,于是 CPU 或者操作系统不得不经常换页,性能会大幅度降低。所以,不要想当然,要用数据证明你所做的优化确实是优化。
还有很多 “ 好心没好报 ” 、 “ 优化反被优化误 ” 的例子。比如很多编译器都会自动识别对数组操作的 for 循环这样的代码模式,并自动做优化。如果你多事地自己去做了优化,改成了指针操作,编译器可能就不认识这个代码模式了,于是就只好保守地产生非优化的代码。这样产生的代码往往反而效率降低了。
再比如现在很多 JVM 都会识别 String 操作的模式。若你为了可能的性能提升而手工地把对 String 的操作改成不那么直观优雅的 StringBuffer 操作,那不仅降低了代码的可读性,还给 JVM 的优化机制帮了倒忙。
再比如, Copy-On-Write 是一种常用的对字符串操作的优化,可以大幅度减少内存分配和读写操作。但是在多线程环境中,为了保持 Copy-On-Write 的正确性,就不得不额外增加不少线程同步操作。究竟是内存操作的开销大还是线程同步操作的开销大呢?最好用数据来说话。事实上,在多线程环境中 Copy-On-Write 多半是个得不偿失的优化。以前不少 STL 实现中 string 都用了 Copy-On-Write ,但现在好多 STL 实现都摒弃了这种做法。其实我觉得更好的办法是提供参数来让用户选择。毕竟如果不需要用到多线程, Copy-On-Write 还是很好的。
何时应该做优化
如果前一节所说的 “ 不应优化 ” 的前提不成立,那么就只能做优化了。具体的说:如果数据表明,性能确实没有达到指标,特别是当 profiler 表明,某处关键路径上的代码执行占用了大量的时间,那么就是优化的时候了。
首先,要确保你要优化的代码是正确的,没有任何已知 bug 。因为优化后的代码往往会变得更复杂而难以修改,所以要趁代码还比较简单的时候赶紧把 bug 都修掉吧。
然后,要确认性能指标,可以查 specification ,或者如果不清楚的话再问问客户,或者根据其他功能性需求计算得出。用 profiler 收集目前的性能数据,和性能指标对比,以确定是否需要优化、哪里需要优化。(数据要保留,因为等优化完后还要用这些数据来做对比,以检查优化是否有效。)常见的 profiler 有 Rational Quantify 、 Borland Optimizeit 等等。很多 UNIX 下面都自带了 profiler ,比如 prof 、 gprof 等,对于一般的使用已经够了。
第三,进行优化。后面 “ 常用的优化方法 ” 一节对此进行了详细介绍。可以照着列出的常见的优化方法一个个地套用,或者更好的办法是进行一次团队头脑风暴会议,让大家提出各种可能的优化方案。记得优化时不要删除原来的实现。可以在源文件中以替代函数或者注释的方式保留原来的实现。
第四,使用 profiler ,验证优化是否如所想的那样有效。如果有效,那是最好;如果无效甚至是帮了倒忙,那么就赶紧取消改动,使用原来的版本,然后继续尝试其他的优化方案。记得优化要一步一步来,从最省事且最有效的方案到最麻烦且收益最小的方案。一旦达成性能指标就收手,不要恋战。
最后,记得对优化过的代码执行单元测试,看看有没有为了性能牺牲了正确性。要记得在注释或者文档中为优化留下记录。
常用的优化方法
最简单的优化:请检查是否使用了编译器的最新版本,是否把优化编译开关打开了,是否正确指定了目标处理器(以便使用 MMX 、 SSE 、 3DNow! 等高性能指令集以及让编译器自动为处理器所支持的其他高级特性做优化)。如果发布的产品要支持多种处理器,那么如果可能的话,请单独为每种处理器进行编译,分别发布,或者使用同一个发布包但让安装程序自动检测处理器型号并安装对应的二进制版本,或者把会在关键路径上执行的代码封装成动态链接库,然后让程序启动时自动检测处理器型号并加载为相应型号优化过的动态链接库版本。
还有,要确保使用了高性能的库,好的算法。比如,同样是从堆上分配内存,不同编译器提供的 malloc 或者 new 的实现,性能差异就不小。 GCC 使用的 DL malloc 就比较高效, Borland 的编译器提供的实现使用了类似内存池的方式来动态管理内存,效率也很高,但也有些编译器对此并没有做什么优化,直接进行系统调用。不仅 malloc/new 如此, STL 的 allocator 也是如此。 SGI STL 带的 allocator 为小于 128 字节的内存块的分配进行了特别优化(用内存池实现),所以小型字符串以及其他会用到 allocator 此项功能的操作都会性能比较好,但其他 STL 实现就没有做这样的优化。
选择正确的算法,往往比优化地实现算法更重要。因为不同时间复杂度的算法可能会给性能带来几个数量级的差异,而实现上的优化则往往付出很大、所得甚少。如果有时候精度不是那么重要,或者不需要找最佳的结果只需要找近似最佳的结果,那么往往可以用低时间复杂度的近似算法来代替。
另外,查表法也是个常用的技巧。假设,用某个公式可以把彩色图像转换成灰度图象,那么如果转换处理量很大的话,对每个象素都用该公式计算一边就不划算了,完全可以事先对所有颜色都计算好,然后处理时查表即可。对三角函数也是如此。当然,为了减小表的尺寸,在精度上往往需要牺牲一些。
但也不要以为因为是预先计算的不需要考虑计算代价,或者内存比较大虚拟内存更大,就可以把表做得很大。记住操作系统或者操作系统进行内存换页或者 Cache 换页都是要时间的,两个临界点分别是 Cache 的尺寸和物理内存的尺寸。具体是全部计算,还是全部查表,还是部分计算部分查表,表要做得多大,这些都需要尝试并用实际数据来支持。一个比较复杂的做法是动态地把计算出来的值缓存到稀疏表中并供以后使用时查询,表的物理尺寸根据当时机器的 Cache 、内存状况动态配置。
如果使用 Java 或者 .NET 上的编程语言的话,因为垃圾会占用空间,垃圾收集器的执行会占用时间,所以除了优化算法及其实现,还要注意你的代码对垃圾收集器是否友好。比如有没有及时把不用的引用置成 null ,有没有不必要的 finalizer 等等。
要避免很大的循环体,因为它们往往会超出 Cache 的尺寸。尽可能避免复杂的 if-else 或者 switch-case 语句,因为现代 CPU 的乱序执行功能看见这些语句会觉得很无奈。即便你非要用这些语句,最好养成习惯,把最可能的分支放在最前面。还有,如果可能的话,不要在循环体中使用这些条件分支语句。
有一些经典著作,如 The Practice of Programming (《程序设计实践》)、 Programming Pearls (《编程珠玑》)、 CodeComplete 2e (国内目前只出版了第 1 版,叫《代码大全》)也都提到了很多优化技术,但是,很重要的一点是,这些书都很少提到或者没有展开讲 “ 构架设计时注意不要留下性能瓶颈或者缺陷 ” 这个问题。这已超出了优化的范畴,而是要求在设计起始阶段时就考虑到性能需求。事实上,在硬件性能极大提高、优化编译器大行其道的今天,我们写程序时已基本上很少需要去考虑局部的微观实现是否优化了,因为有 95% 的可能编译器会替你去操心,或者根本性能不优化也可满足需求。甚至如果程序的内部结构比较清晰的化,算法也是可以很容易地替换的(比如用 Strategy 模式,或者 Policy-Based Design 的方式)。但也有的东西不太好在程序写完后再改,但又可能对性能有极大影响:那就是总体的设计和构架,以及一些影响面很广的设计决策 / 取舍。在今天,这些比较宏观的内容远比微观的优化技巧要重要。
读者可能要问了: “ 不是说 ‘ 不必要的优化是一切罪恶的源泉 ’ 、 ‘ 没有数据证明就不要做优化 ’ 吗?在设计起始阶段根本还没有代码可以执行,怎么获得数据?你怎么保证这不会是不必要的优化呢? ” 噢,这个问题很好回答:当设计还没形成,代码还没写时,这不叫优化,仅仅是设计。优化是一种改变,把现有的缓慢的东西变成快速的东西。而设计时 “ 本来无一物,何来谈优化 ” 呢。
更何况,一些比较宏观的构架上的决策,日后重构起来会非常困难,所以一开始就应该要考虑到。如果一开始需求尚未明确并且你也预计不到日后会有这样的性能需求,那么没有考虑到也不能怪你。但若一开始客户就提出了明确的性能要求,或者你心里很清楚客户一定会需要这样的性能,而你设计时却依然选择了无法或者难以满足这样性能要求的构架,那么这就不太好了。此外,如果两种设计 / 构架,并没有明确的实现复杂性或者优雅程度的差别,而其中一种设计 / 构架明显性能扩展性更好,那么也应该选择后一种。这不叫 “premature optimization” ,而叫做 “ 避免 premature pessimization” (见 C++ Coding Standards 一书的 Item 9 )。
另外,还有一些很常见的和性能相关的话题。而且不少人对它们的认识还有一些误区,比如资源(特别是内存)的获取和释放、线程间的同步(也可看作特殊资源 —— 各种线程锁的获取和释放)、字符串(或者其他缓冲区)的处理,以及这些操作的组合。这些话题很值得进一步讨论,在今后的文章中,会再和读者进行更深层次的交流。