JavaのマイクロベンチマークツールJMHを試してみた

JavaのマイクロベンチマークツールJMHを試してみた

April 30, 2019
Programming
bench mark, gradle, java, JMH

前書き

コードっていろんな書き方出来ますよね その時可読性を意識するか、パフォーマンスを意識するか、とりあえずモダンな機能で書いてみるかとかー..etc 色々選択肢があるわけで

個人的には 可読性 > モダンな書き方 > パフォーマンスくらいの優先度で書いてます (正直 C/C++とかみたいな世界でもない限り, よっぽどパフォーマスん酷くなければ無視していいだろと思ってます..)

でもパフォーマンス気にしないといっても、最近は気になるわけで ググったりするけど、

◯◯ 処理はさほどパフォーマンス落ちないので、ぜひこちらを使いましょう

みたいな記事が多いので、実感が持ちづらい やっぱり自分で手を動かして、実際に計測値を見て判定したい(Golang の testing.b 的な)

ということで Java でメソッドとか機能とかの小さい単位でベンチマークできるJMHを調べて見ました。 (マイクロベンチマークっていうらしいですね)

JMH?

JDK 公式のマイクロベンチマークツールとのこと

サンプルプロジェクト立てる

依存ライブラリとかビルド用に gradle プロジェクトを立てます

Intellij で Project -> new -> Gradle でやりましょう 1 適当な groupId と artifactId つけて 2

JMH のビルド

Gradle 用のプラグインを使います https://github.com/melix/jmh-gradle-plugin

build.gradle を修正

apply plugin: "java"

group 'microbenchmark-sample'
version '1.0-SNAPSHOT'

sourceCompatibility = 1.8

// gradleプロジェクトでjmhを使えるようにする設定
buildscript {
    repositories {
        maven {
            url "https://plugins.gradle.org/m2/"
        }
    }
    dependencies {
        classpath "me.champeau.gradle:jmh-gradle-plugin:0.4.7"
    }
}
apply plugin: "me.champeau.gradle.jmh"

// ライブラリのダウンロード先指定
repositories {
    mavenCentral()
}

// jmhの依存関係解決
dependencies {
    compile 'org.openjdk.jmh:jmh-core:1.21'
}

// jmhの設定
jmh {
    jmhVersion = '1.21'
    warmupIterations = 5
    iterations = 20
    fork = 2
    benchmarkMode = ['thrpt']
    failOnError = true
}

jmhJar {
    destinationDir = projectDir
}

ベンチマーク対象クラスを作成

今回は下のような機能でいくつかの書き方を比較してみる

  • DB へアクセスしてユーザ名リストを取得し, 先頭 1 件のユーザー名を大文字にして返却する
  • DB から取得する時点で期待したソートは実施されているとする
  • 1 件もヒットしない場合のことも考慮すること

フォルダを掘る

~/w/bcmk-sample> mkdir -p src/main/java/bcmk
~/w/bcmk-sample> touch src/main/java/bcmk/App.java

3 つのメソッドを用意

package bcmk;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Random;

public class App {

    // 乱数生成用
    private final static Random random = new Random();

    /**
     * if文のやつ
     * @return
     */
    public Optional<String> getUserName_legacy() {
        List<String> userNameList = getUserNameList();

        if(userNameList.isEmpty()) {
            return Optional.empty();
        } else {
            return Optional.of(userNameList.get(0).toUpperCase());
        }
    }

    /**
     * 三項演算子
     * @return
     */
    public Optional<String> getUserName_old() {
        List<String> userNameList = getUserNameList();

        return userNameList.isEmpty()
                ? Optional.empty()
                : Optional.of(userNameList.get(0).toUpperCase());
    }

    /**
     * Stream使う
     * @return
     */
    public Optional<String> getUserName_stream() {
        List<String> userNameList = getUserNameList();

        return userNameList.stream().findFirst().map(s -> s.toUpperCase());
    }

    /**
     * 空リストか要素入りのリストが返される..DBからの取得の代用(計測の本質は DBじゃないため
     * @return
     */
    private List<String> getUserNameList() {
        List<String> userNameList = new ArrayList<>();
        userNameList.add("taro");
        userNameList.add("moge");

        return random.nextBoolean()
                ? new ArrayList<String>()
                : userNameList;
    }
}

3 つとも同じ機能です。 空か要素入りの List が返ってくるメソッドを呼び出して 結果が0件の場合は Optional.empty()を空でなければ、1 件目を取り出して大文字にして返します。

※Optional は Java で null 安全にコード書くためのやつです、ぬるぽに悩まされないしコードの美しさが上がるります → https://qiita.com/shindooo/items/815d651a72f568112910

