一般に、分岐を避けるために仮想関数を使用する価値はありますか?


21

ブランチミスの仮想コストに相当する命令の大まかな同等物があるように思われますが、同様のトレードオフがあります。

  • 命令対データキャッシュミス
  • 最適化の障壁

次のようなものを見ると:

if (x==1) {
   p->do1();
}
else if (x==2) {
   p->do2();
}
else if (x==3) {
   p->do3();
}
...

メンバー関数配列を持つことができます。または、多くの関数が同じ分類に依存している場合、またはより複雑な分類が存在する場合は、仮想関数を使用します。

p->do()

しかし、一般的には、それを分岐対仮想関数どのように高価であり、いずれかが親指の大まかなルールを持っていた場合(4などの単純なとしてそれがあった場合に素敵な私が思っていたので、一般化するのに十分なプラットフォーム上でテストするのは難しいですifsがブレークポイントです)

一般的に、仮想機能はより明確であり、私はそれらに寄りかかります。しかし、コードを仮想関数からブランチに変更できる非常に重要なセクションがいくつかあります。これに着手する前に、これについて考えたいと思います。(簡単な変更ではなく、複数のプラットフォームで簡単にテストすることもできません)


12
さて、あなたのパフォーマンス要件は何ですか?あなたがヒットしなければならないハードナンバーを持っていますか、それとも時期尚早の最適化に取り組んでいますか?ブランチ方式と仮想方式の両方は、物事の大規模なスキームでは非常に安価です(たとえば、悪いアルゴリズム、I / O、またはヒープ割り当てと比較して)。
アモン

4
将来の変更の方法で取得するために柔軟な/より読み/そうだものは何でも、あなたはそれが働いていたら、その後のプロファイリングんし、これは実際に重要かどうかを確認します。通常はそうではありません。
Ixrec

1
質問:「しかし、一般に、仮想機能はどれくらい高価ですか...」回答:間接分岐(wikipedia)
rwong

1
ほとんどの回答は、指示の数を数えることに基づいていることに注意してください。低レベルのコードオプティマイザーとして、私は命令の数を信用しません。特定のCPUアーキテクチャ-物理的-実験条件下でそれらを証明する必要があります。この質問に対する有効な答えは、理論的ではなく経験的かつ実験的でなければなりません。
-rwong

3
この質問の問題は、これが心配するほど大きいことを前提としていることです。実際のソフトウェアでは、複数のサイズのピザのスライスのように、パフォーマンスの問題は大きな塊になります。たとえば、こちらをご覧ください。最大の問題が何であるかを知っていると思い込まないでください-プログラムに教えてください。それを修正してから、次のものを教えてください。これを6回行うと、仮想関数呼び出しを心配する価値があるところまで行くかもしれません。私の経験では決してありません。
マイクダンレイビー

回答:


21

これらのすでに優れた答えの中でここに飛び込み、多態性コードを測定されたゲインに変更しswitchesたり、if/else分岐したりするアンチパターンに実際に逆戻りするといういアプローチを採用したことを認めたいと思いました。しかし、私はこの大々的なことはしませんでした。最もクリティカルなパスに対してだけです。白黒である必要はありません。

免責事項として、私はレイトレーシングのような分野で仕事をしていますが、そこでは正確さを達成するのはそれほど難しくありません(そして、とにかく曖昧で近似されます)。レンダリング時間の短縮は、多くの場合、最も一般的なユーザーリクエストの1つであり、常に重要な測定パスでそれを達成する方法を考えながら頭を悩ましています。

条件付きの多相リファクタリング

まず、多態性が条件分岐(switchまたは一連のif/elseステートメント)よりも保守性の観点から好ましい理由を理解する価値があります。ここでの主な利点は拡張性です。

ポリモーフィックコードを使用すると、コードベースに新しいサブタイプを導入し、そのインスタンスをポリモーフィックデータ構造に追加し、既存のすべてのポリモーフィックコードをさらに変更することなく自動的に機能させることができます。「この型が 'foo'の場合、それを行う」という形式に似た大規模なコードベース全体に多数のコードが散在している場合、導入するためにコードの50の異なるセクションを更新するという恐ろしい負担を感じるかもしれません新しいタイプのものですが、それでもいくつか不足しています。

