编写高效程序的艺术(一)
原文:
zh.annas-archive.org/md5/D1378BAD9BFB33A3CD435A706973BFFF译者:飞龙
前言
高性能编程艺术正在复苏。我开始编程的时候,程序员必须知道每一位数据的去向(有时确实如此——通过前面板上的开关)。现在,计算机已经具有足够的能力来完成日常任务。当然,总是有一些领域永远不够计算能力。但大多数程序员可以写出效率低下的代码。顺便说一句,这并不是一件坏事:摆脱了性能约束,程序员可以专注于以其他方式改进代码。
因此,这本书首先解释的是为什么越来越多的程序员被迫再次关注性能和效率。这将为整本书设定基调,因为它定义了我们在后续章节中将使用的方法论:关于性能的知识最终必须来自测量,并且每个与性能相关的声明都必须有数据支持。
有五个组成部分,五个元素共同决定了程序的性能。首先,我们深入探讨细节,探索一切性能的低级基础:我们的计算硬件(没有开关——承诺,那些日子已经过去了)。从个别组件——处理器和内存——我们逐步过渡到多处理器计算系统。在这一过程中,我们了解了内存模型、数据共享的成本,甚至无锁编程。
高性能编程的第二个组成部分是对编程语言的有效使用。在这一点上,本书变得更加具体于 C++(其他语言有它们自己的喜爱的低效性)。紧随其后的是第三个元素,即帮助编译器改进程序性能的技能。
第四个组成部分是设计。可以说,这应该是第一个:如果设计没有将性能作为明确目标之一,几乎不可能事后再添加良好的性能。然而,我们最后学习设计性能,因为这是一个高层概念,它汇集了我们之前所学到的所有知识。
高性能编程的最终第五要素是你,读者。你的知识和技能最终将决定结果。为了帮助你学习,本书包含许多示例,可用于实践探索和自学。学习在你翻过最后一页后并不需要停止。
这本书是为谁而写的?
这本书适用于有经验的开发人员和程序员,他们在性能关键项目上工作,并希望学习改进其代码性能的不同技术。属于计算机建模、算法交易、游戏、生物信息学、基于物理的模拟、计算机辅助设计、计算基因组学或计算流体动力学社区的程序员可以从本书中学习各种技术,并将其应用于他们的工作领域。
尽管本书使用 C++语言,但书中演示的概念可以轻松转移或应用于其他编译语言,如 C、C#、Java、Rust、Go 等。
这本书涵盖了什么
第一章,性能和并发性简介,讨论了我们关心程序性能的原因,特别是关于为什么良好性能不是自然而然发生的原因。我们了解到,为了实现最佳性能,甚至是足够的性能,重要的是了解影响性能的不同因素以及程序特定行为的原因,无论是快速还是慢速执行。
第二章《性能测量》是关于测量的。性能通常是非直观的,所有涉及效率的决策,从设计选择到优化,都应该由可靠的数据来指导。本章描述了不同类型的性能测量,解释了它们的区别以及何时应该使用它们,并教授了如何在不同情况下正确地测量性能。
第三章《CPU 架构、资源和性能影响》帮助我们开始研究硬件以及如何有效地使用它以实现最佳性能。本章致力于学习 CPU 资源和能力,以及最佳的使用方式,未能充分利用 CPU 资源的更常见原因,以及如何解决这些问题。
第四章《内存架构和性能》帮助我们了解现代内存架构,它们固有的弱点以及对抗或至少隐藏这些弱点的方法。对于许多程序来说,性能完全取决于程序员是否利用了旨在提高内存性能的硬件功能,本章教授了必要的技能来做到这一点。
第五章《线程、内存和并发》帮助我们继续研究内存系统及其对性能的影响,但现在我们将研究扩展到多核系统和多线程程序的领域。事实证明,内存,已经是性能的“长杆”,在添加并发时会更加成为问题。虽然硬件施加的基本限制无法克服,但大多数程序甚至远未达到这些限制,熟练的程序员有很大的空间来提高他们代码的效率;本章为读者提供了必要的知识和工具来做到这一点。
第六章《并发和性能》帮助您了解开发高性能并发算法和数据结构以用于线程安全程序。一方面,为了充分利用并发,我们必须对问题和解决方案策略进行高层次的考虑:数据组织、工作分区,有时甚至解决方案的定义都会对程序的性能产生重大影响。另一方面,正如我们在上一章中所看到的,性能受到低级因素的极大影响,比如数据在缓存中的排列,甚至最佳设计也可能被糟糕的实现所破坏。
第七章《并发数据结构》解释了并发程序中数据结构的性质,以及当数据结构在多线程上下文中使用时,“栈”和“队列”等熟悉的数据结构的含义会有所不同。
第八章《C++中的并发》描述了最近在 C++17 和 C++20 标准中添加的并发编程功能。虽然现在谈论使用这些功能实现最佳性能的最佳实践还为时过早,但我们可以描述它们的功能,以及当前编译器支持的情况。
第九章《高性能 C++》将我们的注意力从硬件资源的最佳利用转移到了特定编程语言的最佳应用。虽然我们迄今为止学到的一切都可以应用于任何语言的任何程序,通常都很简单明了,但本章涉及了 C++的特性和怪癖。读者将了解 C++语言的哪些特性可能会导致性能问题,以及如何避免这些问题。本章还将涵盖非常重要的编译器优化问题,以及程序员如何帮助编译器生成更高效的代码。
第十章《C++编译器优化》涵盖了编译器优化以及程序员如何帮助编译器生成更高效的代码。
第十一章《未定义行为和性能》有双重重点。一方面,它解释了程序员在试图从其代码中挤取最大性能时经常忽视的未定义行为的危险。另一方面,它解释了我们如何利用未定义行为来提高性能,以及如何正确指定和记录这种情况。总的来说,与通常的“任何事情都可能发生”相比,本章提供了一种更为常见但更相关的理解未定义行为的方式。
第十二章《性能设计》回顾了本书中学到的所有与性能相关的因素和特性,并探讨了我们所获得的知识和理解应该如何影响我们在开发新软件系统或重新架构现有系统时所做的设计决策。
要充分利用本书
除了特定于 C++效率的章节外,本书不依赖于任何神秘的 C++知识。所有示例都是用 C++编写的,但关于硬件性能、高效数据结构和性能设计的教训适用于任何编程语言。要跟随这些示例,您至少需要具备中级的 C++知识。
每一章都提到了编译和执行示例所需的额外软件(如果有的话)。在大多数情况下,任何现代 C++编译器都可以与这些示例一起使用,除了第八章《C++并发》,它需要最新版本才能通过协程部分工作。
如果您使用的是本书的数字版本,我们建议您自己输入代码,或者从书的 GitHub 存储库中访问代码(下一节中提供了链接)。这样做将帮助您避免与复制和粘贴代码相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 上下载本书的示例代码文件,链接为github.com/PacktPublishing/The-Art-of-Writing-Efficient-Programs。如果代码有更新,将在 GitHub 存储库中进行更新。
我们还提供了来自我们丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。快去看看吧!
下载彩色图像
我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图和图表的彩色图像。您可以在这里下载:static.packt-cdn.com/downloads/9781800208117_ColorImages.pdf。
使用的约定
本书中使用了许多文本约定。
文本中的代码:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。例如:"值得注意的是一个新功能,允许可移植地确定 L1 缓存的缓存行大小,std::hardware_destructive_interference_size 和 std::hardware_constructive_interference_size。"
代码块设置如下:
std::vector<double> v;
… add data to v …
std::for_each(v.begin(), v.end(),[](double& x){ ++x; });
任何命令行输入或输出都是这样写的:
Main thread: 140003570591552
Coroutine started on thread: 140003570591552
Main thread done: 140003570591552
Coroutine resumed on thread: 140003570587392
Coroutine done on thread: 140003570587392
粗体:表示一个新术语、一个重要词或者屏幕上看到的词。例如,菜单或对话框中的词以粗体显示。例如:"当CPU1看到由CPU0执行的带释放内存顺序的原子写操作的结果时,可以保证CPU1看到的内存状态已经反映了在这个原子操作之前由CPU0执行的所有操作。"
提示或重要说明
像这样出现。
第一部分:性能基础
在本节中,您将学习关于研究程序性能的方法论,该方法论基于测量、基准测试和分析。您还将学习确定每个计算系统性能的主要硬件组件:处理器、内存及它们的交互。
本节包括以下章节:
-
第一章,性能和并发简介
-
第二章,性能测量
-
第三章,CPU 架构、资源和性能影响
-
第四章,内存架构和性能
-
第五章,线程、内存和并发
第一章:性能和并发简介
动机是学习的关键因素;因此,您必须了解为什么在计算机技术取得了所有进步的情况下,程序员仍然必须努力使其代码获得足够的性能,以及成功需要深刻理解计算硬件、编程语言和编译器能力。本章的目的是解释为什么今天仍然需要这种理解。
本章讨论了我们关心程序性能的原因,特别是关于良好性能并非“自然而然”发生的原因。我们将了解为什么为了实现最佳性能,有时甚至是足够的性能,重要的是要了解影响性能的不同因素,以及程序特定行为的原因,无论是快速执行还是缓慢执行。
在本章中,我们将涵盖以下主要主题:
-
性能为什么重要
-
为什么性能需要程序员的注意?
-
性能是什么意思?
-
如何评估性能
-
学习高性能
为什么要关注性能?
在计算机早期,编程是困难的。处理器速度慢,内存有限,编译器原始,没有付出重大努力就无法取得任何成就。程序员必须了解 CPU 的架构,内存的布局,当编译器无法胜任时,关键代码必须用汇编语言编写。
然后情况变得好转。处理器每年都在变得更快,曾经是巨大硬盘容量的数字变成了普通 PC 主存储器的大小,编译器编写者学会了一些技巧来加快程序速度。程序员可以花更多时间解决问题。这反映在编程语言和设计风格上:在更高级的语言和不断发展的设计和编程实践之间,程序员的关注重点从代码中想要表达的内容转移到了如何表达这些内容。
以前的常识,比如 CPU 有多少寄存器以及它们的名称是什么,变得神秘而深奥。曾经,“大型代码库”是指需要用双手才能搬动的卡片组;现在,“大型代码库”是指超出版本控制系统容量的代码库。以前几乎不需要为特定处理器或内存系统编写专门的代码,可移植代码成为了常态。
至于汇编语言,实际上很难超越编译器生成的代码,这对大多数程序员来说是难以企及的任务。对于许多应用程序及其编写者来说,已经有了“足够的性能”,程序员职业的其他方面变得更加重要(明确地说,程序员可以专注于代码的可读性,而不必担心添加一个有意义名称的函数是否会使程序变得无法接受地慢)。
然后,突然间,“性能自行解决”的免费午餐结束了。看似不可阻挡的计算能力不断增长的进展突然停止了。
图 1.1 - 绘制 35 年微处理器演变历程(参见 github.com/karlrupp/mi… 和 github.com/karlrupp/mi…
大约在 2005 年左右,单个 CPU 的计算能力达到了饱和。在很大程度上,这与 CPU 频率直接相关,而 CPU 频率也停止增长。而频率受到多种因素的限制,其中之一是功耗(如果频率趋势保持不变,今天的 CPU 每平方毫米的功率将比将火箭送入太空的大型喷气发动机还要高)。
从前面的图表可以明显看出,不是所有的进展措施在 2005 年停滞不前:单芯片上的晶体管数量不断增加。那么,如果不是让芯片变得更快,他们在做什么呢?答案是双重的,其中一部分由底部曲线揭示:设计师不是让单个处理器变得更大,而是不得不将多个处理器核心放在同一块芯片上。当然,所有这些核心的计算能力随着核心数量的增加而增加,但前提是程序员知道如何使用它们。"伟大的晶体管之谜"的第二部分(所有的晶体管都去哪了?)是它们进入了处理器能力的各种非常先进的增强功能,这些增强功能可以用来提高性能,但同样,只有程序员努力利用它们。
我们刚刚看到的处理器进展的变化通常被认为是并发编程进入主流的原因。但这种变化甚至更加深刻。在本书中,您将了解到,为了获得最佳性能,程序员再次需要了解处理器和内存架构及其相互作用的复杂性。出色的性能不再是“自然而然”发生的。与此同时,我们在编写清晰表达需要完成的任务而不是如何完成的代码方面取得的进展不应该被撤销。我们仍然希望编写可读性强、易于维护的代码,而且(而且不是但是)我们也希望它高效。
可以肯定的是,对于许多应用程序来说,现代 CPU 仍然具有足够的性能,但性能比以前更受关注,这在很大程度上是因为我们刚刚讨论的 CPU 发展的变化,以及因为我们希望在更多的应用程序中进行更多的计算,这些应用程序并不一定能够获得最佳的计算资源(例如,今天的便携式医疗设备可能会内置完整的神经网络)。
幸运的是,我们不必通过在黑暗的存储室里翻阅腐烂的穿孔卡片堆来重新发现一些失落的性能艺术。任何时候,仍然存在着困难的问题,短语计算能力永远不够对许多程序员来说是真实的。随着计算能力的指数增长,对它的需求也在增加。极限性能的艺术在那些需要它的领域中得以保持。在这一点上,一个这样的领域的例子可能是有启发性和有启发性的。
为什么性能很重要
要找到一个关于性能关注从未真正减弱的领域的例子,让我们来研究使计算本身成为可能的计算的演变,即用于设计计算机本身的电子设计自动化(EDA)工具。
如果我们将 2010 年用于设计、模拟或验证特定微芯片的计算,并自那时起每年运行相同的工作负载,我们会看到类似于这样的情况:
图 1.2 - 某个 EDA 计算的处理时间(以小时为单位),随着年份的变化
2010 年需要 80 小时计算的工作,在 2018 年只需要不到 10 小时(甚至今天更少)。这种改进是从哪里来的?有几个来源:部分是计算机变得更快,但也有软件变得更有效率,发明了更好的算法,优化编译器变得更加有效。
不幸的是,我们在 2021 年并没有制造 2010 年版本的微芯片:可以说,随着计算机变得更加强大,制造更新和更好的微芯片变得更加困难。因此,更有趣的问题是,每年制造当年的新微芯片需要多长时间来完成相同的工作:
图 1.3 - 每年最新微芯片的特定设计步骤的运行时间(以小时为单位)
每年实际完成的计算并不相同,但它们都为同一个目的服务,例如验证芯片是否按预期运行,对于我们每年制造的最新和最好的芯片。从这张图表中我们可以看到,当前一代最强大的处理器,运行最好的可用工具,每年都需要大致相同的时间来设计和建模下一代处理器。我们保持着自己的位置,但并没有取得任何进展。
但事实甚至比这更糟,上面的图表并没有显示一切。从 2010 年到 2018 年,当年制造的最大处理器可以在一夜之间(大约 12 小时)得到验证,使用的是去年制造的最大处理器的计算机。但我们忘了问*有多少这样的处理器?*好吧,现在是完整的真相:
图 1.4 - 前一图表,标注了每次计算的 CPU 数量
每年,配备着不断增长数量的最新、最强大处理器的最强大计算机,运行着最新的软件版本(经过优化以利用越来越多的处理器并更有效地使用每一个处理器),完成了建造下一年最强大计算机所需的工作,而每年,这项任务都处于几乎不可能的边缘。我们没有掉下这个边缘,这在很大程度上是硬件和软件工程师的成就,前者提供了不断增长的计算能力,后者以最大效率使用它。本书将帮助您学习后者的技能。
我们现在理解了本书的主题的重要性。在我们深入细节之前,进行高层次的概述会有所帮助;可以说是对勘探活动将展开的领域的地图的审查。
性能是什么?
我们已经谈论了程序的性能;我们提到了高性能软件。但是当我们说这个词时,我们是什么意思呢?直观地,我们理解高性能程序比性能差的程序更快,但这并不意味着更快的程序总是具有好的性能(两个程序可能都性能差)。
我们也提到了高效的程序,但效率和高性能是一回事吗?虽然效率与性能相关,但并不完全相同。效率涉及最佳地使用资源而不浪费它们。高效的程序充分利用计算硬件。
一方面,高效的程序不会让可用资源空闲:如果有一个需要完成的计算和一个空闲的处理器,那么该处理器应该执行等待执行的代码。这个想法更深入:处理器内部有许多计算资源,高效的程序试图尽可能同时利用这些资源。另一方面,高效的程序不会浪费资源做不必要的工作:它不会执行不需要完成的计算,不会浪费内存来存储永远不会被使用的数据,不会发送不需要的数据到网络等等。简而言之,高效的程序不会让可用的硬件空闲,也不会做任何不必要的工作。
另一方面,性能总是与某些指标相关。最常见的是“速度”,或者程序有多快。更严格定义这个指标的方式是吞吐量,即程序在给定时间内执行的计算量。通常用于相同目的的反向指标是周转时间,或者计算特定结果需要多长时间。然而,这并不是性能的唯一可能定义。
作为吞吐量的性能
让我们考虑四个使用不同实现来计算相同结果的程序。这是所有四个程序的运行时间(单位是相对的;实际数字并不重要,因为我们关心的是相对性能):
图 1.5 - 相同算法的四种不同实现的运行时间(相对单位)
显然,程序 B 具有最高的性能:它在其他三个程序之前完成了,用了一半的时间来计算与最慢程序相同的结果。在许多情况下,这将是我们选择最佳实现所需的所有数据。
但问题的上下文很重要,我们忽略了该程序是在手机等电池供电设备上运行,功耗也很重要。
性能作为功耗
这是四个程序在计算过程中消耗的功率:
图 1.6 - 相同算法的四种不同实现的功耗(相对单位)
尽管花费更长时间来获得结果,程序 C 总体上消耗的功率更少。那么,哪个程序性能最好呢?
同样,这是一个诡计问题,如果不知道完整的上下文。该程序不仅在移动设备上运行,而且执行实时计算:它用于音频处理。这应该更注重实时更快地获得结果,对吗?并不完全是这样。
实时应用的性能
实时程序必须始终跟上它正在处理的事件。音频处理器必须特别跟上语音。如果程序可以比人说话的速度快十倍处理音频,那对我们毫无用处,我们可能还不如把注意力转向功耗。
另一方面,如果程序偶尔落后,一些声音甚至单词将被丢弃。这表明实时或速度在一定程度上很重要,但必须以可预测的方式交付。
当然,这也有一个性能指标:延迟尾部。延迟是在我们的情况下数据准备好(录音)和处理完成之间的延迟。我们之前看到的吞吐量指标反映了处理声音的平均时间:如果我们在手机上说话一个小时,音频处理器需要多长时间来完成所有需要做的计算?但在这种情况下真正重要的是,每个声音的每个小计算都按时完成。
在低级别上,计算速度会波动:有时计算会更快完成,有时会花费更长时间。只要平均速度可接受,重要的是罕见的长时间延迟。
延迟尾部指标是计算作为延迟的特定百分位数的,例如,在 95th 百分位数:如果t是 95th 百分位数的延迟,那么 95%的所有计算所花费的时间都比t少。指标本身是 95th 百分位时间t与平均计算时间t0 的比率(通常也以百分比表示,因此 95th 百分位数的 30%延迟意味着t比t0 大 30%):
图 1.7 - 相同算法的四种不同实现的 95%延迟(百分比)
我们现在看到,程序 B比任何其他实现平均计算结果更快,但也提供了最不可预测的运行时间结果,而程序 D以前从未突出,却像钟表一样计算,并且每次执行给定的计算几乎需要相同的时间。正如我们已经观察到的,程序 D 还具有最糟糕的功耗。不幸的是,这并不罕见,因为使程序在平均情况下更节能的技术通常具有概率性质:它们大多数时候加快计算速度,但并非每次都是如此。
那么,哪个程序最好?当然,答案取决于应用,甚至在这种情况下可能并不明显。
性能取决于上下文
如果这是在大型数据中心运行并需要数天来计算的仿真软件,吞吐量将是关键。在电池供电设备上,功耗通常是最重要的。在更复杂的环境中,比如我们的实时音频处理器,它是多个因素的组合。平均运行时间当然很重要,但只有在变得“足够快”之前才重要。如果听众察觉不到延迟,那么使其更快也没有奖励。延迟尾部很重要:用户讨厌每隔一段时间会有一个词从对话中丢失。一旦延迟足够好,通话质量受到其他因素的限制,进一步改善将带来很少的好处;在这一点上,我们最好节约功耗。
我们现在明白,与效率不同,性能总是针对特定的度量标准定义的,这些度量标准取决于我们正在解决的应用和问题,对于某些度量标准来说,存在“足够好”的概念,当其他度量标准成为前景时。效率,反映了计算资源的利用,是实现良好性能的方式之一,也许是最常见的方式,但不是唯一的方式。
评估、估计和预测性能
正如我们刚刚看到的,度量的概念对性能概念至关重要。有了度量,总是隐含着测量的可能性和必要性:如果我们说“我们有一个度量”,那就意味着我们有一种量化和测量某事的方法,而了解度量的值的唯一方法就是测量它。
测量性能的重要性不言而喻。人们常说,性能的第一定律是永远不要猜测性能。本书的下一章专门讨论性能测量、测量工具、如何使用它们以及如何解释结果。
不幸的是,对性能的猜测太过普遍。像“避免在 C++中使用虚函数,它们很慢”这样过于笼统的陈述也是如此。这类陈述的问题不在于它们不精确,即它们没有提及虚函数相对于非虚函数慢多少的度量标准。作为读者的练习,这里有几个可供选择的量化答案:
-
虚函数慢 100%
-
虚函数大约慢 15-20%
-
虚函数几乎没有慢
-
虚函数快 10-20%
-
虚函数慢 100 倍
哪个答案是正确的?如果您选择了其中任何一个答案,恭喜您:您选择了正确答案。没错,每个答案在特定情况和特定上下文中都是正确的(要了解原因,您将不得不等到第九章,高性能 C++)。
不幸的是,通过接受几乎不可能直觉或猜测性能的真相,我们面临着另一个陷阱:将其作为写出效率低下的代码的借口“以后进行优化”的借口,因为我们不猜测性能。虽然这是真的,但后一种最大化可能会走得太远,就像流行的格言不要过早优化一样。
性能不能后期添加到程序中,因此在初始设计和开发过程中不应该被忽视。性能考虑和目标在设计阶段有其位置,就像其他设计目标一样。早期与性能相关的目标与永远不要猜测性能之间存在明显的紧张关系。我们必须找到正确的折衷方案,描述我们在设计阶段真正想要实现的关于性能的目标的一个好方法是:虽然几乎不可能预测最佳的优化,但可以确定会使后续优化非常困难甚至不可行的设计决策。
同样的情况也适用于程序开发过程中:在优化一个每天只调用一次且只需要一秒钟的函数上花费很长时间是愚蠢的。另一方面,最好一开始就将这段代码封装成一个函数,这样如果程序发展时使用模式发生变化,它可以在不重写程序的情况下进行优化。
描述“不要过早优化”的规则的限制的另一种方法是通过说“是的,但也不要故意使性能变差”。识别两者之间的区别需要对良好设计实践的了解,以及对高性能编程的不同方面的理解。
那么,作为开发人员/程序员,为了精通开发高性能应用程序,您需要学习和了解什么?在下一节中,我们将从一个简略的目标列表开始,然后详细讨论每个目标。
学习高性能
什么使程序高性能?我们可以说“效率”,但首先,这并不总是正确的(尽管通常是),其次,这只是在回避问题,因为下一个明显的问题是,好吧,什么使程序高效?我们需要学习什么才能编写高效或高性能的程序?让我们列出所需的技能和知识:
-
选择正确的算法
-
有效地利用 CPU 资源
-
有效地使用内存
-
避免不必要的计算
-
有效地使用并发和多线程
-
有效地使用编程语言,避免低效率
-
衡量性能和解释结果
实现高性能最重要的因素是选择一个好的算法。不能通过优化实现来“修复”一个糟糕的算法。然而,这也是本书范围之外的因素。算法是特定于问题的,这不是一本关于算法的书。您将不得不进行自己的研究,以找到最适合您所面临问题的最佳算法。
另一方面,实现高性能的方法和技术在很大程度上与问题无关。当然,它们确实取决于性能指标:例如,实时系统的优化是一个具有许多特殊问题的高度特定领域。在本书中,我们主要关注高性能计算意义上的性能指标:尽快进行大量计算。
为了在这个探索中取得成功,我们必须尽可能多地利用可用的计算硬件。这个目标有一个空间和时间的组成部分:在空间方面,我们谈论的是利用处理器中如此庞大数量的晶体管。处理器变得更大,如果不是更快。额外的区域用于什么?可能是增加了一些新的计算能力,我们可以利用。在时间方面,我们的意思是我们应该尽可能多地利用每个时间的硬件。无论如何,如果计算资源处于空闲状态,对我们来说是没有用的,所以目标是避免这种情况。与此同时,繁重的工作并不划算,我们希望避免做任何我们绝对不需要做的事情。这并不像听起来那么明显;你的程序可能以很多微妙的方式进行计算,而这些计算是你不需要的。
在这本书中,我们将从单个处理器开始,学会高效地利用其计算资源。然后,我们将扩大视野,不仅包括处理器,还包括其内存。然后,自然地,我们将研究如何同时使用多个处理器。
但是,高性能程序的必要品质之一是高效地使用硬件:高效地完成本来可以避免的工作对我们没有好处。不创造不必要的工作的关键是有效地使用编程语言,对我们来说是 C++(我们学到的大部分关于硬件的知识都可以应用到任何语言,但一些语言优化技术非常特定于 C++)。此外,编译器位于我们编写的语言和我们使用的硬件之间,因此我们必须学会如何使用编译器来生成最有效的代码。
最后,衡量我们刚才列出的任何目标的成功程度的唯一方法是对其进行测量:我们使用了多少 CPU 资源?我们花了多少时间等待内存?增加另一个线程带来了多少性能提升?等等。获得良好的定量性能数据并不容易;这需要对测量工具有深入的了解。解释结果通常更加困难。
你可以从这本书中学到这些技能。我们将学习硬件架构,以及一些编程语言特性背后的隐藏内容,以及如何像编译器一样看待我们的代码。这些技能很重要,但更重要的是理解为什么事情会以这样的方式运作。计算硬件经常发生变化,语言不断发展,编译器的新优化算法也在不断发明。因此,任何这些领域的具体知识都有相当短的保质期。然而,如果你不仅理解了使用特定处理器或编译器的最佳方法,还理解了我们得出这些知识的方式,你将能够很好地准备重复这个发现过程,因此继续学习。
总结
在这个介绍性的章节中,我们讨论了为什么尽管现代计算机的原始计算能力迅速增长,但对软件性能和效率的兴趣却在上升。具体来说,我们了解了为什么为了理解限制性能的因素以及如何克服它们,我们需要回到计算的基本元素,并了解计算机和程序在低级别上的工作方式:理解硬件并高效地使用它,理解并发性,理解 C++语言特性和编译器优化,以及它们对性能的影响。
这种低级知识必然非常详细和具体,但我们有一个处理这个问题的计划:当我们学习处理器或编译器的具体事实时,我们也会学习到我们得出这些结论的过程。因此,从最深层次来看,这本书是关于学习如何学习的。
我们进一步了解到,如果不定义衡量绩效的指标,绩效的概念就毫无意义。对特定指标评估绩效的需要意味着任何绩效工作都是由数据和测量驱动的。事实上,下一章将专门讨论绩效的测量。
问题
-
尽管处理能力有所提高,为什么程序绩效仍然重要?
-
为什么理解软件绩效需要对计算硬件和编程语言有低层次的了解?
-
绩效和效率之间有什么区别?
-
为什么绩效必须根据特定指标来定义?
-
我们如何判断特定指标的绩效目标是否已经实现?
第二章:性能测量
无论是编写新的高性能程序还是优化现有程序,您面临的第一个任务之一将是定义代码在当前状态下的性能。您的成功将取决于您能够提高其性能的程度。这两种陈述都意味着性能指标的存在,即可以进行测量和量化的东西。上一章最有趣的结果之一是发现甚至没有一个适用于所有需求的性能定义:在您想要量化性能时,您所测量的内容取决于您正在处理的问题的性质。
但测量远不止于简单地定义目标和确认成功。您性能优化的每一步,无论是现有代码还是您刚刚编写的新代码,都应该受到测量的指导和启发。
性能的第一条规则是永远不要猜测性能,并且值得在本章的第一部分致力于说服您牢记这条规则,不容置疑。在摧毁您对直觉的信任之后,我们必须给您提供其他东西来依靠:用于测量和了解性能的工具和方法。
在本章中,我们将涵盖以下主要主题:
-
为什么性能测量是必不可少的
-
为什么所有与性能相关的决策都必须由测量和数据驱动
-
如何测量真实程序的性能
-
什么是程序的基准测试、分析和微基准测试,以及如何使用它们来测量性能
技术要求
首先,您将需要一个 C++编译器。本章中的所有示例都是在 Linux 系统上使用 GCC 或 Clang 编译器编译的。所有主要的 Linux 发行版都将 GCC 作为常规安装的一部分;更新版本可能可以在发行版的存储库中找到。Clang 编译器可以通过 LLVM 项目llvm.org/获得,尽管一些 Linux 发行版也维护自己的存储库。在 Windows 上,Microsoft Visual Studio 是最常见的编译器,但 GCC 和 Clang 也可用。
其次,您将需要一个程序分析工具。在本章中,我们将使用 Linux 的"perf"分析器。同样,它已经安装(或可供安装)在大多数 Linux 发行版上。文档可以在perf.wiki.kernel.org/index.php/Main_Page找到。
我们还将演示另一个分析器的使用,即来自 Google 性能工具集(GperfTools)的 CPU 分析器,可以在github.com/gperftools/gperftools找到(同样,您的 Linux 发行版可能可以通过其存储库进行安装)。
还有许多其他可用的性能分析工具,包括免费和商业工具。它们都基本上提供相同的信息,但以不同的方式呈现,并具有许多不同的分析选项。通过本章的示例,您可以了解性能分析工具的预期和可能的限制;您使用的每个工具的具体情况都需要自己掌握。
最后,我们将使用一个微基准测试工具。在本章中,我们使用了在github.com/google/benchmark找到的 Google Benchmark 库。您很可能需要自己下载和安装它:即使它已经与您的 Linux 发行版一起安装,也可能已经过时。请按照网页上的安装说明进行操作。
安装了所有必要的工具后,我们准备进行我们的第一个性能测量实验。
本章的代码可以在此处找到:github.com/PacktPublishing/The-Art-of-Writing-Efficient-Programs/tree/master/Chapter02
通过示例进行性能测量
在本章的其余部分,我们将有时间更详细地了解每个性能分析工具,但在本节中,我们将进行一个快速的端到端示例,并分析一个简单程序的性能。这将向您展示典型的性能分析流程是什么样子,以及如何使用不同的工具。
还有一个隐藏的目的:在本节结束时,您将相信您不应该猜测性能。
您可能需要分析和优化的任何真实程序可能足够大,以至于在本书中需要很多页,因此我们将使用一个简化的例子。这个程序对一个非常长的字符串中的子字符串进行排序:假设我们有一个字符串S,比如"abcdcba"(这并不算很长;我们实际的字符串将有数百万个字符)。我们可以从这个字符串的任何字符开始得到一个子字符串,例如,子字符串S0从偏移 0 开始,因此其值为"abcdcba"。子字符串S2从偏移 2 开始,其值为"cdcba",而子字符串S5的值为"ba"。如果我们使用常规的字符串比较对这些子字符串按降序排序,子字符串的顺序将是S2,然后是S5,最后是S0(按照第一个字符'c','b'和'a'的顺序)。
我们可以使用 STL 排序算法std::sort对子字符串进行排序,如果我们用字符指针表示它们:现在交换两个子字符串只涉及交换指针,而基础字符串保持不变。以下是我们的示例程序:
bool compare(const char* s1, const char* s2, unsigned int l);
int main() {
constexpr unsigned int L = …, N = …;
unique_ptr<char[]> s(new char[L]);
vector<const char*> vs(N);
… prepare the string …
size_t count = 0;
system_clock::time_point t1 = system_clock::now();
std::sort(vs.begin(), vs.end(),
& {
++count;
return compare(a, b, L);
});
system_clock::time_point t2 = system_clock::now();
cout << "Sort time: " <<
duration_cast<milliseconds>(t2 - t1).count() <<
"ms (" << count << " comparisons)" << endl;
}
请注意,为了使此示例编译,我们需要包含适当的头文件,并为我们缩短的名称编写using声明。
#include <algorithm>
#include <chrono>
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <memory>
#include <random>
#include <vector>
using std::chrono::duration_cast;
using std::chrono::milliseconds;
using std::chrono::system_clock;
using std::cout;
using std::endl;
using std::minstd_rand;
using std::unique_ptr;
using std::vector;
在随后的示例中,我们将省略常见的头文件和对常见名称(如cout或vector)的using声明。
该示例定义了一个字符串,用作要排序的子字符串的基础数据,以及子字符串的向量(字符指针),但我们还没有展示数据本身是如何创建的。然后,使用带有自定义比较函数的std::sort对子字符串进行排序:一个 lambda 表达式调用比较函数本身compare()。我们使用 lambda 表达式来适应compare()函数的接口,该函数接受两个指针和最大字符串长度,以符合std::sort期望的接口(只有两个指针)。这被称为适配器模式。
在我们的例子中,lambda 表达式有第二个作用:除了调用比较函数外,它还计算比较调用的次数。由于我们对排序的性能感兴趣,如果我们想比较不同的排序算法,这些信息可能会有用(我们现在不打算这样做,但这是一种技术,您可能会在自己的性能优化工作中发现有用)。
在这个例子中,比较函数本身只是声明了,但没有定义。它的定义在一个单独的文件中,内容如下:
bool compare(const char* s1, const char* s2, unsigned int l) {
if (s1 == s2) return false;
for (unsigned int i1 = 0, i2 = 0; i1 < l; ++i1, ++i2) {
if (s1[i1] != s2[i2]) return s1[i1] > s2[i2];
}
return false;
}
这是两个字符串的直接比较:如果第一个字符串大于第二个字符串,则返回 true,否则返回 false。我们可以很容易地在与代码本身相同的文件中定义函数,并避免额外文件的需要,但即使在这个小例子中,我们也试图复制一个可能调用许多函数的真实程序的行为,这些函数分散在许多不同的文件中。因此,我们将比较函数放在自己的文件中,在本章中我们称之为compare.C,而示例的其余部分在一个文件中,名为example.C。
最后,我们使用chrono库中的 C++高分辨率计时器来测量排序子字符串所需的时间。
我们示例中唯一缺少的是字符串的实际数据。子字符串排序在许多实际应用程序中是一个相当常见的任务,每个应用程序都有自己获取数据的方式。在我们的人工示例中,数据将不得不同样人工。例如,我们可以生成一个随机字符串。另一方面,在许多实际应用程序中,子字符串排序中有一个字符出现的频率比其他任何字符都要高。
我们也可以模拟这种类型的数据,方法是用一个字符填充字符串,然后随机更改其中的一些字符:
constexpr unsigned int L = 1 << 18, N = 1 << 14;
unique_ptr<char[]> s(new char[L]);
vector<const char*> vs(N);
minstd_rand rgen;
::memset(s.get(), 'a', N*sizeof(char));
for (unsigned int i = 0; i < L/1024; ++i) {
s[rgen() % (L - 1)] = 'a' + (rgen() % ('z' - 'a' + 1));
}
s[L-1] = 0;
for (unsigned int i = 0; i < N; ++i) {
vs[i] = &s[rgen() % (L - 1)];
}
字符串L的大小和子字符串N的数量被选择为在用于运行这些测试的计算机上具有合理的运行时间(如果你想重复这些示例,你可能需要根据你的处理器的速度调整数字)。
现在我们的示例已经准备好编译和执行了:
图 2.1
你得到的结果取决于你使用的编译器、运行的计算机,当然还取决于数据语料库。
现在我们已经有了第一个性能测量,你可能会问的第一个问题是,我们该如何优化它?然而,这并不是你应该问的第一个问题。真正的第一个问题应该是,“我们需要优化吗?”要回答这个问题,你需要有性能的目标和目标,以及关于程序其他部分相对性能的数据;例如,如果实际字符串是从需要十个小时的模拟生成的,那么排序它需要的一百秒几乎不值得注意。当然,我们仍然在处理人工示例,除非我们假设是的,否则在本章中我们不会有太大进展,我们必须改善性能。
现在,我们准备好讨论如何优化了吗?再次,不要那么着急:现在应该问的问题是,“我们要优化什么?”或者更一般地说,程序花费最多时间的地方是哪里?即使在这个简单的示例中,可能是排序本身或比较函数。我们无法访问排序的源代码(除非我们想要黑掉标准库),但我们可以在比较函数中插入计时器调用。
不幸的是,这不太可能产生良好的结果:每次比较都非常快,计时器调用本身需要时间,每次调用函数时调用计时器将显著改变我们试图测量的结果。在现实世界的程序中,使用计时器进行这样的仪器测量通常也不切实际。如果你不知道时间花在哪里(没有任何测量,你怎么知道呢?),你将不得不在数百个函数中插入计时器。这就是性能分析工具发挥作用的地方。
我们将在下一节中更多地了解性能分析工具。现在,可以说以下命令行将编译和执行程序,并使用 GperfTools 包中的 Google 分析器收集其运行时配置文件:
图 2.2
配置文件数据存储在文件prof.data中,由CPUPROFILE环境变量给出。你可能已经注意到,这次程序运行时间更长了。这几乎是性能分析的一个不可避免的副作用。我们将在下一节回到这个问题。假设性能分析工具本身正常工作,程序的不同部分的相对性能应该仍然是正确的。
输出的最后一行告诉我们,分析器已经为我们收集了一些数据,现在我们需要以可读的格式显示它。对于 Google 分析器收集的数据,用户界面工具是google-pprof(通常安装为pprof),最简单的调用方式只是列出程序中的每个函数,以及在该函数中花费的时间的比例(第二列):
图 2.3
分析器显示几乎所有的时间都花在了比较函数compare()上,而排序几乎没有花费任何时间(第二行是std::sort调用的函数之一,应该被视为在排序中花费的时间的一部分,但不包括在比较中)。请注意,对于任何实际的分析,我们需要收集更多的样本,这取决于程序运行的时间,为了获得可靠的数据,您需要在每个要测量的函数中积累至少几十个样本。在我们的情况下,结果是如此明显,我们可以继续使用我们收集的样本。
由于子字符串比较函数占总运行时间的 98%,我们只有两种方法可以提高性能:我们可以使这个函数更快,或者我们可以减少调用它的次数(许多人忘记了第二种可能性,直接选择第一种)。第二种方法需要使用不同的排序算法,因此超出了本书的范围。在这里,我们将专注于第一种选择。让我们再次审查一下比较函数的代码:
bool compare(const char* s1, const char* s2, unsigned int l) {
if (s1 == s2) return false;
for (unsigned int i1 = 0, i2 = 0; i1 < l; ++i1, ++i2) {
if (s1[i1] != s2[i2]) return s1[i1] > s2[i2];
}
return false;
}
这只是几行代码,我们应该能够理解和预测它的行为。这里有一个检查,用于比较子字符串是否相同,这肯定比逐个字符进行比较要快,因此,除非我们确定该函数从不使用相同的指针值调用,否则这一行保留。
然后是一个循环(循环体逐个比较字符),我们必须这样做是因为我们不知道哪个字符可能不同。循环本身运行直到我们找到一个不同之处,或者直到我们比较了最大可能数量的字符。很容易看出后一种情况不可能发生:字符串以空字符结尾,因此,即使两个子字符串中的所有字符都相同,迟早我们会到达较短子字符串的末尾,将其末尾的空字符与另一个子字符串中的非空字符进行比较,较短的子字符串将被认为是两者中较小的。
唯一可能读取字符串末尾之外的情况是当两个子字符串从同一位置开始,但我们在函数的开头就检查了这一点。这很好:我们发现了一些不必要的工作,因此我们可以优化代码,摆脱每次循环迭代中的一个比较操作。考虑到循环体中没有太多其他操作,这应该是显著的。
代码的改变很简单:我们可以只删除比较(我们也不再需要将长度传递给比较函数):
bool compare(const char* s1, const char* s2) {
if (s1 == s2) return false;
for (unsigned int i1 = 0, i2 = 0;; ++i1, ++i2) {
if (s1[i1] != s2[i2]) return s1[i1] > s2[i2];
}
return false;
}
更少的参数,更少的操作,代码量也更少。让我们运行程序,看看这种优化节省了我们多少运行时间:
图 2.4
说这并不是按计划进行的将是一个严重的低估。原始代码花了 98 毫秒来解决同样的问题(图 2.1)。尽管“优化”代码做的工作更少,但花了 210 毫秒(请注意,并非所有编译器在这个例子上都表现出这种性能异常,但我们使用的是真正的生产编译器;这里没有任何诡计,这也可能发生在你身上)。
为了总结这个例子,实际上是一个大大简化的真实程序的例子,我要告诉您,当我们试图优化代码片段时,另一位程序员正在代码的另一个部分工作,也需要一个子字符串比较函数。当分别开发的代码片段放在一起时,只保留了这个函数的一个版本,而这恰好是我们没有编写的版本;另一位程序员几乎写了完全相同的代码:
bool compare(const char* s1, const char* s2) {
if (s1 == s2) return false;
for (int i1 = 0, i2 = 0;; ++i1, ++i2) {
if (s1[i1] != s2[i2]) return s1[i1] > s2[i2];
}
return false;
}
检查这段代码片段和前面的代码片段,看看你能否发现其中的区别。
唯一的区别是循环变量的类型:之前,我们使用了unsigned int,这并没有错:索引从 0 开始并递增;我们不期望出现任何负数。最后的代码片段使用了int,不必要地放弃了可能的索引值范围的一半。
在这次代码整合之后,我们可以再次运行我们的基准测试,这次使用新的比较函数。结果又是意想不到的:
图 2.5
最新版本花费了 74 毫秒,比我们原始版本快(98 毫秒,图 2.1),比几乎相同的第二个版本快得多(210 毫秒,图 2.2)。
关于这个特定的谜团的解释,您将不得不等到下一章。本节的目标是说服您永远不要猜测性能:所谓的“显而易见”的优化——用更少的代码进行完全相同的计算——出乎意料地失败了,而本来根本不应该有任何影响的微不足道的改变——在一个所有值都是非负的函数中使用有符号整数而不是无符号整数——竟然成为了一种有效的优化。
如果性能结果在这个非常简单的例子中都如此反直觉,那么做出关于性能的良好决策的唯一方法必须是基于测量的方法。在本章的其余部分,我们将看到一些用于收集性能测量的最常用工具,学习如何使用它们以及如何解释它们的结果。
性能基准测试
程序收集性能信息的最简单方法是运行它并测量所需的时间。当然,我们需要比这更多的数据才能进行任何有用的优化:知道程序的哪些部分使其花费那么长时间会很好,这样我们就不会浪费时间优化可能非常低效但花费时间很少且对最终结果没有贡献的代码。
我们在添加计时器到示例程序时已经看到了一个简单的例子:现在我们知道排序本身需要多长时间。简而言之,这就是基准测试的整个理念。其余的工作是费力的,用计时器对代码进行仪器化,收集信息,并以有用的格式报告。让我们看看我们有哪些工具,从语言本身提供的计时器开始。
C++ chrono 计时器
C++有一些设施可以用于收集时间信息,它们在其 chrono 库中。您可以测量程序中任意两点之间经过的时间:
#include <chrono>
using std::chrono::duration_cast;
using std::chrono::milliseconds;
using std::chrono::system_clock;
…
auto t0 = system_clock::now();
… do some work …
auto t1 = system_clock::now();
auto delta_t = duration_cast<milliseconds>(t1 – t0);
cout << "Time: " << delta_t.count() << endl;
我们应该指出,C++ chrono 时钟测量实际时间(通常称为挂钟时间)。通常,这是您想要测量的。但是,更详细的分析通常需要测量 CPU 时间,这是 CPU 工作时经过的时间,当 CPU 空闲时停止。在单线程程序中,CPU 时间不能大于实际时间;如果程序计算密集型,那么两个时间理想情况下应该是相同的,这意味着 CPU 已经完全加载。另一方面,用户界面程序大部分时间都在等待用户和空闲 CPU;在这种情况下,我们希望 CPU 时间尽可能低:这表明程序高效,并尽可能少地使用 CPU 资源来服务用户的请求。为此,我们必须超越 C++17 提供的内容。
高分辨率计时器
要测量 CPU 时间,我们必须使用特定于操作系统的系统调用;在 Linux 和其他符合 POSIX 标准的系统上,我们可以使用clock_gettime()调用来访问硬件高分辨率计时器:
timespec t0, t1;
clockid_t clock_id = …; // Specific clock
clock_gettime(clock_id, &t0);
… do some work …
clock_gettime(clock_id, &t1);
double delta_t = t1.tv_sec – t0.tv_sec +
1e-9*(t1.tv_nsec – t0.tv_nsec);
该函数将当前时间返回到其第二个参数中;tv_sec是自过去某个时间点以来的秒数,tv_nsec是自上一整秒以来的纳秒数。时间的起点实际上并不重要,因为我们总是测量时间间隔;但是,要小心先减去秒数,然后再加上纳秒数,否则,通过减去两个大数,您将丢失结果的有效数字。
在前面的代码中,我们可以使用几个硬件计时器,其中一个是由clock_id变量的值选择的。我们已经使用过的是相同的系统或实时时钟。它的 ID 是CLOCK_REALTIME。我们感兴趣的另外两个计时器是两个 CPU 计时器:CLOCK_PROCESS_CPUTIME_ID是一个测量当前程序使用的 CPU 时间的计时器,CLOCK_THREAD_CPUTIME_ID是一个类似的计时器,但它只测量调用线程使用的时间。
在对代码进行基准测试时,通常有助于从多个计时器中报告测量结果。在最简单的情况下,即单线程程序进行不间断计算时,所有三个计时器应该返回相同的结果:
double duration(timespec a, timespec b) {
return a.tv_sec - b.tv_sec + 1e-9*(a.tv_nsec - b.tv_nsec);
}
…
{
timespec rt0, ct0, tt0;
clock_gettime(CLOCK_REALTIME, &rt0);
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &ct0);
clock_gettime(CLOCK_THREAD_CPUTIME_ID, &tt0);
constexpr double X = 1e6;
double s = 0;
for (double x = 0; x < X; x += 0.1) s += sin(x);
timespec rt1, ct1, tt1;
clock_gettime(CLOCK_REALTIME, &rt1);
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &ct1);
clock_gettime(CLOCK_THREAD_CPUTIME_ID, &tt1);
cout << "Real time: " << duration(rt1, rt0) << "s, "
"CPU time: " << duration(ct1, ct0) << "s, "
"Thread time: " << duration(tt1, tt0) << "s" <<
endl;
}
这里的“CPU 密集型工作”是某种计算,所有三个时间应该几乎相同。您可以通过任何类型的计算的简单实验来观察到这一点。时间的值将取决于计算机的速度,但是除此之外,结果应该看起来像这样:
Real time: 0.3717s, CPU time: 0.3716s, Thread time: 0.3716s
如果报告的 CPU 时间与实际时间不匹配,很可能是机器负载过重(许多其他进程正在竞争 CPU 资源),或者程序内存不足(如果程序使用的内存超过了机器上的物理内存,它将不得不使用速度慢得多的磁盘交换,而 CPU 在程序等待内存从磁盘中分页时无法执行任何工作)。
另一方面,如果程序没有进行太多计算,而是等待用户输入,或者从网络接收数据,或者进行其他不需要太多 CPU 资源的工作,我们将看到非常不同的结果。观察这种行为的最简单方法是调用sleep()函数而不是我们之前使用的计算:
{
timespec rt0, ct0, tt0;
clock_gettime(CLOCK_REALTIME, &rt0);
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &ct0);
clock_gettime(CLOCK_THREAD_CPUTIME_ID, &tt0);
sleep(1);
timespec rt1, ct1, tt1;
clock_gettime(CLOCK_REALTIME, &rt1);
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &ct1);
clock_gettime(CLOCK_THREAD_CPUTIME_ID, &tt1);
cout << "Real time: " << duration(rt1, rt0) << "s, "
"CPU time: " << duration(ct1, ct0) << "s, "
"Thread time: " << duration(tt1, tt0) << "s" <<
endl;
}
现在我们将希望看到一个休眠程序使用非常少的 CPU:
Real time: 1.000s, CPU time: 3.23e-05s, Thread time: 3.32e-05s
对于在套接字或文件上被阻塞或等待用户操作的程序,情况也应该是如此。
到目前为止,我们还没有看到两个 CPU 计时器之间的任何差异,除非您的程序使用线程,否则您也不会看到任何差异。我们可以让我们的计算密集型程序执行相同的工作,但使用单独的线程:
{
timespec rt0, ct0, tt0;
clock_gettime(CLOCK_REALTIME, &rt0);
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &ct0);
clock_gettime(CLOCK_THREAD_CPUTIME_ID, &tt0);
constexpr double X = 1e6;
double s = 0;
auto f = std::async(std::launch::async,
[&]{ for (double x = 0; x < X; x += 0.1) s += sin(x);
});
f.wait();
timespec rt1, ct1, tt1;
clock_gettime(CLOCK_REALTIME, &rt1);
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &ct1);
clock_gettime(CLOCK_THREAD_CPUTIME_ID, &tt1);
cout << "Real time: " << duration(rt1, rt0) << "s, "
"CPU time: " << duration(ct1, ct0) << "s, "
"Thread time: " << duration(tt1, tt0) << "s" <<
endl;
}
计算的总量保持不变,仍然只有一个线程在工作,因此我们不希望实时或整个进程的 CPU 时间发生任何变化。然而,调用定时器的线程现在处于空闲状态;它所做的就是等待std::async返回的未来,直到工作完成。这种等待与前面例子中的sleep()函数非常相似,我们可以从结果中看到:
Real time: 0.3774s, CPU time: 0.377s, Thread time: 7.77e-05s
现在实时和整个进程的 CPU 时间看起来像“重型计算”示例中的那样,但特定线程的 CPU 时间很低,就像“睡眠”示例中的那样。这是因为整体程序正在进行大量计算,但调用定时器的线程确实大部分时间都在睡眠。
大多数情况下,如果我们要使用线程进行计算,目标是更快地进行更多的计算,因此我们将使用多个线程并在它们之间分配工作。让我们修改前面的例子,也在主线程上进行计算:
{
timespec rt0, ct0, tt0;
clock_gettime(CLOCK_REALTIME, &rt0);
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &ct0);
clock_gettime(CLOCK_THREAD_CPUTIME_ID, &tt0);
constexpr double X = 1e6;
double s1 = 0, s2 = 0;
auto f = std::async(std::launch::async,
[&]{ for (double x = 0; x < X; x += 0.1) s1 += sin(x);
});
for (double x = 0; x < X; x += 0.1) s2 += sin(x);
f.wait();
timespec rt1, ct1, tt1;
clock_gettime(CLOCK_REALTIME, &rt1);
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &ct1);
clock_gettime(CLOCK_THREAD_CPUTIME_ID, &tt1);
cout << "Real time: " << duration(rt1, rt0) << "s, "
"CPU time: " << duration(ct1, ct0) << "s, "
"Thread time: " << duration(tt1, tt0) << "s" <<
endl;
}
现在两个线程都在进行计算,因此程序使用的 CPU 时间以双倍速率流逝,与实际时间相比。
Real time: 0.5327s, CPU time: 1.01s, Thread time: 0.5092s
这很不错:我们在只有 0.53 秒的实际时间内完成了 1 秒的计算。理想情况下,这应该是 0.5 秒,但实际上,启动线程和等待它们会有一些开销。此外,两个线程中的一个可能需要更长的时间来完成工作,然后另一个线程有时会处于空闲状态。
对程序进行基准测试是收集性能数据的一种强大方式。仅通过观察执行函数或处理事件所需的时间,我们就可以了解代码的性能。对于计算密集型代码,我们可以看到程序是否确实在不停地进行计算,还是在等待某些东西。对于多线程程序,我们可以测量并发性有多有效以及开销是多少。但我们不仅仅局限于收集执行时间:我们还可以报告任何我们认为相关的计数和值:函数被调用的次数,我们排序的平均字符串长度,任何我们需要帮助解释测量的东西。
然而,这种灵活性是有代价的:通过基准测试,我们几乎可以回答关于程序性能的任何问题。但我们必须首先提出问题:我们只报告我们决定测量的内容。如果我们想知道某个函数需要多长时间,我们必须为其添加定时器;如果没有,我们将无法得知任何信息,直到重写代码并重新运行基准测试。另一方面,在代码中到处添加定时器也不可取:这些函数调用相当昂贵,因此使用太多可能会减慢程序速度并扭曲性能测量。通过经验和良好的编码纪律,你可以学会提前为自己编写的代码进行仪器化,这样至少它的主要部分可以轻松进行基准测试。
但是,如果你不知道从哪里开始怎么办?如果你继承了一个没有为任何基准测试进行仪器化的代码库怎么办?或者,也许你将性能瓶颈隔离到了一个大段代码中,但里面没有更多的定时器了怎么办?一种方法是继续对代码进行仪器化,直到你有足够的数据来分析问题。但这种蛮力方法很慢,所以你会希望得到一些关于在哪里集中努力的指导。这就是性能分析的作用:它让你可以为一个没有手动进行简单基准测试的程序收集性能数据。我们将在下一节学习有关性能分析的知识。
性能分析
我们将要学习的下一组性能分析工具是分析工具,或分析器。我们已经看到了一个分析器的使用:在上一节中,我们使用它来识别占用大部分计算时间的函数。这正是分析器的用途,用于找到“热点”函数和代码片段,也就是程序花费大部分时间的代码行。
有许多不同的分析工具可用,包括商业和开源的。在本节中,我们将研究两种在 Linux 系统上流行的分析器。我们的目标不是让你成为某个特定工具的专家,而是让你了解你选择使用的分析器可以期望什么以及如何解释其结果。
首先,让我们指出有几种不同类型的分析器:
-
一些分析器执行解释器或虚拟机下的代码,并观察它花费时间的地方。这些分析器的主要缺点是,它们使程序运行速度比直接编译成机器指令的代码慢得多,至少对于像 C++这样被编译而不通常在虚拟机下运行的语言来说是这样。
-
其他分析器要求在编译或链接期间使用特殊指令对代码进行仪器化。这些指令为分析器提供额外的信息,例如,当函数被调用或循环开始和结束时,它们可以通知数据收集引擎。这些分析器比前一种类型的分析器更快,但仍然比本地执行慢。它们还需要对代码进行特殊编译,并依赖于一个假设,即仪器化的代码与原始代码具有相同的性能,至少是相对的,如果不是绝对的。
-
大多数现代分析器使用现代 CPU 上存在的硬件事件计数器。这些是可以用来跟踪特定硬件事件的特殊硬件寄存器。一个硬件事件的例子是执行一条指令。你可以看到这对于分析是如何有用的:处理器将为我们计算指令而无需任何额外的仪器或开销。我们所需要做的就是读取计数器寄存器的值。
不幸的是,有用的分析比简单地计算指令要复杂一些。我们需要知道每个函数甚至每行代码花费了多少时间。如果分析器在执行每个函数(或每个循环、每行代码等)之前和之后读取指令计数,就可以做到这一点。这就是为什么一些分析器使用混合方法:它们对代码进行仪器化以标记感兴趣的点,但使用硬件性能计数器进行实际测量。
其他分析器依赖于基于时间的采样:它们在一定的间隔内中断程序,比如每 10 毫秒一次,并记录性能计数器的值以及程序的当前位置(即将执行的指令)。如果,比如,所有样本中有 90%是在调用compare()函数时进行的,我们可以假设程序花费了 90%的时间进行字符串比较。这种方法的准确性取决于采样数量和采样之间的间隔。
我们对程序执行的采样越频繁,我们收集的数据就越多,但开销也越大。基于硬件的分析器在某些情况下可能对程序的运行时没有任何不利影响,如果采样不是太频繁的话。
性能分析器
我们将在本节中学习的第一个分析器工具是 Linux 的perf分析器。这是 Linux 上最流行的分析器之一,因为它几乎安装在大多数发行版中。这个分析器使用硬件性能计数器和基于时间的采样;它不需要对代码进行任何仪器化。
运行这个性能分析器的最简单方法是收集整个程序的计数器值;这是使用perf stat命令完成的:
图 2.6
正如您在图 2.6中所看到的,编译不需要任何特殊选项或工具。程序由性能分析器执行,stat选项告诉性能分析器在整个程序运行期间显示硬件性能计数器中累积的计数。在这种情况下,我们的程序运行了 158 毫秒(与程序本身打印的时间一致),执行了超过 13 亿条指令。还显示了其他几个计数器,如“页面错误”和“分支”。这些计数器是什么,还有哪些计数器可以看到?
事实证明,现代 CPU 可以收集许多不同类型的事件的统计信息,但一次只能收集少数类型;在前面的例子中,报告了八个计数器,因此我们可以假设这个 CPU 有八个独立的计数器。然而,这些计数器中的每一个都可以被分配来计算许多事件类型中的一个。性能分析器本身可以列出所有已知的事件,并且可以对其进行计数:
图 2.7
图 2.7中的列表是不完整的(打印输出会继续很多行),并且可用的确切计数器会因 CPU 而异(如果您使用虚拟机,则还会受到 hypervisor 的类型和配置的影响)。我们在图 2.6中收集的性能分析运行结果只是默认的计数器集,但我们可以选择其他计数器进行性能分析:
图 2.8
在图 2.8中,我们测量 CPU 周期和指令,以及分支、分支丢失、缓存引用和缓存丢失。这些计数器及其监视的事件的详细解释将在下一章中介绍。
简而言之,周期时间是 CPU 频率的倒数,因此 3GHz 的 CPU 可以每秒运行 30 亿个周期。顺便说一句,大多数 CPU 可以以可变速度运行,这会使测量变得复杂。因此,为了进行准确的性能分析和基准测试,建议禁用节能模式和其他可能导致 CPU 时钟变化的功能。指令计数器测量执行的处理器指令数量;正如您所看到的,CPU 平均每个周期执行了近四条指令。
"分支"是条件指令:每个if语句和每个带有条件的for循环至少生成一个这样的指令。分支丢失将在下一章中详细解释;现在我们只能说,从性能角度来看,这是一个昂贵且不希望发生的事件。
"缓存引用"计算 CPU 需要从内存中获取数据的次数。大多数情况下,“数据”是一段数据,比如字符串中的一个字符。根据处理器和内存的状态,这种获取可能非常快或非常慢;后者被计为“缓存丢失”(“慢”是一个相对概念;相对于 3GHz 的处理器速度,1 微秒是一个非常长的时间)。内存层次结构将在后面的章节中解释;同样,缓存丢失是一个昂贵的事件。
掌握了 CPU 和内存的工作原理,您将能够利用这些测量来评估程序的整体效率,并确定限制其性能的因素类型。
到目前为止,我们只看到了整个程序的测量。图 2.8中的测量可能告诉我们是什么在阻碍我们代码的性能:例如,如果我们暂时接受“缓存未命中”对性能不利,我们可以推断出这段代码的主要问题是其低效的内存访问(十次内存访问中有一次是慢的)。然而,这种类型的数据并不告诉我们代码的哪些部分负责性能不佳。为此,我们需要收集数据不仅在程序执行之前和之后,还在程序执行期间。让我们看看如何使用perf来做到这一点。
使用 perf 进行详细分析
perf分析器将硬件计数器与基于时间间隔的采样相结合,记录运行程序的性能概况。对于每个样本,它记录程序计数器的位置(要执行的指令的地址)和我们正在监视的性能计数器的值。运行后,数据将被分析;具有最多样本的函数和代码行负责大部分执行时间。
分析器的数据收集运行并不比整体测量运行更困难。请注意,在运行时,指令地址被收集;要将这些转换为原始源代码中的行号,程序必须使用调试信息进行编译。如果您习惯于两种编译模式,“优化”和“非优化调试”,那么编译器选项的这种组合可能会让您感到惊讶:调试和优化都已启用。后者的原因是我们需要对将在生产中运行的相同代码进行分析,否则数据大多是无意义的。考虑到这一点,我们可以为分析编译代码并使用perf record命令运行分析器:
图 2.9
就像perf stat一样,我们可以指定一个计数器或一组计数器来监视,但是这次,我们接受默认计数器。我们没有指定采样的频率;同样,也有一个默认值,但我们也可以明确指定:例如,perf record -c 1000每秒记录 1000 个样本。
程序运行,产生常规输出,以及来自分析器的消息。最后一个告诉我们,分析样本已经捕获在名为perf.data的文件中(同样,这是可以更改的默认值)。要可视化来自此文件的数据,我们需要使用分析工具,它也是同一 perftools 套件的一部分,具体来说是perf report命令。运行此命令将启动此屏幕:
图 2.10
这是分析摘要,按功能的执行时间分解。从这里,我们可以深入研究任何功能,并查看哪些行对执行时间贡献最大:
图 2.11
图 2.11左侧的数字是每行所花费的执行时间的百分比。那么,“行”到底告诉我们什么?图 2.11说明了分析此类概要的更频繁的困难之一。它显示了源代码和由此产生的汇编指令;执行时间计数器自然与每个硬件指令相关联(这是 CPU 执行的内容,因此是唯一可以计数的内容)。编译代码和源代码之间的对应关系是由编译器嵌入的调试信息由分析器建立的。不幸的是,这种对应关系并不精确,原因是优化。编译器执行各种优化,所有这些优化最终都会重新排列代码并改变计算方式。即使在这个非常简单的例子中,您也可以看到结果:为什么源代码行
if (s1 == s2) return false;
为什么出现两次?原始源代码中只有一行。原因是从这一行生成的指令不都在同一个地方;优化器将它们与来自其他行的指令重新排序。因此,分析器在这条线附近显示了两次机器指令。
即使不看汇编代码,我们也可以看到时间花在比较字符上,以及运行循环本身;这两行源代码占据了大部分时间:
for (unsigned int i1 = 0, i2 = 0; i1 < l; ++i1, ++i2) {
if (s1[i1] != s2[i2]) return s1[i1] > s2[i2];
为了充分利用分析,有助于了解我们正在工作的平台的汇编语言的基础知识(在我们的情况下是 X86 CPU)。分析器还有一些有用的工具,可以方便分析。例如,将光标放在jne(如果不相等则跳转)指令上,我们可以看到跳转会带我们去哪里,以及与跳转相关的条件:
图 2.12
这看起来像是跳回重复最后几行代码,所以跳转上面的cmp(比较)指令必须是循环的比较,i1 < l。总的来说,跳转和比较占据了 18%的执行时间,所以我们之前对看似不必要的比较操作的关注似乎是合理的。
perf 分析器有更多的选项和功能来分析、过滤和聚合结果,所有这些都可以从其文档中学习。此外,还有几个 GUI 前端用于这个分析器。接下来,我们将快速看一下另一个分析器,来自 Google 性能工具的分析器。
Google 性能分析器
Google CPU 分析器也使用硬件性能计数器。它还需要对代码进行链接时插装(但不需要编译时插装)。为了准备代码进行分析,你必须将其与分析器库链接:
图 2.13
在图 2.13中,库由命令行选项-lprofiler指定。与 perf 不同,这个分析器不需要任何特殊的工具来调用程序;必要的代码已经链接到可执行文件中。插装的可执行文件不会自动开始分析自身。我们必须通过设置环境变量CPUPROFILE为我们想要存储结果的文件名来激活分析。其他选项也是通过环境变量而不是命令行选项来控制的,例如,变量CPUPROFILE_FREQUENCY设置每秒的样本数:
图 2.14
再次,我们看到了程序本身和分析器的输出,并且得到了我们必须分析的配置文件。分析器有交互模式和批处理模式;交互模式是一个简单的文本用户界面:
图 2.15
只需运行google-pprof(通常安装为pprof)并将可执行文件和配置文件的名称作为参数,就会弹出命令提示符。从这里,我们可以,例如,获取所有函数的摘要,其中包含执行时间的百分比。我们还可以在源代码级别进一步分析程序性能:
图 2.16
正如你所看到的,这个分析器采用了稍微不同的方法,并没有立即将我们深入到机器代码中(尽管也可以生成带注释的汇编代码)。然而,这种表面上的简单有些欺骗性:我们之前描述的注意事项仍然适用,优化编译器仍然对代码进行转换。
不同的性能分析工具由于作者采取的不同方法而具有不同的优势和劣势。我们不想把本章变成性能分析工具的手册,因此在本节的其余部分,我们将展示在收集和分析性能分析结果时可能遇到的一些常见问题。
使用调用图进行分析
到目前为止,我们简单的例子已经避开了一个实际上在每个程序中都会发生的问题。当我们发现比较函数占据了大部分执行时间时,我们立刻知道程序的哪一部分是有问题的:只有一行调用了这个函数。
大多数现实生活中的程序都不会那么简单:毕竟,我们编写函数的主要原因之一就是为了促进代码重用。很显然,许多函数将会从多个位置被调用,有些会被调用多次,而有些则只会被调用几次,通常使用非常不同的参数。仅仅知道哪个函数花费了很多时间是不够的:我们还需要知道它发生在什么上下文中(毕竟,最有效的优化可能是更少地调用昂贵的函数)。
我们需要的是一个不仅告诉我们每个函数和每行代码花费了多少时间,还告诉我们每个调用链花费了多少时间的分析结果。这些性能分析工具通常使用调用图来呈现这些信息:图中调用者和被调用者是节点,调用是边。
首先,我们必须修改我们的例子,以便我们可以从多个位置调用某个函数。让我们首先进行两次sort调用:
std::sort(vs.begin(), vs.end(),
& {
++count; return compare1(a, b, L); });
std::sort(vs.begin(), vs.end(),
& {
++count; return compare2(a, b, L); });
这些调用只在比较函数上有所不同;在我们的例子中,第一个比较函数和之前一样,而第二个则产生了相反的顺序。这两个函数都和我们旧的比较函数一样,在子字符串字符上有相同的循环:
bool compare1(const char* s1, const char* s2, unsigned int l) {
if (s1 == s2) return false;
for (unsigned int i1 = 0, i2 = 0; i1 < l; ++i1, ++i2) {
int res = compare(s1[i1], s2[i2]);
if (res != 0) return res > 0;
}
return false;
}
bool compare2(const char* s1, const char* s2, unsigned int l) {
if (s1 == s2) return false;
for (unsigned int i1 = 0, i2 = 0; i1 < l; ++i1, ++i2) {
int res = compare(s1[i1], s2[i2]);
if (res != 0) return res < 0;
}
return false;
}
这两个函数都使用相同的通用函数来比较每个字符:
int compare(char c1, char c2) {
if (c1 > c2) return 1;
if (c1 < c2) return -1;
return 0;
}
当然,这并不是你在真正的程序中会这样做的方式:如果你真的想避免由于重复循环而导致的代码重复,你会编写一个由字符比较运算符参数化的单个函数。然而,我们不想偏离我们开始的例子太远,我们希望保持代码简单,这样我们可以一次解释一个复杂性。
现在我们准备生成一个调用图,它将显示字符比较的成本是如何在两次对 sort 的调用之间分配的。我们使用的两个性能分析工具都可以生成调用图;在本节中,我们将使用 Google 性能分析工具。对于这个性能分析工具,数据收集已经包括了调用链信息;我们只是到目前为止还没有尝试去可视化它。
我们编译代码并运行性能分析器,就像我们之前做的那样(为了简单起见,我们将每个函数放在自己的源文件中):
Figure 2.17
性能分析工具可以以几种不同的格式显示调用图(Postscript、GIF、PDF 等)。例如,要生成 PDF 输出,我们将运行以下命令:
google-pprof --pdf ./example prof.data > prof.pdf
我们现在感兴趣的信息在调用图的底部:
图 2.18
正如你在图 2.18中所看到的,compare()函数占据了总执行时间的 58.6%,有两个调用者。在这两个调用者中,compare1()函数比compare2()函数稍微多一些调用;前者占据了 27.6%的执行时间(如果包括其对compare()的调用所花费的时间,则为 59.8%),而后者单独占据了 13.8%的时间,或者总共占据了 40.2%的时间。
基本的调用图通常足以识别问题调用链并选择程序的进一步探索区域。性能分析工具还具有更高级的报告功能,如函数名称的过滤、结果的聚合等。掌握所选择工具的功能差异可能是知识和猜测之间的区别:解释性能分析可能会很棘手和令人沮丧,原因有很多:有些是由于工具的限制,但其他一些则更为根本。在下一节中,我们将讨论后者的一个原因:为了使测量结果相关,必须在完全优化的代码上进行。
优化和内联
我们已经看到编译器优化在解释性能分析时会使情况变得复杂:所有的性能分析最终都是在编译后的机器代码上进行的,而我们看到的程序是以源代码形式呈现的。编译器优化使这两种形式之间的关系变得模糊。在重新排列源代码方面,最具侵略性的优化之一是编译时函数调用的内联。
内联要求函数的源代码在调用点可见,因此,为了向您展示这是什么样子,我们必须将整个源代码合并到一个文件中:
bool compare(const char* s1, const char* s2, unsigned int l) {
if (s1 == s2) return false;
for (unsigned int i1 = 0, i2 = 0; i1 < l; ++i1, ++i2) {
if (s1[i1] != s2[i2]) return s1[i1] > s2[i2];
}
return false;
}
int main() {
…
size_t count = 0;
std::sort(vs.begin(), vs.end(),
& {
++count; return compare(a, b, L); });
}
现在编译器可以并且可能会在使用排序的地方直接生成机器代码,而不是调用外部函数。这种内联是一种强大的优化工具;它经常发生,不仅仅是在同一文件中的函数。更常见的是,内联会影响头文件中的函数(整个实现都在头文件中的函数)。例如,在前面的代码中,对std::sort的调用看起来像是一个函数调用,但几乎可以肯定会被内联,因为std::sort是一个模板函数:它的整个主体都在头文件中。
让我们看看我们之前使用的性能分析工具如何处理内联代码。运行 Google 性能分析器对带注释的源代码行产生了这份报告:
图 2.19
正如您所见,性能分析器知道compare()函数被内联,但仍显示其原始名称。源代码中的行对应于函数的代码编写位置,而不是调用位置,例如,第 23 行是这样的:
if (s1[i1] != s2[i2]) return s1[i1] > s2[i2];
另一方面,perf 性能分析器并不容易显示内联函数:
图 2.20
在这里,我们可以看到时间似乎花在了排序代码和主程序本身上。然而,检查带注释的源代码,我们发现从compare()函数源代码生成的代码仍然占绝大多数执行时间:
图 2.21
不幸的是,没有简单的方法来消除优化对性能分析的影响。内联、代码重排和其他转换将详细的性能分析变成了一个随着实践而发展的技能。因此,现在需要一些关于有效使用性能分析的实际建议。
实际的性能分析
也许会有诱惑力认为性能分析是解决所有性能测量需求的终极解决方案:在性能分析器下运行整个程序,收集所有数据,并获得对代码中发生的一切情况的完整分析。不幸的是,情况很少会如此顺利。有时,工具的限制会成为阻碍。通常,大量数据中包含的信息复杂性太过压倒性。那么,你应该如何有效地使用性能分析呢?
建议的方法是首先收集高级信息,然后进行细化。将执行时间分解为大型模块之间的粗略概要可能是一个很好的起点。另一方面,如果模块已经为基准测试进行了仪器化,并且在所有主要执行步骤中都有计时器,那么你可能已经有了这些信息。如果你没有这样的仪器化,初始概要提供了这些步骤的很好建议,因此考虑现在添加基准测试仪器,这样下次就有了:你真的不指望一劳永逸地解决所有性能问题,对吧?
通过基准测试结果和粗略的概要,你可能会遇到以下几种情况之一。如果你很幸运,概要会指向一些低挂果实,比如一个函数占用了 99%的时间来对列表进行排序。是的,这种情况确实会发生:当代码最初编写时,没有人预料到列表会比十个元素更长,所以一段时间内确实如此,然后每个人都忘记了该代码,直到它在概要中显示为长杆。
更有可能的是,概要会引导你到一些大型函数或模块。现在你必须迭代,创建专注于程序有趣部分的测试,并更详细地对代码的一小部分进行概要。一些基准测试数据在解释概要时也可能非常有帮助:虽然概要会告诉你在给定函数或循环中花费了多少时间,但它不会计算循环迭代或跟踪 if-else 条件。请注意,大多数性能分析工具可以计算函数调用次数,因此良好的模块化代码比庞大的单片混乱代码更容易进行概要。
当你收集和完善概要时,数据将引导你关注代码的性能关键区域。这也是你可能会犯的一个常见错误的时候:当你专注于太慢的代码时,你可能会跳过考虑更大的画面而进行优化。例如,概要显示一个特定循环在内存分配上花费了大部分时间。在决定是否需要更高效的内存分配器之前,考虑一下你是否真的需要在每次循环迭代中分配和释放内存。使慢速代码变快的最佳方法通常是减少调用次数。这可能需要不同的算法或更高效的实现。
同样频繁的是,你会发现有一个计算是必须要做的,它是代码的性能关键部分,而加快程序的唯一方法就是让这段代码更快。现在你必须尝试不同的优化方法,看看哪种效果最好。你可以在程序本身中实时进行,但这通常是一种浪费时间的方法,会显著降低你的生产力。理想情况下,你希望快速尝试不同的实现方法,甚至针对特定问题尝试不同的算法。在这里,你可以利用第三种收集性能数据的方法,微基准测试。
微基准测试
在上一节结束时,我们弄清楚了程序在执行过程中花费大部分时间的地方。当我们的“显而易见”和“万无一失”的优化反而使程序运行得更慢时,我们也感到惊讶。现在很明显,我们必须更详细地调查性能关键函数。
我们已经有了这样的工具:整个程序正在执行这段代码,并且我们有方法来衡量它的性能。但我们现在真的对程序的其余部分不感兴趣了,至少在我们解决了已经确定的性能问题之前是这样。
为了优化程序中的几行代码而使用大型程序有以下两个主要缺点:
首先,即使少数行被确定为性能关键,也并不意味着整个程序根本不需要时间(在我们的演示示例中确实如此,但请记住,这个示例应该代表您正在处理的整个大型程序)。在整个工作或性能关键函数仅在特定条件下调用时,您可能需要等待几个小时,比如特定请求通过网络传输。
其次,处理大型程序需要更多时间:编译和链接时间更长,您的工作可能正在与其他程序员所做的代码更改进行交互,甚至编辑时间更长,因为所有额外的代码会分散注意力。总之,在这一点上,我们只对一个函数感兴趣,所以我们希望能够调用这个函数并测量结果。这就是微基准测试的用武之地。
微基准测试的基础知识
简而言之,微基准测试只是我们刚才说我们想要做的事情的一种方式:运行一小段代码并测量其性能。在我们的情况下,只是一个函数,但也可以是一个更复杂的代码片段。重要的是,这个代码片段可以在正确的起始条件下轻松调用:对于一个函数,只是参数,但对于一个更大的片段,可能需要重新创建一个更复杂的内部状态。
在我们的情况下,我们知道我们需要使用哪些参数调用字符串比较函数 - 我们自己构造了参数。我们需要的第二件事是测量执行时间;我们已经看到可以用于此目的的定时器。考虑到这一点,我们可以编写一个非常简单的基准测试,调用我们的字符串比较函数的几个变体并报告结果:
bool compare1(const char* s1, const char* s2) {
int i1 = 0, i2 = 0;
char c1, c2;
while (1) {
c1 = s1[i1]; c2 = s2[i2];
if (c1 != c2) return c1 > c2;
++i1; ++i2;
}
}
bool compare2(const char* s1, const char* s2) {
unsigned int i1 = 0, i2 = 0;
char c1, c2;
while (1) {
c1 = s1[i1]; c2 = s2[i2];
if (c1 != c2) return c1 > c2;
++i1; ++i2;
}
}
int main() {
constexpr unsigned int N = 1 << 20;
unique_ptr<char[]> s(new char[2*N]);
::memset(s.get(), 'a', 2*N*sizeof(char));
s[2*N-1] = 0;
system_clock::time_point t0 = system_clock::now();
compare1(s.get(), s.get() + N);
system_clock::time_point t1 = system_clock::now();
compare2(s.get(), s.get() + N);
system_clock::time_point t2 = system_clock::now();
cout << duration_cast<microseconds>(t1 - t0).count() <<
"us " << duration_cast<microseconds>(t2 - t1).count() <<
"us" << endl;
}
在这个程序中,我们只测试了两个比较函数,都没有循环结束条件,一个使用int索引,另一个使用unsigned int索引。此外,我们不会在后续列表中重复#include和using语句。输入数据只是一个从头到尾填满相同字符的长字符串,因此子字符串比较将一直运行到字符串的末尾。当然,我们可以在任何需要的数据上进行基准测试,但让我们从最简单的情况开始。
该程序看起来将完全符合我们的需求...至少直到我们运行它为止:
图 2.22
零时间,无论如何。出了什么问题?也许,单个函数调用的执行时间太快,无法测量?这不是一个坏猜测,我们可以很容易地解决这个问题:如果一个调用时间太短,我们只需要进行更多的调用:
int main() {
constexpr unsigned int N = 1 << 20;
constexpr int NI = 1 << 11;
unique_ptr<char[]> s(new char[2*N]);
::memset(s.get(), 'a', 2*N*sizeof(char));
s[2*N-1] = 0;
system_clock::time_point t0 = system_clock::now();
for (int i = 0; i < NI; ++i) {
compare1(s.get(), s.get() + N);
}
system_clock::time_point t1 = system_clock::now();
for (int i = 0; i < NI; ++i) {
compare2(s.get(), s.get() + N);
}
system_clock::time_point t2 = system_clock::now();
cout << duration_cast<microseconds>(t1 - t0).count() <<
"us " << duration_cast<microseconds>(t2 - t1).count() <<
"us" << endl;
}
我们可以增加迭代次数NI直到获得一些结果,对吗?不要那么快:
图 2.23
实际上太快了,但为什么?让我们在调试器中逐步执行程序,看看它实际上做了什么:
图 2.24
我们在main中设置断点,因此程序一启动就会暂停,然后我们逐行执行程序...除了我们编写的所有行之外!其他代码在哪里?我们可以猜想是编译器的问题,但为什么?我们需要更多了解编译器优化。
微基准测试和编译器优化
要理解这个缺失代码的奥秘,我们必须重新审视缺失代码实际上做了什么。它创建了一些字符串,调用了比较函数,然后……没有“然后”。除了在调试器中观察代码滚动之外,通过运行这个程序,你怎么知道这段代码是否被执行?你无法知道。编译器已经比我们提前到达了同样的结论。由于程序员无法区分执行和不执行代码的差异,编译器对其进行了优化。但是等等,你说,程序员可以区分:什么都不做比做一些事情要花费更少的时间。在这里,我们来到了 C++标准中非常重要的一个概念,这对于理解编译器优化至关重要:可观察行为。
标准规定,只要这些更改的效果不会改变可观察行为,编译器可以对程序进行任何更改。标准还非常明确地规定了什么构成了可观察行为:
-
对 volatile 对象的访问(读取和写入)严格按照它们出现的表达式的语义进行。特别是,它们不会与同一线程上的其他 volatile 访问重新排序。
-
在程序终止时,写入文件的数据与按原样执行程序时完全相同。
-
发送到交互设备的提示文本将在程序等待输入之前显示。更一般地说,输入和输出操作不能被省略或重新排列。
前面的规则有一些例外情况,但都不适用于我们的程序。编译器必须遵循“as-if”规则:优化后的程序应该表现出与按行执行完全相同的可观察行为。现在注意一下前面的列表中没有包括的内容:在调试器下运行程序并不构成可观察行为。执行时间也不构成可观察行为,否则,没有程序可以被优化以使其更快。
有了这种新的理解,让我们再来看一下基准代码:字符串比较的结果以任何方式都不会影响可观察行为,因此整个计算可以由编译器自行决定是否执行或省略。这一观察还给了我们解决这个问题的方法:我们必须确保计算的结果影响可观察行为。其中一种方法是利用先前描述的 volatile 语义:
int main() {
constexpr unsigned int N = 1 << 20;
constexpr int NI = 1 << 11;
unique_ptr<char[]> s(new char[2*N]);
::memset(s.get(), 'a', 2*N*sizeof(char));
s[2*N-1] = 0;
volatile bool sink;
system_clock::time_point t0 = system_clock::now();
for (int i = 0; i < NI; ++i) {
sink = compare1(s.get(), s.get() + N);
}
system_clock::time_point t1 = system_clock::now();
for (int i = 0; i < NI; ++i) {
sink = compare2(s.get(), s.get() + N);
}
system_clock::time_point t2 = system_clock::now();
cout << duration_cast<microseconds>(t1 - t0).count() <<
"us " << duration_cast<microseconds>(t2 - t1).count() <<
"us" << endl;
}
现在,每次调用比较函数的结果都被写入一个 volatile 变量中,并且根据标准,这些值必须是正确的并按正确的顺序写入。编译器现在别无选择,只能调用我们的比较函数并获取结果。只要结果本身不改变,这些结果的计算方式仍然可以被优化。这正是我们想要的:我们希望编译器为比较函数生成最佳代码,希望它生成的代码与实际程序中生成的代码相同。我们只是不希望它完全删除这些函数。运行这个基准测试表明我们终于实现了我们的目标,代码肯定在运行:
图 2.25
第一个值是compare1()函数的运行时间,它使用int索引,并且确实比unsigned int版本稍快(但不要对这些结果过于信任)。
将我们的计算与一些可观察行为纠缠在一起的第二个选项是简单地打印出结果。然而,这可能会有点棘手。考虑直接的尝试:
int main() {
constexpr unsigned int N = 1 << 20;
constexpr int NI = 1 << 11;
unique_ptr<char[]> s(new char[2*N]);
::memset(s.get(), 'a', 2*N*sizeof(char));
s[2*N-1] = 0;
bool sink;
system_clock::time_point t0 = system_clock::now();
for (int i = 0; i < NI; ++i) {
sink = compare1(s.get(), s.get() + N);
}
system_clock::time_point t1 = system_clock::now();
for (int i = 0; i < NI; ++i) {
sink = compare2(s.get(), s.get() + N);
}
system_clock::time_point t2 = system_clock::now();
cout << duration_cast<microseconds>(t1 - t0).count() <<
"us " << duration_cast<microseconds>(t2 - t1).count() <<
"us" << sink << endl;
}
请注意,变量sink不再是 volatile,而是我们写出它的最终值。这并不像你期望的那样有效:
图 2.26
函数compare2()的执行时间与以前差不多,但compare1()现在似乎快得多。当然,到现在为止,我们已经知道足够多,以理解这种“改进”是虚幻的:编译器只是简单地发现第一次调用的结果被第二次调用覆盖,因此不会影响可观察的行为。
这带来了一个有趣的问题:为什么编译器没有发现循环的第二次迭代给出了与第一次相同的结果,并优化掉了除第一次之外的每次对比函数调用?如果优化器足够先进,它本来可以这样做,然后我们将不得不做更多工作来解决这个问题:通常,将函数编译为单独的编译单元足以防止任何此类优化,尽管一些编译器能够进行整个程序的优化,因此在运行微基准测试时可能需要关闭它们。
还要注意,我们的两次基准运行即使对于未被优化的函数的执行时间也产生了略有不同的值。如果再次运行程序,您将得到另一个值,也在同一范围内,但略有不同。这还不够好:我们需要的不仅仅是大致的数字。我们可以多次运行基准测试,找出我们需要多少次重复,并计算平均时间,但我们不必手动执行。我们也不必编写代码来执行此操作,因为这样的代码已经被编写并作为几种微基准测试工具之一可用。我们现在将学习其中一种工具。
谷歌基准测试
编写微基准测试涉及大量样板代码,主要用于测量时间和累积结果。此外,此代码对于测量的准确性至关重要。有几个高质量的微基准库可用。在本书中,我们使用谷歌基准库。有关下载和安装库的说明可以在技术要求部分找到。在本节中,我们将描述如何使用该库并解释结果。
要使用谷歌基准库,我们必须编写一个小程序,准备输入并执行我们想要进行基准测试的代码。这是一个用于测量我们的字符串比较函数性能的基本谷歌基准程序:
#include "benchmark/benchmark.h"
using std::unique_ptr;
bool compare_int(const char* s1, const char* s2) {
char c1, c2;
for (int i1 = 0, i2 = 0; ; ++i1, ++i2) {
c1 = s1[i1]; c2 = s2[i2];
if (c1 != c2) return c1 > c2;
}
}
void BM_loop_int(benchmark::State& state) {
const unsigned int N = state.range(0);
unique_ptr<char[]> s(new char[2*N]);
::memset(s.get(), 'a', 2*N*sizeof(char));
s[2*N-1] = 0;
const char* s1 = s.get(), *s2 = s1 + N;
for (auto _ : state) {
benchmark::DoNotOptimize(compare_int(s1, s2));
}
state.SetItemsProcessed(N*state.iterations());
}
BENCHMARK(BM_loop_int)->Arg(1<<20);
BENCHMARK_MAIN();
每个谷歌基准程序必须包括库的头文件benchmark/benchmark.h,当然,还有任何其他编译所需的头文件(它们在前面的清单中被省略了)。程序本身由许多基准“固定装置”组成,每个都只是一个具有特定签名的函数:它通过引用接受一个参数benchmark::State,并且不返回任何东西。该参数是由谷歌基准库提供的一个对象,用于与库本身进行交互。
我们需要为每个代码片段(例如我们想要进行基准测试的函数)创建一个固定装置。在每个基准装置中,我们要做的第一件事是设置我们需要用作代码输入的数据。更一般地说,我们可以说我们需要重新创建此代码的初始状态,以表示在真实程序中的情况。在我们的情况下,输入是字符串,因此我们需要分配和初始化字符串。我们可以将字符串的大小硬编码到基准测试中,但也有一种方法可以将参数传递给基准测试装置。我们的装置使用一个参数,即字符串长度,它是一个整数,通过state.range(0)访问。可以传递其他类型的参数,请参阅谷歌基准库的文档了解详情。
整个设置在基准测试测量方面是自由的:我们不测量准备数据所需的时间。被测量执行时间的代码放入基准测试循环的主体中,for (auto _ : state) { … }。在旧的例子中,您可以找到这个循环写成while (state.KeepRunning()) { … },它做的事情是一样的,但效率稍低。该库测量每次迭代所需的时间,并决定要做多少次迭代以积累足够的测量结果,以减少在测量代码片段的运行时间时不可避免的随机噪音。只有基准测试循环内部的代码的运行时间被测量。
当测量足够准确时(或达到一定的时间限制)循环退出。循环之后,我们通常有一些代码来清理之前初始化的数据,尽管在我们的情况下,这个清理是由std::unique_ptr对象的析构函数处理的。我们还可以调用状态对象来影响基准测试报告的结果。该库总是报告运行循环的一次迭代所需的平均时间,但有时以其他方式表达程序速度更方便。对于我们的字符串比较,一种选择是报告代码每秒处理的字符数。我们可以通过调用state.SetItemsProcessed()来实现,其中包括我们在整个运行过程中处理的字符数,每次迭代处理N个字符(或者如果要计算两个子字符串,则为2*N;items可以计算您定义为处理单位的任何内容)。
仅仅定义了一个基准测试装置并不会有任何作用,我们需要在库中注册它。这是使用BENCHMARK宏完成的;宏的参数是函数的名称。顺便说一下,该名称并没有什么特别之处,它可以是任何有效的 C++标识符;我们的名称以BM_开头只是我们在本书中遵循的命名约定。BENCHMARK宏也是您将指定要传递给基准测试装置的任何参数的地方。使用重载的箭头运算符传递基准测试的参数和其他选项,例如:
BENCHMARK(BM_loop_int)->Arg(1<<20);
这行代码使用一个参数1<<20注册了基准测试装置BM_loop_int,可以通过调用state.range(0)在装置内检索到。在本书中,我们将看到更多不同参数的示例,甚至可以在库文档中找到更多。
您还会注意到在前面的代码清单中没有main();相反,有另一个宏,BENCHMARK_MAIN()。main()不是我们编写的,而是由 Google Benchmark 库提供的,它完成了设置基准测试环境、注册基准测试和执行基准测试的所有必要工作。
让我们回到我们想要测量的代码并更仔细地检查一下:
for (auto _ : state) {
benchmark::DoNotOptimize(compare_int(s1, s2));
}
benchmark::DoNotOptimize(…)包装函数的作用类似于我们之前使用的volatile sink:它确保编译器不会优化掉对compare_int()的整个调用。请注意,它实际上并没有关闭任何优化;特别是括号内的代码通常会像我们想要的那样进行优化。它所做的只是告诉编译器,表达式的结果,在我们的情况下是比较函数的返回值,应该被视为“已使用”,就像它被打印出来一样,不能简单地被丢弃。
现在我们准备编译和运行我们的第一个微基准测试:
图 2.27
现在编译行必须列出 Google 基准include文件和库的路径;Google 基准库libbenchmark.a需要几个额外的库。一旦调用,基准程序会打印一些关于我们正在运行的系统的信息,然后执行所有已注册的 fixture 及其所有参数。对于每个基准 fixture 和一组参数,我们会得到一行输出;报告包括每次基准循环体的平均实际时间和平均 CPU 时间,循环执行的次数,以及我们附加到报告的任何其他统计信息(在我们的情况下,每秒处理的字符数,超过 2G 字符每秒)。
这些数字每次运行时有多大的变化?如果我们使用正确的命令行参数启用统计信息收集,基准库可以为我们计算出来。例如,要重复进行基准测试十次并报告结果,我们会这样运行基准测试:
图 2.28
看起来测量结果相当准确;标准偏差相当小。现在我们可以对比不同变体的子字符串比较函数,并找出哪一个是最快的。但在我们这样做之前,我必须告诉你一个大秘密。
微基准测试是谎言
当你开始运行越来越多的微基准测试时,你很快就会发现这一点。起初,结果是合理的,你进行了良好的优化,一切看起来都很好。然后你做了一些小改变,得到了一个完全不同的结果。你回去调查,现在你已经运行过的相同测试给出了完全不同的数字。最终,你找到了两个几乎相同的测试,结果完全相反,你意识到你不能相信微基准测试。它会摧毁你对微基准测试的信心,我现在唯一能做的就是以一种可控的方式摧毁它,这样我们还能从废墟中挽救一些东西。
微基准测试和任何其他详细的性能测量的根本问题是它们强烈依赖于上下文。当你阅读本书的其余部分时,你会越来越明白现代计算机的性能行为是非常复杂的。结果不仅仅取决于代码在做什么,还取决于系统的其余部分在同一时间在做什么,以及之前它在做什么,以及执行路径在到达感兴趣点之前经过的情况。这些事情在微基准测试中都没有被复制。
相反,基准测试有它自己的上下文。基准测试库的作者并不对这个问题一无所知,并且他们尽力去对抗它。例如,你看不到的是,Google 基准库对每个测试进行了烧入:最初的几次迭代可能具有与运行的其余部分非常不同的性能特征,因此库会忽略初始测量,直到结果"稳定"。但这也定义了一个特定的上下文,可能与每次调用函数只重复一次的真实程序不同(另一方面,有时我们确实会在程序运行中多次使用相同的参数调用相同的函数,所以这可能是一个不同的上下文)。
在运行基准测试之前,没有什么可以忠实地复制大型程序的真实环境的每一个细节。但是有些细节比其他细节更重要。特别是,上下文差异的最大来源远远是编译器,或者更具体地说,是编译器在真实程序与微基准测试上所做的优化。我们已经看到编译器如何顽固地试图弄清楚整个微基准测试基本上是一种非常缓慢的无用操作(或者至少是不可观察的无用操作),并用更快的方式替换它。我们之前使用的DoNotOptimize包装器可以解决一些由编译器优化引起的问题。
然而,仍然存在这样的可能性,即编译器可能会发现每次调用函数都返回相同的结果。此外,由于函数定义与调用点在同一个文件中,编译器可以内联整个函数,并使用它可以收集到的关于参数的任何信息来优化函数代码。当函数从另一个编译单元调用时,在一般情况下这样的优化是不可用的。
为了更准确地在我们的微基准测试中表示真实情况,我们可以将比较函数移动到它自己的文件中,并将其单独编译。现在我们有一个文件(编译单元),其中只有基准测试的固定装置:
#include "benchmark/benchmark.h"
extern bool compare_int(const char* s1, const char* s2);
extern bool compare_uint(const char* s1, const char* s2);
extern bool compare_uint_l(const char* s1, const char* s2,
unsigned int l);
void BM_loop_int(benchmark::State& state) {
const unsigned int N = state.range(0);
unique_ptr<char[]> s(new char[2*N]);
::memset(s.get(), 'a', 2*N*sizeof(char));
s[2*N-1] = 0;
const char* s1 = s.get(), *s2 = s1 + N;
for (auto _ : state) {
benchmark::DoNotOptimize(compare_int(s1, s2));
}
state.SetItemsProcessed(N*state.iterations());
}
void BM_loop_uint(benchmark::State& state) {
… compare_uint(s1, s2) …
}
void BM_loop_uint_l(benchmark::State& state) {
… compare_uint_l(s1, s2, 2*N) …
}
BENCHMARK(BM_loop_int)->Arg(1<<20);
BENCHMARK(BM_loop_uint)->Arg(1<<20);
BENCHMARK(BM_loop_uint_l)->Arg(1<<20);
我们可以分别编译文件,然后将它们链接在一起(任何完整程序的优化都必须关闭)。现在我们有一个合理的期望,即编译器没有生成某种特殊的减少版本的子字符串比较,因为它根据我们在基准测试中使用的参数所得出的结论。仅凭这个简单的预防措施,结果就更加符合我们在对整个程序进行性能分析时观察到的情况:
图 2.29
代码的初始版本使用了unsigned int索引和循环中的边界条件(最后一行);简单地删除那个完全不必要的边界条件检查导致了令人惊讶的性能下降(中间行);最后,将索引更改为signed int恢复了丢失的性能,甚至提高了性能(第一行)。
通常,将代码片段分别编译就足以避免任何不需要的优化。不太常见的是,您可能会发现编译器对同一文件中的特定代码块进行不同的优化,这取决于文件中还有什么其他内容。这可能只是编译器中的一个错误,但也可能是某种启发式的结果,在编译器编写者的经验中,这种启发式更常常是正确的。如果您观察到结果取决于根本没有执行的某些代码,只是编译了的代码,这可能就是原因。一个解决方案是使用真实程序中的编译单元,然后只调用您想要进行基准测试的函数。当然,您将不得不满足编译和链接的依赖关系,这就是编写模块化代码和最小化依赖关系的另一个原因。
另一个上下文的来源是计算机本身的状态。显然,如果整个程序耗尽了内存并且正在在交换区中循环页面,您的小内存基准测试将无法代表真实问题;另一方面,问题现在不在于“慢”代码,问题在于其他地方消耗了太多内存。然而,这种上下文依赖的更微妙的版本存在,并可能影响基准测试。通常这种情况的一个显著迹象是:结果取决于测试执行的顺序(在微基准测试中,是BENCHMARK宏的顺序)。如果重新排序测试或仅运行一部分测试会产生不同的结果,那么它们之间存在某种依赖关系。这可能是代码依赖,通常是一些全局数据结构中的数据累积。或者它可能是对硬件状态的微妙依赖。这些更难以弄清楚,但您将在本书的后面学习到一些导致这种依赖的情况。
最后,有一个主要的上下文依赖来源完全掌握在您手中(这并不一定意味着容易避免,但至少是可能的)。这是对程序状态的依赖。我们已经不得不处理这种依赖的最明显方面:我们想要对代码进行基准测试的输入。有时,输入是已知的或可以重建的。通常,性能问题只发生在某些类型的输入下,我们不知道它们有何特殊之处,直到我们分析具有这些特定输入的代码的性能,这正是我们一开始尝试用微基准测试来做的。在这种情况下,通常最容易的方法是从真实程序的真实运行中捕获输入,将它们存储在文件中,并使用它们来重新创建我们正在测量的代码的状态。这个输入可能是简单的数据集合,也可能是需要记录和“回放”到事件处理程序以重现所需行为的事件序列。
我们需要重建的状态越复杂,就越难在部分基准测试中重现真实程序的性能行为。请注意,这个问题在某种程度上类似于编写单元测试的问题:如果程序无法分解为具有简单状态的较小单元,编写单元测试也会更加困难。再次看到了良好设计的软件系统的优势:具有良好单元测试覆盖率的代码库通常更容易进行微基准测试,逐步进行测试。
当我们开始本节时,您已经被警告,这部分内容旨在部分恢复您对微基准测试的信心。它们可以是一个有用的工具,正如我们在本书中多次看到的那样。它们也可能误导您,有时甚至误导得很远。现在您了解了一些原因,更有准备去尝试从结果中恢复有用的信息,而不是完全放弃小规模基准测试。
本章介绍的工具都不是解决所有问题的解决方案;它们也不是用来解决所有问题的。通过使用这些工具以各种方式收集信息,它们可以相互补充,从而实现最佳结果。
摘要
在本章中,您学到了整本书中可能最重要的一课:谈论性能而不参考具体的测量是没有意义的,甚至是不切实际的。其余的内容主要是技艺:我们介绍了几种测量性能的方法,从整个程序开始,逐渐深入到单行代码。
一个大型高性能项目将会看到本章中学到的每种工具和方法被使用不止一次。粗略的测量 - 对整个程序或其大部分进行基准测试和分析 - 指向了需要进一步调查的代码区域。通常会跟随额外的基准测试轮次或更详细的分析。最终,你会确定需要优化的代码部分,问题变成了,“我该如何更快地做到这一点?”在这一点上,你可以使用微基准测试或其他小规模基准测试来尝试优化的代码。你甚至可能会发现你对这段代码的理解并不如你想象的那么多,需要对其性能进行更详细的分析;不要忘记你可以对微基准进行分析!
最终,你将拥有一个在小型基准测试中看起来有利的性能关键代码的新版本。但是,不要假设任何事情:现在你必须测量你的优化或增强对整个程序的性能。有时,这些测量将确认你对问题的理解并验证其解决方案。在其他时候,你会发现问题并不是你所想象的那样,而优化虽然本身有益,但对整个程序的效果并不如预期(甚至可能使情况变得更糟)。现在你有了一个新的数据点,你可以比较旧解决方案和新解决方案的分析,并在这种比较中揭示的差异中寻找答案。
高性能程序的开发和优化几乎从来都不是一个线性的、一步一步的过程。相反,它经历了许多从高层概述到低层详细工作再返回的迭代。在这个过程中,你的直觉起着一定的作用;只是确保始终测试和确认你的期望,因为在性能方面,没有什么是真正明显的。
在下一章中,我们将看到我们之前遇到的谜团的解决方案:删除不必要的代码会使程序变慢。为了做到这一点,我们必须了解如何有效地利用 CPU 以获得最佳性能,整个下一章都致力于此。
问题
-
为什么性能测量是必要的?
-
为什么我们需要这么多不同的性能测量方法?
-
手动基准测试的优势和局限性是什么?
-
分析如何用于性能测量?
-
小规模基准测试,包括微基准测试的用途是什么?
第三章:CPU 架构、资源和性能
通过本章,我们开始探索计算硬件:我们想知道如何最佳地使用它,并从中挤出最佳性能。我们首先要了解的硬件组件是中央处理器。CPU 执行所有计算,如果我们没有有效地使用它,那么没有什么能拯救我们慢速、性能不佳的程序。本章致力于学习 CPU 资源和能力,最佳使用它们的方式,未能充分利用 CPU 资源的更常见原因,以及如何解决它们。
在本章中,我们将涵盖以下主要主题:
-
现代 CPU 的架构
-
利用 CPU 的内部并发以获得最佳性能
-
CPU 流水线和推测执行
-
分支优化和无分支计算
-
如何评估程序是否有效地使用 CPU 资源
技术要求
再次,您将需要一个 C++编译器和一个微基准测试工具,比如我们在上一章中使用的 Google Benchmark 库(位于github.com/google/benchmark)。我们还将使用LLVM 机器码分析器(LLVM-MCA),位于llvm.org/docs/CommandGuide/llvm-mca.html。如果您想使用 MCA,您的编译器选择将更有限:您需要一个基于 LLVM 的编译器,比如 Clang。
本章的代码可以在github.com/PacktPublishing/The-Art-of-Writing-Efficient-Programs/tree/master/Chapter03找到。
性能始于 CPU
正如我们在前几章中观察到的,一个高效的程序是充分利用可用硬件资源并不浪费它们在不需要的任务上的程序。一个高性能的程序不能如此简单地描述,因为性能只能针对特定目标来定义。尽管如此,在本书中,特别是在本章中,我们主要关注计算性能或吞吐量:*我们能用现有的硬件资源多快地解决一个给定的问题?*这种性能类型与效率密切相关:如果我们的程序执行的每个计算都让我们更接近结果,并且在每一刻都尽可能多地进行计算,那么我们的程序将更快地提供结果。
这带我们来到下一个问题:*比如说,一秒钟内可以做多少计算?*答案当然取决于你拥有什么硬件,有多少硬件,以及你的程序能够有效地使用多少。任何程序都需要多个硬件组件:处理器和内存,显然,但对于任何分布式程序还需要网络,对于操纵大量外部数据的任何程序还需要存储和其他 I/O 通道,可能还需要其他硬件,具体取决于程序的功能。但一切都始于处理器,因此,我们的高性能编程探索也必然从这里开始。此外,在本章中,我们将限制自己在单个执行线程上;并发将在后面讨论。
在这个更狭窄的焦点下,我们可以定义本章的主题:如何使用单个线程最佳地利用 CPU 资源。要理解这一点,我们首先需要探索 CPU 具有哪些资源。当然,不同世代和不同型号的处理器将具有不同的硬件能力组合,但本书的目标是双重的:首先,给你一个对主题的一般理解,其次,为你提供获取更详细和具体知识所需的工具。任何现代 CPU 上可用的计算资源的一般概述可概括为它很复杂。为了说明这一点,考虑一下英特尔 CPU 的芯片图像:
图 3.1 - 带有功能区域标记的奔腾 CPU 芯片图像(来源:英特尔)
在图像的顶部叠加了主要功能区域的描述。如果这是你第一次看到这样的图像,最令人震惊的细节可能是执行单元,也就是实际进行加法、乘法和其他我们认为是 CPU 主要功能的操作的部分,实际上并没有占据整个硅片的四分之一。其余部分是其他东西,其基本目的是使加法和乘法能够有效地工作。第二个更实际相关的观察是:处理器有许多具有不同功能的组件。其中一些组件基本上可以自行工作,程序员几乎不需要做什么来充分利用它们。有些需要仔细安排机器代码,幸运的是,这大部分是由编译器完成的。但是超过一半的硅片面积都专门用于那些不仅仅是自我优化的组件:为了使处理器发挥最大性能,程序员需要了解它们的工作原理,它们能做什么,不能做什么,以及什么影响了它们操作的效率(无论是积极的还是消极的)。即使是那些自行工作良好的部分,如果真的需要异常的性能,也可以从程序员的关注中受益。
有许多关于处理器架构的书籍,包括设计者用来提高其作品性能的所有硬件技术。这些书籍可以成为宝贵的知识和理解的来源。这本书不会是又一本这样的书。它所具有的硬件描述和解释,服务于不同的目标:在这里,我们将专注于您可以探索硬件性能的实际方法,从 CPU 开始。我们将在下一节中立即开始这项探索。
用微基准测试探索性能
前一节的结果可能让你有些畏缩:处理器非常复杂,显然需要程序员大量辅助才能达到最高效率。让我们从小处着手,看看处理器可以多快地执行一些基本操作。为此,我们将使用上一章中使用过的Google Benchmark工具。这是一个用于简单数组相加的基准测试:
#include "benchmark/benchmark.h"
void BM_add(benchmark::State& state) {
srand(1);
const unsigned int N = state.range(0);
std::vector<unsigned long> v1(N), v2(N);
for (size_t i = 0; i < N; ++i) {
v1[i] = rand();
v2[i] = rand();
}
unsigned long* p1 = v1.data();
unsigned long* p2 = v2.data();
for (auto _ : state) {
unsigned long a1 = 0;
for (size_t i = 0; i < N; ++i) {
a1 += p1[i] + p2[i];
}
benchmark::DoNotOptimize(a1);
benchmark::ClobberMemory();
}
state.SetItemsProcessed(N*state.iterations());
}
BENCHMARK(BM_add)->Arg(1<<22);
BENCHMARK_MAIN();
在这个第一个例子中,我们展示了所有细节的基准测试,包括输入生成。虽然大多数操作的速度不取决于操作数的值,但我们将使用随机输入值,这样当我们进行对输入值敏感的操作时就不必担心了。还要注意的是,虽然我们将值存储在向量中,但我们不想对向量索引的速度进行基准测试:编译器几乎肯定会优化表达式v1[i]以产生与p1[i]完全相同的代码,但为什么要冒险呢?我们排除尽可能多的非必要细节,直到我们只剩下最基本的问题:我们在内存中有两个数组的值,我们想对这些数组的每个元素进行一些计算。
另一方面,我们必须关注不希望的编译器优化的可能性:编译器可能会发现整个程序只是一种非常长的无用操作(至少就 C++标准而言),并通过优化掉代码的大部分来找到更快的方法。编译器指示不要优化计算结果,并假设内存状态在基准测试迭代之间可以改变,应该防止这种优化。同样重要的是不要走向另一个极端:例如,将变量a1声明为volatile肯定会阻止大多数不希望的优化。不幸的是,它也会阻止编译器优化循环本身,这并不是我们想要的:我们想要看到 CPU 如何高效地对两个数组进行加法,这意味着生成最有效的代码。我们只是不希望编译器发现基准测试循环的第一次迭代与第二次迭代完全相同。
请注意,这是微基准的一个不太常见的应用:通常情况下,我们有一小段代码,我们想知道它有多快,以及如何使它更快。在这里,我们使用微基准来了解处理器的性能,通过调整代码的方式来获得一些见解。
基准测试应该在打开优化的情况下进行编译。运行此基准测试将产生类似以下的结果(确切的数字当然取决于您的 CPU):
图 3.2
到目前为止,除了现代 CPU 速度很快之外,我们无法从这个实验中得出太多结论:它们可以在不到一纳秒的时间内添加两个数字。如果你感兴趣,你可以在这一点上探索其他操作:减法和乘法所花费的时间与加法完全相同,而整数除法则相当昂贵(比加法慢三到四倍)。
为了分析我们的代码的性能,我们必须以处理器看到的方式来看待它,这里发生的事情远不止简单的加法。两个输入数组存储在内存中,但加法或乘法操作是在寄存器中的值之间执行的(或者可能是在寄存器和内存位置之间执行,对于某些操作)。这就是处理器逐步看到我们循环的一次迭代。在迭代开始时,索引变量i在一个 CPU 寄存器中,两个对应的数组元素v1[i]和v2[i]在内存中:
图 3.3 - 第 i 次循环迭代开始时的处理器状态
在我们做任何事情之前,我们必须将输入值移入寄存器。必须为每个输入分配一个寄存器,再加上一个寄存器用于结果。在给定的循环迭代中,第一条指令将一个输入加载到寄存器中:
图 3.4 - 第 i 次迭代的第一条指令后的处理器状态
读取(或加载)指令使用包含索引i和数组v1位置的寄存器来访问值v1[i]并将其复制到寄存器中。下一条指令类似地加载第二个输入:
图 3.5 - 第 i 次迭代的第二条指令后的处理器状态
现在我们终于准备好执行加法或乘法等操作了:
图 3.6 - 第 i 次循环迭代结束时的处理器状态
这行简单的代码在转换为硬件指令后产生了所有这些步骤(以及推进到循环的下一个迭代所需的操作):
a1 += p1[i] + p2[i];
从效率的角度来看,我们希望关注最后一步:我们的 CPU 可以在不到一纳秒的时间内对两个数字进行加法或乘法运算,这还不错,但它还能做更多吗?许多晶体管专门用于处理和执行指令,因此它们必须还能做更多。让我们尝试在相同的值上执行两个操作,而不仅仅是一个:
void BM_add_multiply(benchmark::State& state) {
… prepare data …
for (auto _ : state) {
unsigned long a1 = 0, a2 = 0;
for (size_t i = 0; i < N; ++i) {
a1 += p1[i] + p2[i];
a2 += p1[i] * p2[i];
}
benchmark::DoNotOptimize(a1);
benchmark::DoNotOptimize(a2);
benchmark::ClobberMemory();
}
state.SetItemsProcessed(N*state.iterations());
}
如果加法需要一纳秒,乘法需要一纳秒,那么两者需要多长时间?基准测试给出了答案:
图 3.7 – 单条指令和两条指令的基准测试
图 3.7 – 单条指令和两条指令的基准测试
令人惊讶的是,这里的一加一等于一。我们可以在一个迭代中添加更多的指令:
for (size_t i = 0; i < N; ++i) {
a1 += p1[i] + p2[i];
a2 += p1[i] * p2[i];
a3 += p1[i] << 2;
a4 += p2[i] – p1[i];
}
每次迭代的时间仍然相同(轻微差异在基准测试测量的精度范围内):
图 3.8 – 每次迭代最多四条指令的循环基准测试
似乎我们对处理器一次执行一条指令的观点需要修订:
图 3.9 – 处理器在单个步骤中执行多个操作
只要操作数已经在寄存器中,处理器就可以同时执行多个操作。这被称为指令级并行性(ILP)。当然,可以执行的操作数量是有限的:处理器只有那么多能够进行整数计算的执行单元。尽管如此,通过在一个迭代中添加越来越多的指令来尝试推动 CPU 的极限是很有教育意义的:
for (size_t i = 0; i < N; ++i) {
a1 += p1[i] + p2[i];
a2 += p1[i] * p2[i];
a3 += p1[i] << 2;
a4 += p2[i] – p1[i];
a5 += (p2[i] << 1)*p2[i];
a6 += (p2[i] - 3)*p1[i];
}
处理器可以执行的确切指令数量取决于 CPU 和指令,但是与单个乘法相比,上一个循环显示出明显的减速,至少在我使用的机器上是这样的:
图 3.10 – 每次迭代八条指令的基准测试
现在您可以欣赏到我们原始代码在硬件利用方面是多么低效:CPU 显然可以在每次迭代中执行五到七个不同的操作,因此我们的单个乘法甚至没有占用其四分之一的能力。事实上,现代处理器的能力更加令人印象深刻:除了我们一直在进行实验的整数计算单元之外,它们还具有专门用于执行double或float值的指令的独立浮点硬件,以及同时执行 MMX、SSE、AVX 和其他专门指令的矢量处理单元!
可视化指令级并行性
到目前为止,我们对 CPU 能够并行执行多条指令的能力的结论是基于强有力但间接的证据。从机器码分析器(MCA)可以得到直接证实,这是确实发生的。分析器是 LLVM 工具链的一部分。分析器以汇编代码作为输入,并报告有关指令执行方式、延迟和瓶颈等方面的大量信息。我们不打算在这里学习这个高级工具的所有功能(有关详细信息,请参阅项目主页llvm.org/docs/CommandGuide/llvm-mca.html)。但是,我们现在可以使用它来查看 CPU 如何执行我们的操作。
第一步是使用分析器标记代码,以选择要分析的代码部分:
#define MCA_START __asm volatile("# LLVM-MCA-BEGIN");
#define MCA_END __asm volatile("# LLVM-MCA-END");
…
for (size_t i = 0; i < N; ++i) {
MCA_START
a1 += p1[i] + p2[i];
MCA_END
}
您不必为分析器标记使用#define,但我发现记住这些命令比记住确切的汇编语法更容易(您可以将#define行保存在头文件中,并根据需要包含它)。为什么我们只标记了循环体而不是整个循环进行分析?分析器实际上假设所选的代码片段在循环中运行,并重复了一些迭代次数(默认为十次)。您可以尝试标记整个循环进行分析,但是根据编译器的优化,这可能会使分析器混淆(这是一个强大的工具,但在撰写本文时并不容易使用或特别健壮)。
我们现在可以运行分析器了:
图 3.11
请注意,我们没有将代码编译成可执行文件,而是生成了 Intel 语法的汇编输出(-S)。输出被导入分析器;分析器可以以许多方式报告结果,我们选择了时间表输出。时间表视图显示每条指令在执行过程中的移动。让我们分析两个代码片段,一个只有一个操作(加法或乘法),另一个有两个操作。这是只有一个乘法的迭代的时间表(我们已经删除了时间表中间的所有行):
图 3.12
水平轴是周期时间。分析器模拟运行所选代码片段进行十次迭代;每条指令都用代码中的顺序号和迭代索引来标识,因此第一次迭代的第一条指令的索引是[0,0],最后一条指令的索引是[9,2]。这最后一条指令也是第十次迭代的第三条指令(每次迭代只有三条指令)。整个序列根据时间轴花费了 55 个周期。
现在让我们添加另一个使用已经从内存中读取的值p1[i]和p2[i]的操作:
#define MCA_START __asm volatile("# LLVM-MCA-BEGIN");
#define MCA_END __asm volatile("# LLVM-MCA-END");
…
for (size_t i = 0; i < N; ++i) {
MCA_START
a1 += p1[i] + p2[i];
a2 += p1[i] * p2[i];
MCA_END
}
让我们看一下每次迭代有两个操作的代码的时间表,一个是加法,一个是乘法:
图 3.13
现在执行的指令数量增加了很多,每次迭代有六条指令(最后一条指令的索引是[9,5])。然而,时间表的持续时间只增加了一个周期:在图 3.12中,时间表在第 54 个周期结束,而在图 3.13中,它在第 55 个周期结束。正如我们所怀疑的那样,处理器设法在相同的时间内执行了两倍的指令。
您可能还注意到,到目前为止,我们所有的基准测试都增加了对相同输入值的操作次数(加法、减法、乘法等)。我们得出结论,这些额外的操作在运行时来说是免费的(在一定程度上)。这是一个重要的一般性教训:一旦您在寄存器中有了一些值,对相同的值进行计算可能不会给您带来任何性能损失,除非您的程序已经非常高效,并且已经极限地利用了硬件。不幸的是,这个实验和结论的实际价值有限。有多少次您的所有计算都只是在少数几个输入上进行,下一次迭代使用自己的输入,并且您可以找到更多有用的计算来处理相同的输入?并不是从来没有,但很少。任何试图扩展我们对 CPU 计算能力的简单演示的尝试都将遇到一个或多个复杂性。第一个是数据依赖:循环的顺序迭代通常不是独立的;相反,每次迭代都需要来自前几次迭代的一些数据。我们将在下一节中探讨这种情况。
数据依赖和流水线
到目前为止,我们对 CPU 功能的分析表明,只要操作数已经在寄存器中,处理器就可以同时执行多个操作:我们可以评估一个相当复杂的表达式,只要花费与这些值相加的时间一样多。只取决于两个值的限定词,不幸的是,这是一个非常严重的限制。现在我们考虑一个更现实的代码示例,我们不需要对我们的代码进行太多更改:
for (size_t i = 0; i < N; ++i) {
a1 += (p1[i] + p2[i])*(p1[i] - p2[i]);
}
回想一下,旧代码中有相同的循环,但主体更简单:a1 += (p1[i] + p2[i]);。此外,p1[i]只是向量元素v1[i]的别名,p2和v2也是如此。为什么这段代码更复杂?我们已经看到处理器可以在一个周期内进行加法、减法和乘法,而表达式仍然只取决于两个值,v1[i]和v2[i]。然而,这个表达式不能在一个周期内评估。为了澄清这一点,我们引入了两个临时变量,它们实际上只是在表达式评估过程中的中间结果的名称:
for (size_t i = 0; i < N; ++i) {
s[i] = (p1[i] + p2[i]);
d[i] = (p1[i] - p2[i]);
a1[i] += s[i]*d[i];
}
加法和减法的结果s[i]和d[i]可以在同一时间进行评估,就像我们之前看到的那样。然而,最后一行直到我们有了s[i]和d[i]的值才能执行。无论 CPU 可以同时执行多少次加法和乘法:你不能计算输入未知的操作的结果;因此,CPU 必须等待乘法的输入准备就绪。第 i 次迭代必须分两步执行:首先,我们必须加法和减法(我们可以同时进行),其次,我们必须乘以结果。现在迭代需要两个周期而不是一个,因为计算的第二步取决于第一步产生的数据:
图 3.14 - 循环评估中的数据依赖
即使 CPU 有资源可以同时执行所有三个操作,由于我们计算中固有的数据依赖性,我们无法利用这种能力。当然,这严重限制了我们如何有效地使用处理器。数据依赖在程序中非常常见,但幸运的是,硬件设计者提出了一个有效的解决方法。仔细考虑图 3.14。我们有乘法硬件单元在我们计算s[i]和d[i]的值时闲置着。我们不能提前开始计算它们的乘积,但我们可以做另一件事:我们可以同时计算上一次迭代中s[i-1]和d[i-1]的值。现在循环的两次迭代在时间上交错进行:
图 3.15 - 流水线:行对应于连续的迭代;同一行中的所有操作同时执行
这种代码的转换被称为流水线:一个复杂的表达式被分解成阶段,并在流水线中执行,前一个表达式的第二阶段与下一个表达式的第一阶段同时运行(更复杂的表达式会有更多的阶段,需要更深的流水线)。如果我们的期望是正确的,只要有很多次迭代,CPU 就能够像单个乘法一样快速计算我们的两阶段加减乘表达式:第一次迭代将需要两个周期(先加减,然后乘),这是无法避免的。同样地,最后一次迭代将以单个乘法结束,我们无法同时做其他事情。然而,在中间的所有迭代中,将同时执行三个操作。我们已经知道我们的 CPU 可以同时进行加法、减法和乘法。乘法属于循环的不同迭代这个事实并不重要。
我们可以通过直接的基准测试来确认我们的期望,比较每次循环迭代执行一次乘法所需的时间和执行我们的两步迭代所需的时间:
图 3.16
如预期的那样,两个循环的运行速度基本相同。我们可以得出结论,流水线完全抵消了数据依赖造成的性能损失。请注意,流水线并没有消除数据依赖;每个循环迭代仍然需要在两个阶段中执行,第二阶段依赖于第一阶段的结果。然而,通过交错计算不同阶段的计算,流水线确实消除了由此依赖引起的低效(至少在理想情况下,这是我们目前的情况)。通过机器代码分析器的结果,我们可以更直接地确认这一点:
图 3.17 – 流水线加减乘循环的时间轴视图(顶部)与单个乘法循环的时间轴视图(底部)
正如你所看到的,执行任何一个循环的十次迭代都需要 56 个周期。时间轴上的关键步骤是指令执行时:e标记执行的开始,E标记执行的结束。流水线的效果在时间轴上清晰可见:循环的第一次迭代在第二个周期开始执行,使用指令[0,0];第一次迭代的最后一条指令在第 18 个周期执行完成(水平轴是周期数)。第二次迭代在第 4 个周期开始执行,也就是说,两次迭代有显著的重叠。这就是流水线的作用,你可以看到它如何提高了程序的效率:几乎每个周期,CPU 都在使用多个计算单元执行多个迭代的指令。执行一个简单循环和执行一个更复杂的循环所需的周期数是一样的,所以额外的机器操作不需要额外的时间。
本章不是 Machine Code Analyzer 的使用手册:要更好地理解它产生的时间线和其他信息,您应该研究它的文档。然而,有一个问题我们必须指出。我们循环的每次迭代不仅具有相同的 C++代码,而且还具有完全相同的机器代码。这是有道理的:流水线是由硬件完成的,而不是由编译器完成;编译器只是为一次迭代生成代码以及推进到下一次迭代(或在完成时退出循环所需的操作)。处理器并行执行多条指令;我们可以在时间线上看到这一点。但是仔细观察后会发现有些地方不合理:例如,看一下图 3.17中的指令[0,4]。它在 6 到 12 周期内执行,并使用寄存器 CPUrax和rsi。现在看一下在 8 和 9 周期内执行的指令[1,2]:它也使用相同的寄存器,实际上写入了寄存器rsi,而这个寄存器在同一时间仍然被其他指令使用。这是不可能的:虽然 CPU 可以使用其许多独立的计算单元同时执行多个操作,但它不能同时在同一个寄存器中存储两个不同的值。这个矛盾实际上一直存在,尽管在图 3.15中已经很好地隐藏了起来:假设编译器只为所有迭代生成一份代码副本,我们将用来存储s[i]值的寄存器恰好与我们需要读取s[i-1]值的寄存器相同,并且两个操作同时发生。
重要的是要明白,我们并不是寄存器用尽了:CPU 的寄存器比我们目前看到的要多得多。问题在于,每次迭代的代码看起来都和下一次迭代的代码一模一样,包括寄存器的名称,但是在每次迭代中,不同的值必须存储在寄存器中。看起来我们假设和观察到的流水线似乎是不可能的:下一次迭代必须等待前一次迭代停止使用它所需的寄存器。这并不是真正发生的情况,这个明显的矛盾的解决方案是硬件技术称为rsi,不是真正的寄存器名称,它们由 CPU 映射到实际的物理寄存器。相同的名称rsi可以映射到不同的寄存器,它们都具有相同的大小和功能。
当处理器在流水线中执行代码时,第一次迭代中引用rsi的指令实际上将使用一个我们称之为rsi1的内部寄存器(这不是它的真实名称,但是寄存器的实际硬件名称不是你会遇到的,除非你是在设计处理器)。第二次迭代也有引用rsi的指令,但需要在那里存储不同的值,因此处理器将使用另一个寄存器rsi2。除非第一次迭代不再需要存储在rsi中的值,否则第三次迭代将不得不使用另一个寄存器,依此类推。这种寄存器重命名是由硬件完成的,与编译器完成的寄存器分配非常不同(特别是对于分析目标代码的任何工具,如 LLVM-MCA 或分析器,它是完全不可见的)。最终的效果是循环的多个迭代现在被执行为代码的线性序列,就好像s[i]和s[i+1]确实是指向不同的寄存器一样。
将循环转换为线性代码称为循环展开;这是一种流行的编译器优化技术,但这次是在硬件中完成的,对于能够有效处理数据依赖是至关重要的。编译器的观点更接近源代码的编写方式:单次迭代,一组机器指令,通过跳回到代码片段开始的地方进行重复执行。处理器的观点更像是你在时间线上看到的,一系列线性指令,其中每次迭代都有自己的代码副本并且可以使用不同的寄存器。
我们可以做出另一个重要的观察:CPU 执行我们的代码的顺序实际上并不是指令编写的顺序。这被称为乱序执行,并对多线程程序有重要影响。
我们已经看到处理器如何避免数据依赖所施加的执行效率限制:对数据依赖的解药就是流水线。然而,故事并不会在那里结束,到目前为止我们已经设计的非常简单的循环的执行方案缺少了一些重要的东西:循环必须在某个时刻结束。在下一节中,我们将看到这会使事情变得更加复杂,以及解决方案是什么。
流水线和分支
到目前为止,我们对处理器的高效使用的理解是:首先,CPU 可以同时执行多个操作,比如同时进行加法和乘法。不充分利用这种能力就像把免费的计算能力留在桌子上一样。其次,限制我们最大化效率的因素是我们能够产生数据以供这些操作的速度有多快。具体来说,我们受到数据依赖的限制:如果一个操作计算出下一个操作用作输入的值,那么这两个操作必须按顺序执行。解决这种依赖的方法是流水线:在执行循环或长序列的代码时,处理器会交错进行单独的计算,比如循环迭代,只要它们至少有一些可以独立执行的操作。
然而,流水线也有一个重要的前提。流水线if(condition)语句,我们将执行true分支或false分支,但在评估condition之前我们不知道哪个分支会执行。就像数据依赖是指令级并行性的祸根一样,条件执行或分支是流水线的祸根。
流水线被打断后,我们可以预期程序的效率会显著降低。修改我们之前的基准测试以观察条件语句的有害影响应该是非常容易的。例如,不是写:
a1 += p1[i] + p2[i];
我们可以这样写:
a1 += (p1[i]>p2[i]) ? p1[i] : p2[i];
现在我们已经将数据依赖重新引入为代码依赖:
图 3.18 - 分支指令对流水线的影响
没有明显的方法将这段代码转换为线性指令流来执行,条件跳转无法避免。
现实情况要复杂一些:像我们刚刚建议的基准测试可能会或可能不会显示出性能的显著下降。原因是许多处理器都有某种条件移动甚至条件加法指令,编译器可能会决定使用它们。如果发生这种情况,我们的代码就变成了完全顺序的,没有跳转或分支,可以完美地进行流水线处理:
图 3.19 - 有条件代码与 cmove 进行流水线处理
x86 CPU 具有条件移动指令cmove(尽管并非所有编译器都会使用它来实现前面图中的?:运算符)。具有 AVX 或 AVX2 指令集的处理器具有一组强大的掩码加法和乘法指令,可以用来实现一些条件代码。因此,在对带有分支的代码进行基准测试和优化时,非常重要的是要检查生成的目标代码,并确认代码确实包含分支,并且它们确实影响了性能。还有一些性能分析工具可以用于此目的,我们马上就会看到其中一个。
虽然分支和条件在大多数实际程序中随处可见,但当程序被简化为用于基准测试的几行代码时,它们可能会消失。一个原因是编译器可能决定使用我们之前提到的条件指令之一。另一个常见的原因是在构建不良的基准测试时,编译器可能能够在编译时弄清楚条件的评估结果。例如,大多数编译器将完全优化掉任何类似if (true)或if (false)的代码:在生成的代码中没有这个语句的痕迹,任何永远不会被执行的代码也被消除了。为了观察分支对循环流水线的不利影响,我们必须构建一个测试,使编译器无法预测条件检查的结果。在您的实际基准测试中,您可能会从实际程序中提取数据集。对于下一个演示,我们将使用随机值:
std::vector<unsigned long> v1(N), v2(N);
std::vector<int> c1(N);
for (size_t i = 0; i < N; ++i) {
v1[i] = rand();
v2[i] = rand();
c1[i] = rand() & 1;
}
unsigned long* p1 = v1.data();
unsigned long* p2 = v2.data();
int* b1 = c1.data();
for (auto _ : state) {
unsigned long a1 = 0, a2 = 0;
for (size_t i = 0; i < N; ++i) {
if (b1[i]) {
a1 += p1[i];
} else {
a1 *= p2[i];
}
}
benchmark::DoNotOptimize(a1);
benchmark::DoNotOptimize(a2);
benchmark::ClobberMemory();
}
同样,我们有两个输入向量v1和v2,以及一个控制向量c1,其中包含随机的零和一的值(在这里避免使用vector<bool>,因为它不是字节的数组,而是位的打包数组,因此访问它的成本要高得多,而我们目前不感兴趣基准测试位操作指令)。编译器无法预测下一个随机数是奇数还是偶数,因此无法进行任何优化。此外,我们已经检查了生成的机器代码,并确认我们的编译器(x86 上的 Clang-11)使用简单的条件跳转来实现这个循环。为了有一个基准,我们将比较这个循环的性能与每次迭代都进行无条件加法和乘法的循环:a1 += p1[i]*p2[i]。这个更简单的循环在每次迭代中都进行加法和乘法;然而,由于流水线处理,我们可以免费得到加法:它与下一次迭代的乘法同时执行。另一方面,条件分支则完全不是免费的。
图 3.20
如您所见,条件代码比顺序代码慢大约五倍。这证实了我们的预测,即当下一条指令依赖于前一条指令的结果时,代码无法有效地进行流水线处理。
分支预测
然而,一个敏锐的读者可能会指出,我们刚刚描述的情况不可能是完整的,甚至不是真实的:让我们回到刚才的线性代码,比如我们在上一节中广泛使用的循环:
for (size_t i = 0; i < N; ++i) {
a1 += v1[i] + v2[i]; // s[i] = v1[i] + v2[i]
}
从处理器的角度来看,这个循环的主体是这样的:
图 3.21 – 在宽度为 w 的流水线中执行的循环
在图 3.21中,我们展示了三个交错的迭代,但可能会有更多,流水线的总宽度是w,理想情况下,w足够大,以至于在每个周期,CPU 正好执行与其同时执行的指令一样多(在实践中很少可能达到这种峰值效率)。然而,可能无法在计算和存储和p1[i] + p2[i]的同时访问v[i+2]:没有保证循环还有两次迭代,如果没有,元素v[i+2]就不存在,访问它会导致未定义的行为。在前面的代码中有一个隐藏的条件:在每次迭代中,我们必须检查i是否小于N,只有在这种情况下才能执行第 i 次迭代的指令。
因此,我们在图 3.20中的比较是错误的:我们并没有比较流水线顺序执行和不可预测的条件执行。事实上,这两个基准都是条件代码的例子,它们都有分支。
完整的真相在其中。要理解这一点,我们必须了解条件执行的解药,它会破坏流水线,并且本身是数据依赖的解药。在分支存在的情况下保持流水线的方法是尝试将条件代码转换为顺序代码。如果我们事先知道分支将采取的路径,就可以进行这种转换:我们只需消除分支并继续执行下一个要执行的指令。当然,如果我们事先知道条件是什么,甚至不需要编写这样的代码。但是,考虑循环终止条件。假设循环执行多次,很可能条件i < N评估为true(我们只有N次中的一次会输掉这个赌注)。
处理器使用称为分支预测的技术进行同样的赌博。它分析代码中每个分支的历史,并假设行为在未来不会改变。对于循环结束条件,处理器将很快学会,大多数情况下,它必须继续下一次迭代。因此,正确的做法是流水线下一次迭代,就好像我们确定它会发生一样。当然,我们必须推迟将结果写入内存,直到我们评估条件并确认迭代确实发生;处理器有一定数量的写缓冲区来保存这些未经确认的结果,在提交到内存之前。
因此,仅进行加法的循环的流水线看起来确实如图 3.21所示。唯一的问题是,在完成第 i 次迭代之前开始执行第i+2次迭代时,处理器是基于其对条件分支是否被执行的预测而进行的一种赌博。在我们确定这段代码是否真的存在之前执行代码的这种方式被称为推测执行。如果赌赢了,我们在弄清楚我们需要计算时已经有了结果,一切都很好。如果处理器输了,它必须放弃一些计算以避免产生不正确的结果:例如,写入内存会覆盖之前的内容,并且在大多数硬件平台上无法撤消,而计算结果并将其存储在寄存器中是完全可逆的,当然除了我们浪费的时间。
现在我们对流水线的工作原理有了更全面的了解:为了在并行执行更多指令,处理器查看循环的下一次迭代的代码,并开始与当前迭代同时执行。如果代码包括条件分支,使得不可能确定将执行哪个指令,处理器会根据过去检查相同条件的结果做出合理的猜测,并继续推测性地执行代码。如果预测被证明是正确的,流水线的效果可以和无条件代码一样好。如果预测是错误的,处理器必须丢弃不应该被评估的每条指令的结果,获取先前假定不需要的指令,并代替评估它们。这个事件被称为流水线刷新,这确实是一个昂贵的事件。
现在我们对图 3.20中的先前基准有了更好的理解:两个循环都有一个用于检查循环结束的条件。但是,它几乎完美地预测。 条件基准还具有基于随机数的分支:if(b1[i]),其中b1[i] 50%的时间是 true,随机的。处理器无法预测结果,管道一半的时间被破坏(或者更糟,如果我们设法混淆 CPU 以实际进行错误预测)。
我们应该能够通过直接实验来验证我们的理解:我们只需要将随机条件更改为始终为 true 的条件。唯一的问题是我们必须以编译器无法理解的方式来做到这一点。一种常见的方法是将条件向量的初始化更改为以下内容:
c1[i] = rand() >= 0;
编译器不知道函数rand()总是返回非负随机数,并且不会消除条件。 CPU 的分支预测电路将很快学习到条件if(b1[i])总是评估为 true,并将推测性地执行相应的代码。我们可以将预测良好的分支的性能与不可预测的分支进行比较:
图 3.22
在这里,我们可以看到预测良好的分支的成本是最小的,比预测不佳的分支快得多,即使是完全相同的代码。
分支错误预测的分析
现在,您已经看到单个错误预测的分支会如何影响代码的性能,您可能会想知道,您如何找到这样的代码以便进行优化?当然,包含此代码的函数将花费比您预期的时间更长,但是您如何知道这是因为错误预测的分支还是由于其他一些低效性?到目前为止,您应该已经了解足够多,以避免对性能进行一般性的猜测;但是,对分支预测器的有效性进行推测尤其是徒劳的。幸运的是,大多数分析器不仅可以分析执行时间,还可以分析决定效率的各种因素,包括分支预测失败。
在本章中,我们将再次使用perf分析器。作为第一步,我们可以运行此分析器以收集整个基准程序的整体性能指标:
$ perf stat ./benchmark
这是仅运行BM_branch_not_predicted基准的程序的perf结果(其他基准已在此测试中注释掉):
图 3.23 - 具有预测不佳分支的基准的概要
如您所见,所有分支中有 11%被错误预测(报告的最后一行)。请注意,此数字是所有分支的累积值,包括循环条件的完全可预测结尾,因此总共 11%相当糟糕。我们应该将其与我们的其他基准BM_branch_predicted进行比较,该基准与此基准相同,只是条件始终为真:
图 3.24 - 具有良好预测分支的基准的配置文件
这一次,不到 0.1%的分支没有被正确预测。
整体性能报告非常有用,不要忽视其潜力:它可以快速突出或排除一些可能导致性能不佳的原因。在我们的情况下,我们可以立即得出结论,即我们的程序受到一个或多个错误预测分支的影响。现在我们只需要找出是哪一个,分析器也可以帮助解决这个问题。就像在上一章中,我们已经使用分析器找出程序在代码中花费最多时间的地方一样,我们可以生成分支预测的详细逐行分析。我们只需要向分析器指定正确的性能计数器:
$ perf record -e branches,branch-misses ./benchmark
在我们的情况下,我们可以从perf stat的输出中复制计数器的名称,因为它恰好是默认情况下测量的计数器之一,但是完整列表可以通过运行perf --list来获取。
分析器运行程序并收集指标。我们可以通过生成配置文件来查看它们:
$ perf report
报告分析器是交互式的,让我们可以导航到每个函数的分支错误预测计数器:
图 3.25 - 针对错误预测分支的详细配置文件
超过 99%的错误预测分支发生在一个函数中。由于该函数很小,找到负责的条件操作不应该很难。在较大的函数中,我们需要查看逐行分析。
现代处理器的分支预测硬件相当复杂。例如,如果从两个不同的位置调用一个函数,并且当从第一个位置调用时,条件通常为真,而当从第二个位置调用时,相同的条件为假,那么预测器将学习该模式,并根据函数调用的来源正确预测分支。类似地,预测器可以检测数据中相当复杂的模式。例如,我们可以初始化我们的random条件变量,使值始终交替,第一个是随机的,但下一个是第一个的相反,依此类推:
for (size_t i = 0; i < N; ++i) {
if (i == 0) c1[i] = rand() >= 0;
else c1[i] = !c1[i - 1];
}
分析器确认该数据的分支预测率非常好:
图 3.26 - “真-假”模式的分支预测率
我们几乎准备好了应用我们如何有效使用处理器的知识。但首先,我必须承认我们忽视了一个重大的潜在问题。
推测执行
我们现在了解了流水线如何使 CPU 保持忙碌,以及通过预测条件分支的结果并在我们确定必须执行之前就进行预期代码的执行,我们允许条件代码进行流水线处理。图 3.21说明了这种方法:假设循环条件在当前迭代之后不会发生,我们可以将下一次迭代的指令与当前迭代的指令交错,因此我们可以并行执行更多指令。
迟早,我们的预测会是错误的,但我们所要做的就是丢弃一些本来不应该被计算的结果,并且让它看起来好像它们确实从未被计算过。这会花费我们一些时间,但当分支预测正确时,我们通过加速流水线来弥补这一点。但是,这真的是我们必须做的一切来掩盖我们试图执行一些实际上并不存在的代码的事实吗?
再次考虑图 3.21:如果第 i 次迭代是循环中的最后一次迭代,那么下一次迭代就不应该发生。当然,我们可以丢弃值a[i+1]并且不将其写入内存。但是,为了进行任何流水线处理,我们必须读取v1[i+1]的值。我们无法丢弃我们读取它的事实:我们在检查迭代i是否是最后一次迭代之前就访问了v1[i+1],并且无法否认我们确实访问了它。但是元素v1[i+1]在为向量分配的有效内存区域之外;即使读取它也会导致未定义的行为。
隐藏在“推测执行”无辜标签背后的危险的更有说服力的例子是这个非常常见的代码:
int f(int* p) {
if (p) {
return *p;
} else {
return 0;
}
}
让我们假设指针p很少是NULL,所以分支预测器学习到if(p)语句的true分支通常被执行。当函数最终以p == NULL被调用时会发生什么?分支预测器会像往常一样假设相反,并且true分支会被推测执行。它首先会对NULL指针进行解引用。我们都知道接下来会发生什么:程序会崩溃。后来,我们会发现糟糕,非常抱歉,我们一开始不应该选择那个分支,但是如何撤销一个崩溃呢?
从像我们的函数f()这样的代码非常常见且不会遭受意外随机崩溃的事实,我们可以得出结论,要么推测执行实际上并不存在,要么有一种方法可以撤销崩溃,或者类似的。我们已经看到一些证据表明推测执行确实发生并且对提高性能非常有效。我们将在下一章中看到更多直接证据。那么,当我们试图推测执行一些不可能的事情时,比如对NULL指针进行解引用,它是如何处理的呢?答案是,对于这种潜在灾难的灾难性响应必须被暂时保留,既不被丢弃也不被允许成为现实,直到分支条件实际被评估,并且处理器知道推测执行是否应该被视为真正的执行。在这方面,故障和其他无效条件与普通的内存写入没有什么不同:只要导致该操作的指令仍然是推测的,任何无法撤销的操作都被视为潜在操作。CPU 必须有特殊的硬件电路,比如缓冲区,来暂时存储这些事件。最终结果是,处理器确实对NULL指针进行解引用或者在推测执行期间读取不存在的向量元素v[i+1],然后假装这从未发生过。
现在我们了解了分支预测和推测执行如何使处理器能够在数据和代码依赖性造成的不确定性的情况下高效运行,我们可以把注意力转向优化我们的程序。
复杂条件的优化
对于有许多条件语句的程序,通常是if()语句,分支预测的有效性通常决定了整体性能。如果分支预测准确,几乎没有成本。如果分支一半时间被错误预测,它可能会像十个或更多常规算术指令一样昂贵。
非常重要的是要理解,硬件分支预测是基于处理器执行的条件指令。因此,处理器对于条件的理解可能与我们的理解不同。以下示例有力地证明了这一点:
std::vector<unsigned long> v1(N), v2(N);
std::vector<int> c1(N), c2(N);
for (size_t i = 0; i < N; ++i) {
v1[i] = rand();
v2[i] = rand();
c1[i] = rand() & 0x1;
c2[i] = !c1[i];
}
unsigned long* p1 = v1.data();
unsigned long* p2 = v2.data();
int* b1 = c1.data();
int* b2 = c2.data();
for (auto _ : state) {
unsigned long a1 = 0, a2 = 0;
for (size_t i = 0; i < N; ++i) {
if (b1[i] || b2[i]) { // !!!
a1 += p1[i];
} else {
a1 *= p2[i];
}
}
benchmark::DoNotOptimize(a1);
benchmark::DoNotOptimize(a2);
benchmark::ClobberMemory();
}
这里感兴趣的是条件if (b1[i] || b2[i]):按构造,它总是评估为true,因此我们可以期望处理器的完美预测率。当然,如果事情真的那么简单,我就不会向您展示这个例子了。对于我们来说逻辑上是一个条件的东西,在 CPU 看来是两个单独的条件分支:一半的时间,整体结果是由第一个分支决定的,另一半的时间,是第二个分支使其为真。整体结果总是为真,但无法预测哪个分支使其为真。结果非常不幸:
图 3.27 - “假”分支的分支预测概况
分析器显示的分支预测率与真正随机分支一样糟糕。性能基准证实了我们的期望:
图 3.28
假分支(实际上根本不是分支)的性能与真正随机、不可预测的分支一样糟糕,远远不如预测良好的分支。
在真实的程序中,不应该遇到这种不必要的条件语句。然而,非常常见的是一个复杂的条件表达式,几乎总是基于不同的原因评估为相同的值。例如,我们可能有一个很少为假的条件:
if ((c1 && c2) || c3) {
… true branch …
} else {
… false branch …
}
然而,将近一半的时间,c3为真。当c3为假时,c1和c2通常都为真。整体条件应该很容易预测,并且会执行真分支。然而,从处理器的角度来看,这不是一个单一的条件,而是三个单独的条件跳转:如果c1为真,则必须检查c2。如果c2也为真,则执行跳转到真分支的第一条指令。如果c1或c2中的一个为假,则检查c3,如果为真,则再次执行跳转到真分支。
必须按特定顺序逐步进行这种评估的原因是,C++标准(以及之前的 C 标准)规定逻辑操作(如&&和||)是短路的:一旦整个表达式的结果已知,评估剩下的表达式应该停止。当条件具有副作用时,这一点尤为重要:
if (f1() || f2()) {
… true branch …
} else {
… false branch …
}
现在,只有在f1()返回false时才会调用函数f2()。在前面的示例中,条件只是布尔变量c1、c2和c3。编译器可以检测到没有副作用,并且评估整个表达式到最后不会改变可观察的行为。一些编译器会进行这种优化;如果我们的假分支基准是用这样的编译器编译的,它将显示出预测良好分支的性能。不幸的是,大多数编译器不会将这视为潜在问题(实际上,编译器无法知道整个表达式通常评估为真,即使它的部分不是)。因此,这是程序员通常必须手动进行的优化。
假设程序员知道if()语句的两个分支中的一个经常被执行。例如,else分支可能对应于错误情况或其他异常情况,必须正确处理,但在正常操作下不应该出现。让我们还假设我们做对了事情,并且使用分析器验证了组成复杂布尔表达式的各个条件指令的预测不准确。我们如何优化代码呢?
第一个冲动可能是将条件评估移出if()语句:
const bool c = c1 && c2) || c3;
if (c) { … } else { … }
然而,这几乎肯定不会起作用,原因有两个。首先,条件表达式仍在使用逻辑&&和||操作,因此评估仍必须被短路,并且需要单独且不可预测的分支。其次,编译器可能通过删除不必要的临时变量c来优化此代码,因此生成的目标代码可能根本不会改变。
在循环遍历条件变量数组的情况下,类似的转换可能是有效的。例如,这段代码可能会受到较差的分支预测的影响:
for (size_i i = 0; i < N; ++i) {
if ((c1[i] && c2[i]) || c3[i]) { … } else { … }
}
然而,如果我们预先评估所有条件表达式并将它们存储在一个新数组中,大多数编译器不会消除该临时数组:
for (size_i i = 0; i < N; ++i) {
c[i] = (c1[i] && c2[i]) || c3[i];
}
…
for (size_i i = 0; i < N; ++i) {
if (c[i]) { … } else { … }
}
当然,用于初始化c[i]的布尔表达式现在受到分支错误预测的影响,因此这种转换只有在第二个循环执行的次数比初始化循环多得多时才有效。
通常有效的另一个优化是用加法和乘法或位&和|操作替换逻辑&&和||操作。在执行此操作之前,必须确保&&和||操作的参数是布尔值(值为零或一),而不是整数:即使2的值被解释为 true,表达式2 & 1的结果与bool(2) & bool(1)的结果不同。前者评估为 0(false),而后者给出了预期和正确的答案 1(或 true)。
我们可以在基准测试中比较所有这些优化的性能:
图 3.29
正如你所看到的,通过引入临时变量BM_false_branch_temp来优化false branch的天真尝试是完全无效的。使用临时向量给出了预期的完全预测分支的性能,因为临时向量的所有元素都等于 true,这是分支预测器学到的内容(BM_false_branch_vtemp)。用算术加法(+)或位|替换逻辑||产生类似的结果。
您应该记住,最后两种转换,即使用算术或位操作代替逻辑操作,会改变代码的含义:特别是,表达式中操作的所有参数都会被评估,包括它们的副作用。由您决定这种改变是否会影响程序的正确性。如果这些副作用也很昂贵,那么整体性能变化可能最终不利于您。例如,如果评估f1()和f2()非常耗时,将表达式f1() || f2()中的逻辑||替换为等效的算术加法(f1() + f2())可能会降低性能,即使它改善了分支预测。
总的来说,在false branches中优化分支预测没有标准方法,这也是为什么编译器很难进行有效的优化。程序员必须使用特定于问题的知识,例如特定条件是否可能发生,并结合分析测量结果得出最佳解决方案。
在本章中,我们已经学习了 CPU 操作如何影响性能,然后进展到一个具体且实际相关的例子,应用这些知识来进行代码优化。在结束之前,我们将学习另一种优化方法。
无分支计算
到目前为止,我们已经学到了:为了有效地使用处理器,我们必须提供足够的代码,以便并行执行多条指令。我们之所以没有足够的指令来让 CPU 忙碌的主要原因是数据依赖性:我们有代码,但我们无法运行它,因为输入还没有准备好。我们通过流水线处理代码来解决这个问题,但为了这样做,我们必须提前知道哪些指令将被执行。如果我们不提前知道执行的路径,我们就无法做到这一点。我们处理这个问题的方式是根据评估这个条件的历史来做出一个合理的猜测,即猜测条件分支是否会被执行,猜测越可靠,性能就越好。有时,没有可靠的猜测方式,性能就会受到影响。
所有这些性能问题的根源是条件分支,即在运行时无法知道要执行的下一条指令。解决问题的一个激进方法是重写我们的代码,不使用分支,或者至少减少分支的数量。这就是所谓的无分支计算。
循环展开
事实上,这个想法并不是特别新颖。现在你已经了解了分支如何影响性能的机制,你可以认识到循环展开这一众所周知的技术,作为减少分支数量的早期代码转换的一个例子。让我们回到我们的原始代码示例:
for (size_t i = 0; i < N; ++i) {
a1 += p1[i] + p2[i];
}
现在我们明白了,虽然循环的主体是完全流水线化的,但这段代码中隐藏了一个分支:循环结束的检查。这个检查每次循环迭代都会执行一次。如果我们事先知道,比如说,迭代次数N总是偶数,那么我们就不需要在奇数迭代后执行检查。我们可以明确地省略这个检查,如下所示:
for (size_t i = 0; i < N; i += 2) {
a1 += p1[i] + p2[i]
+ p1[i+1] + p2[i+1];
}
我们已经展开了这个循环,将两次迭代转换为一次更大的迭代。在这个和其他类似的例子中,手动展开循环不太可能提高性能,原因有几个:首先,如果N很大,循环的末尾分支几乎可以完美预测。其次,编译器可能会将循环展开为优化;更有可能的是,矢量化编译器将使用 SSE 或 AVX 指令来实现这个循环,实际上展开了它的主体,因为矢量指令一次处理多个数组元素。所有这些结论都需要通过基准测试或性能分析来确认;如果你发现手动循环展开对性能没有影响,不要感到惊讶:这并不意味着我们对分支的了解是错误的;这意味着我们的原始代码已经受益于循环展开,很可能是由于编译器优化。
无分支选择
循环展开是一个非常具体的优化,编译器已经学会了这样做。将这个想法概括为无分支计算是一个最近的进展,可以产生惊人的性能提升。我们将从一个非常简单的例子开始:
unsigned long* p1 = ...; // Data
bool* b1 = ...; // Unpredictable condition
unsigned long a1 = 0, a2 = 0;
for (size_t i = 0; i < N; ++i) {
if (b1[i]) {
a1 += p1[i];
} else {
a2 += p1[i];
}
}
假设条件变量b1[i]不能被处理器预测。正如我们已经看到的,这段代码的运行速度比具有良好预测分支的循环慢了几倍。然而,我们可以做得更好;我们可以完全消除分支,并用指向两个目标变量的指针数组进行替换:
unsigned long* p1 = ...; // Data
bool* b1 = ...; // Unpredictable condition
unsigned long a1 = 0, a2 = 0;
unsigned long* a[2] = { &a2, &a1 };
for (size_t i = 0; i < N; ++i) {
a[b1[i]] += p1[i];
}
在这种转换中,我们利用了一个布尔变量只能有两个值,0(false)或 1(true)的事实,并且可以隐式转换为整数(如果我们使用bool之外的其他类型,我们必须确保所有true值确实由 1 表示,因为任何非零值都被认为是true,但只有 1 的值适用于我们的无分支代码)。
这种转换将对两种可能指令的条件跳转替换为对两种可能内存位置的条件访问。因为这种条件内存访问可以进行流水线处理,无分支版本提供了显著的性能改进:
图 3.30
在这个例子中,无分支版本的代码快了 3.5 倍。值得注意的是,一些编译器在可能的情况下使用查找数组来实现?:运算符,而不是条件分支。有了这样的编译器,我们可以通过将我们的循环体重写如下来获得相同的性能优势:
for (size_t i = 0; i < N; ++i) {
(b1[i] ? a1 : a2) += p1[i];
}
像往常一样,要确定这种优化是否有效以及其有效性如何,唯一的方法就是进行测量。
前面的例子涵盖了无分支计算的所有基本要素:不是有条件地执行这段代码或那段代码,而是将程序转换为在所有情况下都相同的代码,并且条件逻辑由索引操作实现。我们将通过几个更多的例子来强调一些值得注意的考虑和限制。
无分支计算示例
大多数时候,取决于条件的代码并不像写入结果那样简单。通常,我们必须根据一些中间值以不同的方式进行计算:
unsigned long *p1 = ..., *p2 = ...; // Data
bool* b1 = ...; // Unpredictable condition
unsigned long a1 = 0, a2 = 0;
for (size_t i = 0; i < N; ++i) {
if (b1[i]) {
a1 += p1[i] - p2[i];
} else {
a2 += p1[i] * p2[i];
}
}
在这里,条件影响我们计算的表达式和结果存储的位置。两个分支的共同之处只是输入,而且一般情况下甚至这个也不一定。
为了在没有分支的情况下计算相同的结果,我们必须从由条件变量索引的内存位置中获取正确表达式的结果。这意味着由于我们决定不基于条件改变执行哪些代码,因此将评估两个表达式。在这种理解下,转换为无分支形式是直接的:
unsigned long a1 = 0, a2 = 0;
unsigned long* a[2] = { &a2, &a1 };
for (size_t i = 0; i < N; ++i) {
unsigned long s[2] = { p1[i] * p2[i], p1[i] - p2[i] };
a[b1[i]] += s[b1[i]];
}
两个表达式都被评估,并它们的结果被存储在一个数组中。另一个数组用于索引计算的目标,也就是说,哪个变量被增加。总的来说,我们显著增加了循环体必须执行的计算量;另一方面,这都是顺序代码,没有跳转,所以只要 CPU 有资源可以多做一些操作而不需要额外的周期,我们应该会有所收益。基准测试证实了这种无分支转换的有效性:
图 3.31
必须强调的是,你可以进行多少额外的计算并且仍然优于条件代码是有限制的。在这里,甚至没有一个好的一般性的经验法则可以让你做出明智的猜测(而且你绝对不应该猜测性能)。这种优化的有效性必须进行测量:它高度依赖于代码和数据。例如,如果分支预测非常有效(可预测的条件而不是随机的条件),条件代码将优于无分支版本:
图 3.32
也许我们可以从图 3.31 和图 3.32 中学到的最显著的结论是流水线刷新(错误预测的分支)有多么昂贵,以及 CPU 可以同时进行多少计算。后者可以从完全预测的分支(图 3.32)和无分支实现(图 3.31)之间性能差异相对较小来推断。无分支计算依赖于这种隐藏且大部分未使用的计算能力储备,我们可能在我们的例子中还没有耗尽这个储备。展示同一代码的无分支转换的另一种变体是很有教育意义的,这种变体不是使用数组来选择正确的结果变量,而是如果我们不想实际改变结果,我们总是同时增加两个值:
unsigned long a1 = 0, a2 = 0;
for (size_t i = 0; i < N; ++i) {
unsigned long s1[2] = { 0, p1[i] - p2[i] };
unsigned long s2[2] = { p1[i] * p2[i], 0 };
a1 += s1[b1[i]];
a2 += s2[b1[i]];
}
现在我们不再有目的地数组,而是有两个中间值的数组。这个版本即使在无条件下进行更多的计算,但与之前的无分支代码一样提供了相同的性能:
图 3.33 - 图 3.31 的结果,另一种无分支实现被添加为"BM_branchless1"
了解无分支转换的局限性并不要得意忘形是很重要的。我们已经看到了第一个局限性:无分支代码通常执行更多指令;因此,如果分支预测器最终运行良好,少量的流水线刷新可能不足以证明这种优化。
无分支转换不能如预期那样执行的第二个原因与编译器有关:在某些情况下,编译器可以进行等效或者更好的优化。例如,考虑所谓的夹紧循环:
unsigned char *c = ...; // Random values from 0 to 255
for (size_t i = 0; i < N; ++i) {
c[i] = (c[i] < 128) ? c[i] : 128;
}
这个循环将数组c中的值(无符号字符)夹紧到128的限制。假设初始值是随机的,循环体中的条件无法被准确预测,我们可以预期分支错误预测率非常高。另一种无分支的实现方式使用了256个元素,每个元素对应无符号字符的可能值。索引为 0 到 127 的表项LUT[i]包含索引值本身,而较高索引的表项LUT[i]都包含 128:
unsigned char *c = ...; // Random values from 0 to 255
unsigned char LUT[256] = { 0, 1, …, 127, 128, 128, … 128 };
for (size_t i = 0; i < N; ++i) {
c[i] = LUT[c[i]];
}
对于大多数现代编译器来说,这根本不是优化:编译器通常会使用 SSE 或 AVX 矢量指令来一次复制和夹紧多个字符,而且完全没有任何分支。如果我们对原始代码进行了剖析,而不是假设分支一定会被错误预测,我们就会发现程序并没有因为分支预测不佳而受到影响。
还有一种情况下无分支转换可能不划算,那就是循环体的开销明显大于分支,即使是错误预测的分支。这种情况很重要,因为它通常描述了进行函数调用的循环:
unsigned long f1(unsigned long x, unsigned long y);
unsigned long f2(unsigned long x, unsigned long y);
unsigned long *p1 = ..., *p2 = ...; // Data
bool* b1 = ...; // Unpredictable condition
unsigned long a = 0;
for (size_t i = 0; i < N; ++i) {
if (b1[i]) {
a += f1(p1[i], p2[i]);
} else {
a += f2(p1[i], p2[i]);
}
}
在这里,我们根据条件b1调用f1()或f2()中的一个函数。if-else语句可以被消除,如果我们使用函数指针数组,代码可以变成无分支。
decltype(f1)* f[] = { f1, f2 };
for (size_t i = 0; i < N; ++i) {
a += f[b1[i]](p1[i], p2[i]);
}
这是一种值得做的优化吗?通常不是。首先,如果函数f1()或f2()可以内联,函数指针调用将阻止内联。内联通常是一种重要的优化;为了摆脱分支而放弃内联几乎是不合理的。当函数没有内联时,函数调用本身会中断流水线(这也是内联是如此有效的优化的原因之一)。与函数调用的成本相比,即使是错误预测的分支通常也不那么重要。
尽管如此,有时查找表是一种值得优化的方法:对于只有两个选择的情况几乎从不值得,但如果我们必须根据单个条件从许多函数中进行选择,函数指针表比链式if-else语句更有效。值得注意的是,这个例子与所有现代编译器用来实现虚函数调用的实现非常相似;这样的调用也是使用函数指针数组而不是一系列比较来分派的。当需要优化根据运行时条件调用多个函数的代码时,您应该考虑是否值得使用多态对象进行重新设计。
您还应该记住无分支转换对代码的可读性的影响:函数指针的查找表不如switch或if-else语句易读,而且可能比后者更难调试。考虑到最终结果的许多因素(编译器优化、硬件资源可用性、程序操作的数据的性质),任何优化都必须通过基准测试和性能分析来验证,并权衡对程序员在时间、可读性和复杂性方面的额外成本。
总结
在本章中,我们学习了主处理器的计算能力以及如何有效地使用它们。高性能的关键是充分利用所有可用的计算资源:同时计算两个结果的程序比稍后计算第二个结果的程序更快(假设计算能力可用)。正如我们所了解的,CPU 具有各种类型计算的许多计算单元,其中大多数在任何给定时刻都是空闲的,除非程序经过高度优化。
我们已经看到,有效利用 CPU 指令级并行性的主要限制通常是数据依赖性:简单地说,没有足够的并行工作来让 CPU 保持忙碌。这个问题的硬件解决方案是流水线:CPU 不仅仅执行程序中当前点的代码,而是从没有未满足数据依赖性的未来中获取一些计算,并并行执行它们。只要未来是已知的,这种方法就有效:如果 CPU 无法确定这些计算是什么,它就无法执行未来的计算。每当 CPU 必须等待确定下一条要执行的机器指令时,流水线就会停顿。为了减少这种停顿的频率,CPU 具有特殊的硬件,可以预测最有可能的未来,通过条件代码的路径,以及推测性地执行该代码。因此,程序的性能关键取决于这种预测的准确性。
我们已经学会了使用特殊工具来帮助衡量代码的效率并识别限制性能的瓶颈。在测量的指导下,我们研究了几种优化技术,可以使程序更充分地利用 CPU 资源,等待时间更少,计算更多,并最终有助于提高性能。
在本章中,我们一直忽略了每个计算最终必须执行的一步:访问内存。任何表达式的输入都驻留在内存中,并且必须在其余计算发生之前被带入寄存器。中间结果可以存储在寄存器中,但最终,某些东西必须被写回内存,否则整个代码就没有持久的效果。事实证明,内存操作(读取和写入)对性能有显著影响,并且在许多程序中是阻止进一步优化的限制因素。下一章将致力于研究 CPU 与内存的交互。
问题
-
如何有效地使用 CPU 资源的关键是什么?
-
我们如何利用指令级并行性来提高性能?
-
如果后一个计算需要前一个计算的结果,CPU 如何并行执行计算?
-
为什么条件分支比简单评估条件表达式的成本要昂贵得多?
-
什么是推测执行?
-
有哪些优化技术可用于改善具有条件计算的代码中流水线的效率?