测量性能

测量性能

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

本章将介绍两种测量性能的工具软件:

  1. 分析器(profiler):编译器厂商通常在编译器中都会提供分析器,它会生成各个函数在程序运行过程中被调用的累积时间的表格报表。对性能优化而言,它非常关键,因为它会列出程序中最热点的函数。
  2. 计时器软件(software timer):开发人员可以自己实现这个工具。即使没有分析器,开发人员依然可以通过测量长时间运行的活动来进行性能实验。计时器软件还可以用于测量不受计算限制的任务。

优化思想

必须测量性能

只有测量才能告诉你优化的结果,理由如下:

  • 人的感觉对于检测性能提高了多少来说是不够精确的。
  • 人的记忆力不足以准确地回忆起以往多次实验的结果。
  • 书本中的知识可能会误导你。
  • 经验也可能会欺骗你。
  • 编程语言、编译器、库和处理器都在不断地发展。

系统地完成优化任务:

  • 做出的预测都是可测试的,记录预测。
  • 保留代码变更记录
  • 使用可以使用的最优秀的工具进行测量。
  • 保留实验结果的详细笔记。

优化器是王牌猎人

如果只能让程序的运行速度提高1%是不值得冒险去修改代码的,因为修改代码可能会引入bug。只有能显著地提升性能时才值得修改代码。而且,这1%的速度提升可能只是将测量套件的误差当做了性能改善。因此,我们必须用随机抽样统计和置信水平来证明速度的提升。

90/10规则

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

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

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

阿姆达尔定律

阿姆达尔定律有多种表达方式,不过就优化而言,可以表示为下面的等式:

\[S_T = \frac 1 {1-P+\frac P {S_P}} \]

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

例如,假设一个程序的运行时间是100秒,其中程序花费了80秒多次调用函数f,现在修改f使其运行速度提升了30%,那么:

\[S_T = \frac 1 {1-0.8+\frac {0.8} {1.3}} = 1.22 \]

再例如,假设一个程序的运行时间是100秒,其中程序花费了10秒多次调用函数g,现在修改f使其运行速度提高了100倍,那么:

\[S_T = \frac 1 {1-0.1+\frac {0.1} {100}} = 1.11 \]

这两个例子中阿姆达尔定律是具有警示性的。即使有异常优秀的编码能力或是黑科技将函数g的运行时间缩短为0,对程序整体性能的提升依然只有11%。这证明了90/10规则。

进行实验

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

你对于这个问题的回答构成了你要测试的假设。实验要对程序修改前的运行时间和修改后的运行时间进行测量,如果后者比前者短,那么实验验证了你的假设。

但是需要注意,程序变快或变慢可能与你修改的部分没有任何关系,请保持怀疑的态度。

记实验笔记

如果每次的测试运行情况都被记录在案,那么就可以快速地重复实验。除了版本控制系统、测试结果保存在文件中、在不同的目录下做测试等,纸笔记录依然是传统而有效的方式。

测量基准性能并设定目标

优化工作受两个数字主导:优化前的性能基准测量值和性能目标值。测量性能基准不仅对于衡量每次独立的改善是否成功非常重要,而且对于向其他利益相关人员就优化成本开销做出解释也是非常重要的。而优化目标值之所以重要,是因为在优化过程中优化效果会逐渐变小。

一旦团队研究下性能问题,那么目标数字很容易被设定下来。用户体验(UX)设计的一个学科分支专门研究用户如何看待等待时间。下面是一份常用的性能测试项目清单:

  • 启动时间:从用户按下回车键直至程序进入主输入处理循环所经过的时间。通常,开发人员可以通过测量程序进入main函数到进入主循环的时间来得到启动时间,但有时候也有例外。
  • 退出时间:从用户点击关闭图标或是输入退出命令直至程序实际完全退出所经过的时间。通常,开发人员可以通过测量主窗口接受到关闭命令到程序退出main函数的时间来得到退出时间,但是有时候也有例外。退出时间也包含停止所有的线程和所依赖的进程所需的时间。
  • 响应时间:执行一个命令的平均时间或最长时间。对于网站来说,平均响应时间和最长响应时间都会影响用户对网站的满意度。响应时间可以粗略地以10的幂为单位划分为以下几个级别:
    • 低于0.1秒:用户在直接控制
    • 0.1秒至1秒:用户在控制命令
    • 1秒至10秒:计算机在控制
    • 高于10秒:喝杯咖啡休息一下
  • 吞吐量:与响应时间相对。通常,吞吐量表述为在一定的测试负载下,系统在每个时间单位内所执行的操作的平均数、吞吐量所测量的东西与响应时间相同,但是它更适合于评估批处理程序。通常,这个数字越大越好。

