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

C++性能优化 xuhss 2043℃ 0评论

测量可测量之物,将不可测量之物变为可测量。 ——伽利略 • 伽利雷(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)提出并用他的名字命名 的,它定义了优化一部分代码对整体性能有多大改善。阿姆达尔定律有多种表达方式,不 过就优化而言,可以表示为下面的等式:

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

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

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

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

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

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

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

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

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

在这个例子中阿姆达尔定律是具有警示性的。即使有异常优秀的编码能力或是黑科技将函 数 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)设计的一个学科分支专门研究用 户如何看待等待时间。下面是一份常用的性能测试项目清单,你可以从为这些项目设定性能目标开始。这其中有足够多的与用户体验相关的数字,可以让你意识到危险性。

启动时间

从用户按下回车键直至程序进入主输入处理循环所经过的时间。通常,开发人员可以通 过测量程序进入 main() 函数到进入主循环的时间来得到启动时间,但是有时候也有例 外。为程序提供认证的操作系统厂商对程序在计算机启动时或某个用户登入时就运行有 严格的要求。例如,对那些寻求认证的硬件厂商,微软会要求 Windows shell 必须在启 动后 10 秒内能够进入它们的主循环。这限制了在忙碌的启动环境中,厂商可以预载和 启动的其他程序的数量。为此,微软提供了专用工具来帮助硬件厂商测量启动时间。

退出时间

从用户点击关闭图标或是输入退出命令直至程序实际完全退出所经过的时间。通常,开 发人员可以通过测量主窗口接收到关闭命令到程序退出 main() 的时间来得到退出时间, 但是有时候也有例外。退出时间也包含停止所有的线程和所依赖的进程所需的时间。为 程序提供认证的操作系统厂商对程序的退出时间有严格的要求。退出时间同样非常重 要,因为重启一个服务或是长时间运行的程序所需的时间等于它的退出时间加上它的启 动时间。

响应时间

执行一个命令的平均时间或最长时间。对于网站来说,平均响应时间和最长响应时间 都会影响用户对网站的满意度。响应时间可以粗略地以 10 的幂为单位划分为以下几 个级别。

时间 状态
低于 0.1 秒:用户在直接控制 如果响应时间低于 0.1 秒,用户会感觉他们在直接控制用户界面,他们的操作直接 改变了用户界面。这是用户开始拖动对象至对象发生移动,或是用户点击输入框至 输入框变为高亮之间的最小延迟。任何高于这个值的延迟都会让用户觉得他们发送 了一条命令让计算机去执行。
0.1 秒至 1 秒:用户在控制命令 如果响应时间在 0.1 秒至 1 秒之间,用户虽然仍然会觉得他们处于掌控状态,但是 这个短暂的延迟会被用户理解为计算机执行了一条命令导致 UI 发生了变化。用户可 以忍受这种程度的延迟,不至于分散注意力。
1秒至 10 秒:计算机在控制 如果响应时间在 1 秒至 10 秒之间,用户会觉得他们在执行了一条命令后失去了对 计算机的控制,虽然这时候计算机仍然在处理命令。用户可能会分散注意力,忘记 一件刚才发生的事情——他们需要完成自己的任务。10 秒是用户能保持注意力的 最长时间。如果他们多次遇到这种长时间等待 UI 发生改变的情况,用户满意度会 急速下降。
高于 10 秒:喝杯咖啡休息一下 如果响应时间高于 10 秒,用户会觉得他们有足够的时间去做一些其他的事情。如 果他们的工作需要用到 UI,那么他们会利用等待计算机进行计算的时间去喝一杯 咖啡。如果可以的话,他们甚至会关闭程序,然后去其他地方找找满足感。

