Pengukuran kinerja

Profil JFR dan disiplin benchmark untuk keputusan optimasi yang berdasar.

Daftar isi

Keputusan optimasi yang tepat bertumpu pada pengukuran, bukan pada dugaan. Topik ini membahas mengapa pengukuran waktu yang naif sering menyesatkan, disiplin benchmark yang membuat angkanya dapat dipercaya, profil Java Flight Recorder (JFR) untuk melihat perilaku program di dalam JVM, serta cara membaca profil secara kasar. Satu program demonstrasi menutup topik ini dengan dua algoritme yang hasil akhirnya sama.

Mengapa pengukuran naif menyesatkan#

Cara pengukuran yang paling mudah dipikirkan adalah mencatat waktu sebelum dan sesudah kode berjalan, lalu menyelisihkannya. Angka yang dihasilkan memang nyata, tetapi penafsirannya sering keliru karena tiga penyebab yang bekerja bersamaan.

Pertama, pemanasan JIT (Just-In-Time compilation), yaitu proses JVM mengubah kode yang sering dijalankan dari bytecode menjadi kode mesin. Kode yang baru dieksekusi berjalan dalam bentuk bytecode yang lambat, sedangkan kode yang sudah panas berjalan sebagai kode mesin yang jauh lebih cepat. Pengukuran dalam keadaan dingin mencatat biaya yang tidak akan muncul lagi pada operasi berikutnya.

Kedua, garbage collector berhenti sejenak untuk merapikan memori pada saat yang tidak dapat dipilih oleh program. Satu siklus garbage collector yang terjadi di tengah pengukuran membuat hasilnya membengkak tanpa hubungan dengan kualitas kode yang diukur.

Ketiga, kebisingan mesin. Proses lain berbagi waktu prosesor, cache CPU terisi oleh data lain, dan frekuensi clock berubah mengikuti beban. Mesin yang sama dapat menghasilkan angka yang berbeda dari satu eksekusi ke eksekusi berikutnya, sehingga pengukuran tunggal tidak layak menjadi dasar keputusan.

Disiplin benchmark#

Karena sumber biasnya banyak, pengukuran yang dapat dipercaya adalah pengukuran yang dilakukan secara disiplin. Aturan praktisnya dirangkum sebagai berikut.

  • Lakukan pemanasan terlebih dahulu, yaitu jalankan kode yang diukur berkali-kali sebelum pencatatan dimulai, sehingga JIT selesai mengompilasi jalurnya.
  • Ukur banyak iterasi dan laporkan rata-ratanya, bukan satu durasi tunggal.
  • Ulangi seluruh pengukuran beberapa kali dan bandingkan hasil setiap eksekusi untuk menilai kestabilannya.
  • Pastikan hasil perhitungan benar-benar digunakan, misalnya dicetak atau diakumulasikan, karena JVM berhak membuang kode yang hasilnya tidak pernah dibaca.

Untuk pengukuran yang hasilnya menjadi dasar keputusan, gunakan pustaka JMH (Java Microbenchmark Harness) dari proyek OpenJDK. JMH menangani pemanasan, penghitungan rata-rata, dan pencegahan eliminasi kode mati secara bawaan, sehingga sumber bias yang paling sering terlewat tertangani secara sistematis. Topik ini tidak mendemokan JMH, sedangkan panduan penggunaannya tercantum pada ## Referensi.

Pengukuran manual tetap berguna untuk perbandingan kasar. Fragmen berikut memperlihatkan bentuk dasarnya dengan System.nanoTime.

scala Contoh: pengukuran durasi dengan System.nanoTime
val awal = System.nanoTime()
val hasil = jumlahPerulangan(batas)
val durasi = System.nanoTime() - awal
println(s"hasil: $hasil, durasi: ${durasi / 1000000} ms")

Angka durasi dari fragmen semacam ini bervariasi antarmesin dan antareksekusi, sehingga tidak pernah ditampilkan sebagai keluaran tetap. Nilai yang digunakan untuk keputusan sebaiknya berasal dari JMH atau dari pengukuran berulang dengan disiplin di atas.

Profil dengan JFR#

Durasi menyatakan berapa lama, sedangkan profil menyatakan di mana. Java Flight Recorder (JFR) adalah perekam peristiwa bawaan JVM. JFR mencatat sampel eksekusi, alokasi memori, peristiwa garbage collector, dan operasi baca tulis selama program berjalan, dengan overhead rendah yang dirancang agar perekaman dapat menyala terus-menerus. Berkas rekamannya berformat .jfr dan dibaca dengan alat seperti JDK Mission Control atau perintah jfr yang disertakan dalam JDK.

