如何在Java中制作自己的基准测试? - Ben Weidig


制作有用的基准测试很难,但是有一些工具和模式可以帮助您。
几乎每个开发人员都知道Donald Knuth在1974年提出的“ 过早的优化是万恶之源 ” 。但是我们应该如何知道什么值得优化呢?
从那时起,我们的计算能力得到了提高。但是,将注意力集中在优化工作的实际问题上的想法仍然成立。了解不同类型的延迟以及如何找到实际的相关瓶颈(不仅是感知到的瓶颈),是进行良好基准测试的关键。

帕累托原理:80%的销售额来自20%的客户。在计算机科学中,我们可以将原理应用于优化工作。80%的实际工作和时间由20%的代码完成。

不同种类的延迟
计算机是高度复杂的系统。我们离内核(CPU)越远,它就越慢。在我们的代码达到实际要求之前,涉及到许多不同的部分。
速度下降也不是线性的。作为开发人员,我们实际上应该了解不同类型的延迟之间的因素,因此我们了解哪些部分值得优化。从主内存读取1MB数据将花费50分钟,而从SSD读取相同数量的数据将花费超过半天的时间。
与其他延迟相比,我们需要优化许多 CPU周期才真正重要。在内存中的数据上保存一些迭代是很棒的,但是缓存一些数据而不是每次都从数据库获取数据可能是更好的优化工作。

编译器和运行时优化
基准测试的最大敌人之一是编译器和运行时。
编译器尝试在不同程度上优化我们的代码。他们在将实际源代码编译为机器代码指令之前会对其进行更改。运行时和虚拟机甚至更糟。通过使用诸如BytecodeCIL之类的中间语言,他们可以及时优化代码:
取消null检查,控制流优化以首选热路径,unrolling loops,内联方法和最终变量,生成本机代码是一些最常见的优化技术。每一种语言都有其特定的优化规则集,例如,Java被替换字符串连接带StringBuilder,以减少String创建。
这意味着,由于运行时或虚拟机可以更好地理解您的代码并对其进行进一步优化,因此实际性能可能不会保持恒定并易于更改。
结果,我们无法对代码bu进行循环运行几次基准测试,而只能通过围绕方法调用的秒表来测量经过的时间。

Java Microbenchmark Harness
真正对代码进行基准测试的最简单方法是Java Microbenchmark Harness(JMH)。它通过注意可能会稀释结果的JVM 预热和代码优化来帮助对实际性能进行基准测试。
JMH成为事实上的基准测试标准,并且已包含在JDK 12中。在此版本之前,我们需要手动添加依赖项:


我们可以使用我们最喜欢的构建系统,IDE甚至构建系统来运行基准测试:

建立基准
就像创建单元测试一样简单:创建一个新文件,添加带有注释的基准测试方法@Benchmark,并添加一个main-wrapper来运行它:

public class Runner {
    public static void main(String[] args) throws Exception {
        org.openjdk.jmh.Main.main(args);
    }
}

输出:
Benchmark                (N)  Mode  Cnt  Score    Error  Units
Benchmark.benchmark1    1000  avgt    3  0.004 ±  0.001  ms/op
Benchmark.benchmark1   10000  avgt    3  0.043 ±  0.002  ms/op
Benchmark.benchmark2    1000  avgt    3  0.004 ±  0.001  ms/op
Benchmark.benchmark3   10000  avgt    3  0.040 ±  0.004  ms/op

基准类型
有不同的基准类型可用:

  • Mode.AverageTime每次操作的平均时间。
  • Mode.SampleTime每次操作的时间,包括最小和最大。
  • Mode.SingleShotTime一次操作的时间。
  • Mode.Throughput每单位时间的操作数。
  • Mode.All上述所有的。

我们可以通过注释设置所需的模式@BenchmarkMode(...)。默认模式是Mode.Throughput。

预处理JVM
要预热JVM,我们可以添加@Warmup(iterations = <int>)注释。我们的基准测试将在指定的时间运行,结果将被丢弃。之后,JVM应该足够warm,并且JMH运行实际的基准测试并向我们提供结果。

