大数跨境
0
0

Benchmark测试:如何做好微基准测试?

Benchmark测试:如何做好微基准测试? 二进制跳动
2024-08-27
1
导读: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。

实际上,对于较小代码段运行时间测不准的问题,微基准测试的一种可行方式是迭代、累积运行多次后获取测试时间间隔,再平均到每一次的运行时间上,这样可以减少获取的时间间隔误差对测量结果的影响。

但这里仍存在一个问题,对代码段迭代很多次容易触发 JIT 中的栈上替换(OSR)优化,而真实的业务代码在执行过程中可能没有出现 JIT,也没有触发 OSR。所以,这会导致基准测试值不能反映真实的业务性能水平问题,你需要注意规避。

总而言之,针对 Java 语言,在进行微基准测试时,我们不能过分依赖底层接口获取的测量时间精度,因为 Java 的底层无法保证测量精度非常准确。

不过,除了测量时间精度会对测量结果产生影响以外,由于软件代码本身的运行时间也是不确定的,所以在这种情况下,我们做微基准测试时,还需要在基于波动的测量结果的前提下,尽量准确地获取平均测量结果,以此支撑性能分析。

那么接下来,我们就具体来看看测量结果数据的波动现象。

测量结果数据波动现象

这里我们要先明确一点,即我们不可能完全剥离测试时软硬件运行环境的影响,也不可能完全避免测试结果的计算误差,我们必须客观接受获取的测量结果存在波动的现象。

那么,由于测试性能获取的结果会一直波动,所以根据单次结果去判断性能是否退化比较困难。在此基础上,我们可以基于统计学方法,先测量计算出性能测试结果的波动范围区间,也就是置信区间,然后根据测试结果是否落在置信区间,来判断性能基线是否发生变化。

可是这样问题就来了:如何计算出测试结果的波动范围区间呢?我们先来看一张示意图:

如上图所示,你可以获取大量的测试值并计算出平均值,假设你觉得 95% 左右的测量结果为可信数据,那么你就可以选择平均值周围 95% 的测量结果的最大值与最小值范围,作为置信区间。实际上,判断微基准测试的性能是否发生变化,还有一个更有效的手段,就是使用图表协助分析测试结果的变化趋势。

如上图所示,绿色菱形为每一轮基准测量结果,从中比较容易看到一个性能拐点。这是因为图表携带了比置信区间更多的有效信息,更容易进行准确判断。另外,对于性能基线微基准测试而言,其目标并不在于追求单次测试结果的准确性,而是要测试出性能变化走势的准确性。

OK,在基于以上对微基准测试所面临问题的分析之后,现在我们就知道该如何规避这些因素,以免影响微基准测试结果。而接下来我们要讨论的,就是如何更好地实施执行微基准测试的具体方法。

实施微基准测试的步骤方法

一般来说,在实施微基准测试时,需要根据具体的被测试代码片段手动编码很多代码逻辑来获取测量值。但这里存在一个问题,即很容易忽略前面提到的一些实现因素,从而导致测量结果不能准确反映性能。

那么,有没有更快速、有效的测试步骤流程呢?这里我根据以往的实践经验,为你总结了一个微基准测试的基本步骤流程,可帮助你更好地实现微基准测试。

这个步骤方法主要分为四步:


第一步,确定被测程序的软硬件运行环境、运行器配置等,都与真实的产品环境保持一致。
第二步,合理选择被测方法。针对 Java 而言,首先建议针对包级别的对外接口方法进行测试,这种类型接口方法的性能更加稳定;其次,由于本身微基准测试有一定成本,因此仅对性能影响比较大的关键方法进行测试才更划算;最后,由于执行时间越短的方法,测试准确的困难越大,建议选择被测方法的执行时间要超过一定的门限,比如 10us 等。
第三步,开发微基准测试用例,并验证正确性和准确性。正确性不仅需要确保被测方法被正常执行,已经完成预热阶段,还需要保证被测方法运行方式与产品上线时一致;准确性需要验证测试结果值是否在一个有效的区间范围内波动,才具有指导意义。
第四步,执行测试,并导出测试结果,并通过可视化手段分析变化趋势。

不过,如果自己手动规避微基准测试的各种问题,实施起来会比较复杂。好在每种编程语言都有现成的微基准测试框架可供选择。比如对于 Java 语言来说,JMH 就是首选的微基准性能测试框架;而对 C/C++ 语言而言,Google Benchmark 则是首选的微基准测试框架

所以接下来,我就主要来给你介绍下 Java 的 JMH 框架。

JMH(Java 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         UnitsSample.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 结果,如此才能通过其他可视化手段去分析其变化趋势。

【声明】内容源于网络
0
0
二进制跳动
15 年 + 技术老兵 架构师|技术总监|科技创业技术合伙人 曾任职苏宁科技、电讯盈科、联想云 专注架构设计与技术落地
内容 739
粉丝 0
二进制跳动 15 年 + 技术老兵 架构师|技术总监|科技创业技术合伙人 曾任职苏宁科技、电讯盈科、联想云 专注架构设计与技术落地
总阅读44
粉丝0
内容739