Async RustはMVP状態を一度も脱していない
(tweedegolf.nl)- Async Rust は、エグゼキュータに依存しないコードをサーバーとマイクロコントローラの両方で動かせるが、コンパイラが生成する状態機械のため、とくに組み込みではバイナリサイズの増加が目立つ
bar()のように await 箇所が2つある単純な例でも、360行のMIR とUnresumed,Returned,Panicked,Suspend0,Suspend1の状態が生成され、同期版では23行しか必要ない- 完了した future を再度 poll したとき、
panicの代わりにPoll::Pendingを返すよう変更すれば、unsafe な動作なしで契約を満たせ、実験では組み込みファームウェアの バイナリサイズが2%〜5%減少 した - await のない
async { 5 }でも現在は基本3状態の状態機械が作られるが、毎回Poll::Ready(5)を返すよう最適化すると、組み込みバイナリサイズが 0.2%減少 した - 提案された Project Goal は、リリースモードでの完了後 panic の除去、await のない async block の状態機械除去、単一 await future のインライン化、同一状態の畳み込みをコンパイラで進める取り組みである
Async Rustのコンパイラレベルの肥大化問題
- Async Rust は、エグゼキュータに依存しないコードをサーバーとマイクロコントローラで同時に実行できるようにするが、小さなマイクロコントローラではバイナリサイズの増加がとくに目立つ
- Rustブログは async/await を ゼロコスト抽象化 として紹介したが、async は実際には大きな肥大化を生み、同じ問題はデスクトップやサーバーにもあるものの、メモリや計算資源が多いため表面化しにくい
- async コード記述時の肥大化を避ける 回避策 に続き、この問題をコンパイラ側で解決するための Project Goal が提出された
- future が必要以上に大きくなり、コピーが増える問題は対象外となっている
- この問題はすでに知られており、一部を扱う PR が公開されている: https://github.com/rust-lang/rust/pull/135527
生成される future の構造
- 例のコードでは、
foo()がasync { 5 }を返し、bar()がfoo().await + foo().awaitを実行する- Godbolt の例: godbolt
barには await 箇所が2つあるため、状態機械には少なくとも2つの状態が必要だが、実際にはさらに多くの状態が生成される- Rust コンパイラは複数のパスで MIR をダンプでき、
coroutine_resumeパスは最後の async 専用 MIR パスである- async は MIR には残るが LLVM IR には残らないため、async が状態機械へ変換される過程は MIR パスで起きる
bar関数は 360行のMIR を生成し、同期版は 23行 しか使わない- コンパイラが出力する
CoroutineLayoutは、実質的に enum 形式の状態集合であるUnresumed: 開始状態Returned: 完了状態Panicked: panic 後の状態Suspend0: 1つ目の await 箇所で、foofuture を保持するSuspend1: 2つ目の await 箇所で、1つ目の結果と2つ目のfoofuture を保持する
Future::pollは安全な関数なので、future がすでに完了した後に再び呼ばれても UB を起こしてはならない- 現在は
Suspend1の後にReadyを返し、future をReturned状態に変える - この状態で再度 poll すると panic が発生する
- 現在は
Panicked状態は、async 関数が panic した後にcatch_unwindでそれを捕捉した際、その future を再度 poll できないようにするための状態に見える- panic 後の future は不完全な状態である可能性があるため、再度 poll すると UB につながりうる
- この仕組みは mutex poisoning と非常によく似ている
Panicked状態に関するこの解釈は、確かな文書を見つけにくく、90%ほどの確信にとどまる
完了後の poll で本当に panic が必要か
Returned状態の future は現在 panic するが、必ずそうである必要はない- 必要条件は UB を起こさないことだけである
- panic は比較的高コストであり、最適化で除去しにくい副作用のある経路を追加する
- 完了済み future を再度 poll したときに
Poll::Pendingを返せば、unsafe な動作なしでFuture型の契約を満たせる - コンパイラを修正してこの方式を試したところ、async な組み込みファームウェアで 2%〜5%のバイナリサイズ削減 が確認された
- この動作は、整数オーバーフローにおける
overflow-checks = falseのように、スイッチとして提供する案が提案されている- デバッグビルドでは誤った動作を即座に露呈させるため、引き続き panic する
- リリースビルドでは、より小さな future を得られる
panic=abortを使う場合は、Panicked状態自体を削除できる可能性があり、その影響には追加検討が必要である
await がなくても常に状態機械が生成される
foo()はasync { 5 }を返すだけなので、手動実装での最適な形は、状態を持たず常にPoll::Ready(5)を返す future である- しかし、コンパイラが生成した MIR には
Unresumed,Returned,Panickedという基本3状態が依然として存在する- poll 時に現在状態の discriminant を確認して分岐する
- 完了後に再度 poll すると
`async fn` resumed after completionアサートで panic する
- この場合、状態機械を作らず毎回
Poll::Ready(5)を返すよう最適化できる - これをコンパイラに試験的に適用したところ、組み込みバイナリサイズが 0.2%減少 した
- 削減幅は大きくないが、単純な最適化なので適用する価値がある可能性がある
- この最適化は挙動を少し変えるが、影響を受けるのは規約を守らないエグゼキュータだけである
- 現在のコンパイラでは、その後の poll で panic する
- 最適化後は future が常に
Readyを返す
LLVM だけでは不十分
- MIR の出力が非効率でも LLVM がすべて整理してくれる場合はあるが、条件は限られる
- future が十分に単純であること
opt-level=3を使うこと
- future が複雑になると LLVM は除去できず、慣用的な async Rust コードでは future が深くネストするため、複雑さはすぐに大きくなる
- 組み込みや wasm のようにサイズ最適化をよく行う環境では、LLVM がこれをすべて最適化できない
- Godbolt の例: https://godbolt.org/z/58ahb3nne
- 生成されたアセンブリでは、LLVM は
fooが 5 を返すことは理解しているが、barの答えを 10 に最適化できていない fooの poll 関数呼び出しも残っている- これは、コンパイラが完全には把握できない潜在的な panic 経路があるためである
- LLVM は
fooが実際には1回しか呼ばれず、panic しないことを知らない
- 生成されたアセンブリでは、LLVM は
- IR で panic 分岐をコメントアウトすると、よりよく最適化される: https://godbolt.org/z/38KqjsY8E
- LLVM に事後最適化を期待するより、コンパイラが LLVM により良い入力を与えるべきである
future のインライン化がうまくいかない
- インライン化はその後の最適化パスを可能にするため重要だが、生成される Rust future は現在、早い段階ではインライン化されない
- 各 future が実装を得た後で LLVM とリンカがインライン化の機会を得るが、前述の問題のため、その時点では遅すぎる
- 最も直接的なインライン化の機会は、
bar()が単にfoo(blah).awaitだけを行う形である- trait を使って抽象化を作るときによく現れるパターンである
- 現在のコンパイラは
bar用の状態機械を作り、その内部でfoo状態機械を呼び出す - より効率的には、
barがfoofuture 自体になれる
- preamble と postamble がある場合はより複雑になる
- 例:
bar(input)がinput > 10からblahを作り、foo(blah).awaitした後、その結果に* 2を適用する - async 関数を別シグネチャへ変換するとき、とくに trait 実装でよくある
- 例:
- この形の
barでも、それ自体の async 状態は不要である- 単一の await 箇所を越えて保持されるデータが、
fooにキャプチャされた値以外にないためである - ただし
barが単純にfoo自体になることはできず、状態の大部分をfooに依存できる
- 単一の await 箇所を越えて保持されるデータが、
- 手動実装では、
BarFutはUnresumed { input }とInlined { foo: FooFut }の状態を持てる- 最初の poll で preamble を実行して
foo(blah)を作り、Inlined状態に切り替える - 以後は
foo.poll(cx)の結果に postamble を適用する
- 最初の poll で preamble を実行して
- 最初の await 箇所までコードを先に実行できれば
Unresumed状態も除去できるが、future は poll される前には何もしないことが 保証 されているため、変更できない - poll 中の future の特性を問い合わせられれば、追加のインライン化最適化が可能になる
- たとえば、その future が最初の poll で常に ready を返すと分かれば、呼び出し側 future ではその await 箇所の状態を作る必要がない
- こうした最適化を再帰的に適用すれば、多くの future をはるかに単純な状態機械へ畳み込める
- 現在の
rustcの構造では、各 async block が個別に変換され、その後に関連データが保持されないため、このような問い合わせはできないように見える - future のインライン化はまだ実験されていないが、バイナリサイズと性能に大きく寄与すると期待される
同一状態の畳み込み
- async block の各 await 箇所ごとに、状態機械には追加の状態が生じる
- 次のようなコードは自然だが、2つの分岐で同じ async 関数を await するため、同一の状態が2つ生じる
CommandId::A => send_response(123).awaitCommandId::B => send_response(456).await
- この場合、
CoroutineLayoutにはsend_responseの同じ coroutine 型を保持する_s0,_s1がそれぞれ生じ、Suspend0,Suspend1の2状態が作られる - この関数の MIR は 456行 で、多くの基本ブロックが実質的に重複している
- まず応答値だけを計算し、その後で一度だけ
send_response(response).awaitするよう手動でリファクタリングすると、重複状態はなくなるCommandId::Aは123CommandId::Bは456- その後で
send_response(response).await
- リファクタリング後の
CoroutineLayoutには保持される future が1つだけあり、Suspend0状態だけが残る - MIR 全体の長さは 302行 に減り、重複が消える
- したがって、同一のコード経路と状態を見つけて1つに畳み込む最適化パスは有用に見える
- この最適化は future インライン化パスとうまく組み合わせられる可能性がある
実験リンクと追加ベンチマーク
- 2つの実験を同時に適用すると、
smolエグゼキュータを使った x86 の合成ベンチマークで約 3%の性能向上 が出る - No panics in poll after ready: https://github.com/rust-lang/rust/compare/main...diondokter:rust:resume-pending
- No await, no statemachine: https://github.com/rust-lang/rust/compare/main...diondokter:rust:no-statemachine-when-no-await
Project Goalへの支援要請
- この作業はコンパイラ側で進めるため、Project Goal として提出されている: https://rust-lang.github.io/rust-project-goals/2026/async-statemachine-optimisation.html
- 資金がなければ多くの作業を進めるのは難しいため、この取り組みの恩恵を受ける企業や組織による部分的または全面的な支援が必要である
- 連絡先は
dion@tweedegolf.com - 作業範囲と必要な資金規模は柔軟だが、€30k あれば全体またはかなりの部分を完了できると見積もられている
1件のコメント
Lobste.rs の意見
タイトルだけ見て予想していたよりもずっと 建設的な記事 だった
この作業をやろうとしている人が必要な支援を受けられるといいと思う
この問題が扱われているのは良いことだと思う。今の rustc が LLVM にあまりにも多くのコード を渡して、最適化器が全部何とかしてくれることを期待している、という話は何度か見たことがあるが、特にこの記事ではその作業のための 資金支援 まで求めている
しまった、自分は勘違いしていた
async はどんな形であれランタイム、タスク追跡、完了を確認する ポーリング が必要なので、本質的に「重い」ものだとずっと思っていた。オーバーヘッドは 0 ではないのだから
ここで言う「ゼロコスト抽象化」は言語機能の話であって、付随するランタイムとは別だと考えていた
LLVM に渡す前に rustc が何を出力しているのか を見てみようとすら思わなかった
async Rust に慣れていない人向けに言うと:
これは本当にその通り。async 呼び出しが入れ子になったツリーも、最大限に最適化されると、内部に状態機械を持つ 単一の構造体 へと固まる。実に巧妙なやり方だ
リリースビルドでこのケースに到達すると、ある種の デッドロック になるのか? それとも常に
Pendingのタスクを待つタスクのせいでリークが起きる可能性もあるのか?.awaitでは誤ったポーリングはできないいくつか思うところがある:
panic=unwindは好きになれない。一部のテストハーネスを除けば、panic=abortより優れていてコストを相殺できるほどの利点をほとんど見たことがない。テストハーネスですら、Linux ではやや難解なcloneを使ってpthread_joinの代わりに実行スレッドをwaitするような形で、似た選択を適用できそうに思える。この点は自分が間違っているかもしれないリンク、他の人のところでもさっき死んだ?
修正: ブログ記事が 0.5 秒くらい表示されたあと 404 ページ に飛ばされる
修正 2: ブログ記事一覧に入っていろいろクリックしてみたが、一覧にあるその記事を開いても 404 ページに行く。静的ページか、少なくともそうあるべきブログをどうやったらこんなふうに壊せるんだ?
参考までに、同じ再現手順を試してみたようだが、自分の環境では 404 はまったく出なかった。スマホとデスクトップで、JavaScript をオン/オフの両方で試した。だから、起きていた現象は見た目より複雑だったのかもしれない