1 ポイント 投稿者 GN⁺ 2 시간 전 | 1件のコメント | WhatsAppで共有
  • Async Rust は、エグゼキュータに依存しないコードをサーバーとマイクロコントローラの両方で動かせるが、コンパイラが生成する状態機械のため、とくに組み込みではバイナリサイズの増加が目立つ
  • bar() のように await 箇所が2つある単純な例でも、360行のMIRUnresumed, 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 が必要以上に大きくなり、コピーが増える問題は対象外となっている

生成される future の構造

  • 例のコードでは、foo()async { 5 } を返し、bar()foo().await + foo().await を実行する
  • 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 箇所で、foo future を保持する
    • Suspend1: 2つ目の await 箇所で、1つ目の結果と2つ目の foo future を保持する
  • 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 しないことを知らない
  • IR で panic 分岐をコメントアウトすると、よりよく最適化される: https://godbolt.org/z/38KqjsY8E
  • LLVM に事後最適化を期待するより、コンパイラが LLVM により良い入力を与えるべきである

future のインライン化がうまくいかない

  • インライン化はその後の最適化パスを可能にするため重要だが、生成される Rust future は現在、早い段階ではインライン化されない
  • 各 future が実装を得た後で LLVM とリンカがインライン化の機会を得るが、前述の問題のため、その時点では遅すぎる
  • 最も直接的なインライン化の機会は、bar() が単に foo(blah).await だけを行う形である
    • trait を使って抽象化を作るときによく現れるパターンである
    • 現在のコンパイラは bar 用の状態機械を作り、その内部で foo 状態機械を呼び出す
    • より効率的には、barfoo future 自体になれる
  • preamble と postamble がある場合はより複雑になる
    • 例: bar(input)input > 10 から blah を作り、foo(blah).await した後、その結果に * 2 を適用する
    • async 関数を別シグネチャへ変換するとき、とくに trait 実装でよくある
  • この形の bar でも、それ自体の async 状態は不要である
    • 単一の await 箇所を越えて保持されるデータが、foo にキャプチャされた値以外にないためである
    • ただし bar が単純に foo 自体になることはできず、状態の大部分を foo に依存できる
  • 手動実装では、BarFutUnresumed { input }Inlined { foo: FooFut } の状態を持てる
    • 最初の poll で preamble を実行して foo(blah) を作り、Inlined 状態に切り替える
    • 以後は foo.poll(cx) の結果に postamble を適用する
  • 最初の 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).await
    • CommandId::B => send_response(456).await
  • この場合、CoroutineLayout には send_response の同じ coroutine 型を保持する _s0, _s1 がそれぞれ生じ、Suspend0, Suspend1 の2状態が作られる
  • この関数の MIR は 456行 で、多くの基本ブロックが実質的に重複している
  • まず応答値だけを計算し、その後で一度だけ send_response(response).await するよう手動でリファクタリングすると、重複状態はなくなる
    • CommandId::A123
    • CommandId::B456
    • その後で send_response(response).await
  • リファクタリング後の CoroutineLayout には保持される future が1つだけあり、Suspend0 状態だけが残る
  • MIR 全体の長さは 302行 に減り、重複が消える
  • したがって、同一のコード経路と状態を見つけて1つに畳み込む最適化パスは有用に見える
    • この最適化は future インライン化パスとうまく組み合わせられる可能性がある

実験リンクと追加ベンチマーク

Project Goalへの支援要請

  • この作業はコンパイラ側で進めるため、Project Goal として提出されている: https://rust-lang.github.io/rust-project-goals/2026/async-statemachine-optimisation.html
  • 資金がなければ多くの作業を進めるのは難しいため、この取り組みの恩恵を受ける企業や組織による部分的または全面的な支援が必要である
  • 連絡先は dion@tweedegolf.com
  • 作業範囲と必要な資金規模は柔軟だが、€30k あれば全体またはかなりの部分を完了できると見積もられている

