🥰

Jmh で Scala のコードのマイクロベンチマークをとる

2021/12/24に公開

はじめに

ソフトウェアを改善するためには客観的なデータを集めてベンチワークをとるべきです.

さて、Scala のコレクションを要素ごとにマージする API には Array.zip(arr1,arr2).zipped があります. どちらが速いでしょうか. そして、なぜ速いのでしょうか. 結論を先に言うと zipped のほうが速いです. zipped は処理中の中間配列の割り当て、タプルのアロケーションをする必要がないのでその分パフォーマンスが良いです.
(※ Scala のタプルはオブジェクトの割り当てがあるのでパフォーマンスはあまりよくありません. また配列のサイズが事前にわかっているなら先に割り当てたほうが速いです.)

arr0.zip(arr1).map(/*do something */)
(arr0,arr1).zipped.map(/*do something */)  // this is faster

さて、この質問について Scala 界隈の有名 OSS 作者・コントリビューターの Travis Brown さんが stackoverflow でいい回答 をしているので、この記事ではそれを参考にしながら jmh を使ってベンチマークをとる手順をおさらいしましょう. jmh は Java Microbenchmark Harness の略です.

Scala(or JVM) のベンチマークツールにはいろいろあります. Scala でもチューニングには結局 JVM の情報が必要になるので Java 寄りのライブラリを使うケースが一般的なようです. 古い OSS では google の caliper を使っている例もありますが, 私が見る限り多くの OSS では sbt-jmh を介して jmh を使っています. これは jmh の薄いラッパーです.

jmh ではベンチマーク対象のコードにアノテーションを付与して様々な挙動を定義します.

ベンチマークを書く

さて、御託を並べてもしょうがないのでさっそくコードを書きましょう. jmh のバインディングは sbt プラグインとして公開されているのでそれを使ってシュッと設定をしましょう.

project/plugins.scala
addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.3")

プロジェクト全体でベンチマークを有効にするか、ベンチマーク用のサブプロジェクトを作って、そのプロジェクトでベンチマーク用のコードを書きます.

build.sbt
enablePlugins(JmhPlugin)

// または 
// lazy val example = project
//  .in(projectDir)
//  .enablePlugins(JmhPlugin)

今回は arr0.zip(arr1).map(...)(arr0,arr1).zipped.map(...) のどちらが速いのかをはかるので次のようなコードを書きます. また「宣言的」で「可読性の高い」といわれる関数型ライクな書き方のオーバーヘッドがどれくらいあるのかを確かめるいい機会なので for,while などの実装についても見てみましょう.

package bench

import org.openjdk.jmh.annotations._
import java.util.concurrent.TimeUnit
@State(Scope.Benchmark)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@BenchmarkMode(Array(Mode.Throughput))
class ZippedBench {
  
  var arr0: Array[Double] =  null
  var arr1: Array[Double] = null
  
  @Param(Array("100"))  // "1000", "1000000"))
  var length:Int = 0
  
  @Setup(Level.Iteration)
  def setup() :Unit = {
    arr0 = Array.fill(length)(math.random)
    arr1 = Array.fill(length)(math.random)
  }
  def benchZip(arr0: Array[Double], arr1: Array[Double]): Array[Double] =
    arr0.zip(arr1).map(x => x._1 + x._2)

  def benchZipped(arr0: Array[Double], arr1: Array[Double]): Array[Double] =
    (arr0, arr1).zipped.map((x, y) => x + y).toArray
  
  def benchFor(arr0: Array[Double], arr1: Array[Double]): Array[Double] = {
    val minSize = math.min(arr0.length, arr1.length)
    val newArr = new Array[Double](minSize)
    for (i <- 0 until minSize) {
      newArr(i) = arr0(i) + arr1(i)
    }
    newArr
  }
  
  def benchWhile(arr0: Array[Double], arr1: Array[Double]): Array[Double] = {
    val minSize = math.min(arr0.length, arr1.length)
    val newArr = new Array[Double](minSize)
    var i = 0
    while (i < minSize) {
      newArr(i) = arr0(i) + arr1(i)
      i += 1
    }
    newArr
  }
  
  def benchTabulate(arr0: Array[Double], arr1: Array[Double]): Array[Double] = {
    val minSize = math.min(arr0.length, arr1.length)
    Array.tabulate(minSize)(i => arr0(i) + arr1(i))
  }

  @Benchmark
  def withZip: Array[Double] = benchZip(arr0, arr1)
  
