2 ポイント 投稿者 GN⁺ 2024-04-20 | 1件のコメント | WhatsAppで共有

この記事では、Rust言語のCalling Conventionを改善する方法について詳しく説明している。

Rustの現在のCalling Conventionの問題点

  • Rustでは現在、呼び出し規約(Calling Convention)が明確に定義されていない
  • 実際にはLLVMのデフォルトのC呼び出し規約を使っている
  • Rustは現在、保守的にClangが生成しそうなLLVM関数シグネチャを生成しようとしている
    • デバッガとの互換性のため
    • LLVMのバグを避けるため
  • しかし保守的すぎるため、単純な関数に対しても悪いコードを生成する
fn extract(arr: [i32; 3]) -> i32 { arr[1] }
  • 上のコードはレジスタで渡されるべきだが、ポインタで渡される
  • RustはC ABIよりもさらに保守的である。extern "C"を指定するとレジスタで渡される。

新しいCalling Conventionの提案

  • extern "Rust"関数については既存の呼び出し規約を維持する
  • -Zcallconvフラグを追加してextern "Rust"関数の呼び出し規約を設定する
    • -Zcallconv=legacyは現在の方式
    • -Zcallconv=fastは新たに設計する方式
  • なぜ既存の呼び出し規約を維持すべきなのか?
    • デバッグのしやすさのため、C ABIの順序で配置しない
    • WASMのような一部ターゲットではサポートできない可能性がある
    • デバッグビルドでは意味がない可能性がある
  • 関数ポインタおよびextern "Rust" {}ブロックに関する注意点
    • クレート単位のフラグなので関数ポインタには適用できない
    • 関数ポインタ呼び出しは遅く、まれなので-Zcallconv=legacyを使う
    • 必要ならShimを生成して呼び出し規約を変換する
    • extern "Rust" { fn my_func() -> i32; }のように直接呼び出す場合
      • マングリングされていないシンボルのみ呼び出し可能
      • #[no_mangle]関数は既存の呼び出し規約を使う

LLVMの活用方法

  • 理想的にはLLVMに呼び出し規約を直接指定できるとよいが、現実的には難しい
  • 次のような手順で回避できる
    • 与えられたターゲットについて、レジスタで渡せる最大値の個数を確認する
    • 戻り値をどう渡すか決める。レジスタに収まるならそのまま、収まらなければ参照で渡す
    • 値で渡される引数のうち、参照で渡すべきものを選別する
      • レジスタで渡せる空間より大きいもの
      • x86ではおよそ176バイト
    • レジスタ空間を最大限使うため、どの引数をレジスタで渡すか決める
      • NP-hard問題なのでヒューリスティックが必要
      • 残りはスタックで渡す
    • LLVM IRで関数シグネチャを生成する
      • レジスタで渡される引数はi64、ptr、double、<2 x i64>などの非集約型で表現する
      • スタックで渡される引数は「レジスタ入力」に従う
    • 関数プロローグを生成する
      • Rustレベルの引数をレジスタ入力からデコードして、-Zcallconv=legacy時と同じ%ssa値を生成する
      • 関数本体は呼び出し規約に関係なく同じコードを生成できる
      • 不要なデコードコードはDCEで削除される
    • 関数の戻り値ブロックを生成する
      • -Zcallconv=legacy時と同じ戻り値型に対するphi命令を含む
      • 必要な出力形式にエンコードしてretで返す
      • retの代わりにこのブロックへ分岐する必要がある
    • 関数ポインタとして使われうる非多相・非インライン関数がある場合
      • クレート外に公開される、または関数ポインタとして渡される場合
      • -Zcallconv=legacyを使うShimを生成し、実際の実装をTail Callする
      • 関数ポインタの同一性を維持するために必要である

