软件性能优化漫谈(一):软件性能测量与分析

来源:互联网 发布:小米手环数据修改 编辑:程序博客网 时间:2024/05/01 22:31

    • 引言
    • Amdahl 定律与局部优化
    • 测量是第一法则
      • 计时器精度误差
      • 额外的测量开销
      • 外部随机噪音
    • 性能基线
      • 性能评测
    • 总结
    • 后续

引言

你能获得的对程序最大的加速比,就是当你第一次让它工作起来的时候。
—— John K.Ousterhout

软件(或程序)最主要目标,是在所有可能的情况下都正确工作,一个运行得很快但是给出错误结果的软件是没有任何价值的。

另一方面,在很多情况下,让程序运行得更快也是很重要的。比如资源受限的嵌入式系统,对响应时间有要求的实时系统,或者一个计算量非常大的海量任务处理系统,都需要程序运行得足够快。

而让软件或程序运行得更快的技术,就是软件性能优化技术。

软件性能优化,是指在不改变硬件的前提下,通过软件的等价变换获得更高的执行速度

在本系列文章中,将从软件性能测量与分析、性能优化方法和技术实践等方面,谈一谈软件性能优化的相关问题。

Amdahl 定律与局部优化

首先我们来看一个及其重要的定律:Amdal 定律。

Gene Amdahl,计算领域的先驱之一,做出了一个关于提高系统一部分性能的效果的简单但是富有洞察力的观察,这个观察现在被称为Amdahl定律。

考虑一个系统,其执行某个任务需要的时间为 Told。假设这个系统中的某部分程序的运行时间占用 Told 的百分比为 α,而我们将这部分程序的性能提高了 k 倍。也就是说,这一部分程序原来需要时间 αTold,而现在需要时间 (αTold)/k。因此,整个执行时间就是:

Tnew=(1α)Told+(αTold)/k=Told[(1α)+α/k]

这样,我们可以计算系统的加速比为:

S=Told/Tnew=1(1α)+α/k

根据以上公式,假设我们修改了这部分代码,使其性能提升了5倍(k=5)。那么,当这部分代码原本的运行时间占用系统总时间为20%(α=0.2)时,加速比为 1/(0.8+0.2/5) = 1.19;而当这部分代码原本的运行时间占用系统总时间为80%(α=0.8)时,加速比为 1/(0.2+0.8/5) = 2.78。这展示了 Amdahl 定律的主要思想——要想大幅度提高整个系统的速度,我们必须尽可能去优化运行时间占系统总时间百分比较大的那一部分程序。运行时间占比较多从而具有较高优化价值的这部分程序或代码,通常称之为热点区域(hotspot)。

那另一个问题,就是我们可能找到这样的热点吗?或者说,软件中是否存在这样的热点?幸好,就像其他的事物一样,软件系统也同样遵循帕雷拖定律(也称为二八法则):80%的计算机资源(CPU,内存,……)花费在20%的代码上

这样,为了能够使我们所做的优化能够取得尽可能好的效果,我们就需要去尽力找到消耗了系统80%资源的那20%代码,即热点区域。当我们找到了热点区域以后,就可以使用局部优化技术来对程序进行性能优化。

按照优化的范围来划分,优化技术可以分为局部优化全局优化两种.
- 局部优化:通过修改某个局部的代码以带来性能提升的方法。这里的局部,实际上指的是对代码的修改在逻辑上的影响局限在某个小范围内,但是性能上的影响仍然是全局的。
- 全局优化:对整个应用进行的优化,它需要对整个系统进行分析和修改。比如减少运行实体之间的交互、缩短消息传递的路径、提升系统的内存读写操作性能等等。

局部优化相对于全局优化,因为其对代码逻辑的修改只局限在一个小范围内,因此安全性更高,不容易破坏软件正常行为(从而符合软件的等价变换要求),也更易于被测试。因此,局部优化是被广泛采用并能适用于用于各种不同的场景的优化技术,也是本系列文章讨论的重点。

测量是第一法则

在性能优化的世界里,存在一个第一法则:任何没有经过测量和验证的想法或措施,都是不可靠的。无论是为了找出软件中的热点区域,还是为了评估优化的效果,都需要对程序的性能进行准确的测量。在现代软件的复杂环境下,某些看上去正确的经验和直觉,在实际的场景中很有可能是错误的。比如某些指令比另外一些指令更快,数据操作比计算更快或者计算比数据操作更快等等。

