Artikel mendalam tentang teknologi yang membentuk masa depan.

Bagaimana JIT Compiler Membuat Bahasa Dinamis Jadi Cepat

Cara JIT compiler membuat Ruby, Python, dan JavaScript lebih cepat dengan menghapus operasi berlebih, disertai contoh nyata dari YJIT dan ZJIT.

Sebuah gem Ruby masuk ke mesin bercahaya dan keluar sebagai komet cahaya yang melesat

Ruby punya reputasi lambat. Python juga. JavaScript dulu juga begitu, sampai V8 membuatnya cukup cepat untuk menjalankan beban kerja server-side. Cerita tentang bagaimana bahasa dinamis bisa cepat sebenarnya adalah cerita tentang kompilasi JIT (Just-In-Time), dan ini salah satu bidang paling menarik dalam ilmu komputer praktis. Babak terbarunya: ZJIT milik Ruby menghapus pemuatan dan penyimpanan objek yang redundan di level intermediate representation, kelas optimasi yang sama yang membuat TurboFan dari V8 begitu efektif.

Kalau kamu pernah penasaran kenapa kode Ruby berjalan jauh lebih lambat dibanding C, atau bagaimana JavaScript bisa cukup cepat untuk menjalankan VS Code, jawabannya ada pada pemahaman tentang apa yang sebenarnya dilakukan compiler JIT, dan apa yang membuat optimasi bahasa dinamis jauh lebih sulit dibanding bahasa statis.

Masalah Mendasar dari Bahasa Dinamis

Ketika compiler C melihat a + b, ia sudah tahu tipe a dan b saat kompilasi. Kalau keduanya integer, ia menghasilkan satu instruksi ADD. Kalau keduanya float, ia menghasilkan operasi penjumlahan floating-point. CPU mengeksekusi instruksi itu dalam satu siklus. Tidak ada ambiguitas, tidak ada pengambilan keputusan saat runtime.

Ketika interpreter Ruby melihat a + b, ia hampir tidak tahu apa-apa. a bisa berupa integer, float, string, array, atau objek apa pun yang mendefinisikan method +. Interpreter harus: mengecek tipe a, mencari method + untuk tipe tersebut, mengecek tipe b, mungkin melakukan coercion, menangani kasus khusus (overflow, objek frozen), dan akhirnya menjalankan operasinya. Satu operator + itu bisa melibatkan puluhan instruksi, lookup memori, dan keputusan percabangan.

# What the programmer writes:
def sum(a, b)
a + b
end
# What the interpreter actually has to do (pseudocode):
def sum(a, b)
method = lookup_method(a.class, :+)      # Hash lookup
raise NoMethodError unless method         # Branch
if a.is_a?(Integer) && b.is_a?(Integer)   # Type checks
result = integer_add(a.value, b.value)  # Actual math
if overflow?(result)                     # Overflow check
result = promote_to_bignum(result)    # Bignum promotion
end
elsif a.is_a?(Float) || b.is_a?(Float)
result = float_add(to_float(a), to_float(b))
elsif a.is_a?(String)
result = string_concat(a, b.to_s)
else
result = call_method(method, a, [b])    # Generic dispatch
end
result
end

Overhead ini (pengecekan tipe, pencarian method, dispatch) adalah harga yang harus dibayar untuk sifat dinamis. Fleksibilitas menulis a + b dan membuatnya bekerja untuk integer, float, string, maupun objek custom itu mahal saat runtime.

Bagaimana Kompilasi JIT Melawan Balik

Tugas compiler JIT adalah mengamati apa yang benar-benar dilakukan program saat runtime, lalu menghasilkan machine code yang dioptimasi berdasarkan pengamatan itu. Wawasan kuncinya: meskipun kode Ruby bisa bekerja dengan tipe apa pun, dalam praktiknya sebuah call site hampir selalu menerima tipe yang sama.

Jika sum(a, b) sudah dipanggil 10.000 kali dan a serta b selalu integer, JIT bisa menghasilkan machine code khusus yang mengasumsikan keduanya akan terus integer. Ia menghasilkan satu instruksi penjumlahan integer dengan 'guard', yaitu pengecekan tipe singkat yang mengalihkan eksekusi ke jalur lambat jika asumsi tersebut suatu saat dilanggar.

Interpreter execution of sum(a, b) where a and b are Integers:
1. Load a from stack
2. Check if a is an object reference
3. Load a's class pointer
4. Look up :+ in method table (hash lookup)
5. Check method visibility
6. Load b from stack
7. Check if b is an object reference
8. Check if b is compatible type
9. Unbox a's integer value
10. Unbox b's integer value
11. Perform addition
12. Check for overflow
13. Box result as new Integer object
14. Return result
JIT-compiled version (after profiling shows a,b are always Integers):
1. Guard: check a is Integer (bail if not)
2. Guard: check b is Integer (bail if not)
3. ADD instruction
4. Guard: check no overflow (bail if overflow)
5. Return result