このような型チェックを行う必要があるコードベースのセクションが2つまたは1つでもある場合、ポリモーフィズムの保守性の利点はここで自然に減少します。

最適化の障壁

分岐とパイプライン化の観点からこれをあまり見ないことをお勧めします。最適化の障壁のコンパイラー設計の考え方からもっと見ます。サブタイプに基づいてデータを並べ替える(シーケンスに適合する場合)など、両方の場合に適用される分岐予測を改善する方法があります。

これら2つの戦略の違いは、オプティマイザーが事前に持っている情報量です。既知の関数呼び出しはより多くの情報を提供します。コンパイル時に不明な関数を呼び出す間接的な関数呼び出しは、最適化の障壁につながります。

呼び出されている関数がわかっている場合、コンパイラーは構造を消去し、それをスミスリエンにつぶし、呼び出しをインライン化し、潜在的なエイリアシングのオーバーヘッドを排除し、命令/レジスタ割り当てでより良い仕事をし、場合によってはループや他の形式の分岐を再配置し、ハードを生成することができます必要に応じswitchて、コード化されたミニチュアLUT(GCC 5.3が最近、ジャンプテーブルではなく、結果のデータのハードコード化されたLUTを使用するステートメントに驚いた)。

間接的な関数呼び出しの場合のように、コンパイル時の未知の要素をミックスに導入し始めると、これらの利点の一部が失われ、条件分岐がエッジを提供する可能性が高くなります。

メモリ最適化

一連のクリーチャーをタイトループで繰り返し処理するビデオゲームの例を見てみましょう。そのような場合、次のようなポリモーフィックコンテナがあります。

vector<Creature*> creatures;

注:簡単にするため、unique_ptrここでは避けました。

... Creatureは多態的な基本型です。この場合、ポリモーフィックコンテナの難しさの1つは、サブタイプごとにメモリを個別に/個別に割り当てたいことです(例:operator new個々のクリーチャーごとにデフォルトのスローを使用する)。

多くの場合、最適化の最初の優先順位付け(必要な場合)は、分岐ではなくメモリベースになります。ここでの戦略の1つは、各サブタイプに固定アロケーターを使用して、大きなチャンクに割り当て、割り当てられる各サブタイプにメモリをプールすることにより、連続した表現を促進することです。このような戦略では、creaturesブランチ予測を改善するだけでなく、参照の局所性も改善するため(同じサブタイプの複数のクリーチャーにアクセスできるようにするため)、このコンテナをサブタイプ(およびアドレス)でソートすることは間違いなく役立ちますエビクション前の単一キャッシュラインから)。

データ構造とループの部分的な仮想化

これらすべての動作を行ったが、さらに高速化を望んでいるとしましょう。ここでベンチャーする各ステップが保守性を低下させていることは注目に値します。パフォーマンスリターンが低下する、やや金属研削の段階にあることになります。したがって、この領域に踏み込んだ場合、かなりのパフォーマンス要求が必要になります。この領域では、パフォーマンスの向上のために保守性をさらに犠牲にすることになります。

しかし、次のステップ(および、それがまったく役に立たない場合は常に変更を取り消そうとする意欲ある)を手動で仮想化することできます。

バージョン管理のヒント:私よりもはるかに最適化に精通している場合を除き、この時点で最適化の取り組みが失敗する可能性がある場合、それを捨てる意思のある新しいブランチを作成する価値があります。私にとっては、プロファイラーを手に持っていても、この種のポイントの後はすべて試行錯誤です。

それにもかかわらず、この考え方を大々的に適用する必要はありません。私たちの例を続けて、このビデオゲームはほとんど人間の生き物で構成されているとしましょう。そのような場合、人間の生き物を持ち上げて、それらのためだけに別個のデータ構造を作成することにより、人間の生き物だけを仮想化できます。

vector<Human> humans;               // common case
vector<Creature*> other_creatures;  // additional rare-case creatures

これは、クリーチャーを処理する必要があるコードベースのすべての領域が、人間のクリーチャー用に個別の特殊なケースループを必要とすることを意味します。しかし、これにより、最も一般的なクリーチャータイプである人間の動的ディスパッチオーバーヘッド(または、おそらくより適切な最適化の障壁)がなくなります。これらのエリアの数が多く、余裕がある場合、これを行うことができます。

vector<Human> humans;               // common case
vector<Creature*> other_creatures;  // additional rare-case creatures
vector<Creature*> creatures;        // contains humans and other creatures