LLVMのレジスタ渡しの限界を確認する方法

  • LLVMが許容する最大レジスタ渡し個数を確認するLLVMプログラム
  • x86では整数6個、SSEベクタ8個の入力と、整数3個、SSEベクタ4個の出力が可能
  • aarch64では整数8個、ベクタ8個で入力と出力が同じ
  • これを超えるとスタックで渡される

Rustの構造体と列挙型の扱い

  • rustcがすでに基本集約体と共用体として処理したと仮定する
  • 戻り値の処理
    • 構造体サイズではなく、パディングを除いた実データサイズが重要
    • [(u64, u32); 2]は32バイトだが、8バイトのパディングを除けば24バイト
    • 型の有効サイズ(Effective Size)を定義する
      • パディングを除いた未定義でないビット数
      • [(u64, u32); 2]は192ビット
      • boolは1ビット
    • 有効サイズが出力レジスタ空間より小さければ値で返す
    • x86では整数3個 + SSE 4個 = 88バイト = 704ビット
  • 引数レジスタ処理
    • ナップサック問題でNP-hard
    • 単純なヒューリスティック
      • 有効サイズが入力レジスタ空間全体より大きければ参照で渡す
      • 列挙型は判別子と共用体の組に置き換える
      • 共用体は未初期化ビットに触れる可能性があるため、u8配列または空でない1つの変種として渡す
      • ポインタ、整数、浮動小数点、ブール値など最も基本的な要素に平坦化する
      • 有効サイズ基準で昇順に並べる
      • 可能な限り大きい接頭辞をレジスタに割り当て、残りはスタックにする
      • スタックに回る入力の一部がポインタサイズの小さな倍数より大きければ、スタック上のポインタで渡す
      • 残りは並べ替え前の順序でそのままスタックに直接渡す
      • レジスタで渡すものはサイズの降順で割り当てる
      • ブール値は64個ずつビットパックする

GN+の意見

  • 個人的には、Rustの現在の呼び出し規約は非常に惜しい。C++よりはるかに高い性能を出せるはずなのに、まだできていない
  • Go言語がすでにかなり前に実装した方式である
  • Rustが適用できない理由
    • ABIコード生成が複雑で、LLVMがあまり役に立たない
    • コンパイラチームにLLVMをよく知る人があまりいない
    • コンパイル時間への懸念はあるが、最適化ビルドでしか使わないので大きな問題ではない
  • 筆者自身に直接修正する時間はないが、LLVMに関する専門性を生かしてRustコンパイラチームを支援する意思がある
  • あるいは単にextern "C"extern "fastcall"へ切り替えるのも代替案になりうる

1件のコメント

 
GN⁺ 2024-04-20
Hacker Newsの意見

要約:

  • 最適化された呼び出し規約(Calling Convention)を作る際には、性能を直接測定することが重要である。奇妙に見えるコードが、実際には最速である場合がある。
  • 今日のCPUはCコンパイラが生成した命令トレースを最適化するため、Cコンパイラのようにスタック経由で頻繁に受け渡しすることが役立つ可能性がある。
  • インライン化がうまく機能すれば、呼び出しはまれな境界になるため、他を単純化するために境界で多少の不規則さを許容できる。
  • Rustの構造体はフィールドへの参照を提供できる必要があるため、Cより大きくなる場合がある。Option<u8> フィールドを8個持つ構造体は、Rustでは16バイト、Cでは9バイトである。
  • Rustでは手動でCと同等の実装を行えるが、&Option<T>&mut Option<T> にはマッピングできない。
  • Rustには、Rustレベルのセマンティクスのための呼び出し規約がまだ存在しない。Appleにはそれを構築する動機があったが、Rustにはそのような支援がない。
  • GoとRustの相互運用性は、現時点ではZigを間に挟むことで実現可能である。
  • 現在のRustコンパイラは積極的なインライン化と最適化を行うため、この問題を解決する価値があるのか疑問である。
  • デバッグのためにCargo.tomlフラグを使って懸念を避けられる。フィールドをサイズ順に並べるのは簡単な最適化であり、reprで無効にできる。