これらのすでに優れた答えの中でここに飛び込み、多態性コードを測定されたゲインに変更し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_creatures
2番目のループで処理できます。
必要に応じてこの戦略を拡張し、潜在的にこの方法でいくつかの利益を絞ることができますが、プロセスの保守性をどれだけ低下させているかに注目する価値があります。ここで関数テンプレートを使用すると、手動でロジックを複製することなく、人間と生物の両方のコードを生成できます。
クラスの部分的な仮想化
私が数年前にやったことは本当にひどいもので、それがもう有益かどうかはわかりません(これは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つの中央の場所)。
...しかし、ほとんどの場合は上記のシナリオを推奨し、必要に応じて部分的な仮想化を解除してより効率的なソリューションを目指して繰り返します。拡張性と保守性のニーズとパフォーマンスのバランスをとるために、より多くの呼吸空間を提供します。
仮想関数と関数ポインター
これを整理するために、私はここで、仮想関数と関数ポインタに関する議論があることに気付きました。仮想関数を呼び出すには少し余分な作業が必要なのは事実ですが、それが遅くなるという意味ではありません。直感に反して、さらに高速化することもあります。
ここでは、はるかに重要な影響を与える傾向があるメモリ階層のダイナミクスに注意を払うことなく、命令の観点からコストを測定することに慣れているため、ここでは直感に反します。
class
20個の仮想関数と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キャッシュラインとタイトループに置かれます。
結論
とにかく、それはこのトピックに関する私のちょっとしたスピンです。これらのエリアでの注意深い冒険をお勧めします。直感ではなく、測定を信頼し、これらの最適化が保守性を低下させることが多い方法を考えると、余裕がある範囲でのみ行ってください(そして、賢明なルートは保守性の面で間違っています)。