...これに余裕があれば、それほど重要でないパスはそのままで、すべてのクリーチャータイプを抽象的に処理できます。クリティカルパスはhumans、1つのループとother_creatures2番目のループで処理できます。

必要に応じてこの戦略を拡張し、潜在的にこの方法でいくつかの利益を絞ることができますが、プロセスの保守性をどれだけ低下させているかに注目する価値があります。ここで関数テンプレートを使用すると、手動でロジックを複製することなく、人間と生物の両方のコードを生成できます。

クラスの部分的な仮想化

私が数年前にやったことは本当にひどいもので、それがもう有益かどうかはわかりません(これはC ++ 03時代でした)、クラスの部分的な仮想化でした。その場合、他の目的のために各インスタンスとともにクラスIDを既に保存していました(非仮想である基本クラスのアクセサーを介してアクセスされます)。そこで、これと類似した何かをしました(私の記憶は少しかすんでいます):

switch (obj->type())
{
   case id_common_type:
       static_cast<CommonType*>(obj)->non_virtual_do_something();
       break;
   ...
   default:
       obj->virtual_do_something();
       break;
}

... virtual_do_somethingサブクラスの非仮想バージョンを呼び出すために実装された場所。関数呼び出しを仮想化するために、明示的な静的ダウンキャストを行うのは大変なことです。私はこのタイプのことを何年も試していなかったので、これが今どれほど有益であるかわかりません。データ指向の設計に触れると、データ構造とループをホット/コールド方式で分割する上記の戦略がはるかに便利であり、最適化戦略の扉が開かれます(そしてlessさがはるかに少なくなります)。

卸売業の仮想化

最適化の考え方をこれまで適用したことがないことを認めなければならないので、メリットについてはわかりません。条件の中心セットが1つだけになるとわかっていた場合(たとえば、中心的な場所を処理するイベントが1つだけのイベント処理)になるとわかっていた場合、先見性のある間接的な機能を避けましたここまで。

理論的には、ここでの直接的な利点は、これらの最適化の障壁を完全に消去することに加えて、仮想ポインターよりもタイプを識別する潜在的に小さな方法(たとえば、256個以下の一意のタイプがあるという考えにコミットできる場合は1バイト)である可能性があります。

switchサブタイプに基づいてデータ構造とループを分割せずに1つの中央ステートメントを使用する場合、または順序がある場合は、(上記の最適化された手動の仮想化の例と比較して)メンテナンスが容易なコードを書くことも役立つ場合があります-物事を正確な順序で処理する必要があるこれらの場合の依存関係(たとえそれが場所全体に分岐する場合でも)。これはあなたが行う必要のある場所があまりない場合になるでしょうswitch

メンテナンスが合理的に簡単でない限り、パフォーマンスが非常に重要な考え方であっても、通常はこれを推奨しません。「保守が容易」は、2つの主要な要因に左右される傾向があります。

  • 実際に拡張性を必要としない(例:処理するものが正確に8種類あり、それ以上ではないことを確認する)。
  • これらのタイプをチェックする必要のあるコード内の場所が多くない(例:1つの中央の場所)。

...しかし、ほとんどの場合は上記のシナリオを推奨し、必要に応じて部分的な仮想化を解除してより効率的なソリューションを目指して繰り返します。拡張性と保守性のニーズとパフォーマンスのバランスをとるために、より多くの呼吸空間を提供します。

仮想関数と関数ポインター

これを整理するために、私はここで、仮想関数と関数ポインタに関する議論があることに気付きました。仮想関数を呼び出すには少し余分な作業が必要なのは事実ですが、それが遅くなるという意味ではありません。直感に反して、さらに高速化することもあります。

ここでは、はるかに重要な影響を与える傾向があるメモリ階層のダイナミクスに注意を払うことなく、命令の観点からコストを測定することに慣れているため、ここでは直感に反します。

class20個の仮想関数と20 struct個の関数ポインタを格納するa を比較し、両方が複数回インスタンス化されるclass場合、この場合の各インスタンスのメモリオーバーヘッドは64ビットマシン上の仮想ポインタで8バイト、メモリはのオーバーヘッドstructは160バイトです。

