zuixin 2020-05-12
优化是一项编码活动。在传统的软件开发过程中,直到编码完成,项目进入了集成与测试阶段,能够观察到程序整体的性能时,才会进行优化。而在敏捷开发方式中,当一个带有性能指标的特性编码完成后或是需要实现特定的性能目标时,就会分配一个或多个冲刺 (sprint)进行优化。
性能优化的目的是通过改善正确程序的行为使其满足客户对处理速度、吞吐量、内存占用以及能耗等各种指标的需求。因此,性能优化与编码对开发过程而言有着同等的重要性。对于用户而言,性能糟糕得让人无法接受,这个问题的严重程度不亚于出现 bug 和未实现的特性。
bug 修复与性能优化之间的一个重要区别是,性能是一个连续变量。特性要么是实现了,要么是没有实现;bug 要么存在,要么不存在。但是性能可以是非常糟糕或者非常优秀,还可能是介于这二者之间的某种程度。优化还是一个迭代的过程。每当程序中最慢的部分被改善后,一个新的最慢的部分就会出现。
与其他编码任务相比,优化更像是一门实验科学,需要有更深入的科学思维方式。要想优化成功,需要先观察程序行为,然后基于这些程序行为作出可测试的推测,再进行实验得出测试结果。这些测试结果要么验证推测,要么推翻推测。经验丰富的开发人员常常相信他们对于最优代码具有足够的经验和直觉。但是除非他们频繁地测试自己的直觉,否则通常他们都是错误的。在我个人为本书编写测试代码的经历中,就多次出现测试结果与我的直觉相悖的情况。实验,而非直觉,才是贯穿本书的主题。
开发人员很难理解单个编码决策对大型程序的整体性能的影响。因此,实际上所有完整的程序都包含大量的优化机会。即使是经验丰富的团队在时间充裕的情况下编写出的代码,运行速度通常也可以提高 30% 至 100%。我见过对在时间很紧张的情况下或是欠缺经验的团队编写出的代码进行优化后,程序运行速度提高了 3 至 10 倍的情况。不过,通过微调代码让程序的运行速度提升 10 多倍几乎是不可能的。但是选择一种更好的算法或是数据结构,则可以将程序的某个特性的性能从慢到无法忍受提升至可发布的状态。
许多关于性能优化的讨论都会首先严正地警告大家:“不要!不要优化!如果确实需要,那么请在项目结束时再优化,而且不要做任何非必需的优化。”例如,著名的计算机科学家高德纳曾经这样说过:
我们应当忘记小的性能改善,百分之九十七的情况下,过早优化都是万恶之源。
——高德纳,Structured Programming with go to Statements ,ACM Computing
“不要进行优化”这条建议已经成为了一项编程常识,甚至许多经验丰富的程序员都认为这是毋庸置疑的。他们对性能调优避而不谈。我认为过度推崇这条建议经常被用作两种行为的借口:编程恶习,以及逃避做少量分析以让代码运行得更快。同时我还认为,盲目地接受这条建议会导致浪费大量 CPU 周期、用户满意度下降,会浪费大量时间重写那些本应从一开始就更加高效的代码。
我的建议是不要过于教条。优化是没有问题的。学习高效的编程惯用法并在程序中实践之是没有问题的,即使你不知道哪部分代码的性能很重要。这些惯用法对 C++ 程序很有帮助。使用这些技巧也不会让你被同事鄙夷。如果有人问你为什么不写一些“简单”和低效的代码,你可以这么回答他:“编写高效代码与编写低效无用的代码所需的时间是一样的,为什么还会有人特意去编写低效的代码呢?”
但是,如果你不清楚重要性,因为不确定哪个算法更好而导致许多天过去了项目仍毫无进展,这是不行的。你因为猜测某段代码有严格的执行时间的要求,就花费数周时间去编写汇编代码,然后将代码作为函数被调用(而实际上 C++ 编译器可能已经将函数内联展开了),这是不行的。当你实际上并不知道 C 是否真的更快以及 C++ 是否真的不快时,仅仅因为“大家都知道 C 更快”,就要求你的团队在 C++ 程序中使用 C 语言编写部分代码,这是不行的。换言之,所有软件开发的最佳实践依然都是适用的。优化并不能成为打破这些规则的理由。
不知道性能问题出在哪里就花费很多时间进行优化,这是不行的。在第 3 章中我将介绍“90/10 规则”。这条规则指出程序中只有 10% 的代码的性能是很重要的。因此,试图修改程序中的每条语句去改善程序性能没有必要,也不会有作用。既然只有 10% 的代码会对程序的性能产生显著的影响,那么试图随机找出一个性能改善切入点的概率就会很低。第 3 章会讲解如何使用一些工具帮助大家定位代码中的“热点”。
当我还在读大学时,我的教授曾经警告我们,最优算法的启动性能开销可能比简单算法更大,因此,应当只在大型数据集上使用它们。可能对于某些冷僻的算法来说确实如此,但根据我的经验,对于简单的查找和排序任务而言,最优算法的准备时间很少,即使在小型数据集上使用它们也能改善性能。
我也曾经被建议在开发程序时随便使用一种最容易实现的算法,之后在发现程序运行得太慢时,再回过头来优化它。不可否认,这对于推进项目持续进展是一条好的建议,但是一旦你已经编写过几次最优查找或排序的算法了,那么与编写低效的算法相比,编写最优算法并不会更难。而且你也可以在初次编写算法时就正确地实现并调试一种算法,这样以后就可以复用它了。
实际上,常识可能是性能改善最大的敌人。例如,“每个人都知道”最优排序算法的时间复杂度是 O (n log n ),其中 n 是数据集的大小(参见 5.1 节中关于大 O 符号和时间开销的简单回顾)。这条常识非常有价值,甚至让开发人员不相信他们的 O (n 2 ) 插入排序(insertion sort)算法是最优的,但如果它阻止了开发人员查阅文献得出以下发现就不好了:基数排序(radix sort)算法的时间复杂度是 O (n log r n )(其中 r 是基数或用于排序的桶的数量),处理速度更快;对于随机分布的数据,flashsort 算法的时间复杂度是 O (n ),处理速度更快;还有快速排序算法,根据常识人们将它作为测量其他排序算法性能的测试基准,但在最坏的情况下,它的时间复杂度是 O (n 2 ) 。
亚里士多德曾经误认为“女人的牙齿比男人少”,这个公认的观点让人们信奉了 1500 年,直到有人非常好奇,数了几张嘴中的牙齿数。常识的“解毒剂”是实验形式的科学方法。在第 3 章中我们将利用工具测量软件性能,并通过实验验证优化效果。
在软件开发的世界中,还有一条常识:优化是不重要的。这条常识的理由是,尽管现在代码运行得很慢,但是每年都会有更快的处理器被研发出来,随着时间的推移,它们会免费帮你解决性能问题。就像大多数常识一样,这种想法完全错误。在 20 世纪 80 年代和 90 年代,这种想法似乎看起来是正确的。那时,桌面电脑和独立的应用程序占领了软件开发领域,而且单核处理器的处理速度每 18 个月就翻一倍。虽然总的看来,如今多核处理器的性能不断强大,但是单个核心的性能增长却非常缓慢,甚至有时还有所下降。如今的程序还必须运行于移动平台上,电池的电量和散热都制约了指令的执行速率。而且,尽管随着时间的推移,会给新客户带来更快的计算机,但是也无法改善现有硬件的性能。现有客户的工作负载随着时间的推移在不断地增加。你的公司为现有客户提高处理速度的唯一办法是发布性能优化后的新版本。优化可以让程序永远保持活力。