Microbenchmark adalah pengukuran kinerja function atau potongan kode kecil. Sederhana kelihatannya, namun banyak jebakan menanti developer yang baru pertama kali mencoba. JIT compiler dapat menghapus kode yang hasilnya tidak dipakai, GC dapat memunculkan jeda yang mengganggu pengukuran, dan cold start berbeda jauh dari steady state. Pada bab ini kita akan membedah tiga jebakan utama microbenchmark, mendemokan setiap jebakan dengan step-counting deterministik, dan menyusun aturan emas yang menjamin hasil benchmark dapat dipercaya.

Tiga Jebakan Utama #

Setiap microbenchmark rentan terhadap tiga jebakan yang membuat hasilnya tidak valid. Jebakan pertama adalah JIT warmup: JIT compiler butuh waktu untuk mengidentifikasi hot code dan mengoptimasinya. Pengukuran sebelum warmup akan terlalu pesimistis. Jebakan kedua adalah dead code elimination (DCE): compiler dapat menghapus seluruh kode yang hasilnya tidak dipakai, sehingga benchmark mengukur “apa-apa”. Jebakan ketiga adalah GC pauses: alokasi objek dalam loop benchmark memicu GC yang menambahkan jeda tak terduga.

JebakanPenyebabAkibatSolusi
JIT warmupJIT butuh ~10_000 panggilanHasil awal pesimistisWarmup sebelum ukur
Dead code eliminationHasil tidak dipakaiBenchmark mengukur 0 opsPakai sink variable
GC pausesAlokasi berlebihVariance tinggiKurangi alokasi

Jebakan 1: JIT Warmup #

JVM memulai dalam mode interpreter (slow) dan mengaktifkan JIT compilation setelah function dianggap “hot” (sekitar 10_000 panggilan). Tanpa warmup, benchmark mengukur performance interpreter, bukan performance production. Solusinya: jalankan kode beberapa ribu kali sebelum mulai mengukur.

kotlin Pola warmup yang benar
fun benchmark() {
    // Warmup: 10_000 panggilan tanpa pengukuran
    repeat(10_000) { cpuIntensive(it) }

    // Pengukuran: 1_000 panggilan yang diukur
    repeat(1_000) { cpuIntensive(it) }
}

Setelah warmup, JIT telah mengoptimasi function. Pengukuran berikutnya akan menunjukkan performance steady state yang merupakan performance yang akan dialami pengguna di production.

Jebakan 2: Dead Code Elimination #

Compiler optimasi (terutama JIT dan GraalVM) dapat menghapus kode yang hasilnya tidak pernah dipakai. Bila benchmark memanggil cpuIntensive(x) tetapi return value-nya diabaikan, compiler mungkin menghapus seluruh loop tersebut. Akibatnya, benchmark melaporkan “sangat cepat” padahal tidak ada pekerjaan yang dilakukan.

kotlin Pola sink variable yang benar
var sink: Int = 0  // variable yang menyimpan hasil agar tidak di-eliminasi
repeat(1_000) {
    sink += cpuIntensive(it)  // hasil ditambahkan ke sink
}
println(sink)  // pakai sink agar tidak dianggap dead code

Blackhole di JMH adalah implementasi resmi dari pola ini. Blackhole.consume(x) memberi sinyal ke compiler bahwa x dipakai, sehingga tidak boleh dieliminasi.

Jebakan 3: GC Pauses #

Bila benchmark mengalokasikan objek di setiap iterasi, GC akan dipicu untuk membersihkan objek-objek tersebut. GC pause menyebabkan sebagian iterasi menjadi sangat lambat, sementara iterasi lain normal. Akibatnya, variance hasil benchmark menjadi tinggi dan rata-rata tidak representatif.

kotlin Pola yang menimbulkan GC pauses
fun benchmarkBuruk() {
    repeat(1_000) {
        val list = ArrayList<String>(100)  // alokasi besar per iterasi
        repeat(100) { list.add("item-$it") }
        // list tidak dipakai lagi, menunggu GC
    }
}

Solusi: kurangi alokasi dalam loop utama. Reuse object, pakai primitive array daripada boxed collection, atau pindahkan alokasi ke luar loop bila memungkinkan.

Demo Tiga Jebakan #

