购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

第2章
性能测量

理解应用程序性能的第一步是学会对它进行测量。有些人认为性能本身就是应用程序的特性之一 ,但是与其他特性不同,性能不是非此即彼的特性:应用程序总是有性能表现的,这也是无法用“是”或“否”回答应用程序是否有性能的原因。

与绝大部分功能问题相比,性能问题通常很难跟踪和复现 。基准测试每次运行的结果都不尽相同。例如,解压一个zip文件时,每次运行返回的结果总是一样的,这意味着该操作是可复现的 。然而,我们不可能复现与该操作完全一样的性能剖析结果。

任何关注过性能评估的人可能都知道公允地进行性能测量并从中得出准确结论是多么困难。性能测量有时会出人意料,改变一段看上去并不相关的代码可能对性能带来让人惊讶的重大影响,这就是所谓的测量偏差。因为在测量中存在误差,性能分析通常需要通过统计方法进行处理。该主题值得用一整本书来讨论,该领域已有很多极端案例和大量的研究,我们不再就此深入讨论。相反,我们只关注大体的想法和需要遵循的规则。

开展公允的性能实验是获得精确及有意义结果的基本步骤。设计性能测试和配置测试环境都是性能评估工作的重要组成部分。本章将简要介绍现代系统在性能测量过程中为何会出现噪声以及如何应对,讨论在真实生产环境下测量性能的重要性。

每个长生命周期的产品都会有性能退化的情况,这点在涉及大量代码贡献者和经常发生改变的大型项目上尤为严重。本章专门用几页篇幅讨论在持续集成和持续交付(Continuous Integration/Continuous Delivery,CI/CD)过程中自动化跟踪性能变化的流程。本章还提供在开发者修改代码后如何正确收集并分析性能测量指标的通用指南。

本章最后介绍了开发者测量时间时会使用的软硬件计时器,以及设计和开发微基准测试时经常会遇到的陷阱。

2.1 现代系统中的噪声

在软件和硬件中,存在不少可以提升性能的功能点,但不是所有的功能点都有确定性的行为。以动态频率调节(Dynamic Frequency Scaling,DFS)为例,它是一个在短时间内提升CPU频率让其运行速度明显加快的特性。但是,CPU不能长时间处于“超频”状态,稍后它会降低到基本值。DFS很大程度上由CPU 核的温度决定,因此很难预测其对实验结果的影响。

如果我们运行两次性能基准测试,先在一个“冷”处理器 运行一次,然后立即再运行一次。第一次运行可能是先在“超频”状态运行一段时间,然后频率降回到基本水平。然而,第二次运行可能并没有这个优势,不会进入“超频”状态而一直处于基础频率。即使我们运行相同版本的程序两次,它们的运行环境也可能不同。图2展示了动态频率调节引起测量结果差异的情况。在笔记本计算机上进行基准测试时,这种情况很常见,因为它们的散热能力非常有限。

动态频率调节是一个硬件特性,但是测量结果差异还有可能来自软件功能。以文件系统的缓存为例,如果我们对执行大量文件操作的应用程序做基准测试,则文件系统本身对性能有非常大的影响。当第一次运行基准测试时,文件系统缓存数据项还不存在,但是当第二次运行测试时,缓存已经被预热过了,应用程序会比第一次快很多。

不幸的是,测量偏差还不只来自环境配置。论文(Mytkowicz et al.,2009)表明,UNIX环境变量的大小(即存储环境变量所需要的字节数)和链接顺序(提供给链接器的目标文件顺序)能够对性能产生不可预知的影响。此外,还有很多种影响内存排布的方法,它们会潜在地影响性能测量。论文(Curtsinger&Berger,2013)提出了一种在现代架构下对软件进行统计学意义上可靠的性能分析的方法,该研究表明在运行时有效且重复地将代码、堆栈和堆对象随机放置,可以消除由内存排布引起的测量偏差。不幸的是,这些想法并没有发展得更远,现在这个项目几乎已经被废弃了。

图2 动态频率调节导致的测量结果差异

个人经验 请记住,即使运行任务管理工具——例如Linux top,也会影响测量结果,因为某些CPU核会被激活并分配给该工具的进程,这可能会影响运行实际基准测试程序的CPU核频率。

