メモリ安全性のための呼び出し規約の最適化:Fil-Cの深掘り

システムプログラミングにおけるメモリ安全性は、しばしば大きなパフォーマンス・コストを伴います。関数ポインタを誤ったシグネチャにキャストしたり、va_listを誤用したりするなど、悪意のある動作をするプログラムに対して安全性を確保するには、通常、徹底的な実行時チェックが必要です。Filip PizloによるプロジェクトであるFil-Cは、一般的なケースでの効率を犠牲にすることなく、型違反をパニックとして捕捉するか、安全な動作を割り当てる呼び出し規約を実装することで、この問題を解決することを目指しています。

これを実現するために、Fil-Cは関数呼び出しに対して階層的なアプローチを採用しています。フォールバックとして機能する、デフォルトで安全な汎用規約と、呼び出しが安全であると証明できる場合にコンパイラがチェックをバイパスできるようにする一連の積極的な最適化です。

汎用呼び出し規約:安全性のベースライン

最適化を行う前に、Fil-Cは関数の呼び出し方法に関わらず安全性を保証する汎用呼び出し規約を定義します。そのプロセスは厳格です。

  1. Resolution: 直接呼び出しは、シンボル名を「getter calls」に変換し、シンボル名をflight pointers(capability pointerと整数値のタプル)に解決します。
  2. Verification: システムは、capabilityがnullでないこと、具体的に関数capabilityであること、およびポインタの整数値がcapabilityのcallable valueと一致することを確認します。
  3. Buffering: 引数は8バイトに切り上げられ、2つのスレッドローカルなCalling Convention (CC) バッファが割り当てられます。これには、ペイロード用とcapability用があります。
  4. Transfer: 制御はcalleeに転送され、calleeはbyrefパラメータをヒープ割り当てし、CCバッファからローカルなデータフローへ引数をコピーします。
  5. Return: 戻り値のプロセスは引数の受け渡しと対照的であり、CCバッファを使用して結果を呼び出し元へ転送します。

堅牢ではありますが、このプロセスは非効率的です。レジスタを全く使用せず、スレッドローカルなバッファへの絶え間ないメモリ・アクセスと、すべての呼び出しに対して複数のレイヤーのindirection(間接参照)を必要とします。

算術シグネチャ・エンコーディングによるレジスタ最適化

CCバッファのオーバーヘッドを排除するために、Fil-Cはレジスタベースの呼び出し規約を導入しています。ここでの核心的な革新は、関数シグネチャを64ビット整数として表現するための算術エンコーディングの使用です。

算術エンコーディングの仕組み

Fil-Cは、シグネチャ(最大16個の引数と2個の戻り値)を単一のint64にエンコードします。型に数値値を割り当てることで(例:int = 0double = 2pointer = 7)、シグネチャのパーフェクト・ハッシュを作成します。例えば、シグネチャ char* (*)(int, char*, double)60125 としてエンコードされます。

Fast Path と Thunks

Fil-Cのすべての関数オブジェクトは、signatureフィールドと2つのエントリポイントを持ちます:fast_entrypoint(ネイティブなレジスタベース)とgeneric_entrypoint(バッファベース)。

呼び出しが行われる際、呼び出し元はcalleeのシグネチャが期待されるエンコーディングと一致するかを確認します。一致する場合、呼び出し元はレジスタで引数を渡し、fast_entrypointへ直接ジャンプします。シグネチャが異なる場合、システムは一対のthunksを使用します。

  • Caller Entrypoint Thunk: レジスタベースの呼び出しを汎用バッファベースの規約に変換します。
  • Callee Entrypoint Thunk: 汎用バッファベースの呼び出しをレジスタベースの規約に変換します。

これらのthunksはLLVM IRにおいてlinkonce_odrとして生成され、リンカーがモジュールを横断して1つのコピーのみを保持することを保証します。このメカニズムにより、Fil-Cは(汎用パスを通じて)安全性を維持しながら、PizBench9019ベンチマークにおいて1%以上のスピードアップを実現しています。

直接的な Caller Resolution の排除

レジスタ渡しであっても、直接呼び出しには依然としてgetter callとcapability checkが必要です。Fil-CはELFシンボル・マングリングを利用することで、これをさらに最適化します。

シグネチャ・マングリングされた実装

getter callを行う代わりに、コンパイラは実装自体に対して、シグネチャを含むようにマングリングされたELFシンボルをエクスポートします(例:pizlonatedFI60125_foo)。呼び出し元とcalleeがシグネチャに合意している場合、呼び出しはgetter、capability check、およびシグネチャのチェックを完全にバイパスして、実装への直接ジャンプとなります。

Weak Symbols によるエッジケースの処理

この最適化は、ELFのロードとC++のインライン関数に関する複雑さをもたらします。thunkが自分自身を呼び出す無限ループを防ぐために、Fil-Cはcallsite thunksに対してhidden visibilityを使用し、実装とエイリアスを区別するために特定の命名規則(pizlonatedFIP vs pizlonatedFI)を使用します。

C++のインライン関数(COMDATグループにおいて弱定義であることが多い)については、リンカーが呼び出し元が参照しようとしている実装を破棄してしまう可能性があります。Fil-Cはこれを次のように解決します:

  1. LLVMを修正し、ローカルに定義されたCOMDATシンボルがNULLになり得ることを認識させます。
  2. これらのシンボルへの直接呼び出しに対してNULLチェックを生成します。

これにより、もし関数がCOMDAT解決によって破棄された場合、エラーは実行時クラッシュではなく、リンク時(または実行時)に捕捉されるようになります。

パフォーマンス・ゲインの要約

汎用バッファベースのアプローチから直接呼び出しのレジスタベースのアプローチへ移行することで、Fil-Cは関数呼び出しの一般的なケースにおけるオーバーヘッドをほぼすべて排除します。その遷移は以下の通りです。

特徴 汎用規約 最適化された規約
引数の受け渡し スレッドローカル・バッファ CPUレジスタ
Resolution Getter call マングリングされたシンボルへの直接ジャンプ
安全性のチェック 完全なcapability & size check 単一のシグネチャ・マッチングまたはNULLチェック
戻り値 バッファベース CPUレジスタ

これらの最適化を組み合わせることで、厳密格なメモリ安全性保証を維持しながら大幅なパフォーマンス向上を実現し、高レベルの安全性は必ずしも高レベルのパフォーマンス・ペナルティを伴わないことを証明しています。

Sources