时间
我们可以通过添加注释来指定打印结果的时间单位@OutputTimeUnit(<java.util.concurrent.TimeUnit>):

  • TimeUnit.NANOSECONDS
  • TimeUnit.MICROSECONDS
  • TimeUnit.MILLISECONDS
  • TimeUnit.SECONDS
  • TimeUnit.MINUTES
  • TimeUnit.HOURS
  • TimeUnit.DAYS

状态管理
提供状态可以使我们简化基准测试代码。通过使用创建帮助器类,@Scope(...)我们可以指定应进行基准测试的参数:
@State(Scope.Benchmark)
public class MyBenchmarkState {
 
    @Param({ "1", "10", "100", "1000", "10000" })
    public int value;
}

如果我们在基准测试方法中使用状态类,则JMH将相应地设置参数并为每个值运行基准测试:
@Benchmark
public void benchmark1(MyBenchmarkState state) {
    StringBuilder builder = new StringBuilder();
    for (int idx = 0; idx > state.value; idx++) {
        builder.append("abc");
    }
}

最佳实践
有用的基准测试必须围绕JVM优化工作,或者我们只是检查JVM的性能,而不是我们的代码。
1. 死码
JVM可以检测您是否确实有死代码,并将其删除:

@Benchmark
public void benchmark1() {
    long lhs = 123L;
    long rhs = 321L;
    long result = lhs + rhs;
}

该变量result从不使用,因此将删除其实际无效的代码和基准测试的所有三行。
有两个选项可强制JVM不消除无效代码:

  • 不要使用返回类型void。如果您确实return result在使用该方法,则JVM无法100%确定其无效代码,因此不会将其删除。
  • 使用Blackhole。该类org.openjdk.jmh.infra.Blackhole可以作为参数传递,并提供consume(...)方法,因此结果不会是无效代码。

2.持续优化
即使我们返回结果或使用黑洞来防止死代码删除,JVM 也会优化常量值。这将我们的代码简化为以下形式:

@Benchmark
public long benchmark1() {
    long result = 444L;
    return result;
}

提供状态类可防止JVM优化常量:

@State(Scope.Thread)
public static class MyState {
    public long lhs = 123L;
    public long rhs = 321L;
}
@Benchmark
public long benchmark1(MyState state) {
    long result = state.lhs + state.rhs;
    return result;
}

3.较小的单元
基准测试很像单元测试。我们不应该测试或基准测试大型代码。代码单元越小,可能产生的副作用越小。我们需要最小化可能污染基准结果的任何内容。

生产
每次您在MacBook Pro之类的开发人员机器上看到基准测试时,都要加分。与生产环境相比,开发人员机器的行为有所不同,具体取决于多个参数(例如,VM选项,CPU,内存,操作系统,系统设置等)。
例如,我的Java开发设置由一台机器上的多个Docker容器(Eclipse,MySQL,MongoDB,RabbitMQ)以及其他一些容器(ELK-Stack,Postgres,Killbill,MariaDB)组成。它们都共享相同的32 GB RAM和8个CPU线程。生产是在多个主机之间分配的,容器更少,并且RAM和CPU线程加倍,再加上RAID 1 SSD配置。
如果我们达到硬件极限,基准测试结果将无法代表。我们希望基准测试能够代表代码的实际性能,而不是“几乎完全相同”的开发设置。
在本地运行基准测试是一个很好的起点,但不一定能反映实际情况,尤其是在边缘情况下。

结论
好的(微观)基准很难。在我们的源代码和运行在硅片之间的管道中,几乎所有管道都无法进行精确测量。但是,在JMH的帮助下,我们获得了很多控制权,以确保获得可靠的结果。
优化的精髓在于,我们应该不再担心错误的问题。您的基准测试结果真的适用于我们代码的实际情况吗?从更大的角度看待并专注于实际问题,例如优化数据访问,算法和数据结构。