  @Benchmark
  def withZipped: Array[Double] = benchZipped(arr0, arr1) 
  
  @Benchmark
  def withFor: Array[Double] = benchFor(arr0,arr1)
  
  @Benchmark
  def withWhile: Array[Double] = benchWhile(arr0, arr1)
  
  @Benchmark
  def withTabulate: Array[Double] = benchTabulate(arr0,arr1)
  
}

jmh のベンチマークについて

ベンチマークを実行する前に jmh についてもう少し説明します.

var arr0: Array[Double] =  null
var arr1: Array[Double] = null
//...
  @Setup
  def setup() :Unit = {
    arr0 = Array.fill(length)(math.random)
    arr1 = Array.fill(length)(math.random)
  }

@Benchmark アノテーションをつけたメソッドには ExecutionPlan, StateBlackhole など決められたものしか引数に渡せないのでこのように setup 処理を定義して 異なる長さの arr0, arr1 をベンチマークするメソッドに渡せるようにしています.

@Fork(value = 1, warmups = 2)

まず、 JVM はしばしばループを最適化してしまうので開発者がベンチマーク内で直接ループを書くことは推奨されていません. jmh ではアノテーションを介して jmh にループの回数などを指示します.

上の例では jit 最適化のために warmup する回数を 2回に指定しています. warmup のときのデータは捨てられてベンチマーク結果には含まれません. 長時間、何度も呼び出される処理をベンチマークを計測する際は warmup をするべきですが、短時間、たまにしか呼び出されない処理を計測する場合は少し後で紹介するBenckmarkModeSingleShot にして計測するほうがよさそうです.

``@scala
@OutputTimeUnit(TimeUnit.MILLISECONDS)

結果に表示される時間の単位は`@OutputTimeUnit` で指定します.