获得一致的测量结果需要所有基准测试都在同样的条件下进行,然而,重现所有的环境并完全消除偏差几乎是不可能的,因为可能存在不同的温度、功率传输峰值,运行的相邻程序不同等。追查系统中所有可能的噪声和差异来源可能永无止境,有时甚至是不可能完成的,例如在对大型分布式云计算服务做基准测试时。

所以,消除系统的不确定性有助于进行定义明确、稳定的性能测试,例如微基准测试。例如,对同一个程序的不同版本,我们希望用基准测试来测量代码变化带来的相对加速比时,需要控制基准测试中的大部分变量,包括输入、环境配置等。在这种情况下,消除系统的不确定性能够得出更一致、更精确的比较结果。在完成本地测试后,还需要确保预期性能提升能够在实际应用环境下得到体现。附录A中给出了导致性能测量噪声的例子,以及防止它们发生的办法。此外,还有一些通过设置测试环境以确保低方差的基准测试结果的工具,其中一个叫temci

当估计实际程序的性能优化效果时,不建议去除系统的不确定性行为。工程师应当尝试复制被优化的目标系统的配置,在被测系统中引入人为调整会导致与用户在实际使用中的结果不一致。此外,任何性能分析工作——包括采样(见5.4节),都应当在与实际部署最接近的系统下进行。

最后,重要的是要记住,即使特定的硬件或软件功能存在不确定性行为,也并不意味着是件坏事。它可能会给出不一致的结果,但它的设计初衷就是提升整个系统的性能。禁用这类功能可能会减少基准测试中的噪声,但会让整个测试套件耗时更长。当整个基准测试套件的总运行时长有限制时,这点可能对CI/CD的性能测试尤为重要。

2.2 生产环境中的性能测量

当应用程序在共享基础设施上运行(通常在公有云中)时,通常同一台物理服务器上还会有其他客户的计算负载存在。随着虚拟化和容器等技术的日渐流行,公有云供应商也尝试最大化服务器资源的利用率。不幸的是,这种环境为性能测量带来了新的困难,因为与相邻进程共享资源会对性能测量产生不可预知的影响。

在实验室环境重建生产负载来做分析会很困难,有时不可能通过“内部”性能测试组合出相同的行为,这也是越来越多的云服务供应商和超大规模云使用者选择直接在生产系统上进行性能剖析和监控(Ren et al.,2010)的原因。在“没有其他参与者”的情况下测量性能,可能无法反映客观真实的情况。在实验室环境下实施的代码优化虽表现良好,但在生产环境下表现不佳,因此可以说是在浪费时间。话虽如此,但也并不是不需要持续“内部”测试以及早发现性能问题。虽然不是所有的性能退化都能在实验室中发现,但是工程师应该设计能代表实际场景的性能基准测试用例。

大型服务提供商通过部署遥测系统来监控用户设备的性能已经成为一种趋势。例如,Netflix Icarus的遥测服务运行在全世界数千种不同的设备上,从而帮助Netflix理解真实用户的应用性能体验。它帮助工程师分析收集的数据,并寻找之前无法发现的问题。这类数据可以帮助工程师在确定优化方向时做出更明智的选择。

测量开销是生产环境监控的一个重要问题。由于任何监控都会影响正在运行的服务的性能,因此应该使用尽可能轻量的性能剖析方法。论文(Ren et al.,2010)中提到,“如果对正在提供真实服务的服务器进行持续的性能剖析,极低的性能开销是至关重要的”。通常可以接受总体不超过1%的开销,减少监控开销的办法包括限制被监控的机器数量和使用更小的监控时间间隔。

在这样的生产环境下测量性能意味着我们需要接受其有噪声的天然属性,并使用统计方法来分析结果。论文(Liu et al.,2019)中有一个很好的例子,它介绍了像LinkedIn这样的大公司是如何在生产环境的A/B测试时使用统计方法来测量和比较分位数指标(比如,页面加载时间的第90百分位)的。

2.3 自动检测性能退化问题

