JIT dan Model Performa

Menelusuri jalur kode dari interpreted ke C1 dan C2, lalu belajar berhati-hati terhadap micro-benchmark.

Daftar isi

Bytecode Java tidak langsung menjadi kode mesin yang optimal pada baris pertama. JVM memulai eksekusi sebagai interpreter, memprofilkan bagian yang sering dipanggil, lalu menyerahkannya ke kompiler just-in-time (JIT) agar dikompilasi menjadi kode mesin yang makin lama makin cepat. Topik ini menelusuri jalur tersebut dari interpreter ke C1 dan C2, lalu menutupnya dengan pelajaran yang sama pentingnya, yaitu mengapa micro-benchmark yang ditulis seadanya lebih sering menyesatkan daripada membantu.

Jalur eksekusi dari interpreter ke C2#

Pada mulanya seluruh bytecode dijalankan oleh interpreter, yaitu mesin yang membaca instruksi satu per satu tanpa tahu instruksi mana yang penting. Sambil menafsirkan, JVM mengumpulkan profil eksekusi, misalnya berapa kali sebuah metode dipanggil atau sebuah loop berputar. Bagian yang hitungannya melewati ambang disebut kode panas (hotspot, asal nama HotSpot VM) dan menjadi kandidat kompilasi.

Kompilasi berlangsung bertingkat (tiered compilation). Kompilator C1 menghasilkan kode mesin dengan cepat tetapi hanya mengoptimalkan hal-hal dasar, cocok untuk memangkas biaya interpretasi secepatnya. Kompilator C2 bekerja lebih lama dan memakai profil yang sudah matang untuk melakukan optimasi agresif, misalnya memperluas metode ke tempat pemanggilnya (inlining) dan menghilangkan pemeriksaan batas yang dapat dibuktikan tidak pernah gagal. Tingkatannya lazim dinomori 0 sampai 4, dengan tier 0 untuk interpreter, tier 1 sampai 3 untuk varian C1, dan tier 4 untuk C2. Alurnya digambarkan berikut.

metode yang sering dipanggil (kode panas)bytecodeInterpretertier 0, sambil memprofilC1tier 1-3, cepat, dasarC2tier 4, agresifasumsi C2 keliru saat runtime, lalu kembali diinterpretasikan (deoptimization)
Jalur eksekusi bertingkat dari interpreter sampai C2

Jalur ini tidak satu arah. Bila asumsi yang dipakai C2 terbukti keliru saat runtime, misalnya kelas turunan baru muncul di tempat yang tadinya hanya satu implementasi, JVM membatalkan kode hasil kompilasi lewat deoptimization dan kembali menafsirkannya sambil memperbaiki profilnya. Karena itu performa program Java bukan angka tetap, melainkan kurva yang menghangat seiring waktu berjalan.

Kecepatan jalur ini juga dapat dipaksa dari luar. Flag -Xint memakai interpreter untuk semua kode sehingga program berjalan lambat tetapi konsisten, sedangkan flag -Xcomp berusaha mengompilasi setiap metode sejak pemanggilan pertamanya dengan biaya startup yang mahal. Keduanya jarang dipakai untuk produksi karena skema bertingkat biasanya paling baik, tetapi berguna untuk mengamati sisi ekstrem dari perilaku JVM.

Warmup: kurva performa yang menghangat#

Karena kompilasi terjadi saat program berjalan, pengukuran yang dilakukan terlalu dini mencatat fase yang belum mewakili kondisi stabil. Istilah lazimnya warmup, yaitu masa pembiasaan JVM terhadap kode panas. Program berikut adalah benchmark naif yang mengukur satu metode lima ronde.

java BenchNaif.java
// tidak-dijalankan
public class BenchNaif {

    static long jumlahkan(int batas) {
        long total = 0;
        for (int i = 1; i <= batas; i++) {
            total += i;
        }
        return total;
    }

    public static void main(String[] args) {
        for (int ronde = 1; ronde <= 5; ronde++) {
            long mulai = System.nanoTime();
            long hasil = jumlahkan(50_000_000);
            long selesai = System.nanoTime();
            System.out.printf("ronde %d: %d ms (total=%d)%n", ronde, (selesai - mulai) / 1_000_000, hasil);
        }
    }
}