你只能改善你能够测量的

优化一个函数、子系统、任务或是测试用例永远不等同于改善整个程序的性能。由于测试时的设置在许多方面都与处理客户数据的正式产品不同,在所有环境中都取得在测试过程中测量到的性能改善结果是几乎不可能的。尽管某个任务在程序中负责大部分的逻辑处理,但是使其变得更快可能仍然无法使整个程序变得更快。

分析程序执行

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

有两种方式可以实现一个分析器:检测和采样。

检测:

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

采样:

  1. 通过将优化前的程序连接至分析库上使其变为可分析状态。分析库中的例程会以非常高的频率中断程序的执行,记录指令指针的值。
  2. 每次可分析的程序运行时都会在磁盘上生成一张分析表(profiling table)。
  3. 分析器读取分析表,然后生成一系列可阅读的文字或图形报告。

分析器的输出结果可能会有多种形式:

  • 一份标记有每行代码的执行次数的源代码清单
  • 一份由函数名和该函数被调用的次数组成的清单
  • 函数清单,记录的是每个函数的累计执行时间和在每个函数中进行的函数调用
  • 一份函数和在每个函数中花费的总时间的清单,但不包括调用其他函数的时间、调用系统代码的时间和等待事件的时间

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

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

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

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

测量长时间运行的代码

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

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

测量运行时间是一种测试关于“如何减少某个特定函数的性能开销”的假设的有效方式。可以通过编程在计算机上实现秒表功能。

一点关于测量时间的知识

精确性、正确性和准确性

真正的测量实验必须能够应对可变性(variation):可能破坏完美测量的误差源。可变性有两种类型:随机的和系统的。随机的可变性对每次测量的影响都不同;系统的可变性对每次测量的影响是相似的。

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

如果测量不受随机可变性的影响,它就是精确的。也就是说,如果反复测量同一现象,而且这些测量值之间非常接近,那么测量就是精确的。一系列精确的测量中可能仍然包含系统的可变性。

如果测量不受系统可变性的影响,它就是正确的。也就是说,如果反复测量同一现象,而且所有测量结果的平均值接近实际值,那可以认为测量是正确的。每次独立的测量可能受到随机可变性的影响,所以测量结果可能会更接近或是偏离实际值。

测量时间

软件性能测量要么是测量持续时间,要么是测量速率。用于测量持续时间的工具是时钟

时钟是不会直接测量时分秒的,它们只会对时标进行技术,然后只有时标计数值与秒基准点时钟进行比较后才能校准时钟,显示出时分秒。

时标计数值肯定是一个无符号的值。

测量分辨率

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

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

在测量的准确性与它的分辨率之间是没有任何必须的关联的。

测量结果的单位可能会比有效分辨率小,因为单位才是标准。

用多个时钟测量

当两个事件在同一个地点发生时,很容易通过一个时钟的时标计数来测量事件的经过时间。但是如果这两个事件发生在相距很远的不同地点,可能就需要两个时钟来测量事件。而两个不同时钟的时标次数无法直接比较。如果两个时钟都与UTC完美同步了,那么就可以进行比较。但是,完美的同步是不可能的。两个时钟都有各自独立的可变性因素,导致它们与UTC之间以及它们互相之间产生误差。

用计算机测量时间

要想在计算机上制作一个时钟需要一个周期性的振动源——最好有很好的精确性和正确性——以及一种让软件获取振动源的时标的方法。对于极其非正式的测量结果,精确到几个百分点就可以了,但是最大的问题并非周期性振动源,更困难的是如何让程序得到可靠的时标计数值。

硬件时标计数器的发展

一句话总结就是,PC从来都不是作为时钟来设计的,因此它们提供的时标计数器是不可靠的。历代PC都提供的唯一可靠的时标计数器就是GetTickCount()返回的时标计数器了,尽管它也有缺点。clock()返回的毫秒级的时标更好。GetSystemTimePreciseAsfileTime()返回的100纳秒级别的时标计数器是非常精确的。不过,对时间测量来说,毫秒级别的准确性已经足够了。

返转

返转(wraparound)是指当时钟的时标计数器值达到最大值后,如果再增加就变为0的过程。返转的问题出在缺少额外的位去记录数据,导致下次时间增加后的数值比上次时间的数值小。会返转的时钟仅适用于测量持续时间小于返转间隔的时间。

C++实现无符号算数的方式去确保了即使在发生返转时也可以得到正确的结果。

分辨率不是准确性

