この記事は、Haskell Advent Calendar 2022の22日目の記事です。
GHCのFFIとForeignPtrに関するパフォーマンスの調査をしました。
至らぬところがあるかと思いますが、よろしくおねがいします。
概要
Hasktorchというプロジェクトで行列の演算にpytorchのc++のライブラリ(libtorch)を使っています。
10x10の行列積の計算のベンチマークで、Hmatrixが1.03usに対して、Hasktorchでは3.21usかかりました。
2つのライブラリは構造がよく似ていて、オブジェクトの管理はどちらもForeignPtrを使っていますし、計算はCのライブラリのblasを使っています。違いはHmatrixがC言語のライブラリを扱っているのに対して、HasktorchはC++言語のライブラリを扱っているというところです。2usの違いがパフォーマンスに現れました。
CないしC++関数を使うにはFFIが必要です。Cの関数のパフォーマンスはよく調べられていて、
例えばこちらに行列計算のライブラリの一覧があり、FFIを使っているのがHmatrixで、Haskellだけで書かれているのがdense-linear-algebra(DLA)です。
CのFFI単体のベンチマークはこちらです。
これらのベンチマークからいえることは、実装の違いで数100ns程度の速度の差がでるということです。
数usの違いは出てこないように見えましたので、C++を使っているHasktorchが遅い原因を調べることにしました。
遅い原因は次の複合的なものでした。
- C++のライブラリ(libtorch)が遅い
- FFIのSafe Callを使っていた
- C++の例外の補足のため
- Inline展開されてない
- ForeignPtrのc finalizerが遅い
C++のライブラリのベンチマーク
行列の計算以外のオーバーヘッドを調べるため、2x2の行列足し算についてベンチマークをしました。
https://discuss.pytorch.org/t/too-much-c-api-overhead/164381
C言語で書いたプログラムは0.309nsに対して、C++のライブラリ(libtorch)は894ns程度かかりました。
下記のリンクから、小さなテンソルの生成コスト、DeviceGuard、カーネル関数を選択するコストが高いそうです。
- 関連するリンク
inline-c-cppのベンチマーク
inline-c-cppはC++の例外をHaskell側に返すために、例外の種類、例外のメッセージ、例外の型、例外の種類ごとのポインタといったデータが必要で、それぞれallocを使って領域を確保していました。
また、inline-c-cppはFFIのUnsafe Callに対応しておらず、結果一回の呼び出しに250ns程度かかっていました。
次のPRでinline-c-cppは改良済みです。
https://github.com/fpco/inline-c/pull/140
Inline展開
Inline展開が行われてないコードがあったので削除しました。
一つ一つは数十ns程度のオーバーヘッドですが、複数あって百ns程度のオーバーヘッドになっていました。
ForeignPtrのc finalizerのベンチマーク
GHCはC/C++のオブジェクトをGCで処理できるように削除されるときに呼ばれるc finalizerを設定できます。
c finalizerはweak pointerのリストで管理されています。c finalizer自身もリストになっています。
ezyangさんのblogにその説明があります。実際のベンチマークがなかったので、調べてみました。
単にメモリの確保を行うだけのベンチマークですが、
c finalizerを使うと146nsかかり、使わないと43.4nsと100ns程度の違いがあります。
https://github.com/junjihashimoto/ffi-benchmark
c finalizerが遅い原因は次のようなものでした。
- weak pointerのリストで管理されている。(メモリのレイアウト的な問題)
- weak pointerにあるc finalizerがリストで管理されている。(メモリのレイアウト的な問題)
- つまりc finalizerはリストのリストで管理されている。(これで数十nsは失われます。)
- c finalizerのセットに無駄が多い。
- newForeignPtrを呼び出し、IORefで領域を確保、atomicなロックを使って、c finalizerをセット
- 初期構築なのでlockを使わないで設定できるはず。
- minor gcのたびにすべてのc finalizerのチェックが行われる。
- copy gcの良さが台無しになり、gcの処理時間が増加。
Hmatrixはメモリの確保をGHCにまかせていて、c finalizerを使わないのでパフォーマンスがよいです。
まとめ
- HmatrixとHasktorchを比較してc++のFFIが遅い原因を調べました。
- 遅い原因は次の5つ(C++特有の問題は例外とc finalizerのところだけです。)
- C++のライブラリ(libtorch)が遅い
- C++の例外の補足のため
- FFIのSafe Callを使っていた
- Inline展開されてない
- ForeignPtrのc finalizerが遅い
- Hmatrixは上記の問題を回避しているからすごい。