
2026/03/02 2:25
**「私たちが望むルストの呼び出し規約(2024)」**
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
著者はRustのデフォルト呼び出し規約―実質的にLLVMの汎用C ABI―を批判しており、
[i32;3] のような複雑な型がポインタで渡されることを強いるため、レジスタ使用が制限され保守的なコードになると指摘しています。彼らは
-Zcallconv=fast と呼ばれるRust専用の新規呼び出し規約を提案します。この方式では引数を再配置し、最大値が最初にレジスタに収まるよう(ナップサック型ヒューリスティック)、可能な限りプリミティブをレジスタに詰め込み、オーバーフローしたものは「参照」ポインタへ降格させます。LLVM の poison 値を利用して未使用のレジスタの初期化を回避し、戻り値はフラット化(パディングと小型型のビットパッキングを除去)され、値で返すか参照で返すかが決定されます。
実装にはカスタム LLVM IR シグネチャと、詰められたレジスタを SSA 値へ復号するプロローグ/エピローグロジックが必要であり、同じ関数本体は規約にかかわらず使用できます。関数ポインタや
extern "Rust" ブロックはまだ旧規約を使い、必要に応じてシムが規約間の変換を行います。プロファイルガイド付きヒューリスティクスをオプションで導入すれば、未使用引数の除外やホットパスで参照を値として渡すことで更なる最適化が可能です。
この ABI を採用すると、特に複雑な引数型を持つ関数に対して Rust のバイナリが高速化され、C ライブラリとの後方互換性も維持できます。ただし LLVM の複雑さが増しビルド時間が長くなる可能性があるため、LLVM に精通していない開発者には導入が難しいというトレードオフがあります。
本文
C ABI とより優れた呼び出し規約 ― Rust の呼び出し規約を深掘りする
「C ABI は複雑な型の渡し方に向いていない」とよく不満を漏らします。
代わりに何を使うかと聞かれると、私は Go のレジスタABI(register ABI)を紹介しますが、多くの人はまだその意味が掴めていません。この記事では私の考えを詳細に説明します。
1. 呼び出し規約とは何か?
呼び出し規約(Calling Convention, CC)は ABI の一部で、次のことを定義します。
- 引数はどのレジスタまたはスタック領域へ渡すか
- 戻り値はどこに配置されるか
- 関数プロローグ/エピローグ、アンワインディングなど
本稿では主に x86 を例に挙げますが、ARM や RISC‑V でも同様の考え方が適用できます。x86 アセンブリ、LLVM IR、および Rust(rustc の内部は想定していません)に慣れている前提で進めます。
2. 問題点
Rust のデフォルト CC は「未指定の 0‑呼び出し規約」であり、最終的には LLVM の組み込み C 呼び出し規約へ落とし込まれます。この保守的アプローチは次のような利点があります。
- デバッグサポートが良好 – DWARF が ELF 上で機能する
- LLVM のバグを誘発しにくい – Rust は Clang と同じコード生成経路を使う
しかし、結果として簡単な関数でも悪質なコード生成になってしまいます。
fn extract(arr: [i32; 3]) -> i32 { arr[1] }
デフォルト CC でコンパイルすると:
extract: mov eax, dword ptr [rdi + 4] ; 2 番目の要素をロード ret
12 バイトの配列がポインタ経由で渡されてしまい、レジスタに収まるはずなのにそうなりません。
extern "C" を付ければもっと良い結果になります:
extract: mov rax, rdi shr rax, 32 ret
Clang は
[i32;3] をレジスタで値渡しできることをすでに知っているのに、Rust はそうしてくれません。
3. -Zcallconv
の導入
-Zcallconvアイデア
- 現在の
CC(後方互換性保持)を維持extern "Rust" - 新しいレジスタ中心の規約に切り替えるフラグ
を追加-Zcallconv=fast
で最適化するときは自動的に-O
を有効にするfast
トレードオフ
- デバッグビルド:
はコードが悪くなる可能性があるので避けるべきfast - 関数ポインタ & extern ブロック:これらはレガシーのまま。必要に応じて実装へ tail‑call するシムを用意
- クレート単位で設定:フラグはクレート全体に適用されるため、関数ごとに使用 CC を宣言できる
4. LLVM に望む規約を書き出す方法
- ターゲットごとの「レジスタ渡し可能最大値」を決定(例:x86 なら整数 6 個、SSE ベクトル 8 個)
- 戻り値の戦略を決める
- 出力レジスタに収まればそれを使う
- そうでなければ追加ポインタ(
属性)を渡し、戻り値としてそのポインタを返すsret
- 大きい引数は「by‑reference」に昇格させる(例:x86 では 176 バイト超)
- レジスタに入れる引数を選択(ナップサック問題;ヒューリスティックを使用)
- LLVM IR シグネチャを生成し、レジスタ入力を列挙、その後にスタック引数
- プロローグ/エピローグブロックでレジスタ値を SSA フォームへデコードし、戻り値も同様にエンコード
- 既存呼び出し側が関数アドレスを取得した場合はシムを用意
5. レジスタ容量の決定
小さな LLVM プログラムをターゲット上で実行して、レジスタに渡せる引数数を調べます。
%InputI = type [6 x i64] %InputF = type [0 x double] %InputV = type [8 x <2 x i64>] define void @inputs({ %InputI, %InputF, %InputV }) { ... }
x86 上で実行すると整数 6 個、SSE ベクトル 8 個がレジスタに渡せることがわかります。AArch64 では整数 8 個+ベクトル 8 個です。
重要なのは すべての関数が同じ数のレジスタ引数を使う という点です。未使用のレジスタは poison 値で埋められ、LLVM は「そこに何があっても良い」とみなしますので追加作業は不要です。
6. Rust の型とレジスタパッキング
戻り値
構造体を padding を除いた非ゼロ要素でフラット化し、サイズ順に並べます。例:
[(u64, u32); 2] → (u64, u64, u32, u32) // 24 バイト
実効サイズが利用可能な出力レジスタ数(x86 なら 88 バイト)未満であればそれを使い、そうでなければポインタ経由で返す。
引数
- 大きすぎる引数はポインタに昇格
- 列挙型は discriminant + union のペアに置き換える(例:
→Option<i32>
)(i32, bool) - ユニオンは、単一バリアントしかない場合を除いてバイト配列として渡す
- すべてをプリミティブ型(ptr、i64、double 等)にフラット化
- 効率的サイズ順でソートし、レジスタに収まる最大プレフィックスを選び、残りはスタックへ
- 論理値はビット単位でパック(1 レジスタにつき最大 64 個まで)
この方法で Rust 型からレジスタ/スタック位置への決定論的マッピングが得られます。
7. 実例:do_thing
do_thingstruct Options { colorize: bool, verbose_debug: bool, allow_spurious_failure: bool, retries: u32, }
フラット化後、x86 上の LLVM シグネチャは次のようになります。
define %Out @do_thing( i64 %rdi, ptr %rsi, ptr %rdx, ptr %rcx, i64 %r8, i64 %r9, <4 x i32> %xmm0, <4 x i32> %xmm1, ; 未使用レジスタ ... ) { ; プリミティブをアンパック … ; Rust の値へ再構築 … }
すべての引数がレジスタに収まり、スタック経由で渡す必要はありません。
8. 最適化依存 ABIs
コンパイラは関数本体を知っているので次のような最適化が可能です。
- 未使用パラメータの除去
- 参照渡しされた小構造体で、実際には保存されない場合に直接インライン展開
- プロファイルガイド付きで「ホット」引数をレジスタ優先度高く割り当て
これらは
-Zcallconv=fast と最適化が有効なときのみ意味があります。
9. 結論
Rust は固定 ABI に縛られないため、各関数に合わせた CC をカスタマイズできる点で C++ を凌駕します。Go はすでに同様の手法を採用していますが、Rust が広く取り入れていない理由は次の通りです。
- LLVM の ABI コード生成の複雑さ
- rustc 側の LLVM 実装への専門知識不足
- コンパイル時間への懸念(ただし
はリリースビルド向けに設計)fast
Rust の ABI 改善に関心がある方は、LLVM が望むレジスタ配置を出力する方法から始めると良いでしょう。さらに深掘りしたい場合は遠慮なくご相談ください!