web-dev-qa-db-ja.com

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

分岐ミス仮想関数のコストと同等の大まかな同等の命令があるようですが、同様のトレードオフがあります。

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

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

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

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

p->do()

しかし、一般的に、仮想関数と分岐のコストはどれほど高いかというと、一般化するのに十分なプラットフォームでテストするのは難しいので、大まかな経験則があるかどうか疑問に思っていました(4 ifsはブレークポイントです)

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

21

私はこれらのすでに優れた回答の中でここに飛び込んで、多形コードをswitchesまたはif/elseブランチに変更するアンチパターンに実際に逆方向に作業する醜いアプローチを採用したことを認めたいと思いました測定されたゲイン。しかし、私は最も重要なパスに対してのみ、この卸売をしませんでした。白黒である必要はありません。

免責事項として、私はレイトレーシングのような正確さを達成するのがそれほど難しくない領域で働いています(そして、とにかくあいまいで概算が多い)一方で、速度は多くの場合最も競争力のある品質の1つです。レンダリング時間の短縮は、最も一般的なユーザーリクエストの1つであることが多く、絶えず頭をかいて、最も重要な測定パスでそれを達成する方法を考え出します。

条件文の多態的リファクタリング

まず、条件付き分岐(switchまたはif/elseステートメントの束)よりも保守性の観点からポリモーフィズムが望ましい理由を理解することは価値があります。ここでの主な利点は、拡張性です。

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

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

最適化の障壁

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

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

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

間接的な関数呼び出しの場合のように、コンパイル時に不明なものを混在させ始めると、これらの利点の一部が失われます。条件分岐によってEdgeが提供される可能性が最も高くなります。

メモリの最適化

クリーチャーのシーケンスをタイトなループで繰り返し処理することからなるビデオゲームの例を見てみましょう。このような場合、次のようなポリモーフィックコンテナーが存在する可能性があります。

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

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

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

クラスの部分的な仮想化

