C++ 性能优化篇三《测量性能》

测量可测量之物,将不可测量之物变为可测量。 ——伽利略 • 伽利雷(1564—1642)

测量和实验是所有改善程序性能尝试的基础。本章将介绍两种测量性能的工具软件:分析 器和计时器软件。我将讨论如何设计性能测量实验,使得测量结果更有指导意义,而不是 误导我们。

最基本和最频繁地执行的软件性能测量会告诉我们“需要多长时间”。执行函数需要多长 时间?从磁盘读取配置文件需要多长时间?启动和退出程序需要多长时间?

这些测量问题的解答方法有时简单得令人觉得可笑。牛顿通过用物体掉落至地面的时间除 以他的心跳速度测量出了重力常数 1 。我相信每位开发人员都有通过大声数数进行计时的经 历。在美国,我们通过喊“one-Mississippi, two-Mississippi, three-Mississippi...” 来得到比较 精确的秒数。带有秒表功能的电子手表曾经是计算机极客的必备之物,而非仅仅是潮流的 象征。在嵌入式开发中,熟悉硬件的开发人员有很多优秀的工具可以使用,其中有频率计 数器和信号示波器等甚至可以精确地测量极短例程的时间的工具。软件厂商也会出售专业 工具,由于数量太多,这里不会逐一介绍。

本章将主要介绍两种被广泛使用的、具有通用性且价格低廉的工具。第一个工具是编译器 厂商通常在编译器中都会提供的分析器(profiler)。分析器会生成各个函数在程序运行过 程中被调用的累积时间的表格报表。对性能优化而言,它是一个非常关键的工具,因为它会列出程序中最热点的函数。

第二个工具是计时器软件(software timer)。开发人员可以自己实现这个工具,就像绝地武 士自己打造他们的光剑一样(请原谅我在这里引用了《星球大战》中的内容打比喻)。如 果带有分析器的豪华版编译器太过昂贵,或是编译器厂商在某些嵌入式平台上不提供分析 器,开发人员依然可以通过测量长时间运行的活动来进行性能实验。计时器软件还可以用 于测量不受计算限制的任务。

第三个工具是非常古老的“实验笔记本”,许多开发人员认为它已经完全过时了。但是实 验笔记本或是其他文本文件仍然是不可或缺的优化工具。

3.1 优化思想

在开始介绍测量和实验之前,我想谈一点点我一直在实践的、也是我想在本书中教授的优 化哲学。

3.1.1 必须测量性能

人的感觉对于检测性能提高了多少来说是不够精确的。人的记忆力不足以准确地回忆起以 往多次实验的结果。书本中的知识可能会误导你,使你相信了一些并非总是正确的事情。 当判断是否应当对某段代码进行优化的时候,开发人员的直觉往往差得令人吃惊。他们 编写了函数,也知道这个函数会被调用,但他们并不清楚调用频率以及会被什么代码所调 用。于是,一段低效的代码混入了核心组件中并被调用了无数次。经验也可能会欺骗你。 编程语言、编译器、库和处理器都在不断地发展。之前曾经肯定是热点的函数可能会变得 非常高效,反之亦然。只有测量才能告诉你到底是在优化游戏中取胜了还是失败了。 那些具有最让我折服的优化技巧的开发人员都会系统地完成他们的优化任务:

  • 他们做出的预测都是可测试的,而且他们会记录下预测;
  • 他们保留代码变更记录;
  • 他们使用可以使用的最优秀的工具进行测量;
  • 他们会保留实验结果的详细笔记。
停下来思考
请回过头来再次阅读上节中的内容。其中包含了本书中最重要的建议。多数开发人员 (包括笔者)都会想当然地,而不是按照以上方式有条不紊地进行优化。这是一项必须 不断实践的技能。

3.1.2 优化器是王牌猎人

我说起飞后用核弹炸掉这地方。这是唯一的方法。 ——艾伦 • 蕾普莉(西格丽 • 维弗饰演),《异形 2》,1986 年

优化器是王牌猎人。如果只能让程序的运行速度提高 1% 是不值得冒险去修改代码的,因为修改代码可能会引入 bug。只有能显著地提升性能时才值得修改代码。而且,这 1% 的 速度提升可能只是将测量套件的误差当作了性能改善。因此,我们必须用随机抽样统计 和置信水平来证明速度的提升。但是完全没有必要为了这么一点点性能提升花费这么大气 力。本书中不会推荐大家这么做。

