Tiny JIT untuk Meningkatkan Kecepatan FFI
(railsatscale.com)- 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#bytesizemencatat 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
strlenhingga 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
strlenmilik C lewat FFI - Pemanggilan tidak langsung
x.bytesizedari metode RubyB.strlen - Memanggil ekstensi C yang dibuat dengan Ruby Gem
strlen - Memanggil langsung
str.bytesize
- Memanggil
- Hasil pada lingkungan Ruby 3.5.0dev, ARM64:
ruby-direct: 39.879M i/s, 25.08 ns/istrlen-cext: 30.661M i/s, 32.61 ns/i, 1.30 kali lebih lambat dari pemanggilan langsungstrlen-ruby: 28.697M i/s, 34.85 ns/i, 1.39 kali lebih lambat dari pemanggilan langsungstrlen-ffi: 15.682M i/s, 63.77 ns/i, 2.54 kali lebih lambat dari pemanggilan langsung
- Pemanggilan langsung
String#bytesizeadalah yang paling cepat, dan pemanggilan tidak langsung tambahan menambah overhead - Selisih antara
ruby-directdanstrlen-rubymenunjukkan biaya push/pop stack frame, dan menghilangkan overhead seperti ini adalah area yang ditangani dengan baik oleh kompiler JIT seperti YJIT - Kesenjangan antara
strlen-cextdanstrlen-ffimenunjukkan bahwa biaya tambahan saat memanggil fungsi native lewat FFI cukup besar
Cara mengganti pemanggilan FFI dengan JIT
- Pada saat
attach_function :strlen, [:string], :intdipanggil, informasi yang dibutuhkan sebenarnya sudah tersedia- Nama fungsi yang akan dipanggil:
strlen - Tipe argumen: string
- Tipe nilai balik: int
- Nama fungsi yang akan dipanggil:
- 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
- AArch64 gem: menghasilkan kode mesin ARM64
- Fisk gem: menghasilkan kode mesin x86_64
- JITBuffer gem: mengalokasikan memori yang dapat dieksekusi
- 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
strlendengan antarmuka yang mirip FFImodule Cextend FJITattach_function :strlen, [:string], :int
- Saat
attach_functiondipanggil, FJIT menghasilkan kode mesin yang membuka string Ruby, memanggilstrlenmilik 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/istrlen-fjit: 32.508M i/s, 30.76 ns/i, 1.29 kali lebih lambat dari pemanggilan langsungstrlen-cext: 29.778M i/s, 33.58 ns/i, 1.41 kali lebih lambat dari pemanggilan langsungstrlen-ruby: 28.851M i/s, 34.66 ns/i, 1.45 kali lebih lambat dari pemanggilan langsungstrlen-ffi: 15.629M i/s, 63.98 ns/i, 2.68 kali lebih lambat dari pemanggilan langsung
- Pemanggilan langsung
String#bytesizetetap 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
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
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
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
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
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
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...
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
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
eachRuby ditulis ulang kembali dalam Ruby: https://jpcamara.com/2024/12/01/speeding-up-ruby.html / https://bugs.ruby-lang.org/issues/20182Ada 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
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?
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?
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
arg1adalah string Ruby, fungsi konversi versi unsafe yang lebih cepat bisa dipakaiDalam 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
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 dihasilkantramp.cyang 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 sebelumnyahttps://github.com/libffi/libffi/blob/master/src/tramp.c