後編:JMH(Java Microbenchmark Harness)のサンプルを動かしながら勉強

これの続き

bufferings.hatenablog.com

## 実行時オプション

サンプルを実行しながらなんとなくそんな気がしてたけど

❯ java -jar target/benchmarks.jar JMHSample_11 -f 1 -w 3 -wi 1 -r 3 -i 5 

これだと

という意味になる。-hをつけることでオプションの一覧を見ることができる。

## ループの最適化の影響に注意する

自分でループを使うんじゃなくてJMHの計測の繰り返し処理に任せる。ループを使うと最適化によってアンロールされてパイプライン化されたりしてしまうから。

Benchmark                               Mode  Cnt  Score   Error  Units
JMHSample_11_Loops.measureRight         avgt    5  2.262 ± 0.333  ns/op
JMHSample_11_Loops.measureWrong_1       avgt    5  2.657 ± 1.407  ns/op
JMHSample_11_Loops.measureWrong_10      avgt    5  0.315 ± 0.135  ns/op
JMHSample_11_Loops.measureWrong_100     avgt    5  0.031 ± 0.012  ns/op
JMHSample_11_Loops.measureWrong_1000    avgt    5  0.028 ± 0.021  ns/op
JMHSample_11_Loops.measureWrong_10000   avgt    5  0.021 ± 0.002  ns/op
JMHSample_11_Loops.measureWrong_100000  avgt    5  0.019 ± 0.003  ns/op

へー。ループ10回するだけで、もう1回あたりの平均実行時間は、ずれるんだねぇ。

どうしてもループを使わなきゃいけない場合は、Blackholeに吸わせることで、この最適化を避けることができる。

    @Benchmark
    public void measureRight_1(Blackhole bh) {
        for (int x : xs) {
            bh.consume(work(x));
        }
    }

試しにJMHSample_11_LoopsをBlackholeに吸わせてJMHSample_11_Loops2を作ってみたらこんな感じになった。 へー。良くなった。

❯ java -jar target/benchmarks.jar JMHSample_11 -f 1 -wi 1 -r 3 -i 3 -w 3

...

Benchmark                                Mode  Cnt  Score   Error  Units
JMHSample_11_Loops.measureRight          avgt    3  2.940 ± 1.462  ns/op
JMHSample_11_Loops.measureWrong_1        avgt    3  2.911 ± 2.070  ns/op
JMHSample_11_Loops.measureWrong_10       avgt    3  0.372 ± 0.523  ns/op
JMHSample_11_Loops.measureWrong_100      avgt    3  0.042 ± 0.021  ns/op
JMHSample_11_Loops.measureWrong_1000     avgt    3  0.039 ± 0.085  ns/op
JMHSample_11_Loops.measureWrong_10000    avgt    3  0.030 ± 0.025  ns/op
JMHSample_11_Loops.measureWrong_100000   avgt    3  0.027 ± 0.005  ns/op
JMHSample_11_Loops2.measureRight         avgt    3  3.240 ± 5.628  ns/op
JMHSample_11_Loops2.measureWrong_1       avgt    3  3.234 ± 4.986  ns/op
JMHSample_11_Loops2.measureWrong_10      avgt    3  2.966 ± 2.372  ns/op
JMHSample_11_Loops2.measureWrong_100     avgt    3  3.013 ± 3.742  ns/op
JMHSample_11_Loops2.measureWrong_1000    avgt    3  3.003 ± 3.001  ns/op
JMHSample_11_Loops2.measureWrong_10000   avgt    3  2.838 ± 0.355  ns/op
JMHSample_11_Loops2.measureWrong_100000  avgt    3  2.853 ± 1.185  ns/op

## プロファイルに基づく最適化の影響に注意する

Profile-Guided Optimizations(プロファイルに基づく最適化)の影響を避けるために、フォークして別プロセスで実行する。デフォルトでは5つのフォークが実行されるようになっている。

・・・なるほど?(←わかってない)よく分かってないけど、同じプロセスだと最適化の影響があるっぽいことだけ分かった。

@Forkアノテーションを使ってフォーク数を指定できる。↓だと1個

    @Benchmark
    @Fork(1)
    public int measure_4_forked_c1() {
        return measure(c1);
    }

↓0だとフォークなしで、これは良くない(警告が出る)。

    @Benchmark
    @Fork(0)
    public int measure_1_c1() {
        return measure(c1);
    }

フォーク数を指定すると、その数だけ実行されるので実行ごとの分散を評価することができる。

    @Benchmark
    @Fork(20)
    public void fork_1(SleepyState s) throws InterruptedException {
        TimeUnit.MILLISECONDS.sleep(s.sleepTime);
    }

@Forkを指定しなかったらデフォルトは5。

## グルーピング

@Groupを使うと、複数のベンチマークメソッドをグルーピングして、それぞれのメソッドが使用するスレッドの割合を調整して実行することができる。あぁ、これ用にScope.Groupがあるのか(前回の記事参照)。

❯  java -jar target/benchmarks.jar JMHSample_15 -f 1                                                                                                                                                   
...
# Threads: 4 threads (1 group; 1x "get", 3x "inc" in each group), will synchronize iterations                                                                                                          
...