在局部优化中,一个重要的测量场景就是对某些特定代码或算法的运行速度进行基准性测试。在这种测量中,目标是计算这些代码的运行时间,从而评估它们的速度。而目标代码总是运行在一定的环境中,因此为了能够测量出目标代码的准确运行时间,就需要处理一些干扰或噪音。以下是其中的几种情况:
- 计时器精度误差
- 外部随机噪音
- 上下文运行开销

计时器精度误差

在进行性能测量时,测量的准确度与系统所提供的的计时器精度有关。如果目标代码的运行时间较长,通常不会有什么问题。但是如果目标代码运行速度很快,那么就可能会由于系统提供的时钟精度不够,而造成较大的测量误差。

对于这种情况,一种解决方法当然就是采用高精度的计时器。

一些C/C++程序高精度计时方式的例子:
- Windows 系统:QueryPerformanceFrequency() 和 QueryPerformanceCounter() 两个函数结合使用,可以进行精度达到微秒级的计时
- Linux 系统:使用 clock_gettime(CLOCK_REALTIME, timespec *) 函数可以获得纳秒级别的计时精度
- Intel平台:使用 RDTSC 指令,利用 CPU 的时间戳进行纳秒级别的计时

但是,高精度的计时方式,往往使用起来比较复杂,并且与平台相关,会导致计时的代码变得不可移植。因此,另一种方式就是重复运行目标代码多次,然后计算平均时间。示例代码如下(本文后续代码如果未作其他说明,都为C/C++代码):