実際のコストは、仮想関数を使用するクラス(およびおそらく十分な入力スケールでのページフォールト)と比較して、関数ポインターのテーブルでの強制的および非強制的なキャッシュミスがはるかに多くなる可能性があります。そのコストは、仮想テーブルのインデックス作成のわずかに余分な作業を小さくする傾向があります。

またstructs、関数ポインターで満たされたものを何度もインスタンス化し、仮想関数を持つクラスに変換することで実際に大幅なパフォーマンスの向上(100%以上の改善)をもたらすレガシーCコードベース(私よりも古い)を扱いました。メモリ使用量の大幅な削減、キャッシュの使いやすさの向上などのため

逆に、リンゴとリンゴの比較がより多くなると、C ++仮想関数マインドセットからCスタイル関数ポインタマインドセットに変換するという逆の考え方が、これらのタイプのシナリオで役立つことがわかりました:

class Functionoid
{
public:
    virtual ~Functionoid() {}
    virtual void operator()() = 0;
};

...クラスは、単一のオーバーライド可能な関数(または仮想デストラクタを数える場合は2つ)を格納していました。そのような場合、クリティカルパスでこれを間違いなく次のように変えることができます。

void (*func_ptr)(void* instance_data);

...理想的には、タイプセーフなインターフェースの背後で、危険なキャストを/から隠しますvoid*

単一の仮想関数を持つクラスを使用したい場合、関数ポインターを代わりに使用するとすぐに役立ちます。大きな理由は、必ずしも関数ポインタを呼び出すコストの削減でさえありません。これは、永続的な構造に集約する場合、ヒープの散在する領域に各個別のファンクションを割り当てる誘惑に直面しないためです。この種のアプローチにより、インスタンスデータが同種であり、動作のみが異なる場合、ヒープ関連のオーバーヘッドとメモリフラグメンテーションのオーバーヘッドを簡単に回避できます。

したがって、関数ポインタを使用すると役立つ場合が間違いなくありますが、クラスインスタンスごとに1つのポインタのみを格納する必要がある単一のvtableと関数ポインタのテーブルの束を比較する場合、多くの場合、逆の方法を見つけました。そのvtableは、多くの場合、1つ以上のL1キャッシュラインとタイトループに置かれます。

結論

とにかく、それはこのトピックに関する私のちょっとしたスピンです。これらのエリアでの注意深い冒険をお勧めします。直感ではなく、測定を信頼し、これらの最適化が保守性を低下させることが多い方法を考えると、余裕がある範囲でのみ行ってください(そして、賢明なルートは保守性の面で間違っています)。


仮想関数は関数ポインタであり、そのクラスの実行可能に実装されています。仮想関数が呼び出されると、最初に子で検索され、継承チェーンが検索されます。これが、深い継承が非常に高価であり、一般的にC ++では回避される理由です。
ロバートバロン

@RobertBaron:あなたが言ったように仮想関数が実装されているのを見たことがありません(=クラス階層を介したチェーンルックアップ)。一般に、コンパイラは、すべての正しい関数ポインタを使用して各具象型の「フラット化された」vtableを生成するだけで、実行時に呼び出しは単一のストレートテーブルルックアップで解決されます。深い継承階層に対してペナルティは支払われません。
マッテオイタリア

マッテオ、これは何年も前にテクニカルリーダーから説明を受けたものです。確かに、c ++用であったため、多重継承の影響を考慮していた可能性があります。vtableがどのように最適化されるかについての私の理解を明確にしていただきありがとうございます。
ロバートバロン

良い答えをありがとう(+1)。仮想関数の代わりにstd :: visitにこれがどれだけ当てはまるのだろうか。
DaveFar

13

観察:

  • 多くの場合、vtableルックアップはO(1)操作であり、else if()ラダーは操作であるため、仮想関数は高速O(n)です。ただし、これはケースの分布がフラットな場合にのみ当てはまります。

  • 単一のif() ... else場合、関数呼び出しのオーバーヘッドを節約するため、条件式の方が高速です。

  • したがって、ケースのフラットな分布がある場合、損益分岐点が存在する必要があります。唯一の質問は、それがどこにあるかです。

  • ラダーまたは仮想関数呼び出しのswitch()代わりにを使用するelse if()と、コンパイラーはさらに優れたコードを生成する可能性があります。コンパイラーは、テーブルから検索されるが関数呼び出しではない場所への分岐を実行できます。つまり、すべての関数呼び出しのオーバーヘッドなしで、仮想関数呼び出しのすべてのプロパティがあります。

  • 1つが他よりも頻繁にif() ... elseある場合、そのケースで開始すると、最高のパフォーマンスが得られます。ほとんどのケースで正しく予測される単一の条件分岐を実行します。

  • コンパイラーは、予想されるケースの分布に関する知識がなく、フラットな分布を想定しています。

