HN 日本語サマリー

← 一覧へ戻る
プログラミング

Libffiにおけるパフォーマンス改善

Performance Improvements in Libffi (atgreen.github.io)

16 pointsby atgreen6 コメント

要約

libffiは関数呼び出しをランタイムで解釈するライブラリですが、その性質上速度が犠牲になります。この記事では、引数の配置を毎回再計算するのではなく、一度「プラン」としてキャッシュすることで、関数呼び出しのパフォーマンスを最大6倍高速化する新しいアプローチが紹介されています。これにより、libffiはJITコンパイルのようなセキュリティリスクを回避しつつ、効率を大幅に向上させています。

全文翻訳

libffiは関数呼び出しインタープリターです。実行時に渡された関数のシグネチャ記述に基づいて、各引数をどのように配置し、呼び出しを行うかをその場で決定します。これは、バイトコードVMが命令を解釈する方法と同様に、呼び出し規約を解釈します。事前にシグネチャが不明であることが目的であるため、何も事前にコンパイルされることはありません。 速度を求める場合、インタープリターは選択肢になりません。一般的な解決策はJIT(Just-In-Timeコンパイル)です。これは、各シグネチャに対して専用の呼び出しスタブ、つまり引数をレジスタに配置してジャンプするネイティブコードをコンパイルし、実行時には何も解釈する部分を残しません。JITはより高速ですが、書き込み可能かつ実行可能なメモリに新しいマシンコードを書き込むことでこれを実現しており、これは現代のシステムがまさに排除しようとしているものです。 したがって、libffiは意図的にインタープリターのままでいます。私が答えを出そうとした問いは、実行時にコードを生成したり、書き込み可能かつ実行可能なページをマッピングしたりする代わりに、すでに知っていることを再利用することで、どれだけ高速化できるかということでした。 無駄#libffiを介して関数を呼び出すとき、作業は2つの場所に分かれます。ffi_prep_cifはシグネチャごとに1回実行されます。これは全体を分類しますが、保持するのは2つの結果だけです。呼び出しに必要なスタックフレームのサイズと、戻り値がどのように返されるかを示す小さなコードです。フレームサイズは呼び出しが構築される前に知っている必要があります。なぜなら、レジスタに収まらない引数はスタックに溢れ、そのスペースは事前に予約されるからです。戻り値のコードはその後で使われます。結果は型に応じてrax、xmm0、またはメモリに戻されるため、どこから読み取るかを知る必要があるからです。どちらも小さく固定サイズなので、ffi_cif内に格納されます。ffi_prep_cifが捨ててしまうのは、その時間のほとんどを費やした部分、つまり個々の引数がどこに配置されるか、です。 したがって、すべてのffi_callにおいて、マーシャリングコードは引数リストを再度ウォークし、値を配置する前にその配置をゼロから再導出します。x86-64上の3引数呼び出しの場合、これは約650命令の簿記作業であり、毎回全く同じ結果を生成します。 これらの命令のほとんどは引数のバイトを移動させるものではありません。バイトがどこに行くかを決定しているのです。System V AMD64 ABIは、固定の手順で各引数を分類します。単一の引数に対してその手順を実行するということは、その型をウォークし、構造体のフィールドに再帰し、型記述子のポインタを追いかけ、各8バイトのチャンクをINTEGERまたはSSEレジスタクラスに分類し、残りのレジスタにまだ収まるか、それともスタックに溢れる必要があるかをチェックすることを意味します。これは分岐が多く、ポインタを追いかける作業であり、CPUが遅く実行する種類の作業です。そして、これは決して変わらない配置を計算するために、すべての呼び出しで再実行されます。 しかし、関数の引数配置はシグネチャの純粋な関数です。一度計算し、それを記憶しておけば、それ以降のすべての呼び出しでその作業をスキップできます。 プラン#解決策は「プラン」です。これは、配置をフラットな移動リスト、つまり1つのシグネチャ用の小さなバイトコードにコンパイルしたものです。ffi_callがすべての呼び出しで配置を再導出するのが、毎回構文ツリーを再ウォークしてプログラムを解釈するようなものだとすると、プランはコンパイル済みバイトコードです。ツリーウォークは一度だけ行われ、それ以降のすべての呼び出しでは、単にフラットなリストを実行するだけです。build_planは引数の型を一度ウォークし、ABIルールに従ってそれぞれを分類し、ピースごとに移動を生成します。この8バイトワードはrdiに、あの32ビット整数はrsiに符号拡張され、このdoubleはSSEスロットに、あの大きすぎるものはスタックに溢れます。プランがあれば、呼び出しを行うのは単に移動を実行するだけです。再分類は不要です。 オペコードは意図的に単純です。GP64はワードを汎用レジスタにコピーし、SE8/SE16/SE32は狭いintを符号拡張し、SSE64/SSE32は浮動小数点数を移動させ、STACKは溢れた引数をmemcpyします。3引数呼び出しはこれら3つまたは4つにコンパイルされます。以下に、2つの実際のシグネチャがどのように変換されるかを示します。 ``` long (void *, void *, void *) long (void *, int, void *) GP64 avalue[0] -> rdi GP64 avalue[0] -> rdi GP64 avalue[1] -> rsi SE32 avalue[1] -> rsi (符号拡張) GP64 avalue[2] -> rdx GP64 avalue[2] -> rdx => すべてGP64: thunk => SE32あり: interpret ``` すべての引数が汎用レジスタ内の単一の64ビット値である場合(ほとんどのポインタ渡しコード)、プランはインタープリターすら必要としません。thunk-eligibleとマークされ、.text内の小さな手書きのthunkが引数配列から直接引数レジスタに値をロードして呼び出します。これにより、移動ループ、中間レジスタイメージ、およびコピーの行き来が完全にスキップされます。右側の呼び出しはintを保持しているため、符号拡張が必要であり、代わりに移動ループを実行します。 移動を実行する際には微妙な点があります。ループは実際の引数レジスタをロードしません。C言語では、rdiに値を置いて呼び出しを跨いで保持する方法がないためです。コンパイラがレジスタを所有しています。そのため、各移動はSystem Vレジスタファイルをミラーリングする(6つの整数レジスタと8つのSSEレジスタが順番に配置された)単純なメモリ構造体に書き込みます。そして、そのイメージが構築された後に初めて、短いアセンブリトランポリンがそこからすべての引数レジスタを一気にロードし、ターゲットにジャンプします。Cコードはメモリ内でバイトを移動させ、レジスタは呼び出しの直前に.text内で一度に最終的な値を受け取ります。このトランポリンはffi_callが常に使用してきたものと同じなので、プランは配置が計算されるときに変更されるのであって、レジスタがどのようにロードされるかは変更されません。 プランは単なるデータであり、thunkは他の関数と同様にバイナリの読み取り専用のテキストセクションに組み込まれます。これにより、何も書き込み可能かつ実行可能になることはなく、これはクロージャが静的トランポリンからすでに得ているプロパティと同じです。 一度構築し、何度も呼び出す#プランは小さなオプトインAPIとして公開されています。準備されたffi_cifからプランを構築し、好きなだけ呼び出し、終了したら解放します。 ```c ffi_call_plan *plan = ffi_call_plan_alloc(&cif); /* プランを一度構築 */ ffi_call_plan_invoke(plan, fn, &rv, av); /* 呼び出す、呼び出しごとの設定なし */ /* ... 再度呼び出す、何度も ... */ ffi_call_plan_free(plan); ``` ffi_call自体は変更されていません。ほとんどのバインディングがシグネチャごとにffi_cifをキャッシュしているように、プランをその隣にキャッシュし、ffi_call_plan_invokeを介して呼び出します。プランは一度構築されると不変なので、1つのプランはロックなしでどのスレッドからも共有および呼び出しが可能です。高速パスで処理できないシグネチャも問題ありません。invokeはそのためにffi_callにフォールバックします。 数値#これは公正な比較です。1つのlibffi、同じ関数を3つの方法で呼び出しました。直接呼び出し、ffi_callを介した呼び出し、そして事前に構築されたプランを介した呼び出しです。同じバイナリ、同じマシン(Core Ultra 7 255H)、同じ-O2オプションを使用しているため、2つのFFI行の間で異なるのはAPIだけです。計測されたループはこれを何度も繰り返すだけです。 ```c ffi_type *at[] = { &ffi_type_pointer, &ffi_type_pointer, &ffi_type_pointer }; ffi_cif cif; ffi_prep_cif(&cif, FFI_DEFAULT_ABI, 3, &ffi_type_sint64, at); ffi_call_plan *plan = ffi_call_plan_alloc(&cif); /* 一度構築 */ void *av[] = { &a, &b, &c }; long rv; ffi_call_plan_invoke(plan, (void(*)(void))fn, &rv, av); /* <-- これが計測対象 */ ``` ptr(p,p,p) ns/call vs a regular call 通常の関数呼び出し 1.9 1x ffi_call_plan_invoke 5.1 2.7x ffi_call 31.0 16x その関数を通常のffi_callを介して呼び出すと、直接呼び出す場合の約16倍のコストがかかります。事前に構築されたプランを介すると、3倍未満です。プランはffi_callよりも約6倍高速であり、同じライブラリを2つの方法で利用しているため、その差はAPI以外にはありません。 プランが取り除くもののほとんどは、呼び出しごとの再分類です。ffi_callは毎回配置を再構築するのに対し、invokeは事前に構築された移動を実行するだけです。この形状ではプランがthunkを使用するため、レジスタイメージもスキップされ、通常の呼び出しに近くなります。2nsの呼び出しに加えて約3nsのFFIオーバーヘッドがかかるのに対し、ffi_callでは29nsです。 混合された整数および浮動小数点シグネチャはthunkを使用しません。なぜなら、32ビットintは符号拡張が必要で、doubleはSSEレジスタが必要だからです。そのため、それらは移動ループを実行し、少し高いコストがかかります。それらは