Edited at

マイクロベンチマーク用ライブラリ JMH を Gradle プロジェクトで使う

More than 1 year has passed since last update.


概要

自作のツールで非同期処理を実装する際、 Reactive Extensions に RxJavaReactor Core のどちらを使うか検討する機会がありました。そこで、マイクロベンチマークライブラリの JMH を Gradle プロジェクトに対し使い、パフォーマンスを測定してみました。今回の用途ではベンチマークの結果に大差がなく、好きな方を使えばよいと判断するに至りました。

気になる点や誤っている箇所がございましたら、コメントや編集リクエストの形でお寄せくださいますと幸いです。


想定読者

Gradle を使って JMH でマイクロベンチマークしたいが、まだやったことのない Java 開発者


この記事で触れること

JMH を Gradle プラグインを使って利用する方法について


この記事で触れないこと


  • Reactive Extensions の意義やその使い方について

  • Gradle の使い方について

  • Maven プロジェクトでの JMH 利用について



背景

RxJava で実装した処理に対し、使用ライブラリを RxJava から Reactor Core に変更するという状況で、「修正の前後でパフォーマンスが悪化しない」ことを確認していきます。RxJava と Reactor Core はどちらも Reactive Extensions のライブラリです。


パフォーマンス計測の必要性

「修正の前後でパフォーマンスが悪化しない」ことを確認するためには、修正の前後でのパフォーマンスを計測して比較しなくてはいけません。過日読んだ『闘うプログラマー』(G.パスカル・ザカリー 著,山岡洋一 訳 日経BP社/ISBN-13: 978-4822247577)という小説で、マイケル・アブラッシュ(Michael Abrash)の発言として次の言葉が登場していました。


「コードを改善するために全力をつくした後、改善の度合いを測定すべきだ。パフォーマンスを測定しなければ、コードが良くなったはずだと推測しているにすぎない。推測しているだけでは、おそらくは、一流のコードは書けないだろう」


ただ、Java のパフォーマンス計測は一筋縄でいかないことで良く知られています。世の中には便利なライブラリがありますので、その力を借りることにしましょう。今回は JMH を使ってみます。


JMH とは

OpenJDK のサイトで公開されているマイクロベンチマーク用のライブラリです。かつては自分でソースコードからビルドする必要があったらしいですが、今は Maven Central に登録されていますので、ライブラリの依存を追加するだけで使用できます。ちなみに名称は MVNREPOSITORY によると "Java Microbenchmark Harness" のアクロニムのようです。

JJUG CCC 2016 Spring の「Eclipse Collectionsで学ぶコード品質向上の勘所」で伺った話だと、下記の特徴があるそうです。

1. JVM のウォームアップを Framework が適切にやってくれる

2. 設定をメソッドチェインで書ける


ライセンス

GPL v2.0 とのことです。ベンチマーク用のライブラリを配布物に含めることは基本的にないので、大抵のケースでは問題ないと思いますが、特殊な利用法を採る場合は留意しておくとよさそうです。


マイクロベンチマークとは

ごく小さい単位でのパフォーマンス計測をこう呼びます。


適用例

『Javaパフォーマンス』 (Scott Oaks 著、Acroquest Technology株式会社 監訳、寺田 佳央 監訳、牧野 聡 訳 オライリー・ジャパン/ ISBN978-4-87311-718-8) によると下記の通りです。


  1. Synchronizedメソッドとそうでないメソッドの呼び出し比較

  2. スレッドの生成・再利用でオーバヘッド比較

  3. アルゴリズムの実行時間比較


マイクロベンチマークを正しく記述するのは困難

『Javaパフォーマンス』によれば、次の理由で正確な記述が難しいとされています。


1. 処理結果を利用しなければいけない


  • 処理結果を使わないと、コンパイラが最適化で処理部分を削ってしまいかねない

  • 処理結果を入れる変数をインスタンス変数にして volatile をつける(第9章)


2. 不要な処理を入れてはいけない

Random でテストデータセットを作るのは時間計測部分の外に置く


3. 正しい入力に基づいて測定しなければいけない…… データセットの検査も外側でやる


4. ウォームアップが重要



実行環境

OS
Windows10

Java SE
1.8.0_91

Gradle
2.12

JMH
1.12



JMH 導入


(任意) ベンチマーク用プロジェクトの作成