```scala
@BenchmarkMode(Array(Mode.Throughput))

また、ベンチマークには複数のモードがあります. 計測したい対象に応じてこれらを指定しましょう.

mode 内容
Throughput 一定時間当たり何回処理を実行できるか. 大きいほど速い.
AverageTime 処理の時間に平均どれくらい時間がかかるか. 小さいほど速い.
SampleTime 処理の時間にどれくらい時間がかかるか. min, max などをサンプリングする.
SingleShot コールドスタートで処理を実行するのにどれくらい時間がかかるか.

また、セットアップにも複数のモードがあります.
|mode|内容|
|Level.Trial|ウォームアップを含む一連のベンチマークにつき実行されます.|
|Level.Iteration|イテレーションごとにセットアップを実行します.|
|Level.Invocation|ベンチマークメソッドの呼び出しごとにセットアップを実行します.|

アノテーションを付与することで @Param,@Setup,@Scope などのベンチマークの複数の軸のマトリックスを設定できます.

他にも jmh にはベンチマーク結果がおかしくならないようにJVM のウォームアップや最適化を防ぐためにさまざまなヘルパーが用意されています.

例えばblackhole を使うことで JVM の dead code elimination を回避することができます.

 @Benchmark
   def benchX(blackhole: Blackhole) = {
        // ...
        blackhole.consume(localVar);
    }

結果

大体説明できたと思うのでベンチマークを実行して結果を見ましょう.

sbt
> example/jmh:run -prof gc bench.ZippedBench

※ イテレーションの回数、warmup の回数などのパラメタは sbt タスクの引数 -i,-wi でも指定できます.
まずは Array の長さが 100 のときのケースです.

[info] Benchmark                                                  (length)   Mode  Cnt       Score      Error   Units
[info] ZippedBench.withFor                                             100  thrpt   25  10545.389 ± 713.504  ops/ms
[info] ZippedBench.withFor:·gc.alloc.rate                              100  thrpt   25   7814.800 ± 528.779  MB/sec
[info] ZippedBench.withFor:·gc.alloc.rate.norm                         100  thrpt   25    816.038 ±   0.001    B/op
[info] ZippedBench.withFor:·gc.churn.G1_Eden_Space                     100  thrpt   25   7839.669 ± 530.038  MB/sec
[info] ZippedBench.withFor:·gc.churn.G1_Eden_Space.norm                100  thrpt   25    818.640 ±   1.671    B/op
[info] ZippedBench.withFor:·gc.churn.G1_Survivor_Space                 100  thrpt   25      0.004 ±   0.001  MB/sec
[info] ZippedBench.withFor:·gc.churn.G1_Survivor_Space.norm            100  thrpt   2510⁻³              B/op
[info] ZippedBench.withFor:·gc.count                                   100  thrpt   25   4357.000            counts
[info] ZippedBench.withFor:·gc.time                                    100  thrpt   25  10390.000                ms
[info] ZippedBench.withTabulate                                        100  thrpt   25  10028.418 ± 494.065  ops/ms
[info] ZippedBench.withTabulate:·gc.alloc.rate                         100  thrpt   25   7431.567 ± 366.200  MB/sec
[info] ZippedBench.withTabulate:·gc.alloc.rate.norm                    100  thrpt   25    816.037 ±   0.001    B/op
[info] ZippedBench.withTabulate:·gc.churn.G1_Eden_Space                100  thrpt   25   7456.992 ± 361.188  MB/sec
[info] ZippedBench.withTabulate:·gc.churn.G1_Eden_Space.norm           100  thrpt   25    818.898 ±   1.781    B/op
[info] ZippedBench.withTabulate:·gc.churn.G1_Survivor_Space            100  thrpt   25      0.004 ±   0.001  MB/sec
[info] ZippedBench.withTabulate:·gc.churn.G1_Survivor_Space.norm       100  thrpt   2510⁻³              B/op
[info] ZippedBench.withTabulate:·gc.count                              100  thrpt   25   4080.000            counts
[info] ZippedBench.withTabulate:·gc.time                               100  thrpt   25  10232.000                ms
[info] ZippedBench.withWhile                                           100  thrpt   25  10166.134 ± 654.899  ops/ms
[info] ZippedBench.withWhile:·gc.alloc.rate                            100  thrpt   25   7533.468 ± 485.278  MB/sec
[info] ZippedBench.withWhile:·gc.alloc.rate.norm                       100  thrpt   25    816.036 ±   0.001    B/op
[info] ZippedBench.withWhile:·gc.churn.G1_Eden_Space                   100  thrpt   25   7555.945 ± 489.815  MB/sec
[info] ZippedBench.withWhile:·gc.churn.G1_Eden_Space.norm              100  thrpt   25    818.414 ±   1.797    B/op
[info] ZippedBench.withWhile:·gc.churn.G1_Survivor_Space               100  thrpt   25      0.004 ±   0.001  MB/sec
[info] ZippedBench.withWhile:·gc.churn.G1_Survivor_Space.norm          100  thrpt   2510⁻³              B/op
[info] ZippedBench.withWhile:·gc.count                                 100  thrpt   25   4008.000            counts
[info] ZippedBench.withWhile:·gc.time                                  100  thrpt   25  10043.000                ms
[info] ZippedBench.withZip                                             100  thrpt   25    621.984 ±  64.578  ops/ms
[info] ZippedBench.withZip:·gc.alloc.rate                              100  thrpt   25   4771.580 ± 492.907  MB/sec
[info] ZippedBench.withZip:·gc.alloc.rate.norm                         100  thrpt   25   8448.486 ±   7.740    B/op
[info] ZippedBench.withZip:·gc.churn.G1_Eden_Space                     100  thrpt   25   4779.091 ± 494.497  MB/sec
[info] ZippedBench.withZip:·gc.churn.G1_Eden_Space.norm                100  thrpt   25   8461.487 ±  21.996    B/op
[info] ZippedBench.withZip:·gc.churn.G1_Survivor_Space                 100  thrpt   25      0.026 ±   0.006  MB/sec
[info] ZippedBench.withZip:·gc.churn.G1_Survivor_Space.norm            100  thrpt   25      0.046 ±   0.010    B/op
[info] ZippedBench.withZip:·gc.count                                   100  thrpt   25   3268.000            counts
[info] ZippedBench.withZip:·gc.time                                    100  thrpt   25   7998.000                ms
[info] ZippedBench.withZipped                                          100  thrpt   25    999.613 ±  84.087  ops/ms
[info] ZippedBench.withZipped:·gc.alloc.rate                           100  thrpt   25   3428.084 ± 288.350  MB/sec
[info] ZippedBench.withZipped:·gc.alloc.rate.norm                      100  thrpt   25   3776.233 ±   0.003    B/op
[info] ZippedBench.withZipped:·gc.churn.G1_Eden_Space                  100  thrpt   25   3435.676 ± 290.985  MB/sec
[info] ZippedBench.withZipped:·gc.churn.G1_Eden_Space.norm             100  thrpt   25   3784.129 ±  12.943    B/op
[info] ZippedBench.withZipped:·gc.churn.G1_Survivor_Space              100  thrpt   25      0.010 ±   0.001  MB/sec
[info] ZippedBench.withZipped:·gc.churn.G1_Survivor_Space.norm         100  thrpt   25      0.011 ±   0.001    B/op
[info] ZippedBench.withZipped:·gc.count                                100  thrpt   25   2519.000            counts
[info] ZippedBench.withZipped:·gc.time                                 100  thrpt   25   6699.000                ms

さて zip とzipped の gc.churn.PS_Eden_Space.norm を比較すると zip の場合は短命のオブジェクトが for ,while や zipped の時より生成されていることがわかります.

// ..
[info] ZippedBench.withFor:·gc.alloc.rate.norm                         100  thrpt   25    816.038 ±   0.001    B/op
// ..
[info] ZippedBench.withWhile:·gc.alloc.rate.norm                       100  thrpt   25    816.036 ±   0.001    B/op
// ...
[info] ZippedBench.withZip:·gc.alloc.rate.norm                         100  thrpt   25   8448.486 ±   7.740    B/op
[info] ZippedBench.withZip:·gc.churn.G1_Eden_Space                     100  thrpt   25   4779.091 ± 494.497  MB/sec
// ...
[info] ZippedBench.withZipped:·gc.alloc.rate.norm                      100  thrpt   25   3776.233 ±   0.003    B/op
[info] ZippedBench.withZipped:·gc.churn.G1_Eden_Space                  100  thrpt   25   3435.676 ± 290.985  MB/sec

benchWhile がとても速いことにも注目してみましょう. これは while が関数オブジェクトとコレクション操作ではなくただのイテレーションを素直に実行するのでオーバーヘッドが少ないからです. また事前に配列を割り当てているからです. 文字通り桁違いに速いので zip と zipped の差はあまり気にならなくなってしまいますね(´・ω・`) パフォーマンスが重要な場面では while を使うべきです. ちなみに Scala の数値計算系ライブラリの spire にはパフォーマンスのために forwhile にコンパイル時に書き換えるマクロが用意されています.