雅各布 • 尼尔森(Jakob Nielsen)就用户体验中的响应时间范围写了一篇非常有意思的 文章(https://www.nngroup.com/articles/powers-of-10-time-scales-in-ux/),这是一份出于 好奇而进行的学术研究。

吞吐量

与响应时间相对。通常,吞吐量表述为在一定的测试负载下,系统在每个时间单位内所 执行的操作的平均数。吞吐量所测量的东西与响应时间相同,但是它更适合于评估批处 理程序,如数据库和 Web 服务等。通常,这个数字越大越好。

有时,也可能会发生过度优化的情况。例如,在许多情况下,用户认为响应时间小于 0.1 秒就是一瞬间的事了。在这种情况下,即使将响应时间从 0.1 秒改善为了 1 毫秒,也不会 增加任何价值,尽管响应速度提升了 100 倍。

3.2.3 你只能改善你能够测量的

优化一个函数、子系统、任务或是测试用例永远不等同于改善整个程序的性能。由于测试 时的设置在许多方面都与处理客户数据的正式产品不同,在所有环境中都取得在测试过程 中测量到的性能改善结果是几乎不可能的。尽管某个任务在程序中负责大部分的逻辑处 理,但是使其变得更快可能仍然无法使整个程序变得更快。 例如,一个数据库开发人员通过执行 1000 次某个特定的查询语句分析了数据库性能,然 后基于分析结果进行了优化,但这可能并不会提升整个数据库的速度,而是只提升了该查 询语句的速度。这也可能会提升其他查询语句的速度,但它可能不会改善删除或更新查 询、建立索引或是数据库可以进行的其他处理的速度。

3.3 分析程序执行

分析器是一个可以生成另外一个程序的执行时间的统计结果的程序。分析器可以输出一份 包含每个语句或函数的执行频度、每个函数的累积执行时间的报表。

许多编译器套件,如 Windows 上的 Visual Studio 和 Linux 上的 GCC 都带有分析器,可以 帮助我们找到程序中的热点。微软曾经只在价格昂贵的 Visual Studio 版本中提供了分析 器,但是自 Visual Studio 2015 社区版开始,微软开始向开发者提供免费的分析器。当然, 在 Windows 上还有其他开源的分析器以及对应早期的 Visual Studio 版本的分析器。

有几种方式可以实现一个分析器。一种可以同时支持 Windows 和 Linux 的方法如下。

  1. 程序员设置一个特殊的可以分析程序中所有函数的编译选项,重新编译一次程序,让 程序变为可分析的状态。这涉及在每个函数的开始和结束处添加一些额外的汇编语言 指令。
  2. 程序员将可分析的程序链接到分析库上。
  3. 每次这个可分析的程序运行时都会在磁盘上生成一张分析表(profiling table)。
  4. 分析器读取分析表,然后生成一系列可阅读的文字或图形报告。
  5. 通过将优化前的程序链接至分析库上使其变为可分析状态。分析库中的例程会以非常高 的频率中断程序的执行,记录指令指针的值。
  6. 每次可分析的程序运行时都会在磁盘上生成一张分析表。
  7. 分析器读取分析表,然后生成一系列可阅读的文字或图形报告。

分析器的输出结果可能会有多种形式。一种形式是一份标记有每行代码的执行次数的源代 码清单。另一种形式是一份由函数名和该函数被调用的次数组成的清单。第三种形式同样 也是函数清单,不过里面记录的是每个函数的累计执行时间和在每个函数中进行的函数调 用。还有一种形式是一份函数和在每个函数中花费的总时间的清单,但不包括调用其他函 数的时间、调用系统代码的时间和等待事件的时间。

分析器的分析功能都是量身设计的,它自身的性能开销非常小,因此它对程序整体运行时 间的影响也很小。通常,程序中每个操作的执行速度只会被降低几个百分点。第一种方法 的分析结果会非常精确,代价是更高的间接成本和禁用了某些优化。第二种方法的测量结 果是近似值,而且可能会遗漏一些非频繁地被调用的函数,但是它的优点是可以直接运行 于正式产品之上。

分析器的最大优点是它直接显示出了代码中最热点的函数。优化过程被简化为列出需要调查 的函数的清单,确认各个函数优化的可能性,修改代码,然后重新运行代码得到一份新的分 析结果。如此反复,直至没有特别热点的函数或是你无能为力了为止。由于分析结果中的热 点函数从定义上来说就是代码中发生大量计算的地方,因此,通常这个过程是直截了当的。

以我个人的分析经验来看,对调试构建(debug build)的分析结果和对正式构建(release build)的分析结果是一样的。在某种意义上,调试构建更易于分析,因为其中包含所有的 函数,包括内联函数,而正式构建则会隐藏这些被频繁调用的内联函数。

专业优化提示
在 Windows 上分析调试构建的一个问题是,调试构建所链接的是调试版本的运行时 库。调试版本的内存管理器函数会执行一些额外的测试,以便更好地报告重复释放的 内存和内存泄漏问题。这些额外测试的开销会显著地增加某些函数的性能开销。有一 个环境变量可以让调试器不要使用调试内存管理器:进入控制面板→系统属性→高级 系统设置→环境变量→系统变量,然后添加一个叫作 _NO_DEBUG_HEAP 的新变量并设定 其值为 1。

使用分析器是一种帮助我们找到要优化的代码的非常好的方式,但也有它的问题。

  • 分析器无法告诉你有更高效的算法可以解决当前的计算性能问题。去优化一个低效的算 法只是浪费时间。
  • 对于会执行许多不同任务的待优化的程序,分析器无法给出明确的结果。例如,一个 SQL 数据库在执行 insert 语句时和在执行 select 语句时所运行的代码是不一样的。因 此,当使用 insert 加载数据库时的热点代码,可能在数据库执行 select 语句的时候完 全不会被运行。除非在分析时会进行大量计算,否则请在测试中混合加载数据库操作和 查询数据库操作,使执行 insert 语句的代码在分析结果中不那么突出。因此,要想容易地找出最热点的函数,请尽量一次仅优化一个任务。这对于分析整个程 序中的一个子系统在测试套件上的运行情况非常有帮助。不过,如果每次只优化一个任 务,那么也会引入另外一种不确定性:即它不一定会改善程序的整体性能。而实际上当 程序运行多个任务时,优化的效果可能就体现得不那么明显了。
  • 当遇到 IO 密集型程序或是多线程程序时,分析器的结果中可能会含有误导信息,因为 分析器减去了系统调用的时间和等待事件的时间。不计算这些时间在理论上是完全合理 的,因为程序并不需要为这些等待时间负责。但是结果却是分析器可以告诉我们程序做 了多少事情,而不是花了多少实际时间去做这些事情。有些分析器不仅统计了函数调用 的次数,还计算出了每个函数的调用时间。如果函数调用次数非常多,意味着分析器可 能隐藏了实际时间。

分析器并不完美。有些优化可能性可能不会被分析出来,而且程序员在理解分析器的输出 结果时也可能会有问题。不过,对于许多程序来说,分析器的分析结果已经足够好了,不 需要再使用其他的优化方法了。

3.4 测量长时间运行的代码

如果程序只是运行一个计算密集型的任务,那么分析器会自动地告诉我们程序中的热点在 哪里。不过如果程序要做许多不同的处理,可能在分析器看来,没有任何一个函数是热 点。程序还有可能会花费大量的时间等待 I/O 或是外部事件,这样降低了程序的性能,增 加了程序的实际运行时间。在这种情况下,我们需要测量程序中各个部分的时间,然后试 着减少其中低效部分的运行时间。

开发人员通过不断地缩小长时间运行的任务的范围直至定位其中一段代码花费了太长时 间,感觉不对劲这种方式来查找代码中的热点。在找出这些可疑代码后,开发人员会在测 试套件中对小的子系统或是独立的函数进行优化实验。

测量运行时间是一种测试关于“如何减少某个特定函数的性能开销”的假设的有效方式。

一般,我们很难意识到可以通过编程在计算机上实现秒表功能。你可以非常方便地使用手 机或是手提电脑在工作日的 6:45 叫醒你,或是在早上 10 点的站立会议前 5 分钟提醒你 参加会议。但是在现代计算机上测量亚微秒级的运行时间却是有点难度的,特别是因为在 普通的 Window/PC 平台上存在没有可以稳定地工作于不同型号的硬件和不同的软件版本 上的高精度计时器的历史遗留问题。

因此,作为一名开发人员,你需要随时准备好制作一个自己的秒表,而且必须知道它们以 后可能会发生变化。为了使这成为可能,接下来我会讨论如何测量时间以及有哪些工具可 用于在计算机上测量时间。

3.4.1 一点关于测量时间的知识

浅学害人。

​ ——亚历山大 • 蒲柏,“批评论”(http://poetry.eserver.org/ essay-oncriticism.html), 1774 年

一次完美的测量是指精确地得到大小、重量或者在本书中是某个事件每次持续的时间。完 美的测量就像是将弓箭不断地精准地射中靶心一样。这种箭术只存在于故事书中,测量也 是一样的。

真正的测量实验(就像真正的弓箭)必须能够应对可变性(variation):可能破坏完美测 量的误差源。可变性有两种类型:随机的和系统的。随机的可变性对每次测量的影响都不 同,就像一阵风导致弓箭偏离飞行线路一样。系统的可变性对每次测量的影响是相似的, 就像一位弓箭手的姿势会影响他每一次射箭都偏向靶子的左边一样。

可变性自身也是可以测量的。衡量一次测量过程中的可变性的属性被称为精确性(precision) 和正确性(trueness)。这两种属性组合成的直观特性称为准确性(accuracy)。

  1. 精确性、正确性和准确性

很明显,对测量感到兴奋的科学家就相关的专业用语展开了喋喋不休的争论。你只需在维 基百科上查找一下“准确性”这个词,就会发现关于究竟应该使用哪些词来解释已经达成 一致的概念有多少争议了。我选择使用 1994 版的 ISO 5725-1 中的上下文来解释术语:“测 量方法和结果中的准确性(正确性和精确性)——卷 1:通用原则和定义”(1994)。

如果测量不受随机可变性的影响,它就是精确的。也就是说,如果反复地测量同一现象, 而且这些测量值之间非常接近,那么测量就是精确的。一系列精确的测量中可能仍然包含 系统的可变性。尽管一位弓箭手将一组弓箭射到了偏离靶心的一块区域中,但我们仍然可 以说这是精确的,尽管不太准确。他射中的靶子的样子可能如图 3-2 所示。

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

​ 图 3-2:高精确性(但低正确性)的射箭结果

如果测量一个事件(比如一个函数的运行时间)10 次,而且 10 次的结果完全相同,我们 可以认为测量是精确的。(像在任何实验中一样,我应当会对此持怀疑态度,直到找到足 够的证据为止。)如果其中只有 6 次结果相同,3 次结果略微有些不同,1 次结果的差异非 常大,那么测量就是不够精确的。

如果测量不受系统可变性的影响,它就是正确的。也就是说,如果反复地测量同一现象, 而且所有测量结果的平均值接近实际值,那可以认为测量是正确的。每次独立的测量可能 受到随机可变性的影响,所以测量结果可能会更接近或是偏离实际值。正确性并不受弓箭手的技能影响。在图 3-3 中,将四箭的平均值看作是一把箭的话,那么它应当是正中靶心 的。而且,就环数(离靶心的距离)而言,这四箭具有相同的准确性。

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

图 3-3:弓箭手的箭找到了正确的靶心

测量的准确性是一个取决于每次独立的测量结果与实际值有多接近的非正式的概念。与实 际值的差异由随机可变性与系统可变性两部分组成。只有同时具有精确性和正确性的测量 才是准确的测量。

  1. 测量时间

本书中涉及的软件性能测量要么是测量持续时间(两个事件之间的时间),要么是测量速 率(单位时间内事件的数量,与持续时间相对)。用于测量持续时间的工具是时钟。

所有时钟的工作原理都是周期性地计数。某些时钟的计数会表示为时、分、秒,有些则是 直接显示时标的次数。但是时钟(除了日晷外)是并不会直接测量时、分、秒的。它们只 会对时标进行计数,然后只有将时标计数值与秒基准的时钟进行比较后才能校准时钟,显 示出时、分、秒。

周期性地改变的东西受到可变性的影响也会出现误差。有些可变性是随机的,有些可变性 则是系统的。

  • 日晷利用了地球的周期性旋转。从定义上说,一次完整的旋转是一天。地球并非完美的 时钟,不仅是因为周期太长,而且我们发现由于大陆在它表面上缓慢地移动,它的旋转 速度时快时慢(微秒级别)。这种可变性是随机的;来自月球和太阳的潮汐力会降低地 球的整体旋转速率。这种可变性是系统的。
  • 老式时钟会对钟摆有规律的摆动计数。齿轮会随着钟摆驱动指针旋转来显示时间。钟摆 摆动的间隔可以手动调整,这样所显示的时间可以与地球旋转同步。钟摆摆动的周期取 决于钟摆的重量和它的长度,这样就可以根据需要让摆动得更快或是更慢。这种可变性 是系统的;而即使在最开始钟摆的摆动非常精准,但摩擦、气压和累积的灰尘都会对摆 动造成影响。这些都是随机可变性因素。
  • 电子时钟使用它的交流电源的周期性的 60Hz 正弦波驱动同步电机。齿轮会下分基本振 荡和驱动指针来显示时间。电子时钟也并非完美的时钟,因为根据惯例(不是自然法 则),交流电源的周期只有 60Hz(在美国)。当负荷过高时,电力公司会先降低振荡周期,稍后又提高振荡周期,这样电子时钟并不会走慢。所以,在炎热夏日的午后电子时钟的 一秒可能会比凉爽夜晚的一秒快(虽然我们总是对此表示怀疑)。这种可变性是随机的。 将一个为美国用户制造的电子时钟插入到欧洲 50Hz 的交流电源插座中,它会走得慢。 与气温引起的随机可变性相比,这种由欧洲电源插座引起的可变性是系统的。
  • 数字腕表采用石英晶体的诱导振动作为基本振动。逻辑电路会下分基本振动并驱动时间 显示。石英晶体的振动周期取决于它的大小、温度以及加载的电压。石英晶体的大小的 影响是系统的可变性,而温度和电压的可变性则是随机的。

时标计数值肯定是一个无符号的值。不可能存在 -5 次时标。我之所以在这里提醒大家这 个看似非常明显的事实,是因为正如稍后会向大家展示的,许多开发人员实现计时函数时 选择有符号类型来表示持续时间。我不知道为什么他们这么做。我那十几岁的儿子应该会 说:“这没什么大不了。”

  1. 测量分辨率

测量的分辨率是指测量所呈现出的单位的大小。

一位弓箭手只要将弓箭射在指定环内的任意位置,所得到的分数都是相同的。靶心并非是 无限小的点,而是一个给定直径的圆环(请参见图 3-4)。一支箭要么设在靶心,或是九 环、八环等。每一环的宽度就是射箭得分的分辨率。

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

图 3-4:分辨率:一支箭设在一环中任意地方的得分是相同的

时间测量的有效分辨率会受到潜在波动的持续时间的限制。时间测量结果可以是一次或者 两次时标,但不能是这两者之间。这些时标之间的间隔就是时钟的有效分辨率。

观察人员可能会察觉到一个走得很慢的时钟的两次时标之间发生的事情,例如钟摆的一次 摆动。这只是说明在人类脑海中有一个更快的时钟(虽然没有那么准确),他们会将这个 时钟的时间与钟摆的时间进行比较。观察人员如果想测量那些不可感知的持续时间,例如 毫秒级别,只能用时钟的时标。

在测量的准确性与它的分辨率之间是没有任何必需的关联的。例如,假设我记录了我每天 的工作,那么我可以报告说我花了两天来编写本节内容。在这个例子中,测量的有效分辨 率是“天”。如果我想把这个时间换成秒,那么可以报告说成我花了 172 800 秒来编写本节

内容。但除非我手头上有一个秒表,否则以秒为单位进行报告会让人误认为比之前更加准 确,或是给人一种没有吃饭和睡觉的错觉。

测量结果的单位可能会比有效分辨率小,因为单位才是标准。我有一个可以以华氏温度为 单位显示温度的烤箱。恒温器控制着烤箱,但是有效分辨率只有 5°F。所以在烤箱加热的 过程中,显示屏上显示的温度会是 300°F,接着是 305°F、310°F、315°F 等。以一度为单 位显示温度应该比恒温器的单位更合理。有效分辨率只有 5°F 只是表示测量的最低有效位 只能是 0 或者 5。

当读者知道他们身边廉价的温度计、尺子和其他测量设备的有效分辨率后可能会感到吃惊 和失望,因为这些设备的显示分辨率是 1 个单位或是 1/10 单位。

  1. 用多个时钟测量

只有一块表的人知道现在的时间,而拥有两块表的人却永远不能确定现在的时间。

​ ——多认为该名言出自 Lee Segall

当两个事件在同一个地点发生时,很容易通过一个时钟的时标计数来测量事件的经过时 间。但是如果这两个事件发生在相距很远的不同地点,可能就需要两个时钟来测量时间。 而两个不同时钟的时标次数无法直接比较。

人类想到了一个办法,那就是通过与国际协调时间(Coordinated Universal Time)同步。 国际协调时间与经度 0 度的天文学上的午夜同步,而经度 0 度这条线穿过了英格兰格林威 治皇家天文台中的一块漂亮的牌匾(请参见图 3-5)。这样就可以将一个以时标计数值表示 的时间转换为以时分秒表示的相对 UTC(Universal Time Coorinated,国际协调间,由 法国和英国的时钟专家商定的一个既不是法式拼写也不是英式拼写的缩写)午夜的时间。

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

图 3-5:英格兰格林威治皇家天文学馆的本初子午线的标记(摄影: Ævar Arnfjörð Bjarmason, license CC BY-SA 3.0)

如果两个时钟都与 UTC 完美地同步了,那么其中一个时钟的相对 UTC 时间可以直接与另 外一个相比较。但是当然,完全的同步是不可能的。两个时钟都有各自独立的可变性因 素,导致它们与 UTC 之间以及它们互相之间产生误差。

3.4.2 用计算机测量时间

要想在计算机上制作一个时钟需要一个周期性的振动源——最好有很好的精确性和正确 性——以及一种让软件获取振动源的时标的方法。要想专门为了计时而制造一台计算机是 很容易的。不过,多数现在流行的计算机体系结构在设计时都没有考虑过要提供很好的时 钟。我将会结合 PC 体系结构和微软的 Windows 操作系统讲解问题所在。Linux 和嵌入式 平台上也存在类似的问题。

PC 时钟电路核心部分的晶体振荡器的基本精度是 100PPM,即 0.01%,或者每天约 8 秒的 误差。虽然这个精度只比数字腕表的精度高一点点,但对性能测量来说已经足够了,因为 对于极其非正式的测量结果,精确到几个百分点就可以了。廉价的嵌入式处理器的时钟电 路的精确度较低,但是最大的问题并非周期性振动的振动源,更困难的是如何让程序得到 可靠的时标计数值。

  1. 硬件时标计数器的发展

起初的 IBM PC 是不包含任何硬件时标计数器的。它确实有一个记录一天之中的时间的 时钟,软件也可以读取这个时间。最早的微软的 C 运行时库复制了 ANSI C 库,提供了 time_t time(time_t*) 函数。该函数会返回一个距离 UTC 时间 1970 年 1 月 1 日 0:00 的秒 数。旧版本的 time() 函数返回的是一个 32 位有符号整数,但是在经历了 Y2K3 之后,它被 修改成了一个 64 位的有符号整数。

起初的 IBM PC 会使用来自交流电源的周期性的中断来唤醒内核去进行任务切换或是进行 其他内核操作。在北美,这个周期是 16.67 毫秒,因为交流电源是 60Hz 的。如果交流电 源是 50Hz 的话,这个周期就是 20 毫秒。

自 Windows 98(可能更早)以来,微软的 C 运行时提供了 ANSI C 函数 clock_t clock()。 该函数会返回一个有符号形式的时标计数器。常量 CLOCKS_PER_SEC 指定了每秒钟的时标的 次数。返回值为 -1 表示 clock() 不可用。clock() 会基于交流电源的周期性中断记录时标。 clock() 在 Windows 上的实现方式与 ANSI 所规定的不同,在 Windows 上它所测量的是经 过时间而非 CPU 时间 4 。最近,clock() 被根据 GetSystemTimeAsfileTime() 重新实现了。在 2015 年时它的时标是 1 毫秒,分辨率也是 1 毫秒。这使得它成了 Windows 上一个优秀的 毫秒级别的时钟。

自 Windows 2000 开始,可以通过调用 DWORD GetTickCount() 来实现基于 A/C 电源中断的 软件时标计数器。GetTickCount() 的时标计数值取决于 PC 的硬件,可能会远比 1 毫秒长。 GetTickCount() 会进行一次将时标转换为毫秒的计算来消除部分不确定性。这个方法的一 个升级版是 ULONGLONG GetTickCount64(),它会以 64 位无符号整数的形式返回相同的时标计数值,这样可以测量更长的处理时间。虽然没有办法知道当前的中断周期,但下面这对 函数可以缩短和然后恢复周期:

MMRESULT timeBeginPeriod(UINT)
MMRESULT timeEndPeriod(UINT)

这两个函数作用于全局变量上,会影响所有的进程和其他函数,如取决于交流电源的中断 周期的 Sleep()。另外一个函数 DWORD timeGetTime() 可以通过另一种方法获取相同的时标 计数值。

自奔腾体系结构后,英特尔提供了一个叫作时间戳计数器(Time Stamp Counter,TSC)的 硬件寄存器。TSC 是一个从处理器时钟中计算时标数的 64 位寄存器。RDTSC 指令可以非常 快地访问该寄存器。

自 Windows 2000 问世后,可以通过调用函数 BOOL Query PerformanceCounter(LARGE_ INTEGER*) 来读取 TSC,这将会产生一次特殊的不带分辨率的时标计数。可以通过调用 BOOL QueryPerformanceFrequency(LARGE_INTEGER*) 来获得分辨率,它会返回每秒钟时标的 频率。LARGE_INTEGER 是一个带有有符号格式的 64 位整数的结构体,因为在当时引入了以 上这些函数的 Visual Studio 中还没有原生的 64 位有符号整数类型。

初始版本的 QueryPerformanceCounter() 的一个问题是,它的时标速率取决于处理器的时钟。不同处理器和主板的处理器时钟不同。在当时,老式的 PC,特别是那些使用超微半导体公司(Advanced Micro Devices,AMD)处理器的 PC 是没有 TSC 的。在当时没有 TSC 可用的情况下,QueryPerformanceCounter() 会返回 GetTickCount() 返回的低分辨率的时标计数值。

在 Windows 2000 中还新增加了一个 void GetSystemTimeAsfileTime(fiLETIME*) 函数,它 会返回一个自 1601 年 1 月 1 日 00:00 UTC 开始计算的以 100 纳秒为时标的计数值。其中, fiLETIME 也是一个带有 64 位整数的结构体,不过这次是无符号的形式。尽管该时标计数 器显示出来的分辨率看起来非常高,有些实现却使用了与 GetTickCount() 所使用的低分辨 率计数器相同的计数器。

很快,QueryPerformanceCounter() 的更多问题暴露出来了。有些处理器实现了可变时钟频率来管理功耗。这会导致时标周期发生了变化。在拥有多个独立处理器的多处理器系统 中,QueryPerformanceCounter() 返回的值取决于线程运行于哪个处理器之上。处理器开 始实现指令重排序之后,导致 RDTSC 指令可能会发生延迟,降低使用了 TSC 的软件的 准确性。

为了解决这些问题,Windows Vista 为 QueryPerformanceCounter() 使用了一种不同的计 数器,称为 Advanced Configuration and Power Interface(ACPI)电源管理计时器。使用 这个计数器虽然能够解决多处理器的同步问题,但是却显著地增加了延迟。与此同时, 英特尔重新指定了 TSC 为最大且不变的时钟频率。此外,英特尔还增加了不可重排序的 RDTSCP 指令。

自 Windows 8 开始,Windows 提供了一种基于 TSC 的、可靠的、高分辨率的硬件时标计 数。只要该系统运行于 Windows 8 或者之后的版本上,void GetSystemTimePreciseAsfileT ime(fiLETIME*) 就可以生成一个固定频率和亚微秒准确度的高分辨率时标。

一句话总结本堂历史课的内容就是,PC 从来都不是设计作为时钟的,因此它们提供的时 标计数器是不可靠的。如果以过去 35 年的历史为鉴,未来的处理器和操作系统可能依然 无法提供稳定的、高分辨率的时标计数值。

历代 PC 都提供的唯一可靠的时标计数器就是 GetTickCount() 返回的时标计数器了,尽管它也有缺点。clock() 返回的毫秒级的时标更好,而且近 10 年生产的 PC 应该都是支持该函数的。如果只考虑 Windows 8 及之后的版本和新的处理器的话, GetSystemTimePreciseAsfileTime() 返回的 100 纳秒级别的时标计数器是非常精确的。不 过,就我个人的经验来看,对时间测量来说毫秒级别的准确性已经足够了。

  1. 返转

返转(wraparound)是指当时钟的时标计数器值到达最大值后,如果再增加就变为 0 的 过程。12 小时制的模拟时钟在每天的正午和午夜各会进行一次返转。Windows 98 在连续 运行 49 天后会因 32 位毫秒时标计数器的返转而挂起(请参见 Q216641)。当两位数的年 份返转时会发生 Y2K 问题。玛雅日历在 2012 年返转,因为玛雅人认为那就是世界末日。 UNIX 时间戳(自 UTC1970 年 1 月 1 日 00:00 起的带符号的 32 位秒数)会在 2038 年 1 月 发生返转,这可能会称为某些“历史悠久”的嵌入式系统的“世界末日”。返转的问题出在缺少额外的位去记录数据,导致下次时间增加后的数值比上次时间的数值小。会返转的时钟仅适用于测量持续时间小于返转间隔的时间。

例如,在 Windows 上,GetTickCount() 函数会返回一个分辨率为 1 毫秒的 32 位无符号的 整数值作为时标计数值。那么,GetTickCount() 的返回值会每 49 天返转一次。也就是说, GetTickCount() 适用于测量那些所需时间小于 49 天的操作。如果一个程序在某个操作开始时和结束时分别调用了 GetTickCount()两个返回值之间的差值就是两次调用之间经过的毫秒数。例如:

DWORD start = GetTickCount();
 DoBigTask();
DWORD end = GetTickCount();
cout << "Startup took " << end-start << " ms" << endl;

C++ 实现无符号算术的方式去确保了即使在发生返转时也可以得到正确的结果。 GetTickCount() 对于记住自程序启动后所经过的时间是比较低效的。许多“历史悠久”的 服务器可以持续运行数个月甚至数年。返转的问题在于,由于缺少位数去记录返转的次 数,end-start 的结果中可能体现不出发生了返转,或是体现出一个或者多个返转。 自 Windows Vista 开始,微软加入了 GetTickCount64() 函数,它会返回一个 64 位无符号且 显示分辨率为 1 毫秒的时标计数值。GetTickCount64() 的结果只有在数百万年后才会发生返转。这就意味着,几乎不会有人能够见证返转发生了。

  1. 分辨率不是准确性

在 Windows 上,GetTickCount() 会返回一个无符号的 32 位整数值。如果一个程序在某个操作开始和结束时分别调用了 GetTickCount(),两个返回值之间的差值就是两次调用之间 经过的毫秒单位的执行时间。因此,GetTickCount() 的分辨率是 1 毫秒。

例如,下面这段代码通过在循环中反复调用 Foo(),在 Windows 上测量了名为 Foo() 的函数的相对性能。通过在代码块开始和结束时得到的时标计数值,我们可以计算出循环处理 所花费的时间:

DWORD start = GetTickCount();
for (unsigned i = 0; i < 1000; ++i) {
 Foo();
}
DWORD end = GetTickCount();
cout << "1000 calls to Foo() took " << end-start << "ms" << endl;

如果 Foo() 中包含了大量的计算,那么这段代码的输出结果可能如下:

1000 calls to Foo() took 16ms

不幸的是,从微软网站中关于 GetTickCount() 的文档(https://msdn.microsoft.com/en-us/ library/windows/desktop/ms724408(v=vs.85).aspx)来看,调用 GetTickCount() 的准确性 可能是 10 毫秒或 15.67 毫秒。也就是说,如果连续调用两次 GetTickCount(),那么结果之间的差值可能是 0 或者 1 毫秒,也可能是 10、15 或 16 毫秒。因此,测量的基础精度 是 15 毫秒,额外的分辨率毫无价值。之前代码块的输出结果可能会是 10ms、20ms 或精 确的 16ms。

GetTickCount() 特别让人沮丧的一点是,除了分辨率是 1 毫秒外,无法确保在两台 Windows 计算机中该函数是以某种方式或是相同方式实现的。

我在 Windows 上测试了许多计时函数,试图找出它们在某一台计算机(基于 i7 处理器的 Surface 3 平板电脑)的某个操作系统(Windows 8.1)上的可用分辨率。示例代码 3-1 中的 测试循环地调用了测量时间的函数,并检查这些连续的函数调用的返回值之间的差值。如果时标的可用分辨率大于函数调用的延迟,那么这些连续的函数调用的返回值将要么相同,要么它们之间的差值是若干个基础时标,单位是函数的分辨率。我计算了非零差值的 平均值,以排除操作系统偷用时间片段去执行其他任务的误差。

代码清单 3-1 测量 GetTickCount() 的时标

unsigned nz_count = 0, nz_sum = 0;
ULONG last, next;
for (last = GetTickCount(); nz_count < 100; last = next) {
 next = GetTickCount();
 if (next != last) {
 nz_count += 1;
 nz_sum += (next - last);
 }
}
std::cout << "GetTickCount() mean resolution "
 << (double)nz_sum / nz_count
 << " ticks" << std::endl;

我将测量结果总结在了表 3-1 中。

表3-1 :在i7的Surface Pro 3(Windows 8.1)上测量的时标结果:

函数 时标
time() 1 秒
GetTickCount() 15.6 毫秒
GetTickCount64() 15.6 毫秒
timeGetTime() 15.6 毫秒
clock() 1.0 毫秒
GetSystemTimeAsFileTime() 0.9 毫秒
GetSystemTimePreciseAsFileTime() 约 450 纳秒
QueryPerformanceCounter() 约 450 纳秒

需要特别注意的是 GetSystemTimeAsfileTime() 函数。它的显示分辨率是 100 纳秒,但 是看起来却似乎是基于同样低分辨率的 1 毫秒时标的 clock() 实现的,而 GetSystemTime PreciseAsfileTime() 看起来则是用 QueryPerformanceCounter() 实现的。

现代计算机的基础时钟周期已经短至了数百皮秒(100 皮秒是 10-10 秒)。它们可以以几纳秒的速度执行指令。但是在这些 PC 上却没有提供可访问的皮秒级或是纳秒级的时标计数器。在 PC 上,可使用的最快的时标计数器的分辨率是 100 纳秒级的,而且它们的基础准确性可能远比它们的分辨率更低。这就导致不太可能测量函数的一次调用的持续时间。读者可以参见 3.4.3 节看看如何应对这个问题。

  1. 延迟

延迟是指从发出命令让活动开始到它真正开始之间的时间。延迟是从丢下一枚硬币到井水 中到听见井水溅落之间的时间(请参见图 3-6)。它也是发令员鸣枪至选手出发之间的时间。

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

图 3-6:延迟:从丢下一枚硬币到井水中到听见井水溅落之间的时间

就计算机上的时间测量而言,之所以会有延迟是因为启动时钟、运行实验和停止时钟是一 系列的操作。整个测量过程可以分解为以下五个阶段。

  • (1) “启动时钟”涉及调用函数从操作系统中获取一个时标计数。这个调用的时间大于 0。 在函数调用过程中,会实际地从处理器寄存器中获取时标计数器的值。这个值就是开始 时间。我们称其为间隔 t1。
  • (2) 在读取时标计数器的值后,它仍然必须被返回和赋值给一个变量。这些动作也需要花费 时间。实际的时钟已经在计时的过程中了,但是时标计数值还没有增加。我们称其为间 隔 t2。
  • (3) 测量实验开始,然后结束。我们称其为间隔 t3。
  • (4) “停止时钟”涉及另外一个函数调用去获取一个时标计数值。虽然实验已经结束了,但 是在函数运行至读取时标计数值的过程中,计时器仍然在计时。我们称其为间隔 t4。
  • (5) 读取时标计数器的值后,它仍然必须被返回和赋值给一个变量。这时,虽然时钟仍然在 计时,但是由于已经读取了时标计数器的值,因此测量结果并不会继续错误地累加。我 们称其为间隔 t5。

因此,虽然实际上测量时间应当是 t3,但测量到的值却更长一些,是 t2+t3+t4。因此,延迟就 是 t2+t4。如果延迟对相对实验运行时间的比例很大,实验员必须从实验结果中减去延迟。

假设获取一次时标计数值的时间是 1 微秒(μs),而且时标计数值是由当时执行的最后一条 指令获得的。在以下这段伪代码中,直到第一次函数调用的最后一条指令调用 get_tick() 才开始测量时间,因此在测量活动之前是没有延迟的。在测试的最后调用 get_tick() 的延 迟则被计算到了测量结果中:

start = get_tick() // 测量开始之前的1μs延迟没有影响
do_activity()
stop = get_tick() // 测量开始之后的1μs延迟被计算到测量结果中
duration = stop-start

如果被测量的活动的执行时间是 1 微秒,那么测量结果就是 2 微秒,误差达将会到 100%;而如果被测量的活动的执行时间是 1 毫秒,那么测量结果就是 1.001 微秒,误差 只有 0.1%。

如果同一个函数既在实验前被调用了,也在实验后被调用了,那么有 t1=t4 和 t2=t5。也就是 说,延迟就是计时函数的执行时间。

我在 Windows 上测试了计时函数的调用延迟,也就是它们的执行时间。代码清单 3-2 展示 了一个典型的用于计算 GetSystemTimeAsfile() 函数的时间的测试套件。

代码清单 3-2 Windows 计时函数的延迟

ULONG start = GetTickCount();
LARGE_INTEGER count;
for (counter_t i = 0; i < nCalls; ++i)
 QueryPerformanceCounter(&count);
ULONG stop = GetTickCount();
std::cout << stop - start
 << "ms for 100m QueryPerformanceCounter() calls"
 << std::endl;

表3-2:Windows计时函数的延迟(2013,i7,Win 8.1)

函数 时标
GetSystemTimeAsFileTime() 2.8 纳秒
GetTickCount() 3.8 纳秒
GetTickCount64() 6.7 纳秒
QueryPerformanceCounter() 8.0 纳秒
clock() 13 纳秒
time() 15 纳秒
TimeGetTime() 17 纳秒
GetSystemTimePreciseAsFileTime() 22 纳秒

测试结果中非常有趣的地方是,在我的 i7 平板电脑上,所有的延迟都在若干纳秒的范围 内。所以,这些函数调用是相当高效的。这就意味着延迟不会对在循环中连续调用函数约 1 秒的测量结果的准确性造成影响。不过,对于那些读取相同的低分辨率时标的函数,这 些时间开销的差距仍然在 10 倍左右。GetSystemTimePreciseAsfileTime() 的延迟最高,而 这个最高的延迟相对于它的时标占到了大约 5%。延迟问题在慢速处理器上更严重。

  1. 非确定性行为

计算机是带有大量内部状态的异常复杂的装置,其中绝大多数状态对开发人员是不可见的。 执行函数会改变计算机的状态(例如高速缓存中的内容),这样每次重复执行指令时,情况 都会与前一条指令不同。因此,内部状态的不可控的变化是测量中的一个随机变化源。

而且,操作系统对任务的安排也是不可预测的,这样在测量过程中,在处理器和内存总线上运行的其他活动会发生变化。这会降低测量的准确性。

操作系统甚至可能会暂停执行正在被测量的代码,将 CPU 时间分配给其他程序。但是在暂停过程中,时标计数器仍然在计时。这会导致与操作系统没有将 CPU 时间分配给其他 程序相比,测量出的执行时间变大了。这是一种会对测量造成更大影响的随机变化源。

3.4.3 克服测量障碍

那么,这到底有多糟糕呢?我们能让计算机完全用于计时吗?我们需要做什么才能实现计 算机计时呢?在本节中,我将对我个人使用 3.4.4 节中的 stopclass 类来为本书测试函数的 经验进行总结。

  1. 别为小事烦恼

好消息是测量误差只要在几个百分点以内就足够指引我们进行性能优化了。换种方式说, 如果希望从性能优化中获得线性改善效果,误差只需要有两位有效数字就可以了。即对于循环执行某个函数 1000 毫秒的实验来说,大约 10 毫秒。我们将可能的误差源整理在了表 3-3 中。

表3-3:各变化源对在Windows上测量1秒时间的影响度

变化源 影响度
时标计数器函数延迟 < 0.00001
基本时钟稳定性 < 0.01
时标计数器的可用分辨率 < 0.1
  1. 测量相对性能

优化后代码的运行时间与优化前代码的运行时间的比率被称为相对性能。相对性能有众多 优点,其一是它们抵消了系统可变性,因为两次测量受到的可变性影响是一样的。同时, 相对性能是一个百分比,比“多少毫秒”这种测量结果更加直观。

  1. 通过测量模块测试改善可重复性模块测试

模块测试,即使用预录入的输入数据进行的子系统测试,可以让分析运行或性能测量变得 具有可重复性。许多组织都有自己的模块测试扩展库,还可以为性能调优加入新的测试。 对于性能调优,有一个常见的担忧:“我的代码就像一个大的毛线球,而且我没有为其编 写任何测试用例。我必须在最新的输入数据或最新的数据库上测试它的性能,但这些数据 经常会发生变化。我得不到一致的或者可重复的测量结果。我应当怎么办呢?” 我没有任何办法来解决这种问题。如果我用一组可重复的 Mock 输入数据来测试模块或是 子系统,那么在这些测试中反映出来的性能改善效果通常适用于最新的测试数据。如果我 通过一次大型但不可重复的测试找到了热点函数,那么通过模块测试用例去改善这些热点 函数,所得到的性能提升效果通常也适用于最新的测试数据。每位开发人员都知道为什么 他们应当构建由松耦合的模块组合而成的软件系统;每位开发人员都知道为什么他们应当 维护优秀的测试用例库。优化只是应当这样做的又一个理由。

  1. 根据指标优化性能

开发人员仍然有一线希望可以基于不可预测的最新数据优化性能。这种方式就是不测量临 界响应时间等值,而是收集指标、代码统计数据(例如中间值和方差),或是响应时间的 指数平滑平均数。由于这些统计数字是从大量的独立事件中得到的,因此这些数字的持续 改善表明对代码的修改是成功的。

以下是在根据指标优化性能时可能遇到的一些问题。

  • 代码统计必须基于大量事件才有效。当执行“修改 / 测试 / 评估”这样的循环改善过程时, 与使用固定的输入数据进行直接测量相比,根据指标优化性能更加耗费时间。
  • 相比于分析代码和测量运行时间,收集指标需要更完善的基础设施。通常都需要持久化 的存储设备来存放统计数据。而存储这些数据的时间开销非常大,会对性能产生影响。 收集指标的系统必须设计得足够灵活,可以支持多种实验。
  • 尽管有行之有效的方法去验证或是推翻基于统计的假设,但是这种方法需要开发人员能 够妥当地应对一些统计的复杂性。
  1. 通过取多次迭代的平均值来提高准确性

在实验中通过取多次测量的平均值可以提高单次测量的准确性。当开发人员在循环中测量函数 调用的时间,或是让程序处理那些会让它多次执行某个函数的输入数据时,就是在取平均值。

对一个函数调用进行多次迭代测量的一个优点是可以抵消随机变化性。这种情况下,高速 缓存的状态几乎会聚集于一个值,让我们可以在每次迭代测量的结果之间进行公平合理的 比较。经过一段足够长的时间间隔后,随机调度程序的行为对原函数和优化后函数的影响 是一样的。尽管同样的函数在另一个更大型程序中的绝对时间是不一样的,但是通过测量 相对性能仍然能够准确地反映出性能改善的程度。 另外一个优点是可以使用现成的但不精确的时标计数器。现在,计算机的处理速度已经足 够在 1 秒内处理数千次甚至数百万次迭代了。

  1. 通过提高优先级减少操作系统的非确定性行为

通过提高测量进程的优先级,可以减小操作系统使用 CPU 时间片段去执行测量程序以外 的处理的几率。在 Windows 上,可以通过调用 SetPriorityClass() 函数来设置进程的优先 级,而 SetThreadPriority() 函数则可以用来设置线程的优先级。下面这段代码提高了当 前进程和线程的优先级:

SetPriorityClass(GetCurrentProcess(), ABOVE_NORMAL_PRIORITY_CLASS);
SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_HIGHEST);

在测量结束后,通常应当将进程和线程恢复至正常优先级:

SetPriorityClass(GetCurrentProcess(), NORMAL_PRIORITY_CLASS);
SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_NORMAL);
  1. 非确定性发生了就克服它

我为性能优化而测量性能的方式是极度非正式的。其中没有深奥的统计学知识。我的测试 只运行几秒钟,而不是几小时。但我认为并不需要为这种非正式的方式感到愧疚。这些方 法可以将测量结果转换为开发人员可以理解的相对于程序整体运行时间的性能改善结果, 因此,我知道我一定是在正确的优化道路上前进。

如果我以两种不同的方式运行相同的测量实验,得到的结果的差异可能在 0.1% 至 1% 之 间。这毫无疑问与我的 PC 的初始状态不同有关。我没有办法控制这些状态,因此我并不担 心。如果差异比较大,我就会让测试程序运行得时间更长一些。由于这也会让我的测试 / 调 试周期变长,所以除非万不得已,否则我不会这么做。

即使我发现两次测量结果之间的差异达到了几个百分点,在单次测量中测量结果的相对变 化看起来仍然小于 1%。也就是说,通过在相同的测试中测量一个函数的两种变化,我甚至 能看出发生了相当微妙的变化。

我会尽量在一台没有播放视频、升级 Java 或是压缩大文件的“安静”的计算机上测量时 间。在测量过程中,我也会尽量不移动鼠标或是切换窗口。特别是当 PC 中只有一个处理 器时,这一点非常重要。但是当使用现代多核处理器时,我发现即使我忘记了上面这些注 意事项,测量结果也不会有什么大的变化。

如果在测量时间时调用了某个函数 10 000 次,这段代码和相关的数据会被存储在高速缓存 中。当为一个实时系统测量最差情况下的绝对时间时,这会有影响。但是现在我是在一个 内核本身就充满了非确定性的系统上测量相对时间。而且,我所测试的函数是我的分析器 指出的热点函数。因此,即使是当正式版本在运行时,它们也会被缓存于高速缓存中。这样,迭代测试就确确实实地重现了真实运行状态。

如果修改后的函数看起来快了 1%,那么通常不值得进行修改。根据阿姆达尔定律,该函数优化结果对整个程序的运行时间的贡献会变得微不足道。速度提高 10% 是临界值,而速 度提高 100% 则对缩短整个程序的运行时间有非常大的帮助。只进行有明显效果的性能改 善可以将开发人员从对方法论的担忧中解放出来。

3.4.4 创建stopwatch类

我会用一个 stopwatch 类来测量程序中部分代码的执行时间并分析代码。这个类的工作 方式非常像一块机械秒表。初始化秒表或是调用它的 start() 成员函数后,秒表将开始 计时;调用秒表的 stop() 成员函数或是销毁秒表类的实例后,秒表将停止计时并显示出计时结果。

编写一个 stopwatch 类并不难,网上也有很多现成的代码。代码清单 3-3 展示了我所使用 的 stopwatch 类。

代码清单 3-3 stopwatch 类

template <typename T> class basic_stopwatch : T {
 typedef typename T BaseTimer;
public:
 // 创建一个秒表,开始计时一项程序活动(可选)
 explicit basic_stopwatch(bool start);
 explicit basic_stopwatch(char const* activity = "Stopwatch",
 bool start=true);
 basic_stopwatch(std::ostream& log,
 char const* activity="Stopwatch",
 bool start=true);
 // 停止并销毁秒表
 ~basic_stopwatch();
 // 得到上一次计时时间(上一次停止时的时间)
 unsigned LapGet() const;
 // 判断:如果秒表正在运行,则返回true
 bool IsStarted() const;
 // 显示累计时间,一直运行,设置/返回上次计时时间
 unsigned Show(char const* event="show");
 // 启动(重启)秒表,设置/返回上次计时时间
 unsigned Start(char const* event_namee="start");
 // 停止正在计时的秒表,设置/返回上次计时时间
 unsigned Stop(char const* event_name="stop");
private: // 成员变量
 char const* m_activity; // "activity"字符串
 unsigned m_lap; // 上次计时时间(上一次停止时的时间)
 std::ostream& m_log; // 用于记录事件的流
};

这段代码只是重新定义了类。为了使性能最优化,成员函数将会被内联展开。

stopwatch 的类型模板参数 T 的值的类是一个更加简单的计时器,它提供了依赖于操作系 统和 C++ 标准的函数去访问时标计数器。我编写过多个版本的 TimerBase 类,去测试各 种不同的时标计数器的实现方式。在有些现代 C++ 处理器上,T 的值的类可以使用 C++ 库,或是可以直接从操作系统中得到时标。 代码清单 3-4 展示的 TimerBase 类使 用了在 C++11 及之后的版本中提供的 C++ 库。

代码清单 3-4 使用了 的 TimerBase 类

# include <chrono>
using namespace std::chrono;
class TimerBase {
public:
 // 清除计时器
 TimerBase() : m_start(system_clock::time_point::min()) { }
 // 清除计时器
 void Clear() {
 m_start = system_clock::time_point::min();
 }
 // 如果计时器正在计时,则返回true
 bool IsStarted() const {
 return (m_start.time_since_epoch() != system_clock::duration(0));
 }
 // 启动计时器
 void Start() {
 m_start = system_clock::now();
 }
 // 得到自计时开始后的毫秒值
 unsigned long GetMs() {
 if (IsStarted()) {
 system_clock::duration diff;
 diff = system_clock::now() - m_start;
 return (unsigned)(duration_cast<milliseconds>(diff).count());
 }
 return 0;
 }
 private:
 system_clock::time_point m_start;
};

这种实现方式的优点是在不同操作系统之间具有可移植性,但是它需要用到 C++11。

代码清单 3-5 中的 TimerBase 类与这个类的功能相同,不过其中使用的是在 Windows 上和 Linux 上都可以使用的 clock() 函数。

代码清单 3-5 使用了 clock() 的 TimerBase 类

class TimerBaseClock {
public:
46 | 第 3 章
 // 清除计时器
 TimerBaseClock() { m_start = -1; }
 // 清除计时器
 void Clear() { m_start = -1; }
 // 如果计时器正在计时,则返回true
 bool IsStarted() const { return (m_start != -1); }
 // 启动计时器
 void Start() { m_start = clock(); }
 // 得到自计时开始后的毫秒值
 unsigned long GetMs() {
 clock_t now;
 if (IsStarted()) {
 now = clock();
 clock_t dt = (now - m_start);
 return (unsigned long)(dt * 1000 / CLOCKS_PER_SEC);
 }
 return 0;
 }
private:
 clock_t m_start;
};

这种实现方式的优点是在不同 C++ 版本和不同操作系统之间具有可移植性,缺点是在 Linux 上和 Windows 上,clock() 函数的测量结果略有不同。

代码清单 3-6 中的 TimerBase 类可以工作于旧版本的 Windows 上和 Linux 上。如果是在 Windows 上,那么还必须显式地提供 gettimeofday() 函数,因为它既不属于 Windows API,也不属于 C 标准库。

代码清单 3-6 使用了 gettimeofday() 的 TimerBase

# include <chrono>
using namespace std::chrono;
class TimerBaseChrono {
public:
 // 清除计时器
 TimerBaseChrono() :
 m_start(system_clock::time_point::min()) {
 }
 // 清除计时器
 void Clear() {
 m_start = system_clock::time_point::min();
 }
 // 如果计时器正在计时,则返回true
 bool IsStarted() const {
 return (m_start != system_clock::time_point::min());
 }
 // 启动计时器
 void Start() {
 m_start = std::chrono::system_clock::now();
 }
 // 得到自计时开始后的毫秒值
 unsigned long GetMs() {
 if (IsStarted()) {
 system_clock::duration diff;
 diff = system_clock::now() - m_start;
 return (unsigned)
 (duration_cast<milliseconds>(diff).count());
 }
 return 0;
 }
private:
 std::chrono::system_clock::time_point m_start;
};
这种实现方式在不同的 C++ 版本和不同操作系统之间具有可移植性。但当运行于 Windows 上时,需要实现 gettimeofday() 函数。

stopwatch 类的最简单的用法用到了 RAII(Resource Acquisition Is Initialization,资源获取 就是初始化 5 )惯用法。程序会在由大括号包围的语句块中的开头处初始化 stopwatch 类, stopwatch 类的默认操作是开始计时。当 stopwatch 在语句块结束前被析构时,它会输出最 终计时结果。程序可以在执行过程中通过调用 stopwatch 类的 show() 成员函数输出中间计 时结果。这样,开发人员就可以只使用一个计时器来分析几个互相联系的代码块。例如:

{
 Stopwatch sw("activity");
 DoActivity();
}

这段代码将会在标准输出中打印出以下结果 :

activity: start
activity: stop 1234mS

stopwatch 在运行时不会产生间接开销。开始计时和停止计时的延迟包括了获取当前时间 的系统调用的开销,如果调用了 show() 成员函数输出计时结果,那么还要加上产生输出的 开销。如果是测试那些需要花费数十毫秒或者更长时间的任务,那么这个延迟可以忽略。 但是如果开发人员试图测试微秒级别的活动的时间,那么间接开销的比重将会显著增大, 测量结果的准确度也因此会降低。

测量运行时间的最大缺点可能是需要直觉和经验去解释这些结果。在通过多次测量缩小了 查找热点代码的范围后,开发人员必须接着检查代码或者进行实验找出和移除热点代码。 检查代码时需要依靠开发人员自己的经验或是本书中概述的启发式规则。这些规则的优点 是可以帮助你找出那些长时间运行的代码,缺点则是无法明确地指出最热点的代码。

3.4.5 使用测试套件测量热点函数

一旦通过分析器或是运行时分析找出了一个候选的待优化函数,一种简单的改善它的方法 是构建一个测试套件,在其中多次调用该函数。这样可以将该函数的运行时间增大为一 个可测量的值,同时还可以抵消因后台任务、上下文切换等对运行时间造成的影响。采 用“修改 - 编译 - 运行”的迭代方式去独立地测量一个函数,会比采用“修改 - 编译 - 运 行”,然后运行分析器并解析它的输出更快。本书中的许多例子都会使用这种技巧。 这个计时测试套件(代码清单 3-7)只是先调用了 stopwatch,然后循环中调用了 10000 次 需要被测量的函数。

代码清单 3-7 计时测试套件

typedef unsigned counter_t;
counter_t const iterations = 10000;
 ...
{
 Stopwatch sw("function_to_be_timed()");
 for (counter_t i = 0; i < iterations; ++i) {
 result = function_to_be_timed();
 }
}

迭代次数需要凭经验估计。如果 stopwatch 使用的时标计数器的有效分辨率是大约 10 毫 秒,那么测试套件在桌面处理器上的运行时间应当在几百到几千毫秒。

这里,我使用了 counter_t 来替代 unsignedunsigned long,这是因为对于一些非常短 小的函数,该变量的类型可能需要是 64 位 unsigned long long。相比于回过头来重新修改 所有类型名称,我更习惯于使用 typedef。这是对优化过程自身的一种优化。

最外层的一组大括号非常重要,因为它定义了 sw(也就是 Stopwatch 类的实例)的存在范 围。由于 stopwatch 使用了 RAII 惯用法,sw 的构造函数会得到第一次时标计数值,而它 的析构函数则会得到最后一次时标计数值并将结果放入到标准输出流中。

3.5 评估代码开销来找出热点代码

经验告诉我分析代码和测量运行时间是帮助找出需要优化的代码的两种有效方法。分析器 会指出某个函数被频繁地调用了或是在程序总运行时间中所占的比率很大。但它不太可能 指出某个具体的 C++ 语句可以优化。分析代码的成本也可能是非常高的。测量时间也可能会表明一大段代码很 慢,但不会指出其中存在的具体问题。

开发人员下一步需要做的是,对指出的代码块中的每条语句的开销进行评估。这一步就像 是证明一条定理一样,并不需要太精确。大多数情况下,只需大致观察一下这些语句就能得到它们的开销,然后从中找出性能开销大的语句和语法结构。

3.5.1 评估独立的C++语句的开销

正如 2.2.1 节中所讲述的,访问内存的时间开销远比执行其他指令的开销大。在烤箱和咖 啡机所使用的简单微处理器中,执行一条指令所花费的时间大致包含从内存中读取指令的 每个字节所需要的时间,加上读取指令的输入数据所需的时间,再加上写指令结果的时 间。相比之下,隐藏于内存访问时间之下的解码和执行指令的时间就显得微不足道了。

在桌面级微处理器上,情况就更加复杂了。许多处于不同阶段的指令会被同时执行。读取 指令流的开销可以忽略。不过,访问指令所操作的数据的开销则无法忽略。正是由于这个 原因,读写数据的开销可以近似地看作所有级别的微处理器上的执行指令的相对开销。

有一条有效的规则能够帮助我们评估一条 C++ 语句的开销有多大,那就是计算该语句对 内存的读写次数。例如,有一条语句 a = b + c;,其中 a、b 和 c 都是整数,b 和 c 的值 必须从内存中读取,而且它们的和必须写入至内存中的位置 a。因此,这条语句的开销是 三次内存访问。这个次数不依赖于微处理器的指令集。这是语句不可避免的、必然会发生 的开销。

再比如,r = *p + a[i]; 这条语句访问内存的次数如下:一次访问用于读取 i,一次读取 a[i],一次读取 p,一次读取 *p 所指向的数据,一次将结果写入至 r。也就是说 , 总共进 行了 5 次访问。7.2.1 节中讲解了函数调用访问内存的开销。

理解这是一条启发式规则是非常重要的。在实际的硬件中,获取执行语句的指令会发生额 外的内存访问。不过,由于这些访问是顺序的,所以它们可能非常高效。而且这些额外的 开销与访问数据的开销是成比例的。编译器可能会在优化时通过复用之前的计算或是发挥 代码静态分析的优势来省略一些内存访问。单位时间内的开销也取决于 C++ 语句要访问的 内容是否在高速缓存中。

但是其他因素是等价的,有影响的是访问语句要用到的数据需要多少次读写内存。这条启 发式规则并不完美,但这是所有你能做的了,除非你想去查看编译器输出的冗长无味、收 效甚微的汇编代码。

3.5.2 评估循环的开销

由于每条 C++ 语句都只会进行几次内存访问,通常情况下热点代码都不会是一条单独的 语句,除非受其他因素的作用,让其频繁地执行。这些因素之一就是该语句出现在了循环 中。这样,合计开销就是该语句的开销乘以该语句被执行的次数了。

如果你很幸运,可能会偶然找到这样的代码。分析器可能会指出一条单独的语句被执行了 100 万次,或者其他的热点函数包含以下这样的循环:

for (int i=1; i<1000000; ++i) {
 do_something_expensive();
 if (mostly_true) {
 do_more_stuff();
 even_more();
 }
}

这个循环中的语句很明显会被执行 100 万次,因此它是热点语句。看起来你需要花点精力 去优化。

  1. 评估嵌套循环中的循环次数

当一个循环被嵌套在另一个循环里面的时候,代码块的循环次数是内层循环的次数乘以外 层循环的次数。例如:

for (int i=0; i<100; ++i) {
 for (int j=0; j<50; ++j) {
 fiddle(a[i][j]);
 }
}

在这里,代码块的循环次数是 100*50=5000。

上面的代码块非常直接。但是实际上这里可能有无数种变化。例如,当进行数学运算时, 在有些重要的情况下会对三角矩阵进行循环计算。而且有时候,代码编写得非常糟糕,需要花费很大气力才能看清嵌套循环的轮廓。

嵌套循环可能并非一眼就能看出来。如果一个循环调用了一个函数,而这个函数中又包含 了另外一个循环,那么内层循环就是嵌套循环。正如我们稍后会在 7.1.8 节中看到的,有 时在外层循环中重复地调用函数的开销也是可以消除的。

内存循环可能被嵌入在标准库函数中,特别是处理字符串或字符的 I/O 函数。如果这些 函数被重复调用的次数非常多,那么可能值得去重新实现标准函数库中的函数来回避调 用开销。

  1. 评估循环次数为变量的循环的开销

不是所有循环中的循环次数都是很明确的。许多循环处理会不断重复直至满足某个条件为 止,比如有些循环会重复地处理字符,直至找到空格为止;还有些循环则会重复地处理数 字,直到遇到非数字为止。这种循环的重复次数也是可以估算出来的。当然,只需要大致 地估算一下即可,例如每个数字的平均位数是 5,或是每个单词的平均字母数是 6。估算 的目的是找出可能需要优化的代码。

  1. 识别出隐式循环

响应事件的程序(例如 Windows UI 程序)在最外层都会有一个隐式循环。这个循环甚至 在程序中是看不到的,因为它被隐藏在了框架中。如果这个框架以最大速率接收事件的 话,那么每当事件处理器取得程序控制权,或是在事件分发前,抑或是在事件分发过程中都会被执行的代码,以及最频繁地被分发的事件中的代码都可能是热点代码。

  1. 识别假循环

不是所有的 while 或者 do 语句都是循环语句。我就曾经遇到过使用 do 语句帮助控制流程 的代码。下面这段示例代码还有更好的实现方式,不过使用了更复杂的 if-then-else 逻 辑的话,这种惯用法就有其用武之地了。下面这个“循环”只会被执行一次。当它遇到 while(0) 后就会退出:

do {
 if (!operation1())
 break;
 if (!operation2(x,y,z))
 break;
} while(0);

这种惯用法也时常被用于将几条语句“打包”为 C 风格的宏。

3.6 其他找出热点代码的方法

如果开发人员熟悉需要优化的代码,可以选择仅凭直觉去推测影响程序整体运行时间的代 码块在哪里,然后做实验去验证对这些代码的修改是否可以提高程序整体性能。 我不建议选择这种方法,除非整个项目只有你一个人。通过使用分析器或是计时器分析代 码,开发人员可以向同事和经理展示他们在性能优化工作中取得的进展。如果你仅凭直觉 进行优化,也不发表结果,有时甚至即使你发表了结果,团队成员也会质疑你的方法,使 你无法专心于你的工作。他们也应该如此。这是因为他们分不清你到底是在是用你高度专 业的直觉进行优化还是只是在碰运气。

转载请注明:xuhss » C++ 性能优化篇三《测量性能》

喜欢 (1)

您必须 登录 才能发表评论!