软件供应商提高产品的部署频率逐渐成为一种趋势,公司正在持续地寻找各种可以加快产品推向市场的方法。不幸的是,这并不意味着软件产品会自动随着每个版本的发布而表现更佳。尤其是,软件的性能缺陷会以惊人的速度蔓延到生产环境中(Jin et al.,2012)。软件中有大量的修改,这对通过分析所有运行结果和历史数据来检测性能退化来说是一个挑战。

软件性能退化是指软件从一个版本演进到下一个版本时被错误地引入了缺陷。识别出性能缺陷并优化意味着需要在充满噪声的测试环境中,通过测量性能来检测哪些提交的代码修改影响了性能。从数据库系统到搜索引擎再到编译器,几乎所有大型软件系统在持续演进和部署的生命周期中都会出现性能退化问题。在软件开发过程中,性能退化也许是不可能完全避免的,但是如果配合适当的测试工具和诊断工具,可将此类缺陷渗透到生产代码的可能性降到最低。

第一个办法是安排人员看图来比较结果,不过这个办法很快就被大家放弃了,因为人很容易因注意力不集中而错过性能退化缺陷,尤其在分析如图3所示的嘈杂图表时。例如,人类可能会捕捉到发生在8月5日的性能退化,但之后发生的退化并不明显,很容易被忽略。除了容易出错之外,让人参与到该检测环节中意味着必须有人日复一日地承担这种耗时且枯燥的工作。

图3 四次测试的性能趋势图,其中8月5日发生了小小的性能退化(值越大越好)

图片来自(Daly et al.,2020)

第二个办法是设定一个简单的阈值门限,这听上去比第一个办法好一些,但仍然存在着不足。性能测试的波动不可避免,有时,一处无害的代码修改 都能引起基准测试结果的变化。选择合适的阈值门限是非常困难的事情,并且不能保证误报率低。阈值设定过低会导致误报一些由随机噪声引起而不是代码变化引起的性能退化数据,阈值设定过高会导致过滤不出真正存在性能退化的问题。小的改变通常可以逐渐堆积,导致更大的性能退化,但小的改变经常不被注意 。在图3中,我们可以观察到对每个测试都需要做出阈值门限调整。由于噪声水平的不同,我们对上面线条代表的测试设定的阈值并不能适用于下面线条代表的测试。CI系统中每次测试需要设置不同的阈值来报告性能退化的实例可参考Chromium项目中的LUCI。

论文(Daly et al.,2020)采用了最近的一种识别性能退化的方法,MongoDB开发者开展了改动点分析,用以发现他们的数据库产品在代码库演进过程中性能的变化。根据论文(Matteson&James,2014),改动点分析是在时序观测中检测分散的变化的过程。MongoDB开发者使用了E-Divisivemeans算法,该算法把改动点按时间序列分为集中的簇,然后逐次地选择改动点簇。他们的开源CI系统Evergreen 使用了该算法,以把改动点展现在图上并开出Jira工单。关于该自动化性能测试系统的更多信息,详见论文(Ingo&Daly,2020)。

论文(Alam et al.,2019)中介绍了另一个有趣的方法,即AutoPerf,它能够利用硬件性能计数器(PMC,见3.9节)来诊断修改后的程序是否发生了性能退化。首先,它根据原始程序运行时收集到的PMC剖析数据分析被修改函数的性能分布。然后,它收集修改后的程序运行时所采集的剖析数据,并与第一步的数据进行比对以检测性能异常。AutoPerf表明,该设计可以有效地诊断出一些复杂软件的性能缺陷,比如那些隐藏在并行程序中的缺陷。

无论使用何种算法来检测性能退化问题,典型的CI系统都应当能够自动进行以下动作:

1.配置待测试系统。

2.运行程序。

3.报告运行结果。

4.判断性能是否发生变化。

5.将结果可视化展示。

CI 系统应当能够同时支持自动基准测试和手动基准测试,产生可复现的结果,并对发现的性能退化问题生成工单。迅速检测性能退化问题非常重要。首先,当测试发生时,合入的代码修改比较少,这使得负责性能退化的分析人员能在切换到其他任务前仔细分析发现的问题。其次,对开发者来说,由于测试时代码细节还在他的大脑记忆中,相比几周后再来回忆细节,更容易找到退化点。

2.4 手动性能测试

