如何在JVM上做讲究一点的Benchmark

如何在JVM上做讲究一点的Benchmark

前些天看到了一个同事写的问题分析分享,他从服务的性能打点中得出Jackson性能比较差,并自己做实验得出了Jackson比org.json效率更低的结论。对他的代码稍做分析发现同事对写Benchmark还是有一些误解,结论分析也还是太过于草率。

其实做出有说服力的Benchmark还是需要一些工作的,也是比较严肃的。

本文仅限函数级的Benchmark。

1. Benchmarking

什么是Benchmarking?Benchmarking是有别于Testing的:

  • Testing: 确保部分程序的表现是符合预期的(一般是二值输出,正确或者错误)
  • Benchmarking: 计算部分程序的性能(Performance)指标(产出是个连续值,是个随机变量,要用统计方法对待它!)

影响程序性能的因素有很多,比如:

  1. 处理器的速度
  2. 处理器的核数(并发性能)
  3. 内存访问速度和吞吐量
  4. 缓存访问模式
  5. 程序运行时开销(垃圾回收GC,运行时编译JIT ,线程调度)

前三条并不是关注的重点,一般在评测的时候交待一下机器配置即可。4是在特殊的缓存访问模式下会有不同表现,比如经常需要刷新cache的值(cache失效)。5是程序启动时的额外开销,以及Full/Young GC带来的程序性能损耗,一般需要避开这些对Benchmark的影响。

怎样更好地做Benchmark呢?更详细的信息可以参考文献Statistically Rigorous Java Performance Evaluation。简单一点,首先要把性能指标当作随机变量,再用上一些简单的统计方法:

  1. 重复实验(比如10万+)
  2. 选用一些统计量(平均值,方差,分位点)
  3. 去掉异常值
  4. 确保稳定的状态(通过预热)
  5. 避免异常情况(GC,JIT)

为什么要预热Warmup?因为JVM程序在启动后需要经历一段时间的预热步骤,可能包括:

  1. 解释代码
  2. 部分程序被编译成机器码
  3. JVM还可能做一些额外优化
  4. 程序达到一个稳定状态

这些步骤会显著提升首次执行的时间,需要排除掉。

2. Json序列化/反序列化 Benchmark

上面的基本道理还是有必要了解的,从理念上提升理解,然后再借助一些实践这些方法论的工具,做Benchmark这件事就能比较好地完成了。

2.1 测试需要关注的点

回到Json序列化和反序列化的问题,有几点应该关注:

  1. 交待机器配置(一般只需要交待CPU和内存)
  2. 重复次数可观(10~100万)
  3. 区分Json(反)序列化是到Java Bean,还是完成相应库的内部结构表达
    • org.json/net.sf-json是只能转换到内部结构的,而gson/jackson/fastjson是能直接转换到java bean的,只做对等能力的评测,这个很重要。
  4. 需要在不同的负载(字符串的大小)下进行测试
  5. 需要在简单的单层以及多层嵌套结构下分别测试
  6. 需要有基本的预热

2.2 使用测试框架

合理使用测试框架可以轻松完成2.1中提到的点,只是一点参数设置而已。

JMH

JMH is a Java harness for building, running, and analysing nano/micro/milli/macro benchmarks written in Java and other languages targetting the JVM.

OpenJDK发布的代码工具,不需要使用OpenJDK,加入下面的依赖即可使用。

"org.openjdk.jmh" % "jmh-generator-annprocess" % "1.21" 
"org.openjdk.jmh" % "jmh-core" % "1.21"

ScalaMeter

如果是Scala的代码,也可以使用ScalaMeter。

ScalaMeter is a microbenchmarking and performance regression testing framework for the JVM platform that allows expressing performance tests in a way which is both simple and concise.
It can be used both from Scala and Java.

  • write performance tests in a DSL similar to ScalaTest and ScalaCheck
  • specify test input data
  • specify how test results are collected
  • organize performance tests hierarchically

两个包使用起来都不难。

2.3 回到Json序列化反序列化Benchmark

本来想做一个示例项目,看了fabienrenaud/java-json-benchmark这个项目后就放弃了,代码、数据图表和结论都有,需要自取。

向流量低头,想想CSDN上大量的低水平入门代码也能有很高访问量,我觉得还是得提供那么一两个例子的。

前面提到了Json解析应该分为解析到内部表示和加上databind到Java Bean的两种,下面以只转换到中间表示为例,说明简单的JMH和ScalaMeter如何写Benchmark。

2.3.1 JMH示例

package json;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;
import java.util.concurrent.TimeUnit;

import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import org.json.JSONObject;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 2)
@Measurement(iterations = 3) // 10 iterations, 10s each
@Fork(1)
@State(Scope.Benchmark)
public class JsonStream {
    static ObjectMapper mapper = new ObjectMapper();

    final static String s = "{\"type\":\"duration\",\"sub_type\":\"concrete\",\"start\":\"2019-09-10T05:00:00\",\"end\":\"2019-09-10T17:00:00\",\"grain\":\"arbitrary\",\"festival_flag\":false,\"token\":\"明天上午五点到下午五点\",\"start_timestamp\":1568062800000,\"end_timestamp\":1568106000000}";