今回は対象とする処理を別のプロジェクトに切り出して計測します。このようなプロジェクトを作成しました。

パフォーマンス計測対象とするクラスは下記の2つです。


  1. jp.toastkid.name.reactor.NameLoader.java

  2. jp.toastkid.name.rxjava.NameLoader.java

やっている処理としては下記の通りです。詳しくはソースコードをご覧ください。


  1. 1行に1つの JSON を記述したテキストファイルを全行読み込むストリームを作る

  2. 1行ずつ読み込んで、Jackson の ObjectReader を使って NameInformation オブジェクトに変換

  3. 2 のオブジェクトを List に格納

  4. 2 のオブジェクトが持っている nationality という String を Set に格納


JMH Gradle プラグインの導入

Gradle で JMH を動かすには JMH Gradle プラグイン を使うのがよさそうです。


注意点

この JMH プラグイン、 私が使おうと思った際にはリポジトリの README 通りに依存を追加したら依存解決できず、下記の通りに build.gradle に記述する必要がありました。


プラグインの依存解決

buildscript {

repositories {
maven {
url "https://plugins.gradle.org/m2/"
}
}
dependencies {
classpath "me.champeau.gradle:jmh-gradle-plugin:0.3.1"
}
}

apply plugin: "me.champeau.gradle.jmh"



JMH オプション

お好みで JMH のオプションも指定できます。


JMHオプション

jmh {

jmhVersion = '1.12'
warmupIterations = 5
iterations = 20
fork = 2
benchmarkMode = ['thrpt']
failOnError = true
}

jmhJar {
destinationDir = projectDir
}


使えるオプションは README に記載されています。以下、そのまま写します。

name
sample
description

include
'some regular expression'
include pattern (regular expression) for benchmarks to be executed

exclude
'some regular expression'
exclude pattern (regular expression) for benchmarks to be executed

iterations
10
Number of measurement iterations to do.

benchmarkMode
['thrpt','ss']
Benchmark mode. Available modes are: [Throughput/thrpt, AverageTime/avgt, SampleTime/sample, SingleShotTime/ss, All/all]

batchSize
1
Batch size: number of benchmark method calls per operation. (some benchmark modes can ignore this setting)

fork
2
How many times to forks a single benchmark. Use 0 to disable forking altogether

failOnError
false
Should JMH fail immediately if any benchmark had experienced the unrecoverable error?

forceGC
false
Should JMH force GC between iterations?

jvm
'myjvm'
Custom JVM to use when forking.

jvmArgs

'Custom JVM args to use when forking.'

jvmArgsAppend

'Custom JVM args to use when forking (append these)'

jvmArgsPrepend

'Custom JVM args to use when forking (prepend these)'

humanOutputFile
project.file("${project.buildDir}/reports/jmh/human.txt")
human-readable output file

resultsFile
project.file("${project.buildDir}/reports/jmh/results.txt")
results file

operationsPerInvocation
10
Operations per invocation.

benchmarkParameters
[:]
Benchmark parameters.

profilers
[]
Use profilers to collect additional data. Supported profilers: [cl, comp, gc, stack, perf, perfnorm, perfasm, xperf, xperfasm, hs_cl, hs_comp, hs_gc, hs_rt, hs_thr]

timeOnIteration
'1s'
Time to spend at each measurement iteration.

resultFormat
'CSV'
Result format type (one of CSV, JSON, NONE, SCSV, TEXT)

synchronizeIterations
false
Synchronize iterations?

threads
4
Number of worker threads to run with.

threadGroups
[2,3,4]
Override thread group distribution for asymmetric benchmarks.

timeout
'1s'
Timeout for benchmark iteration.

timeUnit
'ms'
Output time unit. Available time units are: [m, s, ms, us, ns].

verbosity
'NORMAL'
Verbosity mode. Available modes are: [SILENT, NORMAL, EXTRA]

warmup
'1s'
Time to spend at each warmup iteration.

warmupBatchSize
10
Warmup batch size: number of benchmark method calls per operation.

warmupForks
0
How many warmup forks to make for a single benchmark. 0 to disable warmup forks.

warmupIterations
1
Number of warmup iterations to do.

warmupMode
'INDI'
Warmup mode for warming up selected benchmarks. Warmup modes are: [INDI, BULK, BULK_INDI].

