1 poin oleh GN⁺ 2 jam lalu | 1 komentar | Bagikan ke WhatsApp
  • Async Rust memungkinkan kode yang independen dari executor berjalan baik di server maupun mikrokontroler, tetapi state machine yang dibuat compiler menyebabkan pembengkakan ukuran biner yang sangat terlihat, terutama di embedded
  • Bahkan contoh sederhana seperti bar() dengan 2 titik await menghasilkan 360 baris MIR serta state Unresumed, Returned, Panicked, Suspend0, Suspend1, sedangkan versi sinkron hanya membutuhkan 23 baris
  • Jika future yang sudah selesai di-poll lagi diubah agar mengembalikan Poll::Pending alih-alih panic, kontrak tetap bisa dipenuhi tanpa perilaku unsafe, dan eksperimen menunjukkan ukuran biner firmware embedded berkurang 2%~5%
  • Bahkan async { 5 } tanpa await saat ini tetap membuat state machine dengan 3 state dasar, tetapi jika dioptimalkan agar selalu mengembalikan Poll::Ready(5), ukuran biner embedded berkurang 0.2%
  • Project Goal yang diusulkan bertujuan mendorong penghapusan panic setelah selesai di mode rilis, penghapusan state machine untuk async block tanpa await, inline future dengan satu await, dan penggabungan state yang identik di compiler

Masalah pembengkakan di level compiler pada Async Rust

  • Async Rust memungkinkan kode yang independen dari executor berjalan sekaligus di server dan mikrokontroler, tetapi pada mikrokontroler kecil peningkatan ukuran biner sangat terasa
  • Blog Rust memperkenalkan async/await sebagai abstraksi tanpa biaya, tetapi pada praktiknya async menghasilkan banyak pembengkakan (bloat), dan meski masalah yang sama juga ada di desktop dan server, hal itu kurang terlihat karena memori dan sumber daya komputasinya lebih besar
  • Setelah ada cara mengakali agar tidak terlalu membengkak saat menulis kode async, kini diajukan Project Goal untuk menyelesaikan masalah ini di compiler
  • Masalah future yang menjadi terlalu besar dan terlalu banyak disalin tidak termasuk dalam cakupan

Struktur future yang dihasilkan

  • Kode contoh memperlihatkan foo() mengembalikan async { 5 }, dan bar() menjalankan foo().await + foo().await
  • bar memiliki 2 titik await, sehingga state machine minimal membutuhkan 2 state, tetapi pada kenyataannya lebih banyak state yang dibuat
  • Compiler Rust dapat membuang MIR di berbagai pass, dan pass coroutine_resume adalah pass MIR khusus async terakhir
    • Async masih tersisa di MIR tetapi tidak lagi ada di LLVM IR, jadi proses perubahan async menjadi state machine terjadi di pass MIR
  • Fungsi bar menghasilkan 360 baris MIR, sedangkan versi sinkron hanya memakai 23 baris
  • CoroutineLayout yang dikeluarkan compiler pada dasarnya adalah kumpulan state berbentuk enum
    • Unresumed: state awal
    • Returned: state selesai
    • Panicked: state setelah panic
    • Suspend0: titik await pertama, menyimpan future foo
    • Suspend1: titik await kedua, menyimpan hasil pertama dan future foo kedua
  • Future::poll adalah fungsi yang aman, sehingga memanggilnya lagi setelah future selesai tidak boleh menyebabkan UB
    • Saat ini setelah Suspend1, ia mengembalikan Ready lalu mengubah future ke state Returned
    • Jika di-poll lagi pada state ini, panic akan terjadi
  • State Panicked tampaknya ada agar setelah fungsi async panic dan ditangkap oleh catch_unwind, future tersebut tidak bisa di-poll lagi
    • Setelah panic, future bisa berada dalam state yang tidak lengkap, sehingga mem-poll lagi dapat berujung pada UB
    • Mekanisme ini sangat mirip dengan mutex poisoning
    • Interpretasi terhadap state Panicked ini tidak didukung dokumentasi yang benar-benar pasti, jadi tingkat keyakinannya sekitar 90%

