2 puan yazan GN⁺ 2025-02-14 | 1 yorum | WhatsApp'ta paylaş
  • Ruby’de yerel fonksiyonları çağırırken FFI kullanışlıdır, ancak strlen benchmark’ı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#bytesize doğ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_function anı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-disable ve 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 strlen fonksiyonunu çağırma
    • Ruby metodu B.strlen içinden x.bytesize dolaylı çağrısı
    • strlen Ruby Gem’iyle oluşturulmuş C eklentisi çağrısı
    • str.bytesize doğrudan çağrısı
  • Ruby 3.5.0dev, ARM64 ortamındaki sonuçlar:
    • ruby-direct: 39.879M i/s, 25.08 ns/i
    • strlen-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#bytesize doğrudan çağrısı en hızlısı; ek dolaylı çağrı ise ek yük getirir
  • ruby-direct ile strlen-ruby arası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ır
  • strlen-cext ile strlen-ffi arası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
  • 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
  • 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ğlar
    • module C
    • extend FJIT
    • attach_function :strlen, [:string], :int
  • attach_function çağrıldığında FJIT, Ruby string’ini açan, C’nin strlen fonksiyonunu ç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/i
    • strlen-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#bytesize doğrudan çağrısı hâlâ en hızlısı
  • FJIT’in ürettiği makine kodu ikinci en hızlı sonuçtu ve strlen C 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-disable bayrakları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 f32d5071b7b01f258eb45cf533496d82d5c0f6a1 commit’ine checkout etmek gerekiyor

1 yorum

 
GN⁺ 2025-02-14
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

    • Deneyimim olmadan konuşursam, Python ve Java’nın ayrı ayrı C’ye giden FFI’ları çok daha iyi görünüyor; ikisini küçük bir C köprüsü ile bağlamak genel çözüm olmaz mı diye düşünüyorum.
    • cgo is not Go da bakmaya değer.
      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
    • Böyle durumlarda kuyruk, dosya, HTTP gibi süreçler arası iletişim yöntemlerinin nereye oturacağını merak ediyorum.
  • 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.

    • Gerçekten öyle mi, emin değilim. Ruby düşüşte görünüyor: https://www.tiobe.com/tiobe-index/ruby/
      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

    • Java’nın düzgün bir JIT’e kavuştuğu dönemde buna benzer küçük bir drama yaşanmıştı
      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
    • İşte bu noktada JIT devreye girer. İdeal durumda JIT kodu daha iyi bir hâle yeniden optimize edebilir
      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...
    • JIT ya da AOT derleyicisi olan yönetilen dillerde, çoğu zaman çok miktarda kodu o dilde yazmak idealdir. Böylece C çağrı sınırını geçerken mümkün olmayan inline etme ve başka optimizasyonlar mümkün olur
      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
    • FFI, kodlar arasında opak ve optimize edilemeyen bir sınır oluşturur. Bu şekilde sık sık gidip gelen kodların maliyeti yüksektir
      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
    • Ruby’nin each metodunun 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/20182
      tender 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

    • Tamamen güvenli ve aklı başında yaklaşım, çalıştırma sırasında komut satırından veri alıp sonucu komut satırına ya da bellek sayfasına döken C kodu yazmaktır
      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?

    • Neyi C ile yazılabilir demek istediğinizi anlamadım. FFI kütüphaneleri, native extension yazmadan Ruby’den kütüphane metotlarını dinamik olarak bağlayıp çalıştırmanızı sağlar
      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 yavaştır ve bildiğim kadarıyla JIT yapmaz.
      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.
    • libffi Ruby’nin ne olduğunu bilmediği için Ruby tiplerini unbox etmeyi bilemez.
      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.
    • libffi FFI çağrısını JIT’lemez; argüman değerlerini yine doğrudan yerleştirmeniz gerekir. Örneğin string argümanı varsa, Ruby string nesnesini C string işaretçisine dönüştüren kodu ayrıca yazmanız gerekir. Ayrıca libffi oldukça yavaştır.
      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.
    • libffi’nin ne yaptığını bildiğimi sanıyordum; GOT benzeri bir şeylerle oynadığını düşünmüştüm. Ama sanırım sen haklısın.
      https://github.com/libffi/libffi/blob/master/src/tramp.c