Microbenchmarking dengan Java

1. Perkenalan

Artikel singkat ini difokuskan pada JMH (Java Microbenchmark Harness). Pertama, kita terbiasa dengan API dan mempelajari dasar-dasarnya. Kemudian kita akan melihat beberapa praktik terbaik yang harus kita pertimbangkan saat menulis microbenchmark.

Sederhananya, JMH menangani hal-hal seperti pemanasan JVM dan jalur pengoptimalan kode, membuat pembandingan sesederhana mungkin.

2. Memulai

Untuk memulai, kita sebenarnya dapat terus bekerja dengan Java 8 dan cukup mendefinisikan dependensi:

 org.openjdk.jmh jmh-core 1.19   org.openjdk.jmh jmh-generator-annprocess 1.19 

Versi terbaru dari JMH Core dan JMH Annotation Processor dapat ditemukan di Maven Central.

Selanjutnya, buat tolok ukur sederhana dengan memanfaatkan anotasi @Benchmark (di kelas publik mana pun):

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

Kemudian kami menambahkan kelas utama yang memulai proses pembandingan:

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

Sekarang menjalankan BenchmarkRunner akan mengeksekusi benchmark kita yang bisa dibilang agak tidak berguna. Setelah proses selesai, tabel ringkasan disajikan:

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

3. Jenis Tolok Ukur

JMH mendukung beberapa kemungkinan tolok ukur: Throughput, AverageTime, SampleTime , dan SingleShotTime . Ini dapat dikonfigurasi melalui anotasi @BenchmarkMode :

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

Tabel yang dihasilkan akan memiliki metrik waktu rata-rata (bukan throughput):

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

4. Konfigurasi Pemanasan dan Eksekusi

Dengan menggunakan anotasi @Fork , kita dapat mengatur bagaimana eksekusi benchmark terjadi: parameter nilai mengontrol berapa kali benchmark akan dieksekusi, dan parameter pemanasan mengontrol berapa kali benchmark akan kering sebelum hasil dikumpulkan, misalnya :

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

Ini menginstruksikan JMH untuk menjalankan dua garpu pemanasan dan membuang hasil sebelum beralih ke pembandingan waktu nyata.

Selain itu, anotasi @Warmup dapat digunakan untuk mengontrol jumlah iterasi pemanasan. Misalnya, @Warmup (iterations = 5) memberi tahu JMH bahwa lima iterasi pemanasan sudah cukup, bukan default 20.

5. Status

Sekarang mari kita periksa bagaimana tugas yang lebih tidak sepele dan lebih indikatif dari pembandingan algoritma hashing dapat dilakukan dengan memanfaatkan State . Misalkan kita memutuskan untuk menambahkan perlindungan ekstra dari serangan kamus pada database kata sandi dengan melakukan hashing kata sandi beberapa ratus kali.

Kita dapat menjelajahi dampak kinerja dengan menggunakan objek 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(); } }

Metode benchmark kami akan terlihat seperti:

@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(); }

Di sini, iterasi kolom akan diisi dengan nilai yang sesuai dari anotasi @Param oleh JMH saat diteruskan ke metode benchmark. The @Setup Metode dijelaskan dipanggil sebelum setiap permintaan dari benchmark dan menciptakan baru Hasher memastikan isolasi.

Saat eksekusi selesai, kita akan mendapatkan hasil yang mirip dengan di bawah ini:

# 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. Penghapusan Kode Mati

Saat menjalankan microbenchmark, sangat penting untuk menyadari pengoptimalan . Jika tidak, hal itu dapat memengaruhi hasil benchmark dengan cara yang sangat menyesatkan.

Untuk membuat masalah menjadi lebih konkret, mari pertimbangkan sebuah contoh:

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

Kami mengharapkan biaya alokasi objek lebih banyak daripada tidak melakukan apa pun. Namun, jika kita menjalankan benchmark:

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

Ternyata menemukan tempat di TLAB, membuat dan menginisialisasi objek hampir gratis! Hanya dengan melihat angka-angka ini, kita harus tahu bahwa ada sesuatu yang kurang tepat di sini.

Di sini, kami adalah korban penghapusan kode mati . Compiler sangat pandai dalam mengoptimalkan kode yang redundan. Faktanya, itulah yang dilakukan oleh compiler JIT di sini.

Untuk mencegah pengoptimalan ini, kita harus mengelabui kompiler dan membuatnya berpikir bahwa kode tersebut digunakan oleh beberapa komponen lain. Salah satu cara untuk mencapai ini adalah dengan mengembalikan objek yang dibuat:

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

Juga, kita dapat membiarkan Blackhole mengkonsumsinya:

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

Memiliki Blackhole mengkonsumsi objek adalah cara untuk meyakinkan compiler JIT untuk tidak menerapkan optimasi kode eliminasi mati . Bagaimanapun, jika kita menjalankan tolok ukur ini lagi, angkanya akan lebih masuk akal:

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. Lipat Konstan

Mari pertimbangkan contoh lain:

@Benchmark public double foldedLog() { int x = 8; return Math.log(x); }

Penghitungan berdasarkan konstanta dapat mengembalikan keluaran yang sama persis, berapa pun jumlah eksekusinya. Oleh karena itu, ada kemungkinan yang cukup bagus bahwa compiler JIT akan mengganti pemanggilan fungsi logaritma dengan hasilnya:

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

Bentuk evaluasi parsial ini disebut pelipatan konstan . Dalam kasus ini, pelipatan konstan sepenuhnya menghindari panggilan Math.log , yang merupakan inti dari tolok ukur.

Untuk mencegah pelipatan konstan, kita dapat merangkum keadaan konstan di dalam objek keadaan:

@State(Scope.Benchmark) public static class Log { public int x = 8; } @Benchmark public double log(Log input) { return Math.log(input.x); }

Jika kami menjalankan tolok ukur ini satu sama lain:

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

Rupanya, tolok ukur log melakukan beberapa pekerjaan serius dibandingkan dengan foldedLog , yang masuk akal.

8. Kesimpulan

Tutorial ini berfokus pada dan memamerkan harness benchmarking mikro Java.

Seperti biasa, contoh kode dapat ditemukan di GitHub.