コンパイラにはswitch()else if()ラダーまたはテーブルルックアップとしてコーディングするタイミングに関して、いくつかの優れたヒューリスティックが備わっている可能性が高いためです。ケースの分布が偏っていることを知らない限り、私はその判断を信頼する傾向があります。

だから、私のアドバイスはこれです:

  • ケースの1つが頻度の点で他のケースよりも小さい場合、ソートされたelse if()ラダーを使用します。

  • それ以外の場合switch()は、他のメソッドのいずれかがコードをより読みやすくする場合を除き、ステートメントを使用します。読みやすさを大幅に低下させながら、無視できるほどのパフォーマンスゲインを購入しないようにしてください。

  • を使用しswitch()てもパフォーマンスにまだ満足できない場合は、比較を行いますが、switch()すでに最速の可能性があることを確認する準備をしてください。


2
一部のコンパイラでは、注釈がコンパイラにどのケースが本当である可能性が高いかを伝えることができ、それらのコンパイラは注釈が正しい限りより高速なコードを生成できます。
gnasher729

5
O(1)操作は、実際の実行時間ではO(n)またはO(n ^ 20)よりも必ずしも高速ではありません。
whatsisname

2
@whatsisnameそれが私が「多くの場合」と言った理由です。の定義によりO(1)O(n)が存在するkため、O(n)関数はO(1)all の関数よりも大きくなりますn >= k。唯一の質問は、あなたがそのような多くのケースを持っている可能性が高いかどうかです。そして、はい、はしごが仮想関数呼び出しやロードされたディスパッチよりもswitch()明らかにelse if()遅いほど多くの場合のステートメントを見てきました。
cmaster-モニカの復元15年

この回答で私が抱えている問題は、完全に無関係なパフォーマンスの向上に基づいて決定を下すことに対する唯一の警告であり、最後の段落のどこかに隠されています。ここでの他のすべてが、についての決定作るためには良い考えかもしれふりifswitchもパフォーマンスに基づいて対仮想関数を。で、非常にまれなケースではかもしれないが、大多数の場合にはそうではありません。
ドックブラウン

7

一般に、分岐を避けるために仮想関数を使用する価値はありますか?

一般に、はい。メンテナンスの利点は重要です(分離のテスト、懸念の分離、モジュール性と拡張性の改善)。

しかし、一般的に、仮想関数と分岐はどれほど高価ですか?一般化するのに十分なプラットフォームでテストするのは難しいので、誰かが大まかな経験則を持っているかどうか疑問に思っていました

コードのプロファイルを作成し、ブランチ間のディスパッチ(条件評価)が実行される計算(ブランチ内のコード)よりも時間がかかる場合を除き、実行される計算を最適化します。

つまり、「仮想関数と分岐のコストはどれほど高いか」に対する正解は、測定して調べることです。

経験則:上記の状況(分岐の計算よりも分岐の識別の方が高い)がない限り、コードのこの部分を保守作業のために最適化します(仮想関数を使用します)。

このセクションをできるだけ速く実行したいということです。それはどれくらい速いですか?具体的な要件は何ですか?

一般的に、仮想機能はより明確であり、私はそれらに寄りかかります。しかし、コードを仮想関数からブランチに変更できる非常に重要なセクションがいくつかあります。これに着手する前に、これについて考えたいと思います。(簡単な変更ではなく、複数のプラットフォームで簡単にテストすることもできません)

その後、仮想関数を使用します。これにより、必要に応じてプラットフォームごとに最適化することもでき、それでもクライアントコードをクリーンに保つことができます。


多くのメンテナンスプログラミングを行ったので、少し注意して話を進めます。仮想関数は、リストに挙げた利点のために、メンテナンスにはかなり悪いです。中心的な問題は柔軟性です。あなたはそこにほとんど何でも入れることができます...そして人々はします。動的ディスパッチについて静的に推論することは非常に困難です。しかし、ほとんどの特定の場合、コードにはそれほど柔軟性は必要ありません。実行時の柔軟性をなくすと、コードについて推論しやすくなります。それでも、動的ディスパッチを使用してはならないと言っているほどには行きたくありません。それはばかげている。
イーモンネルボンヌ