    @Benchmark
    public JsonNode jackson() throws IOException {
        return mapper.reader().readTree(s);
    }

    @Benchmark
    public JSONObject orgJson() {
        JSONObject node = new JSONObject(s);
        return node;
    }

    @Benchmark
    public JsonElement gson() {
        return JsonParser.parseString(s);
    }

    public static void main(String[] args) throws RunnerException {
        final Options options = new OptionsBuilder()
                .include(".*" + JsonStream.class.getSimpleName() + ".*")
                .forks(1)
                .build();

        new Runner(options).run();
    }
}

时间和迭代次数都比较小,跑10轮每轮1分钟,半个小时就过去了。
结果如下,仅供参考

# JMH version: 1.21
# VM version: JDK 1.8.0_152, Java HotSpot(TM) 64-Bit Server VM, 25.152-b16
# VM invoker: /Library/Java/JavaVirtualMachines/jdk1.8.0_152.jdk/Contents/Home/jre/bin/java
# VM options: ...
# Warmup: 2 iterations, 10 s each
# Measurement: 3 iterations, 10 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Throughput, ops/time
...
Benchmark            Mode  Cnt     Score     Error   Units
JsonStream.gson     thrpt    3   697.574 ± 193.477  ops/ms
JsonStream.jackson  thrpt    3  1238.795 ± 108.857  ops/ms
JsonStream.orgJson  thrpt    3   392.496 ±  24.216  ops/ms

别看成Error了,看Score。

2.3.2 ScalaMeter示例

不是Scala用户的话,可以忽略ScalaMeter。(个人理解,参考就好)ScalaMeter提供了一些其它的度量,各种类和方法调用次数、内存消耗以及忽略GC等。但是ScalaMeter相对JMH少了一些比较实用的Throughput之类的直接计算模式,也是让人头疼。JMH设计上更简单,更容易理解也是个比较大的优点。

package json

import java.io.IOException

import org.json.JSONObject
import org.scalameter.{Bench, Gen}
import org.scalameter.api._

import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper}
import com.google.gson.{JsonElement, JsonParser}

object JsonParseScalaMeter extends Bench.OfflineReport {

  val mapper = new ObjectMapper
  val s =
    "{\"type\":\"duration\",\"sub_type\":\"concrete\",\"start\":\"2019-09-10T05:00:00\",\"end\":\"2019-09-10T17:00:00\",\"grain\":\"arbitrary\",\"festival_flag\":false,\"token\":\"明天上午五点到下午五点\",\"start_timestamp\":1568062800000,\"end_timestamp\":1568106000000}"

  @throws[IOException]
  def jackson(): JsonNode = mapper.reader.readTree(s)

  def orgJson(): JSONObject = {
    val node = new JSONObject(s)
    node
  }

  def gson(): JsonElement = JsonParser.parseString(s)

  // Generator
  val gen = Gen.unit("fixed")

  //DSL - group
  performance of "Json parse (non-databind)" in {
    measure method "jackson" in {
      using(gen) config (exec.benchRuns -> 10) in { _ =>
        for (_ <- 1 to 10000) jackson()
      }
    }

    measure method "gson" in {
      using(gen) config (exec.benchRuns -> 10) in { _ =>
        for (_ <- 1 to 10000) gson()
      }
    }

    measure method "org.json" in {
      using(gen) config (exec.benchRuns -> 10) in { _ =>
        for (_ <- 1 to 10000) orgJson()
      }
    }
  }
}

输出示例

:::Summary of regression test results - Accepter():::
Test group: Json parse (non-databind).jackson
- Json parse (non-databind).jackson.Test-0 measurements:
  - at fixed -> (): passed
    (mean = 12.32 ms, ci = <-2.29 ms, 26.92 ms>, significance = 1.0E-10)

Test group: Json parse (non-databind).gson
- Json parse (non-databind).gson.Test-1 measurements:
  - at fixed -> (): passed
    (mean = 17.48 ms, ci = <-30.37 ms, 65.33 ms>, significance = 1.0E-10)

Test group: Json parse (non-databind).org.json
- Json parse (non-databind).org.json.Test-2 measurements:
  - at fixed -> (): passed
    (mean = 54.94 ms, ci = <28.10 ms, 81.78 ms>, significance = 1.0E-10)

Summary: 3 tests passed, 0 tests failed.

2.3.3 更多

上面的代码示例可以在du00/mini-jvm-bencnmark找到,对于做Benchmark还是不够严谨的,范围也太小。对更广泛的java json包的表现可以在移步项目fabienrenaud/java-json-benchmark,代码、数据图表和结论都有,需要自取这里就不剧透了。但是这个项目里面的代码太复杂,作为测试代码用例还是不太行,所以上面的简单代码还是有那么一点点意义。

3. 小结

最后,开头提到的同事分析的Jackon的问题成立吗?不一定,真实环境总是复杂的,比如在Full GC发生的时候,所有操作的时间都会拉长。严谨一点应该是定位到“有问题”的点,消除环境上的差异,单纯地再做一次这一条耗时长的数据的重复十万次解析,问题自然明了。

你可能感兴趣的:(杂谈,java,编程语言)