gitbook/性能优化高手课/docs/387611.md
2022-09-03 22:05:03 +08:00

199 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 17 | Benchmark测试如何做好微基准测试
你好我是尉刚强。从这节课开始我们就进入了课程的第三个模块性能看护篇。接下来我们会用5节课的时间来学习和掌握性能测试的核心理论、测试工具的选择和使用并理解如何才能更好地集成在流水线中监控软件产品性能的能力。
今天我们先来了解下基准测试Benchmark的分类并重点学习下在进行微基准测试时都会碰到哪些问题以及高效实现微基准测试的方法步骤和手段。
现在,我想先问你一个问题:软件为什么要进行基准测试呢?
实际上,从软件生命周期的视角来看,由于新需求的不断引入,导致软件实现在持续不断地演进与变化,而在这个过程中,软件的熵会不断增大,同时软件的性能也很容易被不断地劣化。所以说,性能优化是一个持续改进的过程,如果没有好的措施来看护软件的性能基线,就很容易导致软件系统的性能长期处于不稳定的状态。
那么,**基准测试的目的,就是为软件系统获取一个已知的基线水平。**这样,当软件修改变化导致性能发生劣化的时候,我们就可以在第一时间发现问题。
但是如何对软件系统做好基准测试是一件非常有挑战的事情我举个简单的例子有些互联网SaaS服务在进行性能测试时需要很大规模的用户接入可是这在测试场景下是很难构造的。
另外,基准测试按照被测系统规模,可以分为微基准测试与宏基准测试。其中,**微基准测试**主要针对的是**软件编码实现层面**上的性能基线测试,而**宏基准测试**则是针对**产品系统级**所开展的性能基线测试。
所以今天这节课,我会先给你介绍下微基准测试中面临的一些核心挑战与难点,带你分析如何才能做好微基准测试。至于宏基准测试的相关知识点,我会在下节课给你讲解。
不过在开始之前我还要说明一点就是由于微基准测试与编程语言实现的相关性比较大所以接下来我主要是从程序员使用非常多的Java语言为出发点来给你介绍微基准测试面临的问题。
OK下面我们就从Java软件程序的微基准测试开始来了解下即时编译对代码实现性能测试的影响吧。
## JIT对代码实现性能测试影响
事实上对于Java软件程序来说进行微基准测试其实存在很大的挑战而这其中最大的挑战就来自于**JIT**Just In Time也就是JDK中的HotSpot虚拟机的即时编译技术。
JIT技术会在程序运行过程中寻找到热点代码并将这部分代码提前编译成机器码保存起来这样在下次运行时就可以避免解释执行而是可以直接运行机器码以此提升系统性能。
**那么JIT又是如何影响微基准测试呢**下面我就通过几个场景案例,来给你介绍说明下。
首先在代码运行的过程中JIT中会对一些比较小的函数方法实施**内联优化**,也就是将一个函数方法(对象方法)生成的指令直接插入到被调用函数的指令内,这样就可以通过减少函数调用开销来提升执行性能。
然后针对程序中For循环频繁执行的代码块JIT也会根据循环执行次数来决定是否启动编译优化当满足一定的次数门限后就会实施**栈上替换OSR**也就是把循环体内生成的字节码替换为编译好的机器码来加速执行从而导致For循环在不同遍历中的执行代码和运行时间不一致。
同时JIT的代码优化是实时动态的行为会受制于Code Cache的大小限制。所以如果优化后的运行效果不理想JIT还会触发**逆优化**它的功能是把原来放到Code Cache中的机器码删除掉这部分代码又回退为Java字节码执行。
所以综上所述这些技术手段其实都会造成代码的执行时间发生变化进一步就会影响微基准测试但这只是JIT即时优化技术中很小的一部分这里我们只需明白JIT技术会影响到代码的微基准测试结果即可
而除了各种技术手段的影响之外还有一个原因就是Java虚拟机在运行期存在两种模式Client模式和Server模式。Client模式主要追求编译期的优化速度而Server模式更关注运行期的性能所以**针对这两种模式JIT进行热点代码优化的默认策略并不一样**,这也会直接影响到微基准测试的结果。
那么根据以上的分析我们怎样才能避免JIT对微基准性能测试带来如此大的干扰呢
答案就是**使用充足的代码预热**。也就是说你首先需要将Java的被测代码循环执行很多次以确保代码已经被JIT优化过然后再对该段代码进行微基准测试来获取测量值如何更方便地进行预热我会在后面的JMH测试框架部分讲解
> 补充在C/C++语言中,由于在编译期间,所有代码都被编译转换成了汇编指令,所以在对代码段进行性能测试时,并不需要这个单独的预热阶段。
所以简而言之,微基准测试就是对代码执行时间的一项测量活动,而既然是对时间的测量,肯定就会受到测量精度的影响。
那么针对Java而言测量时间的精度是否需要满足微基准测试的需求呢下面我们就一起来探讨下这个问题。
## 测量时间的精度问题
在现实世界中,我们会使用手表来计算时间间隔,如果手表上的时间最小单位是秒,那么你可以大致认为测量出的时间间隔误差小于秒。而在计算机系统中,当测量时间使用更小的单位之后,那测量时间间隔的误差是否仍然小于最小的时间单位呢?
这个答案其实是否定的。因为**对于计算机系统来说,通常测量获取的时间不是准确的**。这要怎么理解呢?接下来我给你举个具体的例子。
在Java语言中测试时间的方法通常会使用**System.currentTimeMillis()**这是一个获取系统当前时刻距离1970年1月1日的毫秒偏移量值因为返回值是一个long类型的数字所以可以帮助我们更方便地计算时间间隔。
不过虽然这个接口获取的时间偏移是基于ms毫秒单位的但受制于底层实现的差异每次获取时间的准确度并不确定甚至有些场景下获取的时间偏差可能会超过10ms。
因此为了解决这个问题Java语言中后来引入了一个**System.nanoTime()方法**这是一个获取系统当前时刻与之前某一个时刻的偏移值可以支持我们记录更精准的时间间隔。它可以获取更小的时间单位ns纳秒但同样的这并不代表误差会小于ns。
> 补充目前测量时间间隔的最精确方法是通过指令获取代码运行期间CPU中的时钟寄存器差值再根据CPU的时钟周期频率来计算出时间间隔。这种方式在做C/C++实时系统的运行时间分析时使用得比较多但它也受制于CPU的指令级发射机制和编译乱序优化的影响测试出来的时间间隔也会存在一定的误差。
实际上,针对较小的代码段运行时间测不准的问题,**微基准测试的一种可行方式**,就是迭代、累积运行多次后获取的测试时间间隔,然后再平均到每一次的运行时间上,这样就可以减少获取的时间间隔误差对测量结果的影响。
但这里仍然存在一个问题,就是**对代码段迭代很多次又容易触发JIT中的栈上替换OSR优化**可真实的业务代码在执行过程中并没有出现JIT也没有触发OSR。所以这样就会导致基准测试值不能反映真实的业务性能水平问题你也需要注意规避。
总而言之针对Java语言在进行微基准测试时我们不能太依赖底层接口获取的测量时间精度因为Java的底层无法保证测量精度是非常准确的。
不过,除了测量时间精度会对测量结果产生影响以外,由于软件代码本身的运行时间也是不确定的,所以针对这种情况,我们在做微基准测试的时候,还需要在基于波动的测量结果的前提下,来尽量准确地获取平均测量结果,以此支撑性能分析。
那么接下来,我们就具体来看看测量结果数据的波动现象。
## 测量结果数据波动现象
这里我们要先明确一点,就是我们不可能完全剥离掉测试时软硬件运行环境的影响,也不可能完全避免测试结果的计算误差,**我们必须客观接受获取的测量结果存在波动的这种现象**。
那么,由于测试性能获取的结果会是一直波动的,所以根据单次结果去判断性能是否退化,其实也会比较困难。
所以在这个基础上,我们可以基于统计学方法,先测量计算出性能测试结果的波动范围区间,也就是**置信区间**,然后根据测试结果是否落在置信区间,来判断性能基线是否发生变化。
可是这样问题就来了:如何计算出测试结果的波动范围区间呢?我们先来看一张示意图:
![](https://static001.geekbang.org/resource/image/68/f2/68fcee033b19f0b6d7a505982baf65f2.jpg?wh=2000x1052)
如上图所示你可以获取大量的测试值并计算出平均值假设你觉得95%左右的测量结果为可信数据那么你就可以选择平均值周围95%的测量结果的最大值与最小值范围,作为置信区间。
实际上,判断微基准测试的性能是否发生变化,还有一个更有效的手段,就是**使用图表**协助分析测试结果的变化趋势。
![](https://static001.geekbang.org/resource/image/2e/5e/2ee43bf89615d64df27afyy35ae1d45e.jpg?wh=2000x1017)
如上图所示,绿色菱形为每一轮基准测量结果,其中你会比较容易看到一个性能拐点。这是因为图表携带了比置信区间更多的有效信息,更容易进行准确判断。另外,对于性能基线微基准测试而言,它的目标也并不在于追求单次测试结果的准确性,而是要测试出性能变化走势的准确性。
OK在基于以上微基准测试所面临的问题分析之后现在我们就知道该如何规避这些因素以避免影响到微基准测试结果。而接下来我们要讨论的就是如何更好地实施执行微基准测试的具体方法。
## 实施微基准测试的步骤方法
一般来说,在实施微基准测试的时候,你需要根据具体的被测试代码片段,手动编码很多代码逻辑来获取测量值。但这里存在一个问题,就是你会很容易忽略前面提到的一些实现因素,从而导致测量结果不能准确反映性能。
那么,有没有什么更快速、有效的测试步骤流程呢?这里我根据以往的实践经验,给你总结了一个微基准测试的基本步骤流程,可以帮助你更好地实现微基准测试。
这个步骤方法主要分为四步:
* 第一步,确定被测程序的软硬件运行环境、运行器配置等,都与真实的产品环境保持一致。
* 第二步,合理选择**被测方法**。针对Java而言首先建议你针对包级别的对外接口方法进行测试这种类型接口方法的性能更加稳定其次由于本身微基准测试有一定的成本因此仅对性能影响比较大的关键方法进行测试才更划算最后由于执行时间越短的方法测试准确的困难越大建议选择被测方法的执行时间要超过一定的门限比如10us等。
* 第三步,开发微基准测试用例,并验证**正确性**和**准确性**。正确性不仅需要确保被测方法被正常执行,已经完成预热阶段,还需要保证被测方法运行方式与产品上线时一致;准确性需要验证测试结果值是否在一个有效的区间范围内波动,才具有指导意义。
* 第四步,执行测试,并导出测试结果,并通过可视化手段分析变化趋势。
不过如果是自己手动来规避微基准测试的各种问题的话实施起来会比较复杂。好在每种编程语言都有现成的微基准测试框架可供选择比如对于Java语言来说JMH就是首选的微基准性能测试框架而对C/C++语言而言Google Benchmark则是首选的微基准测试框架。
所以接下来我就主要来给你介绍下Java的JMH框架。
## JMH测试框架是如何帮助完成微基准测试的
JMHJava Macrobenchmark Harness是一个测试Java或JVM上其他语言的微基准测试工具它把支撑微基准测试的标准过程机制与手段都内置到了框架中从而可以支持我们**通过注解的方式,来高效率开发微基准测试用例**。
我们来看一个例子。如以下代码段所示,我们可以**使用@Benchmark**来标记需要基准测试的方法,然后写一个**main方法**来启动基准测试:
```
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 2, time = 1)
@BenchmarkMode({Mode.Throughput})
public class Sample {
@Benchmark //这里标注的方法就是一个被测函数方法
public void helloworld() {
System.out.println("hello world")
}
//
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(Sample.class.getSimpleName())
.forks(1)
.build();
new Runner(opt).run(); //启动基准测试
}
}
```
另外在JMH中我们还可以**使用@Warmup注解来配置预热时间**。下面的代码示例中就表示配置预热3轮每轮1秒钟这样就可以跳过预热阶段来规避JIT编译优化对测试结果的影响。
```
@Warmup(iterations = 3, time = 1)
```
然后,我们还可以**使用@Measurement注解来配置基准测试运行时间**。下面代码中表示的是配置测试2轮每轮1秒钟在每轮执行期间还会不断地迭代执行。因此我们会得到两轮执行之后的一个测试结果
```
Benchmark Mode Cnt Score Error Units
Sample.helloworld thrpt 2 2703833258.555 ± 354675008.250 us/op
```
除此之外JMH还支持以下几种测试模式
* **Throughput**,表示吞吐量,测试每秒可以执行操作的次数;
* **Average Time**,表示平均耗时,测试单次操作的平均耗时;
* **Sample Time**,表示采样耗时,测试单次操作的耗时,包括最大、最小耗时,以及百分位耗时等;
* **Single Shot Time**表示只计算一次的耗时一般用来测试冷启动的性能不设置JVM预热
* **All**,表示测试以上的所有指标。
这样,我们就可以通过如下的方式来选择配置前面提到的测试模式:
```
@BenchmarkMode({Mode.Throughput})
```
最后,**JMH还支持多种格式的结果输出**比如TEST、CSV、SCSV、JSON、LaTeX等。如下所示这是一个打印出JSON格式的命令
```
java -jar benchmark.jar -rf json
```
而且JMH的测试结果在导出后还可以使用JMH Visual进行显示但这个工具只显示单个测试导出结果。所以在通常情况下为了更好地监控被测方法的性能变化趋势我们还需要持续地导出并保存JMH结果这样才能通过其他可视化手段去分析其变化趋势。
当然了今天这节课我主要目的是带你理解做好微基准测试的方法与步骤所以并不会给你详细介绍JMH的构建配置过程这里我给你推荐一个基于Gradle构建的[JMH的样例库](https://github.com/melix/jmh-gradle-example),你可以直接下载下来,参考开发测试用例或配置构建工程。
## 小结
热力学之父开尔文男爵Lord Kelvin曾经说过一句对性能优化领域有哲学指导意义的话If you cannot measure it, you cannot improve it. 这句话的大致意思是,你只能优化你能测量到的性能问题。不仅如此,你也只能看护你能测量到的软件性能。
而微基准测试,正是你支撑与看护高性能编码实现的重要手段。
今天这节课,我带你理解了微基准测试会碰到问题与挑战、高效开展微基准测试的方法步骤,以及借助微基准性能测试框架来更好地协助测试的方法。其中,你需要重点关注的是做好微基准测试的理论和方法,这样当具体的测量结果不准确时,你就可以做到有的放矢,找到应对方案。
另外,通过学习今天的课程,你还可以在深入理解基线性能面临的问题与挑战的基础上,来指导在核心高性能模块软件开发的过程中,准确高效地开发微基准测试,并能够及时发现测试中存在的问题。
## 思考题
在真实的软件产品中,你有没有发现过哪些被测方法代码,很难保持测试态与运行态的执行方式一致的呢?
欢迎在留言区分享你的看法。如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。