日期:2010-07-16  浏览次数:20435 次

  在这个硬件性能日新月异的年代里,我们充分的享受着硬件带给我们的“免费午餐”,似忽软件的效率问题已经不再是一种被开发者关心的问题,但是,随着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实现就没有做这样的优化。

  选择正确的算法,往往比优化地实现算法更重要。因为不同时间复杂度的算法可能会给性能带来几个数量级的差异,而实现上的优化则往往付出很大、所得甚少。如果有时候精度不是那么重要,或者不需要找最佳的