1 puan yazan GN⁺ 1 시간 전 | 1 yorum | WhatsApp'ta paylaş
  • Async Rust, executor’dan bağımsız kodun sunucularda ve mikrodenetleyicilerde birlikte çalışmasını mümkün kılıyor; ancak derleyicinin ürettiği durum makinesi nedeniyle özellikle gömülü tarafta ikili dosya boyutundaki artış belirgin oluyor
  • bar() gibi iki adet await noktası olan basit bir örnek bile 360 satırlık MIR ile Unresumed, Returned, Panicked, Suspend0, Suspend1 durumlarını üretiyor; eşzamanlı sürüm ise yalnızca 23 satır gerektiriyor
  • Tamamlanmış bir future yeniden poll edildiğinde panic yerine Poll::Pending döndürecek şekilde değiştirmek, unsafe davranış olmadan sözleşmeyi karşılamayı mümkün kılıyor ve deneylerde gömülü firmware’in ikili dosya boyutunu %2 ila %5 azaltıyor
  • Await içermeyen async { 5 } bile şu anda varsayılan üç durumlu bir durum makinesi üretiyor; ancak her seferinde Poll::Ready(5) döndürecek şekilde optimize edilirse gömülü ikili boyutu %0,2 azalıyor
  • Önerilen Project Goal, sürüm modunda tamamlanma sonrası panic’in kaldırılması, await içermeyen async block’larda durum makinesinin kaldırılması, tek await’li future’ın inline edilmesi ve aynı durumların derleyicide birleştirilmesini hedefliyor

Async Rust’ta derleyici seviyesinde şişme sorunu

  • Async Rust, executor’dan bağımsız kodun sunucularda ve mikrodenetleyicilerde aynı anda çalışmasını sağlıyor; ancak küçük mikrodenetleyicilerde ikili dosya boyutundaki artış özellikle dikkat çekiyor
  • Rust blogu async/await’i sıfır maliyetli soyutlama olarak tanıttı; ancak async gerçekte ciddi miktarda şişme yaratıyor ve masaüstü ile sunucu tarafında da aynı sorun var, sadece bellek ve işlem kaynakları fazla olduğu için daha az görünür kalıyor
  • Async kod yazarken şişmeyi önlemeye yönelik geçici çözümlerin ardından, sorunu derleyici tarafında çözmek için bir Project Goal sunuldu
  • Future’ın gerekenden fazla büyümesi ve çok sayıda kopyalama yapılması kapsam dışında bırakıldı

Üretilen future’ın yapısı

  • Örnek kodda foo() async { 5 } döndürüyor ve bar() ise foo().await + foo().await çalıştırıyor
  • bar içinde iki await noktası bulunduğundan durum makinesinde en az iki durum gerekse de, gerçekte daha fazla durum üretiliyor
  • Rust derleyicisi çeşitli geçişlerde MIR dökümü yapabiliyor ve coroutine_resume geçişi son async’e özgü MIR geçişi
    • Async, LLVM IR’de artık yer almasa da MIR’de kalıyor; dolayısıyla async’in durum makinesine dönüştürülme süreci MIR geçişlerinde gerçekleşiyor
  • bar fonksiyonu 360 satırlık MIR üretiyor; eşzamanlı sürüm ise yalnızca 23 satır kullanıyor
  • Derleyicinin çıktıladığı CoroutineLayout, fiilen enum biçimindeki bir durum kümesi
    • Unresumed: başlangıç durumu
    • Returned: tamamlanmış durum
    • Panicked: panic sonrası durum
    • Suspend0: ilk await noktası ve foo future’ını saklıyor
    • Suspend1: ikinci await noktası ve ilk sonucu ile ikinci foo future’ını saklıyor
  • Future::poll güvenli bir fonksiyon olduğundan, future zaten tamamlandıktan sonra tekrar çağrılsa bile UB’ye yol açmamalı
    • Şu anda Suspend1 sonrasında Ready döndürüp future’ı Returned durumuna geçiriyor
    • Bu durumda yeniden poll edilirse panic oluşuyor
  • Panicked durumu, async fonksiyon panic verdikten sonra bu panic catch_unwind ile yakalandığında ilgili future’ın tekrar poll edilmesini engellemek için var gibi görünüyor
    • Panic sonrasında future eksik bir durumda kalabilir; bu yüzden yeniden poll etmek UB’ye yol açabilir
    • Bu mekanizma mutex poisoning’e oldukça benziyor
    • Panicked durumuna ilişkin bu yorum için kesin bir belge bulmak zor; bu yüzden buna dair güven düzeyi yaklaşık %90