Apakah poll setelah selesai memang harus panic

  • Future dalam state Returned saat ini akan panic, tetapi sebenarnya itu tidak wajib
    • Syarat yang penting hanyalah tidak menimbulkan UB
  • Panic relatif mahal dan menambahkan jalur dengan efek samping yang sulit dihilangkan lewat optimasi
  • Jika future yang sudah selesai di-poll lagi mengembalikan Poll::Pending, kontrak tipe Future tetap terpenuhi tanpa perilaku unsafe
  • Saat compiler dimodifikasi untuk menguji pendekatan ini, firmware embedded async menunjukkan penurunan ukuran biner 2%~5%
  • Perilaku ini diusulkan tersedia sebagai sakelar seperti overflow-checks = false pada integer overflow
    • Di build debug, panic tetap dipertahankan agar perilaku yang salah langsung terlihat
    • Di build rilis, future yang lebih kecil bisa didapatkan
  • Saat menggunakan panic=abort, ada kemungkinan state Panicked itu sendiri dapat dihapus, tetapi dampaknya masih perlu ditinjau lebih lanjut

State machine tetap dibuat meski tidak ada await

  • foo() hanya mengembalikan async { 5 }, sehingga bentuk implementasi manual yang paling optimal adalah future tanpa state yang selalu mengembalikan Poll::Ready(5)
  • Namun MIR yang dihasilkan compiler masih memiliki 3 state dasar: Unresumed, Returned, Panicked
    • Saat di-poll, discriminant dari state saat ini diperiksa lalu bercabang
    • Jika di-poll lagi setelah selesai, panic terjadi dengan assert `async fn` resumed after completion
  • Dalam kasus ini, compiler bisa dioptimalkan agar tidak membuat state machine dan cukup selalu mengembalikan Poll::Ready(5)
  • Saat ini diterapkan secara eksperimental di compiler, ukuran biner embedded berkurang 0.2%
    • Penghematannya memang tidak besar, tetapi optimasinya sederhana sehingga tetap berpotensi layak diterapkan
  • Optimasi ini sedikit mengubah perilaku, tetapi yang terdampak hanyalah executor yang tidak mematuhi kontrak
    • Compiler saat ini panic pada poll berikutnya
    • Setelah optimasi, future akan selalu mengembalikan Ready

LLVM saja tidak cukup

  • Meski keluaran MIR tidak efisien, kadang LLVM bisa membereskannya, tetapi syaratnya terbatas
    • Future harus cukup sederhana
    • Harus memakai opt-level=3
  • Saat future menjadi lebih kompleks, LLVM tidak mampu menghapus semuanya, dan dalam kode Async Rust yang idiomatis future cenderung bertingkat dalam, sehingga kompleksitas cepat membesar
  • Di lingkungan seperti embedded atau wasm yang sering mengutamakan optimasi ukuran, LLVM tidak bisa mengoptimalkan semuanya
  • Contoh Godbolt: https://godbolt.org/z/58ahb3nne
    • Dari assembly yang dihasilkan, LLVM mengetahui bahwa foo mengembalikan 5, tetapi tidak bisa mengoptimalkan jawaban bar menjadi 10
    • Panggilan ke fungsi poll milik foo juga masih tersisa
    • Penyebabnya adalah adanya jalur panic potensial yang tidak bisa dipahami compiler sepenuhnya
    • LLVM tidak tahu bahwa foo pada kenyataannya hanya dipanggil sekali dan tidak panic
  • Jika cabang panic di IR dikomentari, optimasinya menjadi lebih baik: https://godbolt.org/z/38KqjsY8E
  • Daripada berharap LLVM melakukan optimasi lanjutan setelahnya, compiler seharusnya memberi input yang lebih baik ke LLVM

