Cara Julia Menjalankan Kode

Perjalanan kode Julia dari sintaks hingga kode mesin melalui kompilasi JIT LLVM per spesialisasi tipe.

Daftar isi

Topik ini menelusuri perjalanan kode Julia dari teks sumber hingga instruksi yang langsung dijalankan prosesor. Alurnya mencakup empat tahap kompilasi, mekanisme spesialisasi per kombinasi tipe, serta cache yang membuat pemanggilan berikutnya cepat. Setelah membacanya, latensi pemanggilan pertama dan kecepatan pemanggilan selanjutnya dapat dijelaskan dari mekanismenya, bukan dari kesan.

Empat tahap dari sintaks ke kode mesin#

Julia tidak menafsirkan kode baris demi baris pada setiap eksekusi. Sebuah fungsi dikompilasi terlebih dahulu melalui empat tahap. Parser menguraikan teks sumber menjadi pohon sintaks, yaitu struktur data yang memuat setiap ekspresi beserta posisinya pada berkas. Tahap penurunan (lowering) kemudian menyederhanakan pohon tersebut menjadi bentuk inti bahasa yang lebih sederhana, misalnya dengan mengganti notasi ringkas menjadi pemanggilan fungsi eksplisit.

Tahap ketiga adalah inferensi tipe. Julia menyimpulkan tipe setiap nilai dan setiap operasi dari tipe argumen yang dikirim pemanggil. Apabila tipe tersebut pasti, tahap keempat menghasilkan instruksi mesin yang spesifik, misalnya satu instruksi penjumlahan titik mengambang. Apabila tipe tidak dapat dipastikan, kode yang dihasilkan harus mengakomodasi banyak kemungkinan sehingga lebih lambat, dan kondisi inilah yang membedakan kode cepat dari kode lambat pada modul ini.

Alur tersebut dirangkum pada diagram berikut, termasuk bagian yang menjadi khas Julia, yaitu hasil kompilasi yang dibuat per kombinasi tipe lalu disimpan untuk dipakai ulang.

tambah(x, y)satu definisi fungsiKode mesin versi Int64add i64Kode mesin versi Float64fadd doubledipanggil dengan (Int64, Int64)dipanggil dengan (Float64, Float64)Hasil kompilasi untuk setiap kombinasi tipe disimpan sebagai cache.Pemanggilan berikutnya dengan tipe yang sama langsung menjalankan kode mesinnya.
Alur kompilasi Julia dengan spesialisasi per kombinasi tipe argumen

Satu fungsi, banyak spesialisasi#

Spesialisasi adalah versi kode mesin yang dihasilkan untuk satu kombinasi tipe argumen. Satu definisi fungsi Julia dengan demikian dapat memiliki beberapa spesialisasi sekaligus. Skrip berikut memanggil fungsi yang sama dengan dua kombinasi tipe berbeda, lalu menampilkan tipe hasilnya.

julia spesialisasi.jl
# satu definisi, dua spesialisasi: tipe argumen menentukan versinya
tambah(x, y) = x + y

a = tambah(2, 3)
b = tambah(2.0, 3.0)

println("tambah(2, 3)     = ", a, " bertipe ", typeof(a))
println("tambah(2.0, 3.0) = ", b, " bertipe ", typeof(b))
text Keluaran
tambah(2, 3)     = 5 bertipe Int64
tambah(2.0, 3.0) = 5.0 bertipe Float64

Kedua pemanggilan mengembalikan nilai lima yang tampak sama, tetapi tipe hasilnya berbeda, yaitu Int64 untuk bilangan bulat dan Float64 untuk bilangan titik mengambang. Julia menyiapkan kode mesin terpisah untuk masing-masing karena operasi penjumlahan untuk kedua tipe itu berbeda. Daftar spesialisasi yang pernah dibuat ikut tersimpan pada tabel metode, sehingga pemanggilan dengan tipe yang sama tidak mengulang pekerjaan kompilasi.

Mengintip hasil kompilasi dengan @code_llvm#

Makro @code_llvm dari pustaka standar InteractiveUtils menampilkan LLVM IR, yaitu representasi antara sebelum instruksi mesin. Bentuknya berguna untuk memeriksa apakah inferensi tipe benar-benar menghasilkan operasi yang spesifik. Skrip berikut menampilkan LLVM IR untuk penjumlahan dua Float64.

julia Contoh: mengintip LLVM IR dengan @code_llvm
using InteractiveUtils