startClock();for (unsigned i= 0; i < maxCount; ++i){    ... //被测量的目标代码}measureTime();

采用这种方式,当重复运行次数足够多时,计时器带来的误差就可以忽略不计了。

但是,这种方式得到的测量值仍然不准确。因为我们希望测量的是循环内的目标代码的运行时间 t,但是得到的测量结果 tm 却将循环本身的时间消耗 to 也一并包含进去了。在下一节中,将讨论如何解决类似问题。

额外的测量开销

正如以上例子那样,很多情况下为了完成测量,需要增加一些额外的代码,比如循环、测试环境的安装清理、测试框架的函数调用以及测量操作本身等。因此,需要将这些额外的运行开销从测试结果中去除,才能得到我们想要的针对目标代码的准确结果。

在上一节中的例子中,我们如果知道 to 值,就可以计算出目标代码实际的运行时间:

t=tmto

那要怎么才能得到 to 的值呢?我们可以抽取掉循环中的目标代码,只运行循环本身,然后进行测量,就能得到 to。代码如下:

startClock();for (unsigned i= 0; i < maxCount; ++i){}measureTime();

但是……等等,这个方式可能会有点问题。我们知道,现代编译器本身有着强大的优化能力。因此以上代码中,由于循环内部没有任何操作,那么这个循环本身很有可能会被编译器优化掉,而不消耗任何的运行时间!

为了避免这一点,我们需要告诉编译器不要对这段代码进行优化。一种方式是在编译时关闭编译器的优化选项(比如使用 –debug 或 -O0 参数)。但是,这是一个全局行为,会改变整个程序的编译结果,而导致测量出来的结果与实际运行系统时的情况不一致。

另一种方式就是只在这个循环本身做文章。比如C/C++语言中,可以利用 volatile 关键字的特性,来阻止编译器的优化。可以修改成以下代码:

startClock();for (volatile unsigned i= 0; i < maxCount; ++i){}measureTime();

当我们给循环变量 i 添加 volatile 关键字以后,这段循环代码就不会被编译器优化掉。

看上去很不错。但是……使用 volatile 关键字以后,却会带来另一个问题。那就是它改变了变量 i 的特性,可能会导致在上一节的代码中编译器可以进行的其他的一些优化也被阻止了(比如将 i 保存在寄存器中以提高访问速度)。这样,我们测量到的循环的运行时间,就会与上一节中循环所消耗的实际时间不一致。

因此,我们再次修改这段代码:

startClock();for (unsigned i= 0; i < maxCount; ++i){    asm volatile("");}measureTime();

以上代码中,以内联汇编(inline assembly)的方式在循环内嵌入了一段空的汇编代码。volatile 关键字保证了这段汇编代码(虽然是空代码)一定会存在,从而确保循环体不会被优化掉。

这种方式效果很好,但是也存在一个问题,就是这种方式不具备移植性(asm 是 gcc 的关键字,在 Windows 平台下要使用 __asm)。因此,具体采用哪种方式,需要根据具体情况决定。

外部随机噪音

有时候,目标代码以外的程序行为,可能会造成目标代码的运行时间发生波动。这种波动引起的误差,通常被称为随机噪音。我们在测量目标代码的性能时,就需要尽量去除随机噪音的影响。随机噪音有两个特点:
- 很难找到噪音源,所以无法被直接测量
- 时间消耗不恒定,具有一定的波动性

因此,无法使用通常的方式去对随机噪音进行测量。这里提出一个一种简单而低成本的方式:重复测量目标代码多次,然后取最短的运行时间作为最终的测量值。

示例代码如下:

unsigned minTime = numeric_limits<unsigned>::max();for (unsigned i = 0; i < MAX_COUNT; ++i){    startClock();    ... //被测量的目标代码    auto t = measureTime();    if (minTime > t) minTime = t;}

这里可能会有一个疑问,为何我们会取最短的运行时间作为最终测量值,而不是取平均值?假设目标代码的实际运行时间为 t,随机噪音为 tn,则测量时间为 tm=t+tn。因为 tn 总是非负的,因此当tm越小时,意味着 tn 越接近于0,tm 越接近于 t,我们也就能够得到最接近目标代码实际运行时间的测量值。

性能基线

接下来的一个问题,就是对于某段代码,如果我们已经能够测量出它的性能数据,是否就能够判断它当前的性能到底是好还是不好了呢?

比如说,现在有一段排序算法,测试出来的数据是“能够在 8ms 内完成一百万个浮点数的排序”。那这样一个表现,到底是已经很快了,还是其实还有很大的优化余地?

进行这一判断的一种方式,是通过算法理论对这段代码进行分析,比如分析这段排序算法的复杂度,然后推测其性能优化空间。这种分析与推测,能够给我们在判断性能优化的方向时提供一些指导。但是,由于受到运行环境和输入的影响,这种分析结果很有可能与实际情况有很大偏差。(还记得我们前面提过的第一法则吗?)

另一种方式,就是在测量的基础上建立性能基线。所谓基线,就是某段代码在当前真实系统下的具有参考意义的性能表现。基线的主要要素包括:
0. 用于建立基线的基准代码。这个代码应该是“经典的”并且“被广泛接受的”,比如经典算法或标准库。
0. 基于以上的代码,程序在真实环境下运行得到的性能数据。这个数据就可以作为性能调优的一个参考指标。

以下是C/C++语言下的一些比较好的基线代码的例子:

  • 对于排序算法,使用 std::sort 作为基线算法

  • 对于 I/O 操作,使用 iostreams 标准库作为基线

  • 对于从字符串到数字的格式化转换,使用 sscanf 函数作为基线

有了基线数据以后,我们就可以根据基线做出初步的判断,推断哪些代码具有较大的优化空间。当目标代码的性能表现比基线更差时,我们就认为这段代码的性能并不够好,还可以继续优化。

比如对于同样的输入和运行环境,某段代码当前使用的排序算法比直接使用 std::sort 进行排序要慢30%。这时候,我们就可以认为这段代码还存在较大的优化空间。

性能评测

当我们有了基线以后,就可以对代码的性能状况进行评测了。评测的步骤如下:
0. 运行基线代码 n 次,测量其总的运行时间 ta
0. 运行要评价的代码 n 次,测量其总的运行时间 tb
0. 计算目标代码相对于基线代码的加速比:r=tatb

此外,在基于基线进行性能评测时,需要避免几个误区:
0. 在错误的软件版本上进行评测,比如使用 debug 版本。
0. 测量基线和目标代码时,初始环境有差异,比如由于热区效应而导致的在缓存、文件读写、数据库访问等方面的性能差异。
0. 在测量代码中包含一些有副作用的、对性能影响较大的操作,比如 mallocprintf 等。
0. 将需要调优的多个目标代码放在一起进行测量,这样可能会错误的判断调优的效果。

总结

Amdalh 定律与二八法则是指导我们进行软件性能优化的理论基础。软件性能优化的一个重要的前提就是建立合理的性能基线,并在实际测量的基础上进行正确程序性能评测。这种基于基线对软件进行性能测量与分析的方法,就是通常所说的 benchmarking (基准分析法/标杆分析法)。

后续

本文主要重点是软件性能的测量与分析。在后续的文章中,将逐步开始讨论提升软件性能的方法,比如:
- 算法与数据结构的优化
- 函数内联优化
- 降低运算强度
- 循环优化
- ……

0 0
原创粉丝点击