2 puan yazan GN⁺ 2024-04-20 | 1 yorum | WhatsApp'ta paylaş
  • Rust’ın mevcut extern "Rust" çağrı kuralı LLVM’in C çağrı kuralı yoluna dayanıyor; karmaşık değer aktarımında register kullanımı konusunda muhafazakâr kaldığı için daha iyi kod üretimi fırsatlarını kaçırıyor
  • Temel fikir, crate düzeyindeki -Zcallconv bayrağıyla mevcut yöntem olan legacy ile yeni register odaklı yöntem olan fastı ayırmak ve optimizasyon derlemelerinde daha agresif bir ABI kullanmak
  • LLVM’e doğrudan yeni bir çağrı kuralı eklemeden de sabit LLVM fonksiyon imzaları ve poison değerleri ile kullanılmayan register argümanlarını maliyetsiz biçimde boş bırakarak argüman yerleşimini kontrol etmek mümkün
  • Struct, enum, union, bool, Result gibi Rust tipleri; padding hariç geçerli boyut, düzleştirme, bit paketleme ve stack/register bölme sezgiselleriyle daha sıkı biçimde aktarılabilir
  • Fonksiyon gövdesi, borrow checker bilgisi ve profil bilgisi ABI kararlarına yansıtılırsa daha güçlü optimizasyonlar mümkün olur; ancak rustc ABI kod üretiminin karmaşıklığı ve LLVM uzmanlığı eksikliği pratik engeller olarak kalıyor

Rust’ın şu anda kaçırdığı çağrı kuralı optimizasyonu

  • Çağrı kuralı (calling convention), fonksiyon argümanları ve dönüş değerlerinin nasıl aktarılacağını, hangi register’ların kullanılacağını, prologue/epilogue ve unwinding işlemlerinin nasıl ele alınacağını belirleyen ABI’nin bir parçasıdır
  • Rust kendi unspecified çağrı kuralını tanımlar; ancak pratikte LLVM’in yerleşik C çağrı kuralına indirilir ve LLVM’in prologue/epilogue kod üretimine dayanır
  • rustc, Clang’in üreteceğine benzer LLVM fonksiyon imzaları üretmek için muhafazakâr davranır
    • Debugger’ın bozulma olasılığını azaltabilir
    • Clang’in pek kullanmadığı ABI kod üretim yollarıyla LLVM hatalarını tetikleme olasılığını düşürebilir
  • ELF tabanlı sistemlerde DWARF, Linux C ABI’sini sabitlemediğinden, yazının kapsamı içinde hata ayıklanabilirlik temel sorun olarak görülmez
  • Basit bir örnek olarak fn extract(arr: [i32; 3]) -> i32 içinde 12 baytlık dizi register yerine pointer ile aktarılır
    • extern "C" eklendiğinde aynı [i32; 3], rdi ve rsi içine packed edilerek aktarılır
    • Bu, Rust’ın varsayılan yolunun Linux C ABI’sinden bile daha muhafazakâr olduğu bir örnektir

-Zcallconv: legacy ve fast’i ayırma yöntemi

  • extern "Rust"ın mevcut çağrı kuralı korunur; ancak crate derleme bayrağı -Zcallconv ile kullanılacak çağrı kuralı seçilir
    • -Zcallconv=legacy: mevcut yöntem
    • -Zcallconv=fast: yeni tasarlanacak register odaklı yöntem
    • -O, otomatik olarak -Zcallconv=fast ayarlayabilir
  • fast çağrı kuralı argümanları C ABI sırasına göre yerleştirmediğinden, x86’nın alışılagelmiş register sırasını bekleyen biri için kafa karıştırıcı olabilir
  • WASM gibi register ve spilling kavramı olmayan hedeflerde -Zcallconv=fast desteklenmeyebilir
  • Optimizasyonun kapalı olduğu debug derlemelerinde fast daha kötü kod üretebilir; bu nedenle etkinleştirilmesi uygun olmayabilir
  • Fonksiyon pointer’ları ve extern "Rust" {} blokları için ayrı kısıtlar gerekir
    • Bayrak crate düzeyindedir; fakat fonksiyon pointer’ının hangi extern "Rust" sürümünü kullandığını ifade etmek zordur
    • Fonksiyon pointer çağrıları yavaş ve nadir bir yol olarak görülüp -Zcallconv=legacy zorunlu kılınabilir
    • Gerekirse çağrı kuralını dönüştüren bir shim üretilebilir
    • Unmangled sembollerin çağrılabildiği yollar nedeniyle #[no_mangle] sembollerinin de legacy çağrı kuralını kullanması sağlanabilir

