1. 序章

このクイック記事は、JMH(Java Microbenchmark Harness)に焦点を当てています。 まず、APIに慣れ、その基本を学びます。 次に、マイクロベンチマークを作成するときに考慮すべきいくつかのベストプラクティスを確認します。

簡単に言えば、JMHはJVMのウォームアップやコード最適化パスなどを処理し、ベンチマークを可能な限り単純にします。

2. 入門

開始するには、実際にJava 8で作業を続け、依存関係を定義するだけです。

<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>1.33</version>
</dependency>
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>1.33</version>
</dependency>

JMHCoreおよびJMHAnnotation Processor の最新バージョンは、MavenCentralにあります。

次に、 @Benchmark アノテーション(任意のパブリッククラス)を利用して、簡単なベンチマークを作成します。

@Benchmark
public void init() {
    // Do nothing
}

次に、ベンチマークプロセスを開始するメインクラスを追加します。

public class BenchmarkRunner {
    public static void main(String[] args) throws Exception {
        org.openjdk.jmh.Main.main(args);
    }
}

BenchmarkRunner を実行すると、ほぼ間違いなく役に立たないベンチマークが実行されます。 実行が完了すると、要約テーブルが表示されます。

# Run complete. Total time: 00:06:45
Benchmark      Mode  Cnt Score            Error        Units
BenchMark.init thrpt 200 3099210741.962 ± 17510507.589 ops/s

3. ベンチマークの種類

JMHは、スループット、 AverageTime、 SampleTime 、およびSingleShotTimeのいくつかの可能なベンチマークをサポートしています。 これらは、@BenchmarkModeアノテーションを介して構成できます。

@Benchmark
@BenchmarkMode(Mode.AverageTime)
public void init() {
    // Do nothing
}

結果のテーブルには、(スループットではなく)平均時間メトリックが含まれます。

# Run complete. Total time: 00:00:40
Benchmark Mode Cnt  Score Error Units
BenchMark.init avgt 20 ≈ 10⁻⁹ s/op

4. ウォーミングアップと実行の構成

@Fork アノテーションを使用することで、ベンチマークの実行方法を設定できます。 value パラメーターは、ベンチマークの実行回数を制御し、 Warmup [X194X ]パラメータは、結果が収集される前にベンチマークがドライランする回数を制御します。次に例を示します。

@Benchmark
@Fork(value = 1, warmups = 2)
@BenchmarkMode(Mode.Throughput)
public void init() {
    // Do nothing
}

これにより、JMHは2つのウォームアップフォークを実行し、リアルタイムのベンチマークに移行する前に結果を破棄するように指示されます。

また、 @Warmup アノテーションを使用して、ウォームアップの反復回数を制御できます。 たとえば、 @Warmup(iterations = 5)は、デフォルトの20ではなく、5回のウォームアップ反復で十分であることをJMHに通知します。

5. 州

ここで、 State を利用して、ハッシュアルゴリズムのベンチマークを行うという、ささいなことではなく、よりわかりやすいタスクを実行する方法を調べてみましょう。 パスワードを数百回ハッシュすることにより、パスワードデータベースに対する辞書攻撃からの保護を強化することにしたとします。

State オブジェクトを使用して、パフォーマンスへの影響を調べることができます。

@State(Scope.Benchmark)
public class ExecutionPlan {

    @Param({ "100", "200", "300", "500", "1000" })
    public int iterations;

    public Hasher murmur3;

    public String password = "4v3rys3kur3p455w0rd";

    @Setup(Level.Invocation)
    public void setUp() {
        murmur3 = Hashing.murmur3_128().newHasher();
    }
}

ベンチマークメソッドは次のようになります。

@Fork(value = 1, warmups = 1)
@Benchmark
@BenchmarkMode(Mode.Throughput)
public void benchMurmur3_128(ExecutionPlan plan) {

    for (int i = plan.iterations; i > 0; i--) {
        plan.murmur3.putString(plan.password, Charset.defaultCharset());
    }

    plan.murmur3.hash();
}

ここで、フィールド iterations には、ベンチマークメソッドに渡されるときにJMHによって@Paramアノテーションから適切な値が入力されます。 @Setup 注釈付きメソッドは、ベンチマークを呼び出すたびに呼び出され、新しいHasherを作成して分離を保証します。

実行が終了すると、次のような結果が得られます。

