- 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
Komentar Lobste.rs
Ternyata ini tulisan yang jauh lebih konstruktif daripada yang saya perkirakan hanya dari judulnya
Saya berharap siapa pun yang ingin mengerjakan ini mendapat dukungan yang dibutuhkan
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:
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?Dengan
.await, polling yang keliru tidak mungkin terjadiAda beberapa hal yang terpikir:
panic=unwind. Selain beberapa test harness, saya hampir tidak pernah melihat keunggulan dibandingpanic=abortyang cukup untuk menutup biayanya. Bahkan untuk test harness pun, di Linux sepertinya mungkin menerapkan pilihan serupa dengan menggunakanclonesecara agak aneh untukwaitthread eksekusi alih-alihpthread_join. Bisa jadi saya salah soal iniApakah 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?
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