Perekaman dinyalakan melalui opsi JVM -XX:StartFlightRecording, baik dari perintah java maupun dari konfigurasi runtime yang digunakan aplikasi.

bash Contoh: menjalankan program dengan perekaman JFR
# tidak-dijalankan
# perekaman berlangsung 60 detik lalu ditulis ke berkas rekam.jfr
java -XX:StartFlightRecording=duration=60s,filename=rekam.jfr -jar aplikasi.jar

# meringkas isi rekaman tanpa alat grafis
jfr summary rekam.jfr

Opsi duration menentukan lama perekaman, sedangkan filename menentukan berkas rekamannya. Nilai durasi dan berkas pada contoh di atas disesuaikan dengan kebutuhan masing-masing aplikasi.

Membaca profil secara kasar#

Profil dibaca dari dua sisi yang paling sering menentukan. Sisi pertama adalah hotspot CPU, yaitu metode yang menyerap porsi sampel eksekusi terbesar. Percepatan yang paling berdampak terletak pada kode yang mendominasi sampel, sedangkan kode yang porsinya kecil jarang layak disentuh sekalipun terasa lambat saat dibaca. Sisi kedua adalah tekanan alokasi, yaitu kode yang membuat objek paling banyak. Alokasi yang besar memicu siklus garbage collector yang lebih sering, sehingga pengurangan alokasi pada jalur panas sering menurunkan durasi sekalipun logika kodenya tidak berubah.

Temuan profil langsung mengarahkan langkah optimasi. Alokasi objek pembungkus (boxing) pada perulangan numerik, misalnya, terlihat jelas pada sisi alokasi dan diselesaikan dengan tipe primitif atau koleksi khusus primitif. Setelah satu perubahan diterapkan, ukur kembali dengan disiplin yang sama, karena dampak yang diperkirakan sering berbeda dari dampak yang terukur.

Demonstrasi dua algoritme#

Program berikut menghitung jumlah bilangan 1 sampai 1.000.000 dengan dua cara. Cara pertama menjumlahkan satu per satu melalui perulangan while. Cara kedua menggunakan rumus penjumlahan deret, yaitu n * (n + 1) / 2, yang menyelesaikan pekerjaan dalam beberapa operasi aritmetika tanpa perulangan.

scala jumlah-jutaan.scala
// Dua algoritme menghitung jumlah bilangan 1 sampai 1.000.000
@main def jumlahJutaan(): Unit =
  val batas = 1000000L

  def jumlahPerulangan(n: Long): Long =
    var total = 0L
    var i = 1L
    while i <= n do
      total += i
      i += 1
    total

  def jumlahRumus(n: Long): Long =
    n * (n + 1) / 2

  val hasilPerulangan = jumlahPerulangan(batas)
  val hasilRumus = jumlahRumus(batas)

  println("Jumlah perulangan: " + hasilPerulangan)
  println("Jumlah rumus     : " + hasilRumus)
  println("Hasil sama       : " + (hasilPerulangan == hasilRumus))

Jalankan program tersebut.

bash
# mengompilasi dan menjalankan program dua algoritme
scala-cli run jumlah-jutaan.scala
text Keluaran
Jumlah perulangan: 500000500000
Jumlah rumus     : 500000500000
Hasil sama       : true

Kedua algoritme menghasilkan nilai yang sama, yaitu 500000500000, dan persamaan itu dinyatakan oleh program pada baris ketiga. Nilai inilah yang layak masuk blok keluaran, karena hasil perhitungannya sama di mesin mana pun. Pertanyaan berikutnya, yaitu mana yang lebih cepat dan seberapa besar, adalah wilayah pengukuran. Jawabannya diperoleh melalui disiplin benchmark pada bagian sebelumnya atau melalui profil JFR, meskipun angkanya berbeda antarmesin dan antareksekusi. Program ini sengaja dipisahkan dari pengukurannya, karena menyatukan keduanya akan membuat keluaran berubah setiap kali dijalankan.

Ringkasan#

  • Pengukuran durasi satu kali menyesatkan karena pemanasan JIT, jeda garbage collector, dan kebisingan mesin.
  • Disiplin benchmark mencakup pemanasan, banyak iterasi, rata-rata, pengulangan antareksekusi, dan pustaka JMH untuk keputusan optimasi yang berdasar.
  • JFR mencatat peristiwa JVM melalui opsi -XX:StartFlightRecording dengan overhead yang rendah.
  • Profil dibaca dari hotspot CPU dan tekanan alokasi, lalu setiap perubahan optimasi diukur kembali.
  • Keluaran program demonstrasi hanya memuat nilai yang tetap, sedangkan perbandingan durasi dilaporkan melalui pengukuran.

Referensi#