在Windows上,GetTickCount()的分辨率是1毫秒。不幸的是,从微软官方文档来看,调用GetTickCount()的准确性可能是10毫秒或15.67毫秒。

函数 时标
time() 1s
GetTickCount() 15.6ms
GetTickCount64() 15.6ms
timeGetTime() 15.6ms
clock() 1.0ms
GetSystemTimeAsFileTime() 0.9ms
GetSystemTimePrecoseAsFileTime() 约450ns
QueryPerformanceCounter() 约450ns

GetSystemTimeAsFileTime()的显示分辨率是100ns,但看起来却似乎是预计同样低分辨率的1毫秒时标的clock()实现的,而GetSystemTimePrecoseAsFileTime()看起来则是用QueryPerformanceCounter()实现的。

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

延迟

延迟是指从发出命令让活动开始到它真正开始之间的时间。就计算机上的时间测量而言,之所以会有延迟是因为启动时钟、运行实验和停止时钟是一系列的操作。整个测量过程可以分解为以下五个阶段:

  1. 启动时钟涉及调用函数从操作系统中获取一个时标计数。(\(t_1\)
  2. 在读取时标计数器的值后,它仍然必须被返回和赋值给一个变量。(\(t_2\)
  3. 测量实验开始,然后结束。(\(t_3\)
  4. 停止时钟涉及另外一个函数 调用去获取一个时标计数值(\(t_4\)
  5. 读取时标计数器的值后,它仍然必须被返回和赋值给一个变量。(\(t_5\)

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

如果同一个函数既在实验前被调用了,也在实验后被调用了,那么有\(t_1=t_4\)\(t_2=t_5\)。也就是说,延迟就是计时函数的执行时间。

函数 执行时间
time() 15ns
GetTickCount() 3.8ns
GetTickCount64() 6.7ns
timeGetTime() 17ns
clock() 13ns
GetSystemTimeAsFileTime() 2.8ns
GetSystemTimePrecoseAsFileTime() 22ns
QueryPerformanceCounter() 8.0ns

测试结果中所有的延迟都在若干纳秒的范围内。所以这些函数调用是相当高效的。不过,对于那些读取相同的低分辨率时标的函数,这些时间开销的差距仍然在10倍左右。其中,最高的延迟相对于它的时标占到了大约5%。延迟问题在慢速处理器上更严重。

非确定性行为

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

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

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

克服测量障碍

别为小事烦恼

好消息是测量误差只要在几个百分点以内就足以指引我们进行性能优化了。换种方式说,如果希望从性能优化中获得线性改善效果,误差只需要有两位有效数字就可以了。

变化源 影响度
时标计数器函数延迟 < 0.00001
基本时钟稳定性 < 0.01
时标计数器的可用分辨率 < 0.1
表3-3 各变化源对在Windows上测量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);

非确定性发生了就克服它

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

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

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

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

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

创建stopwatch类

代码清单3-3 stopwatch类
template <typename T> class basic_stopwatch : public T {
public:
    typedef typename T BaseTimer;
	typedef typename T::tick_t tick_t;

    // create, optionally start timing an activity
    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); 

    // stop and destroy a stopwatch
    ~basic_stopwatch();

    // get last lap time (time of last stop)
    tick_t LapGet() const;

    // predicate: return true if the stopwatch is running
    bool IsStarted() const;

    // show accumulated time, keep running, set/return lap
    tick_t Show(char const* event="show");

    // (re)start a stopwatch, set/return lap time
    tick_t Start(char const* event_namee="start");

    // stop a running stopwatch, set/return lap time
    tick_t Stop(char const* event_name="stop");

private:    //  members
    char const*     m_activity;         // "activity" string
    tick_t          m_lap;		// lap time (time of last stop or 0)
    std::ostream&   m_log;		// stream on which to log events
};
代码清单3-4 使用了<chrono>的TimeBase类
#include <chrono>

using namespace std::chrono;
class TimerBaseChrono {
public:
	//	clears the timer
	TimerBaseChrono() : m_start(system_clock::time_point::min()) { }

	//  clears the timer
	void Clear() { 
		m_start = system_clock::time_point::min(); 
	}

	//	returns true if the timer is running
	bool IsStarted() const {
		return (m_start != system_clock::time_point::min());
	}

	//	start the timer
	void Start()            { m_start = std::chrono::system_clock::now(); }

	//	get the number of milliseconds since the timer was started
	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;
; 
代码清单3-5 使用了clock()的TimeBase类
class TimerBaseClock {
public:
	// tick type
	typedef clock_t tick_t;

	// clears the timer
	TimerBaseClock()		{ m_start = -1; }

	// clears the timer
	void Clear()			{ m_start = -1; }

	// returns true if the timer is running
	bool IsStarted() const  { return (m_start != -1); }

	// start the timer
	void Start()            { m_start = clock(); }

	// get elapsed time in ticks
	tick_t GetTicks() {
		if (IsStarted()) {
			tick_t now = clock();
			tick_t dt  = (now - m_start);
			return dt;
		}
		return 0;
	}

	// get the number of milliseconds since the timer was started
	unsigned GetMs() {
		return GetMs(GetTicks());
	}
	static unsigned GetMs(tick_t dt) {
		return (unsigned long)((dt + (500/CLOCKS_PER_SEC)) * (1000 / CLOCKS_PER_SEC));
	}
private:
	tick_t m_start;
};
代码清单3-6 使用了gettimeofday()的TimeBase
#include <chrono>

using namespace std::chrono;
class TimerBaseChrono {
public:
	//	clears the timer
	TimerBaseChrono() : m_start(system_clock::time_point::min()) { }

	//  clears the timer
	void Clear() { 
		m_start = system_clock::time_point::min(); 
	}

	//	returns true if the timer is running
	bool IsStarted() const {
		return (m_start != system_clock::time_point::min());
	}

	//	start the timer
	void Start()            { m_start = std::chrono::system_clock::now(); }

	//	get the number of milliseconds since the timer was started
	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;
; 

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

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

一旦通过分析器或是运行时分析找出了一个候选的待优化函数,一种简单的改善它的方法是构建一个测试条件,在其中多次调用该函数。这样可以将该函数的运行时间增大为一个可测量的值,同时还可以抵消因后台任务、上下文切换等对运行时间造成的影响。采用“修改-编译-运行”的迭代方式去独立地测量一个函数,会比采用运行分析器并解析它的输出更快。

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

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

对于一些非常短小的函数,该变量的类型可能是需要64位unsigned long long。相比于回过头来重新修改所有类型名称,使用typedef是对优化过程自身的一种优化。

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

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

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

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

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

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

有一条有效的规则能够帮助我们评估一条C++语句的开销有多大,那就是计算该语句对内存的读写次数。这个次数不依赖于微处理器的指令集。这是语句不可避免的、必然会发生的开销。

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

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

评估循环的开销

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

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

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

嵌套循环可能并非一眼就能看出来。如果一个循环调用了一个函数,而这个函数中又包含了另外一个循环,那么内层循环就是嵌套循环。

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

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

不是所有循环中的循环次数都是很明确的。许多循环处理会不断重复直至满足某个条件为止。这种循环的重复次数也是可以估算出来的。当然,只需要大致地估算一下即可,估算的目的是找出可能需要优化的代码。

识别出隐式循环

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

识别假循环

不是所有的while或者do语句都是循环语句。也有使用do语句帮助控制流程的代码。下面这段示例代码还有更好的实现方式,不过使用了更复杂的if-else逻辑的话,这种惯用法就有其用武之地了。

do{
	if (!operation1())
		break;
	if (!operation2())
		break;
} while(0);

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

其他找出热点代码的方法

如果开发人员熟悉需要优化的代码,可以选择仅凭直觉去推测影响程序整体运行时间的代码快在哪里,然后做实验去验证对这些代码的修改是否可以提高程序整体性能。

我不建议选择这种方法,除非整个项目只有你一个人。通过使用分析器或是计时器分析代码,开发人员可以向同事和经理展示他们在性能优化工作中取得的进展。如果你仅凭直觉进行优化,也不发表结果,有时甚至即使你发表了结果,团队成员也会质疑你的方法,使你无法专心于你的工作。他们也应该如此。这是因为他们分不清你到底是在用你高度专业的直觉进行优化还是只是在碰运气。

小结

  • 必须测量性能
  • 做出可测试的预测并记录预测
  • 记录代码修改
  • 如果每次都记录了实验内容,那么就可以快速地重复实验。
  • 一个程序会花费90%的运行时间去执行10%的代码。
  • 只有正确且精确的测量才是准确的测量。分辨率不是准确性。
  • 在Windows上,clock()函数提供了可靠的毫秒级的时钟计时功能。在Windows 8和之后的版本中,GetSystemTimePreciseAsfileTime()提供了亚微秒级的计时功能。
  • 只进行有明显效果的性能改善,开发人员就无需担忧方法论的问题。
  • 计算一条C++语句对内存的读写次数,可以估算出一条C++语句的性能开销。
posted @ 2020-03-19 18:32  睿阳  阅读(382)  评论(0编辑  收藏  举报