FFI hızını artırmak için Tiny JIT’ler
(railsatscale.com)- Ruby’de yerel fonksiyonları çağırırken FFI kullanışlıdır, ancak
strlenbenchmark’ında çağrı ek yükü C eklentilerine göre daha büyük olduğundan performans darboğazı hâline gelir - Temel benchmark’ta
String#bytesizedoğrudan çağrısı 39.879M i/s, C eklentisi 30.661M i/s, Ruby dolaylı çağrısı 28.697M i/s, FFI ise 15.682M i/s ile en yavaş sonuçtu - Ana fikir,
attach_functionanında zaten bilinen fonksiyon adı, argüman türleri ve dönüş türünü kullanarak çalışma zamanında harici fonksiyon çağrısı için makine kodu üretmek - Kavram kanıtı FJIT, Ruby 3.5.0dev ARM64 ortamında
strlençağrısını 32.508M i/s’ye çıkararak FFI’dan 2 kattan fazla hızlı, C eklentisinden de biraz hızlı oldu - Şu anda ARM64, tek argüman/tek dönüş, sınırlı türler,
--rjit --rjit-disableve belirli bir Ruby head commit’ine bağımlılık gibi kısıtlar olduğundan gerçek kullanım için uygulamanın genişletilmesi gerekiyor
Ruby’de FFI’ın yavaşladığı nokta
- Temel yaklaşım, Ruby kodunu mümkün olduğunca çok kullanmak ve yerel kodu yalnızca gerçekten gerektiğinde çağırmaktır
- YJIT Ruby kodunu optimize edebilir, ancak C kodunu optimize edemez
- Yerel bir kütüphane gerekiyorsa ideal yöntem, gerçek fonksiyonun etrafına ince bir C eklentisi sarmalayıcısı koymak ve işin büyük kısmını Ruby’de yapmaktır
- Bu tür basit API’ler FFI ile iyi uyuşur, ancak mevcut FFI C eklentisiyle aynı performansı vermez
strlen temel benchmark’ı
- Karşılaştırılan dört seçenek var
- FFI ile C’nin
strlenfonksiyonunu çağırma - Ruby metodu
B.strleniçindenx.bytesizedolaylı çağrısı strlenRuby Gem’iyle oluşturulmuş C eklentisi çağrısıstr.bytesizedoğrudan çağrısı
- FFI ile C’nin
- Ruby 3.5.0dev, ARM64 ortamındaki sonuçlar:
ruby-direct: 39.879M i/s, 25.08 ns/istrlen-cext: 30.661M i/s, 32.61 ns/i, doğrudan çağrıdan 1.30 kat yavaşstrlen-ruby: 28.697M i/s, 34.85 ns/i, doğrudan çağrıdan 1.39 kat yavaşstrlen-ffi: 15.682M i/s, 63.77 ns/i, doğrudan çağrıdan 2.54 kat yavaş
String#bytesizedoğrudan çağrısı en hızlısı; ek dolaylı çağrı ise ek yük getirirruby-directilestrlen-rubyarasındaki fark, stack frame push/pop maliyetini gösterir; bu tür ek yüklerin ortadan kaldırılması YJIT gibi JIT derleyicilerin iyi yaptığı bir alandırstrlen-cextilestrlen-ffiarasındaki fark, FFI ile yerel fonksiyon çağırırken ek maliyetin büyük olduğunu ortaya koyar
FFI çağrısını JIT ile değiştirme yöntemi
attach_function :strlen, [:string], :intçağrısı anında gerekli bilgiler zaten vardır- Çağrılacak fonksiyon adı:
strlen - Argüman türü: string
- Dönüş türü: int
- Çağrılacak fonksiyon adı:
- Bu bilgiler kullanılarak Ruby değerlerini yerel türlere açan, harici fonksiyonu çağıran ve dönüş değerini yeniden Ruby nesnesiyle saran makine kodu üretilebilir
- Gerekli bileşenler üç tanedir
- AArch64 gem: ARM64 makine kodu üretimi
- Fisk gem: x86_64 makine kodu üretimi
- JITBuffer gem: çalıştırılabilir bellek ayırma
- Yalnızca makine kodu üretmek yeterli değildir; Ruby’nin bu makine koduna atlayabilmesi gerekir ki FFI ek yükü atlanabilsin
RJIT’ten yararlanan yol
- RJIT, Ruby ile yazılmış ve Ruby ile birlikte dağıtılan bir Ruby JIT derleyicisidir
- İç yapısı YJIT’e benzese de üretim kullanımı hedeflenmediği için YJIT kadar yaygın bilinmez
- Kokubun, RJIT’i Gem olarak ayırmaya yönelik bir özellik isteği sundu
- Bu öneri, üçüncü taraf Ruby JIT derleyicileri oluşturmayı kolaylaştırmak için iki temel sağlar
- RJIT’i Gem olarak ayırmak
- Ruby iç türlerini Ruby veri yapıları olarak üretmek; böylece üçüncü taraf JIT’lerin Ruby veri türlerini sarmak ve açmak için gerekli bilgileri alabilmesini sağlamak
- Bir diğer değişiklik de JIT giriş fonksiyonu işaretçisi varsa bunun her zaman çalıştırılmasıdır
- Üçüncü taraf bir JIT makine kodunu kaydettiğinde Ruby otomatik olarak o koda atlayabilir
- Bu iki unsur varsa FFI arayüzü görevi gören küçük ve tek amaçlı bir JIT derleyicisi oluşturulabilir
FJIT kavram kanıtı
- FJIT kavram kanıtı, “FFI JIT”in kısaltmasıdır ve çalışma zamanında harici fonksiyonları çağıran makine kodu üretir
- Örnek, FFI’a benzer bir arayüzle
strlen’i bağlarmodule Cextend FJITattach_function :strlen, [:string], :int
attach_functionçağrıldığında FJIT, Ruby string’ini açan, C’ninstrlenfonksiyonunu çağıran ve string uzunluğunu Ruby nesnesi olarak döndüren makine kodu üretir
FJIT benchmark sonuçları
- Ruby 3.5.0dev,
+RJIT +PRISM, ARM64 ortamındaki sonuçlar:ruby-direct: 41.907M i/s, 23.86 ns/istrlen-fjit: 32.508M i/s, 30.76 ns/i, doğrudan çağrıdan 1.29 kat yavaşstrlen-cext: 29.778M i/s, 33.58 ns/i, doğrudan çağrıdan 1.41 kat yavaşstrlen-ruby: 28.851M i/s, 34.66 ns/i, doğrudan çağrıdan 1.45 kat yavaşstrlen-ffi: 15.629M i/s, 63.98 ns/i, doğrudan çağrıdan 2.68 kat yavaş
String#bytesizedoğrudan çağrısı hâlâ en hızlısı- FJIT’in ürettiği makine kodu ikinci en hızlı sonuçtu ve
strlenC eklentisinden biraz daha iyi çıktı - FJIT, FFI çağrısından 2 kattan fazla hızlıdır ve Ruby dolaylı çağrısından da hızlıdır
- Bu sonuç, C eklentisiyle aynı ya da daha iyi hız elde ederken “Ruby’yi mümkün olduğunca çok yazma” yaklaşımının korunabileceğine işaret eder
Gerçek uygulama öncesinde kalan kısıtlar
- Kavram kanıtı JIT derleyicisi şu anda ARM64 platformuyla sınırlı
- Gerçek bir uygulamaya genişletmek için x86_64 backend’i eklenmeli
- Tüm argüman türleri ve dönüş türleri henüz işlenmiyor
- Tüm argüman türlerini desteklemek mümkün görülüyor ve iş yükünün aşırı olmayacağı düşünülüyor
- Şu anda yalnızca tek argüman alan ve tek değer döndüren fonksiyonları işliyor
- Şimdilik Ruby’nin
--rjit --rjit-disablebayraklarıyla çalıştırılması gerekiyor- Kokubun’un özelliği dahil edilirse bu koşulun gereksiz hâle geleceği tahmin ediliyor
- Kavram kanıtı yalnızca o dönemin current Ruby head sürümünde çalışıyor
- Güncelleme itibarıyla Ruby head’de RJIT kaldırıldığı için betiği çalıştırmak üzere Ruby’yi
f32d5071b7b01f258eb45cf533496d82d5c0f6a1commit’ine checkout etmek gerekiyor
1 yorum
Hacker News görüşleri
Java kısıt çözücüsü Timefold’un CPython’da tanımlı fonksiyonları çağırmasını sağlamak için FFI ile epey uğraştım; performans sorunlarının çoğu, ana dil ile harici dil arasında proxy üzerinden gidip gelirken ortaya çıktı.
JNI ya da yeni foreign interface ile doğrudan FFI çağrıları yapmak, doğrudan Java metodu çağırmaya yakın derecede hızlı; ancak CPython ile Java’nın çöp toplayıcıları birbiriyle pek uyumlu olmadığından senkronizasyon için kara büyü gerekiyor.
Buna karşılık JPype veya GraalPy gibi proxy’lerde parametrelerin ve dönüş değerlerinin dönüştürülmesi gerekiyor; ayrıca ters yönde FFI çağrılarını da tetikleyebiliyorlar. Bir CPython nesnesini Java’ya verirseniz Java o nesnenin proxy’sini oluşturuyor; o proxy’yi tekrar CPython’a verdiğinizde ise onu çözmek yerine proxy’nin proxy’sini oluşturuyor.
Sonuç olarak JPype proxy’si, CPython’ı FFI ile doğrudan çağırmaya göre %1402 daha yavaştı; GraalPy proxy’si ise %453 daha yavaştı.
Nihayetinde CPython bytecode’unu Java bytecode’una çevirdik ve kullanılan CPython sınıflarına karşılık gelen Java veri yapılarını oluşturarak proxy’ye kıyasla 100 kat hızlandık. Ek olarak, CPython bytecode’u son derece istikrarsız, belgelenmesi zayıf ve VM özellikleri de çetrefilli olduğundan başka bir bytecode’a doğrudan eşlemek zor; bu yüzden onu çevirmeye ya da okumaya kalkışmamak daha iyi.
Ayrıntıları yazıda topladım: https://timefold.ai/blog/java-vs-python-speed
Go kodu ile C kodu, adres alanı, sinyal işleyicileri, iş parçacığı TLS slotları gibi kaynakları nasıl paylaşacakları konusunda uzlaşmak zorunda; pratikte bu daha çok Go’nun C kodunun varsayımlarının etrafından dolaşması gerektiği anlamına geliyor. C kodu yalnızca tek bir iş parçacığında çalıştığını varsayabilir ya da çok iş parçacıklı ortama hiç hazırlıklı olmayabilir.
C, Go’nun çağrı kurallarını ya da büyüyen stack’lerini bilmediği için C kodunu çağırmak üzere goroutine stack ayrıntılarını kaydetmek, C stack’ine geçmek ve kendisinin nasıl çağrıldığını ya da tüm Go runtime’ını bilmeyen C kodunu çalıştırmak gerekir.
Python, JNI kullanan Java, libffi kullanan diller ya da cgo kullanan Go ile bağlama yapsanız veya C kodunu sarsanız da sonuçta C’nin dünyasında yaşıyorsunuz.
https://dave.cheney.net/2016/01/18/cgo-is-not-go / https://archive.vn/GZoMK
Rails At Scale ve byroot’un yazıları sayesinde Ruby’nin iç yapısını ve performansını derinlemesine ele alan yazıları görmek için gerçekten iyi bir dönem. Ruby ve Rails’teki son iyileştirmelere de bakınca Ruby geliştiricisi olmak için de oldukça iyi bir zaman.
Belirli türde uygulamalarda hâlâ popüler, ama altın çağının epey geride kaldığı anlaşılıyor; son iyileştirmeler güzel, fakat 2025’te JIT’in teknik olarak o kadar ilginç olup olmadığından emin değilim.
“Üçüncü taraf bir kütüphaneyi çağırmak yerine, harici fonksiyonları çağırmak için gereken kodu doğrudan JIT’leyemez miyiz?” yaklaşımı LuaJIT FFI’ın temelini andırıyor: https://luajit.org/ext_ffi.html
Bu yüzden LuaJIT’in FFI’ı çok hızlı gibi görünüyor.
“Mümkün olduğunca çok Ruby ile yazın. Özellikle YJIT Ruby kodunu optimize edebilir ama C kodunu edemez” sözü bana pek anlaşılır gelmiyor
Ruby oldukça yavaş bir dil değil mi? Native’e ineceksem mümkün olduğunca büyük kısmı native kod yapmak isterim gibi geliyor
Büyük bir sürümde, bir UI öğesinin davranışını işleyen Java kodu darboğaz olarak tespit edildi ve sonraki büyük sürümde C ile yeniden yazıldı
Daha sonra JIT gerçekten işe yarar hâle gelince, FFI ek yükü elle ayarlanmış C kodu ile JIT’in ürettiği kod arasındaki farktan daha büyük oldu ve bir sonraki büyük sürümde tekrar saf Java uygulamasına dönüldü
O neslin dilleri içinde Java’nın FFI’ı epey hızlı sayılırdı; birkaç sürüm sonra daha iyi bir yöntemle değişti, o sıralarda Java UI koduyla pek uğraşmadığım için artık takip etmedim. Aynı dönemde platforma özgü kod ile genel Java UI kodu arasındaki arayüz de düzenleniyordu, bu yüzden sonunda tam olarak ne oldu emin değilim
Bu tür işlerde bu tahterevalli etkisini sürekli izlemek gerekir. Birkaç milestone bekleyip elle tuning işini azaltmanın mı daha iyi olduğuna, yoksa siyasi/teknik nedenlerle bunun hemen şimdi mi gerekli olduğuna bakmak gerekir
Bir kez çalışıp biten işler için verimsiz olabilir, ama uzun ömürlü masaüstü/sunucu iş yüklerinde tüm uygulama açısından karşılığını verir
Örneğin Dalvik’in JIT’i oldukça zayıftı; bu yüzden matematik fonksiyonlarını C üzerinden çağırmak daha hızlıydı. Ancak ART sonrasında buna artık gerek kalmadı ve JIT, C çağrısının maliyetini aşabilir hâle geldi
https://developer.android.com/reference/android/util/FloatMa...
Bu eğilime bazen “self-hosting” denir; tarayıcılar da normalde C/C++ ile yazılmış olabilecek kısımları ayrıcalıklı JavaScript’e taşıyarak bunu sıkça kullanır. Standart kütüphanenin şaşırtıcı derecede büyük bir kısmı native kod değildir
C# gibi çok daha hızlı ve birlikte çalışabilirlik maliyeti neredeyse 0’a yakın dillerde bile hâlâ çağrı maliyeti vardır; bazen VM’in durum bayraklarını değiştirme ya da çöp toplama geçişi maliyetleri de ödenir
Ruby YJIT ölçülebilir bir faktör hâline gelmeye başladıysa, yukarıdaki kural da giderek daha önemli olacaktır
eachmetodunun neden Ruby ile yeniden yazıldığını JPCamara’nın harika anlattığı bir yazı var: https://jpcamara.com/2024/12/01/speeding-up-ruby.html / https://bugs.ruby-lang.org/issues/20182tender love’ın bonus yazısı da var: https://railsatscale.com/2023-08-29-ruby-outperforms-c/
Özetle, JIT kazanır
FFI, Foreign Function Interface’tir; yani Ruby’den C çağırmanın yoludur
Sonra Ruby’den terminal üzerinden o C programını bayraklar veya verilerle çalıştırırsanız Ruby C kodunu çalıştırabilir
Bunu neden JIT derlemek gerektiğini anlamıyorum. C ile yazılabiliyorsa yükleme anında doğrudan derlenemez mi?
Bu, üretkenliği büyük ölçüde artırır ve aynı kodun CRuby, JRuby ve TruffleRuby’de paylaşılabilmesini sağlar
Açılışta tüm binding’leri statik olarak bilebiliyorsanız stub yazıp metot tablosuna koyabilirsiniz; ama yine de bu runtime’da gerçekleşen bir şey olduğu için JIT kapsamına girer. Ayrıca sistemden geçen tiplere uyum sağlayamayacağı için kabul edilen değerler ya da optimizasyonlar konusunda muhafazakâr davranmak zorunda kalır; bu da bugün libffi’ın yaptığı şeye yakındır
AOT yaklaşımı, native extension yazmaktır
Yan konu ama genelde FFI’ın kendisinden çok FFI kullanan gem’lerden kaçınıyorum. Derleme çoğu zaman çok zahmetli olduğu için Rubygems/bundler ara katmanını atlayıp doğrudan build etmek daha kolay gelmişti
Biraz ilgili olarak, bu kütüphane JVMCI kullanarak JNI olmadan native kütüphaneleri çağıracak arm64/amd64 kodunu anında üretiyor: https://github.com/apangin/nalim
Bu tam olarak libffi’nin yaptığı şey değil mi?
libffi’de bir fonksiyon için tanımlayıcı nesne oluşturursunuz; bu çalışma zamanı veri yapısı argüman ve dönüş değeri tiplerini temsil eder.
Bir FFI çağrısında geçirmek istediğiniz değerlerin işaretçi dizisini ve tanımlayıcıyı iletmeniz gerekir. İçeride muhtemelen değer dizisiyle tanımlayıcı birlikte gezilir; değerler tiplerine uygun şekilde stack’e yerleştirilir ve fonksiyon bitince dönüş tipine göre değer alınır. Bu sürecin birçok noktasında tipe göre dallanmalar olması muhtemeldir.
libffi çağrı mekanizması JIT’lense bile, argüman dizisini hazırlama işi hâlâ yavaştır. Ara diziden geçmeden argümanlara doğrudan erişen FFI JIT’e göre daha az doğrudandır.
FFI JIT kodu argüman değerlerini doğrudan alır, Ruby tipinden C tipine dönüştürür; her değer için inline kodla stack veya register üzerinde doğru konuma koyar, ardından fonksiyonu çağırır ve dönüş değerini Ruby tipine çevirir. Aslında elle extension kodu yazmaya oldukça benzer.
Tip çıkarımı varsa dönüşüm kodunda tip kontrolleri atlanabilir. Örneğin
arg1’in bir Ruby string’i olduğu garanti ediliyorsa, dönüşüm fonksiyonunun daha hızlı unsafe sürümü kullanılabilir.JIT kodunun en kötü durumda yalnızca Ruby tipini yansıtması yeterlidir; argümanlarla ilgili dizi veya liste gerekmez. Hangi C tipine dönüştürüleceği koda hardcode edildiğinden, çalışma zamanında C tarafını tarif eden veri yapısını dolaşmaya gerek yoktur.
Bu yazının avantajı, kullanıcının
attach_functionçağırırken verdiği bilgilere dayanarak tip unbox kodunun üretilen makine kodunun içinde fiilen cache’lenmesidir.Kardeş yorumda bağlantısı verilen
tramp.c, “ters yönde FFI” için, yani dinamik kullanıcı işini bir fonksiyon işaretçisi olarak dışa açmak içindir; oradaki JIT ise önceden derlenmiş kodu çağırmaya yönelik toplam 3 komut düzeyindedir.https://github.com/libffi/libffi/blob/master/src/tramp.c