1.1 优化是软件开发的一部分
优化是一项编码活动。在传统的软件开发过程中,直到编码完成,项目进入了集成与测试 阶段,能够观察到程序整体的性能时,才会进行优化。而在敏捷开发方式中,当一个带有 性能指标的特性编码完成后或是需要实现特定的性能目标时,就会分配一个或多个冲刺 (sprint)进行优化。 性能优化的目的是通过改善正确程序的行为使其满足客户对处理速度、吞吐量、内存占用 以及能耗等各种指标的需求。因此,性能优化与编码对开发过程而言有着同等的重要性。 对于用户而言,性能糟糕得让人无法接受,这个问题的严重程度不亚于出现 bug 和未实现 的特性。 bug 修复与性能优化之间的一个重要区别是,性能是一个连续变量。特性要么是实现了, 要么是没有实现;bug 要么存在,要么不存在。但是性能可以是非常糟糕或者非常优秀, 还可能是介于这二者之间的某种程度。优化还是一个迭代的过程。每当程序中最慢的部分 被改善后,一个新的最慢的部分就会出现。 与其他编码任务相比,优化更像是一门实验科学,需要有更深入的科学思维方式。要想优 化成功,需要先观察程序行为,然后基于这些程序行为作出可测试的推测,再进行实验得 出测试结果。这些测试结果要么验证推测,要么推翻推测。经验丰富的开发人员常常相信 他们对于最优代码具有足够的经验和直觉。但是除非他们频繁地测试自己的直觉,否则通 优化概述 | 3 常他们都是错误的。在我个人为本书编写测试代码的经历中,就多次出现测试结果与我的 直觉相悖的情况。实验,而非直觉,才是贯穿本书的主题。
1.2 优化是高效的
开发人员很难理解单个编码决策对大型程序的整体性能的影响。因此,实际上所有完整的 程序都包含大量的优化机会。即使是经验丰富的团队在时间充裕的情况下编写出的代码, 运行速度通常也可以提高 30% 至 100%。我见过对在时间很紧张的情况下或是欠缺经验的 团队编写出的代码进行优化后,程序运行速度提高了 3 至 10 倍的情况。不过,通过微调 代码让程序的运行速度提升 10 多倍几乎是不可能的。但是选择一种更好的算法或是数据 结构,则可以将程序的某个特性的性能从慢到无法忍受提升至可发布的状态。
1.3 优化是没有问题的
许多关于性能优化的讨论都会首先严正地警告大家:“不要!不要优化!如果确实需要, 那么请在项目结束时再优化,而且不要做任何非必需的优化。”
例如,著名的计算机科学 家高德纳曾经这样说过:
我们应当忘记小的性能改善,百分之九十七的情况下,过早优化都是万恶之源。 ——高德纳 2 ,Structured Programming with go to Statements,ACM Computing Surveys3 6(4), December 1974, p268. CiteSeerX4 : 10.1.1.103.6084 (http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.103.6084)
威廉 • 沃尔夫 5 曾说过:
以(没有必要达成的)效率之名犯下的计算之罪比其他任何一个原因(包括盲目 愚蠢)都多。 ——A Case Against the GOTO,第 25 届美国 ACM 会议论文集(1972): 796
“不要进行优化”
这条建议已经成为了一项编程常识,甚至许多经验丰富的程序员都认为 这是毋庸置疑的。他们对性能调优避而不谈。我认为过度推崇这条建议经常被用作两种行 为的借口:编程恶习,以及逃避做少量分析以让代码运行得更快。同时我还认为,盲目地 接受这条建议会导致浪费大量 CPU 周期、用户满意度下降,会浪费大量时间重写那些本 应从一开始就更加高效的代码。
我的建议是不要过于教条。优化是没有问题的。学习高效的编程惯用法并在程序中实践之 是没有问题的,即使你不知道哪部分代码的性能很重要。这些惯用法对 C++ 程序很有帮 助。使用这些技巧也不会让你被同事鄙夷。如果有人问你为什么不写一些“简单”和低效 的代码,你可以这么回答他:“编写高效代码与编写低效无用的代码所需的时间是一样的, 为什么还会有人特意去编写低效的代码呢?”
但是,如果你不清楚重要性,因为不确定哪个算法更好而导致许多天过去了项目仍毫无进 展,这是不行的。你因为猜测某段代码有严格的执行时间的要求,就花费数周时间去编写 汇编代码,然后将代码作为函数被调用(而实际上 C++
编译器可能已经将函数内联展开 了),这是不行的。当你实际上并不知道 C 是否真的更快以及 C++ 是否真的不快时,仅仅 因为“大家都知道 C 更快”,就要求你的团队在 C++ 程序中使用 C 语言编写部分代码,这 是不行的。换言之,所有软件开发的最佳实践依然都是适用的。优化并不能成为打破这些 规则的理由。
不知道性能问题出在哪里就花费很多时间进行优化,这是不行的。在第 3 章中我将介绍 “90/10 规则”。这条规则指出程序中只有 10% 的代码的性能是很重要的。因此,试图修改 程序中的每条语句去改善程序性能没有必要,也不会有作用。既然只有 10% 的代码会对程 序的性能产生显著的影响,那么试图随机找出一个性能改善切入点的概率就会很低。第 3 章会讲解如何使用一些工具帮助大家定位代码中的“热点”。
当我还在读大学时,我的教授曾经警告我们,最优算法的启动性能开销可能比简单算法更大,因此,应当只在大型数据集上使用它们。可能对于某些冷僻的算法来说确实如此,但 根据我的经验,对于简单的查找和排序任务而言,最优算法的准备时间很少,即使在小型 数据集上使用它们也能改善性能。
我也曾经被建议在开发程序时随便使用一种最容易实现的算法,之后在发现程序运行得太慢时,再回过头来优化它。不可否认,这对于推进项目持续进展是一条好的建议,但是一 旦你已经编写过几次最优查找或排序的算法了,那么与编写低效的算法相比,编写最优算法并不会更难。而且你也可以在初次编写算法时就正确地实现并调试一种算法,这样以后就可以复用它了。
实际上,常识可能是性能改善最大的敌人。例如,“每个人都知道”最优排序算法的时间 复杂度是 O(n log n),其中 n 是数据集的大小(参见 5.1 节中关于大 O 符号和时间开销的简 单回顾)。这条常识非常有价值,甚至让开发人员不相信他们的 O(n2 ) 插入排序(insertion sort)算法是最优的,但如果它阻止了开发人员查阅文献得出以下发现就不好了:基数排 序(radix sort)6 算法的时间复杂度是 O(n logrn) (其中 r 是基数或用于排序的桶的数量),处理速度更快;对于随机分布的数据,flashsort 算法的时间复杂度是 O(n),处理速度更快; 还有快速排序算法,根据常识人们将它作为测量其他排序算法性能的测试基准,但在最坏 的情况下,它的时间复杂度是 O(n2 ) 7 。亚里士多德曾经误认为“女人的牙齿比男人少”(出 自《动物志》第二卷第一部分,http://classics.mit.edu/Aristotle/history_anim.2.ii.html),这个 公认的观点让人们信奉了 1500 年,直到有人非常好奇,数了几张嘴中的牙齿数。常识的“解毒剂”是实验形式的科学方法。
在第 3 章中我们将利用工具测量软件性能,并通过实 验验证优化效果。
在软件开发的世界中,还有一条常识:优化是不重要的。这条常识的理由是,尽管现在代 码运行得很慢,但是每年都会有更快的处理器被研发出来,随着时间的推移,它们会免费 帮你解决性能问题。就像大多数常识一样,这种想法完全错误。在 20 世纪 80 年代和 90 年代,这种想法似乎看起来是正确的。那时,桌面电脑和独立的应用程序占领了软件开发 领域,而且单核处理器的处理速度每 18 个月就翻一倍。虽然总的看来,如今多核处理器 的性能不断强大,但是单个核心的性能增长却非常缓慢,甚至有时还有所下降。如今的程 序还必须运行于移动平台上,电池的电量和散热都制约了指令的执行速率。而且,尽管随 着时间的推移,会给新客户带来更快的计算机,但是也无法改善现有硬件的性能。现有客户的工作负载随着时间的推移在不断地增加。你的公司为现有客户提高处理速度的唯一办 法是发布性能优化后的新版本。优化可以让程序永远保持活力。
1.4 这儿一纳秒,那儿一纳秒
这儿 10 亿,那儿 10 亿,很快就该说到真的钱了。 ——常被误认为出自参议员 Everett Dirkson(1898—1969),但他宣称自己从 未说过这句话,虽然他承认说过许多类似的话
桌面电脑的处理速度快得惊人,它们能够每纳秒(或者以更快的速度)分发一条指令,这可是 每 10-9 秒啊!这很容易让人误以为当计算机的性能如此强劲时,性能优化可能就无所谓了。
这种思考方式的问题在于,处理器的处理速度越快,被浪费的指令的累积速度也会越快。 如果一个程序所执行的指令中 50% 都是不必要的,那么不管这些不必要的指令的执行速度 有多快,只要删除这些指令,程序的处理速度就会变为原来的两倍。
那些说“性能无所谓”的同事也可能是想说性能对于某些特殊的应用程序——例如受人体 反应约束或运行于处理器速度极快的桌面计算机上的应用程序——无所谓。但对于那些运 行于内存、电源或者处理速度受限的小型嵌入式设备和移动处理器上的应用程序来说,性能的影响非常大;对于那些运行于大型计算机上的服务器程序的影响也非常大。总而言 之,性能对那些必须争夺极其有限的计算资源(内存、电源、CPU 周期)的应用程序的影响非常大。同样,只要工作负载很大以至于需要进行分布式处理时,性能的影响就会非常 大。在这种情况下,性能的差距会决定到底是需要 100 台服务器或云实例,还是需要 500 台或 1000 台。
尽管计算机的性能在近 50 年提高了 6 个数量级,但是这里我仍然要讨论优化。以史为鉴, 在未来优化仍然非常重要。
1.5 C++代码优化策略总结
围捕惯犯。
——路易斯 • 雷诺上尉(克劳德 • 雷恩斯饰演),电影《卡萨布兰卡》,1942 年 6 月
C++ 的混合特性为我们提供了多种实现方式,一方面可以实现性能管理的全自动化,另一 方面也可以对性能进行更加精准的控制。正是这些选择方式,使得优化 C++ 程序以满足性 能需求成为可能。
C++ 有一些热点代码是性能“惯犯”,其中包括函数调用、内存分配和循环。下面是一份 改善 C++ 程序性能的方法的总结,也是本书的大纲。这些优化建议简单得让人震惊,而且 所有这些建议都是曾经发表过的。当然,恶魔隐藏在细节中。本书的示例和启发将会帮助 读者在优化机会出现时更好地把握住它们。
1.5.1 用好的编译器并用好编译器
C++ 编译器是非常复杂的软件构件。每种编译器为 C++ 语句生成的机器码都有差别。它们 所看到的优化机会是不同的,会为相同的源代码产生不同的可执行文件。如果打算为代码 做出最后一丁点性能提升,那么你可以尝试一下各种不同的编译器,看看是否有一种编译 器会为你产生更快的可执行文件。
关于如何选择 C++ 编译器的一条最重要的建议,是使用支持 C++11 的编译器。C++11 实 现了右值引用(rvalue reference)和移动语义(move semantics),可以省去许多在以前的 C++ 版本中无法避免的复制操作(我会在 6.6 节讨论移动语义)。
有时,用好的编译器也意味着用好编译器。例如,如果应用程序非常缓慢,那么你应当检 查是否打开了编译器的优化选项。这条建议看似非常明显,但是我已经记不清有多少次我 向其他人提出这个建议后,他们都承认在编译时确实忘记打开优化选项了。多数情况下, 只要正确地打开了优化选项,你都不用做额外的优化,因为编译器就可以让程序的运行速 度提高数倍。
默认情况下,许多编译器都不会进行任何优化,因为如果不进行优化,编译器就可以稍微 缩短一点编译时间。这一点在 20 世纪 90 年代曾经非常重要,但是如今的编译器和计算机 已经足够快了,以至于因优化而产生的额外的编译时间开销微不足道。当关闭优化选项 时,调试也会变得更加简单,因为程序的执行流程与源代码完全一致。优化选项可能会将 代码移出循环、移除一些函数调用和完全移除一些变量。当编译选项打开后,有些编译器 将不会生成任何调试符号。虽然部分编译器可能仍然会生成调试符号,但是开发人员想通 过在调试器中观察执行流程来理解程序正在做什么就会变得非常困难。许多编译器在调试 构建时允许打开和关闭个别不会过多干扰调试的优化选项。仅仅是打开函数内联优化选项 就可以显著地提升 C++ 程序的性能,因为编写许多小的成员函数去访问各个类的成员变量 是一种优秀的 C++ 编码风格。
C++ 编译器的文档对可用优化选项和预处理指令做了全面说明。这份文档如同你在购买新 车时获取的操作手册一样——你完全可以不看操作手册就直接坐上并驾驶你的新车,但是 其中可能包含了大量的能够帮助你更高效地使用这个庞大、复杂的工具的信息。
如果你正在 Windows 或者 Linux 上开发 x86 体系结构的应用程序,那么你很幸运,因为有 一些非常棒的编译器适合你,而且这些编译器的开发和维护也处于非常活跃的状态。在本 书问世的前五年,微软将 Visual C++ 升级了三个版本。每年 GCC 也会发布不止一个版本。
在 2016 年早期,大家一致认为无论是在 Linux 上还是在 Windows 上,英特尔的 C++ 编译 器编译出来的代码的速度都最快;虽然 GNU 的 C++ 编译器 GCC 编译出的代码的速度稍 慢,但是非常符合标准;而微软的 Visual C++ 则折中。虽然我乐于绘制一张图表来说明 英特尔 C++ 编译器产生的代码比 GCC 快很多,以此来帮助你选择编译器,但这取决于你 的代码以及哪家厂商或是组织又发布了一个提升效率的升级版本。虽然购买英特尔 C++ 编译器需要花费 1000 多美元,但它有 30 天的免费试用期。Visual C++ Express 是免费的。 Linux 上的 GCC 是永久免费的。因此,对你的代码进行一项实验,试试各种编译器可以带 来多大的性能提升,其成本并不高。
1.5.2 使用更好的算法
选择一个最优算法对性能优化的效果最大。各种优化手段都能改善程序的性能。它们可以 压缩以前看似低效的代码的执行时间,就像通过升级 PC 能让程序运行得更快一样。但不 幸的是,如同升级 PC 一样,大部分优化手段只能使程序性能呈线性提升。许多优化手段 可以将程序性能提升 30% 至 100%。如果足够幸运,也许你可以将性能提升至三倍。但是 除非你能找到一种更加高效的算法,否则要想实现性能的指数级增长通常是不太可能的。
优化战争故事 |
---|
让我们回到 8 英寸软盘和 1MHz 处理器的时代。某位开发人员设计了一个程序来管理 电台。这个程序的功能之一是每天生成一份日志,其中记录了排好序的当天播放过的 歌曲。问题是这个程序需要大约 27 小时才能将一天的数据排序完毕,很明显,这让人 无法接受。为了让这个重要的排序处理能够运行得更快,这位开发人员诉诸于“英雄 壮举”。他使用逆向工程破解了计算机,通过文档上没有写明的方式侵入了微代码,之 后编写微代码实现了在内存中进行排序,将程序运行时间减少到了 17 小时。但 17 个 小时仍然太长了。最后他绝望了,打电话给计算机制造商(也就是我曾经工作过的公 司)求助。 我问这位开发人员他使用的是什么排序算法,他说是归并排序(Merge Sort)。归并排 序位于最优比较排序算法之列。那么他对多少条记录排序呢?他回答说“几千条”。这 说不通,他所使用的系统应该可以在一个小时之内完成对数据排序。 我让这位开发人员描述了算法的具体细节。我已经记不清接下来他说的那些折磨人的话了,不过我发现了他实现的是插入排序。插入排序是一个非常糟糕的选择,排序所 需时间与记录数的平方成正比。他知道有种叫作“归并排序”的最 优算法。但他却用 Merge 和 Sort 两个单词描述出插入算法。 我使用真正的归并排序为这位顾客编写了一个非常普通的排序例程,这个例程只需要 45 分钟就可以完成他的数据排序。 |
“英勇”地去优化一个糟糕的算法很愚蠢。对代码优化而言,学习和使用查找和排序的最优算法才是康庄大道。一个低效的查找或排序算法的例程可以完全占用一个程序的运行时 间。修改代码可以将程序运行时间减少一半。但是替换一种更优的算法后,数据集越大, 可以缩短的运行时间就越多。即使在一个只有一打数据的小数据集上,如果频繁查找数 据,最优的查找或排序算法也可以帮你节省很多时间。最优算法适用于大大小小的各种程序,从精简的闭式计算到短小的关键字查找函数,再 到复杂的数据结构和大规模的程序。市面上有许多优秀的图书讨论了这个主题。开发人员 在职业生涯中都应该不断学习这些知识。
1.5.3 使用更好的库
C++ 编译器提供的标准 C++ 模板库和运行时库必须是可维护的、全面的和非常健壮的。令 开发人员吃惊的是,我们无需对这些库进行调优。可能更令人吃惊的是,虽然 C++ 已经发 明出来 30 年多了,商业 C++ 编译器的库仍然有 bug,而且可能不遵循现在的 C++ 标准, 甚至不遵循编译器发布时的标准。这使得测量和推荐优化方法的任务变得非常复杂,也使 得开发人员认为没有任何优化经验是可以移植的。
对进行性能优化的开发人员来说,掌握标准 C++ 模板库是必需的技能。本教程对查找和排序 算法、容器类的最优惯用法、I/O、并发和 内存管理提出了一些优化建议。 有一些开源库实现了非常重要的功能,如内存管理。它们提供的复杂的 实现可能比供应商提供的 C++ 运行时库更快、更强。这些可供选择的开源库的一个优势 是,它们很容易地整合至现有的工程中,并能够立即改善程序性能。 Boost Project(http://www.boost.org)和 Google Code(https://code.google.com)等公开了很 多可供使用的库,
其中有一些用于 I/O、窗口、处理字符串和并发的库。它们虽然不是标准库的替代品,却可以帮助我们改善性能和加入新的 特性。这些库在设计上的权衡与标准库不同,从而获得了处理速度上的提升。 最后,开发人员还可以开发适合自己项目的库,通过放松标准库中的某些安全性和健壮性 约束来换取更快的运行速度。
某些方式的函数调用的开销非常大。优秀的函数库的 API 所提供的函数 反映了这些 API 的惯用法,使得用户可以无需频繁地调用其中的基础函数。例如,如果一 个用于获取字符的 API 只提供了 get_char() 函数,那么用户在获取每个字符时都需要进行 一次函数调用。而如果这个 API 同时提供一个 get_buffer() 函数,就可以避免在获取每个 字符时都发生昂贵的函数调用开销。 要想隐藏高度优化后的程序的复杂性,函数和类库是非常合适的地方。作为调用库的回 报,它们会以最高的效率完成工作。库函数通常位于深层嵌套调用链的底端,在那里,性 能改善的效果会更加明显。
1.5.4 减少内存分配和复制
减少对内存管理器的调用是一种非常有效的优化手段,以至于开发人员只要掌握了这一个 技巧就可以变为成功的性能优化人员。绝大多数 C++ 语言特性的性能开销最多只是几个指 令,但是每次调用内存管理器的开销却是数千个指令。 由于字符串是许多 C++ 程序中非常重要(和性能开销大)的部分,我
对缓存复制函数的一次调用也可能消耗数千个 CPU 周期。因此,很明显减少复制是一种 提高代码运行速度的优化方式。大量复制的发生都与内存分配有关,所以修改一处往往也 会消灭另一处。其他可能会发生复制的热点代码是构造函数和赋值运算符以及输入输出。
1.5.5 移除计算
除了内存分配和函数调用外,单条 C++ 语句的性能开销通常都很小。但是如果在循环中 执行 100 万次这条语句,或是每次程序处理事件时都执行这条语句,那么这就是个大问题 了。绝大多数程序都会有一个或多个主要的事件处理循环和一个或多个处理字符的函数。 找出并优化这些循环几乎总是可以让性能优化硕果累累。第 7 章提出了一些帮助你找出频 繁被执行的语句的建议,你会发现这些语句几乎总是位于循环处理中。
以性能优化为主题的文献介绍了许多高效地使用单独的 C++ 语句的技巧。许多程序员相信 这些诀窍是优化的基础。这种看法的问题在于,除非一段代码真的是热点代码(被频繁地 执行的代码),否则从中移除一两句内存访问对程序的整体性能不会有什么改善。我们 将介绍在尝试减少计算数量之前,如何确定程序中的哪部分会被频繁地执行。 同时,现代 C++ 编译器在进行这些局部改善方面也做得非常优秀了。因此,开发人员不 应当有强迫症,将大段代码中的出现的 i++ 都换成 ++i,或是展开所有的循环,不遗余力 地向每位同事讲解什么是达夫设备(Duff’s Device)8 以及它的优点。
1.5.6 使用更好的数据结构
选择最合适的数据结构对性能有着深刻的影响,因为插入、迭代、排序和检索元素的算法 的运行时开销取决于数据结构。除此之外,不同的数据结构在使用内存管理器的方式上也 有所不同。另一个原因是数据结构可能有也可能没有优秀的缓存本地化。
1.5.7 提高并发性
大多数程序都需要等待发生在物理现实世界中的无聊、慢吞吞的活动完成。它们必须等待 文件从硬盘上读取完成、网页从互联中返回或者是用户的手指缓慢地按下键盘。任何时 候,如果一个程序的处理进度因需要等待这些事件被暂停,而没有利用这些时间进行其他 处理,都是一种浪费。 现代计算机都可以使用多个处理核心来执行指令。如果一项工作被分给几个处理器执行, 那么它可以更快地执行完毕。 伴随并发执行而来的是用于同步并发线程让它们可以共享数据的工具。有人可以用好这些 工具,有人则用不好。第 12 章探讨了如何高效地控制并发线程同步。
1.5.8 优化内存管理
内存管理器作为 C++ 运行时库中的一部分,管理着动态内存分配。它在许多 C++ 程序中 都会被频繁地执行。C++ 确实为内存管理提供了丰富的 API,虽然多数开发人员都从来没 有使用过。
转载请注明:xuhss » C++ 性能优化篇二《影响优化的计算机行为》