软件构建(十一)——代码性能的调整

本文首先概述了程序运行性能应该考虑的一些问题,然后从策略上和技术上两个方面来探讨代码性能的调整问题。技术上的代码调整并没有什么万金油的方法,也不是灵丹妙药,唯一可以信赖的法则就是每次都应当在具体的环境下评估代码调整所带来的效果,而本文所列的调整方法则仅供参考。此外,追求性能的背后往往伴随着牺牲程序的可读性和可维护性,而在很多情况下,代码的可读性和可维护性都要比运行速度或资源占用更为重要。因此在调整代码时一定要考虑这样的性能提升是否真的必要。

1. 性能概述

现实中,相对于代码质量和纯粹的性能,用户更关心的是程序的外在特性和处理能力。性能同代码速度之间存在着很松散的联系。如果只是关注于代码的运行速度,你的工作不免顾此失彼。要特别当心放弃其他功能去让你的代码跑得更快。如果过分强调速度,程序的整体性能(表现)常常不升反降。

如果要追求程序的效率,可以从以下几个方面来思考问题:

  • 性能需求:客户要求的系统响应时间有时决定了设计方案的复杂度和成本,在花费时间处理一个性能问题之前,要想清楚是否真的需要满足这样的需求
  • 程序架构:优先考虑整体性能,然后再为每个子系统和类设置要达到的性能目标
  • 类和子程序设计:在这一层次,数据结构和算法将对性能产生重要影响
  • 与操作系统的交互:考虑系统I/O,系统调用等性能
  • 代码编译:这一层次由编译器和转化后的机器码决定,通常程序员无法干预
  • 硬件:有时直接升级硬件是最直接了当的提升性能的办法

2. 策略上的代码调整

2.1 佩雷托法则

帕雷托法则即是众所周知的80/20法则,其对程序性能优化也是有效的,即程序中20%的子程序耗费了80%的执行时间。因此程序员们应当衡量代码的各个部分(如使用性能分析器),找出最需要关注的地方,然后集中火力来对付占用了绝大部分资源的少量代码。

2.2 代码调整的一些误区

  • 在高级语言中,减少代码的行数就可以提升所生成的机器代码的运行速度,或是减少其资源占用——错误!:实际上高级语言代码行数和程序最终的资源占用和运行速度之间并没有必然联系
  • 特定运算可能比其他的快,代码规模也较小——错误!:程序性能受语言、编译器种类、编译器版本、库种类、库版本、中央处理器、机器内存等等环境的影响
  • 应当随时随地进行优化——错误!:应该在整个系统完成之后综合分析,才能最准确快速地找到瓶颈
  • 程序的运行速度同其正确性同等重要——错误!:程序要先保证正确运行,再考虑运行速度
  • 编译器的优化功能可能比你想象的要强大得多:在编写代码时自作聪明,编译器在优化这些代码的时候会痛苦不堪,结果是你的程序倒霉,应该使用直白的代码

2.3 常见的性能瓶颈

  • 输入/输出操作:包括文件、数据库、网络等等的读写
  • 操作系统内存分页时的缺页中断
  • 系统调用:考虑编写自己的服务程序来替代,或者减少不必要的系统调用等等
  • 解释型语言
  • 代码中的错误:如没有去掉调试代码,忘记释放内存,数据库表设计失误,轮询并不存在的设备,超时等等

2.4 性能测量

在优化代码的过程中,一定要使用工具来进行性能测量,特别是对代码进行改进之后要实际测量一下看有没有改进。一个的典型的案例是C++对矩阵元素求和,通常的写法是二重循环遍历行列求和,但有的人可能会觉得数组访问和循环条件判断会花掉很多时间,对于一个100×100的矩阵会产生一万次乘法和加法,于是考虑改成一重循环用指针访问数组(但这样会牺牲代码可读性)。但实际测量结果时修改后性能没有任何变化,原因是编译器早已将数组访问改为用指针实现。这个案例告诉我们,性能问题的很多方面都是违反直觉的,而且经验对性能优化也没有太大的帮助。