LLVM’i dolaylı olarak yönlendirme yöntemi

  • İdeal olarak LLVM’e “bu argüman şu register’da, bu dönüş değeri şu register’da” gibi doğrudan çağrı kuralı belirtmek istenir; ancak LLVM’e çağrı kuralı eklemek çok miktarda C++ kodu yazmayı gerektirir
  • Bunun yerine aşağıdaki süreçle özel bir çağrı kuralına yakın etki elde edilebilir
    • Hedef triple’a göre register üzerinden aktarılabilecek maksimum değer sayısı belirlenir
    • Dönüş değerinin çıktı register’larına sığıp sığmadığı ya da sret niteliğine sahip ek bir ptr argümanıyla by-reference döndürülmesi gerekip gerekmediği belirlenir
    • Çok büyük by-value argümanlar by-reference’a indirilir
    • Hangi argümanların register’dan gönderileceği seçilerek register alanı kullanım oranı maksimize edilir
    • Kalan argümanlar stack’te tutulur
    • LLVM IR fonksiyon imzası i64, ptr, double, <2 x i64> gibi aggregate olmayan argümanlardan oluşturulur
    • Fonksiyon prologue’unda register girdileri Rust düzeyindeki argümanlara decode edilir
    • Fonksiyon bitiş bloğunda dönüş değeri gerekli çıktı biçimine encode edilip ret yapılır
    • Adresi alınabilen non-polymorphic, non-inline fonksiyonlar için fonksiyon pointer kimliğini korumak üzere legacy shim oluşturulur
  • Hangi değerlerin register’a konacağına karar verme problemi sırt çantası problemi (knapsack problem) gibidir ve NP-hard’dır; gerçek uygulamada sezgiseller gerekir
  • Bu bilgi çok geç hesaplanmayıp yeniden hesaplamayı önlemek için rmeta içine konabilir
  • Rust’ın ABI’si her sürümde kırıldığı için, farklı Rust derleyicilerinin ürettiği kodların link edilmemesi gerektiği koşulu zaten mevcut durumla uyumludur

LLVM’in izin verdiği register aktarım sınırı

  • LLVM, aggregate by-value argümanları fonksiyona aktarırken mümkün olduğunca çok register’a “explode” etmeye çalışır
  • x86’da LLVM’in register üzerinden aktarabildiği girdiler kabaca şöyledir
    • 6 tamsayı
    • 8 SSE vektörü
    • Dönüş için bunun yarısı: 3 tamsayı ve 4 vektör
  • aarch64-unknown-linux üzerinde hem girdi hem çıktı için 8 tamsayı ve 8 vektör mümkündür
  • x86’daki tüm -Zcallconv=fast fonksiyonlarının aynı sayıda by-register argümana sahip olması sağlanabilir
    • Tamsayı register’ları için 6 argüman
    • xmm0dan xmm7ye kadar 8 vektör argümanı
    • Gerçek pointer aktarımında ilgili i64, ptr ile değiştirilir
    • double aktarımında <2 x i64> yuvasının yerini alır
  • Çoğu fonksiyon 176 bayt aktarmasa bile kullanılmayan argümanlara LLVM poison geçirilerek ek maliyetten kaçınılabilir
    • LLVM, poisonı o anda en uygun değer olarak görebilir
    • Register argümanı olarak poison aktarıldığında “zaten o register’da bulunan değer” gibi ele alınabilir; bu yüzden register’a dokunmak gerekmez
    • Örnekte load_rcx() pointer’ı rcx ile alır; kalan 13 register’a poison yükleyen kod optimizasyon sırasında hiç kod üretmez
  • Bu yöntem argüman aktarımını neredeyse tamamen kontrol etmeyi sağlar; ancak girdi ve çıktı için aynı register’ı kullanan ideal durum mimariden mimariye değişir
    • ARM ve RISC-V, girdi ve çıktıda aynı register’ı kullanan yapıya daha yakındır
    • x86 öyle değildir; ancak register ayırma sırasını farklı varsayarak gereksiz register taşımaları azaltılabilir