import spire.syntax.fastFor._

// print numbers 1 through 10
fastFor(0)(_ < 10, _ + 1) { i =>
  println(i)
}

https://github.com/typelevel/spire

benchTabulate もシンタックスの簡潔さと比較して悪くない数字を出しています. これも Arrayの大きさが事前にわかっている場合は前もって割り当てしておけば Array のサイズを追加するオーバーヘッドを回避できるからです.

Scala のコレクションについて詳しくパフォーマンスをとった結果を紹介している lihaoyi さんのブログ記事からも、割り当て済み配列(Array-prealloc) のパフォーマンスがとてもいいことが見て取れます. この記事は Scala の公式ドキュメントのコレクションのパフォーマンスが説明しきれていない部分をしっかり説明してくれているので Scala でパフォーマンスを気にするなら一度読んでみるといいでしょう.

https://www.lihaoyi.com/post/BenchmarkingScalaCollections.html

次は配列の大きさを 10000 にして再度ベンチマークをとってみましょう.
要素数のオーダーによって同じプログラムでもパフォーマンスが変化することがあります. この変化がデータ量に応じてどのように変化するか注意する必要があります. データ構造やアルゴリズムにはデータ数が多い場合はパフォーマンスが良い一方でデータが少ない場合はあまりパフォーマンスがよくないケース、あるいはその逆のケースがあります. これは↑の lihaoyi さんのブログ記事のデータを見てみてみるとわかりやすいです.
実際のアプリケーションではどれくらいの大きさのデータを扱うのか見積もれるとよりよいパフォーマンスチューニングができるはずです.