使用するのに最適な抽象化は、まれなものです(つまり、コードベースには不透明な抽象化がいくつかあります)が、非常に堅牢です。基本的に、ある特定のケースで同じような形をしているという理由だけで、動的ディスパッチ抽象化の背後に何かを貼り付けないでください。そのインターフェースを共有するオブジェクト間の区別を気にする理由を合理的に思いつかない場合にのみそうしてください。できない場合:漏れやすい抽象化よりも、非カプセル化ヘルパーを使用する方が良い。そしてそれでも; 実行時の柔軟性とコードベースの柔軟性の間にはトレードオフがあります。
イーモンネルボンヌ

5

他の答えはすでに良い理論的議論を提供しています。私が最近実行した実験の結果を追加しswitchて、op-codeに大きな値を使用して仮想マシン(VM)を実装するか、op-codeをインデックスとして解釈するのが良いかどうかを推定します関数ポインタの配列に。これはvirtual関数呼び出しとまったく同じではありませんが、かなり近いと思います。

1〜10000の範囲でランダムに選択された命令セットサイズ(均一ではないが、低範囲をより高密度にサンプリング)でVMのC ++ 14コードをランダムに生成するPythonスクリプトを作成しました。 RAM。指示は意味がなく、すべて次の形式になっています。

inline void
op0004(machine_state& state) noexcept
{
  const auto c = word_t {0xcf2802e8d0baca1dUL};
  const auto r1 = state.registers[58];
  const auto r2 = state.registers[69];
  const auto r3 = ((r1 + c) | r2);
  state.registers[6] = r3;
}

スクリプトは、switchステートメントを使用してディスパッチルーチンも生成します…

inline int
dispatch(machine_state& state, const opcode_t opcode) noexcept
{
  switch (opcode)
  {
  case 0x0000: op0000(state); return 0;
  case 0x0001: op0001(state); return 0;
  // ...
  case 0x247a: op247a(state); return 0;
  case 0x247b: op247b(state); return 0;
  default:
    return -1;  // invalid opcode
  }
}

…および関数ポインタの配列。

inline int
dispatch(machine_state& state, const opcode_t opcode) noexcept
{
  typedef void (* func_type)(machine_state&);
  static const func_type table[VM_NUM_INSTRUCTIONS] = {
    op0000,
    op0001,
    // ...
    op247a,
    op247b,
  };
  if (opcode >= VM_NUM_INSTRUCTIONS)
    return -1;  // invalid opcode
  table[opcode](state);
  return 0;
}

生成されたディスパッチルーチンは、生成されたVMごとにランダムに選択されました。

ベンチマークのために、オペコードのストリームは、ランダムにシードされた(std::random_device)メルセンヌツイスターランダムエンジン(std::mt19937_64)によって生成されました。

各VMのコードは-DNDEBUG-O3-std=c++14スイッチを使用してGCC 5.2.0でコンパイルされました。まず、-fprofile-generate1000のランダムな命令をシミュレートするために収集されたオプションとプロファイルデータを使用してコンパイルされました。次に-fprofile-use、収集されたプロファイルデータに基づいて最適化できるオプションを使用して、コードを再コンパイルしました。

次に、VMを(同じプロセスで)50 000 000サイクルで4回実行し、各実行の時間を測定しました。最初の実行は、コールドキャッシュの影響を排除するために破棄されました。実行の間にPRNGが再シードされなかったため、同じシーケンスの命令を実行しませんでした。

このセットアップを使用して、ディスパッチルーチンごとに1000データポイントが収集されました。データは、グラフィカルデスクトップやその他のプログラムを実行せずに64ビットGNU / Linuxを実行する2048 KiBキャッシュを備えたクアッドコアAMD A8-6600K APUで収集されました。以下に、各VMの命令ごとの平均CPU時間(標準偏差を含む)のプロットを示します。

ここに画像の説明を入力してください

このデータから、非常に少数のオペコードを除いて、関数テーブルを使用することをお勧めします。switch500から1000の命令のバージョンの外れ値についての説明はありません。

