
2026/03/10 2:28
**「Rust における間接参照のコスト」**
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
要約
この記事は、Rust の非同期コードにおいて余分な関数呼び出しを追加することは通常不要であり、可読性を損なう可能性があるため、開発者は微小最適化よりも明確さを優先すべきだと主張しています。
-
重要性の理由:
- コンパイラはしばしば呼び出される側の状態機械を呼び出し側に統合します。リリースビルドでは、状態機械がインライン化されたため
がノーオペレーションになり、インライン版と抽出版でほぼ同一のアセンブリが生成されます。await - 小さな関数は最適化器によって自動的にインライン化されることが多く、明示的に
を付与した場合のみ確実にインライン化されます。#[inline]
- コンパイラはしばしば呼び出される側の状態機械を呼び出し側に統合します。リリースビルドでは、状態機械がインライン化されたため
-
具体例:
- 大きな
の中で長い「Suspend」アームを自分の非同期関数(async fn handle_event
)へ移動させることができます。追加呼び出しのオーバーヘッドは無視でき、可読性と保守性が向上します。handle_suspend
- 大きな
-
間接参照が影響するケース:
- 高負荷 CPU ループや明示的な
呼び出しは関数横断最適化を妨げる可能性があるため、まれに小さな遅延が観測されます。dyn Trait - 通常の I/O バウンドイベント処理(例: Suspend アーム)では、そのコストはロック、割り当て、およびネットワークレイテンシによって圧倒的に小さくなります。
- 高負荷 CPU ループや明示的な
-
人間中心のコスト:
- 数ナノ秒程度の実行時間増加で追加の複雑性を正当化することは稀です。余分な間接参照は理解負荷を高め、コードレビュー期間を延ばし、バグリスクを上げます。これらの利益はほとんどの場合、わずかな速度向上よりも優先されます。
-
実践的アドバイス:
- 明確さのために意味ある関数を抽出する。
- 最適化器を信頼し、プロファイリングツール(Criterion, Callgrind, perf, dtrace)で回帰が測定された場合のみ
を追加する。#[inline] - パフォーマンスが明確に重要でない限り、保守性と可読性を重視する。
本文
「余計な関数呼び出しはオーバーヘッドになる。インライン化せよ!」という警告は、ほとんどの場合において Rust の非同期コードでは事実無根です。
そのアームのサイズを見てみよう
async fn handle_event(&self, event: Event) -> Result<()> { match event.kind { EventKind::Suspend => { // … アプリケーション動作が 20 行以上 … } // … 他のアーム … } }
作者は書くときに「ホットコンテキスト(直前の思考状態)」を頭に描いていたため、
その偏見で妥当化していることがわかります。
そして同じ理由でチームメンバーや将来の自分もそれを受け入れるよう促します。
結果として可読性と保守性が犠牲になり、実際に得られる利益はほぼゼロです。
Suspend
アームが長くなったので「抽出する」ことを提案
Suspendasync fn handle_event(&self, event: Event) -> Result<()> { match event.kind { EventKind::Suspend => self.handle_suspend(event).await, // … 他のアーム … } } async fn handle_suspend(&self, event: Event) -> Result<()> { // … アプリケーション動作が 20 行以上 … }
すると「関数呼び出しは余計なオーバーヘッドになる」という指摘が飛びます。
一見妥当に思える主張ですが、実際には…
コンパイラの視点
非同期関数を別途抽出する場合に起こることは次の通りです。
- 引数渡しと ABI への準拠
- ランタイム側での Future 状態機械設定(状態を指すポインタなど)
の Pin 化(await 間に借用が発生するため)self- スタックフレームのプッシュ/ポップとポインタ固定
- スケジューラ・ワーカー・イベントループへの追加間接参照
これらはすべて実際に存在しますが、重要なのは「他の作業と比べてどれだけ意味を持つか」です。
-
アームは既にマッチ分岐でジャンプテーブルや比較チェーンを生成しているためSuspend
1 回の分岐 はすでに支払われています。
さらに「関数呼び出し」を入れると、追加で 一つの間接ジャンプ とフレーム設定が発生します。
それは実際に
が行う処理(ループや計算など)と比べれば無視できる量です。Suspend -
を呼ぶとき、コンパイラはヒープ確保を必ずしも行いません。await
通常は 親 Future の状態機械に子の状態を統合 します。
つまり「抽出した関数を呼ぶ」という操作自体がインライン化と同等になるケースが多いです。 -
リリースビルド(
)では最適化が有効になり、--release
小さな抽出関数は 自動でインライン化 されることがあります。
その結果、以下のように書いた場合でもアセンブリレベルで差異はほぼありません。
#[no_mangle] fn do_the_work_inlined() -> u64 { let mut acc = 0u64; for i in 1..=10 { acc = acc.wrapping_mul(i).wrapping_add(12345); } acc } #[no_mangle] fn do_the_work_extracted() -> u64 { do_some_work() } #[no_mangle] fn do_some_work() -> u64 { let mut acc = 0u64; for i in 1..=10 { acc = acc.wrapping_mul(i).wrapping_add(12345); } acc }
cargo rustc --release -- --emit asm を実行すると、do_the_work_inlined と do_the_work_extracted のアセンブリは同一になることが多いです。
間接参照が本当に重要になるケース
-
極めて高頻度のループ(毎秒数百万回呼ばれる関数)
キャッシュ圧迫や分岐予測に影響するため、オーバーヘッドを意識すべきです。 -
の動的ディスパッチdyn Trait
vtable ラックアップはコンパイラがインライン化できない実際の間接参照です。 -
明示的に設けた非最適化パス
コンパイラが境界を越えて最適化できず、オーバーヘッドが残るケースです。
しかしこれらは「同期関数内で毎秒数百万回呼ばれる」など、
非同期イベントハンドラーの
Suspend アームとは性質が異なります。もし
Suspend がそのように頻繁に実行されるなら、それ自体が設計上問題 です。
実際のコストは認知的負担
プロファイラで「オーバーヘッド」が見えなくても、それが 存在しないわけではありません。
人間にとって重要なのは、数ナノ秒の遅延よりもずっと大きな影響を与える「認知的負荷」です。
-
関数を開くたびに理解コストがかかる
コードレビューやバグ修正時に時間が増え、ミスのリスクも上昇します。 -
Rust の設計哲学は「クリーンな抽象化」を推奨
オプティマイザを信頼し、
や#[inline]
は実際に問題があるときだけ使用するべきです。#[inline(always)]
結論
-
リリースビルドでは追加呼び出しのオーバーヘッドはほぼゼロ
多くの場合統計的にも無視できる程度です。 -
本当に大切なのは可読性と保守性
数ナノ秒を犠牲にしてまで抽象化を削減する必要はありません。
したがって、次のように答えるべきです。
「リリースビルドでベンチマークを取った結果、差異はゼロでした。
性能上の利益は I/O 時間などで測るものであり、呼び出し単体では数秒にもならないので、人間がコードを読む際のメリットを優先すべきです。」
推奨アプローチ
-
関数を抽出する
- 名前を明確にして意図を示す。
- 必要に応じてコメントで「このケースで何が起こるか」を記述。
-
必要なら
を付ける#[inline]- 実際にベンチマークでボトルネックが確認されたときのみ使用。
-
定期的にリリースビルドで測定
- もしプロファイラが特定の呼び出しをボトルネックとして指摘したら、
コード変更の影響を検証し、必要なら再構成する。
- もしプロファイラが特定の呼び出しをボトルネックとして指摘したら、
最終的には「読みやすいコード」を保ちつつ、コンパイラに任せる方が長期的に見て最適です。