[info] Benchmark                                                  (length)   Mode  Cnt       Score      Error   Units
[info] ZippedBench.withFor                                           10000  thrpt   25     107.929 ±    5.136  ops/ms
[info] ZippedBench.withFor:·gc.alloc.rate                            10000  thrpt   25    7842.329 ±  373.072  MB/sec
[info] ZippedBench.withFor:·gc.alloc.rate.norm                       10000  thrpt   25   80020.112 ±    0.109    B/op
[info] ZippedBench.withFor:·gc.churn.G1_Eden_Space                   10000  thrpt   25    7896.289 ±  376.114  MB/sec
[info] ZippedBench.withFor:·gc.churn.G1_Eden_Space.norm              10000  thrpt   25   80570.446 ±  167.376    B/op
[info] ZippedBench.withFor:·gc.churn.G1_Survivor_Space               10000  thrpt   25       0.019 ±    0.001  MB/sec
[info] ZippedBench.withFor:·gc.churn.G1_Survivor_Space.norm          10000  thrpt   25       0.192 ±    0.010    B/op
[info] ZippedBench.withFor:·gc.count                                 10000  thrpt   25    4230.000             counts
[info] ZippedBench.withFor:·gc.time                                  10000  thrpt   25   10299.000                 ms
[info] ZippedBench.withTabulate                                      10000  thrpt   25     107.724 ±    9.385  ops/ms
[info] ZippedBench.withTabulate:·gc.alloc.rate                       10000  thrpt   25    7827.217 ±  681.723  MB/sec
[info] ZippedBench.withTabulate:·gc.alloc.rate.norm                  10000  thrpt   25   80020.331 ±    0.165    B/op
[info] ZippedBench.withTabulate:·gc.churn.G1_Eden_Space              10000  thrpt   25    7878.829 ±  687.149  MB/sec
[info] ZippedBench.withTabulate:·gc.churn.G1_Eden_Space.norm         10000  thrpt   25   80547.058 ±  125.516    B/op
[info] ZippedBench.withTabulate:·gc.churn.G1_Survivor_Space          10000  thrpt   25       0.019 ±    0.001  MB/sec
[info] ZippedBench.withTabulate:·gc.churn.G1_Survivor_Space.norm     10000  thrpt   25       0.195 ±    0.017    B/op
[info] ZippedBench.withTabulate:·gc.count                            10000  thrpt   25    4465.000             counts
[info] ZippedBench.withTabulate:·gc.time                             10000  thrpt   25   10845.000                 ms
[info] ZippedBench.withWhile                                         10000  thrpt   25     106.784 ±   12.534  ops/ms
[info] ZippedBench.withWhile:·gc.alloc.rate                          10000  thrpt   25    7759.108 ±  910.523  MB/sec
[info] ZippedBench.withWhile:·gc.alloc.rate.norm                     10000  thrpt   25   80020.140 ±    0.200    B/op
[info] ZippedBench.withWhile:·gc.churn.G1_Eden_Space                 10000  thrpt   25    7814.662 ±  919.085  MB/sec
[info] ZippedBench.withWhile:·gc.churn.G1_Eden_Space.norm            10000  thrpt   25   80589.199 ±  152.628    B/op
[info] ZippedBench.withWhile:·gc.churn.G1_Survivor_Space             10000  thrpt   25       0.019 ±    0.001  MB/sec
[info] ZippedBench.withWhile:·gc.churn.G1_Survivor_Space.norm        10000  thrpt   25       0.197 ±    0.025    B/op
[info] ZippedBench.withWhile:·gc.count                               10000  thrpt   25    4199.000             counts
[info] ZippedBench.withWhile:·gc.time                                10000  thrpt   25   10431.000                 ms
[info] ZippedBench.withZip                                           10000  thrpt   25       5.421 ±    0.469  ops/ms
[info] ZippedBench.withZip:·gc.alloc.rate                            10000  thrpt   25    4135.340 ±  357.686  MB/sec
[info] ZippedBench.withZip:·gc.alloc.rate.norm                       10000  thrpt   25  840175.666 ±    3.198    B/op
[info] ZippedBench.withZip:·gc.churn.G1_Eden_Space                   10000  thrpt   25    4144.427 ±  357.885  MB/sec
[info] ZippedBench.withZip:·gc.churn.G1_Eden_Space.norm              10000  thrpt   25  842060.986 ± 2273.471    B/op
[info] ZippedBench.withZip:·gc.churn.G1_Survivor_Space               10000  thrpt   25       1.729 ±    0.626  MB/sec
[info] ZippedBench.withZip:·gc.churn.G1_Survivor_Space.norm          10000  thrpt   25     361.943 ±  129.956    B/op
[info] ZippedBench.withZip:·gc.count                                 10000  thrpt   25    3176.000             counts
[info] ZippedBench.withZip:·gc.time                                  10000  thrpt   25    7682.000                 ms
[info] ZippedBench.withZipped                                        10000  thrpt   25      13.356 ±    0.793  ops/ms
[info] ZippedBench.withZipped:·gc.alloc.rate                         10000  thrpt   25    4367.561 ±  259.319  MB/sec
[info] ZippedBench.withZipped:·gc.alloc.rate.norm                    10000  thrpt   25  360163.802 ±    0.938    B/op
[info] ZippedBench.withZipped:·gc.churn.G1_Eden_Space                10000  thrpt   25    4374.518 ±  259.326  MB/sec
[info] ZippedBench.withZipped:·gc.churn.G1_Eden_Space.norm           10000  thrpt   25  360744.065 ±  884.520    B/op
[info] ZippedBench.withZipped:·gc.churn.G1_Survivor_Space            10000  thrpt   25       0.524 ±    0.198  MB/sec
[info] ZippedBench.withZipped:·gc.churn.G1_Survivor_Space.norm       10000  thrpt   25      44.326 ±   17.612    B/op
[info] ZippedBench.withZipped:·gc.count                              10000  thrpt   25    3427.000             counts
[info] ZippedBench.withZipped:·gc.time                               10000  thrpt   25    7921.000                 ms
[info] ZippedBench.withZip                                           10000  thrpt   25       5.421 ±    0.469  ops/ms
// ..
[info] ZippedBench.withZip:·gc.alloc.rate.norm                       10000  thrpt   25  840175.666 ±    3.198    B/op
// ..
[info] ZippedBench.withZipped                                        10000  thrpt   25      13.356 ±    0.793  ops/ms
// ..
[info] ZippedBench.withZipped:·gc.alloc.rate.norm                    10000  thrpt   25  360163.802 ±    0.938    B/op
// ..