1件のコメント

 
GN⁺ 2 시간 전
Lobste.rs の意見
  • タイトルだけ見て予想していたよりもずっと 建設的な記事 だった

    • 単に事実に近いと思う。MVP リリースから 7 年が経ったが、言語設計やコンパイラ実装ではほとんど進展がなく、MVP を主に作り上げた人たちが同じ頃にプロジェクト活動を減らしたことで、その後の引き継ぎが止まってしまった状態だと思う
      この作業をやろうとしている人が必要な支援を受けられるといいと思う
  • 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

    この問題が扱われているのは良いことだと思う。今の rustc が LLVM にあまりにも多くのコード を渡して、最適化器が全部何とかしてくれることを期待している、という話は何度か見たことがあるが、特にこの記事ではその作業のための 資金支援 まで求めている

  • しまった、自分は勘違いしていた
    async はどんな形であれランタイム、タスク追跡、完了を確認する ポーリング が必要なので、本質的に「重い」ものだとずっと思っていた。オーバーヘッドは 0 ではないのだから
    ここで言う「ゼロコスト抽象化」は言語機能の話であって、付随するランタイムとは別だと考えていた
    LLVM に渡す前に rustc が何を出力しているのか を見てみようとすら思わなかった

  • async Rust に慣れていない人向けに言うと:

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

    これは本当にその通り。async 呼び出しが入れ子になったツリーも、最大限に最適化されると、内部に状態機械を持つ 単一の構造体 へと固まる。実に巧妙なやり方だ

  • リリースビルドでこのケースに到達すると、ある種の デッドロック になるのか? それとも常に Pending のタスクを待つタスクのせいでリークが起きる可能性もあるのか?

    • その通り。そうした future は 停止した状態 になって決して完了しない。ただし、その状態に到達できるのは、すでにバグのある低レベル async コードだけで、完了した future を正しく追跡できないコードは、おそらくすでにリークやデッドロックを起こしている可能性が高い
      .await では誤ったポーリングはできない
  • いくつか思うところがある:

    1. この記事は、より多くの最適化ロジックを LLVM の外に出して MIR 層 に移すべきだという主張のように見える。たとえば async 関数のインライン化が LLVM より MIR で簡単な理由は理解できる。async について MIR で実現できたなら、そのロジックを同期関数にも一般化して、LLVM の最適化パスの一部を取り除くこともできるのではないかと思う。大仕事なのは分かっているし、実務的な質問というより方向性の話に近い。フロントエンド/ミドルエンドのコンパイラがある程度複雑になれば、LLVM の汎用最適化のかなりの部分は別の場所へ移したほうがよくなるのかもしれない
    2. 依然として panic=unwind は好きになれない。一部のテストハーネスを除けば、panic=abort より優れていてコストを相殺できるほどの利点をほとんど見たことがない。テストハーネスですら、Linux ではやや難解な clone を使って pthread_join の代わりに実行スレッドを wait するような形で、似た選択を適用できそうに思える。この点は自分が間違っているかもしれない
  • リンク、他の人のところでもさっき死んだ?
    修正: ブログ記事が 0.5 秒くらい表示されたあと 404 ページ に飛ばされる
    修正 2: ブログ記事一覧に入っていろいろクリックしてみたが、一覧にあるその記事を開いても 404 ページに行く。静的ページか、少なくともそうあるべきブログをどうやったらこんなふうに壊せるんだ?

    • 少し不必要に無礼で攻撃的な言い方に感じる。ウェブサイトにもバグはあり得るし、報告すること自体は有用だけれど、このコメントはちょっと意地が悪く聞こえる
      参考までに、同じ再現手順を試してみたようだが、自分の環境では 404 はまったく出なかった。スマホとデスクトップで、JavaScript をオン/オフの両方で試した。だから、起きていた現象は見た目より複雑だったのかもしれない