Inline future tidak berjalan dengan baik

  • Inline penting karena memungkinkan pass optimasi berikutnya, tetapi future Rust yang dihasilkan saat ini tidak di-inline pada tahap awal
  • Setelah tiap future mendapat implementasinya, LLVM dan linker memang mendapat kesempatan untuk melakukan inline, tetapi akibat masalah sebelumnya tahap itu sudah terlambat
  • Peluang inline paling langsung adalah ketika bar() hanya melakukan foo(blah).await
    • Pola ini sering muncul saat membuat abstraksi dengan trait
    • Compiler saat ini membuat state machine untuk bar lalu memanggil state machine foo di dalamnya
    • Secara lebih efisien, bar bisa langsung menjadi future foo itu sendiri
  • Jika ada preamble dan postamble, kasusnya lebih kompleks
    • Contoh: bar(input) membuat blah dari input > 10, lalu menjalankan foo(blah).await dan menerapkan * 2 pada hasilnya
    • Ini umum saat mengubah fungsi async ke signature lain, terutama dalam implementasi trait
  • Bentuk bar seperti ini juga tidak memerlukan state async miliknya sendiri
    • Tidak ada data yang perlu dipertahankan melewati satu titik await selain nilai yang sudah ditangkap oleh foo
    • Namun bar tidak bisa begitu saja menjadi foo itu sendiri, meski sebagian besar state dapat bergantung pada foo
  • Dalam implementasi manual, BarFut dapat memiliki state Unresumed { input } dan Inlined { foo: FooFut }
    • Pada poll pertama, preamble dijalankan untuk membuat foo(blah) lalu diubah ke state Inlined
    • Setelah itu, hasil foo.poll(cx) diteruskan dengan menerapkan postamble
  • Jika kode hingga sebelum titik await pertama bisa dieksekusi lebih awal, state Unresumed juga bisa dihapus, tetapi hal itu tidak bisa diubah karena ada jaminan bahwa future tidak melakukan apa pun sebelum di-poll
  • Jika properti future yang sedang di-poll bisa diinspeksi, optimasi inline tambahan dapat dimungkinkan
    • Misalnya, jika diketahui bahwa future selalu mengembalikan ready pada poll pertama, future pemanggil tidak perlu membuat state untuk titik await tersebut
    • Jika optimasi semacam ini diterapkan secara rekursif, banyak future bisa dilipat menjadi state machine yang jauh lebih sederhana
  • Dalam struktur rustc saat ini, tampaknya tiap async block ditransformasikan secara terpisah dan data terkait tidak dipertahankan setelahnya, sehingga kueri seperti ini tidak memungkinkan
  • Inline future belum diuji secara eksperimental, tetapi diperkirakan sangat membantu ukuran biner dan performa

Menggabungkan state yang identik

  • Setiap titik await dalam async block menambah state baru pada state machine
  • Kode seperti berikut terasa alami, tetapi karena kedua cabang me-await fungsi async yang sama, akan muncul 2 state identik
    • CommandId::A => send_response(123).await
    • CommandId::B => send_response(456).await
  • Dalam kasus ini, CoroutineLayout memiliki _s0 dan _s1 yang masing-masing menyimpan tipe coroutine send_response yang sama, serta membuat 2 state: Suspend0 dan Suspend1
  • MIR fungsi ini memiliki 456 baris, dan banyak basic block pada dasarnya duplikat
  • Jika kode direfaktor manual agar lebih dulu menghitung nilai respons lalu hanya sekali memanggil send_response(response).await, state yang duplikat hilang
    • CommandId::A menjadi 123
    • CommandId::B menjadi 456
    • Setelah itu send_response(response).await
  • Setelah refaktor, CoroutineLayout hanya memiliki satu future yang disimpan dan hanya menyisakan satu state Suspend0
  • Panjang total MIR turun menjadi 302 baris, dan duplikasi hilang
  • Karena itu, pass optimasi untuk menemukan jalur kode dan state yang identik lalu menggabungkannya tampak berguna
    • Optimasi ini kemungkinan akan cocok digabungkan dengan pass inline future

Tautan eksperimen dan benchmark tambahan

Permintaan dukungan untuk Project Goal

  • Pekerjaan ini diajukan sebagai Project Goal agar bisa dikerjakan di compiler: https://rust-lang.github.io/rust-project-goals/2026/async-statemachine-optimisation.html
  • Tanpa pendanaan, sulit untuk mendorong banyak pekerjaan, sehingga dibutuhkan dukungan parsial atau penuh dari perusahaan atau organisasi yang akan mendapat manfaat dari pekerjaan ini
  • Kontaknya adalah dion@tweedegolf.com
  • Cakupan pekerjaan dan besaran pendanaan yang dibutuhkan bersifat fleksibel, tetapi diperkirakan €30k cukup untuk menyelesaikan seluruh atau sebagian besar pekerjaan