tambah(x, y) = x + y
@code_llvm debuginfo=:none tambah(2.0, 3.0)
text Transkrip terminal
; Function Signature: tambah(Float64, Float64)
define double @julia_tambah_1114(double %"x::Float64", double %"y::Float64") local_unnamed_addr #0 {
top:
  %0 = fadd double %"x::Float64", %"y::Float64"
  ret double %0
}

Hasilnya hanya satu instruksi fadd double, yaitu penjumlahan titik mengambang yang dipahami langsung oleh prosesor x86-64. Kode ini sama ringkasnya dengan yang dihasilkan compiler C untuk fungsi setara. Akhiran angka pada nama fungsi seperti _1114 berasal dari penomoran internal kompilasi dan dapat berbeda antarsesi, sehingga isi transkrip ini bisa sedikit berbeda ketika contoh dijalankan ulang.

Latensi pemanggilan pertama dan cache#

Pemanggilan pertama sebuah fungsi untuk satu kombinasi tipe terasa jauh lebih lambat daripada pemanggilan berikutnya karena menanggung seluruh proses kompilasi just-in-time. Setelah kompilasi selesai, hasilnya disimpan pada cache kompilasi dalam sesi tersebut, dan pemanggilan dengan tipe yang sama langsung menjalankan kode mesinnya tanpa pekerjaan tambahan.

Paket yang terpasang melalui Pkg mendapat perlakuan tambahan. Saat dimuat, paket menggunakan hasil prakompilasi yang disimpan pada disk berupa berkas pkgimage, sehingga sebagian besar pekerjaan kompilasinya tidak diulang pada setiap sesi. Fungsi yang didefinisikan di dalam skrip tidak mendapat manfaat dari cache disk ini, karena skrip baru dikenal Julia saat dijalankan.

Posisi di antara bahasa dinamis lain#

Pada bahasa dinamis yang menafsirkan kode, setiap operasi diperiksa dan dieksekusi ulang setiap kali ditemui, sehingga biaya tipe dinamis dibayar berulang-ulang sepanjang program berjalan. Julia memindahkan pemeriksaan tipe itu ke waktu kompilasi, yaitu tipe dianalisis sekali per spesialisasi, dan sisa eksekusinya berupa instruksi mesin tanpa lapisan penafsiran.

Perlu dicatat bahwa beberapa implementasi bahasa dinamis lain juga mengenal kompilasi just-in-time. Perbedaannya terletak pada posisi mekanismenya, yaitu desain Julia menjadikan inferensi tipe dan spesialisasi sebagai jalur utama eksekusi sejak awal, bukan lapisan percepatan yang menyertai penafsiran. Konsekuensinya, kecepatan penuh hanya diperoleh apabila tipe argumen dapat dipastikan, dan itulah sebabnya stabilitas tipe menjadi penentu utama kecepatan Julia.

Kesalahan umum#

Kesalahan pada topik ini hampir selalu berkaitan dengan membaca waktu eksekusi tanpa mempertimbangkan kompilasi. Tiga kesalahan berikut paling sering ditemui.

  1. Menilai kecepatan fungsi dari pemanggilan pertamanya. Pemanggilan itu memuat waktu kompilasi just-in-time, sehingga terasa jauh lebih lambat. Perbaikannya: panggil fungsi sekali untuk menghangatkan, kemudian ukur pemanggilan-pemanggilan berikutnya.
  2. Mengira satu definisi fungsi berarti satu kode mesin. Julia menyimpan satu spesialisasi untuk setiap kombinasi tipe, sehingga perilaku dan kecepatannya dievaluasi per kombinasi, bukan per fungsi.
  3. Membaca assembly terlebih dahulu ketika kode terasa lambat. Assembly adalah alat paling rinci sekaligus paling sulit dibaca, sedangkan pemeriksaan tipe dan LLVM IR biasanya sudah menjelaskan masalahnya dengan lebih sedikit usaha.

Ringkasan#

  • Julia mengompilasi fungsi melalui empat tahap, yaitu penguraian, penurunan, inferensi tipe, dan pembangkitan kode LLVM.
  • Satu definisi fungsi memiliki satu spesialisasi kode mesin untuk setiap kombinasi tipe argumen yang pernah digunakan.
  • Pemanggilan pertama menanggung kompilasi, sedangkan pemanggilan berikutnya memakai cache kompilasi dalam sesi, dan paket menggunakan pkgimage yang disimpan pada disk.
  • Makro @code_llvm memperlihatkan hasil inferensi tipe dalam bentuk instruksi, misalnya satu fadd double untuk penjumlahan dua Float64.

Referensi#