2 poin oleh GN⁺ 2025-02-14 | 1 komentar | Bagikan ke WhatsApp
  • Saat memanggil fungsi native dari Ruby, FFI memang praktis, tetapi pada benchmark strlen, overhead pemanggilannya lebih besar daripada ekstensi C sehingga menjadi bottleneck kinerja
  • Pada benchmark acuan, pemanggilan langsung String#bytesize mencatat 39.879M i/s, ekstensi C 30.661M i/s, pemanggilan tidak langsung Ruby 28.697M i/s, dan FFI 15.682M i/s sehingga menjadi yang paling lambat
  • Ide utamanya adalah membuat kode mesin untuk pemanggilan fungsi eksternal saat runtime dengan memanfaatkan nama fungsi, tipe argumen, dan tipe nilai balik yang sudah diketahui pada saat attach_function
  • Proof of concept FJIT pada lingkungan Ruby 3.5.0dev ARM64 berhasil meningkatkan pemanggilan strlen hingga 32.508M i/s, lebih dari 2 kali lebih cepat daripada FFI dan bahkan sedikit lebih cepat daripada ekstensi C
  • Saat ini masih ada batasan seperti ARM64, satu argumen dan satu nilai balik, tipe yang terbatas, ketergantungan pada --rjit --rjit-disable, serta commit Ruby head tertentu, sehingga implementasinya masih perlu diperluas sebelum bisa dipakai secara nyata

Titik di mana FFI melambat di Ruby

  • Arah dasarnya adalah menulis kode Ruby sebanyak mungkin, dan hanya memanggil kode native bila benar-benar diperlukan
  • YJIT dapat mengoptimalkan kode Ruby, tetapi tidak bisa mengoptimalkan kode C
  • Jika membutuhkan library native, pendekatan idealnya adalah menaruh pembungkus ekstensi C yang tipis di sekitar fungsi yang dibutuhkan, sementara sebagian besar pekerjaan tetap ditangani di Ruby
  • API sederhana seperti ini cocok dengan FFI, tetapi FFI yang ada tidak dapat menyamai kinerja ekstensi C

Benchmark berbasis strlen

  • Ada empat objek perbandingan
    • Memanggil strlen milik C lewat FFI
    • Pemanggilan tidak langsung x.bytesize dari metode Ruby B.strlen
    • Memanggil ekstensi C yang dibuat dengan Ruby Gem strlen
    • Memanggil langsung str.bytesize
  • Hasil pada lingkungan Ruby 3.5.0dev, ARM64:
    • ruby-direct: 39.879M i/s, 25.08 ns/i
    • strlen-cext: 30.661M i/s, 32.61 ns/i, 1.30 kali lebih lambat dari pemanggilan langsung
    • strlen-ruby: 28.697M i/s, 34.85 ns/i, 1.39 kali lebih lambat dari pemanggilan langsung
    • strlen-ffi: 15.682M i/s, 63.77 ns/i, 2.54 kali lebih lambat dari pemanggilan langsung
  • Pemanggilan langsung String#bytesize adalah yang paling cepat, dan pemanggilan tidak langsung tambahan menambah overhead
  • Selisih antara ruby-direct dan strlen-ruby menunjukkan biaya push/pop stack frame, dan menghilangkan overhead seperti ini adalah area yang ditangani dengan baik oleh kompiler JIT seperti YJIT
  • Kesenjangan antara strlen-cext dan strlen-ffi menunjukkan bahwa biaya tambahan saat memanggil fungsi native lewat FFI cukup besar

Cara mengganti pemanggilan FFI dengan JIT

  • Pada saat attach_function :strlen, [:string], :int dipanggil, informasi yang dibutuhkan sebenarnya sudah tersedia
    • Nama fungsi yang akan dipanggil: strlen
    • Tipe argumen: string
    • Tipe nilai balik: int
  • Dengan informasi ini, kita bisa menghasilkan kode mesin yang membuka nilai Ruby menjadi tipe native, memanggil fungsi eksternal, lalu membungkus nilai baliknya kembali menjadi objek Ruby
  • Ada tiga komponen yang dibutuhkan
  • Menghasilkan kode mesin saja tidak cukup; Ruby juga harus bisa melompat ke kode mesin tersebut agar dapat melewati overhead FFI

