2 puan yazan GN⁺ 2024-12-06 | 1 yorum | WhatsApp'ta paylaş
  • 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 --yjit açı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_yjit hook’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 40 biçimindeydi
  • ruby --yjit ./code.rb 40 ile ç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).each ve (0...100_000).each biçimindeki Range#each
  • Ruby 3.4 itibarıyla Range#each hâlâ C ile yazılmış durumda
  • CRuby’nin range.c dosyası, Range sınıfını ve each metodunu C fonksiyonu range_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.succ ifadelerinden 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.times ve 100_000.times olarak değiştirildiğinde Loops süresi ciddi biçimde azalıyor
    • Range#each: 25,57 saniye
    • Integer#times: 13,66 saniye
  • Başka bir ölçüm ortamında Integer#times 9 saniyeye kadar indi; Ruby 3.4’te 8 saniyelik sonuçlar da görüldü

Integer#succ ve VM düzeyinde optimizasyon

  • Integer#times implementasyonu, artırma işleminde i += 1 yerine i.succ kullanıyor
  • Integer#succ, tamsayının bir sonraki değerini döndüren metottur
  • Ruby VM bytecode’unda i.succ, tek bir opt_succ adımıyla temsil edilir
  • Buna karşılık i += 1 iki adıma ayrılır
    • putobject_INT2FIX_1_: 1 tamsayısını VM stack’ine koyar
    • opt_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#each de 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 Primitive kullanıyor
    • Primitive.attr! :inline_block, :c_trace
    • Primitive.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#each ile döngüye girildiğinde Integer#times ile benzer performans çıkıyor
    • Range#each: 25,57 saniye
    • Integer#times: 13,66 saniye
    • Array#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 saniye
    • range#each: 26,61 saniye
    • times: 13,12 saniye
    • for: 14,91 saniye
    • while: 37,10 saniye
    • loop 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 saniye
    • range#each: 33,88 saniye
    • times: 33,18 saniye
    • for: 36,32 saniye
    • while: 37,14 saniye
    • loop do: 50,65 saniye
  • while örneği beklenenden yavaştı; implementasyon biçiminden kaynaklanan bir sorun olabilir
  • for in ile array#each, Ruby VM bytecode’u düzeyinde neredeyse aynıdır; performansları da benzerdir
    • for in genel 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 saniye
      • range#each: 0,92 saniye
      • times: 2,39 saniye
      • for: 2,06 saniye
      • while: 3,90 saniye
      • loop do: 0,77 saniye
    • MRuby 3.3
      • Fibonacci: 28,83 saniye
      • array#each: 144,65 saniye
      • range#each: 126,40 saniye
      • times: 128,22 saniye
    • Artichoke
      • Fibonacci: 19,71 saniye
      • array#each: 236,10 saniye
      • range#each: 214,55 saniye
      • times: 214,51 saniye

Range#each’i Ruby ile monkey patch etme deneyi

  • Range#each basit bir Ruby implementasyonuyla monkey patch edildiğinde performans ciddi biçimde iyileşiyor
  • Implementasyon begin, end, loop, yield, i.succ kullanan basit bir biçimde
  • Ölçümler şöyle
    • C implementasyonu Range#each: 25,57 saniye
    • Ruby implementasyonu Range#each: 16,64 saniye
  • Bu implementasyon tüm Range durumları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_yjit bloğ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_yjit kodu YJIT etkinleştiği anda uygun metot sürümünü uygular
  • with_yjit bir YJIT hook’udur ve çağrıldıktan sonra runtime’da undef :with_yjit ile kaldırılır

YJIT’in ürettiği makine kodunu görme yöntemi

  • CRuby --enable-yjit=dev seçeneğiyle derlendiğinde YJIT’in ürettiği makine kodu disassembly’si görülebilir
  • Derleme örneği şöyle
    • ./configure --enable-yjit=dev
    • make install
  • Çalıştırırken --yjit-dump-disasm bayrağı kullanılır
    • ./ruby --yjit --yjit-dump-disasm test.rb 40
  • Integer#times içindeki i.succ, VM bytecode’unda opt_succ olarak görünür
  • YJIT’in Rust implementasyonu opt_succ iç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

 
GN⁺ 2024-12-06
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
    u derleme zamanında bilinmiyor olsa bile iç döngü de birkaç komuta indirgenebilir gibi görünüyor; bu da clang tarafının yakında yapabileceği daha standart bir optimizasyon gibi duruyor

    • Derleyiciler genelde dizinin tek tek elemanları için canlılık analizi yapmaz
      İ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ı
    • Çoğu durumda kapalı formda hesaplamak mümkün:
      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...

    • Ruby'nin hep Noel'de yayınlanması öngörülebilir ve sevimli bir takvim yaratıyor
      Performans iyileştirmelerinin ara sürümlere de girebildiğini hatırlıyorum
  • Integer#succ sadece performans için değil okunabilirlik nedeniyle de sık kullandığım bir şey
    Örneğin UUID kütüphanesindeki #bytes metodunda kodu okurken kafamı “bit slicing modu”nda tutmak için iki kez kullandım
    16 kez dönen döngüyü 0xF.succ, döngü içinde 256'ya bölme kısmını da 0xFF.succ olarak ifade ettim: https://github.com/okeeblow/DistorteD/blob/ba48d10/Globe%20G...

    • Bu durumda 0xF.succ ifadesinin neden 0x10'dan daha iyi hissettirdiğini merak ediyorum
  • https://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 yoktu
    Bazen 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

    • TruffleRuby için başlangıç süresi ve en yüksek performansa ulaşma süresi hesaba katılmalı; bu da native/JVM runtime ayarlarına göre değişir
      Ayrıntılar için https://github.com/oracle/truffleruby
  • Ruby gerçekten hızlanmış; özellikle TruffleRuby daha da etkileyici

    • O, Oracle'ın işi: https://github.com/oracle/truffleruby
    • Rails, Truffle üzerinde çalışmıyor ve anladığım kadarıyla bir süre daha çalışması da zor görünüyor
      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ç

    • Başta C ile yazılmıştı, sonra Rust'a taşındı ve iyi bir karar gibi görünüyor
      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

    • Bu da başka bir dil karşılaştırma deposu ama sunum biçimi okumayı zorlaştırıyor
      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

    • Yeniden karşılaştırmak için derleme süresi de dahil edilmeli ve bunun ilgili build'in beklenen çalışma sayısına yayılması gerekir
      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
    • Yemeğe benziyor; üstüne şeker serpersen insanlar çok daha fazla sever
      Genel olarak Ruby yavaştır ama kodlaması gerçekten keyiflidir, bu yüzden yeni başlayanlar için daha çekici gelir
    • Yavaş diller daha yüksek seviyeli olduğu için kullanımı daha kolaydır
    • En popüler 20 dile bakıldığında da bu korelasyonun sürüp sürmediğini merak ediyorum
  • 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