性能优化有三个层次:
系统层次
算法层次
代码层次
系统层次关注系统的控制流程和数据流程,优化主要考虑如何减少消息传递的个数;如何使系统的负载更加均衡;如何充分利用硬件的性能和设施;如何减少系统额外开销(比如上下文切换等)。
算法层次关注算法的选择(用更高效的算法替换现有算法,而不改变其接口);现有算法的优化(时间和空间的优化);并发和锁的优化(增加任务的并行性,减小锁的开销);数据结构的设计(比如lock-free的数据结构和算法)。
代码层次关注代码优化,主要是cache相关的优化(I-cache,D-cache相关的优化);代码执行顺序的调整;编译优化选项;语言相关的优化技巧等等。
性能优化需要相关的工具支持,这些工具包括编译器的支持;CPU的支持;以及集成到代码里面的测量工具等等。这些工具主要目的是测量代码的执行时间以及相关的cache miss, cache hit等数据,这些工具可以帮助开发者定位和分析问题。
性能优化和性能设计不同。性能设计贯穿于设计,编码,测试的整个环节,是产品生命周期的第一个阶段;而性能优化,通常是在现有系统和代码基础上所做的改进,属于产品生命周期的后续几个阶段(假设产品有多个生命周期)。性能优化不是重新设计,性能优化是以现有的产品和代码为基础的,而不是推倒重来。性能优化的方法和技巧可以指导性能设计,但两者的方法和技巧不能等同。两者关注的对象不同。性能设计是从正向考虑问题:如何设计出高效,高性能的系统;而性能优化是从反向考虑问题:在出现性能问题时,如何定位和优化性能。性能设计考验的是开发者正向建设的能力,而性能优化考验的是开发者反向修复的能力。两者可以互补。
下面是一个代码优化技巧列表,需要不断地补充,优化和筛选。
1) Code adjacency (把相关代码放在一起),推荐指数:5颗星
把相关代码放在一起有两个涵义,一是相关的源文件要放在一起;二是相关的函数在object文件里面,也应该是相邻的。这样,在可执行文件被加载到内存里面的时候,函数的位置也是相邻的。相邻的函数,冲突的几率比较小。而且相关的函数放在一起,也符合模块化编程的要求:那就是 高内聚,低耦合。
如果能够把一个codepath上的函数编译到一起(需要编译器支持,把相关函数编译到一起), 很显然会提高I-cache的命中率,减少冲突。但是一个系统有很多个code path,所以不可能面面俱到。不同的性能指标,在优化的时候可能是冲突的。所以尽量做对所以case都有效的优化,虽然做到这一点比较难。
2) Cache line alignment (cache对齐),推荐指数:4颗星
数据跨越两个cacheline,就意味着两次load或者两次store。如果数据结构是cacheline对齐的,就有可能减少一次读写。数据结构的首地址cache line对齐,意味着可能有内存浪费(特别是数组这样连续分配的数据结构),所以需要在空间和时间两方面权衡。
3) Branch prediction (分支预测),推荐指数:3颗星(不推荐静态分支预测)
代码在内存里面是顺序排列的。对于分支程序来说,如果分支语句之后的代码有更大的执行几率,那么就可以减少跳转,一般CPU都有指令预取功能,这样可以提高指令预取命中的几率。分支预测用的就是likely/unlikely这样的宏,一般需要编译器的支持,这样做是静态的分支预测。现在也有很多CPU支持在CPU内部保存执行过的分支指令的结果(分支指令的cache),所以静态的分支预测就没有太多的意义。如果分支是有意义的,那么说明任何分支都会执行到,所以在特定情况下,静态分支预测的结果并没有多好,而且likely/unlikely对代码有很大的侵害(影响可读性),所以一般不推荐使用这个方法。
4) Data prefetch (数据预取),推荐指数:4颗星
指令预取是CPU自动完成的,但是数据预取就是一个有技术含量的工作。数据预取的依据是预取的数据马上会用到,这个应该符合空间局部性(spatial locality),但是如何知道预取的数据会被用到,这个要看上下文的关系。一般来说,数据预取在循环里面用的比较多,因为循环是最符合空间局部性的代码。
但是数据预取的代码本身对程序是有侵害的(影响美观和可读性),而且优化效果不一定很明显(命中的概率)。数据预取可以填充流水线,避免访问内存的等待,还是有一定的好处的。
5) Memory coloring (内存着色),推荐指数:不推荐
内存着色属于系统层次的优化,在代码优化阶段去考虑内存着色,有点太晚了。所以这个话题可以放到系统层次优化里面去讨论。
6)Register parameters (寄存器参数),推荐指数:4颗星
寄存器做为速度最快的内存单元,不好好利用实在是浪费。但是,怎么用?一般来说,函数调用的参数少于某个数,比如3,参数是通过寄存器传递的(这个要看ABI的约定)。所以,写函数的时候,不要带那么多参数。C语言里还有一个register关键词,不过通常都没什么用处(没试过,不知道效果,不过可以反汇编看看具体的指令,估计是和编译器相关)。尝试从寄存器里面读取数据,而不是内存。
7) Lazy computation (延迟计算),推荐指数:5颗星
延迟计算的意思是最近用不上的变量,就不要去初始化。通常来说,在函数开始就会初始化很多数据,但是这些数据在函数执行过程中并没有用到(比如一个分支判断,就退出了函数),那么这些动作就是浪费了。
变量初始化是一个好的编程习惯,但是在性能优化的时候,有可能就是一个多余的动作,需要综合考虑函数的各个分支,做出决定。
延迟计算也可以是系统层次的优化,比如COW(copy-on-write)就是在fork子进程的时候,并没有复制父进程所有的页表,而是只复制指令部分。当有写发生的时候,再复制数据部分,这样可以避免不必要的复制,提供进程创建的速度。
8] Early computation (提前计算),推荐指数:5颗星
有些变量,需要计算一次,多次使用的时候。最好是提前计算一下,保存结果,以后再引用,避免每次都重新计算一次。函数多了,有时就会忽略这个函数都做了些什么,写程序的人可以不了解,但是优化的时候不能不了解。能使用常数的地方,尽量使用常数,加减乘除都会消耗CPU的指令,不可不查。
9)Inline or not inline (inline函数),推荐指数:5颗星
Inline or not inline,这是个问题。Inline可以减少函数调用的开销(入栈,出栈的操作),但是inline也有可能造成大量的重复代码,使得代码的体积变大。Inline对debug也有坏处(汇编和语言对不上)。所以用这个的时候要谨慎。小的函数(小于10行),可以尝试用inline;调用次数多的或者很长的函数,尽量不要用inline。
10) Macro or not macro (宏定义或者宏函数),推荐指数:5颗星
Macro和inline带来的好处,坏处是一样的。但我的感觉是,可以用宏定义,不要用宏函数。用宏写函数,会有很多潜在的危险。宏要简单,精炼,最好是不要用。中看不中用。
11) Allocation on stack (局部变量),推荐指数:5颗星
如果每次都要在栈上分配一个1K大小的变量,这个代价是不是太大了哪?如果这个变量还需要初始化(因为值是随机的),那是不是更浪费了。全局变量好的一点是不需要反复的重建,销毁;而局部变量就有这个坏处。所以避免在栈上使用数组等变量。
12) Multiple conditions (多个条件的判断语句),推荐指数:3颗星
多个条件判断时,是一个逐步缩小范围的过程。条件的先后,决定了前面的判断是否多余的。根据code path 的情况和条件分支的几率,调整条件的顺序,可以在一定程度上减少code path的开销。但是这个工作做起来有点难度,所以通常不推荐使用。
13) Per-cpu data structure (非共享的数据结构),推荐指数:5颗星
Per-cpu data structure 在多核,多CPU或者多线程编程里面一个通用的技巧。使用Per-cpu datastructure的目的是避免共享变量的锁,使得每个CPU可以独立访问数据而与其他CPU无关。坏处是会消耗大量的内存,而且并不是所有的变量都可以per-cpu化。并行是多核编程追求的目标,而串行化是多核编程里面最大的伤害。有关并行和串行的话题,在系统层次优化里面还会提到。
局部变量肯定是threadlocal的,所以在多核编程里面,局部变量反而更有好处。
14) 64 bits counter in 32 bits environment (32位环境里的64位counter),推荐指数:5颗星
32位环境里面用64位counter很显然会影响性能,所以除非必要,最好别用。有关counter的优化可以多说几句。counter是必须的,但是还需要慎重的选择,避免重复的计数。关键路径上的counter可以使用per-cpu counter,非关键路径(exception path)就可以省一点内存。
15) Reduce call path or call trace (减少函数调用的层次),推荐指数:4颗星
函数越多,有用的事情做的就越少(函数的入栈,出栈等)。所以要减少函数的调用层次。但是不应该破坏程序的美观和可读性。个人认为好程序的首要标准就是美观和可读性。不好看的程序读起来影响心情。所以需要权衡利弊,不能一个程序就一个函数。
16) Move exception path out (把exception处理放到另一个函数里面),推荐指数:5颗星
把exceptionpath和critical path放到一起(代码混合在一起),就会影响critical path的cache性能。而很多时候,exception path都是长篇大论,有点喧宾夺主的感觉。如果能把criticalpath和exception path完全分离开,这样对i-cache有很大帮助。
17) Read, write split (读写分离),推荐指数:5颗星
在cache.pdf里面提到了伪共享(false sharing),就是说两个无关的变量,一个读,一个写,而这两个变量在一个cache line里面。那么写会导致cache line失效(通常是在多核编程里面,两个变量在不同的core上引用)。读写分离是一个很难运用的技巧,特别是在code很复杂的情况下。需要不断地调试,是个力气活(如果有工具帮助会好一点,比如cache miss时触发cpu的execption处理之类的)。
18) Reduce duplicated code(减少冗余代码),推荐指数:5颗星
代码里面的冗余代码和死代码(deadcode)很多。减少冗余代码就是减小浪费。但冗余代码有时又是必不可少(copy-paste太多,尾大不掉,不好改了),但是对critical path,花一些功夫还是必要的。
19) Use compiler optimization options (使用编译器的优化选项),推荐指数:4颗星
使用编译器选项来优化代码,这个应该从一开始就进行。写编译器的人更懂CPU,所以可以放心地使用。编译器优化有不同的目标,有优化空间的,有优化时间的,看需求使用。
20) Know your code path (了解所有的执行路径,并优化关键路径),推荐指数:5颗星
代码的执行路径和静态代码不同,它是一个动态的执行过程,不同的输入,走过的路径不同。我们应该能区分出主要路径和次要路径,关注和优化主要路径。要了解执行路径的执行流程,有多少个锁,多少个原子操作,有多少同步消息,有多少内存拷贝等等。这是性能优化里面必不可少,也是唯一正确的途径,优化的过程,也是学习,整理知识的过程,虽然有时很无聊,但有时也很有趣。
何时应该优化
如果数据表明,性能确实没有达到指标,特别是当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的乱序执行功能看见这些语句会觉得很无奈。即便你非要用这些语句,最好养成习惯,把最可能的分支放在最前面。还有,如果可能的话,不要在循环体中使用这些条件分支语句。
有一些经典著作,如ThePractice of Programming(《程序设计实践》)、Programming Pearls(《编程珠玑》)、CodeComplete 2e(国内目前只出版了第1版,叫《代码大全》)也都提到了很多优化技术,但是,很重要的一点是,这些书都很少提到或者没有展开讲“构架设计时注意不要留下性能瓶颈或者缺陷”这个问题。这已超出了优化的范畴,而是要求在设计起始阶段时就考虑到性能需求。事实上,在硬件性能极大提高、优化编译器大行其道的今天,我们写程序时已基本上很少需要去考虑局部的微观实现是否优化了,因为有95%的可能编译器会替你去操心,或者根本性能不优化也可满足需求。甚至如果程序的内部结构比较清晰的化,算法也是可以很容易地替换的(比如用Strategy模式,或者Policy-Based Design的方式)。但也有的东西不太好在程序写完后再改,但又可能对性能有极大影响:那就是总体的设计和构架,以及一些影响面很广的设计决策/取舍。在今天,这些比较宏观的内容远比微观的优化技巧要重要。
读者可能要问了:“不是说‘不必要的优化是一切罪恶的源泉’、‘没有数据证明就不要做优化’吗?在设计起始阶段根本还没有代码可以执行,怎么获得数据?你怎么保证这不会是不必要的优化呢?”噢,这个问题很好回答:当设计还没形成,代码还没写时,这不叫优化,仅仅是设计。优化是一种改变,把现有的缓慢的东西变成快速的东西。而设计时“本来无一物,何来谈优化”呢。
更何况,一些比较宏观的构架上的决策,日后重构起来会非常困难,所以一开始就应该要考虑到。如果一开始需求尚未明确并且你也预计不到日后会有这样的性能需求,那么没有考虑到也不能怪你。但若一开始客户就提出了明确的性能要求,或者你心里很清楚客户一定会需要这样的性能,而你设计时却依然选择了无法或者难以满足这样性能要求的构架,那么这就不太好了。此外,如果两种设计/构架,并没有明确的实现复杂性或者优雅程度的差别,而其中一种设计/构架明显性能扩展性更好,那么也应该选择后一种。这不叫“premature optimization”,而叫做“避免premature pessimization”(过早悲观)(见C++ CodingStandards一书的Item 9)。
另外,还有一些很常见的和性能相关的话题。而且不少人对它们的认识还有一些误区,比如资源(特别是内存)的获取和释放、线程间的同步(也可看作特殊资源——各种线程锁的获取和释放)、字符串(或者其他缓冲区)的处理,以及这些操作的组合。这些话题很值得进一步讨论,在今后的文章中,会再和读者进行更深层次的交流。
分支优化
局部性优化
循环展开
作者:柒月