ハクソク

世界を動かす技術を、日本語で。

Async RustはMVP状態を脱却できなかった

概要

  • async Rust のバイナリサイズ増大(bloat)の根本的な課題
  • コンパイラ最適化によるbloat解消の提案と実験結果
  • Futureの状態管理やpanic処理、状態機械の仕組み解説
  • 手動最適化・inliningによるバイナリサイズ削減の可能性
  • 今後の最適化案やコミュニティへの協力呼びかけ

async Rustのバイナリ肥大化問題とコンパイラ改善提案

  • async Rustは実行環境を選ばず、サーバーからマイコンまで幅広く利用可能な並行処理技術
  • 特に組み込み機器などメモリ制約が厳しい環境では、asyncによるバイナリサイズ増大が深刻な問題
  • 既存のワークアラウンド(回避策)では根本解決に至らず、コンパイラレベルでの最適化が望ましい状況
  • Project Goalを提出し、開発資金の支援を募集中

async Futureの内部構造と生成コードの課題

  • async関数は**状態機械(state machine)**としてMIR(Mid-level IR)上で変換・管理
  • 例としてbar関数では2つのawaitポイントごとに異なる状態が生成され、MIR出力は360行に及ぶ
  • 状態はUnresumed, Returned, Panicked, Suspend0, Suspend1など複数存在
  • Returned/Panicked状態はpoll後の再呼び出しやpanic後の再利用を防止するために設計
  • パニック処理はコストが高く、バイナリ肥大化や最適化阻害の要因

パニック処理の見直しによるバイナリ削減案

  • poll後にパニックではなくPendingを返すことで、2〜5%のバイナリサイズ削減を確認
  • デバッグビルドでは従来通りパニック、リリースビルドではサイズ重視の挙動切替を提案
  • panic=abort時はPanicked状態自体を省略できる可能性も検討

シンプルなFutureの最適化例

  • 例:async { 5 }のような即時完了Futureは状態管理不要
    • 手動実装では常にPoll::Ready(5)を返すだけで十分
  • 現状のコンパイラ生成コードは不要な状態管理・分岐が含まれ、最適化余地あり
  • この単純最適化でも0.2%のバイナリ削減を確認

LLVM最適化の限界と入力品質の重要性

  • LLVMは最適化レベル3であれば一部不要コードを除去できるが、複雑なFutureでは困難
  • パニック分岐があるとLLVMが最適化しきれず、呼び出しや状態管理が残存
  • MIR段階での最適化が不可欠

Futureのインライン化とさらなる最適化案

  • Rustのasync Futureは自動でインライン化されないため、状態機械がネストしてしまいバイナリ肥大
  • 例:barが単にfoo().awaitする場合、bar独自の状態機械を持つのは非効率
  • 前処理・後処理を含む場合も、状態をうまく共有すれば状態数削減が可能
  • Futureの特性(即時Readyなど)をコンパイラが認識できれば、さらなる最適化が可能

状態の統合(state collapsing)による重複削減

  • 複数のawaitが本質的に同じ状態を持つ場合、状態を統合することで重複コード削減
  • 例:match分岐ごとにawaitする場合、awaitの前に分岐し、1つのawaitにまとめることでMIRが大幅に短縮
    • let response = match ...; send_response(response).await;のような書き換え

今後の展望とコミュニティへの協力要請

  • async bloatの根本解決には、コンパイラの抜本的な最適化が不可欠
  • 手動最適化やワークアラウンドだけでは限界があるため、ツールチェーン側の対応が重要
  • 資金援助やアイデア提供など、Rustコミュニティの協力を呼びかけ

この内容はasync Rustのバイナリサイズ最適化に関心のある開発者や、組み込み用途でRustを利用するエンジニアにとって有用な知見です。

Hackerたちの意見