Cuplikan berikut mendemokan ketiga jebakan dengan output deterministik. JIT warmup dihitung berdasarkan threshold khas, DCE dijelaskan via sink variable, dan GC pause disimulasikan setiap 25 iterasi:

kotlin
// Demo jebakan microbenchmark: JIT warmup, dead code elimination,
// GC pauses. Menggunakan step counter (bukan nanotime) agar deterministik.

// Simulasi "warmup": hitung berapa kali function dipanggil sebelum
// JIT dianggap cukup mengoptimasi (threshold khas: 10_000 panggilan).

class BenchmarkState {
    var invocationCount: Long = 0
    var jitCompiled: Boolean = false
    var jitThreshold: Long = 10_000
    var deadCodeEliminated: Int = 0
    var gcPaused: Boolean = false
}

fun cpuIntensive(x: Int): Int {
    // Operasi yang dapat di-eliminasi compiler bila hasil tidak dipakai
    var acc = 0
    for (i in 0 until 100) {
        acc += x * i
    }
    return acc
}

fun cpuIntensiveUnused(x: Int): Int {
    // Sama seperti di atas, namun pemanggil tidak memakai return value
    var acc = 0
    for (i in 0 until 100) {
        acc += x * i
    }
    return acc
}

fun main() {
    println("=== Benchmarking Fundamentals ===")
    println()
    println("Tiga jebakan umum microbenchmark:")
    println("  1. JIT warmup (cold start vs steady state)")
    println("  2. Dead code elimination (DCE)")
    println("  3. GC pauses (karena alokasi berlebih)")
    println()

    // === Jebakan 1: JIT Warmup ===
    println("=== Jebakan 1: JIT Warmup ===")
    val state = BenchmarkState()
    val samples = mutableListOf<Pair<Int, Long>>()

    repeat(3) { phase ->
        state.invocationCount = 0
        repeat(15_000) {
            cpuIntensive(it)
            state.invocationCount++
            if (!state.jitCompiled && state.invocationCount >= state.jitThreshold) {
                state.jitCompiled = true
            }
        }
        val avg = 15_000L
        samples.add(phase + 1 to avg)
        println("  Phase ${phase + 1}: 15000 panggilan, JIT compiled=${state.jitCompiled}")
    }
    println("  Pelajaran: 3 phase pertama mengaktifkan JIT; phase awal lebih lambat.")
    println()

    // === Jebakan 2: Dead Code Elimination ===
    println("=== Jebakan 2: Dead Code Elimination ===")
    var sink1: Int = 0
    var sink2: Int = 0

    // Variants A: return value dipakai (tidak di-eliminasi)
    state.invocationCount = 0
    repeat(100) { sink1 += cpuIntensive(it) }

    // Variants B: return value TIDAK dipakai (compiler mungkin eliminasi)
    state.deadCodeEliminated = 0
    repeat(100) { cpuIntensiveUnused(it) } // tidak ada yang memakai return

    println("  Variant A (hasil dipakai): 100 panggilan efektif")
    println("  Variant B (hasil tidak dipakai): compiler mungkin eliminasi seluruh loop")
    println("  Pelajaran: selalu 'consume' hasil benchmark dengan sink variable.")
    println("  sink1=$sink1, sink2=$sink2 (mencegah compiler optimasi variant A)")
    println()

    // === Jebakan 3: GC Pauses ===
    println("=== Jebakan 3: GC Pauses ===")
    println("  Skenario: 1000 alokasi String per iterasi, 100 iterasi")
    var totalAlokasi = 0
    var totalIterations = 0
    repeat(100) { i ->
        val list = ArrayList<String>(10)
        repeat(10) { list.add("iterasi-$i-item-$it") }
        totalAlokasi += list.size
        totalIterations++
        // Simulasi: setiap 25 iterasi, GC pause terjadi
        if (i in listOf(25, 50, 75)) {
            state.gcPaused = true
            println("    [GC] minor pause di iterasi $i")
        }
    }
    println("  Total alokasi: $totalAlokasi, iterasi: $totalIterations")
    println("  GC pause terjadi: ${if (state.gcPaused) "ya" else "tidak"}")
    println("  Pelajaran: alokasi berlebih menyebabkan GC, yang menambah variance benchmark.")
    println()

    // === Aturan Emas ===
    println("=== Aturan Emas Microbenchmark ===")
    val rules = listOf(
        "1. Selalu warmup (10_000+ panggilan) sebelum mengukur",
        "2. Konsumsi hasil dengan sink variable untuk cegah DCE",
        "3. Kurangi alokasi di dalam loop utama",
        "4. Jalankan beberapa iterasi, ambil median bukan rata-rata",
        "5. Gunakan framework benchmark (JMH) yang menangani jebakan otomatis"
    )
    rules.forEach { println("  $it") }
}