ベンチマークのすべてのソースコード、完全な実験データ、高解像度プロット、私のWebサイトで見つけることができます。


3

私が支持したcmasterの良い答えに加えて、関数ポインターは一般的に仮想関数よりも厳密に速いことに留意してください。通常、仮想関数のディスパッチでは、最初にオブジェクトからvtableへのポインターを追跡し、適切にインデックスを作成してから、関数ポインターを逆参照します。したがって、最終ステップは同じですが、最初は追加のステップがあります。さらに、仮想関数は常に「this」を引数として使用し、関数ポインターはより柔軟です。

留意すべきもう1つの点は、クリティカルパスにループが含まれる場合、ディスパッチの宛先ごとにループをソートすると役立つ場合があることです。明らかにこれはnlognですが、ループを横断するのはnだけですが、何度も横断する場合は価値があります。ディスパッチ先でソートすることにより、同じコードが繰り返し実行されることを保証し、icache内でそれをホットに保ち、キャッシュミスを最小限に抑えます。

留意すべき3番目の戦略:仮想関数/関数ポインターからif / switch戦略に移行することに決めた場合、ポリモーフィックオブジェクトからboost :: variant(スイッチも提供するもの)訪問者の抽象化の形のケース)。ポリモーフィックオブジェクトはベースポインターによって格納する必要があるため、データはキャッシュのいたるところにあります。これは、仮想ルックアップのコストよりも、クリティカルパスに大きな影響を与える可能性があります。バリアントは差別化された結合としてインラインで保存されますが、最大データ型に等しいサイズ(および小さな定数)があります。オブジェクトのサイズの違いが大きすぎない場合、これはオブジェクトを処理するのに最適な方法です。

実際、データのキャッシュコヒーレンシの改善が元の質問よりも大きな影響を与えても驚くことはありません。


ただし、仮想関数に「余分な手順」が含まれることはわかりません。コンパイル時にクラスのレイアウトがわかっている場合、それは基本的に配列アクセスと同じです。つまり、クラスの先頭へのポインタがあり、関数のオフセットがわかっているので、それを追加して結果を読み取り、それがアドレスになります。多くのオーバーヘッド。

1
追加の手順が必要です。vtable自体には関数ポインターが含まれているため、vtableに到達すると、関数ポインターで開始したのと同じ状態になります。vtableに到達する前のすべては余分な作業です。クラスにはvtableが含まれておらず、vtableへのポインターが含まれており、そのポインターに続いて追加の逆参照が行われます。実際、ポリモーフィッククラスは一般にベースクラスポインターによって保持されるため、3番目の逆参照が発生することがあります。そのため、ポインターを逆参照してvtableアドレスを取得する必要があります(逆参照するには;-))。
ニルフリードマン

反対に、vtableがインスタンスの外部に格納されているという事実は、一時的な局所性と、たとえば、すべての関数ポインターが異なるメモリアドレスに格納されている関数ポインターのさまざまな構造体に対して実際に役立ちます。このような場合、100万のvptrを持つ1つのvtableは、100万の関数ポインターテーブルを簡単に破ることができます(メモリ消費だけで開始)。ここでは多少の混乱が生じる可能性がありますが、簡単に分解することはできません。一般に、関数ポインタは少し安くなることが多いのですが、上下に並べることはそれほど簡単ではありません。

別の言い方をすれば、仮想関数がオブジェクトポインターのボートロードを伴う場合(各オブジェクトが複数の関数ポインターまたは単一のvptrのいずれかを格納する必要がある場合)は、仮想関数が迅速かつ大幅に関数ポインターを上回るようになります。関数ポインターは、たとえば、メモリに1つの関数ポインターだけが格納されていて、それがボートロードと呼ばれる場合、より安価になる傾向があります。そうしないと、多くの冗長なメモリを占有し、同じアドレスをポイントするために生じるデータの冗長性とキャッシュミスの量により、関数ポインタが遅くなり始める可能性があります。

もちろん、関数ポインターを使用すると、100万個の個別のオブジェクトで共有されている場合でも、それらを中央の場所に保存して、メモリの占有やキャッシュミスのボートロードを回避できます。しかし、それらはvpointerと同等になり始め、呼び出したい実際の関数アドレスに到達するためにメモリ内の共有場所へのポインタアクセスを伴います。ここでの基本的な質問は、現在アクセスしているデータの近くに、または中央の場所に関数アドレスを保存しますか?vtableは後者のみを許可します。関数ポインターは両方の方法を許可します。

