2 puan yazan GN⁺ 2024-04-20 | 1 yorum | WhatsApp'ta paylaş

Bu yazı, Rust dilinin Calling Convention mekanizmasının nasıl iyileştirilebileceğini ayrıntılı olarak açıklıyor.

Rust'ın mevcut Calling Convention yapısının sorunları

  • Rust'ta şu anda çağrı kuralı (Calling Convention) açık biçimde tanımlanmış değil
  • Pratikte LLVM'in varsayılan C çağrı kuralı kullanılıyor
  • Rust şu anda muhafazakâr biçimde, Clang'in üretebileceği türden LLVM fonksiyon imzaları üretmeye çalışıyor
    • Hata ayıklayıcılarla uyumluluk için
    • LLVM hatalarından kaçınmak için
  • Ancak fazla muhafazakâr olduğu için basit fonksiyonlarda bile kötü kod üretiyor
fn extract(arr: [i32; 3]) -> i32 { arr[1] }
  • Yukarıdaki kodun register üzerinden aktarılması gerekirken pointer ile aktarılıyor
  • Rust, C ABI'den bile daha muhafazakâr. extern "C" olarak belirtildiğinde register ile aktarılıyor.

Yeni Calling Convention önerisi

  • extern "Rust" fonksiyonları için mevcut çağrı kuralı korunur
  • extern "Rust" fonksiyonlarının çağrı kuralını ayarlamak için -Zcallconv bayrağı eklenir
    • -Zcallconv=legacy mevcut yöntemdir
    • -Zcallconv=fast yeni tasarlanacak yöntemdir
  • Neden mevcut çağrı kuralı korunmalı?
    • Hata ayıklama kolaylığı için C ABI sırasına göre yerleştirme yapılmaz
    • WASM gibi bazı hedefler bunu desteklemeyebilir
    • Debug derlemelerinde bunun pek anlamı olmayabilir
  • Fonksiyon pointer'ları ve extern "Rust" {} bloklarıyla ilgili dikkat edilmesi gerekenler
    • Bu, crate düzeyinde bir bayrak olduğu için fonksiyon pointer'larına uygulanamaz
    • Fonksiyon pointer çağrıları yavaştır ve nadirdir; bu yüzden -Zcallconv=legacy kullanılır
    • Gerekirse çağrı kuralı dönüşümü için shim üretilir
    • extern "Rust" { fn my_func() -> i32; } gibi doğrudan çağrı durumlarında
      • Yalnızca mangling yapılmamış semboller çağrılabilir
      • #[no_mangle] fonksiyonlar mevcut çağrı kuralını kullanır

LLVM'i kullanma yaklaşımı

  • İdeal olarak LLVM'e çağrı kuralını doğrudan belirtebilmek güzel olurdu, ancak pratikte bu zor
  • Bunun yerine şu adımlarla dolaylı bir çözüm mümkün
    • Verilen hedef için register ile aktarılabilecek azami değer sayısını belirle
    • Dönüş değerinin nasıl aktarılacağına karar ver. Register'a sığıyorsa olduğu gibi, büyükse referansla aktar
    • Değer olarak geçirilen argümanlar arasından hangilerinin referansla aktarılması gerektiğini seç
      • Register ile aktarılabilir alandan büyük olanlar
      • x86'da bu yaklaşık 176 bayt
    • Register alanını en iyi şekilde kullanmak için hangi argümanların register ile aktarılacağını belirle
      • Bu NP-hard bir problem olduğu için sezgisel bir yöntem gerekir
      • Kalanlar stack üzerinden aktarılır
    • LLVM IR ile fonksiyon imzasını oluştur
      • Register ile aktarılan argümanlar i64, ptr, double, <2 x i64> gibi non-aggregate türlerle ifade edilir
      • Stack üzerinden aktarılan argümanlar "register inputs" kuralını izler
    • Fonksiyon prologue'unu oluştur
      • Rust düzeyindeki argümanları register girdilerinden decode edip -Zcallconv=legacy durumundakiyle aynı %ssa değerlerini üret
      • Fonksiyon gövdesi, çağrı kuralından bağımsız olarak aynı kodu üretebilir
      • Gereksiz decode kodu DCE ile kaldırılır
    • Fonksiyon return bloğunu oluştur
      • -Zcallconv=legacy durumundakiyle aynı dönüş tipi için phi komutlarını içerir
      • Gerekli çıktı biçimine encode edilip ret ile döndürülür
      • ret yerine bu bloğa dallanılmalıdır
    • Fonksiyon pointer olarak kullanılabilecek monomorfik ve inline olmayan fonksiyonlar varsa
      • Crate dışına açılıyorsa veya fonksiyon pointer olarak geçiriliyorsa
      • -Zcallconv=legacy kullanan bir shim üretilir ve gerçek implementasyona Tail Call yapılır
      • Fonksiyon pointer eşitliğini korumak için bu gereklidir