Tamamlandıktan sonra poll edildiğinde gerçekten panic gerekli mi?

  • Returned durumundaki future şu anda panic veriyor, ancak bunun zorunlu olması gerekmiyor
    • Gerekli olan tek koşul, UB’ye yol açmaması
  • Panic görece maliyetli ve optimizasyonla kaldırılması zor olan yan etkili bir yol ekliyor
  • Tamamlanmış future yeniden poll edildiğinde Poll::Pending döndürmek, unsafe davranış olmadan Future türünün sözleşmesini karşılayabiliyor
  • Derleyici bu yaklaşımla değiştirilip deney yapıldığında, async gömülü firmware’de ikili dosya boyutunda %2 ila %5 azalma görüldü
  • Bu davranışın, tıpkı tamsayı taşmasındaki overflow-checks = false gibi bir anahtarla sunulması öneriliyor
    • Hatalı davranışı hemen görünür kılmak için debug derlemelerinde panic devam ediyor
    • Release derlemelerinde ise daha küçük future’lar elde edilebiliyor
  • panic=abort kullanıldığında Panicked durumunun kendisinin tamamen kaldırılması mümkün olabilir; bunun etkisi ayrıca değerlendirilmeli

Await olmasa bile her zaman durum makinesi üretiliyor

  • foo() yalnızca async { 5 } döndürdüğünden, elle yazılmış en iyi uygulama durum içermeyen ve her zaman Poll::Ready(5) döndüren bir future olurdu
  • Ancak derleyicinin ürettiği MIR’de yine de Unresumed, Returned, Panicked olmak üzere temel üç durum bulunuyor
    • Poll sırasında mevcut durumun discriminant’ı kontrol edilip dallanılıyor
    • Tamamlandıktan sonra yeniden poll edilirse `async fn` resumed after completion assert’i ile panic veriliyor
  • Bu durumda durum makinesi üretmek yerine her seferinde Poll::Ready(5) döndürecek şekilde optimize etmek mümkün
  • Bu değişiklik derleyiciye deneysel olarak uygulandığında gömülü ikili boyutu %0,2 azaldı
    • Kazanç büyük değil, ancak basit bir optimizasyon olduğu için uygulanmaya değer olabilir
  • Bu optimizasyon davranışı bir miktar değiştiriyor, ancak etkilenecek olanlar yalnızca sözleşmeye uymayan executor’lar
    • Mevcut derleyici sonraki poll işleminde panic veriyor
    • Optimizasyondan sonra ise future her zaman Ready döndürüyor

Yalnızca LLVM yeterli değil

  • MIR çıktısı verimsiz olsa bile LLVM’nin her şeyi temizleyebildiği durumlar var, ancak koşullar sınırlı
    • Future yeterince basit olmalı
    • opt-level=3 kullanılmalı
  • Future karmaşıklaştıkça LLVM bunu kaldıramıyor; deyimsel async Rust kodunda future’lar derin biçimde iç içe geçtiği için karmaşıklık hızla büyüyor
  • Gömülü sistemler veya wasm gibi boyut optimizasyonunun sık yapıldığı ortamlarda LLVM her şeyi optimize edemiyor
  • Godbolt örneği: https://godbolt.org/z/58ahb3nne
    • Üretilen assembly’de LLVM, foo’nun 5 döndürdüğünü biliyor ama bar sonucunu 10’a optimize edemiyor
    • foo için poll fonksiyonu çağrısı da hâlâ duruyor
    • Bunun nedeni, derleyicinin tamamen çözemediği potansiyel panic yolları
    • LLVM, foo’nun pratikte yalnızca bir kez çağrıldığını ve panic vermediğini bilmiyor
  • IR içindeki panic dalları yorum satırına alındığında optimizasyon daha iyi oluyor: https://godbolt.org/z/38KqjsY8E
  • LLVM’den sonradan optimizasyon beklemek yerine, derleyicinin LLVM’ye daha iyi girdi vermesi gerekiyor