warmupBenchmarks
['.*Warmup']
Warmup benchmarks to include in the run in addition to already selected. JMH will not measure these benchmarks, but only use them for the warmup.

zip64
true
Use ZIP64 format for bigger archives

jmhVersion
'1.12'
Specifies JMH version

includeTests
true
Allows to include test sources into generate JMH jar, i.e. use it when benchmarks depend on the test classes.

duplicateClassesStrategy
'fail'
Strategy to apply when encountring duplicate classes during creation of the fat jar (i.e. while executing jmhJar task)

JMH の実行オプションについては下記の記事が詳しいです。

また、オプションはアノテーションで指定することもできます。サンプルコードを見て、どういうアノテーションが使えるのかを調べるのもよいでしょう。


ベンチマーク対象とするメソッドに @Benchmark アノテーションを付与


最終的なベンチマーク用クラス


LoaderBenchmark.java

import java.util.concurrent.TimeUnit;

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;

@State(Scope.Thread)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class LoaderBenchmark {

@Benchmark
@BenchmarkMode(Mode.AverageTime)
public void reactor() {
final jp.toastkid.name.reactor.App m = new jp.toastkid.name.reactor.App();
System.out.print("rec.1 names: " + m.firstNames.size());
System.out.print("rec.2 names: " + m.familyNames.size());
}

@Benchmark
@BenchmarkMode(Mode.AverageTime)
public void rxjava() {
final jp.toastkid.name.rxjava.App m = new jp.toastkid.name.rxjava.App();
System.out.print("rxj.1 names: " + m.firstNames.size());
System.out.print("rxj.2 names: " + m.familyNames.size());
}

}



修正箇所


  1. JMH Gradle プラグインの追加

  2. ソースフォルダ src/jmh/java を追加

  3. ベンチマーク用クラス LoaderBenchmark の追加

  4. JMH の Main と干渉するクラス名 Main を App に修正


注意点


  • ベンチマーク用クラスは final にできない

  • ベンチマーク対象メソッドは private にできない

  • JMH の Gradle プラグインを利用する場合、ベンチマーク用のクラスを src/jmh/java 以下に置かないといけない



実行

準備が完了したら、$ gradle clean jmh でベンチマークを実行します。あとは終了するまでしばし待ちましょう。


実行結果

$ gradle clean jmh

:clean
:compileJava
:processResources
:classes
:compileTestJava
:processTestResources UP-TO-DATE
:testClasses
:compileJmhJava
:processJmhResources UP-TO-DATE
:jmhClasses
:jmhRunBytecodeGenerator
Processing 1 classes from /path/to/jmh_example/build/classes/jmh with "reflection" generator
Writing out Java source to /path/to/jmh_example/build/jmh-generated-sources and resources to /path/to/jmh_example/build/jmh-generated-classes
:jmhCompileGeneratedClasses
:jmhJar
:jmh
# JMH 1.12 (released 128 days ago, please consider updating!)
# VM version: JDK 1.8.0_91, VM 25.91-b14
# VM invoker: C:/Program Files/Java/jdk1.8.0_91/jre/bin/java.exe
# VM options: -Dfile.encoding=windows-31j -Duser.country=JP -Duser.language=ja -Duser.variant
# Warmup: 5 iterations, 1 s each
# Measurement: 20 iterations, 1 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: jp.toastkid.name.LoaderBenchmark.reactor

# Run progress: 0.00% complete, ETA 00:01:40
# Fork: 1 of 2
# Warmup Iteration 1: rec.1 (省略)
……中略……
# Warmup Iteration 5: rec.1 (省略)
Iteration 1: rec.1 names: (省略)
……中略……
Iteration 20: rec.1 names: (省略)

# Run progress: 25.00% complete, ETA 00:01:17
# Fork: 2 of 2
# Warmup Iteration 1: rec.1 (省略)
……中略……
# Warmup Iteration 5: rec.1 (省略)

Iteration 1: rec.1 names: (省略)
……中略……
Iteration 20: rec.1 names: (省略)

Result "reactor":
0.060 ±(99.9%) 0.003 ops/ms [Average]
(min, avg, max) = (0.050, 0.060, 0.075), stdev = 0.005
CI (99.9%): [0.057, 0.063] (assumes normal distribution)