Rust tiplerini register’lara daha iyi uydurma

  • Rust struct’ları ve union’ları ele alınırken rustc’nin kullanıcı tiplerini zaten temel aggregate ve union olarak işlediği varsayılır; hangi parçaların register’da tutulacağına karar verilir
  • Dönüş değerlerinde struct’ın toplam boyutundan çok padding hariç geçerli boyutu önemlidir
    • [(u64, u32); 2] toplamda 32 bayttır, ancak 8 baytı padding’dir
    • (u64, u32, u64, u32) olarak düzleştirilip boyuta göre (u64, u64, u32, u32) sırasına dizilirse 24 bayt olur
    • x86’nın 3 tamsayı dönüş register’ına sığabilir
  • Geçerli boyut, non-undef bit sayısı olarak tanımlanır
    • [(u64, u32); 2] 192 bittir
    • bool 1 bittir
    • char teknik olarak 21 bittir; ancak basitleştirme için u32 alias’ı gibi ele alınır
  • Çok sayıda bool içeren struct’lar, birden fazla boolu tek register’a bit paketleme ile döndürebilir
  • Argüman tarafı daha zordur ve aşağıdaki sezgiseller uygulanabilir
    • Geçerli boyutu toplam by-register girdi alanından büyük olan argümanlar by-reference’a indirilir
    • x86 için toplam girdi alanı 176 bayt, yani 1408 bittir
    • enum, discriminant ve union çifti hâline dönüştürülür
      • Option<i32> içsel olarak (union { i32, () }, i1) gibi görülebilir
      • Option<Option<i32>> ise (union { i32, (), () }, i2) gibi görülebilir
    • union, başlatılmamış bitlere keyfî olarak dokunulmasına izin verebildiğinden genellikle u8 dizisi gibi aktarılır
    • Yalnızca bir non-empty variant’a sahip union, o variant ile değiştirilir
    • Dönüştürülen argüman pointer, tamsayı, float, bool gibi primitive’lere düzleştirilir
    • u128, f64 gibi küçük argüman register’ından büyük alanlar bölünebilir
    • Primitive listesi geçerli boyuta göre sıralanır ve register’a sığan en büyük prefix seçilir
    • Kalanlar stack’te tutulur
    • Stack’e giden kısım pointer boyutunun küçük bir katından büyükse bellek trafiğini azaltmak için pointer-on-the-stack’e indirilir
    • Register ile aktarılan değerler büyükten küçüğe yerleştirilir; bool ise register başına 64 adede kadar bit paketlenir

Karmaşık Rust fonksiyonu örneği ve mevcut rustc’nin sınırı

  • Option<usize>, &dyn Context, &str, [char; 6], Options struct’ını alan do_thing örneğinde, düzleştirme ve sıralama sonrasında ham LLVM argümanlarının tamamı register’lara sığabilir
  • Örneğin raw argument LLVM tipleri şu biçimde olur
    • gprs: i64, ptr, ptr, ptr, i64, i32, i32
    • xmm0: i32, i32, i32, i32
    • xmm1: i32, i1, i1, i1, i1
  • Fonksiyon prologue’u primitive’leri çıkarır ve Rust düzeyindeki değerlere yeniden birleştirir
    • Option<usize>: { i64, i1 }
    • trait object: { ptr, ptr }
    • &str: { ptr, i64 }
    • [char; 6]: [6 x i32]
    • Options: { i32, i1, i1, i1 }
  • Argüman değerlerini gerçekten materialize eden komutlara !dbg metadata eklenirse gdb argüman değerlerini yazdırırken daha iyi sonuç verebilir
  • Mevcut rustc aynı fonksiyon için LLVM’e pointer boyutunda 8 parametre geçirir; sonuçta 6 tamsayı register’ının tamamı kullanılır ve 2 değer stack üzerinden geçirilir

Dönüş değeri ve Result optimizasyon olanağı

  • Bu tasarım mümkün olan tüm çağrı kuralı optimizasyonlarını kapsamaz
  • Bazı durumlarda x86’daki AVX register’ları gibi ek register’lar kullanılabilir
  • Struct’ı register ve stack arasında bölerek aktarma yöntemi de değerlendirilebilir
  • Result dönüşü için ayrı optimizasyon fırsatları vardır
    • ? ile birden çok fonksiyon katmanından geçildiğinde yinelenen register taşımaları artabilir
    • Result register’a sığmayacak kadar büyükse, her ? çağrı stack’inde ok bit’ini bellekten yükleyip kontrol etmek gerekir
    • Alternatif olarak error bir out-parameter pointer olarak tutulabilir; ok variant payload ve is-ok bit ise Option<T> olarak döndürülebilir
    • ? işlecinin Into çağrısını beraberinde getirdiği ayrıntıları ele almak zor olsa da uygulanabilir

Optimizasyona bağımlı ABI

  • Rust, C’den farklı olarak -Zcallconv=fast içinde çağıranın göreceği ABI’yi oluştururken fonksiyon gövdesini görebilir
  • Crate, fonksiyon bazında register aktarımı açısından kesin ABI’yi ilan edebilir
  • En basit optimizasyon, kullanılmayan argümanları ABI’den atmaktır
    • Fonksiyon hiçbir parametreyi kullanmıyorsa o argüman için register harcanmaz
  • &T argümanı saklanmıyor, raw pointer’a dönüştürülmüyor ve T küçük olup T: Freeze ise referans yerine pointee’nin kendisi by-value aktarılabilir
  • HashMap::get() gibi API’ler adaydır
    • key i32 gibi bir tipse şu anda tamsayıyı stack’e spill edip onun pointer’ını geçirmek gerekir
    • Bu bellek trafiği önlenebilir
  • Profil tabanlı ABI daha agresif bir biçimdir
    • Daha hot argümanlara register ayırma sırasında öncelik verilebilir
    • Büyük bir struct referansla alınsa bile, hot olan 3 i64 alanı çağıran tarafından önceden load edilip hem pointer hem register üzerinden geçirilebilir
    • Callee zaten o load’u yapacağı için ek maliyet görmez
    • Instrumentation profile, yalnızca ABI’si farklı olan fonksiyon kopyalamayı da gerekçelendirebilir

