メンバー関数へのポインターを使用する場合と、メンバー関数を使用する場合のコストはどれくらいですか?スイッチ?
-
02-07-2019 - |
質問
次のような状況があります。
class A
{
public:
A(int whichFoo);
int foo1();
int foo2();
int foo3();
int callFoo(); // cals one of the foo's depending on the value of whichFoo
};
現在の実装では、次の値を保存します。 whichFoo
コンストラクターのデータメンバーで、 switch
で callFoo()
どの foo を呼び出すかを決定します。あるいは、 switch
コンストラクター内でポインターを右に保存します fooN()
呼び出される callFoo()
.
私の質問は、クラス A のオブジェクトが 1 回だけ構築される場合、どちらの方法がより効率的であるかということです。 callFoo()
非常に多くの回数呼び出されます。したがって、最初のケースでは switch ステートメントが複数回実行されますが、2 番目のケースでは switch が 1 つだけあり、それへのポインターを使用してメンバー関数が複数回呼び出されます。ポインターを使用してメンバー関数を呼び出すと、直接呼び出すよりも時間がかかることはわかっています。このオーバーヘッドがシステムのコストより多いか少ないか知っている人はいますか? switch
?
説明:どのアプローチがより良いパフォーマンスをもたらすかは、実際に試して時間を測定するまでは決して分からないことは承知しています。ただし、この場合、アプローチ 1 はすでに実装されており、少なくとも原理的にはアプローチ 2 の方が効率的であるかどうかを知りたかったのです。それは可能であるように見えますが、今ではわざわざそれを実装して試してみるのが理にかなっています。
ああ、美的理由から、私はアプローチ 2 の方が好きです。それを実装するための正当化を探していると思います。:)
解決
ポインターを介してメンバー関数を呼び出すと、直接呼び出すよりも時間がかかるということをどの程度確信していますか?違いを測定できますか?
一般に、パフォーマンス評価を行う際は、直感に頼るべきではありません。コンパイラとタイミング関数を検討して、実際に 測定 さまざまな選択肢。驚かれるかも知れません!
より詳しい情報:素晴らしい記事があります メンバー関数ポインターと可能な限り最速の C++ デリゲート これは、メンバー関数ポインターの実装について非常に詳しく説明しています。
他のヒント
次のように書くことができます:
class Foo {
public:
Foo() {
calls[0] = &Foo::call0;
calls[1] = &Foo::call1;
calls[2] = &Foo::call2;
calls[3] = &Foo::call3;
}
void call(int number, int arg) {
assert(number < 4);
(this->*(calls[number]))(arg);
}
void call0(int arg) {
cout<<"call0("<<arg<<")\n";
}
void call1(int arg) {
cout<<"call1("<<arg<<")\n";
}
void call2(int arg) {
cout<<"call2("<<arg<<")\n";
}
void call3(int arg) {
cout<<"call3("<<arg<<")\n";
}
private:
FooCall calls[4];
};
実際の関数ポインターの計算は線形で高速です。
(this->*(calls[number]))(arg);
004142E7 mov esi,esp
004142E9 mov eax,dword ptr [arg]
004142EC push eax
004142ED mov edx,dword ptr [number]
004142F0 mov eax,dword ptr [this]
004142F3 mov ecx,dword ptr [this]
004142F6 mov edx,dword ptr [eax+edx*4]
004142F9 call edx
コンストラクターで実際の関数番号を修正する必要さえないことに注意してください。
このコードを、によって生成された asm と比較しました。 switch
. 。の switch
このバージョンではパフォーマンスは向上しません。
尋ねられた質問に答えるには:最も細かいレベルでは、メンバー関数へのポインターのパフォーマンスが向上します。
尋ねられていない質問に対処するには:ここでの「より良い」とはどういう意味ですか?ほとんどの場合、その差は無視できる程度であると予想されます。ただし、実行するクラスによっては、違いが大きくなる場合があります。違いを心配する前にパフォーマンス テストを行うことは、明らかに正しい最初のステップです。
スイッチを使い続ける場合は、それはまったく問題ありませんが、ロジックをヘルパー メソッドに入れて、コンストラクターから if を呼び出す必要があります。あるいは、これは典型的なケースです。 戦略パターン. 。Foo のシグネチャを持つ 1 つのメソッドを持つ IFoo という名前のインターフェイス (または抽象クラス) を作成できます。コンストラクターに IFoo のインスタンスを取り込ませることになります (コンストラクター 依存関係の注入 必要な foo メソッドを実装したものです。このコンストラクターで設定されるプライベート IFoo があり、Foo を呼び出すたびに IFoo のバージョンを呼び出すことになります。
注記:私は大学以来 C++ を扱っていないので、私の専門用語がここで間違っているかもしれませんが、一般的な考え方はほとんどの OO 言語に当てはまります。
例が実際のコードである場合は、クラス設計を再検討する必要があると思います。コンストラクターに値を渡し、それを使用して動作を変更することは、実際にはサブクラスを作成することと同じです。より明確にするためにリファクタリングを検討してください。これを行うと、コードで関数ポインターが使用されることになります (実際には、すべての仮想メソッドはジャンプ テーブル内の関数ポインターです)。
ただし、コードが一般的にジャンプ テーブルが switch ステートメントよりも高速であるかどうかを尋ねる単純化された例である場合、私の直感では、ジャンプ テーブルの方が高速であると言えますが、コンパイラの最適化ステップに依存します。しかし、パフォーマンスが本当に心配な場合は、決して直感に頼らないでください。テスト プログラムを起動してテストするか、生成されたアセンブラを確認してください。
1 つ確かなことは、switch ステートメントがジャンプ テーブルよりも遅くなることは決してないということです。その理由は、コンパイラーのオプティマイザーができる最善のことは、一連の条件付きテスト (すなわち、スイッチ) をジャンプテーブルに移動します。したがって、本当に確実にしたい場合は、コンパイラを決定プロセスから外し、ジャンプ テーブルを使用します。
作るべきみたいだね callFoo
純粋な仮想関数を作成し、いくつかのサブクラスを作成します。 A
.
本当に速度が必要で、広範なプロファイリングとインストルメントを実行し、呼び出しが必要であると判断した場合を除きます。 callFoo
が本当にボトルネックになっています。ありますか?
関数ポインタは、ほとんどの場合、chained-if よりも優れています。これらはよりクリーンなコードを作成し、ほぼ常に高速になります (おそらく、2 つの関数の間で選択するだけで、常に正しく予測される場合を除く)。
ポインタの方が速いと考えるべきです。
最新の CPU は命令をプリフェッチします。予測を誤ったブランチはキャッシュをフラッシュします。これは、キャッシュを再充填する間に停止することを意味します。ポインタではそんなことはできません。
もちろん、両方を測定する必要があります。
必要な場合にのみ最適化する
初め:ほとんどの場合、気にする必要はありませんが、その違いは非常に小さいものです。まず、この呼び出しを最適化することが本当に意味があることを確認してください。通話オーバーヘッドに実際にかなりの時間が費やされていることが測定によって示された場合にのみ、最適化に進みます (恥知らずなプラグ - 参照)。 アプリケーションを最適化して高速化するにはどうすればよいでしょうか?) 最適化が重要でない場合は、より読みやすいコードを優先します。
間接的な通話コストはターゲット プラットフォームによって異なります
低レベルの最適化を適用する価値があると判断したら、ターゲット プラットフォームを理解します。ここで回避できるコストは、分岐予測ミスのペナルティです。最新の x86/x64 CPU では、この予測ミスは非常に小さい可能性があります (ほとんどの場合、間接呼び出しを非常に正確に予測できます)。ただし、PowerPC またはその他の RISC プラットフォームをターゲットにしている場合、間接呼び出し/ジャンプはまったく予測されないことが多く、回避されます。パフォーマンスが大幅に向上する可能性があります。こちらも参照 仮想通話コストはプラットフォームによって異なります.
コンパイラはジャンプテーブルを使用してスイッチを実装することもできます
注意点が 1 つあります:特に多くの可能な値を切り替える場合、Switch は間接呼び出し (テーブルを使用) として実装できる場合もあります。このようなスイッチは、仮想関数と同じ予測ミスを示します。この最適化を信頼できるものにするために、最も一般的なケースではスイッチの代わりに if を使用することをお勧めします。
タイマーを使用して、どちらが速いかを確認してください。ただし、このコードを何度も繰り返すのでない限り、違いに気づくことはほとんどありません。
コンストラクターからコードを実行している場合は、構築が失敗した場合にメモリ リークが発生しないことを確認してください。
このテクニックは Symbian OS で頻繁に使用されます。http://www.titu.jyu.fi/modpa/Patterns/pattern-TwoPhaseConstruction.html
callFoo() を 1 回だけ呼び出している場合は、 最も可能性が高い 関数ポインタはわずかな量だけ遅くなります。何度も呼び出している場合は、 最も可能性が高い 関数ポインタはわずかな量だけ高速になります (スイッチを通過し続ける必要がないため)。
いずれにしても、アセンブルされたコードを見て、思ったとおりの動作をしているかどうかを確認してください。
切り替えの利点の 1 つは (並べ替えやインデックス付けよりも)、見落とされがちですが、大部分のケースで特定の値が使用されることがわかっている場合です。最も一般的なものを最初にチェックするようにスイッチを注文するのは簡単です。
ps。グレッグの答えを強化するために、速度を重視する場合は、測定してください。CPU にプリフェッチ/予測分岐やパイプライン ストールなどがある場合、アセンブラを調べても役に立ちません。