# Run complete. Total time: 00:06:47

Benchmark                   (iterations)   Mode  Cnt      Score      Error  Units
BenchMark.benchMurmur3_128           100  thrpt   20  92463.622 ± 1672.227  ops/s
BenchMark.benchMurmur3_128           200  thrpt   20  39737.532 ± 5294.200  ops/s
BenchMark.benchMurmur3_128           300  thrpt   20  30381.144 ±  614.500  ops/s
BenchMark.benchMurmur3_128           500  thrpt   20  18315.211 ±  222.534  ops/s
BenchMark.benchMurmur3_128          1000  thrpt   20   8960.008 ±  658.524  ops/s

6. デッドコードの除去

マイクロベンチマークを実行するときは、最適化に注意することが非常に重要です。 そうしないと、非常に誤解を招くような方法でベンチマーク結果に影響を与える可能性があります。

問題をもう少し具体的にするために、例を考えてみましょう。

@Benchmark
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
public void doNothing() {
}

@Benchmark
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
public void objectCreation() {
    new Object();
}

オブジェクトの割り当てには、何もしないよりもコストがかかると予想されます。 ただし、ベンチマークを実行すると、次のようになります。

Benchmark                 Mode  Cnt  Score   Error  Units
BenchMark.doNothing       avgt   40  0.609 ± 0.006  ns/op
BenchMark.objectCreation  avgt   40  0.613 ± 0.007  ns/op

どうやらTLABで場所を見つけて、オブジェクトの作成と初期化はほとんど無料です! これらの数値を見るだけで、ここで何かが完全に足し合わないことを知る必要があります。

ここで、私たちはデッドコード除去の犠牲者です。 コンパイラは、冗長なコードを最適化するのに非常に優れています。 実際のところ、これはまさにJITコンパイラがここで行ったことです。

この最適化を防ぐには、コンパイラをだまして、コードが他のコンポーネントによって使用されていると思わせる必要があります。 これを実現する1つの方法は、作成されたオブジェクトを返すことです。

@Benchmark
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
public Object pillarsOfCreation() {
    return new Object();
}

また、Blackholeにそれを消費させることができます。

@Benchmark
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
public void blackHole(Blackhole blackhole) {
    blackhole.consume(new Object());
}

Blackholeにオブジェクトを消費させることは、デッドコード除去の最適化を適用しないようにJITコンパイラを説得する方法です。 とにかく、これらのベンチマークを再度実行すると、数値はより意味のあるものになります。

Benchmark                    Mode  Cnt  Score   Error  Units
BenchMark.blackHole          avgt   20  4.126 ± 0.173  ns/op
BenchMark.doNothing          avgt   20  0.639 ± 0.012  ns/op
BenchMark.objectCreation     avgt   20  0.635 ± 0.011  ns/op
BenchMark.pillarsOfCreation  avgt   20  4.061 ± 0.037  ns/op

7. 定数畳み込み

さらに別の例を考えてみましょう。

@Benchmark
public double foldedLog() {
    int x = 8;

    return Math.log(x);
}

定数に基づく計算では、実行回数に関係なく、まったく同じ出力が返される場合があります。 したがって、JITコンパイラが対数関数呼び出しをその結果に置き換える可能性はかなりあります。

@Benchmark
public double foldedLog() {
    return 2.0794415416798357;
}

この形式の部分評価は定数畳み込みと呼ばれます。 この場合、定数畳み込みは、ベンチマークの要点であるMath.log呼び出しを完全に回避します。

定数畳み込みを防ぐために、状態オブジェクト内に定数状態をカプセル化できます。

@State(Scope.Benchmark)
public static class Log {
    public int x = 8;
}

@Benchmark
public double log(Log input) {
     return Math.log(input.x);
}

これらのベンチマークを相互に実行すると、次のようになります。

Benchmark             Mode  Cnt          Score          Error  Units
BenchMark.foldedLog  thrpt   20  449313097.433 ± 11850214.900  ops/s
BenchMark.log        thrpt   20   35317997.064 ±   604370.461  ops/s

どうやら、 log ベンチマークは、賢明なfoldedLog と比較して、いくつかの深刻な作業を行っています。

8. 結論

このチュートリアルでは、Javaのマイクロベンチマークハーネスに焦点を当てて紹介しました。

いつものように、コード例はGitHubにあります。