当性能提升 20% 的时候,事情就完全不同了。它会消除所有反对方法论的声音。本书中虽 然没有太多统计数字,不过我并不会为此感到抱歉。本书的重点是帮助开发人员找到这样 的性能改善点:其显著的效果足以战胜任何对其价值的质疑。这些性能改善点可能仍然取 决于操作系统和编译器等因素,因此它们可能会在其他操作系统上或是其他时间点没有太 好的效果。但是即使开发人员把他们的代码移植到新操作系统上,这些修改也几乎从来都 不会反过来降低程序性能。

3.1.3 90/10规则

性能优化的基本规则是 90/10 规则:一个程序花费 90% 的时间执行其中 10% 的代码。这 只是一条启发性的规则,并非自然法则,但对于我们的思考和计划却具有指导性。这条规 则有时也被称为 80/20 规则,但思想是一样的。直观地说,90/10 规则表示某些代码块是会 被频繁地执行的热点(hot spot),而其他代码则几乎不会被执行。这些热点就是我们要进 行性能优化的对象。

优化战争故事
我是在作为专业开发人员研发一种叫作 9010A 的带键盘的嵌入式设备(图 3-1)的项 目中初识 90/10 规则的。程序中有个函数会轮询键盘,查看用户是否按下了 STOP 键。这个函数会被每个例程 频繁地执行。手动优化 C 编译器输出的这个函数的 Z80 汇编代码(耗费了 45 分钟) 将整体吞吐量提高了 7%,对这台设备来说,非常不错了。一般情况下,这是一条典型的性能优化经验。在优化过程的初期,大量的运行时间都 集中消耗在程序中的某个位置。这个位置也非常明显:在每个循环的每次迭代中都要 重复进行的处理,就像每天的家务劳动一样。想要优化这些代码需要做出一项痛苦的 选择——用汇编语言重写这些 C 语言代码。但是由于使用汇编语言的代码范围极其有 限,选择使用汇编语言降低了需要承受的风险。当这段代码被频繁执行时,这条经验同样很典型。当我们改善了这段代码后,另一段 代码成为了最频繁地被执行的代码——不过它对整体运行时间的影响已经小多了。它 实在是太小了,以至于我们在进行了这一处改动后就停止了性能优化。我们甚至找不 到改动后可以将程序执行速度提高 1% 的地方了。

90/10 规则的一个结论是,优化程序中的所有例程并没有太大帮助。优化一小部分代码事 实上已经足够提供你所想要的性能提升了。识别出 10% 的热点代码是值得花费时间的,但 靠猜想选择优化哪些代码可能只是浪费时间。

这里我想再一次引用第 1 章中曾经引用过的高德纳的一句名言。不过,此处是那句名言一 个较长的版本:

程序员浪费了太多的时间去思考和担忧程序中那些非关键部分的速度,而且考虑 到调试和维护,这些为优化而进行的修改实际上是有很大负面影响的。我们应当 忘记小的性能改善,97% 的情况下,过早优化都是万恶之源。