Future inline etme iyi çalışmıyor

  • Inline etme, sonrasındaki optimizasyon geçişlerini mümkün kıldığı için önemli; ancak üretilen Rust future’ları şu anda erken aşamada inline edilmiyor
  • Her future kendi implementasyonunu aldıktan sonra LLVM ve linker inline etme fırsatı yakalıyor, ancak önceki sorunlar nedeniyle bu aşama artık çok geç kalıyor
  • En doğrudan inline fırsatı, bar() fonksiyonunun yalnızca foo(blah).await yapması durumu
    • Trait kullanılarak soyutlama kurulurken sık görülen bir desen
    • Mevcut derleyici bar için bir durum makinesi oluşturuyor ve onun içinde foo durum makinesini çağırıyor
    • Daha verimli yaklaşımda bar, doğrudan foo future’ı olabilir
  • Preamble ve postamble olduğunda durum daha karmaşık
    • Örneğin bar(input), input > 10 ile blah oluşturuyor, ardından foo(blah).await yapıyor ve sonuca * 2 uyguluyor
    • Bu, özellikle trait implementasyonlarında async fonksiyonları farklı imzalara dönüştürürken sık görülüyor
  • Bu tür bir bar da kendi async durumuna ihtiyaç duymuyor
    • Tek await noktasını aşarak korunması gereken veri, foo içinde yakalanan değerler dışında yok
    • Yine de bar doğrudan foo’nun kendisi olamaz; fakat durumun büyük bölümü fooya bırakılabilir
  • Elle yazılmış bir uygulamada BarFut, Unresumed { input } ve Inlined { foo: FooFut } durumlarına sahip olabilir
    • İlk poll’da preamble çalıştırılır, foo(blah) oluşturulur ve Inlined durumuna geçilir
    • Sonrasında foo.poll(cx) sonucuna postamble uygulanır
  • İlk await noktasından önce kodu önceden çalıştırmak mümkün olsaydı Unresumed durumu da kaldırılabilirdi; ancak future’ın poll edilmeden önce hiçbir şey yapmaması garanti edildiği için bu değiştirilemez
  • Poll edilmekte olan bir future’ın özellikleri sorgulanabilse ek inline optimizasyonları mümkün olabilir
    • Örneğin future’ın ilk poll’da her zaman ready döndürdüğü bilinseydi, çağıran future içinde o await noktası için durum oluşturmaya gerek kalmazdı
    • Bu tür optimizasyonlar özyinelemeli biçimde uygulanırsa birçok future çok daha basit durum makinelerine indirgenebilir
  • Mevcut rustc yapısında her async block ayrı ayrı dönüştürülüyor ve sonrasında ilgili veriler korunmadığı için bu tür sorgular mümkün görünmüyor
  • Future inline etme henüz deneysel olarak uygulanmadı, ancak ikili boyut ve performans açısından büyük fayda sağlaması bekleniyor

Aynı durumların birleştirilmesi

  • Async block içindeki her await noktası, durum makinesine ek bir durum ekliyor
  • Aşağıdaki gibi bir kod doğal görünse de iki dalda da aynı async fonksiyon await edildiği için iki özdeş durum oluşuyor
    • CommandId::A => send_response(123).await
    • CommandId::B => send_response(456).await
  • Bu durumda CoroutineLayout içinde send_response için aynı coroutine türünü tutan _s0, _s1 alanları ayrı ayrı oluşuyor ve Suspend0, Suspend1 adlı iki durum yaratılıyor
  • Bu fonksiyonun MIR’i 456 satır uzunluğunda ve birçok temel blok fiilen yinelenmiş durumda
  • Kod önce yalnızca yanıt değerini hesaplayıp sonra tek kez send_response(response).await yapacak şekilde elle yeniden düzenlenirse yinelenen durumlar ortadan kalkıyor
    • CommandId::A için 123
    • CommandId::B için 456
    • Sonrasında send_response(response).await
  • Yeniden düzenleme sonrasında CoroutineLayout içinde depolanan tek bir future kalıyor ve yalnızca bir Suspend0 durumu bulunuyor
  • Toplam MIR uzunluğu 302 satıra düşüyor ve tekrar ortadan kalkıyor
  • Bu nedenle aynı kod yollarını ve durumları bulup tekilleştiren bir optimizasyon geçişi faydalı görünüyor
    • Bu optimizasyonun future inline etme geçişiyle iyi birleşmesi muhtemel

Deney bağlantıları ve ek benchmark’lar

Project Goal için destek çağrısı

  • Bu çalışma, derleyici tarafında ilerletilmek üzere bir Project Goal olarak sunuldu: https://rust-lang.github.io/rust-project-goals/2026/async-statemachine-optimisation.html
  • Finansman olmadan çok fazla ilerleme kaydetmek zor olduğu için, bu çalışmadan fayda sağlayacak şirket veya kuruluşların kısmi ya da tam desteğine ihtiyaç var
  • İletişim adresi dion@tweedegolf.com
  • İş kapsamı ve gerekli finansman miktarı esnek, ancak €30k ile işin tamamının ya da önemli bir bölümünün bitirilebileceği tahmin ediliyor

