Scala
sbt
jmh

JMHを使ってScalaのパフォーマンスを計測する

プログラムを実装する上で何通りもの書き方が存在しますが、Scalaでパフォーマンスを見たいときは、JMHを使って計測する方法が挙げられます。

マイクロベンチツールJMH

JMHの名称は、Java Microbenchmark Harnessです。
マイクロベンチツールという名前だけに、比較的短い処理に要する時間を測るツールです。

JMHではさまざまなモードでベンチマークを実行できます

Benchmark mode 説明
Throughput 単位時間当たりの操作数を測定。関数を実行できる回数を秒単位で表す。
AverageTime 1操作あたりの平均時間
SampleTime 関数の実行に要する時間(最大、最小時間など)を測定
SingleShotTime 単一の関数の実行にかかる時間を測定。コールドスタート(JVMのウォームアップなし)での動作をテストするのに適している。
All すべてのベンチマークモードで計測

※デフォルトでは、Throughputモードで計測されます。

JMHの使い方

sbt-jmhプラグインを使用することでsbtプロジェクトでJMHテストを実行することができます。

環境

  • scala version = 2.12.4
  • sbt version = 0.13.16

ドキュメントに書いてあるようにplugins.sbtbuild.sbtに以下のように追記します。

  • project/plugins.sbt
addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.3.2")
  • build.sbt
enablePlugins(JmhPlugin)

準備はここまでで、あとはベンチマークしたい関数に@Benchmarkのアノテーションをつけるだけです。

import org.openjdk.jmh.annotations.Benchmark
...
 @Benchmark
 def hoge() = {
//処理
}

Benchmark modeを指定したい場合は、以下のようにモードを指定します。

import org.openjdk.jmh.annotations._
...
 @Benchmark @BenchmarkMode(scala.Array(Mode.AverageTime))
 def hoge() = {
//処理
}

アノテーションを書いたあとは、
プロジェクトのベースディレクトリでsbt シェルを起動させます。

$ sbt 

コンパイルする。

> jmh:compile

実行することでパフォーマンスを出すことができます。

> jmh:run -i 3 -wi 3 -f1 -t1
オプション 説明
i イテレーション (計測回数)
wi ウォームアップ イテレーション (ウォーミングアップ回数)
f フォーク
t スレッド

ドキュメントより、オプションの値は以下をオススメされています。

  • イテレーション(i)は、少なくとも10回以上
  • ウォームアップ イテレーション(wi)は少なくとも10〜20回以上行い、それから10〜20回のイテテレーションを行うとより現実的な結果がでる。

実際にサンプルコードで試している

map,forを使った関数の比較と、flatMap、forを使った関数で比較します。

package sample

import org.openjdk.jmh.annotations._

class JMHSample {

  @Benchmark
  def measureMapMeth(): Seq[Int] = {
    mapMeth(10000)
  }

  @Benchmark
  def measureForMeth(): Seq[Int] = {
    forMeth(10000)
  }

  def mapMeth(x: Int): Seq[Int] = {
    1.to(x).map(x => x.*(2))
  }

  def forMeth(x: Int): Seq[Int] = {
    for {
      a <- 1 to x
    } yield {
      a.*(2)
    }
  }

  @Benchmark
  def measureFlatMapMeth(): Seq[Int] = {
    flatMapMeth(10000)
  }

  @Benchmark
  def measureNestForMeth(): Seq[Int] = {
    nestForMeth(10000)
  }

  def flatMapMeth(x: Int): Seq[Int] = {
    1.to(x).flatMap {
      a => (1 to a).map(b => a + b)
    }
  }


  def nestForMeth(x: Int): Seq[Int] = {
    for {
      a <- 1 to x
      b <- 1 to a
    } yield {
      a + b
    }
  }
}

実行結果

> jmh:run -i 3 -wi 3 -f1 -t 1
・・・
[info] Benchmark                      Mode  Cnt      Score       Error  Units
[info] JMHSample.measureForMeth      thrpt    3   9120.018 ± 15931.762  ops/s
[info] JMHSample.measureMapMeth      thrpt    3  10228.366 ±  3842.323  ops/s
[info] JMHSample.measureNestForMeth  thrpt    3      0.135 ±     0.930  ops/s
[info] JMHSample.measureFlatMapMeth  thrpt    3      0.163 ±     1.283  ops/s
[success] Total time: 140 s

ウォームアップ、イテレーションの回数を増やしもう一度行って見ます。

> jmh:run -i 20 -wi 20 -f1 -t 1
・・・
[info] Benchmark                      Mode  Cnt      Score      Error  Units
[info] JMHSample.measureForMeth      thrpt   20  10260.024 ± 1172.508  ops/s
[info] JMHSample.measureMapMeth      thrpt   20  10383.953 ±  843.856  ops/s
[info] JMHSample.measureNestForMeth  thrpt   20      0.175 ±    0.023  ops/s
[info] JMHSample.measureFlatMapMeth  thrpt   20      0.191 ±    0.022  ops/s
[success] Total time: 567 s

Errorが減ってScoreも上がっていますが、ほとんど差がないですね。
数値だけ見ると1秒間あたりの処理回数は、forを使っている関するよりもmapを使っている関数の方が高いですが、
何度か同じ条件で実行すると、Errorが多かった方のScoreが下がっていましたので
一概にどっちがいいのかは言えなさそうです。

終わりに

JMHを使って簡単に1秒間あたりの処理回数が調べられて大変便利だなと感じました。
Socoreに関しては明らかな差がある場合は有効かと思いましたが、今回のようにあまり差がない場合は、何度か繰り返すと結果が変わるので、同じ性能くらいの認識で良いのかなと思いまます。

今回は簡単なコードだったのですが、実際にもっと処理が多い関数などで調査したい場合には良いのではないかと思います。

参考記事