概要
自作のツールで非同期処理を実装する際、 Reactive Extensions に RxJava と Reactor 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で学ぶコード品質向上の勘所」で伺った話だと、下記の特徴があるそうです。
- JVM のウォームアップを Framework が適切にやってくれる
- 設定をメソッドチェインで書ける
ライセンス
GPL v2.0 とのことです。ベンチマーク用のライブラリを配布物に含めることは基本的にないので、大抵のケースでは問題ないと思いますが、特殊な利用法を採る場合は留意しておくとよさそうです。
マイクロベンチマークとは
ごく小さい単位でのパフォーマンス計測をこう呼びます。
適用例
『Javaパフォーマンス』 (Scott Oaks 著、Acroquest Technology株式会社 監訳、寺田 佳央 監訳、牧野 聡 訳 オライリー・ジャパン/ ISBN978-4-87311-718-8) によると下記の通りです。
- Synchronizedメソッドとそうでないメソッドの呼び出し比較
- スレッドの生成・再利用でオーバヘッド比較
- アルゴリズムの実行時間比較
マイクロベンチマークを正しく記述するのは困難
『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行に1つの JSON を記述したテキストファイルを全行読み込むストリームを作る
- 1行ずつ読み込んで、Jackson の ObjectReader を使って NameInformation オブジェクトに変換
- 2 のオブジェクトを List に格納
- 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 {
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 アノテーションを付与
最終的なベンチマーク用クラス
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());
}
}
修正箇所
- JMH Gradle プラグインの追加
- ソースフォルダ
src/jmh/java
を追加 - ベンチマーク用クラス LoaderBenchmark の追加
- 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パフォーマンス』の同書籍中より
- パフォーマンスを追求するエンジニアや開発者
- JVM 自体のパフォーマンスに興味があり、コーディングなしで JVM の振る舞いを変えたい人
- パフォーマンス分析をこれから始める人
リンク
- how to write a small benchmark of getbytes with jmh?
- Javaのベンチツール、JMHにテストコードを書いて計測してみる
- マイクロベンチマークツール、JMHを試す
おまけ
ベンチマーク用の実行 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 引数のあるメソッドを呼び出そうとするとダメなようでした。仕方ないので引数なしのメソッドからありのメソッドを迂回して呼んでいます。