1 komentar

 
GN⁺ 2 jam lalu
Komentar Lobste.rs
  • Ternyata ini tulisan yang jauh lebih konstruktif daripada yang saya perkirakan hanya dari judulnya

    • Menurut saya ini cukup dekat dengan fakta. Sudah 7 tahun sejak MVP dirilis, tetapi hampir tidak ada kemajuan baik dalam desain bahasa maupun implementasi compiler, dan setelah orang-orang yang terutama membuat MVP itu mengurangi keterlibatan mereka dalam proyek pada periode yang kurang lebih sama, proses estafet sesudahnya seakan berhenti
      Saya berharap siapa pun yang ingin mengerjakan ini mendapat dukungan yang dibutuhkan
  • 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

    Senang melihat masalah ini sedang ditangani. Saya sudah beberapa kali melihat tulisan bahwa saat ini rustc mengirim terlalu banyak kode ke LLVM dan berharap optimizer menangani semuanya, dan khususnya tulisan ini juga meminta pendanaan untuk pekerjaan tersebut

  • Astaga, saya yang bodoh
    Saya selalu mengira async pada dasarnya pasti “gemuk” karena bagaimanapun juga ia memerlukan runtime, pelacakan task, dan polling untuk memeriksa penyelesaian. Overhead itu kan memang tidak nol
    Saya menganggap “abstraksi tanpa biaya” yang dibicarakan di sini merujuk pada fitur bahasa, terpisah dari runtime tambahan yang menyertainya
    Saya bahkan tidak pernah terpikir untuk melihat apa yang sebenarnya dikeluarkan rustc sebelum diserahkan ke LLVM

  • Untuk orang yang belum akrab dengan async Rust:

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

    Ini memang benar sekali. Bahkan pohon panggilan async yang bersarang, setelah dioptimalkan semaksimal mungkin, akan mengeras menjadi satu struct tunggal dengan state machine di dalamnya. Benar-benar pendekatan yang cerdas

  • Apakah jika mencapai kasus ini dalam build rilis akan muncul semacam deadlock? Atau mungkinkah terjadi kebocoran karena task-task menunggu pekerjaan yang selalu Pending?

    • Betul. Future seperti itu akan terhenti dan tidak akan pernah selesai. Namun keadaan seperti itu hanya bisa dicapai dari kode async tingkat rendah yang memang sudah buggy, dan kode yang gagal melacak future yang sudah selesai dengan benar kemungkinan besar memang sudah menyebabkan kebocoran dan deadlock
      Dengan .await, polling yang keliru tidak mungkin terjadi
  • Ada beberapa hal yang terpikir:

    1. Tulisan ini tampak seperti argumen bahwa lebih banyak logika optimisasi seharusnya dipindahkan keluar dari LLVM ke lapisan MIR. Misalnya, saya paham kenapa inline async function lebih mudah dilakukan di MIR daripada di LLVM. Jika itu bisa dilakukan untuk async di MIR, saya jadi bertanya-tanya apakah logika itu bisa digeneralisasi ke function sinkron juga, lalu sebagian pass optimisasi LLVM dihapus. Saya tahu ini pekerjaan besar, dan ini lebih ke arah orientasi daripada pertanyaan praktis. Begitu compiler frontend/middle-end menjadi cukup kompleks, sepertinya mungkin lebih baik kalau cukup banyak optimisasi umum LLVM dipindahkan ke tempat lain
    2. Saya tetap tidak suka panic=unwind. Selain beberapa test harness, saya hampir tidak pernah melihat keunggulan dibanding panic=abort yang cukup untuk menutup biayanya. Bahkan untuk test harness pun, di Linux sepertinya mungkin menerapkan pilihan serupa dengan menggunakan clone secara agak aneh untuk wait thread eksekusi alih-alih pthread_join. Bisa jadi saya salah soal ini
  • Apakah tautannya baru saja mati juga buat orang lain?
    Sunting: tulisan blog muncul sekitar setengah detik lalu pindah ke halaman 404
    Sunting 2: saya masuk ke daftar tulisan blog, mengklik beberapa hal, dan bahkan saat membuka tulisan itu dari daftar tetap menuju halaman 404. Bagaimana bisa blog yang statis, atau setidaknya seharusnya statis, dibuat rusak seperti ini?

    • Nadanya terasa agak terlalu tidak perlu kasar dan menyerang. Situs web juga bisa punya bug, dan melaporkannya memang berguna, tetapi komentar ini terdengar agak menyebalkan
      Sebagai catatan, saya mengikuti langkah reproduksi yang tampaknya sama dan sama sekali tidak mendapatkan 404. Saya mencobanya di ponsel dan desktop, dengan JavaScript aktif maupun nonaktif. Jadi sepertinya gejala yang dialami mungkin lebih rumit daripada yang terlihat