软件工程师在软件开发期间就能利用现有的性能测试基础设施是很好的事情。在上一节,我们谈到在CI系统中支持提交性能评测任务的功能是一个很好的特性。如果支持该功能,那么开发者在向代码库提交补丁时就能从系统得到针对补丁的性能测试结果。但是该目标不一定能够达成,原因可能是硬件不可用,测试基础设施的设置过于复杂,需要采集更多的指标等。本节将提供一些本地性能评估的基本建议。

当进行代码性能优化时,需要用某些方法来证明性能确实更好了。此外,当我们正常提交变更代码时,我们需要确保性能没有退化。通常,我们通过如下三步完成:1)测量基线性能;2)测量修改后程序的性能;3)对两者进行比较。该场景的目标是比较同一功能程序不同版本之间的性能。例如,假设有一个用递归方式计算斐波那契数列的程序,我们决定用迭代的方式重新实现它,两者在功能上都是正确的并且能生成相同的数字,现在我们就需要比较两个程序的性能。

强烈建议不能只进行一次测试,而是多次运行基准测试,这样基线程序有 N 个测量值,改动过的程序也有 N 个测量值。我们需要比较两组测试结果以确定哪一个程序更快。这本身就是一项很难处理的工作,在很多情况下,我们会被测量数据误导而得出错误的结论。如果你向任何数据科学家征求意见,他们都会告诉你不能依赖单一指标(如最小值、均值、中位数等)。

以图4中两个不同版本的程序测量数据的分布为例,图中曲线表示特定版本的程序在特定时间内完成运行的概率。例如,有约32%的概率A版本程序会在102s内运行结束,这会让人觉得版本A比版本B快,然而,这个论断正确的概率为 P 。这是因为还有一些测量数据表明,版本B比版本A快,即使在所有的测量数据显示版本B比版本A慢的情况下,概率 P 也不等于100%。因为我们总是可以为版本B找到新的样本,某些新样本可能表明版本B比版本A的速度还要快。

图4 两组测试数据的统计分布

使用统计分布图的一个优势是可以发现基准测试中的不良行为 。如果数据分布是双峰的,基准测试会表现出两类不同的行为,引起双峰分布的常见原因是代码有快、慢两条执行路径,例如访问缓存(命中和未命中)、获取锁(竞争锁和非竞争锁)等。“解决”这些问题的方法是隔离不同的功能模块并分别做基准测试。

数据科学家通常使用数据分布图形来展示数据,并且避免计算加速比,这种方法可以避免有倾向性的结论,还可以让读者自己对数据做出解读。箱形图(见图5)是一种比较流行的数据展示方法,它可以在同一张图上比较多个分布。

图5 箱形图

性能数据分布的可视化展示可以帮助我们发现某些异常,但我们不应该用它来进行加速比的计算。通常,仅通过观察性能数据的分布无法计算加速比。此外,如前所述,可视化展示无法应用于自动化基准测试系统。通常,我们需要一个标量值,用以代表2个版本程序的性能加速比,比如“版本A比版本B快 X %”。

两个数据分布之间的统计关系可以通过假设检验方法来确定,如果两组数据的关系根据阈值概率(显著性水平)不符合零假设分布,则该对比结果被认为具有统计显著性。如果分布是高斯分布 (正态分布),使用参数假设检验(例如学生T检验)来比较分布即可满足要求。如果需要比较的分布不是高斯分布(如严重偏斜的分布或多峰分布),那么可以使用非参数检验(如Mann-Whitney、Kruskal Wallis等)。假设检验非常适合用来确定性能加速(或减速)的表现是否具有随机性 。Dror G.Feitelson所著的 Workload Modeling for Computer Systems Performance Evaluation 是一本不错的性能工程统计参考书 ,书中对模态分布、偏度等主题进行了更详细的阐述。