Benchmark                      Mode  Cnt   Score    Error  Units
JMHSample_15_Asymmetric.g      avgt    5  53.744 ± 12.789  ns/op
JMHSample_15_Asymmetric.g:get  avgt    5  28.416 ± 10.066  ns/op
JMHSample_15_Asymmetric.g:inc  avgt    5  62.187 ± 15.156  ns/op

## SyncIterationオプション(-si)

複数のスレッドで実行するときにスレッドの開始終了部分がパフォーマンスに影響するのを避けるために、立ち上げと終了を除去する仕組み。デフォルトでtrueになってる。

trueの場合

❯  java -jar target/benchmarks.jar JMHSample_17 \
             -w 1s -r 1s -f 1 -t 64 -si true

...

Benchmark                          Mode  Cnt    Score   Error   Units
JMHSample_17_SyncIterations.test  thrpt    5  134.461 ± 7.350  ops/ms

falseの場合

❯  java -jar target/benchmarks.jar JMHSample_17 \
             -w 1s -r 1s -f 1 -t 64 -si false

...

Benchmark                          Mode  Cnt    Score   Error   Units
JMHSample_17_SyncIterations.test  thrpt    5  143.721 ± 5.549  ops/ms

起動終了部分の影響でスコアは良くなるけど、この結果は、あてにならない。

## アノテーションで指定

WarmupやMeasurementの回数や時間などはアノテーションで指定可能。クラスでもメソッドでもつけられる。

@Warmup(iterations = 5, time = 100, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 5, time = 100, timeUnit = TimeUnit.MILLISECONDS)

## 他にも

  • BlackholeにCPUを消費させることができたり
  • 複数スレッドがデータを扱う際の、FalseSharingによるパフォーマンス劣化に対する対応をStateが最初からしてくれてたり
  • 一定時間内に実行できた回数でパフォーマンスをはかるより、計測回数を指定してパフォーマンスを図った方が良い場合はシングルショットxバッチサイズでやることができる
  • 設定ごとのパフォーマンスの違いを見たい場合は@Paramアノテーションでパラメータを指定する。パラメータの組み合わせもやってくれるっぽい

とか、色々ある。面白かったー!!

## 試しに

あんまり考えずに、こういうコードを書いてみた。

  • 文字列の連結を+StringBuilderappendでやる
  • それぞれ10000回を5イテレーション
  • その前にウォームアップで10000回を1イテレーション
  • 1回あたりの結果をミリ秒で表示
@Fork(1)
@Warmup(iterations=1, batchSize = 10000)
@Measurement(iterations=5, batchSize = 10000)
@BenchmarkMode(Mode.SingleShotTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Thread)
public class MyBenchmark {

    String s = "";

    StringBuilder sb = new StringBuilder();

    String word = "a";

    @Benchmark
    public String baseline() {
        return s;
    }

    @Benchmark
    public String measureStringPlus() {
        s += word;
        return s;
    }

    @Benchmark
    public StringBuilder measureStringBuilderAppend() {
        sb.append(word);
        return sb;
    }
}

実行してみたらこうなった。

❯ java -jar target/benchmarks.jar MyBenchmark                                                                                                                                                          
# JMH version: 1.21                                                                                                                                                                                    
# VM version: JDK 1.8.0_191, Java HotSpot(TM) 64-Bit Server VM, 25.191-b12  
...
Benchmark                               Mode  Cnt    Score     Error  Units
MyBenchmark.baseline                      ss    5    0.430 ±   0.243  ms/op
MyBenchmark.measureStringBuilderAppend    ss    5    0.697 ±   0.317  ms/op
MyBenchmark.measureStringPlus             ss    5  147.748 ± 246.386  ms/op

へー。200倍くらい違う。

なんとなく、Java 11に変えてみる。

❯ sdk use java 11.0.1-open

Using java version 11.0.1-open in this shell.

❯ java -jar target/benchmarks.jar MyBenchmark
...
# JMH version: 1.21                                                                                                                                                                                    
# VM version: JDK 11.0.1, OpenJDK 64-Bit Server VM, 11.0.1+13 
...
Benchmark                               Mode  Cnt   Score     Error  Units
MyBenchmark.baseline                      ss    5   0.752 ±   0.017  ms/op
MyBenchmark.measureStringBuilderAppend    ss    5   0.834 ±   0.641  ms/op
MyBenchmark.measureStringPlus             ss    5  75.690 ± 171.308  ms/op

お。+Java 8のときより速くなった。

んー。なんとなくEpsilonGC(何もしないGC)をONにしてみる

❯ java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -jar target/benchmarks.jar MyBenchmark
...
Benchmark                               Mode  Cnt    Score     Error  Units
MyBenchmark.baseline                      ss    5    0.970 ±   2.173  ms/op
MyBenchmark.measureStringBuilderAppend    ss    5    0.641 ±   0.911  ms/op
MyBenchmark.measureStringPlus             ss    5  161.040 ± 302.442  ms/op

あれ。速くなるのかなぁと思ってたら、Java 8のときより遅くなっちゃった。ふむー。面白い。それぞれで何がどう影響してこういう結果になってるのか全然分かんない。もっと勉強しなきゃなぁ。マイクロベンチマークって難しいな。

## ところで、何でJMHチェックし始めたんだっけ?

と思ったら、そうだ、S1Pのセッションを見てたんだった。次はそれ見てみよっと。意味が分かるようになってるといいな。