何年も前に私がやったことは本当にひどいもので、それがもう有益であるかどうかさえわかりません(これは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は、サブクラスで非仮想バージョンを呼び出すために実装されました。関数呼び出しを非仮想化するために明示的な静的ダウンキャストを実行するのは非常に重要です。私は何年もこのタイプのものを試していなかったので、これがどれほど有益であるか私にはわかりません。データ指向の設計に触れると、データ構造とループをホット/コールドの方法で分割する上記の戦略の方がはるかに便利であり、最適化戦略の扉が開かれます(そして、醜さははるかに少なくなります)。

卸売りの仮想化

私はこれまでに最適化の考え方を適用したことがないので、そのメリットについては理解できません。条件付きの中央セットが1つしかないことがわかっている場合(例:中央の場所のイベントを1つだけ処理するイベント処理)があるとわかっていた場合は、先見の明の間接関数を回避しましたここまで。

理論的には、ここでの直接的な利点は、これらの最適化バリアを完全になくすことに加えて、仮想ポインターよりもタイプを識別する潜在的に小さな方法(例:一意のタイプが256以下であるという考えにコミットできる場合は1バイト)かもしれません。

サブタイプに基づいてデータ構造とループを分割する必要なく、中央のswitchステートメントを1つだけ使用する場合は、(上記の最適化された手動の仮想化解除の例とは対照的に)メンテナンスが容易なコードを書くことが役立つ場合もあります。 、または正確な順序で処理する必要があるこれらの場合に順序依存がある場合(たとえそれがすべての場所に分岐する場合でも)。これは、switchを実行する必要のある場所があまり多くない場合に当てはまります。

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

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

...しかし、私はほとんどの場合上記のシナリオを推奨し、必要に応じて部分的な仮想化によってより効率的なソリューションに向けて繰り返します。これにより、拡張性と保守性のニーズとパフォーマンスのバランスをとるための、より多くの余裕が生まれます。

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

ちょっと気をつけて、ここで仮想関数と関数ポインターの議論があったことに気づきました。仮想関数を呼び出すために少し余分な作業が必要になることは事実ですが、それはそれらが遅いという意味ではありません。直感に反して、それはそれらをより速くするかもしれません。

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

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

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

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

反対に、比較がりんご同士の比較になると、C++仮想関数の考え方からCスタイルの関数ポインターの考え方に変換するという反対の考え方が、次のタイプのシナリオで役立つことがわかりました。

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

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

void (*func_ptr)(void* instance_data);

... void*へ/からの危険なキャストを非表示にするタイプセーフインターフェースの背後にあるのが理想的です。

単一の仮想関数を持つクラスを使用したい場合は、代わりに関数ポインターを使用するとすぐに役立ちます。大きな理由は、関数ポインタを呼び出す際のコストの削減であるとは限りません。それらを永続的な構造に集約する場合、ヒープの分散した領域に個別のfunctionoidを割り当てるという誘惑にもはや直面しないからです。この種のアプローチにより、たとえばインスタンスデータが同種であり、動作のみが変化する場合に、ヒープ関連およびメモリの断片化のオーバーヘッドを回避しやすくなります。

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

結論

とにかく、それはこのトピックに関する私の小さなスピンです。これらのエリアでは注意して冒険することをお勧めします。本能ではなく測定値を信頼し、これらの最適化がしばしば保守性を低下させる方法を考えると、許容できる範囲でのみ実行します(そして、賢明なルートは保守性の側に誤りがあるでしょう)。

22
user204677

観察:

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

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

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

  • switch()ラダーまたは仮想関数呼び出しの代わりにelse if()を使用すると、コンパイラーはさらに優れたコードを生成する可能性があります。しかし、これは関数呼び出しではありません。つまり、仮想関数呼び出しのすべてのプロパティがあり、すべての関数呼び出しのオーバーヘッドがありません。

  • 残りの頻度よりも頻度が高い場合は、その場合にif() ... elseを開始すると最高のパフォーマンスが得られます。ほとんどの場合に正しく予測される単一の条件付きブランチを実行します。

  • コンパイラは予想されるケースの分布を認識しておらず、フラットな分布を想定しています。

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

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

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

  • それ以外の場合は、他のいずれかの方法でコードを読みやすくしない限り、switch()ステートメントを使用します。読みやすさが大幅に低下するため、ごくわずかなパフォーマンスの向上を購入しないでください。

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

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

一般的に、はい。メンテナンスのメリットは非常に大きい(分離のテスト、問題の分離、モジュール性と拡張性の向上)。

しかし、一般的に、仮想関数と分岐のコストはどれほど高いかというと、一般化するのに十分なプラットフォームでテストするのは難しいので、大まかな経験則があるかどうか疑問に思っていました(ブレークポイントが4 ifsのように単純である場合は、素敵です)。

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

つまり、「仮想関数と分岐のコストの高さ」に対する正しい答えは、測定と調査です。

経験則:上記の状況でない限り(分岐の識別は分岐の計算よりもコストがかかります)、メンテナンス作業のためにコードのこの部分を最適化します(仮想関数を使用)。

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

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

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

7
utnapistim

他の答えはすでに良い理論的議論を提供しています。最近実行した実験の結果を追加して、オペコード上で大きなswitchを使用して仮想マシン(VM)を実装するのが良いのか、それとも関数ポインタの配列へのインデックスとしてのオペコード。これはvirtual関数呼び出しとまったく同じではありませんが、かなり近いと思います。

Pythonスクリプトを作成して、VMのC++ 14コードをランダムに生成し、命令セットのサイズをランダムに選択しました(ただし、均一ではありませんが、低範囲をより密にサンプリングしています)。生成されたVMには常に128個のレジスタがあり、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-generateオプションと1000個のランダムな命令をシミュレートするために収集されたプロファイルデータを使用してコンパイルされました。次に、コードを-fprofile-useオプションを使用して再コンパイルし、収集したプロファイルデータに基づいて最適化できるようにしました。

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

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

enter image description here

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

ベンチマークのすべてのソースコード、完全な実験データ、および 高解像度プロット は、 私のWebサイト上 にあります。

5
5gon12eder

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

覚えておくべきもう1つのこと:クリティカルパスにループが含まれる場合は、ループをディスパッチ先ごとに並べ替えると便利です。明らかにこれはnlognですが、ループをトラバースするのはnだけですが、何度もトラバースする場合は、これに値する可能性があります。ディスパッチ先でソートすることにより、同じコードが繰り返し実行され、icacheでホットに保たれ、キャッシュミスを最小限に抑えることができます。

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

実際、データのキャッシュコヒーレンシの向上が元の質問よりも大きな影響を与えるとしても、私は驚くことではないので、私は間違いなくそれについて詳しく調べます。

3
Nir Friedman

これが XY-problem だと思う理由を説明してもいいですか? (彼らに尋ねるのはあなただけではありません。)

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

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

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

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

最後に、この拡大プロセスにより、3.7マイクロ秒を除くすべてを排除できました。これは、元の時間の0.14%、つまり730倍のスピードアップです。

enter image description here

最初に大きな問題を削除すると、適度なスピードアップが得られますが、後の問題を削除する道が開けます。これらの後者の問題は、最初は全体のわずかな部分であった可能性がありますが、初期の問題が取り除かれた後、これらの小さな問題は大きくなり、大きなスピードアップを生み出す可能性があります。 (この結果を得るには、何も見落とすことがないということを理解することが重要です。 this post は、簡単にできることを示していますあります。)

enter image description here

最終的なプログラムは最適でしたか?おそらく違います。スピードアップのどれも、キャッシュミスとは関係がありませんでした。キャッシュミスは今重要ですか?多分。

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

2
Mike Dunlavey