一旦通过假设检验方法确定两组数据存在统计上显著的差异,就可以使用算数平均或几何平均的方法来计算加速比,但是有些事项需注意。对于小样本采样,均值和几何均值会受异常值影响。除非数据分布具有小方差,否则不应当只考虑使用均值。如果测量值的方差与均值大小在同一个数量级,那么均值就不是具有代表性的指标。图6显示了两个版本程序的例子。只看均值(见图6a),我们倾向于说版本A比版本B快20%。然而,如果考虑测量值的方差(见图6b),我们可以看到并不总是这样。如果我们取版本A最差的值和版本B最好的值,版本B会比版本A快20%。对于正态分布的数据来说,均值、标准差、标准误差的组合可用于评估两个版本之间的加速比。对于偏斜、多峰的采样数据,必须用更适合基准测试的百分位数,例如最小值、中位数、第90百分位数、第95百分位数、第99百分位数、最大值或这些值的组合等。

图6 反映均值如何带来误导的两个直方图

为了准确地计算加速比,最重要的工作之一就是收集大量的样本数据,也就是大量地运行基准测试。这听上去很容易,但有时并不可行,比如某些SPEC 基准测试 在现代机器上运行超过10min,这意味着仅获得3组样本就需要1h:每个版本的程序都需要30min。假设测试套件中不止一个基准测试,而是有数百个。即使把测试工作分布到多台机器,收集足够多统计数据的成本也会变得非常高昂。

需要收集多少样本数据才满足统计分布需要呢?这取决于对比测试的精确度要求。分布数据中样本的方差越小,需要的样本越少。标准差是分布数据中测量值一致性的指标。我们可以实施自适应策略,基于标准差来动态地限制运行基准测试的次数,即收集样本直到标准差到达特定的范围 。一旦标准差低于某个阈值,就可以停止采集测量值。关于该策略的更多细节,请见文献(Akinshin,2019)的第4章。

另一个需要特别小心的是异常值的存在。虽然可以根据置信区间将某些样本(例如冷运行结果)作为异常值丢弃,但不能任意地丢弃测量值集合中的不良样本。对某些类型的基准测试而言,异常值可能是重要的指标。例如,当对具有实时性要求的软件进行基准测试时,第99百分位数也非常值得关注。Gil Tene在YouTube上发表了一系列关于测量时延数据的讲座,很好地介绍了该主题。

2.5 软件计时器和硬件计时器

为了对执行时间做基准测试,工程师通常使用两种不同的计时器,这些计时器在所有现代平台上都提供:

系统级高分辨率计时器 它是一个系统计时器,通过统计自某任意时间(称为纪元 )起开始流逝的嘀嗒数而实现。该时钟是单调递增的。系统计时器的分辨率是纳秒级的 ,并且在所有CPU上都是一致的,它适合用来测量持续时间超过1μs的事件。可以通过系统调用从操作系统中查询系统时间,系统计时器与CPU频率无关。在Linux操作系统下,可通过clock_gettime系统调用访问系统计时器 。在C++语言中,标准的做法是使用std::chrono访问系统计时器,如代码清单1所示。

时间戳计时器 (Time Stamp Counter,TSC)这是一个通过硬件寄存器实现的硬件计时器。TSC也是单调递增的,并且以固定速率增长,也就是说它与频率无关。每个CPU都有自己的TSC,用来记录流逝的参考周期数(见4.6节),它适合用来测量持续时间从纳秒到1min之间的事件。TSC的值可以使用编译器的内置函数__rdtsc查询,如代码清单2所示。而__rdtsc在底层实际调用了汇编指令RDTSC。文献(Paoloni,2010)中给出了使用RDTSC汇编指令对代码进行基准测试的更多底层详细描述。

代码清单1 使用C++std::chrono访问系统计时器

代码清单2 使用编译器内置函数__rdtsc访问TSC

计时器的选择非常简单,即根据需要测量的时间长短来选择即可。如果需要测量的时间很短暂,则TSC可以提供更好的准确度。相反,如果需要测量的时间长达数小时,用TSC来测量的话毫无意义。除非真的需要时钟周期的精度,否则大部分情况下选择系统计时器通常就足够了。请务必牢记,访问系统计时器通常比访问TSC的时延长,执行系统调用clock_gettime所需时间很容易达到执行指令RDTSC所需时间的10倍,后者需要20多个CPU时钟周期。这让最小化测量开销变得很重要,尤其在生产环境下。CppPerformanceBenchmarks的gitlab页面 介绍了在不同平台下使用不同API调用计时器的性能比较。

2.6 微基准测试