いい記事だね!こういう最適化の深掘りが大好き。プロジェクトの目標がうまくいくといいな!コンパイラって、よく「トリビアル」なケースの最適化にあまり力を入れてない気がする。ただ、内容に対してタイトルが大げさすぎるかな。「コンパイラがまだ見逃している非同期Rustの最適化」ってタイトルでもクリックしちゃうけどね。
タイトルには同意。大げさすぎる。著者はトリビアルな関数のオーバーヘッドにこだわりすぎてる気がする。「パニック」と「リターン」の状態のオーバーヘッドが気になるみたいだけど、それほど大きな問題じゃないよ。ほとんどの有用な非同期ブロックは十分大きいから、エラーケースのオーバーヘッドは消えちゃう。インライン化が足りないって指摘には一理あるけど、大量のアクティビティを扱うときに制限されるのは、各アクティビティに必要な状態空間なんだよね。
タイトルについてだけど、これを選んだのはただの真実だから。2019年頃にasyncが導入されてから、あんまり変わってないよね。確かに、今はトレイトやクロージャでasyncが使えるようになったけど、それはタイプシステムの更新であって、asyncの仕組み自体の更新じゃない。ワーカーは少し扱いやすくなったけど、それもstd/coreの更新だし。私の理解では、async Rustを実現した人たちはかなり疲れてしまって、活動が減ってしまったみたいで、誰もそのバトンを引き継いでないんだ。(ただ、キャプチャされた変数のメモリ配置を最適化するGoogleの人たちからのPRが1つオープンになってるけど、これは本当にありがたい。)私や私の周りの人たちはヘビーユーザーだから、もしかしたら私がやるべきなのかも。子犬のように自由な感じかな。だから、タイトルはちょっと釣りっぽいけど、私はその背後に立ってるよ。
非同期って、全体的にまだまだ未熟なアイデアに感じる。通常のコードはすでに非同期だったし、非同期操作を待つ必要があるときは、スレッドが準備ができるまでスリープして、カーネルがそれを抽象化してくれる。でも、論理的なスレッドにコードを構造化するのは嫌だったから、イベントのためにコールバックシステムを追加したんだよね。そしたらコールバックは理解するのがすごく難しいことに気づいて、逐次制御の方がいいって思った。だからスレッドが正しいプログラミングモデルだったんだ。今は言語のランタイムがポータビリティとパフォーマンスのために「グリーンスレッド」を好むけど、ほとんどの言語はそれをちゃんと提供してない。代わりに、非同期/非非同期の変な色分けや、スケジューリング、優先度、プリエンプションの問題がある。1970年よりもひどいスケジューリングとプロセスモデルだよ。
> スレッドが正しいプログラミングモデルだった。 パフォーマンスやメモリにあまりこだわらない問題に関しては、そうだね。たぶん、デフォルトとしてスレッドを使うべきだよ。ただし、自分の問題がこの一般的な範疇に入らないと分かっている場合を除いてね。残念ながら、スレッドにはカーネルでの多くの管理オーバーヘッドがあって、コンテキストスイッチもかなり高コストだから、高パフォーマンスのシナリオではカーネルスレッドを使う余裕がないかもしれない。
スレッドは非同期+コールバックよりも良くも悪くもない。ただ違うだけ。スレッドにうまくマッピングできる問題もあれば、非同期で表現する方がずっと楽な問題もある。
> スレッドが準備ができるまでスリープして、カーネルがそれを抽象化してくれる。 確かにそうだけど、カーネルとOSスケジューラを巻き込むと、物事は本来の速度よりも3〜4桁遅くなるんだ。最後にコルーチン/スケジューリングコードに取り組んでいたとき、即座に終了するスレッドを作成して参加させるのに約200μsかかって、私たちのグリーンスレッドを作成してスケジューリングし、待つのには約400nsかかった。誰かがまた別の非常に複雑な非同期フレームワークを設計するのを10年待つ必要はないよ。20行のASMで自分のグリーンスレッドやスタックフルコルーチンを作れるから。
問題は、両方の椅子に座ろうとすることから来てる。非同期を望むけど、オプトアウトしたいっていうのが、機能の色分けを含む多くの醜さを引き起こしてる。ゴーランを見てみて、すべてが非同期で、変更する方法がないのは素晴らしい。マイクロコントローラーのような、すべてのバイトが重要な場面にはあまり向いてないかもしれないけど、オーバーヘッドを許容できるなら、Rustの非同期よりずっと良い。非同期が導入される前は、Rustは面白くて合理的な言語だったけど、今は理由もなく目が痛くなるような混沌とした状態になっちゃった。
> 通常のコードはすでに非同期だったよ。非同期操作を待つ必要があるとき、スレッドは準備ができるまでスリープするし、カーネルがそれを抽象化してくれる。 あんまりそうじゃないと思う。非同期コードは、同時にできる処理を最大限に活かせてないことが多い気がする(例えば「N個のI/O操作を全部同時にやる」って書く代わりに「操作Xについて、await process(x)」って書く感じ)。でも、スレッドの世界ではこの同時実行の問題が悪化するんだよね。なぜなら、そんな同時実行に最適化する方法がないから。スレッドは本質的に重すぎて、効率的に同時実行を表現できないんだ。これは新しい教訓じゃなくて、ワークスティーリングエグゼキュータが従来のスレッドよりもずっと低いレイテンシーを提供することが知られてるのは長い間だし、これが理由でAppleはGCDを開発したんだよ。スレッドはカーネルに必要なワークロードに関する情報を提供しないし、カーネルスレッドは細かい同時実行を実現するための非常に重いメカニズムなんだ。しかも、I/Oや混合ワークロードの同時実行が必要なときはさらに厄介。すごく簡単に並列化できる純粋な計算とは違ってね。 すべてのプログラムがこのレベルのパフォーマンスを必要とするわけじゃない?多分、そうじゃないよ。でも、より高いパフォーマンスを達成するのはかなり簡単だし、実際には従来のアプローチでは同じ努力で達成できないレイテンシーやスループットを得られるんだ。非同期が方向的には正しいって言えるのは、io_uringがカーネルの高パフォーマンスI/Oへのアプローチで、従来のスレッドやシステムコールとは全然違うし、完了も非同期の同時実行に近いからだね(ただし、完全に活用するのは非同期の世界では難しいけど、async/awaitは非同期タスクの関係性を表現するには色が足りないからね)。
コールバックの方が実際には考えやすいと思う。並行処理をテストする時、レースコンディションを適切に処理できてるか確認するのが、コールバックだとスケジューリングをコントロールできるからずっと簡単なんだ。各コールバックは離散的な単位を表してるから、どのイベントが順序を入れ替えられるかがわかる。これで、いろんな順序を考慮しやすくなる。一方、スレッドだと順序を無視しがちで、別のスレッドで起こってるこの複雑さを考えないことが多い。簡単じゃなくて、単純すぎるんだよね。さらに、スケジューリングを変更したり、並行シナリオをテストするのは、スレッドを止めるための人工的な障壁を導入したり、I/Oをスタブ化してモックを渡してコールバックで順序を制御するようにしないとできない。コールバックの問題は、キャプチャされた時のコールスタックが論理的なコールスタックじゃないことが多いってこと。少数のライブラリやランタイムがコールスタックを意味のあるものにするために努力してる場合を除いてね。そうじゃなければ、良いエラーディフィニションが必要だよ。もちろん、パラダイムを混ぜて、両方の悪いところを持つこともできるけど。
私の理解では、「グリーンスレッド」も高コストなんだ。例えば、各「スレッド」に大きなスタックを割り当てる必要があるか、Goのようにスタックを動的に成長させるためにスタック割り当てをフックする必要がある。スタックを成長させると、移動させなきゃいけなくなって、スタックオブジェクトへのポインタを持てなくなるかもしれない。
現代の適切な言語は両方を提供してる。スレッドを維持しつつ、必要な時にだけasyncに手を伸ばせるんだ。選択肢を提供しない言語はまた別の問題だけどね。
この文脈でのカーネルって何?
組み込みではスレッドがないけど、同時待機を表現する方法が欲しいんだよね。全然違う問題だよ。
これはC++でずっと続いている、醜いけど必要な議論の一種だね。Rustで非同期が導入されたとき、そのウイルスのような性質があまり好きじゃなかった。Rustに幸運を祈るし、こういう人がもっと増えればRustの未来は明るいかもしれない。
最近、Rustの非同期を使い始めたんだけど、今直面してる主な問題はコードの重複なんだ。非同期とブロッキングAPIの両方をサポートしたい関数をすべて重複させなきゃいけない。`maybe-async`があればいいのにと思う。利用可能なクレートを見てみたけど(maybe-async、bisync)、どれも問題があったり、制限が厳しかったりするんだよね。
クラシックな関数の色付け問題。 https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
実際に何をしているかによるけど、シンプルな場合は型を置き換えてawaitするマクロを作れるかもしれないね。
キーワードジェネリクスに関する作業が進んでるみたいで、これによって関数が`async`や`const`のようなキーワードに対してジェネリックになれるんだ。今のところ、両方の世界で生きるコードを書くためのベストな選択肢はsans-ioだね。Fireguardのトーマス・アイジンガーがこのパターンについて良い記事を書いてるよ。これによって同期/非同期の問題がうまく解決されるだけでなく、テストも簡単になって、DSTのような技術への扉も開かれる。私もこのトピックについて書いたことがあるけど、問題はasyncとsyncの違いだけじゃなくて、異なる実行環境によるものだってことを強調してる。0: https://github.com/rust-lang/effects-initiative 1: https://www.firezone.dev/blog/sans-io 2: https://notes.eatonphil.com/2024-08-20-deterministic-simulat... 3: https://hugotunius.se/2024/03/08/on-async-rust.html
こういうことって、もっと複雑な非同期関数に適用すると目に見える違いが出るのかな?ブログの例はあまりにもシンプルで、結論を出すには不十分だと思う。
こんにちは、著者です。ブログで、コンパイラの最もシンプルな最適化を2つすぐにハックしてみたことを書いたんだけど、実際の組み込み(非同期)コードベースで2%-5%のバイナリサイズの節約が得られたんだ。それに、デスクトップで行った簡単でおそらく深く欠陥のある合成ベンチマークでは、3%のパフォーマンス向上が見られた。だから、確かに重要なんだよ。最適化は積み重なることを忘れないで。LLVMが本来の動作をするのを妨げているからね。だから、未来のサイズを小さくすれば、LLVMがもっと最適化できるようになる。小さな変更が本当に大きな影響を与えるんだ。
この記事、もう好きだな。2026年のRustの目標について知ることができたから。うちのチームではこの言語を使ってるけど、必要なことをするために深く掘り下げる必要はなかったんだ。でも、コミュニティのフィードバックを受けながら言語がゼロから成長していくのを見るのはすごく楽しい。C++ではそれをあまり感じられなかったし、他の分野でどうなってるのかも全然わからない。唯一の不満は、目標の達成に特定の資金が必要で、ちょっとキックスターターっぽく感じることかな。これが今まで見つけた中で一番いいモデルなの?
> C++ではそれをあまり感じられなかったし、他の分野でどうなってるのかも全然わからない。C++のISO委員会内でも、その言語の進化プロセスがちょっと壊れてるという合意があるみたいで、主にそのサイズと組織の仕方が原因なんだ。 > 唯一の不満は、目標の達成に特定の資金が必要で、ちょっとキックスターターっぽく感じることかな。これが今まで見つけた中で一番いいモデルなの? 残念ながら、技術が商業的に普及するとこういう風になるみたい。大きな寄付者が興味のある部分だけを支援するのは責められないよね。幸い、TweedeGolfのかなりの資金は(オランダの)政府から来てると思う。
オープンソースには2種類の作業があると思う:1. 機能 2. メンテナンス 新しい機能は「売る」ことができる。作るのにお金がかかるけど、実際の問題を解決するからね。その問題もお金がかかるし、もしそれが機能を作るコストより高ければ、企業はお金を出すことに前向きだよね(一般的に)。メンテナンスは難しいけど、今はメンテナーファンドも出てきてる! RustNLのやつみたいにね: https://rustnl.org/maintainers/ これらは広範な継続的な作業で、多くの団体が少しずつ支援してる。これが一番いいモデルかはわからないけど、少なくともなんとか機能してるみたい。
他のコメント者たちと同意見で、タイトルはちょっと大げさだと思う。内容はよく書かれていて、ポイントも伝わってた。私自身はRustのasyncについて強い意見を持つほどの経験はないけど、いくつかのことは目立った。良い点としては、明示的なランタイムを持てるのがいいね。プロジェクト全体をasyncに汚染するのではなく、その逆ができる。まずは同期で、IOの「エッジ」でランタイムを使う。これは私が取り組んでいるプロジェクトにぴったり合っていて、zigがIOコードでやっている戦略に似ているみたい。これによって、この特定のケースで関数の色付け問題が大きく解決された。IOとCPUバウンドのコードを厳密に分けることが求められたから、明示的なIOランタイムを使うのは自然なことだった。悪い点としては、エコシステム全体がtokioにどれだけ依存しているかが狂ってると思う。まるでJavaのGCがオプションだったかのようで、実際にはみんな同じサードパーティのGCランタイムを使っていて、ライブラリを引っ張るとそのランタイムを使わざるを得ない。こういう中央集権的な依存関係は健康的じゃないよね。
代替案は何だろう?tokioを使うのは全然嬉しいけど、他の人たちがsmolやasync-std、glommioとかの他のexecutorを楽しめるのもいいよね。tokioはしっかりメンテナンスされてるから、標準ライブラリの一部じゃなくても大丈夫だと思う。逆に、標準ライブラリに組み込んじゃうと、他のexecutorを使いにくくなったり、他のプラットフォームへの移植が難しくなったりするんじゃないかって心配してる。でも、もしかしたらその心配は杞憂かもね。
Javaの話が出たから言うけど、歴史を通じて似たような問題があったのは面白いよね。ログの件(今はslf4jに落ち着いてるけど、まだ他のライブラリを使ってるのも見かけるし)、commons(最初はApache Commons、今はGuava)、JSON(Jacksonに落ち着いたけど、GsonやSimple-jsonもよく見る)、nullabilityアノテーション(最初は非公式のJSR-305から始まって、次はchecker framework、最近はJSpecifyに移行してる)。こういう基本的なことは、言語が提供しないと、フラグメンテーションや準デファクトライブラリが出てきちゃうよね。
最近、新しいCPUの話を聞いてこれが頭に浮かんでる。Zen 7はすごい性能になりそうだし、数十個のコアのうちの1つだけでコーディングするのはもったいないよね。
重複状態の崩壊(awaitブランチからマッチを引き上げる、彼のprocess_commandの例みたいに)は、今の既存のasyncコードに誰でも適用できる一番簡単なパターンだよ。コンパイラの作業は不要で、リファクタリングだけで済む。
> Futures aren't (trivially) inlined 私のプログラミング言語では、他のasync関数内でasync関数呼び出しをインライン化するためのカスタムパスを書いたんだ。一般的にはうまくいくし、ボイラープレートを減らすことができるけど、結果のバイナリサイズがかなり大きくなっちゃう。技術的にはRustも同じことができるよ。