Javaによるマイクロベンチマーク
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 パラメーターは、ベンチマークの実行回数を制御し、
@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コンパイラがここで行ったことです。
@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);
}
@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のにあります。