Skip to content

Instantly share code, notes, and snippets.

@zhanhai
Last active October 10, 2018 08:50
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save zhanhai/96890df0f3a794e5fda5 to your computer and use it in GitHub Desktop.
Save zhanhai/96890df0f3a794e5fda5 to your computer and use it in GitHub Desktop.
首先介绍了micro benchmark的基本概念 ,在什么情况下考虑benchmark。接下来说明在Java中正确实现benchmark面临的困难,为什么要依赖JMH来benchmark,最后给出JMH基本实践方法。
  • 在本机进程间进行数据传输时,是文件共享还是socket通信效率高?
  • Netty开发中使用多个并发channel是否有助于提高吞吐?
  • 怎么设置SOCKET发送缓冲区大小以及其他TCP参数以充分利用网络带宽?

我们在开发中,可能会经常面临上面类似的问题,要准确回答这种性质的问题,得到让人信服的结论,不能只靠分析和猜测,正确的方法是针对不同实现策略和算法,在相同的负载或者环境下执行性能测试,比较它们的测试结果,根据性能评分得出结论。 我们把这种性能测试和评估的过程称为Micro benchmark

和测试阶段执行的load test和stress test不同,Micro benchmark针对的是单个应用中的某一部分代码,不用准备复杂的环境和构造数据,是轻量级的测试活动,它应当和Unit test一样,成为开发的日常环节。这样,我们可以在代码重构,性能优化甚至daily build时,快速执行,发现性能缺陷,避免到集成测试或者更晚的压力阶段发现性能问题。当然,这就意味着我们需要一个自动化的Micro benchmark执行框架。

实现Micro benchmark自动化框架似乎不是一件难事,无外乎就是按照指定的时间或者次数,不断循环执行测试代码,最后根据调用次数和执行时间等数据计算出TPS、平均响应时间等指标,最后以一定格式输出测试结果。但是,对大多数开发人员来说,针对JAVA应用完成这么一个框架几乎是一件不可能的事情,根本原因在于JVM/JIT在运行中会对JAVA应用执行大量动态优化,如果不能正确处理(很多时候甚至是避免)这些优化,我们的测试代码在JVM中的执行方式很可能偏离我们的意图,最终导致错误的、无法被信任的测试结果,本来性能较好的方案得分较低,明显性能差的方案反而得分更高。关于这一点,可能很多同学很难相信,眼见为实,我们以一个具体的例子(参考:Avoiding Benchmarking Pitfalls on the JVM)来说明。

比如我们实现了一个简单的自动benchmark框架:

public class WrongBench {

    public static void bench(String name, long runMillis, int loop,
                             int warmup, int repeat, Runnable runnable) {
        System.out.println("Running: " + name);
        int max = repeat + warmup;
        long average = 0L;
        for (int i = 0; i < max; i++) {
            long nops = 0;
            long duration = 0L;
            long start = System.currentTimeMillis();
            while (duration < runMillis) {
                for (int j = 0; j < loop; j++) {
                    runnable.run();
                    nops++;
                }
                duration = System.currentTimeMillis() - start;
            }
            long throughput = nops / duration;
            boolean benchRun = i >= warmup;
            if (benchRun) {
                average = average + throughput;
            }
            System.out.print(throughput + " ops/ms" + (!benchRun ? " (warmup) | " : " | "));
        }
        average = average / repeat;
        System.out.println("\n[ ~" + average + " ops/ms ]\n");
    }
}

在上面的实现中,runnable代表被测试的任务,name是benchmark的名称,runMillisloop表示每轮测试的持续时间和内部循环次数,warmuprepeat则是热身阶段和正式测试阶段的执行迭代个数。我们通过这个实现,不断循环执行被测试任务,最终输出该任务的吞吐量(ops/ms)。

接下来,我们使用这个简单的测试框架来测试几个函数的性能:

public class DistanceAlgo {

    public static double distance(
            double x1, double y1,
            double x2, double y2) {
        double dx = x2 - x1;
        double dy = y2 - y1;
        return Math.sqrt((dx * dx) +
                (dy * dy));
    }

    public static double constant(
            double x1, double y1,
            double x2, double y2) {
        return 0.0d;
    }

    public static void nothing() {

    }
}