找出性能瓶颈之后,应该结合多种优化方法反复对代码进行迭代和调整,尽管每种方法单独实施起来收效并不大,但把所有方法结合起来将可以优化出非常好的结果。

3. 技术上的代码调整

再次重申,在很多情况下,代码的可读性和可维护性都要比运行速度或资源占用更为重要。因此在调整代码时一定要考虑这样的性能提升是否真的必要。而且调整完之后一定要用工具测量一下性能的提升到底有没有起效。

  • 逻辑判断的调整
    • 在知道答案后停止判断:利用好“短路求值”的语言特性中断and的条件判断,或者在循环遍历查找时找到后break退出
    • 按照出现频率来调整if-elseif的顺序,让运行最快和判断结果最有可能为真的判断首先被执行
    • 用查询表替代复杂表达式
    • 使用惰性求值:仅到必须使用的时候才去处理数据,例如有一张大数据表,程序仅仅用到很小一部分,与其在程序启动时生成表的所有内容,不如放到需要的时候再计算
  • 循环的调整
    • 将循环中的判断往外提,避免每次在循环中做重复的判断
    • 当两个循环的下标变化相同时,可以考虑合并成一个循环,但要确保代码先后顺序一致
    • 将循环展开(减少了循环条件判断):会严重影响可读性,而且只对少量元素的循环适用
    • 尽可能减少在循环内部做的工作:例如将重复计算的常量移到循环外
    • 在线性查找循环中,使用“哨兵值”来替代多次条件判断
    • 把最忙的(即循环次数多的)循环放在最内层
  • 数据变换的调整
    • 使用整数而不是浮点数
    • 尽可能减少数组维度:例如考虑用一维数组来表示二维的矩阵
    • 尽可能减少数组引用
    • 使用辅助索引:添加相关数据,使得对某种数据类型的访问更为高效。例如,如果数据类型中的条目很大或是存于磁盘上难于移动,可以创建一个存放关键码和指向详细信息的指针的辅助索引,在内存中对索引进行排序或查找,最后进行一次代价高昂的访问即可
    • 使用缓存机制:缓存常用的或者需要耗费大量时间计算的值。创建新元素的代价越大,请求相同信息的次数越多,那么缓存就越有价值,风险是增加程序的复杂性
  • 表达式的调整
    • 利用代数恒等式:例如not (a or b)not a and not b省一次not操作,判断x < y要比判断sqrt(x) < sqrt(y)省上几十甚至上百倍的时间
    • 削弱运算强度:如用加法替代乘法,乘法代替乘幂,移位替代乘除,三角恒等式代换等价的三角函数,单精度数代替双精度数等等
    • 编译期初始化:把代价高昂的常量计算提前定义成常量
    • 如果系统函数提供的功能过于复杂,考虑自己写一个:例如计算以2为底且是整数的对数函数,与其使用官方的浮点log函数计算,不如自己写一个穷举整数范围的Log2函数
    • 删除重复使用的公共子表达式
  • 子程序的调整
    • 将子程序重写为内联(C++)
    • 用低级语言重写核心代码:应先用高级语言编写整个应用程序,经过完整测试验证正确性后,如果需要改进性能,再考虑用少量的低级语言去重写核心部分。例如某些语言可能不擅长处理位操作,此时将这部分直接翻译成汇编是不错的选择

4. 总结

研究表明,任何特定优化的效果实际上都不可预测:每一步代码调整所产生的影响都受制于编程语言、编译器、编译器的版本、代码
库、库版本以及编译器设置等各种因素。此外,代码调整无可避免地为性能改善的良好愿望而付出复杂性、可读性、简单性、可维护性方面的代价。由于每一次调整后需要对性能进行重新评估,代码调整还引入了巨额的管理维护开销。

进行代码调整时,应该恪守“对每一次的改进进行量化”的准则。如果某项优化非常重要,值得为它付出剖析和对优化效果进行量化测量的代价,那么只要优化有效,我们还是可以去做的。

参考文献:电子工业出版社《代码大全(第2版)》第25、26章

当前网速较慢或者你使用的浏览器不支持博客特定功能,请尝试刷新或换用Chrome、Firefox等现代浏览器