LLVM'in register ile aktarma sınırlarını nasıl belirleyebiliriz?

  • LLVM'in izin verdiği azami register iletimi sayısını kontrol eden bir LLVM programı mevcut
  • x86'da girişte 6 tamsayı, 8 SSE vektörü; çıkışta ise 3 tamsayı, 4 SSE vektörü mümkün
  • aarch64'te giriş ve çıkış için aynı şekilde 8 tamsayı ve 8 vektör kullanılabiliyor
  • Bu sınırın ötesindeki değerler stack'e aktarılıyor

Rust'ta struct ve enum işleme

  • rustc'nin bunları zaten temel aggregate ve union olarak ele aldığı varsayılıyor
  • Dönüş değeri işleme
    • Önemli olan struct'ın boyutu değil, padding hariç gerçek veri boyutu
    • [(u64, u32); 2] 32 bayt olsa da 8 bayt padding çıkarıldığında 24 bayt kalır
    • Türün etkin boyutu (Effective Size) tanımlanır
      • Padding hariç tanımsız bitlerin sayısı
      • [(u64, u32); 2] için 192 bit
      • bool için 1 bit
    • Etkin boyut, çıkış register alanından küçükse değer olarak döndürülür
    • x86'da 3 tamsayı + 4 SSE = 88 bayt = 704 bit
  • Argüman register işleme
    • Bu bir Knapsack problemi, yani NP-hard
    • Basit bir sezgisel yaklaşım
      • Etkin boyut toplam giriş register alanından büyükse referansla aktar
      • Enum'ları discriminator-union çiftine dönüştür
      • Union'lar başlatılmamış bitlere dokunabileceği için u8 dizisi ya da boş olmayan tek bir varyant olarak aktar
      • Pointer, tamsayı, kayan nokta, boolean gibi en temel öğelere kadar düzleştir
      • Etkin boyuta göre artan sırada sırala
      • Mümkün olan en büyük öneki register'a yerleştir, kalanları stack'e koy
      • Stack'e gidecek girdilerin bir kısmı pointer boyutunun küçük katlarından büyükse stack üzerindeki pointer ile aktar
      • Kalanlar sıralama öncesindeki sıraya göre doğrudan stack'e aktar
      • Register ile aktarılacakları boyuta göre azalan sırada yerleştir
      • Boolean değerleri 64'erli bit paketleme ile taşı

GN+ görüşü

  • Bana göre Rust'ın mevcut çağrı kuralı gerçekten büyük bir eksik. C++'tan çok daha iyi performans verebilecekken bunu hâlâ yapamıyor
  • Go dili bunu zaten uzun zaman önce uyguladı
  • Rust'ın bunu henüz uygulayamama nedenleri
    • ABI kod üretimi karmaşık ve LLVM bu konuda pek yardımcı olmuyor
    • Derleyici ekibinde LLVM'i çok iyi bilen kişi sayısı az
    • Derleme süresiyle ilgili kaygılar var, ancak bu yalnızca optimize derlemelerde kullanılacağı için büyük bir sorun değil
  • Yazarın bunu bizzat düzeltmeye zamanı yok, ancak LLVM uzmanlığıyla Rust derleyici ekibine yardımcı olmaya istekli
  • Ya da doğrudan extern "C" veya extern "fastcall" kullanımına geçmek de bir alternatif olabilir

1 yorum

 
GN⁺ 2024-04-20
Hacker News görüşü

Özet:

  • Optimize edilmiş bir çağrı kuralı (calling convention) oluştururken performansı doğrudan ölçmek önemlidir. Tuhaf görünen kod, pratikte en hızlısı olabilir.
  • Günümüz CPU'ları, C derleyicisinin ürettiği komut izlerini optimize eder; bu yüzden C derleyicisi gibi değerleri sık sık stack üzerinden geçirmek faydalı olabilir.
  • Inlining çoğu durumda başarılı olduğu için çağrılar seyrek bir sınır haline gelir; bu nedenle başka şeyleri basitleştirmek adına bu sınırda bir miktar düzensizliğe izin verilebilir.
  • Rust'ta struct'ların alanlara referans verebilmesi gerektiğinden, boyutları C'ye göre daha büyük olabilir. Option<u8> alanı 8 adet olan bir struct Rust'ta 16 bayt, C'de ise 9 bayttır.
  • Rust'ta elle C ile eşdeğer bir uygulama yapılabilir, ancak bu &Option<T> veya &mut Option<T> ile eşlenemez.
  • Rust'ın henüz Rust düzeyi anlamlar için bir çağrı kuralı yok. Apple'ın bunu geliştirmek için bir motivasyonu vardı, ancak Rust tarafında böyle bir destek yok.
  • Go ile Rust arasındaki birlikte çalışabilirlik şu anda arada Zig kullanılarak sağlanabiliyor.
  • Mevcut Rust derleyicisi agresif inlining ve optimizasyon yaptığı için, bu sorunu çözmenin gerçekten değerli olup olmadığı tartışmalıdır.
  • Debugging için Cargo.toml bayrakları kullanılarak kaygılar azaltılabilir. Alanları boyuta göre sıralamak kolay bir optimizasyondur ve repr ile devre dışı bırakılabilir.