​ ——高德纳,“使用 goto 语句进行结构化编程”, ACM Computing Surveys 6 (Dec 1974): 268. CiteSeerX: 10.1.1.103.6084(http://citeseerx.ist.psu.edu/ viewdoc/summary?doi=10.1.1.103.6084)

正如有些人所建议的那样,高德纳博士也并非警告我们所有的优化都是罪恶的。他只是说 浪费时间去优化那非关键的 90% 的程序是罪恶的。很明显,他也意识到了 90/10 规则。

3.1.4 阿姆达尔定律

阿姆达尔定律是由计算机工程先锋基恩 • 阿姆达尔(Gene Amdahl)提出并用他的名字命名 的,它定义了优化一部分代码对整体性能有多大改善。阿姆达尔定律有多种表达方式,不 过就优化而言,可以表示为下面的等式:

在这里插入图片描述

其中 ST 是因优化而导致程序整体性能提升的比率,P 是被优化部分的运行时间占原来程序 整体运行时间的比例,SP 是被优化部分 P 的性能改善的比率。

例如,假设一个程序的运行时间是 100 秒。通过分析(请参见 3.3 节)发现程序花费了 80 秒多次调用函数 f。现在假设修改 f 使其运行速度提升了 30%,那么这对程序整体运行时 间有多大改善呢?

P 是函数 f 的运行时间占原来程序整体运行时间的比例,即 0.8;SP 是被优化的部分 P 的 性能改善的比率,即 1.3。将它们代入到阿姆达尔定律的公式中:

在这里插入图片描述

也就是说,将这个函数的性能提升 30% 会将程序整体运行时间缩短 22%。在这个例子中, 阿姆达尔定律证明了 90/10 规则,而且通过这个例子向我们展示了,对 10% 的热点代码进 行适当的优化,就可以带来如此大的性能提升。

下面我们再来看一个例子。我们还是假设一个程序的运行时间是 100 秒。通过分析,你发 现有一个函数 g 的运行时间是 10 秒。现在假设你修改了函数 g,将它的运行速度提高了 100 倍。那么这对程序整体性能的提升有多大呢?

P 是函数 g 的运行时间占原来程序整体运行时间的比例,即 0.1;SP 是 100。将它们代入到 阿姆达尔定律的公式中:

在这里插入图片描述

在这个例子中阿姆达尔定律是具有警示性的。即使有异常优秀的编码能力或是黑科技将函 数 g 的运行时间缩短为 0,它仍然是那并不重要的 90% 代码中的一部分。将性能提升的比 率精确到两个小数位后,对程序整体性能的提升依然只有 11%。阿姆达尔定律告诉我们, 如果被优化的代码在程序整体运行时间中所占的比率不大,那么即使对它的优化非常成功 也是不值得的。阿姆达尔定律的教训是,当你的同事兴冲冲地在会议上说他知道如何将一 段计算处理的速度提高 10 倍,这并不一定意味着性能优化工作就此结束了。

3.2 进行实验

开发软件在某种意义上就是一项实验。你想让程序做一些事情,然后开始编程,最后观察 程序的运行结果是否与预想的一样。性能调优则是更有正式意义的实验。在开始性能调优 前,必须要有正确的代码,即在某种意义上可以完成我们所期待的处理的代码。你需要擦 亮眼睛审视这些代码,然后问自己:“为什么这些代码是热点?”为什么某个函数与程序 中的上百个函数不同,出现在了分析器的最差性能列表中的最前面?是这个函数浪费了很 多时间在冗余处理上吗?有其他更快的方法进行相同的计算吗?这个函数使用了紧缺的计 算机资源吗?是这个函数自身已经是非常快了,只不过它被调用了太多次,已经没有优化 的余地了吗?

你对于“为什么这些代码是热点”这个问题的回答构成了你要测试的假设。实验要对程序 的两种运行时间进行测量:一种是修改前的运行时间,一种是修改后的运行时间。如果后 者比前者短,那么实验验证了你的假设。

请注意这里的用词。实验并不需要证明任何事情。修改后的代码可能会因为某些原因运行 得更快或者更慢,但这些原因却与你修改的部分没有任何关系。比如:

  • 当你在测量运行时间时,计算机可能在接收邮件或是检查 Java 是否有版本更新;
  • 在你重编译之前,一位同事刚刚签入了一个性能改善后的库;
  • 你的修改可能运行得更快,但是处理逻辑却是不正确的。

优秀的科学家是怀疑论者。他们总是对事物持有怀疑。如果没有出现所期待的实验结果, 或是实验结果太好了,不像是对的,那么怀疑论者会再进行一次实验或者质疑她的假设, 抑或检查是否有 bug。

优秀的科学家会接受新知识,即使这些知识与他们脑海中的知识相悖。我在编写本书的过 程中学到了一些出乎意料的优化知识。本书的技术审核者也从本书中学到了知识。优秀的 科学家从不会停止学习。

优化战争故事
在第 5 章有一个查找关键字的示例函数。我为这个示例函数编写了几个不同的版本。 其中一个版本是线性查找(linear search),另一个版本则是二分查找(binary search)。 当测量这两个函数的性能时,我发现线性查找的速度比二分查找快几个百分点。这让 我觉得不可思议。二分查找本应当更快,但是测量结果却不是这样的。 我注意到有人在互联网上发表报告说线性查找经常会更快,因为相比二分查找,它的 缓存局部性(cache locality)更好,而且确实我实现的线性查找应当具有非常优秀的缓 存局部性。但是这个结果却与我的经验以及我从受人尊崇的书本上学到的有关查找算 法性能的知识相违背。 进行了更深入的调查后我发现,在测试时所使用的测试表格中只有几个单词,而且要 查找的关键字我自己都能从表格中找到。如果一个表格有 8 个项目,那么线性查找平 均会检查其中半数(4)后返回结果。而二分查找每次被调用时都会将表格一分为二 (共 4 次),然后才能查找到关键字。这两种算法对小的关键字集有着完全相同的平均 性能。直觉告诉我二分查找总是比线性查找更快,但这个结果告诉我我错了。 但是这并非我想证明的结果。所以我扩大了测试数据表格,想着这个表格在达到某 个大小时,一定会出现二分查找更快的结果。另外,我还向其中加入了一些原本不 在测试表格中的单词。可是测试结果依然不变,线性查找更快。这时,我不得不将 编写这份示例代码的任务搁置了几天,但是这个结果却一直折磨着我。 我仍然相信二分查找应当更快。我检查了两种查找方式的单元测试代码,最终发现线 性查找在进行第一次比较后总是返回成功。我的测试用例检查了是否返回了非零值, 而不是检查是否返回了正确值。接着,我惭愧地修改了线性查找算法和测试用例。现 在,实验结果与我所期待的一样,二分查找的速度更快了。 在这个例子中,实验结果先否定然后又验证了我的假设——整个过程中一直在挑战 我的假设。

3.2.1 记实验笔记

优秀的优化人员(如同所有优秀的科学家)都会关心可重复性。这时实验室笔记本就可以 发挥作用了。为了验证猜想,优化人员在对代码进行一处或多处修改后,利用输入数据集 对代码进行性能测试,而测试则会在若干毫秒后结束。在与下次运行时间进行比较前一直 记着上次程序的运行时间,这事儿并不难。如果每次代码改善都是成功的,用脑袋记住就 足够了。

不过,开发人员的猜想可能会出错,这将导致最近一次的程序运行时间比上一次的更长。 这时,无数的疑问会充斥在开发人员的脑中。虽然 5 号测试的运行时间比 4 号长,但是它 比 3 号短吗?在进行 3 号测试时修改了哪些代码?两次测试间的速度差异是由其他因素造 成的,还是的确变快了?

如果每次的测试运行情况都被记录在案,那么就可以快速地重复实验,回答上述问题就会 变得很轻松了。否则,开发人员必须回过头去重新做一次实验来获取运行时间——前提是 他还记得应该修改哪些代码或是撤销哪些修改。如果测试运行很简单,开发人员的记忆力 也非常好,那么他很幸运,只需要花费一点时间即可重复实验。但是也有可能没那么幸 运,明明想重复实验却偏离了正确的前进道路,或是毫无意义地浪费一天去重复实验。

每当我给出这条建议时,总会有人说:“我不需要笔和纸就能做到!我可以写一段 Perl 脚 本去修改代码版本管理工具的命令,让它帮忙将每次运行的测试结果和所修改的代码一起 保存起来。如果我将测试结果保存在文件中……如果我在不同的目录下做测试……”

我并不想妨碍开发人员创新。如果你是一位主动吸收最佳实践的高级开发经理,那么尽管 这么做吧。不过我想说的是,使用纸和笔记录是一种很稳健、容易使用而且有着千年历史 的技术。即使在开发团队替换了版本管理工具或测试套件的情况下,这项技术仍然可用。 它还适用于开发人员的下一份工作。这项传统的解决方案仍然可以节省开发人员的时间。

3.2.2 测量基准性能并设定目标

独立开发人员可以随意地、迭代地进行优化,直到他觉得性能足够好了为止。不过工作于 团队中的开发人员需要满足经理和其他利益相关人员的需求。优化工作受两个数字主导: 优化前的性能基准测量值和性能目标值。测量性能基准不仅对于衡量每次独立的改善是否 成功非常重要,而且对于向其他利益相关人员就优化成本开销做出解释也是非常重要的。

而优化目标值之所以重要,是因为在优化过程中优化效果会逐渐变小。在优化过程的最初 阶段,树上总是有些容易摘取的挂得很低的水果:一些独立的进程或是想当然地编写的函 数,优化它们后可以使性能提升很多。但是一旦实现了这些简单的优化目标后,下一轮性 能提升就需要付出更多的努力。

许多团队之所以在一开始没有为性能或是响应性设定目标,只是因为他们并不习惯这么 做。幸运的是,差劲的性能往往表现得非常明显(例如用户界面长时间不响应、托管服 务器的规模没有可扩展性、按照 CPU 时间付费的成本非常高等)。一旦团队研究下性能问 题,那么目标数字很容易被设定下来。用户体验(UX)设计的一个学科分支专门研究用 户如何看待等待时间。下面是一份常用的性能测试项目清单,你可以从为这些项目设定性能目标开始。这其中有足够多的与用户体验相关的数字&#x

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值