Itu proses 14 langkah yang dipangkas menjadi 5 langkah, di mana langkah 1-2 dan 4 hanya berupa instruksi perbandingan tunggal. Komputasi sebenarnya, yaitu ADD, hanya memakan satu siklus CPU. Begitulah JavaScript berpindah dari 'terlalu lambat untuk hal serius' menjadi 'cukup cepat untuk menjalankan IDE lengkap'.

Perjalanan JIT Ruby: Dari MJIT ke YJIT ke ZJIT

Sejarah JIT di Ruby adalah studi kasus seberapa sulit masalah ini. Ruby sudah melewati beberapa implementasi JIT, masing-masing dengan pendekatan berbeda.

MJIT (Ruby 2.6, 2018) menerjemahkan bytecode Ruby ke kode C, lalu memanggil GCC atau Clang untuk mengompilasinya. Hasilnya kode yang cukup teroptimasi, tapi warm-up-nya sangat buruk, karena mengompilasi C butuh hitungan detik, bukan milidetik. Saat kode hasil JIT siap, program mungkin sudah selesai dijalankan.

YJIT (Ruby 3.1, 2022) adalah kontribusi Shopify, awalnya ditulis dengan C lalu ditulis ulang dengan Rust. YJIT memakai teknik bernama 'lazy basic block versioning', yaitu mengompilasi kode satu basic block per satu, hanya saat kode itu benar-benar dieksekusi, dan membuat versi khusus berdasarkan tipe yang diamati. Hasilnya warm-up yang cepat (milidetik, bukan detik) dengan performa puncak yang bagus. YJIT biasanya meningkatkan performa Ruby sebesar 15-30% pada beban kerja nyata seperti aplikasi Rails.

ZJIT adalah evolusi berikutnya, dan di sinilah hal-hal menjadi benar-benar menarik. ZJIT memperkenalkan intermediate representation (IR), yaitu representasi terstruktur dari program di antara bytecode dan machine code. IR ini memungkinkan optimasi compiler klasik yang tidak mudah dilakukan oleh pendekatan YJIT yang langsung dari bytecode ke machine code.

Menghapus Pemuatan dan Penyimpanan yang Redundan

Optimasi spesifik yang baru-baru ini didaratkan ZJIT, yaitu menghapus pemuatan dan penyimpanan objek yang redundan, terdengar rumit tapi dampaknya besar. Begini alasannya.

Objek Ruby menyimpan instance variable di sebuah property table. Setiap kali kamu membaca @name, interpreter memuat nilainya dari property table objek di memori. Setiap kali kamu menulis @name = value, ia menyimpan ke tabel itu. Dalam method yang mengakses instance variable yang sama berkali-kali, interpreter memuatnya dari memori setiap kali, karena secara umum sesuatu mungkin saja mengubah nilainya di antara pembacaan.

class Rectangle
def area
@width * @height
end
def perimeter
# @width and @height are each loaded TWICE from memory
2 * (@width + @height)
end
def scale(factor)
@width = @width * factor    # Load @width, multiply, store @width
@height = @height * factor  # Load @height, multiply, store @height
self
end
end
# In a tight loop, these redundant loads add up:
rects.each { |r| r.scale(2).perimeter }

Dengan IR, ZJIT bisa melakukan load elimination: jika @width sudah dimuat dan belum ada yang mengubahnya, nilainya cukup dipakai ulang dari register alih-alih dimuat ulang dari memori. ZJIT juga bisa melakukan store elimination: jika kamu menulis @width dua kali berturut-turut tanpa ada yang membacanya di antara kedua penulisan, penulisan pertama bisa dihapus.

Optimasi seperti ini sudah jadi standar di compiler bahasa statis, GCC dan LLVM sudah melakukannya selama puluhan tahun. Tapi untuk bahasa dinamis jauh lebih sulit karena masalah aliasing. Di Ruby, memanggil method apa pun berpotensi mengubah instance variable objek mana pun (lewat instance_variable_set, method_missing, atau trace hook). JIT harus membuktikan bahwa di antara dua pembacaan @width, tidak ada yang bisa mengubahnya, dan itu berarti menganalisis apa saja yang mungkin dilakukan setiap operasi di antaranya.

Keunggulan IR