Loop panjang juga punya jalurnya sendiri. Metode yang masih diinterpretasikan tetapi loop di dalamnya berputar sangat lama dapat dikompilasi di tengah eksekusi lewat mekanisme on-stack replacement, sehingga putaran berikutnya sudah memakai kode mesin. Inilah sebabnya pengukuran yang dibungkus satu loop raksasa tetap dapat berpindah fase di tengah jalan, dan hasilnya makin sulit diinterpretasikan.

Program ini dapat dijalankan di sandbox, tetapi keluarannya sengaja tidak ditempel, karena setiap eksekusi menghasilkan angka yang berbeda. Pola yang muncul tetap konsisten, yaitu ronde-ronde awal tercatat lebih lambat daripada ronde akhir, sesuai jalur interpretasi menuju C2 yang dijelaskan di atas. Angka mentah seperti inilah yang memikat, sekaligus paling mudah menyesatkan.

Untuk melihat kompilasi terjadi, JVM menyediakan flag -XX:+PrintCompilation yang mencetak setiap metode saat dikompilasi beserta tingkat tier-nya.

bash
# tidak-dijalankan
# menampilkan metode yang dikompilasi JIT beserta tingkat tier-nya
java -XX:+PrintCompilation BenchNaif.java

Jebakan micro-benchmark#

Micro-benchmark adalah pengukuran untuk potongan kode yang sangat kecil, dan bahaya terbesarnya adalah mengukur sesuatu yang sudah tidak ada. Kompiler C2 boleh menghilangkan perhitungan yang hasilnya tidak pernah dipakai (dead code elimination), sehingga loop benchmark yang hanya menghitung tanpa menyimpan hasil dapat dikosongkan dan yang terukur bukan lagi kode aslinya. C2 juga boleh melipat perhitungan yang jawabannya sudah diketahui saat kompilasi (constant folding), memutar loop dengan lebih efisien, atau memperluas metode ke pemanggilnya, sehingga kode yang diuji tidak lagi berbentuk yang tertulis di editor.

Faktor di luar JIT menambah gangguan, misalnya jeda GC yang jatuh di tengah pengukuran, frekuensi CPU yang berubah, dan kebisingan sistem operasi. Perbandingan dua versi kode dengan satu kali jalan masing-masing tidak pernah cukup, karena selisihnya sering kali lebih kecil daripada kebisingan pengukurannya. Alat standar untuk pekerjaan ini adalah JMH, framework benchmark dari OpenJDK yang mengurus warmup, mencegah dead code elimination lewat penampung hasil, dan melaporkan statistik lengkap dengan rentang ketidakpastiannya.

Kesalahan umum#

  • Mengukur sebelum warmup selesai. Ronde awal mencatat pekerjaan interpreter dan C1, sehingga kesimpulannya berlaku untuk fase yang justru paling singkat dalam umur aplikasi nyata.
  • Menyimpan hasil perhitungan ke variabel yang tidak pernah dibaca. C2 boleh menghilangkan seluruh perhitungan itu, dan yang terukur menjadi kosong. Pastikan hasil selalu dikonsumsi, misalnya dicetak di akhir.
  • Membandingkan dua versi kode dengan satu kali eksekusi. Selisih yang tampak sering lebih kecil daripada kebisingan pengukuran, sehingga kesimpulannya tidak dapat dipercaya.
  • Menyalin flag tuning JIT dari internet tanpa pengukuran. Flag yang menguntungkan satu beban kerja bisa merugikan beban kerja lain, dan efeknya baru terlihat lewat pengukuran yang benar.

Ringkasan#

  • JVM menafsirkan bytecode sambil memprofil, lalu mengompilasi kode panas melalui C1 menuju C2.
  • Performa Java adalah kurva yang menghangat, sehingga pengukuran harus menunggu kondisi stabil.
  • C2 boleh menghilangkan atau melipat perhitungan, sehingga benchmark naif berisiko mengukur kode yang sudah tidak ada.
  • JMH adalah alat standar untuk micro-benchmark karena mengurus warmup, dead code elimination, dan statistik.

Referensi#