Neden hâlâ yapılmadı?

  • Rust, C++’a göre daha az ABI kısıtına sahip olduğundan daha iyi kod üretebilir ve bu fikir Go register ABI’nin pratikte kullandığı yöntemle de örtüşür
  • İlk engel ABI kod üretimi karmaşıklığıdır
    • LLVM yararlı kontrol düğmelerini neredeyse hiç sunmaz
    • rustc içinde de dostça bir alan değildir
    • Yanlış uygulanırsa kullanılabilirlik açısından kötü sonuçlar doğurabilir
  • Bir diğer engel uzmanlık eksikliğidir
    • rustc katkıcıları arasında LLVM semantiğini ve kod üretimi özelliklerini yeterince anlayıp iyi kod çıkarabilecek ve LLVM’i çökertmeyecek kişi sayısı azdır
  • Derleme süresi de yük olabilir
    • Fonksiyon imzaları karmaşıklaştıkça LLVM’in işlemesi gereken prologue/epilogue kodu artar
    • Ancak -Zcallconv yalnızca optimizasyon açıkken kullanılmak üzere tasarlandığından, bu kesin bir dezavantaj olarak görülmez
  • Rust’ın ABI kodu bus factor’ı düşük bir alandır ve LLVM bilgisi, Rust derleyici ekibinin daha optimize kod üretmesine yardımcı olmak için doğrudan kullanılabilir