通过junit启动测试

    @Test
    public void benchmarkAll(){
        benchmarkDistance();
        benchmarkContant();
        benchmarkNothing();
    }

    
    public void benchmarkDistance(){
        bench("distance", RUN_MILLIS, LOOP, WARMUP, REPEAT, () ->
                DistanceAlgo.distance(0.0d, 0.0d, 10.0d, 10.0d));
    }

    
    public void benchmarkContant(){
        bench("constant", RUN_MILLIS, LOOP, WARMUP, REPEAT, () ->
                DistanceAlgo.constant(0.0d, 0.0d, 10.0d, 10.0d));
    }

    
    public void benchmarkNothing(){
        bench("nothing", RUN_MILLIS, LOOP, WARMUP, REPEAT,
                DistanceAlgo::nothing);
    }

在测试之前,虽然我们不能知道具体的分数,但是这几种算法的排名是显然的,nothing得分最高,因为什么也没有执行,distance得分最低,因为它的计算量最大。可是实际的测试结果,让我们大吃一惊:distance的tpms最高,nothing最低!这显然不合常理。

Running: distance
[ ~48913203 ops/ms ]

Running: constant
[ ~1128029 ops/ms ]

Running: nothing
[ ~307605 ops/ms ]

之所以会得到这样的测试结果,实际上就是没有处理好JVM优化造成的后果。

首先,我们不应该在一个进程内执行多个benchmark。测试第一个benchmark时,JVM发现总是生成使用相同的Runnable调用bench方法,因此会针对这个bench调用,生成native code。而这段生成的native code在测试第二个benchmark时不再适用,因此JVM后会继续优化,生成新的native code,可以同时支持第一个Runnable和第二个Runnable,但是这段新代码的性能不如之前的代码。而在运行第三个benchmark时,JVM发现了第三个Runnbale,但是JVM此时无能为力,无法继续优化(JVM的限制,对于一个调用,最多只能绑定两个实现),因此,第三个benchmark只能通过效率较低的无优化代码执行。 这样,三个benchmark没有在公平的环境下比较,较先执行的benchmark总是得分更高。

其次,即使我们把这些benchmark分别在不同的JVM中执行,对distance的测试仍然不准确,我们会发现它的性能和contant非常接近:

Running: constant
[ ~49005393 ops/ms ]

Running: distance
[ ~48977615 ops/ms ]

但是distance是基于double进行计算,contant则始终返回常量,两者的性能差异应该比上面要更大。

这样的结果是由于JVM执行了Dead code elimination(DCE)优化。JVM发现distance的计算结果没有被任何地方使用,并且该计算没有改变任何状态,所以JVM将删除对distance的计算代码!为了验证这一点,我们修改一下benchmark代码。

    static double last = 0.0d;
    ...
    @Test
    public void benchmarkDistanceWithReturn(){
        bench("distance_with_return", RUN_MILLIS, LOOP, WARMUP, REPEAT, () ->
                last = DistanceAlgo.distance(0.0d, 0.0d, 10.0d, 10.0d));
    }
 

修改后,我们把distance的计算结果设置到类的静态属性中,这样JVM不会忽略distance的计算结果。执行测试后,我们得到的结果是38434424,和constant的49005393和被DCE的distance的47636229有较大差距。

从上面这个例子可以看出,JVM的优化动作确实会对我们的benchmark造成影响,使得运行时的benchmark代码不能反映我们的初始意图:要么代码被忽略执行;要么一部分得到了额外优化另一部分没有。 实际上,JVM还有很多其它的优化动作,比如:constant-folding, loop unrollingin-lining等等(有兴趣的同学可以参考 OpenJDK wiki Performance Techniques page)。这些优化动作都可能对benchmark造成不同的影响。

要正确实现一个benchmark框架,我们必须非常清楚JVM的运行优化策略,然后采取措施,避免我们的benchmark代码不受这些优化动作的影响,这些对普通开发人员确实太困难了。幸运地是,我们可以使用JMH(java micro benchmark harness), 这个框架由Open SDK的开发团队提供,他们基于自己对 JDK/JVM实现的了解,在开发micro bench框架时考虑了各种情况,通过一些特别手段规避JVM/JIT的优化对benchmark的影响,有兴趣的同学可以详细了解他们在这方面的努力

我们将在后续的文章里详细介绍JMH的使用。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment