- Dalam benchmark bahasa kecil, CRuby berada di urutan ketiga dari belakang, tetapi ini menunjukkan bahwa bottleneck bukan ada pada keseluruhan bahasa, melainkan pada implementasi iterasi yang tidak bisa dilihat oleh YJIT
- Ruby 3.3.6 di M3 MacBook Pro mencatat Fibonacci 12,17 detik dan Loops 28,80 detik, sementara node.js berada di kisaran 1 detik untuk keduanya; di M2 MacBook Air, Ruby bahkan lebih lambat dengan 16,33 detik dan 33,43 detik
- Hanya dengan mengaktifkan
ruby --yjit, Fibonacci turun drastis dari 16,88 detik menjadi 2,06 detik, tetapi Loops hanya turun dari 33,43 detik menjadi 25,57 detik sehingga Range#each tetap menjadi bottleneck - Metode yang implementasi C-nya dipindahkan ke sisi Ruby, seperti Integer#times di Ruby 3.3 dan Array#each di Ruby 3.4, menjadi target optimasi YJIT sehingga benchmark iterasi membaik ke kisaran 13–14 detik
- CRuby memiliki hook
with_yjitsehingga saat YJIT aktif ia dapat memakai implementasi Ruby, dan saat nonaktif tetap mempertahankan implementasi C, menciptakan arus perubahan pustaka inti menuju kode Ruby yang ramah YJIT
Posisi Ruby yang Terlihat dari Benchmark
- Repo language comparison yang baru-baru ini dibagikan adalah repositori kolaboratif untuk membuat benchmark kecil berbagai bahasa
- Hasil CRuby di repositori ini menempatkannya di urutan ketiga dari belakang, hanya lebih cepat dari R dan Python
- Benchmark melihat dua sumbu
- Loops: menekankan performa perulangan, percabangan, dan operasi matematika dasar
- Fibonacci: menonjolkan overhead pemanggilan fungsi dan biaya rekursi
- Contoh Loops menjalankan nested loop sebanyak total 10.000 × 100.000 kali, yaitu 1 miliar iterasi
- Contoh Fibonacci tidak memakai implementasi yang dioptimalkan, melainkan naive Fibonacci yang sengaja dibuat sederhana
Nilai Awal dan Dampak Mengaktifkan YJIT
- Di M3 MacBook Pro, Ruby 3.3.6 berada di kisaran berikut
- Fibonacci: 12,17 detik
- Loops: 28,80 detik
- node.js: sedikit di atas 1 detik untuk kedua contoh
- Di M2 MacBook Air, benchmark yang sama berjalan lebih lambat
- Ruby Fibonacci: 16,33 detik
- Ruby Loops: 33,43 detik
- node.js Fibonacci: 1,36 detik
- node.js Loops: 2,07 detik
- Perintah eksekusi di repositori aslinya berbentuk
ruby ./code.rb 40tanpa YJIT - Saat dijalankan dengan
ruby --yjit ./code.rb 40, hasilnya berubah- Fibonacci: 2,06 detik
- Loops: 25,57 detik
- YJIT memberi dampak besar pada Fibonacci, tetapi peningkatannya terbatas pada Loops
Mengapa Range#each Kurang Menguntungkan bagi YJIT
- Loop inti pada kode Loops berbentuk Range#each seperti
(0...10_000).eachdan(0...100_000).each - Pada Ruby 3.4,
Range#eachmasih ditulis dalam C range.cdi CRuby menghubungkan kelasRangedan metodeeachke fungsi Crange_eachrange_eachbercabang ke berbagai jalur untuk menangani beragam bentuk range(0...).each(0...100).each("a"..."z").each
- Fungsi C itu sendiri bisa cepat, tetapi YJIT tidak bisa melihat bagian dalamnya
- Optimasi berhenti di titik pemanggilan fungsi, lalu berlanjut lagi setelah fungsi C mengembalikan hasil
- YJIT dapat membuat optimasi yang terspesialisasi untuk jalur eksekusi panas, tetapi implementasi C membatasi keuntungan ini
Perubahan saat Diganti ke Integer#times
- Di Ruby 3.3,
Integer#timesdiubah dari fungsi C menjadi metode Ruby - Strukturnya sederhana, berupa loop Ruby dengan
while i < self,yield i, dani = i.succ - Karena ditulis dalam kode Ruby, YJIT bisa menganalisis dan mengoptimalkan bagian dalamnya
- Jika iterasi range diganti menjadi
10_000.timesdan100_000.times, waktu Loops turun besarRange#each: 25,57 detikInteger#times: 13,66 detik
- Di lingkungan pengukuran lain,
Integer#timesturun hingga 9 detik, dan di Ruby 3.4 bahkan ada hasil 8 detik
Integer#succ dan Optimasi di Tingkat VM
- Implementasi
Integer#timesmemakaii.succalih-alihi += 1untuk operasi peningkatan nilai Integer#succadalah metode yang mengembalikan nilai berikutnya dari sebuah integer- Dalam bytecode Ruby VM,
i.succdirepresentasikan sebagai satu langkahopt_succ - Sebaliknya,
i += 1dipecah menjadi dua langkahputobject_INT2FIX_1_: mendorong integer 1 ke stack VMopt_plus: menjalankan operasi+
- Dalam program Ruby biasa hal ini nyaris tidak perlu dipikirkan, tetapi di tingkat JIT dan VM, selisih satu langkah pun memengaruhi performa saat diulang jutaan hingga miliaran kali
Array#each Juga Dipindah ke Implementasi Ruby di Ruby 3.4
- Di Ruby 3.4,
Array#eachjuga dipindahkan dari C ke implementasi sisi Ruby - Percobaan awal berupa kode Ruby sederhana, tetapi ada race condition yang terkait internal CRuby
- Implementasi akhirnya memakai
Primitivedi dalam kode RubyPrimitive.attr! :inline_block, :c_tracePrimitive.cexpr!ary_fetch_next
- Bentuk ini bukan Ruby murni sepenuhnya karena mencampurkan evaluasi kode C dan struktur Ruby, tetapi YJIT tetap bisa melakukan optimasi yang signifikan
- Jika array dibuat lebih dulu lalu diiterasi dengan
Array#each, performanya mendekatiInteger#timesRange#each: 25,57 detikInteger#times: 13,66 detikArray#each: 13,96 detik
Pengukuran Ruby Microbench
- Repo terpisah ruby_microbench membandingkan contoh asli dan berbagai bentuk iterasi Ruby
- Hasil di Ruby 3.4 dengan YJIT aktif adalah sebagai berikut
- Fibonacci: 2,19 detik
array#each: 14,02 detikrange#each: 26,61 detiktimes: 13,12 detikfor: 14,91 detikwhile: 37,10 detikloop do: 13,95 detik
- Jika YJIT dimatikan di Ruby 3.4, sebagian besar hasil menjadi lebih lambat
- Fibonacci: 16,49 detik
array#each: 34,29 detikrange#each: 33,88 detiktimes: 33,18 detikfor: 36,32 detikwhile: 37,14 detikloop do: 50,65 detik
- Contoh
whilelebih lambat dari perkiraan, dan ini mungkin masalah pada cara implementasinya for indanarray#eachhampir identik di tingkat bytecode Ruby VM sehingga performanya juga miripfor inpada dasarnya adalah gula sintaks yang di VM umumnya diubah menjadi pemanggilan#each
Perbandingan dengan Implementasi Ruby Lain
- Benchmark yang sama juga dijalankan pada berbagai implementasi Ruby lain
- Beberapa hasilnya adalah sebagai berikut
- TruffleRuby 24.1
- Fibonacci: 0,92 detik
array#each: 0,97 detikrange#each: 0,92 detiktimes: 2,39 detikfor: 2,06 detikwhile: 3,90 detikloop do: 0,77 detik
- MRuby 3.3
- Fibonacci: 28,83 detik
array#each: 144,65 detikrange#each: 126,40 detiktimes: 128,22 detik
- Artichoke
- Fibonacci: 19,71 detik
array#each: 236,10 detikrange#each: 214,55 detiktimes: 214,51 detik
- TruffleRuby 24.1
Eksperimen Monkey Patch Range#each ke Ruby
- Jika
Range#eachdi-monkey patch menjadi implementasi Ruby sederhana, performanya meningkat tajam - Implementasinya berupa bentuk sederhana yang memakai
begin,end,loop,yield, dani.succ - Nilai pengukurannya adalah sebagai berikut
- Implementasi C
Range#each: 25,57 detik - Implementasi Ruby
Range#each: 16,64 detik
- Implementasi C
- Implementasi ini adalah versi yang terlalu disederhanakan dan tidak menangani semua kasus
Range - Meski begitu, memindahkan dari C ke Ruby tetap memungkinkan YJIT mengoptimalkannya, dan membuka peningkatan performa dengan cara yang sulit atau bahkan mustahil direplikasi dalam kode C biasa
Pustaka Standar YJIT dan with_yjit
- Ruby Outperforms C karya Aaron Patterson menulis ulang ekstensi C untuk parsing GraphQL ke Ruby, dan berkat optimasi YJIT, kode Ruby itu menjadi lebih cepat daripada C
- Tim inti YJIT di CRuby sedang menerapkan pendekatan menghapus sebagian kode C dari fitur inti, atau memakai implementasi Ruby hanya saat YJIT aktif
- Blok
with_yjitmenerapkan implementasi Ruby tersebut hanya jika YJIT aktif- Jika YJIT nonaktif, implementasi C tetap dijalankan
- Jika YJIT aktif, versi Ruby yang bisa dioptimalkan YJIT akan dipakai
- Sejak Ruby 3.3, YJIT bisa diinisialisasi secara tertunda, dan kode
with_yjitakan menerapkan versi metode yang sesuai pada saat YJIT diaktifkan with_yjitadalah hook YJIT, dan setelah dipanggil akan dihapus dari runtime denganundef :with_yjit
Cara Melihat Kode Mesin yang Dihasilkan YJIT
- Jika CRuby dibangun dengan opsi
--enable-yjit=dev, kita bisa melihat disassembly kode mesin yang dihasilkan YJIT - Contoh build adalah sebagai berikut
./configure --enable-yjit=devmake install
- Saat menjalankan, gunakan flag
--yjit-dump-disasm./ruby --yjit --yjit-dump-disasm test.rb 40
Integer#timesdengani.succmuncul sebagaiopt_succdalam bytecode VM- Implementasi Rust milik YJIT melakukan hal berikut untuk
opt_succ- Menjaga agar receiver adalah Fixnum
- Jika bukan Fixnum, keluar ke jalur eksekusi lain
- Jika Fixnum, secara internal ia menambahkan 2, bukan 1, karena representasi tagged internal
- Jika terjadi overflow, ia keluar ke jalur lain
- Contoh ini menunjukkan bahwa optimasi JIT bekerja melintasi banyak lapisan, dari kode Ruby, kode C, bytecode VM, implementasi Rust, hingga kode mesin
Arah Optimasi CRuby
- Pengerjaan implementasi Ruby banyak dilakukan dalam bahasa yang lebih rendah tingkatannya daripada Ruby, dan di CRuby ini terutama berarti C serta sebagian Rust
- Lapisan seperti YJIT membuka kemungkinan memindahkan lebih banyak fitur bahasa ke kode Ruby biasa
- Jika lebih banyak fitur inti ditulis dalam Ruby, kontributor Ruby dapat menjadi lebih mudah berpartisipasi dalam CRuby
- Seperti Java, salah satu kemungkinan masa depan CRuby adalah struktur dengan inti kecil berlevel rendah dan sebagian besar bahasa ditulis menggunakan dirinya sendiri
- Dalam arus saat ini, yang terus menjadi penting bukan mempertahankan implementasi C apa adanya, melainkan memasukkan implementasi Ruby yang bisa dioptimalkan YJIT ke jalur inti
1 komentar
Komentar Hacker News
Contoh loop-nya tampak seperti benchmark aneh yang menjalankan loop bersarang 1 miliar kali, dan kalau dioptimalkan secara manual, sepertinya lebih dari 99% waktu eksekusi akan terkonsentrasi di bagian awal.
Jika melakukan analisis liveness per elemen array, tampaknya seluruh loop luar bisa dihilangkan; saya penasaran apakah ada compiler yang benar-benar melakukan analisis seperti ini.
Meski
utidak diketahui pada waktu kompilasi, loop dalamnya juga mungkin bisa diganti dengan beberapa instruksi saja, dan ini terlihat seperti optimisasi yang lebih standar yang mungkin segera dilakukan oleh pihak seperticlang.Data yang harus dilacak terlalu banyak, dan kemungkinan besar hanya berguna untuk kasus seperti kode keliru semacam ini.
Dulu saat membuat compiler AI, analisis liveness untuk elemen individual tensor sebenarnya pasti berguna, tetapi waktu kompilasi dan kebutuhan memorinya akan sangat tidak masuk akal sehingga tidak dilakukan.
result = ((u * (u - 1)) / 2 * (100000/u)) + (100000%u * (100000%u - 1) / 2) + r)Artikelnya membahas versi Ruby yang akan datang, dan karena penasaran saya melihat bahwa ruby 3.4.0 tampaknya akan rilis pada Natal ini, sementara ruby 3.5.0 pada Natal berikutnya.
Saya juga penasaran apa pengaruh JIT minimal milik Python pada loop seperti ini.
Python 3.13 harus dibangun dengan JIT diaktifkan, jadi akan menarik jika seseorang menjalankan benchmark di lingkungan build seperti itu: https://drew.silcock.dev/blog/everything-you-need-to-know-ab...
Seingat saya, peningkatan performa juga bisa masuk ke point release.
Integer#succsering dipakai bukan hanya karena performa, tetapi juga karena keterbacaan.Misalnya, di metode
#bytessebuah library UUID, saya memakainya dua kali agar saat membaca kode kepala saya tetap berada dalam “mode bit slicing”.Loop 16 kali dinyatakan sebagai
0xF.succ, dan bagian yang membagi dengan 256 di dalam loop dinyatakan sebagai0xFF.succ: https://github.com/okeeblow/DistorteD/blob/ba48d10/Globe%20G...0xF.succterasa lebih baik daripada0x10.Saya sedang berkontribusi ke https://github.com/bddicken/languages, dan setelah mencoba pendekatan Lua saya juga teringat TruffleRuby yang disebut di suatu tempat, tetapi saat menjalankan
main.rb, praktis tidak ada perbedaan besar antara TruffleRuby dan Ruby biasa.Kadang Ruby biasa justru lebih cepat daripada TruffleRuby.
Saya ingin memeriksa setelah perubahan apa benchmark kecepatan TruffleRuby di artikel itu muncul, dan kalau bisa diverifikasi, saya ingin menambahkannya sebagai commit ke repositori utama.
Kalau implementasi TruffleRuby benar-benar lebih cepat daripada Node.js dan mendekati Bun atau Go, itu cukup mengejutkan.
Untuk detailnya lihat https://github.com/oracle/truffleruby.
Ruby benar-benar sudah jauh lebih cepat, dan khususnya TruffleRuby lebih mengesankan.
Sayang, karena kombinasi itulah yang paling mungkin memberi dampak terbesar pada performa Ruby.
Saya tidak tahu bahwa YJIT ditulis dengan Rust; itu benar-benar menarik.
Kekurangannya, jika tidak ada toolchain atau platform yang sesuai, ia mungkin tidak diaktifkan saat build, tetapi itu tampak seperti kompromi yang cukup baik.
Ada juga repositori perbandingan bahasa yang sudah berjalan lebih lama dan mencakup lebih banyak bahasa: https://github.com/niklas-heer/speed-comparison
Label sumbu grafik dan label batang saling tumpang tindih, dan tidak ada garis kisi vertikal.
Akan lebih baik kalau berupa tabel HTML sederhana.
Menarik bahwa Python adalah bahasa paling lambat dalam benchmark, tetapi per Oktober 2024 tetap menjadi bahasa yang paling banyak digunakan di GitHub.
Tampaknya ada korelasi bahwa semakin lambat sebuah bahasa, semakin populer.
Saya cukup mendalami dan menyukai Rust, tetapi semua bahasa dan runtime, interpreter, compiler adalah alat.
Bergantung pada masalah dan pendekatan penyelesaiannya, kita membutuhkan kumpulan alat yang tepat, dan jika sebuah program mungkin hanya dijalankan beberapa kali, kecepatan eksekusi yang lambat tidak terlalu penting.
Program Python dan R sering berada dalam kasus seperti itu.
Secara umum Ruby memang lambat, tetapi sangat menyenangkan untuk coding, sehingga lebih menarik bagi pemula.
Solusi Advent of Code saya bentuknya sangat mirip sampai mengejutkan, jadi ini terlihat seperti perubahan yang bisa mengubah permainan.