为了验证某些假设,可以编写一个独立的微基准测试程序。通常,微基准测试程序是在优化某些特定功能时跟踪优化进展的手段。几乎所有现代编程语言都有基准测试框架,比如对C++来说可以使用Google benchmark库,对C#来说有BenchmarkDotNet 库,对Julia来说有BenchmarkTools 程序包,对Java来说有JMH(Java Microbenchmark Harness)等。

写微基准测试程序时,重要的是确保你想测试的场景在微基准测试程序运行时执行。优化编译器可能会消除使实验变得无用(甚至使你得出错误结论)的代码。对于下面的代码片段,现代编译器很可能会消除整个循环:

检验这件事的简单方法是审视一下基准测试的性能剖析文件,看看关注的代码是否凸显为热点。有时能立刻发现异常的时间开销,所以在分析和比较基准测试时要充分利用常识。防止编译器优化重要代码的一种常用手段是,使用类似DoNotOptimize的辅助函数 ,这些辅助函数可以在幕后完成必要的内联汇编优化:

如果实现得好的话,微基准测试可以成为优良的性能数据来源,可用于比较关键功能不同实现方式的性能。定义一个基准测试优劣的依据是,它能否在真实条件下测试将来要使用的功能的性能。如果基准测试使用的合成输入与实际使用的输入不同,那么基准测试可能会误导你,让你得出错误的结论。此外,当基准测试在没有其他重需求进程的系统中运行时,它拥有所有资源,包括DRAM和缓存空间。这样的基准测试表现出的可能是该函数的更快版本,但是会比其他版本消耗更多的内存。如果有其他消耗大量DRAM的相邻进程,会导致属于基准测试的内存被交换到磁盘上,最终导致测试结果与之前的正好相反。

基于同样的原因,在根据单元测试的结果总结结论时要小心。虽然现代单元测试框架 提供了每个测试的持续时间,但这并不能替代使用真实输入在实际条件下测试该功能的精心开发的基准测试[更多信息请见文献(Fog,2004)的16.2节]。虽然并不总能复现与现实情况完全相同的输入和环境,但是要开发好的基准测试程序就必须考虑到这一点。

2.7 本章总结

由于测量的不稳定性,调试性能通常比调试功能更为困难。

如果不设定目标,优化工作将永无止境。要确定是否达到了预期目标,需要为如何衡量该目标设定有意义的定义和指标。根据关心的内容,它可能是吞吐量、延迟、每秒操作数(屋顶线性能)等。

现代系统性能往往具有不确定性,消除系统中的不确定性有助于进行定义明确、稳定的性能测试,例如微基准测试。在生产部署中衡量性能时,为了处理环境噪声的问题,需要使用统计方法分析结果。

越来越多的大型分布式软件供应商选择直接在生产系统上剖析和监控性能,这要求只能使用轻量级的剖析技术。

采用自动化性能跟踪系统有助于防止性能退化问题渗透到生产软件系统中,此类CI系统应能够运行自动化性能测试,可视化结果并标记潜在的缺陷。

对性能分布做可视化展示有助于发现性能异常,这也是向更广泛的受众展示性能结果的稳妥办法。

性能数据分布之间的统计关系可以通过假设检验(例如学生T检验)方法来识别和发现。一旦确定性能差异在统计上是显著的,那么性能加速比可以通过算术平均或几何平均来计算。

丢弃冷启动运行数据以确保性能测试数据都来自热运行是可以的,但是,不应该故意丢弃不需要的数据。如果决定丢弃一些样本数据,那么应对所有分布数据做一致处理。

为了对执行时间做基准测试,工程师可以使用所有现代平台都提供的两种不同的计时器。系统级高分辨率计时器适合测量持续时间超过1 μs的事件,若需要高精度测量短事件,则可以使用时间戳计时器。

微基准测试适合迅速证明一些事情,但是你应该始终在实际条件下用真实应用程序验证你的想法,通过分析性能剖析文件确保是在对有意义的代码进行基准测试。 aOfkQN1AkUb4iEUY9/1U3Uple0DgeICwUGPCNAOaH/a+EZqWJkbw/kGcmvJljqf/

点击中间区域
呼出菜单
上一章
目录
下一章
×