web-dev-qa-db-ja.com

C ++では、なぜ、そしてどのように仮想関数が遅くなりますか?

誰でも、仮想テーブルがどのように機能するか、仮想関数が呼び出されたときにどのポインタが関連付けられるかを詳しく説明できますか?.

それらが実際に遅い場合、仮想関数の実行にかかる時間が通常のクラスメソッドよりも長いことを示すことができますか?コードを見なくても、どうやって/何が起こっているのかを見失うのは簡単です。

39
MdT

仮想メソッドは一般に、関数ポインターが格納されている、いわゆる仮想メソッドテーブル(略してvtable)を介して実装されます。これにより、実際の呼び出しに間接性が追加されます(vtableから呼び出す関数のアドレスをフェッチしてから呼び出すだけです-すぐに呼び出すだけではありません)。もちろん、これには少し時間がかかり、さらにコードが必要です。

ただし、それが必ずしも遅延の主な原因であるとは限りません。本当の問題は、コンパイラが(一般に/通常)がどの関数が呼び出されるかを知らないことです。そのため、インライン化したり、他の最適化を実行したりすることはできません。これだけでは、12の無意味な命令(レジスタの準備、呼び出し、その後の状態の復元)が追加される可能性があり、一見無関係な他の最適化が妨げられる可能性があります。さらに、多くの異なる実装を呼び出すことによって狂ったように分岐すると、他の方法で狂ったように分岐した場合と同じヒットが発生します。キャッシュと分岐予測子は役に立ちません。分岐は完全に予測可能なものより時間がかかりますブランチ。

大きなbut:これらのパフォーマンスヒットは、通常、非常に小さいため問題になりません。高性能のコードを作成し、警告頻度で呼び出される仮想関数を追加することを検討する場合は、検討する価値があります。ただし、また、仮想関数呼び出しを他の分岐手段(if .. elseswitch、関数ポインタなど)は基本的な問題を解決しません-かなり遅くなる可能性があります。問題(存在する場合)は仮想関数ではなく、(不要な)間接参照です。

編集:呼び出し手順の違いは他の回答で説明されています。基本的に、静的(「通常」)呼び出しのコードは次のとおりです。

  • スタック上のいくつかのレジスターをコピーして、呼び出された関数がそれらのレジスターを使用できるようにします。
  • 呼び出された関数が呼び出された場所に関係なくそれらを見つけることができるように、引数を事前定義された場所にコピーします。
  • 返信先アドレスをプッシュします。
  • 関数のコードに分岐/ジャンプします。これはコンパイル時のアドレスであり、コンパイラ/リンカーによってバイナリにハードコードされます。
  • 定義済みの場所から戻り値を取得し、使用したいレジスタを復元します。

仮想呼び出しは、コンパイル時に関数アドレスが不明であることを除いて、まったく同じことを行います。代わりに、いくつかの指示...

  • オブジェクトから、仮想関数ごとに1つある関数ポインター(関数アドレス)の配列を指すvtableポインターを取得します。
  • Vtableからレジスターに正しい関数アドレスを取得します(正しい関数アドレスが格納されるインデックスはコンパイル時に決定されます)。
  • ハードコードされたアドレスにジャンプするのではなく、そのレジスター内のアドレスにジャンプします。

分岐について:分岐は、次の命令を実行させるだけでなく、別の命令にジャンプするものです。これには、ifswitch、さまざまなループの一部、関数呼び出しなどが含まれ、コンパイラーは、実際には内部で分岐を必要とする方法で分岐していないように見えるものを実装する場合があります。なぜこれが遅いのか、なぜこのCPUがこのスローダウンに対抗するために何をするのか、そしてこれが万能薬ではないのかについては ソート済み配列を未ソート配列よりも速く処理するのはなぜですか? を参照してください。

56
user7043

仮想関数呼び出しと非仮想呼び出しからの実際の逆アセンブルされたコードをそれぞれ次に示します。

mov    -0x8(%rbp),%rax
mov    (%rax),%rax
mov    (%rax),%rax
callq  *%rax

callq  0x4007aa

非仮想呼び出しのアドレスをコンパイルできるのに対して、仮想呼び出しは正しいアドレスを検索するために3つの追加の指示を必要とすることがわかります。

ただし、ほとんどの場合、余分なルックアップ時間は無視できると見なすことができます。ループのようにルックアップ時間が重要な状況では、通常、ループの前に最初の3つの命令を実行することで値をキャッシュできます。

ルックアップ時間が重要になるもう1つの状況は、オブジェクトのコレクションがあり、各オブジェクトで仮想関数を呼び出してループしている場合です。ただし、その場合は、とにかく呼び出す関数を選択するsomeの手段が必要になります。仮想テーブルのルックアップは、他の手段と同じように優れた手段です。実際、vtableルックアップコードは非常に広く使用されているため、大幅に最適化されているため、手動で回避しようとすると、worseのパフォーマンスが得られる可能性が高くなります。

24
Karl Bielefeldt

遅い何より

仮想関数は、直接の関数呼び出しでは解決できない問題を解決します。一般に、同じものを計算する2つのプログラムのみを比較できます。 「このレイトレーサーはコンパイラよりも速い」というのは意味がなく、この原則は、個々の関数やプログラミング言語の構成要素などの小さなものにも一般化されます。

オブジェクトのタイプなど、データに基づいてコードの一部に動的に切り替える仮想関数を使用しない場合は、switchステートメントなどを使用して同じことを行う必要があります。事。その別のものには独自のオーバーヘッドがあり、プログラムの保守性とグローバルパフォーマンスに影響を与えるプログラムの編成に影響があります。

C++では、仮想関数の呼び出しは常に動的であるとは限りません。正確な型がわかっているオブジェクトに対して呼び出しが行われた場合(オブジェクトがポインターまたは参照ではないため、またはその型が静的に推論される可能性があるため)、呼び出しは通常のメンバー関数呼び出しにすぎません。これは、ディスパッチのオーバーヘッドがないことを意味するだけでなく、これらの呼び出しを通常の呼び出しと同じ方法でインライン化できることも意味します。

言い換えると、仮想関数が仮想ディスパッチを必要としないときにC++コンパイラーが機能するので、通常、非仮想関数と比較してそのパフォーマンスを心配する必要はありません。

New:また、共有ライブラリを忘れてはなりません。共有ライブラリにあるクラスを使用している場合、通常のメンバー関数の呼び出しは、callq 0x4007aaのような単純な1つの命令シーケンスではありません。 「プログラムリンクテーブル」またはそのような構造を介して間接化するなど、いくつかのフープを通過する必要があります。したがって、共有ライブラリの間接化により、(完全に間接的ではない)仮想呼び出しと直接呼び出しの間のコスト差が(完全ではないにしても)ある程度平準化される可能性があります。したがって、仮想関数のトレードオフについての推論では、プログラムの構築方法を考慮に入れる必要があります。つまり、ターゲットオブジェクトのクラスが、呼び出しを行っているプログラムにモノリシックにリンクされているかどうかです。

19
Kaz

仮想呼び出しは

res_t (*foo)(arg_t);
foo = (obj->vtable[foo_offset]);
foo(obj,args)

非仮想関数を使用すると、コンパイラは最初の行を定数で折りたたむことができます。これは、間接参照であり、動的呼び出しが静的呼び出しに変換されます。

これにより、関数をインライン化することもできます(すべての最適化の結果)。

13
ratchet freak