Scala 3 + JMH を最短でセットアップする ~ sbt-jmh の最小構成 ~
この記事は Scala Advent Calendar 2025 の 7 日目です。
n 番煎じな記事ですが、せっかく空いているならと思い滑り込みました。
はじめに
Scala というか JVM 関連の言語で定番なのかなと思っているマイクロベンチマークツール JMH を試したときの備忘録です。
sbt-jmh を使うと sbt からベンチマークを測るコマンドが使えるので便利でした。
個人的には元々 Scala Meter というベンチマークツールを使っていたのですが、Scala 3 対応がまだ追いついていない印象があり、代替として別のツールを調べていたところ、JMH にたどり着き、sbt-jmh がシンプルで良かったため今回触ってみました。
今回利用したコードは以下の Repository に置いてあります。
1. sbt-jmh の準備
project/plugins.sbt にプラグインを追加します。
// project/plugins.sbt
addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.8")
build.sbt は以下のようにしました。enablePlugins(JmhPlugin) を付けるだけで JMH 用の設定が入ります。
// build.sbt
ThisBuild / scalaVersion := "3.3.7"
lazy val root = (project in file("."))
.enablePlugins(JmhPlugin)
.settings(
name := "scala-jmh-sample",
organization := "com.example",
Test / fork := true,
// ベンチマーク用のソースを src/bench/scala に置く
Jmh / unmanagedSourceDirectories += baseDirectory.value / "src" / "bench" / "scala"
)
Test / fork := true は、テスト(および test タスクにぶら下がるプラグイン動作)を sbt 本体とは別 JVM で実行する設定です。
テスト実行時のクラスパス汚染や JVM オプションの影響が sbt セッションに漏れないので、特にベンチマークのように GC 設定やヒープサイズを変えたいときに便利という理解でいます。
なお、JMH はベンチマーク実行時に独自に fork(別 JVM 起動)を行うためのものです。したがって、Test / fork := true はなくても問題ありません。
今回はテストタスクと sbt 本体の JVM を分離する目的で付けています。
今回はプロダクションコードと完全に分離するため、src/bench/scala に JMH 専用コードを配置する構成にしました。
sbt-jmh は src/jmh のようなデフォルトディレクトリを持たないため、unmanagedSourceDirectories で明示的に追加しています。
2. 実装サンプルコードとベンチマーク計測用の記述を書く
プロダクション側はベンチマーク非依存にして、JMH のアノテーションはベンチマーク用クラスだけに閉じ込めます。
以下の 3 パターンを用意しました。
- フィボナッチを素直に再帰で計算する
SimpleFibonacci(implementation1) - 末尾再帰でスタックを使わない
TailrecFibonacci(implementation2) - キャッシュで計算済みを再利用する
CachedFibonacci(implementation3)
// src/main/scala/example/Fibonacci.scala
package example
import scala.collection.mutable
import scala.annotation.tailrec
object SimpleFibonacci {
def calculate(n: Int): Long = {
if (n <= 1) n else calculate(n - 1) + calculate(n - 2)
}
}
object TailrecFibonacci {
def calculate(n: Int): Long = {
@tailrec
def loop(remaining: Int, a: Long, b: Long): Long = {
if (remaining == 0) a else loop(remaining - 1, b, a + b)
}
loop(n, 0L, 1L)
}
}
final class CachedFibonacci(cache: mutable.Map[Int, Long] = mutable.Map(0 -> 0L, 1 -> 1L)) {
def calculate(n: Int): Long = {
cache.getOrElseUpdate(n, calculate(n - 1) + calculate(n - 2))
}
}
JMH 側のクラスでだけアノテーションを付け、プロダクションコードには一切 JMH 依存を入れません。@Param で入力サイズを変え、@Setup(Level.Iteration) で毎イテレーションごとに新しいキャッシュを作っています。
なお、JMH では計測対象の結果を Blackhole.consume に渡す必要があります。
そうしないと、JIT によって「結果が使われていない = 不要」と判断され、コードが削除されてしまいます。
その場合、意図しない高速化が起きるため、ベンチマークとして正しい値が出ません。
// src/bench/scala/example/FibonacciBenchmark.scala
package example
import java.util.concurrent.TimeUnit
import org.openjdk.jmh.annotations.*
import org.openjdk.jmh.infra.Blackhole
@State(Scope.Benchmark)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Array(Mode.AverageTime))
class FibonacciBenchmark {
@Param(Array("20", "30", "35"))
var n: Int = _
private var cached: CachedFibonacci = _
@Setup(Level.Iteration)
def setup(): Unit = {
cached = new CachedFibonacci()
}
@Benchmark
def implementation1(blackhole: Blackhole): Unit = {
blackhole.consume(SimpleFibonacci.calculate(n))
}
@Benchmark
def implementation2(blackhole: Blackhole): Unit = {
blackhole.consume(TailrecFibonacci.calculate(n))
}
@Benchmark
def implementation3(blackhole: Blackhole): Unit = {
blackhole.consume(cached.calculate(n))
}
}
3. 実行してみる
Jmh/run でベンチマークを実行できます。フィルタを付けると対象を絞れます。
sbt "Jmh/run -i 3 -wi 1 -f 1 -t 1 .*FibonacciBenchmark.*"
-
-i 3: 本計測のイテレーション数 -
-wi 1: ウォームアップ回数 -
-f 1: fork の回数 -
-t 1: スレッド数
ベンチマーク名やメソッドをリネームしたのに前のコードが走る場合、JMH の生成コードやコンパイル成果物が target/ に残っている可能性があります。
なので、sbt Jmh/clean → sbt Jmh/compileで再生成すると反映されます。全スコープを掃除したいときは sbt clean でも問題ありません。
手元ではこんな結果になりました。
[info] Benchmark (n) Mode Cnt Score Error Units
[info] FibonacciBenchmark.implementation1 20 avgt 3 10940.873 ± 771.730 ns/op
[info] FibonacciBenchmark.implementation1 30 avgt 3 1291339.533 ± 138631.600 ns/op
[info] FibonacciBenchmark.implementation1 35 avgt 3 14861651.639 ± 6639040.520 ns/op
[info] FibonacciBenchmark.implementation2 20 avgt 3 2.204 ± 2.782 ns/op
[info] FibonacciBenchmark.implementation2 30 avgt 3 3.100 ± 1.506 ns/op
[info] FibonacciBenchmark.implementation2 35 avgt 3 2.763 ± 0.728 ns/op
[info] FibonacciBenchmark.implementation3 20 avgt 3 1.922 ± 0.283 ns/op
[info] FibonacciBenchmark.implementation3 30 avgt 3 2.009 ± 0.632 ns/op
[info] FibonacciBenchmark.implementation3 35 avgt 3 2.012 ± 0.505 ns/op
-
implementation1(素朴再帰)は指数的に時間が伸びて厳しく -
implementation2(末尾再帰)は線形で数ナノ秒程度に収まり -
implementation3(キャッシュあり)はほぼ同水準かわずかに速い
という差が見えます(今回は各イテレーションでキャッシュを作り直しているため、キャッシュの恩恵は小さめ)。
4. まとめ
- sbt-jmh は導入が軽く、Scala 3 でも問題なく動作する
- ベンチマーク専用ディレクトリを分ければプロダクションコードにも影響しない
- Scala Meter が使いにくい場合の選択肢として十分実用的
Discussion