2 poin oleh GN⁺ 2024-12-06 | 1 komentar | Bagikan ke WhatsApp
  • 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_yjit sehingga 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 40 tanpa 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).each dan (0...100_000).each
  • Pada Ruby 3.4, Range#each masih ditulis dalam C
  • range.c di CRuby menghubungkan kelas Range dan metode each ke fungsi C range_each
  • range_each bercabang 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#times diubah dari fungsi C menjadi metode Ruby
  • Strukturnya sederhana, berupa loop Ruby dengan while i < self, yield i, dan i = i.succ
  • Karena ditulis dalam kode Ruby, YJIT bisa menganalisis dan mengoptimalkan bagian dalamnya
  • Jika iterasi range diganti menjadi 10_000.times dan 100_000.times, waktu Loops turun besar
    • Range#each: 25,57 detik
    • Integer#times: 13,66 detik
  • Di lingkungan pengukuran lain, Integer#times turun hingga 9 detik, dan di Ruby 3.4 bahkan ada hasil 8 detik

Integer#succ dan Optimasi di Tingkat VM

  • Implementasi Integer#times memakai i.succ alih-alih i += 1 untuk operasi peningkatan nilai
  • Integer#succ adalah metode yang mengembalikan nilai berikutnya dari sebuah integer
  • Dalam bytecode Ruby VM, i.succ direpresentasikan sebagai satu langkah opt_succ
  • Sebaliknya, i += 1 dipecah menjadi dua langkah
    • putobject_INT2FIX_1_: mendorong integer 1 ke stack VM
    • opt_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#each juga dipindahkan dari C ke implementasi sisi Ruby
  • Percobaan awal berupa kode Ruby sederhana, tetapi ada race condition yang terkait internal CRuby
  • Implementasi akhirnya memakai Primitive di dalam kode Ruby
    • Primitive.attr! :inline_block, :c_trace
    • Primitive.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 mendekati Integer#times
    • Range#each: 25,57 detik
    • Integer#times: 13,66 detik
    • Array#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 detik
    • range#each: 26,61 detik
    • times: 13,12 detik
    • for: 14,91 detik
    • while: 37,10 detik
    • loop 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 detik
    • range#each: 33,88 detik
    • times: 33,18 detik
    • for: 36,32 detik
    • while: 37,14 detik
    • loop do: 50,65 detik
  • Contoh while lebih lambat dari perkiraan, dan ini mungkin masalah pada cara implementasinya
  • for in dan array#each hampir identik di tingkat bytecode Ruby VM sehingga performanya juga mirip
    • for in pada 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 detik
      • range#each: 0,92 detik
      • times: 2,39 detik
      • for: 2,06 detik
      • while: 3,90 detik
      • loop do: 0,77 detik
    • MRuby 3.3
      • Fibonacci: 28,83 detik
      • array#each: 144,65 detik
      • range#each: 126,40 detik
      • times: 128,22 detik
    • Artichoke
      • Fibonacci: 19,71 detik
      • array#each: 236,10 detik
      • range#each: 214,55 detik
      • times: 214,51 detik

Eksperimen Monkey Patch Range#each ke Ruby

  • Jika Range#each di-monkey patch menjadi implementasi Ruby sederhana, performanya meningkat tajam
  • Implementasinya berupa bentuk sederhana yang memakai begin, end, loop, yield, dan i.succ
  • Nilai pengukurannya adalah sebagai berikut
    • Implementasi C Range#each: 25,57 detik
    • Implementasi Ruby Range#each: 16,64 detik
  • 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_yjit menerapkan 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_yjit akan menerapkan versi metode yang sesuai pada saat YJIT diaktifkan
  • with_yjit adalah hook YJIT, dan setelah dipanggil akan dihapus dari runtime dengan undef :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=dev
    • make install
  • Saat menjalankan, gunakan flag --yjit-dump-disasm
    • ./ruby --yjit --yjit-dump-disasm test.rb 40
  • Integer#times dengan i.succ muncul sebagai opt_succ dalam 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

 
GN⁺ 2024-12-06
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 u tidak 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 seperti clang.

    • Compiler biasanya tidak melakukan analisis liveness terhadap elemen individual array.
      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.
    • Dalam kebanyakan kasus, ini bisa dihitung dalam bentuk tertutup:
      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...

    • Ruby selalu dirilis saat Natal, jadi jadwalnya mudah diprediksi dan terasa lucu.
      Seingat saya, peningkatan performa juga bisa masuk ke point release.
  • Integer#succ sering dipakai bukan hanya karena performa, tetapi juga karena keterbacaan.
    Misalnya, di metode #bytes sebuah 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 sebagai 0xFF.succ: https://github.com/okeeblow/DistorteD/blob/ba48d10/Globe%20G...

    • Saya penasaran mengapa dalam kasus ini 0xF.succ terasa lebih baik daripada 0x10.
  • 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 TruffleRuby, perlu memperhitungkan waktu startup dan waktu untuk mencapai performa puncak, dan ini bergantung pada konfigurasi runtime native/JVM.
      Untuk detailnya lihat https://github.com/oracle/truffleruby.
  • Ruby benar-benar sudah jauh lebih cepat, dan khususnya TruffleRuby lebih mengesankan.

    • Itu milik Oracle: https://github.com/oracle/truffleruby
    • Rails tidak berjalan di Truffle, dan sejauh pemahaman saya tampaknya juga akan tetap sulit untuk sementara waktu.
      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.

    • Awalnya ditulis dalam C lalu di-porting ke Rust, dan sepertinya itu keputusan yang bagus.
      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

    • Ini repositori perbandingan bahasa lainnya, tetapi cara penyajiannya sulit dibaca.
      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.

    • Untuk membandingkan ulang, waktu kompilasi juga harus disertakan dan diamortisasi sesuai jumlah eksekusi yang diperkirakan untuk build tersebut.
      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.
    • Mirip makanan: kalau diberi gula di atasnya, orang jauh lebih menyukainya.
      Secara umum Ruby memang lambat, tetapi sangat menyenangkan untuk coding, sehingga lebih menarik bagi pemula.
    • Bahasa yang lambat lebih high-level, sehingga lebih mudah digunakan.
    • Saya penasaran apakah korelasi itu tetap berlaku jika melihat 20 bahasa terpopuler.
  • Solusi Advent of Code saya bentuknya sangat mirip sampai mengejutkan, jadi ini terlihat seperti perubahan yang bisa mengubah permainan.