# JMH 1.12 (released 128 days ago, please consider updating!)
# VM version: JDK 1.8.0_91, VM 25.91-b14
# VM invoker: C:/Program Files/Java/jdk1.8.0_91/jre/bin/java.exe
# VM options: -Dfile.encoding=windows-31j -Duser.country=JP -Duser.language=ja -Duser.variant
# Warmup: 5 iterations, 1 s each
# Measurement: 20 iterations, 1 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: jp.toastkid.name.LoaderBenchmark.rxjava

# Run progress: 50.00% complete, ETA 00:00:51
# Fork: 1 of 2
# Warmup Iteration 1: rxj.1 (省略)
……中略……
# Warmup Iteration 5: rxj.1 (省略)
Iteration 1: (省略)
……中略……
Iteration 20: (省略)

# Run progress: 75.00% complete, ETA 00:00:25
# Fork: 2 of 2
# Warmup Iteration 1: (省略)
……中略……
# Warmup Iteration 5: (省略)
Iteration 1: rxj.1 (省略)
……中略……
Iteration 20: rxj.1 (省略)

Result "rxjava":
0.059 ±(99.9%) 0.002 ops/ms [Average]
(min, avg, max) = (0.047, 0.059, 0.064), stdev = 0.004
CI (99.9%): [0.057, 0.061] (assumes normal distribution)

# Run complete. Total time: 00:01:43

Benchmark Mode Cnt Score Error Units
LoaderBenchmark.reactor thrpt 40 0.060 ± 0.003 ops/ms
LoaderBenchmark.rxjava thrpt 40 0.059 ± 0.002 ops/ms

Benchmark result is saved to build/reports/jmh/results.txt

BUILD SUCCESSFUL

Total time: 1 mins 54.1 secs



結果の確認

最終的な結果だけを取り出して表にしてみます。

Benchmark
Mode
Cnt
Score
Error
Units

LoaderBenchmark.reactor
thrpt
40
0.060
± 0.003
ops/ms

LoaderBenchmark.rxjava
thrpt
40
0.059
± 0.002
ops/ms

この結果から、2つのライブラリに特段の性能差がないことが伺えます。


生成されたベンチマークコード

JMH 用のソースコードは標準設定ですと build/jmh-generated-sources 以下に出力されます。主にクラス名_メソッド名_jmhTest.java というファイルを開くと、マイクロベンチマークを実施するコードを覗くことができます。



まとめ

Gradle プロジェクトに実装した処理に対し、 JMH でパフォーマンスを計測し、2つのライブラリでの実装間に大きなパフォーマンスの差が見られないと判断することができました。普段 Java での開発時に感じているパフォーマンスの疑問があれば、JMH でのマイクロベンチマークを実行して、その疑問を解消すると、よりよい Java との付き合い方ができそうです。



参考


ソースコード

今回のソースコードは GitHub Repository に置きました。ご興味がございましたらそちらをご参照ください。


書籍

『Javaパフォーマンス』 (Scott Oaks 著、Acroquest Technology株式会社 監訳、寺田 佳央 監訳、牧野 聡 訳 オライリー・ジャパン/ ISBN978-4-87311-718-8)


対象読者

『Javaパフォーマンス』の同書籍中より


  1. パフォーマンスを追求するエンジニアや開発者

  2. JVM 自体のパフォーマンスに興味があり、コーディングなしで JVM の振る舞いを変えたい人

  3. パフォーマンス分析をこれから始める人


リンク



おまけ


ベンチマーク用の実行 jar 作成

ここまで述べた通り、 JMH でのベンチマークは、Gradle プラグインを用いれば $ gradle jmh コマンドだけで実行できます。

$ gradle jmhJar コマンドで jmh 用の executable jar を生成できます。今回の場合は jmx_example-0.0.1-jmh.jar という jar が生成されます。その jar を -jar オプションで指定して動かすことでもベンチマークは実行可能です。


実行コマンド例

$ java -jar jmx_example-0.0.1-jmh.jar -wi 5 -i 5 -f 1  -bm ss -tu ms



Method parameters should be @State classes.

@Benchmark アノテーションをつけたメソッドで下記のエラーメッセージが出ます。

Method parameters should be @State classes.

[org.openjdk.jmh.generators.reflection.RFMethodInfo@3feba861]

:jmhRunBytecodeGenerator FAILED

私のケースでは、どうも String 引数のあるメソッドを呼び出そうとするとダメなようでした。仕方ないので引数なしのメソッドからありのメソッドを迂回して呼んでいます。