GC に関係するパラメータは長さに比例して変化していますが、スループットはデータ量が増えると zip のほうがだいぶ悪くなっていますね🤔 データ量が多いときは zip と比べたら zipped のほうがマシですね. とはいえ 相変わらず for, while, tabulate のほうが桁違いに速いです. パフォーマンスは大体想定通りですが、for, tabulate, while の順に誤差が大きくなっているのも気になりますね🤔

さて、ベンチマークはあくまではかろうとしたものしかはかれないことに注意しなければなりません. 何を計測するか,その結果をどう解釈すべきかは計測者にゆだねられています.

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

sbt-jmh のレポジトリの About には Trust no one, bench everything. とありますが、benchmark の結果の数字もちゃんと眉に唾を付けて判断しましょう.

Further Reading

jmh の正しい使い方については jmh の github にあるサンプルが最も網羅的で参考になるはずなのでベンチマークを書く前に一通り目を通しておくといいでしょう.

https://github.com/openjdk/jmh/tree/master/jmh-samples/src/main/java/org/openjdk/jmh/samples

また、途中で少し説明した JVM の最適化によるベンチマークの落とし穴については以下の記事が最適化アリ/ナシの結果の数字も載せているので参考になります.

https://www.baeldung.com/java-microbenchmark-harness

実際にプロダクションで使われている Scala ライブラリのベンチマークについては、 cats-effect3 と zio のベンチマークの記事がおすすめです.

https://gist.github.com/djspiewak/f4cfc08e0827088f17032e0e9099d292

jmh に plot 機能はないのでサードパーティの plot ツールを使いましょう.
https://github.com/jzillmann/jmh-visualizer

余談

Java のアノテーションは言語機能の貧しさをその場しのぎで埋め合わせるための,お世辞にもセンスがいい使い方とは言えないものがしばしばあって頭が痛くなります.(golang のように言語機能の貧しさをごまかさず素直に書くスタイルのほうが健全だと思います. あるいは Scala・Kotlin など、必要な機能がもともと用意されているものを使うべきだと思います.)
(というかコンパイル時にわかる情報をランタイムリフレクションで使うな(#^ω^). IDE に依存するコード生成をすな(#^ω^). ちゃんと保守して更新して Java のバージョンを上げろ(#^ω^))

しかし、その点についてはjmh は比較的筋のいいアノテーション(とランタイムマクロ)の使い方だと私は思います. というのは、ベンチマークは書かれたコードの扱うドメインとは異なる軸でソースコードを評価するためのものである、と考えるからです. ベンチマークはランタイムの情報が得られること、ベンチマーク対象のソースコードをなるべく改変しないこと(仮にベンチマークのために特殊なコードをいくつも余分に書かなければならないならそのベンチマークから得られるデータは現実的なユースケースに合致するだろうか)、柔軟にパラメータを変更できること、必要に応じてつけたり外したりできることが期待されます. jmh のアノテーションはこれらのユースケースに合致していると思います.

Discussion