Jalur yang memanfaatkan RJIT

  • RJIT adalah kompiler JIT untuk Ruby yang ditulis dalam Ruby dan didistribusikan bersama Ruby
  • Struktur internalnya mirip dengan YJIT, tetapi karena tidak ditujukan untuk penggunaan produksi, RJIT tidak seterkenal YJIT
  • Kokubun mengajukan permintaan fitur untuk mengekstrak RJIT menjadi Gem
  • Usulan ini menyediakan dua fondasi agar lebih mudah membuat kompiler JIT Ruby pihak ketiga
    • Memisahkan RJIT menjadi Gem
    • Membuat tipe internal Ruby sebagai struktur data Ruby, sehingga JIT pihak ketiga bisa mendapatkan informasi yang diperlukan untuk membungkus dan membuka tipe data Ruby
  • Perubahan lainnya adalah selalu mengeksekusi jika ada pointer fungsi entri JIT
    • Jika JIT pihak ketiga mendaftarkan kode mesin, Ruby dapat otomatis melompat ke kode tersebut
  • Dengan dua elemen ini, kita bisa membuat kompiler JIT kecil dengan tujuan tunggal yang berperan sebagai antarmuka FFI

Proof of concept FJIT

  • Proof of concept FJIT, singkatan dari “FFI JIT”, menghasilkan kode mesin saat runtime untuk memanggil fungsi eksternal
  • Contohnya menempelkan strlen dengan antarmuka yang mirip FFI
    • module C
    • extend FJIT
    • attach_function :strlen, [:string], :int
  • Saat attach_function dipanggil, FJIT menghasilkan kode mesin yang membuka string Ruby, memanggil strlen milik C, lalu mengembalikan panjang string sebagai objek Ruby

Hasil benchmark FJIT

  • Hasil pada lingkungan Ruby 3.5.0dev, +RJIT +PRISM, ARM64:
    • ruby-direct: 41.907M i/s, 23.86 ns/i
    • strlen-fjit: 32.508M i/s, 30.76 ns/i, 1.29 kali lebih lambat dari pemanggilan langsung
    • strlen-cext: 29.778M i/s, 33.58 ns/i, 1.41 kali lebih lambat dari pemanggilan langsung
    • strlen-ruby: 28.851M i/s, 34.66 ns/i, 1.45 kali lebih lambat dari pemanggilan langsung
    • strlen-ffi: 15.629M i/s, 63.98 ns/i, 2.68 kali lebih lambat dari pemanggilan langsung
  • Pemanggilan langsung String#bytesize tetap yang paling cepat
  • Kode mesin yang dihasilkan FJIT menjadi yang tercepat kedua, dan mencatat hasil sedikit lebih baik daripada ekstensi C strlen
  • FJIT lebih dari 2 kali lebih cepat daripada pemanggilan FFI, dan bahkan lebih cepat daripada pemanggilan tidak langsung Ruby
  • Hasil ini mengisyaratkan bahwa kita bisa mendapatkan kecepatan setara atau lebih baik daripada ekstensi C sambil tetap mempertahankan pendekatan “menulis Ruby sebanyak mungkin”

Batasan yang masih tersisa sebelum penerapan nyata

  • Kompiler JIT proof of concept saat ini terbatas pada platform ARM64
    • Untuk diperluas menjadi implementasi nyata, backend x86_64 perlu ditambahkan
  • Belum semua tipe argumen dan tipe nilai balik didukung
    • Dukungan untuk semua tipe argumen dinilai memungkinkan, dan beban kerjanya tampaknya tidak berlebihan
  • Saat ini hanya fungsi yang menerima satu argumen dan mengembalikan satu nilai yang bisa ditangani
  • Untuk sekarang, Ruby harus dijalankan dengan flag --rjit --rjit-disable
    • Jika fitur dari Kokubun masuk, syarat ini diperkirakan tidak lagi diperlukan
  • Proof of concept ini hanya berjalan pada Ruby head saat itu
  • Berdasarkan pembaruan terbaru, RJIT telah dihapus dari Ruby head, sehingga untuk menjalankan skripnya, Ruby harus di-checkout ke commit f32d5071b7b01f258eb45cf533496d82d5c0f6a1