2

これがXY問題だと思う理由を説明してもいいですか?(質問するのはあなただけではありません。)

キャッシュミスと仮想関数に関するポイントを理解するだけでなく、全体的な時間を節約することがあなたの本当の目標だと思います。

実際のソフトウェアでの実際のパフォーマンスチューニングの例を次に示します。

実際のソフトウェアでは、プログラマーの経験がどれほど優れていても、それを実現することができます。プログラムが作成されてパフォーマンスチューニングが行われるまで、それらが何であるかはわかりません。ほとんどの場合、プログラムを高速化する方法は複数あります。結局のところ、プログラムが最適であるということは、問題を解決するための可能なプログラムのパンテオンでは、どれも時間がかからないということです。本当に?

私がリンクした例では、元々「ジョブ」あたり2700マイクロ秒かかりました。一連の6つの問題が修正され、ピザの周りを反時計回りに回りました。最初の高速化により、時間の33%が削除されました。2番目は11%を削除しました。しかし、2番目の問題は発見された時点では11%ではなく、16%でした最初の問題がなくなったためです。同様に、最初の2つの問題がなくなったため、3番目の問題は7.4%から13%(ほぼ2倍)に拡大されました。

最後に、この拡大プロセスにより、3.7マイクロ秒を除くすべてを排除することができました。これは元の時間の0.14%、つまり730xの高速化です。

ここに画像の説明を入力してください

最初に発生した大きな問題を削除すると、中程度のスピードアップが得られますが、後の問題を削除する方法が整います。これらの後者の問題は、最初は全体の取るに足りない部分であった可能性がありますが、初期の問題を取り除いた後、これらの小さな問題は大きくなり、大幅な高速化を実現できます。(この結果を得るために、見逃すことのできるものはないことを理解することが重要です。この投稿では、どれだけ簡単にできるかを示しています。)

ここに画像の説明を入力してください

最終プログラムは最適でしたか?おそらくない。キャッシュミスとは関係ありませんでした。キャッシュミスは問題になりますか?多分。

編集:私はOPの質問の「非常に重要なセクション」にホーミングする人々からダウン票を得ています。時間の何分の1を占めているかを知るまで、何かが「非常に重要」であることはわかりません。呼び出されるメソッドの平均コストが10サイクル以上である場合、それらをディスパッチする方法は、実際に実行していることに比べて、おそらく「クリティカル」ではありません。私はこれを何度も繰り返しています。人々は「ナノ秒ごとに必要」をペニーとポンドバカの理由として扱っています。


彼はすでに、最後のナノ秒ごとのパフォーマンスを必要とするいくつかの「非常に重要なセクション」があると言っています。したがって、これは彼が尋ねた質問に対する答えではありません(たとえ他の人の質問に対する素晴らしい答えであっても)
gbjbaanb

2
@gbjbaanb:すべての最後のナノ秒がカウントされる場合、なぜ質問は「一般」で始まるのですか?それはナンセンスです。ナノ秒がカウントされると、一般的な答えを見つけることができず、コンパイラが何をするか、ハードウェアが何をするか、バリエーションを試して、すべてのバリエーションを測定します。
gnasher729

@ gnasher729わかりませんが、なぜ「非常に重要なセクション」で終わるのですか?スラッシュドットのように、タイトルだけでなく、常にコンテンツを読むべきだと思います!
gbjbaanb

2
@gbjbaanb:誰もが「非常に重要なセクション」を持っていると言います。彼らはどうやって知っていますか?たとえば、10個のサンプルを取り、それらを2つ以上で確認するまで、何かが重要であることはわかりません。このような場合、呼び出されるメソッドが10命令を超える場合、仮想関数のオーバーヘッドはおそらく重要ではありません。
マイクダンラベイ

@ gnasher729:さて、私が最初にやることは、スタックサンプルを取得することです。その後、呼び出しツリーのリーフですべての時間を費やし、すべての呼び出しが本当に避けられない場合、コンパイラとハードウェアが何をするかは問題になりますか?サンプルがメソッドディスパッチの処理中に着地した場合にのみ、メソッドディスパッチの問題を知っています。
マイクダンラベイ
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.