1 yorum

 
GN⁺ 2024-04-20
Hacker News yorumları
  • Çağrı kuralını optimize ederken kilit nokta, kafanızda iyi göründüğünü düşündüğünüz biçimleri tartmak değil, performansı ölçmektir.
    Kod hızlıysa iyidir; hızlı görünüyorsa değil.
    Yazarın kötü kod dediği şeyin, hiç de sezgisel olmayan nedenlerle en hızlı olduğu durumlar da olabilir; bunu ancak büyük benchmark’larda ölçerek anlayabilirsiniz.
    Kötü görünen bir çağrı kuralının iyi çalışmasının nedenlerinden biri, argüman register’larını idareli kullanarak register ayırıcıyı biraz daha rahatlatmasıdır.
    Ayrıca günümüz CPU’ları C derleyicilerinin ürettiği komut akışına göre optimize edilmiştir; özellikle MSVC gibi stack üzerinden aktarımı beklenenden sık yapan C derleyicisi tarzı kod üretirseniz CPU’nun en verimli noktasına denk gelebilirsiniz.
    Inlining o kadar iyi çalışıyor ki sıcak yollarda çağrılar nadir bir sınır hâline geliyor; bu sınır biraz dağınık olsa bile başka şeyleri basitleştiriyorsa sorun olmayabilir.
    Buradaki değişikliğin kötü olduğu anlamına gelmiyor; ama yalnızca tuhaf görünen koda bakıp ölçüm yapmadan tartışmak tuhaf.
    JavaScriptCore’da işim çağrı kuralı optimizasyonuydu ve gerçek, büyük kod tabanlarında kötü görünen stack üzerinden aktarım kodunun kazandığı durumlar şaşırtıcı derecede sık yaşanıyordu.

    • Kodun hızlı görünüyor olmasının her zaman gerçekten hızlı olduğu anlamına gelmediğine güçlü biçimde katılıyorum.
      Ancak performans ölçüm sonuçlarının tek ölçüt olmaması gerektiğini düşünüyorum.
      “Günümüz” CPU’larının optimize edildiği ifadesindeki önemli kelime günümüz; CPU’lar sürekli değiştiği için çağrı kuralı uzun vadeli bir tasarım olmalı.
      Bu yüzden, ne yazık ki C++’ın yaptığı biçimden çok uzaklaşmamak avantajlıdır. Çünkü gelecekteki işlemci optimizasyonlarının da büyük olasılıkla o tarafa hedeflenmesi muhtemel.
      Aynı zamanda, argüman register’larını idareli kullanmak gibi kolay kolay değişmeyecek genel ilkeleri dikkate alarak çağrı kuralını sağlam ve geleceğe dönük yapmak iyi olur.
      Rust’ın son birkaç yılda tuhaflık toleransı (https://steveklabnik.com/writing/the-language-strangeness-bu...) açısından fazla muhafazakârlaştığını düşündüğüm için bunu söylemek biraz garip geliyor. Sonuçta farklı olmadan daha iyi olmak mümkün değil.
    • Register üzerinden aktarımın daha hızlı olup olmadığı da fonksiyon gövdesine bağlıdır.
      Fonksiyon başlar başlamaz parametrenin adresini alıp bilinmeyen bir fonksiyona geçiriyorsa, zaten stack’e dökmek zorunda kalırsınız.
      Fonksiyon gövdesine dayalı çağrı kuralı optimizasyonunu görmek ilginç olurdu. C’deki statik fonksiyonlar için, adresleri alınmadığı sürece güvenli gibi görünüyor.
    • Bu deneyim bire bir taşınmaz.
      JIT, tek bir assembly satırı üretmeden önce bile gerçekten çalışmakta olan CPU hakkında çok bilgi toplamış durumdadır; bu yüzden bu problemde avantajlıdır.
      Tamamen statik derlenmiş kodda ise çalışma zamanındaki mimari özellik kümesi bilinemez; bu nedenle en çok optimize etmek istediğiniz kodda aksine sık sık inlining bariyerleriyle karşılaşırsınız.
    • Performans yalnızca çalışma hızını değil, binary boyutunu da kapsayabilir.
      Mevcut Rust küçük platformlarda bu konuda zayıf görünüyor ve çağrı kuralı Result dönüşleriyle ilgili olarak yardımcı olabilir.
    • Orijinal metin çoğunlukla x86 ile ilgili; Intel, insanların satın aldığı kendi silikonları üzerinde çirkin x86 kodunun hızlı çalışmasını sağlamak için onlarca yıl boyunca şaşırtıcı mühendislik yaptı.
      Yine de stack üzerinden aktarımın ampirik avantajının, çok sayıda register’a sahip ARMV8 CPU’lara veya RISC-V’ye geçildiğinde de geçerli olup olmayacağını merak ediyorum.
  • Makul bir taslak, ancak çağıranın sakladığı/çağrılanın sakladığı ayrımı eksik ve giriş register’larının bir kısmını çıktıya atama gibi yaygın bir hata var
    Debugger’ın C’den farklı bir çağrı kuralını anlayacağını beklemek de iyimser. DWARF neyi encode edebiliyor olursa olsun, pratikte feci biçimde başarısız olma olasılığı yüksek
    Optimizasyon ayarlarına göre ABI’yi değiştirmek ayrı derleme ile çok kötü etkileşir
    Argümanları bin packing yapar gibi yeniden yerleştirme yöntemi çalışır, ama derleyici karmaşıklığını ciddi artırır; soldan sağa ilk uyan yerleştirmeye kıyasla buna değip değmediğini bilmiyorum. Geliştiricinin argümanların nereye gideceğini tahmin etmesi de zorlaşır
    Adresi dışarı kaçan fonksiyonlarla kaçmayan fonksiyonlar için farklı çağrı kuralları belirleme yönündeki genel fikir mantıklı. Empedans eşlemesi yapan prologue’u ayırma yöntemi de iyi çalışır
    Rust’ın C’den farklı bir çağrı kuralına sahip olmaya istekli olması gerekir, ama bunun tüm fonksiyonların kullandığı, hard-code edilmiş tek bir kural olması gerekip gerekmediğinden emin değilim. Bunu tip sistemine koymak doğal görünüyor; geliştiricinin çağrı kuralını kontrol edebilmesini sağlamak da assembly’nin performans avantajlarından birini ortadan kaldırır

    • Giriş register’larının bir kısmını çıkış register’ı olarak kullanmanın neden bu kadar sorun olduğunu merak ediyorum
      Çağıran açısından zaten iki fonksiyon çağrısı arasında çıkış register’larını boşaltmak gerekir ve sistem çağrısı kurallarında da oldukça yaygın kullanılır
      Amaç çağrılan tarafın giriş değerlerini olduğu gibi tutarken çıkış değerini hazırlamasını kolaylaştırmak mı, diye düşünüyorum. Öyleyse çıkış register’larını giriş sırasının sonuna koyup çakışmayı önlemeye çalışmayı anlayabilirim, ama herhangi bir çakışmayı tamamen yasaklamak için iyi bir neden pek göremiyorum
    • Geliştiricinin çağrı kuralını kontrol etmesine izin verirseniz, Function A’nın Function B, Function C, Function D’yi çağırdığı türden bir zincirde ara fonksiyonların argümanlarını başka kurallara çevirerek overhead’i azaltan optimizasyon da aynı anda engellenir
      Böyle bir optimizasyonu korurken kontrol yetkisine de izin veren semantiğin ne olacağı, hatta bunun fiilen bir hayalden ibaret olup olmadığı kuşkulu
      Gerçekte assembly çoğu derleyici optimizasyonunun hedefi olmadığı için performans açısından dezavantajlıdır. “Davranışa bakıp tamamen gereksiz olduğunu saptama ve tümüyle kaldırma” optimizasyonundan da çoğu zaman yararlanamaz; artık 1990’larda değiliz
      Yine de bu tür optimizasyonların dikkate bile alınamadığı durumlarda, inline assembly’nin kesin geri kalacağı yerin en fazla profil tabanlı optimizasyon olduğunu düşünüyorum. Çünkü uygulama geliştiricisi kodun davranışını kusursuz bilir, derleyici geliştiricisi ise bilmez
      Çağrı overhead’i, ilgili sıcak sınırları kapsayana kadar daha fazla assembly yazarak ortadan kaldırılabilir
    • DWARF şu anda özel çağrı kurallarını hiç encode etmiyor
    • Bin packing aksine yavaşlatma olasılığı taşıyor; özellikle bool söz konusu olduğunda bağımlılık zinciri oluşturabilir
      x64’te bool’ları önce register’a koyup, shift edip, sonucu OR’lamaktan daha iyi bir yöntem pek görünmüyor
      Basit yöntem uzunluğu 64 olan bir bağımlılık zinciri oluşturur ve 64 cycle ceza doğurabilir; ama iyi yapılırsa 6 cycle’a, gerçekçi olarak da yaklaşık 12 cycle’a indirilebilir gibi
      Fakat 64 adet bool’un nereden geldiği de sorun. O kadar çok register yok, dolayısıyla sonunda stack’ten yeniden okumak gerekir
      Rust ABI’si struct içindeki bool’u zaten böyle sıkı paketliyorsa bu iş zaten yapılacaktır, ama tam bilmiyorum
      Üstelik çağıran tarafın da hepsini yeniden açması gerekir
      Derleyiciye değerleri stack’teki sonuç alanına akıtmayı öğretmek daha kolay ve muhtemelen daha performanslı olur
    • Modern işlemciler çoğunlukla bir store’dan hemen sonra gelen read’i kolayca forward eder ve stack durumu takibi için de çeşitli hileler vardır
      Bu durumda değerleri register’a koymanın pratikte ne kadar fayda sağladığı şüpheli
  • C çağrı kuralı pek iyi değil
    C çağrı kuralını değiştiremeyeceğimiz doğru, ama bu durumu daha az üzücü yapmıyor
    Kullanılabilir tüm caller-saved register’lar argümanlar ve dönüş değerleri için kullanılmalı; oysa geleneksel SysV ABI’de dönüş değeri için yalnızca bir, bazen iki register kullanılır
    struct Point3D { long x, y, z } döndürürken Point3D rax, rdi, rsi içine konabilecekken stack’e dökülüyor
    Başka sistemlerde başka numaralar da var. Doğru hatırlıyorsam SBCL’de bir fonksiyon birden fazla değer döndürdüğünde çıkışta carry flag ayarlanıyor. Örneğin Result’ın hata içerip içermediğini belirtmek için carry flag kullanılsa iyi olmaz mı diye düşünüyorum

    • “Pek iyi değil” güçlü bir ifade, ama dönüş değerleri konusunda doğru
      C çağrı kuralı fiilen C’nin desteklediği şeyi, yani tek argüman dönüşünü destekler. Struct döndürme bile doğru düzgün değil
      C’de bu daha çok “bunu bilmiyor muydun” durumuna yakın; C++ tarafında ise “zaten inline etsen yeter” tarzına dönüşüyor
      Öte yandan memory spill gerçekten olur. Örneğin SPARC’ın geniş register alanı ve window’ları basit fonksiyonlarda çok sayıda kullanılmayan register bırakıyordu; register ring’i spill etmek de cache’i bozan büyük stack kullanımına yol açıyordu
      x86’da veriyi “gereken yere” yeniden yerleştiren çok sayıda mov olsa bile sonuçta çoğu zaman daha hızlıydı
      Yalnızca çağrılan koduna bakınca “bu argüman burada, şu dönüş değeri şurada olursa kesin daha hızlı olur” demek geliyor insanın içinden, ama çağıranı bilemezsiniz
      Argüman hazırlığının aynen geçip geçmeyeceği, dönüş değerinin sıcak biçimde tüketilip tüketilmeyeceği garanti edilemez. Örneğin struct Point { x: i32, y: i32, z: i32 } argüman/dönüş olarak kullanılıyorsa ve çağıran bir döngüde mystruct.deepinside.point[i] = func(mystruct.deepinside.point[i]) gibi bir şey yapıyorsa, register’a koyup çıkarmak overhead’e dönüşebilir veya vektörleştirmeyi engelleyebilir
      Çağrılan bunu bilemez; derleyicinin iki tarafı da görüp inline edebildiği durumlar bunun istisnasıdır
      Çağrı ile ilgili en kolay kazanım, neredeyse tüm C ABI’lerine işlemiş fonksiyon ilkel tek bir değer döndürür varsayımını ortadan kaldırmak gibi görünüyor. Geri kalanı için çok sayıda benchmark ve kod üretimi istatistiği gerekir
  • Rust’ta yapıların istenenden daha büyük olmasına yol açan can sıkıcı bir başka ayrıntı daha var
    None veya Some(u8) olan 8 adet Option alanı içeren bir Foo yapısını düşünürsek, C’de 1 bitlik 8 bool ve 8 adet uint8_t ile toplam 9 baytta ifade etmek mümkün
    Rust’ta ise 1 baytlık ayırt edici ve uint8_t 8 kez tekrarlandığı için 16 bayt oluyor
    Bunun nedeni, yapının alanların ödünç alınmasını sağlayabilmesi gerekmesi. Elinizde &Foo varsa derleyicinin &Foo::some_field, yani bir &Option oluşturabilmesi gerekir; bu &Option da programdaki diğer tüm &Option’larla aynı biçimde olmalıdır
    Bu yüzden içerideki Option, programdaki diğer Option’larla aynı yerleşime sahip olmalı: yani kendi ayırt edici bitlerinin bayta yuvarlanmış hâli ve u8. Gerçekte &Foo::some_field hiç oluşturulmasa bile yapı bu maliyeti öder
    Daha büyük tiplerin Option’larını düşünürsek durum daha da kötüleşir. 8 Option alanı olan bir yapıda her ayırt edici 2 bayta yuvarlanır, toplam 32 bayt eder; dörtte biri, hatta ayırt edicilerin kullanılmayan bitleri de dahil edilirse neredeyse yarısı ara padding olarak boşa gider. C’deki eşdeğer yapı için 18 bayt yeterlidir
    Option kullanıldığında Rust yapısı 128 bayt, C yapısı ise 72 bayt olabilir
    Elbette paketlenmiş ayırt ediciler için bir u8 ve 8 adet MaybeUninit tutup, &Foo için Option<&T>’ye, &mut Foo için Option<&mut T>’ye eşleyen fonksiyonları kendiniz yazarak C’dekiyle aynı temsili uygulayabilirsiniz. Ancak bunu &Option veya &mut Option olarak yapamazsınız
    https://play.rust-lang.org/?version=stable&mode=debug&editio...

    • C sürümünü de elle uygulamak gerektiğinden, Rust’ta da aynısını elle yapmak o kadar tuhaf değil
      Esasen 8 adet Option tutan kullanıcı tanımlı bir tip tarif ediliyor; performansı önemsemeye başladığınızda iç Option işlemesini kendiniz yönetmeniz gerekir
    • C’deki eşdeğer sürümü de elle uygulamak gerekir
      Rust’ın hedefe uygun olduğunda seçip kullanabileceğiniz kullanışlı bir özellik sunması, bunu bir dezavantaj yapmaz
      Anlatılan kullanım durumu görece nadir; gerçek bir performans darboğazıysa Rust’ta biraz daha zaman harcayıp uygulamak büyük bir sorun değil
      Genel kullanımda Option<_> tipinin sağladığı faydalar çok büyük olduğundan, bunu Rust’ın “can sıkıcı ayrıntısı” olarak görmek zor
  • Polimorfik olmayan, inline edilmeyen bir fonksiyonun adresi fonksiyon işaretçisi olarak alınabiliyorsa -Zcallconv=legacy kullanan bir shim oluşturup gerçek uygulamayı hemen tail call ile çağırmaktan bahsediliyor; fonksiyon işaretçisi eşitliğini koruma niyetini anlıyorum
    Ama legacy shim bir Rust çağrı kuralı fonksiyonunu tail call ile çağırırsa, çağrı kuralının dönüş değeri farkını düzeltemez, değil mi?

    • Doğru. İnsanlar çağrı kuralının dönüş tarafını unutma eğiliminde olduğu için anlaşılabilir bir yazım hatası gibi görünüyor
  • Biraz farklı bir konu ama şu anda Go ile Rust birlikte çalışabilirliği mümkün mü merak ediyorum
    Eskiden araya Zig koyarak bunu başaran bir örnek gördüğümü hatırlıyorum ama bulamıyorum. Eski Rust kodum var ve onu yavaş yavaş Go’ya taşımak istiyorum

    • Mümkün. CGO ile extern "C" FFI kullanarak Rust fonksiyonlarını çağırabilirsiniz
      GitHub kod aramasında bunun nasıl kullanıldığını RustConf 2023’te sunmuştum (https://www.youtube.com/watch?v=KYdlqhb267c); sonrasında 1Password gibi yerlerde de benzer şeyler yaptıklarını duydum
      C birlikte çalışabilirlik sınırının üzerinden tip taşımak zahmetli olduğu için pek eğlenceli değil, ama mümkün ve kod yeniden kullanımı da mümkün
    • Go’dan Rust çağırmak için Rust fonksiyonunu extern "C" olarak bildirip, Go’dan C çağırır gibi çağırmanız yeterli
      Ters yönü pek bilmiyorum
    • Yönetilen bellek ile yönetilmeyen belleği karıştırmak genellikle akıllıca değildir
      Yönetilen kod, serbest bırakacağı veya taşıyacağı belleğe sahip olabilmeli; yönetilmeyen kod ise belleğin ne zaman serbest bırakılacağını veya taşınacağını çıkarımlayabilmelidir
      cgo gibi şeyler Go’nun yönetilen kodundan yönetilmeyen belleğe FFI çağrılarını karıştırmanıza izin verir, ama bunun bir bedeli vardır
      Birbirini çağıran dillerin çöp toplayıcıyı paylaşmadığı uygulamalarda bu sorun her zaman ortaya çıkar
      Yönetilen/yönetilmeyen kodu karıştırmak eski bir fikir olmakla birlikte hâlâ aktif olarak araştırılan bir konudur
      Yerleşik runtime özellikle bunun için tasarlanmış değilse, yönetilmeyen koddan yönetilen koda çağrı yapmak neredeyse her zaman kötü bir fikirdir ve genellikle araya bir serileştirme katmanı girer
    • Rust ve Swift’i epey fazla kullanmam gerektiği için, sonunda serileştirilmiş protobuf’un bayt dizilerini sıradan fonksiyon çağrılarıyla alıp verme yönteminde karar kıldım
      Bu ana işinizse kötü olduğunu düşünebilirsiniz, ama birkaç haftada bir koda döndüğümde neyi nasıl yaptığımı hatırlayamamaktan bıktım
    • Epey lanetli bir örnek olarak, yakın zamanda Rust’tan araya C koyarak Go kodu çağırdım
      Durum tutan bir Rust closure’ını Go koduna callback olarak geçirip bir Go standart kütüphane fonksiyonuna verdim; Rust closure’ının içindeki panic unwind’ı da buna dahildi
      https://github.com/Voultapher/sort-research-rs/commit/df6c91...
  • Bölüm başlığının nasıl eğik ayarlandığını anlamak için Öğeyi İnceleye uzun süre baktım ama Safari araçlarında takıldım. Bunu nasıl yapmışlar?

    • Stil .post-title öğesinde: transform: skewY(-2deg) translate(-1rem, -0.4rem);
    • Bununla bağlantılı olarak minimap’in CSS element() fonksiyonunu (https://developer.mozilla.org/en-US/docs/Web/CSS/element) kullandığını sanmıştım; aslında yazı gövdesinin çok küçültülmüş bir kopyasıymış
    • h1, h2, h3, h4, h5, h6 için transform:skewY(-2deg) translate(-1rem,0rem);, transform-origin:top;, font-style:italic;, text-decoration-line:underline;, text-decoration-color:goldenrod;, text-underline-offset:4%;, text-decoration-thickness:.25ex uygulanmış
  • Buna karşılık 2019 tarihli “How Swift Achieved Dynamic Linking Where Rust Couldn't” yazısı var
    https://faultlore.com/blah/swift-abi/
    Rust’ın hâlâ Rust düzeyi semantik için bir çağrı kuralına sahip olmaması üzücü; ama aynı zamanda o yazı, oraya varmak için gereken iş miktarının muazzam olduğunu gösteriyor
    Apple, Swift’i uygulamaların güvenebileceği pratik bir sistem dili yapmak için güçlü biçimde motive olmuştu; Rust’ın ise böyle bir sponsoru yok
    HN tartışması: https://news.ycombinator.com/item?id=21488415

    • Adil olmak gerekirse Swift yönteminin çalışma zamanı maliyetleri olduğu da belirtilmeli
      Rust’ta da bu ödünleşme için daha fazla destek seçeneği olsa iyi olurdu; bunun https://github.com/rust-lang/rfcs/pull/3470 gibi şeylerle sınırlı kalması gerekmiyor
  • Mevcut Rust derleyicisi agresif biçimde inline edip sonra optimize ediyorsa, bunun zahmete değip değmeyeceği konusunda şüpheliyim
    Çağrılan fonksiyon küçükse inline edilecektir; büyükse fonksiyonun içinde epey zaman harcanacağından çağrı ek yükü küçük olacaktır

    • Çalışma zamanı fonksiyonları, örneğin dyn Trait, inline edilemez; dolayısıyla böyle bir değişiklik fayda sağlar
      Çağrıları ucuzlatabilirsek bu kadar agresif inline etmeye gerek kalmayabilir; bu da kod boyutuna ve derleme süresine yardımcı olabilir
    • Muhtemelen değer gibi görünüyor
      Inline etmeye uygun olmayan karmaşık bir fonksiyonun belleğe birkaç kez erişme olasılığı yüksektir ve bu erişimin darboğaz olma ihtimali büyüktür
      Yığın üzerinden aktarma, önbellek baskısını ve yükleme/saklamaları artırdığı için bu darboğazı daha da daraltır
      Rust, fonksiyon çağrılarının kayda değer bir oranında argümanları en iyi şekilde aktarabilirse yalnızca birkaç L1 erişim döngüsünden kaçınmakla kalmaz, CPU’nun asıl bellek darboğazına daha hızlı ulaşmasını da sağlayabilir
      Belki birkaç yüzde puanlık bir kazanç olabilir; ama şu an şarap içiyorum ve hesap yapmıyorum
  • x86 referans materyalindeki “Diana’s silk dress cost $89” ezber cümlesinin ne olduğunu açıklayabilir misiniz?