Ruby’yi Hızlandırmak İçin C Kodunu Ruby ile Yeniden Yazmak
(jpcamara.com)- Küçük dil benchmark’larında CRuby sondan üçüncü oldu; ancak darboğazın dilin tamamında değil, YJIT’in içini göremediği döngü implementasyonunda olduğunu gösterdi
- Ruby 3.3.6, M3 MacBook Pro’da Fibonacci’de 12,17 saniye, Loops’ta 28,80 saniyeydi; node.js ise ikisinde de 1 saniye civarındaydı. M2 MacBook Air’de Ruby sırasıyla 16,33 saniye ve 33,43 saniye ile daha yavaştı
- Yalnızca
ruby --yjitaçıldığında Fibonacci 16,88 saniyeden 2,06 saniyeye ciddi biçimde düşüyor; ancak Loops 33,43 saniyeden yalnızca 25,57 saniyeye iniyor ve Range#each darboğaz olarak kalıyor - Ruby 3.3’teki Integer#times ve Ruby 3.4’teki Array#each gibi C implementasyonundan Ruby tarafına taşınan metotlar YJIT optimizasyonunun hedefi hâline geliyor ve döngü benchmark’ı 13–14 saniye aralığına iyileşiyor
- CRuby,
with_yjithook’u ile YJIT etkin olduğunda Ruby implementasyonunu kullanıp, etkin olmadığında C implementasyonunu koruyabiliyor; bu da çekirdek kütüphaneyi YJIT dostu Ruby koduna dönüştürme eğilimi yaratıyor
Benchmark’larda Ruby’nin konumu
- Yakın zamanda paylaşılan language comparison repo, çeşitli diller için küçük benchmark’ların iş birliğiyle oluşturulduğu bir depo
- Bu depodaki CRuby sonucu, Ruby’den daha yavaş yalnızca R ve Python olacak şekilde sondan üçüncü oldu
- Benchmark iki eksene bakıyor
- Loops: döngüler, koşullu ifadeler ve temel matematik işlemlerinin performansını öne çıkarıyor
- Fibonacci: fonksiyon çağrısı overhead’ini ve özyineleme maliyetini ortaya koyuyor
- Loops örneği, iç içe döngülerle toplam 10.000 × 100.000 kez, yani 1 milyar döngü çalıştırıyor
- Fibonacci örneği optimize edilmiş bir implementasyon değil; bilerek basit bir naive Fibonacci kullanıyor
İlk ölçümler ve YJIT uygulamasının etkisi
- M3 MacBook Pro’da Ruby 3.3.6 şu seviyedeydi
- Fibonacci: 12,17 saniye
- Loops: 28,80 saniye
- node.js: iki örnekte de 1 saniyenin biraz üzerinde
- M2 MacBook Air’de aynı benchmark daha yavaş çıktı
- Ruby Fibonacci: 16,33 saniye
- Ruby Loops: 33,43 saniye
- node.js Fibonacci: 1,36 saniye
- node.js Loops: 2,07 saniye
- Orijinal depodaki çalıştırma komutu YJIT olmadan
ruby ./code.rb 40biçimindeydi ruby --yjit ./code.rb 40ile çalıştırıldığında sonuç değişiyor- Fibonacci: 2,06 saniye
- Loops: 25,57 saniye
- YJIT, Fibonacci’de büyük etki yarattı; ancak Loops’ta iyileşme sınırlı kaldı
Range#each neden YJIT için dezavantajlı?
- Loops kodundaki temel döngü,
(0...10_000).eachve(0...100_000).eachbiçimindeki Range#each - Ruby 3.4 itibarıyla
Range#eachhâlâ C ile yazılmış durumda - CRuby’nin
range.cdosyası,Rangesınıfını veeachmetodunu C fonksiyonurange_each’e bağlıyor range_each, çeşitli range biçimlerini işlemek için farklı yollara dallanıyor(0...).each(0...100).each("a"..."z").each
- C fonksiyonunun kendisi hızlı olabilir; ancak YJIT onun içini göremez
- Optimizasyon fonksiyon çağrısı noktasında durur ve C fonksiyonu döndükten sonra yeniden devam eder
- YJIT, sıcak yürütme yollarına özel optimizasyonlar üretebilir; C implementasyonu ise bu avantajı sınırlar
Integer#times’a geçildiğinde değişim
- Ruby 3.3’te
Integer#times, C fonksiyonundan Ruby metoduna geçirildi - Temel yapı
while i < self,yield i,i = i.succifadelerinden oluşan basit bir Ruby döngüsüdür - Ruby koduyla yazıldığı için YJIT içini analiz edip optimize edebilir
- Range döngüsü
10_000.timesve100_000.timesolarak değiştirildiğinde Loops süresi ciddi biçimde azalıyorRange#each: 25,57 saniyeInteger#times: 13,66 saniye
- Başka bir ölçüm ortamında
Integer#times9 saniyeye kadar indi; Ruby 3.4’te 8 saniyelik sonuçlar da görüldü
Integer#succ ve VM düzeyinde optimizasyon
Integer#timesimplementasyonu, artırma işlemindei += 1yerinei.succkullanıyorInteger#succ, tamsayının bir sonraki değerini döndüren metottur- Ruby VM bytecode’unda
i.succ, tek biropt_succadımıyla temsil edilir - Buna karşılık
i += 1iki adıma ayrılırputobject_INT2FIX_1_: 1 tamsayısını VM stack’ine koyaropt_plus:+işlemini gerçekleştirir
- Sıradan Ruby programlarında bununla neredeyse hiç ilgilenmek gerekmez; ancak JIT ve VM düzeyinde, milyonlarca ila milyarlarca kez tekrarlanırken tek adımlık fark bile performansı etkiler
Array#each de Ruby 3.4’te Ruby implementasyonuna taşındı
- Ruby 3.4’te
Array#eachde C’den Ruby tarafındaki implementasyona geçirildi - İlk deneme basit Ruby koduydu; ancak CRuby iç mekanizmasıyla ilgili bir race condition vardı
- Nihai implementasyon Ruby kodu içinde
PrimitivekullanıyorPrimitive.attr! :inline_block, :c_tracePrimitive.cexpr!ary_fetch_next
- Tamamen saf Ruby’den ziyade C kodu değerlendirmesiyle Ruby yapılarının karıştığı bir biçim; ancak YJIT yine de kayda değer optimizasyon yapabiliyor
- Diziyi önceden oluşturup
Array#eachile döngüye girildiğindeInteger#timesile benzer performans çıkıyorRange#each: 25,57 saniyeInteger#times: 13,66 saniyeArray#each: 13,96 saniye
Ruby Microbench ölçümü
- Ayrı bir depo olan ruby_microbench, orijinal örnekle çeşitli Ruby döngü biçimlerini karşılaştırıyor
- Ruby 3.4’te YJIT açıkken sonuçlar şöyle
- Fibonacci: 2,19 saniye
array#each: 14,02 saniyerange#each: 26,61 saniyetimes: 13,12 saniyefor: 14,91 saniyewhile: 37,10 saniyeloop do: 13,95 saniye
- Ruby 3.4’te YJIT kapatıldığında çoğu daha yavaşlıyor
- Fibonacci: 16,49 saniye
array#each: 34,29 saniyerange#each: 33,88 saniyetimes: 33,18 saniyefor: 36,32 saniyewhile: 37,14 saniyeloop do: 50,65 saniye
whileörneği beklenenden yavaştı; implementasyon biçiminden kaynaklanan bir sorun olabilirfor inilearray#each, Ruby VM bytecode’u düzeyinde neredeyse aynıdır; performansları da benzerdirfor ingenel olarak VM’de#eachçağrısına dönüşen bir sözdizimsel şekere yakındır
Diğer Ruby implementasyonlarıyla karşılaştırma
- Aynı benchmark çeşitli Ruby implementasyonlarında da çalıştırıldı
- Bazı sonuçlar şöyle
- TruffleRuby 24.1
- Fibonacci: 0,92 saniye
array#each: 0,97 saniyerange#each: 0,92 saniyetimes: 2,39 saniyefor: 2,06 saniyewhile: 3,90 saniyeloop do: 0,77 saniye
- MRuby 3.3
- Fibonacci: 28,83 saniye
array#each: 144,65 saniyerange#each: 126,40 saniyetimes: 128,22 saniye
- Artichoke
- Fibonacci: 19,71 saniye
array#each: 236,10 saniyerange#each: 214,55 saniyetimes: 214,51 saniye
- TruffleRuby 24.1
Range#each’i Ruby ile monkey patch etme deneyi
Range#eachbasit bir Ruby implementasyonuyla monkey patch edildiğinde performans ciddi biçimde iyileşiyor- Implementasyon
begin,end,loop,yield,i.succkullanan basit bir biçimde - Ölçümler şöyle
- C implementasyonu
Range#each: 25,57 saniye - Ruby implementasyonu
Range#each: 16,64 saniye
- C implementasyonu
- Bu implementasyon tüm
Rangedurumlarını işlemeyen aşırı basitleştirilmiş bir sürüm - Yine de C’den Ruby’ye taşındığında YJIT optimize edebiliyor ve sıradan C koduyla yeniden üretmesi zor ya da imkânsız olan performans iyileştirmeleri mümkün oluyor
YJIT standart kütüphanesi ve with_yjit
- Aaron Patterson’ın Ruby Outperforms C yazısı, GraphQL ayrıştırma için kullanılan bir C extension’ın Ruby ile yeniden yazıldığı ve YJIT optimizasyonu sayesinde Ruby kodunun C’den hızlı olduğu bir örnektir
- CRuby’nin çekirdek YJIT ekibi, bazı çekirdek işlevlerde C kodunu kaldırıyor ya da yalnızca YJIT açıkken Ruby implementasyonunu kullanan yaklaşımı uyguluyor
with_yjitbloğu, ilgili Ruby implementasyonunu yalnızca YJIT etkinse uygular- YJIT kapalıysa C implementasyonu çalışmaya devam eder
- YJIT açıksa YJIT tarafından optimize edilebilen Ruby sürümü kullanılır
- Ruby 3.3’ten itibaren YJIT geç başlatılabilir;
with_yjitkodu YJIT etkinleştiği anda uygun metot sürümünü uygular with_yjitbir YJIT hook’udur ve çağrıldıktan sonra runtime’daundef :with_yjitile kaldırılır
YJIT’in ürettiği makine kodunu görme yöntemi
- CRuby
--enable-yjit=devseçeneğiyle derlendiğinde YJIT’in ürettiği makine kodu disassembly’si görülebilir - Derleme örneği şöyle
./configure --enable-yjit=devmake install
- Çalıştırırken
--yjit-dump-disasmbayrağı kullanılır./ruby --yjit --yjit-dump-disasm test.rb 40
Integer#timesiçindekii.succ, VM bytecode’undaopt_succolarak görünür- YJIT’in Rust implementasyonu
opt_succiçin şunları yapar- Alıcının Fixnum olup olmadığını guard eder
- Fixnum değilse başka bir yürütme yoluna çıkar
- Fixnum ise iç tag temsili nedeniyle 1 eklemek için gerçekte 2 ekler
- Overflow oluşursa başka bir yola çıkar
- Bu örnek, JIT optimizasyonunun Ruby kodu, C kodu, VM bytecode’u, Rust implementasyonu ve makine kodu gibi birçok katman arasında gidip gelerek çalıştığını gösterir
CRuby optimizasyonunun yönü
- Ruby implementasyonu çalışmaları Ruby’den daha düşük seviyeli dillerde yoğun biçimde yapılır; CRuby’de ağırlıklı olarak C ve kısmen Rust kullanılır
- YJIT gibi katmanlar, daha fazla dil özelliğinin sıradan Ruby koduna taşınması olasılığını doğuruyor
- Çekirdek işlevlerin daha büyük bölümü Ruby ile yazılırsa Ruby geliştiricilerinin CRuby’ye katkıda bulunması kolaylaşabilir
- Java’da olduğu gibi küçük bir düşük seviyeli çekirdek üzerinde dilin büyük bölümünün kendi diliyle yazıldığı yapı, CRuby’nin olası geleceklerinden biri olarak anılıyor
- Mevcut eğilimde, C implementasyonunu olduğu gibi korumaktansa YJIT’in optimize edebileceği Ruby implementasyonlarını kritik yürütme yollarına almak önemini koruyor
1 yorum
Hacker News yorumları
Döngü örneği 1 milyar kez iç içe döngü çalıştıran garip bir benchmark gibi görünüyor ve elle optimize edilirse çalışma süresinin %99'undan fazlası baştaki kısımda toplanacakmış gibi duruyor
Dizi elemanları bazında canlılık analizi (liveness analysis) yapılırsa tüm dış döngünün kaldırılabileceği anlaşılıyor; böyle bir analizi gerçekten yapan derleyiciler olup olmadığını merak ediyorum
uderleme zamanında bilinmiyor olsa bile iç döngü de birkaç komuta indirgenebilir gibi görünüyor; bu daclangtarafının yakında yapabileceği daha standart bir optimizasyon gibi duruyorİzlenmesi gereken veri çok fazladır ve bunun ancak böyle bozuk koda benzer durumlarda işe yarama ihtimali yüksektir
Eskiden AI derleyicisi yaparken tensörlerin tek tek elemanları için canlılık analizi pratikte yararlı olabilirdi ama derleme süresi ve bellek gereksinimi absürt düzeydeydi, bu yüzden yapılmadı
result = ((u * (u - 1)) / 2 * (100000/u)) + (100000%u * (100000%u - 1) / 2) + r)Yazı gelecekte çıkacak bir Ruby sürümünü ele alıyor; merak edip baktım, ruby 3.4.0'ın bu Noel'de çıkması, ruby 3.5.0'ın da gelecek Noel'de çıkması bekleniyor
Ayrıca Python'un asgari düzeyde bir JIT'inin bu tür döngülerde ne etki yapacağını da merak ediyorum
Python 3.13'te JIT açık olacak şekilde derleme yapmak gerekiyor; bu yüzden birinin derlenmiş bir ortamda benchmark çalıştırması ilginç olabilir: https://drew.silcock.dev/blog/everything-you-need-to-know-ab...
Performans iyileştirmelerinin ara sürümlere de girebildiğini hatırlıyorum
Integer#succsadece performans için değil okunabilirlik nedeniyle de sık kullandığım bir şeyÖrneğin UUID kütüphanesindeki
#bytesmetodunda kodu okurken kafamı “bit slicing modu”nda tutmak için iki kez kullandım16 kez dönen döngüyü
0xF.succ, döngü içinde 256'ya bölme kısmını da0xFF.succolarak ifade ettim: https://github.com/okeeblow/DistorteD/blob/ba48d10/Globe%20G...0xF.succifadesinin neden0x10'dan daha iyi hissettirdiğini merak ediyorumhttps://github.com/bddicken/languages deposuna katkıda bulunuyorum; Lua yaklaşımını denedikten sonra bir yerde bahsedilen TruffleRuby'yi de hatırladım ama
main.rbçalıştırdığımda TruffleRuby ile normal Ruby arasında fiilen büyük bir fark yoktuBazen normal Ruby, TruffleRuby'den daha da hızlıydı
Yazıdaki TruffleRuby hız benchmark'ının hangi değişiklikten sonra çıktığını kontrol etmek istiyorum ve doğrulayabilirsem ana depoya bir commit olarak eklemek isterim
TruffleRuby uygulaması gerçekten Node.js'ten hızlıysa ve Bun ya da Go seviyesine yakınsa bu oldukça şaşırtıcı olur
Ayrıntılar için https://github.com/oracle/truffleruby
Ruby gerçekten hızlanmış; özellikle TruffleRuby daha da etkileyici
Ruby performansına en büyük etkiyi yapma ihtimali olan kombinasyon bu olacağı için üzücü
YJIT'in Rust ile yazıldığını bilmiyordum; gerçekten ilginç
Eksisi, uygun toolchain veya platform yoksa derleme sırasında etkinleşmeyebilmesi; ama makul bir ödünleşim gibi duruyor
Daha uzun süredir var olan ve daha fazla dili kapsayan başka bir dil karşılaştırma deposu da var: https://github.com/niklas-heer/speed-comparison
Grafik eksen etiketleriyle çubuk etiketleri üst üste biniyor ve dikey kılavuz çizgileri de yok
Keşke basit bir HTML tablo olsaydı
Python benchmark'ta en yavaş dil olmasına rağmen Ekim 2024 itibarıyla GitHub'da en çok kullanılan dil olması ilginç
Dil ne kadar yavaşsa o kadar popüler gibi bir korelasyon varmış gibi görünüyor
Rust'ı epey seven biri olarak söylüyorum: tüm diller, runtime'lar, interpreter'lar ve derleyiciler birer araçtır
Probleme ve çözüm yaklaşımına göre iyi bir araç seti gerekir; program sadece birkaç kez çalışacaksa yavaş çalışma hızı çok da önemli değildir
Python ve R gibi programlarda böyle durumlar sık görülür
Genel olarak Ruby yavaştır ama kodlaması gerçekten keyiflidir, bu yüzden yeni başlayanlar için daha çekici gelir
Benim Advent of Code çözümlerim de şaşırtıcı derecede buna benziyor; bu yüzden oyunun kurallarını değiştirecek bir gelişme gibi görünüyor