Alasan ZJIT memperkenalkan IR (dan kenapa TurboFan dari V8, DFG/FTL dari JavaScriptCore, serta C2 dari HotSpot semuanya memakai IR) adalah karena IR membuat optimasi ini bisa dikombinasikan. IR pada dasarnya adalah graf operasi, di mana optimasi bisa diterapkan sebagai transformasi graf.

  • Constant folding: Jika kedua operand penjumlahan sudah diketahui konstanta, ganti operasinya dengan hasilnya. 2 + 3 menjadi 5 saat kompilasi.
  • Dead code elimination: Jika hasil sebuah operasi tidak pernah dipakai, hapus operasi itu sepenuhnya.
  • Common subexpression elimination: Jika komputasi yang sama muncul dua kali, hitung sekali saja dan gunakan ulang hasilnya.
  • Load/store elimination: Hapus operasi memori yang redundan seperti yang dijelaskan di atas.
  • Escape analysis: Jika sebuah objek dibuat dan tidak pernah keluar dari method saat ini, alokasikan di stack alih-alih heap (atau hilangkan alokasinya sama sekali).
  • Inlining: Ganti pemanggilan method dengan isi method-nya, sehingga membuka lebih banyak peluang untuk optimasi lain.

Optimasi ini saling memperkuat. Inlining sebuah method membuat operasinya terlihat dalam konteks pemanggil, yang bisa mengungkap nilai konstanta, yang memungkinkan constant folding, yang kemudian membuat kode menjadi dead code, yang lalu dihapus. Satu keputusan inlining bisa berantai menghapus puluhan operasi.

Jaring Pengaman Deoptimization

Semua yang dilakukan compiler JIT bersifat spekulatif. Ia mengasumsikan tipe tidak akan berubah, method tidak akan didefinisikan ulang, dan monkey-patching tidak akan membatalkan kode yang sudah dioptimasi. Saat asumsi itu runtuh, JIT perlu 'deoptimize', yaitu membuang kode yang sudah dioptimasi dan kembali ke interpreter.

Deoptimization adalah salah satu bagian tersulit dalam desain JIT. Kode yang sudah dioptimasi mungkin sudah menghapus variabel lokal, mengurutkan ulang operasi, atau melakukan inlining pada pemanggilan yang sangat bersarang. Untuk kembali ke interpreter, JIT harus merekonstruksi state interpreter, yaitu semua variabel lokal, call stack, dan program counter, dari apa pun yang tersedia di kode yang sudah dioptimasi. Ini memerlukan metadata (disebut 'on-stack replacement' atau peta OSR) yang memetakan state kode teroptimasi kembali ke state interpreter.

Ketika deoptimization sering terjadi, kondisi yang disebut 'deopt thrashing', performanya bisa lebih buruk daripada interpretasi murni. JIT menghabiskan waktu mengompilasi kode teroptimasi, menjalankannya sebentar, men-deoptimize, lalu mengulanginya. V8 menanganinya dengan melacak jumlah deoptimization dan akhirnya menyerah mengoptimasi fungsi tertentu. YJIT mengambil pendekatan yang lebih sederhana: ia membuat beberapa versi setiap jalur kode untuk kombinasi tipe yang berbeda, sehingga mengurangi kebutuhan deoptimization dengan harga kode yang dihasilkan lebih banyak.

Mengapa Ini Penting di Luar Ruby

Perjalanan JIT Ruby mencerminkan apa yang terjadi di berbagai bahasa dinamis. Copy-and-patch JIT milik Python (masuk di CPython 3.13) adalah langkah pertama menuju kompilasi JIT yang layak untuk Python. LuaJIT sudah sangat cepat selama bertahun-tahun dengan memakai tracing JIT yang agresif. JIT di PHP 8.0+ memakai LLVM untuk optimasi berbasis IR-nya.

Polanya konsisten: mulai dengan interpreter, tambahkan profiling untuk memahami perilaku runtime, kompilasi hot path dengan spesialisasi tipe, perkenalkan IR untuk optimasi klasik, lalu perbaiki terus. Setiap bahasa menghadapi tantangan yang sama seperti dynamic dispatch, objek yang bisa diubah, eval, dan monkey-patching, dan akhirnya sampai pada solusi yang mirip.

Bagi developer yang memakai bahasa-bahasa ini, kesimpulan praktisnya adalah kesenjangan performa antara bahasa dinamis dan statis semakin menyempit. Kesenjangan ini tidak akan pernah hilang sepenuhnya, karena pengecekan tipe dan guard tetap punya biaya, dan deoptimization adalah overhead yang melekat. Tapi JIT yang teroptimasi dengan baik bisa berada dalam 2-5x kode C yang setara untuk beban komputasi, dan itu 'cukup cepat' untuk sebagian besar aplikasi.

Kesimpulan yang lebih halus: tulis kode yang lurus-lurus saja. Compiler JIT mengoptimasi pola yang bisa diprediksi. Call site monomorfik (tempat method selalu menerima tipe yang sama) mudah dioptimasi. Call site polimorfik (tipe bervariasi) lebih sulit. Call site megamorfik (puluhan tipe) mungkin tidak pernah dioptimasi. Kode yang sederhana untuk dipahami manusia biasanya juga sederhana untuk dioptimasi JIT, dan ini kebetulan yang menguntungkan kedua pihak.