Output yang Dihasilkan:

text
=== Benchmarking Fundamentals ===

Tiga jebakan umum microbenchmark:
  1. JIT warmup (cold start vs steady state)
  2. Dead code elimination (DCE)
  3. GC pauses (karena alokasi berlebih)

=== Jebakan 1: JIT Warmup ===
  Phase 1: 15000 panggilan, JIT compiled=true
  Phase 2: 15000 panggilan, JIT compiled=true
  Phase 3: 15000 panggilan, JIT compiled=true
  Pelajaran: 3 phase pertama mengaktifkan JIT; phase awal lebih lambat.

=== Jebakan 2: Dead Code Elimination ===
  Variant A (hasil dipakai): 100 panggilan efektif
  Variant B (hasil tidak dipakai): compiler mungkin eliminasi seluruh loop
  Pelajaran: selalu 'consume' hasil benchmark dengan sink variable.
  sink1=24502500, sink2=0 (mencegah compiler optimasi variant A)

=== Jebakan 3: GC Pauses ===
  Skenario: 1000 alokasi String per iterasi, 100 iterasi
    [GC] minor pause di iterasi 25
    [GC] minor pause di iterasi 50
    [GC] minor pause di iterasi 75
  Total alokasi: 1000, iterasi: 100
  GC pause terjadi: ya
  Pelajaran: alokasi berlebih menyebabkan GC, yang menambah variance benchmark.

=== Aturan Emas Microbenchmark ===
  1. Selalu warmup (10_000+ panggilan) sebelum mengukur
  2. Konsumsi hasil dengan sink variable untuk cegah DCE
  3. Kurangi alokasi di dalam loop utama
  4. Jalankan beberapa iterasi, ambil median bukan rata-rata
  5. Gunakan framework benchmark (JMH) yang menangani jebakan otomatis

Mari kita bedah bagian-bagian kunci dari kode di atas:

  1. BenchmarkState: class yang melacak status benchmark (apakah JIT sudah aktif, jumlah invocation, GC pause). Pada JMH nyata, @State annotation berperan sama.
  2. jitThreshold = 10_000: angka khas di HotSpot JVM. Setelah function dipanggil 10_000 kali, JIT menganggapnya “hot” dan mengaktifkan compilation ke native code. Pada demo, state.jitCompiled di-set true saat threshold terlewati.
  3. cpuIntensiveUnused vs cpuIntensive: kedua function identik, namun pemanggilan cpuIntensiveUnused tidak menggunakan return value. Compiler dapat mengenali pola ini dan menghapus seluruh loop, sehingga “benchmark” variant B sebenarnya mengukur 0 operasi.
  4. sink1 += cpuIntensive(it): pola sink variable. Hasil setiap pemanggilan ditambahkan ke sink1, sehingga compiler tidak bisa mengeliminasi. Pada akhir, sink1 di-print untuk memastikan tidak dianggap dead code.
  5. Simulasi GC pause di iterasi 25/50/75: setiap 25 iterasi, demo mencetak pesan “[GC] minor pause”. Pada benchmark nyata, GC pause terdeteksi via JFR atau -verbose:gc.

Aturan Emas Microbenchmark #

Aturan emas yang muncul dari tiga jebakan di atas adalah: warmup dulu, konsumsi hasil, kurangi alokasi, ambil median, dan gunakan framework. Aturan kelima paling penting: JMH (Java Microbenchmark Harness) menangani semua jebakan di atas secara otomatis, sehingga developer dapat fokus pada kode yang ingin diukur daripada metodologi pengukuran.

Referensi Lanjutan #

Selanjutnya #

Setelah memahami jebakan microbenchmark, kita siap memakai framework yang menanganinya secara otomatis. Bab berikutnya membahas JMH (Java Microbenchmark Harness) — standar de facto untuk benchmark di JVM. Materi tersebut ada di chapter JMH.