1 yorum

 
GN⁺ 1 시간 전
Lobste.rs görüşleri
  • Sadece başlığa bakınca beklediğimden çok daha yapıcı bir yazıymış

    • Bana göre bu neredeyse düpedüz gerçek. MVP’nin çıkışının üzerinden 7 yıl geçti ama dil tasarımı ya da derleyici implementasyonu tarafında neredeyse hiç ilerleme olmadı; MVP’yi büyük ölçüde ortaya çıkaran kişiler de benzer bir dönemde projedeki faaliyetlerini azaltınca sonrasında devir teslim adeta durdu
      Umarım bu işi yapmak isteyen kişi ihtiyaç duyduğu desteği alır
  • I want to work on this in the compiler and as such have submitted it as a Project Goal

    Stop generating statemachines that don’t have to be there
    Make the compiler’s job easier by removing panic paths and branches
    Make statemachines smaller

    Bu sorunun ele alındığını görmek güzel. Şu anda rustc’nin LLVM’e fazla miktarda kod verdiğini ve optimizer’ın her şeyi halletmesini beklediğini söyleyen birkaç yazı görmüştüm; özellikle bu yazı bu iş için finansman da talep ediyor

  • Aman Tanrım, ben aptalmışım
    async’in bir şekilde runtime, iş takibi ve tamamlanmayı kontrol eden polling gerektirdiği için özünde hep “şişkin” olduğunu düşünüyordum. Sonuçta bu overhead sıfır değil
    Burada sözü edilen “sıfır maliyetli soyutlama”nın dil özelliğiyle ilgili olduğunu, sonradan eklenen runtime’dan ayrı düşünmek gerektiğini varsayıyordum
    LLVM’e vermeden önce rustc’nin gerçekte ne ürettiğine bakmayı hiç düşünmemişim

  • async Rust’a aşina olmayanlar için:

    It's amazing how we can write executor agnostic code that can run concurrently on huge servers and tiny microcontrollers.

    Bu gerçekten doğru. İç içe geçmiş async çağrı ağaçları, maksimum optimizasyondan sonra içinde durum makinesi bulunan tek bir struct hâline kadar katlanabiliyor. Gerçekten çok zekice bir yaklaşım

  • Release build’de bu duruma gelindiğinde bir tür deadlock mu oluşuyor? Yoksa sürekli Pending dönen bir işi bekleyen task’lar yüzünden sızıntı da olabilir mi?

    • Evet. Bu tür future’lar takılı kalmış olur ve asla tamamlanmaz. Ama böyle bir duruma zaten ancak hatalı düşük seviyeli async kodda ulaşılabilir; tamamlanmış future’ları düzgün takip edemeyen kod büyük ihtimalle zaten sızıntı ve deadlock üretiyordur
      .await ile yanlış polling yapılamaz
  • Aklıma birkaç şey geliyor:

    1. Bu yazı, daha fazla optimizasyon mantığının LLVM dışına çıkarılıp MIR katmanına taşınması gerektiğini savunuyor gibi görünüyor. Örneğin async fonksiyon inline etmenin neden LLVM’den ziyade MIR’de daha kolay olduğunu anlıyorum. Eğer bu async için MIR’de yapılabildiyse, acaba bu mantık senkron fonksiyonlara da genellenip LLVM’in bazı optimizasyon pass’leri kaldırılabilir mi diye düşünüyorum. Bunun büyük bir iş olduğunu biliyorum; bu daha çok pratik bir sorudan ziyade bir yön tartışması. Frontend/middle-end derleyici yeterince karmaşıklaştığında, LLVM’in genel amaçlı optimizasyonlarının kayda değer bir kısmının başka katmanlara taşınması daha iyi olabilir gibi geliyor
    2. Hâlâ panic=unwind tercihini sevmiyorum. Bazı test harness’leri dışında, panic=abort yerine bunu seçmenin maliyeti karşılayacak kadar büyük bir faydasını neredeyse hiç görmedim. Hatta test harness için bile Linux’ta pthread_join yerine çalışan thread’i wait etmek için tuhaf bir şekilde clone kullanılarak benzer bir tercih yapılabileceğini düşünüyorum. Bu noktada yanılıyor olabilirim
  • Link başkasında da az önce bozuldu mu?
    Düzeltme: blog yazısı yaklaşık yarım saniye görünüp sonra 404 sayfasına düşüyor
    Düzeltme 2: Blog yazıları listesine gidip etrafa tıkladım; listede duran o yazıyı açınca da 404 sayfasına gidiyor. Statik bir sayfa olan ya da en azından öyle olması gereken bir blogu nasıl bu kadar bozabilirsiniz?

    • Üslup biraz gereksiz yere kaba ve saldırgan geliyor. Web sitelerinde de bug olabilir; bildirmek faydalı ama bu yorum biraz huysuzca tınlıyor
      Bu arada aynı yeniden üretim adımlarını izledim sanırım ama bende hiç 404 çıkmadı. Telefonda ve masaüstünde, JavaScript açıkken de kapalıyken de denedim. Yani yaşadığınız şey göründüğünden daha karmaşık olabilir