1 つ目の if 文利用は見た目が汚いので個人的に嫌です。(return 二つが気持ち悪い 2 つ目の三項演算子利用は妥協です。(コードに 0 とかの数値とかが直で描かれるのが美しさを感じません 3 つ目の Stream がいいですよね. これはメソッドから受け取った List を一旦 Stream にして、findFirst メソッドを呼びます. 返却の型は Optionalです。つまり List が空であればこの時点で Optional.empty()を生成 空でなければ Optional で包んだ String がかえります. この Optionalに対してさらに map メソッドを呼びます map は呼び出し元が Optional.empty()の場合は何もせずにそのまま Optional.empty()を返します. 空でなければラムダ式を使って、なかの要素を取り出し何かしらの処理をして再度 Optional で包んで返します. この処理が 1 行でかかれてる、いいですよね

個人的には Stream 利用推しということです。

ベンチマーク用のパッケージを作成

上で書いた 3 つのメソッドを比較して行きましょう src/jmh というフォルダを掘ります

~/w/bcmk-sample> mkdir -p src/jmh/java/bcmk
~/w/bcmk-sample> touch src/jmh/java/bcmk/SampleBenchmark.java

で SampleBenchmark.java には下のようなコードです

package bcmk;

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;

import java.util.concurrent.TimeUnit;


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

    @Benchmark
    public void if文の古いコード() {
        final bcmk.App m = new bcmk.App();
        System.out.print("Result: " + m.getUserName_legacy().orElse("該当ユーザーはいませんでした"));
    }

    @Benchmark
    public void 三項演算子() {
        final bcmk.App m = new bcmk.App();
        System.out.print("Result: " + m.getUserName_old().orElse("該当ユーザーはいませんでした"));
    }

    @Benchmark
    public void Stream使ったやつ() {
        final bcmk.App m = new bcmk.App();
        System.out.print("Result: " + m.getUserName_stream().orElse("該当ユーザーはいませんでした"));
    }
}

先ほど作ったクラスを読み込んで、3つのメソッドをそれぞれ実行します あとはアノテーションをつければよしなに計測してくれます デフォだとスループット(秒間の処理実行回数)を計測します

実行

Intellij だったら GUI から実行か

3

もしくはターミナルで下のコマンド実行でいけます

~/w/bcmk-sample> ./gradlew clean jmh

2、30 分かかります

結果

Result "bcmk.SampleBenchmark.三項演算子":
  134.958 ±(99.9%) 10.147 ops/ms [Average]
  (min, avg, max) = (94.187, 134.958, 167.342), stdev = 18.036
  CI (99.9%): [124.811, 145.104] (assumes normal distribution)


# Run complete. Total time: 00:25:04

REMEMBER: The numbers below are just data. To gain reusable insights, you need to follow up on
why the numbers are the way they are. Use profilers (see -prof, -lprof), design factorial
experiments, perform baseline and negative tests that provide experimental control, make sure
the benchmarking environment is safe on JVM/OS/HW level, ask for reviews from the domain experts.
Do not assume the numbers tell you what you want them to tell.

Benchmark                     Mode  Cnt    Score    Error   Units
SampleBenchmark.Stream使ったやつ  thrpt   40  154.498 ±  9.895  ops/ms
SampleBenchmark.if文の古いコード    thrpt   40  146.304 ±  7.848  ops/ms
SampleBenchmark.三項演算子        thrpt   40  134.958 ± 10.147  ops/ms

BUILD SUCCESSFUL in 25m 12s
6 actionable tasks: 1 executed, 5 up-to-date
18:03:17: Task execution finished 'jmh'.

Score が秒間の実行回数(つまり多ければ多いほどいいです) Error は誤差ですね Score の ±Error 値が信頼値です

Stream が一番実行できてますね! パフォーマンスでも問題なさそうです (参考演算子がいちばんよくないんですね…参考演算子すきなんですが…

一応平均処理時間も見ておきましょう

計測モードの変更

build.grade の設定を変えます

// jmhの設定
- jmh {
-    jmhVersion = '1.21'
-    warmupIterations = 5
-    iterations = 20
-    fork = 2
-    benchmarkMode = ['thrpt']
-    failOnError = true
- }

// モードを消します(コードで設定します)
// 処理時間かかるので, iterationsとかも落とします
+ jmh {
+    jmhVersion = '1.21'
+    warmupIterations = 2
+    iterations = 5
+    fork = 2
+    failOnError = true
+ }
    @Benchmark
+    @BenchmarkMode(Mode.AverageTime)
    public void Stream使ったやつ() {
        final bcmk.App m = new bcmk.App();
        System.out.print("Result: " + m.getUserName_stream().orElse("該当ユーザーはいませんでした"));
    }

各メソッドに BenchmarkMode アノテーションを付与します

それで再実行

結果


Result "bcmk.SampleBenchmark.三項演算子":
  0.008 ±(99.9%) 0.002 ms/op [Average]
  (min, avg, max) = (0.007, 0.008, 0.011), stdev = 0.001
  CI (99.9%): [0.006, 0.010] (assumes normal distribution)


# Run complete. Total time: 00:07:04

REMEMBER: The numbers below are just data. To gain reusable insights, you need to follow up on
why the numbers are the way they are. Use profilers (see -prof, -lprof), design factorial
experiments, perform baseline and negative tests that provide experimental control, make sure
the benchmarking environment is safe on JVM/OS/HW level, ask for reviews from the domain experts.
Do not assume the numbers tell you what you want them to tell.

Benchmark                    Mode  Cnt  Score    Error  Units
SampleBenchmark.Stream使ったやつ  avgt   10  0.007 ±  0.001  ms/op
SampleBenchmark.if文の古いコード    avgt   10  0.006 ±  0.001  ms/op
SampleBenchmark.三項演算子        avgt   10  0.008 ±  0.002  ms/op

BUILD SUCCESSFUL in 7m 6s
6 actionable tasks: 1 executed, 5 up-to-date
18:15:01: Task execution finished 'jmh'.

同じくらいですね。 問題ないと思います

終わり

今後はこれでちょくちょく気になったら見て行こうかなと思う

参考ページ