1 komentar

 
GN⁺ 2025-02-14
Opini Hacker News
  • Saya banyak menangani FFI agar constraint solver Java Timefold bisa memanggil fungsi yang didefinisikan di CPython, dan sebagian besar masalah performa muncul saat bolak-balik lewat proxy antara bahasa host dan bahasa eksternal
    Jika melakukan pemanggilan FFI langsung lewat JNI atau antarmuka eksternal baru, kecepatannya hampir setara dengan pemanggilan langsung metode Java, tetapi garbage collector CPython dan Java tidak begitu cocok satu sama lain, sehingga sinkronisasinya membutuhkan semacam ilmu hitam
    Sebaliknya, proxy seperti JPype atau GraalPy perlu mengonversi parameter dan nilai balik, dan bahkan bisa memicu pemanggilan FFI tambahan ke arah sebaliknya. Jika objek CPython diteruskan ke Java, Java membuat proxy untuk objek itu, lalu jika proxy tersebut diteruskan kembali ke CPython, ia tidak diurai, melainkan dibuatkan proxy dari proxy
    Akibatnya, proxy JPype 1402% lebih lambat daripada memanggil CPython langsung lewat FFI, dan proxy GraalPy 453% lebih lambat
    Pada akhirnya, kami menerjemahkan bytecode CPython menjadi bytecode Java dan membuat struktur data Java yang sesuai dengan kelas CPython yang digunakan, sehingga menjadi 100 kali lebih cepat dibanding proxy. Sebagai tambahan, bytecode CPython sangat tidak stabil, dokumentasinya minim, dan karakteristik VM-nya juga rumit, sehingga sulit dipetakan langsung ke bytecode lain; sebaiknya jangan mencoba menerjemahkan atau membacanya
    Detailnya sudah saya rangkum di tulisan ini: https://timefold.ai/blog/java-vs-python-speed

    • Berbicara tanpa pengalaman langsung, FFI dari Python dan Java masing-masing ke C tampak jauh lebih baik, jadi saya pikir menghubungkan keduanya dengan C bridge kecil mungkin bisa menjadi solusi umum
    • cgo is not Go juga layak dijadikan referensi
      Kode Go dan kode C harus menyepakati cara berbagi resource seperti address space, signal handler, dan slot TLS thread, tetapi dalam praktiknya ini lebih berarti Go harus mengakali asumsi-asumsi kode C. Kode C bisa saja berasumsi berjalan hanya di satu thread, atau sama sekali tidak siap untuk lingkungan multithread
      Karena C tidak tahu calling convention Go atau stack yang dapat bertumbuh, untuk memanggil kode C, Go harus mencatat detail stack goroutine, beralih ke stack C, lalu menjalankan kode C yang tidak tahu bagaimana ia dipanggil maupun keseluruhan runtime Go
      Baik membuat binding atau membungkus kode C dengan Python, Java yang memakai JNI, bahasa yang memakai libffi, maupun Go yang memakai cgo, pada akhirnya kita hidup di dunia C
      https://dave.cheney.net/2016/01/18/cgo-is-not-go / https://archive.vn/GZoMK
    • Saya penasaran, dalam kasus seperti ini, pendekatan komunikasi antarproses seperti queue, file, atau HTTP kira-kira cocoknya di bagian mana
  • Berkat Rails At Scale dan tulisan-tulisan byroot, sekarang benar-benar waktu yang bagus untuk membaca tulisan yang membahas struktur internal dan performa Ruby secara mendalam. Ditambah perbaikan terbaru pada Ruby dan Rails, ini juga masa yang cukup baik untuk menjadi developer Ruby

    • Saya tidak yakin itu benar. Ruby tampaknya sedang menurun https://www.tiobe.com/tiobe-index/ruby/
      Ruby masih populer untuk jenis aplikasi tertentu, tetapi masa jayanya tampaknya sudah cukup lewat; perbaikan terbaru memang bagus, tetapi saya tidak tahu apakah JIT masih begitu menarik secara teknis pada 2025
  • Pendekatan “alih-alih memanggil library pihak ketiga, bukankah kita bisa langsung melakukan JIT pada kode yang dibutuhkan untuk memanggil fungsi eksternal?” tampaknya dekat dengan dasar LuaJIT FFI: https://luajit.org/ext_ffi.html
    Karena itu FFI milik LuaJIT sepertinya sangat cepat

  • Saya kurang memahami pernyataan “tulislah sebanyak mungkin dalam Ruby. Khususnya, YJIT bisa mengoptimalkan kode Ruby, tetapi tidak bisa mengoptimalkan kode C”
    Bukankah Ruby bahasa yang cukup lambat? Kalau turun ke native, rasanya orang ingin membuat sebanyak mungkin bagian sebagai kode native

    • Ada drama kecil seperti ini pada masa Java mulai memiliki JIT yang layak
      Dalam satu rilis besar, kode Java yang menangani perilaku suatu elemen UI ditemukan menjadi bottleneck, lalu pada rilis besar berikutnya ditulis ulang dalam C
      Setelah JIT benar-benar mulai berguna, overhead FFI menjadi lebih besar daripada selisih antara kode C yang dituning manual dan kode yang dihasilkan JIT, sehingga pada rilis besar berikutnya implementasinya dikembalikan lagi menjadi Java murni
      Untuk bahasa generasi itu, FFI Java tergolong cukup cepat, tetapi beberapa rilis kemudian diganti dengan cara yang lebih baik; saat itu saya sudah tidak banyak mengikuti kode UI Java, jadi tidak terus mengikutinya lagi. Pada periode yang sama, mereka juga sedang merapikan antarmuka antara kode per platform dan kode UI Java umum, jadi saya tidak yakin akhirnya bagaimana
      Dalam pekerjaan semacam ini, kita harus terus memperhatikan efek jungkat-jungkit seperti ini. Perlu dipertimbangkan apakah lebih baik menunggu beberapa milestone agar pekerjaan tuning manual berkurang, atau apakah memang diperlukan sekarang juga karena alasan politis maupun teknis
    • Di titik ini JIT muncul. Secara ideal, JIT bisa mengoptimalkan ulang kode ke kondisi yang lebih baik
      Untuk pekerjaan yang dijalankan sekali lalu selesai mungkin tidak efisien, tetapi pada workload desktop/server yang berumur panjang, ada imbal hasil dari sudut pandang aplikasi secara keseluruhan
      Misalnya, JIT Dalvik cukup lemah sehingga memanggil fungsi matematika lewat C lebih cepat, tetapi setelah ART hal itu tidak lagi perlu, dan JIT bisa melampaui biaya pemanggilan C
      https://developer.android.com/reference/android/util/FloatMa...
    • Dalam bahasa terkelola yang memiliki JIT atau compiler AOT, sering kali ideal untuk menulis banyak kode dalam bahasa tersebut. Dengan begitu, inlining dan optimasi lain yang tidak mungkin dilakukan saat melewati batas pemanggilan C menjadi mungkin
      Alur semacam ini kadang disebut “self-hosting”, dan browser juga banyak memanfaatkannya dengan memindahkan bagian-bagian yang semula mungkin ditulis dalam C/C++ ke JavaScript yang memiliki privilese. Bagian yang cukup besar dari standard library ternyata bukan kode native
    • FFI menciptakan batas yang buram dan tidak bisa dioptimalkan di antara kode. Kode yang sering bolak-balik seperti ini biayanya besar
      Bahkan pada bahasa seperti C# yang jauh lebih cepat dan biaya interoperabilitasnya nyaris 0, tetap ada biaya pemanggilan, dan kadang ada juga biaya perubahan flag status VM atau transisi garbage collection
      Jika Ruby YJIT mulai menjadi faktor yang dapat diukur, aturan di atas juga akan semakin penting
    • Ada tulisan bagus dari JPCamara yang menjelaskan alasan metode each Ruby ditulis ulang kembali dalam Ruby: https://jpcamara.com/2024/12/01/speeding-up-ruby.html / https://bugs.ruby-lang.org/issues/20182
      Ada juga tulisan bonus dari tender love: https://railsatscale.com/2023-08-29-ruby-outperforms-c/
      Ringkasnya, JIT menang
  • FFI adalah Foreign Function Interface, yaitu cara memanggil C dari Ruby

    • Pendekatan yang sepenuhnya aman dan waras adalah menulis kode C yang saat dijalankan menerima data lewat command line dan memuntahkan hasilnya ke command line atau halaman memori
      Lalu dari Ruby, jalankan program C itu di terminal dengan flag atau data, maka Ruby bisa menjalankan kode C
  • Saya tidak paham mengapa ini harus dikompilasi JIT. Kalau bisa ditulis dalam C, bukankah bisa saja dikompilasi saat load?

    • Saya tidak tahu maksudnya apa yang bisa ditulis dalam C. Library FFI memungkinkan Ruby melakukan binding dinamis ke metode library dan menjalankannya tanpa menulis ekstensi native
      Ini sangat meningkatkan produktivitas, dan memungkinkan kode yang sama dibagikan di CRuby, JRuby, dan TruffleRuby
      Jika semua binding bisa diketahui secara statis saat boot, kita bisa menulis stub dan memasukkannya ke tabel metode, tetapi itu tetap terjadi saat runtime, jadi termasuk JIT. Selain itu, karena tidak bisa beradaptasi dengan tipe yang mengalir melalui sistem, ia harus konservatif dalam nilai yang diterima maupun optimasinya, dan itu mendekati apa yang dilakukan libffi saat ini
      Pendekatan AOT adalah menulis ekstensi native
  • Sedikit menyimpang, tetapi biasanya saya lebih menghindari gem yang memakai FFI daripada FFI itu sendiri. Kompilasi sering kali terlalu merepotkan, sehingga lebih mudah membangun langsung tanpa melewati lapisan perantara Rubygems/bundler

  • Sedikit terkait, library ini memakai JVMCI untuk menghasilkan kode arm64/amd64 secara on-the-fly agar bisa memanggil library native tanpa JNI: https://github.com/apangin/nalim

  • Bukankah ini persis yang dilakukan libffi?

    • libffi itu lambat, dan setahu saya tidak melakukan JIT
      Di libffi, kita membuat objek deskriptor untuk sebuah fungsi, dan struktur data runtime ini merepresentasikan tipe argumen serta nilai balik
      Saat melakukan panggilan FFI, kita harus meneruskan array pointer ke nilai-nilai yang ingin dikirim beserta deskriptornya. Di dalamnya, kemungkinan besar ia menelusuri array nilai dan deskriptor bersama-sama, menempatkan nilai di stack sesuai tipenya, lalu setelah fungsi selesai mengambil nilai berdasarkan tipe baliknya. Di berbagai bagian proses ini, besar kemungkinan ada percabangan per tipe
      Kalaupun mekanisme pemanggilan libffi di-JIT, pekerjaan menyiapkan array argumen tetap lambat. Ini kurang langsung dibanding FFI JIT yang mengakses argumen secara langsung tanpa melalui array perantara
      Kode FFI JIT menerima nilai argumen secara langsung, mengonversinya dari tipe Ruby ke tipe C, menaruh setiap nilai ke posisi yang benar di stack atau register dengan kode inline, lalu memanggil fungsi dan mengubah nilai baliknya menjadi tipe Ruby. Pada dasarnya mirip seperti menulis kode ekstensi dengan tangan
      Jika ada inferensi tipe, pemeriksaan tipe dalam kode konversi bisa dilewati. Misalnya jika ada jaminan bahwa arg1 adalah string Ruby, fungsi konversi versi unsafe yang lebih cepat bisa dipakai
      Dalam kasus terburuk, kode JIT cukup mencerminkan kira-kira tipe Ruby saja, dan tidak memerlukan array atau daftar terkait argumen. Karena tipe C apa yang akan dikonversi sudah di-hardcode di dalam kode, tidak perlu menelusuri struktur data yang mendeskripsikan sisi C saat runtime
    • Karena libffi tidak tahu apa itu Ruby, ia tidak bisa tahu cara meng-unbox tipe Ruby
      Keunggulan tulisan ini adalah, berdasarkan informasi yang diberikan pengguna saat memanggil attach_function, kode untuk unboxing tipe pada dasarnya di-cache di dalam kode mesin yang dihasilkan
    • libffi tidak melakukan JIT pada panggilan FFI, dan tetap harus menempatkan nilai argumen secara langsung. Misalnya untuk argumen string, kode untuk mengonversi objek string Ruby menjadi pointer string C harus ditulis terpisah. Dan libffi memang cukup lambat
      tramp.c yang ditautkan di komentar saudara adalah untuk “FFI terbalik”, yaitu mengekspos pekerjaan pengguna dinamis sebagai pointer fungsi, dan JIT di sana hanya sebatas total 3 instruksi untuk memanggil kode yang sudah dikompilasi sebelumnya
    • Saya kira saya tahu apa yang dilakukan libffi, tadinya saya pikir ia bermain-main dengan sesuatu seperti GOT. Tapi sepertinya kamu benar
      https://github.com/libffi/libffi/blob/master/src/tramp.c