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


38

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

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


5
vtableから正しいメソッド呼び出しを検索することは、やるべきことがあるため、メソッドを直接呼び出すよりも明らかに時間がかかります。どのくらい長く、またはその追加時間があなた自身のプログラムのコンテキスト内で重要であるかどうかは、別の質問です。 en.wikipedia.org/wiki/Virtual_method_table
ロバートハーベイ

10
何より遅い?一部のプログラマーが仮想関数が遅いと聞いたからといって、たくさんのswitchステートメントを使用した動的な動作の壊れた、遅い実装のコードを見てきました。
クリストファー・クロイツィヒ

7
多くの場合、仮想呼び出し自体が遅いというわけではありませんが、コンパイラーはそれらをインライン化することができません。
ケビン・スー

4
@Kevin Hsu:はい、これは絶対です。誰かが「仮想関数呼び出しのオーバーヘッド」を排除することでスピードアップしたと言ったときはいつでも、実際にスピードアップがどこから来たのかを見れば、コンパイラーが最適化できなかったために可能になりました以前は不確定な呼び出し。
ティムデイ

7
アセンブリコードを読むことができる人でさえ、実際のCPU実行におけるオーバーヘッドを正確に予測することはできません。デスクトップベースのCPUメーカーは、分岐予測だけでなく、仮想関数のレイテンシをマスクするという主な理由で価値予測と投機的実行にも数十年の研究に投資してきました。どうして?デスクトップOSとソフトウェアはそれらを頻繁に使用するためです。(モバイルCPUについては同じことは言いません。)
rwong

回答:


55

仮想メソッドは、通常、いわゆる仮想メソッドテーブル(略してvtable)を介して実装されます。このテーブルには、関数ポインターが格納されます。これにより、実際の呼び出しに間接性が追加されます(vtableから呼び出す関数のアドレスを取得してから呼び出します-すぐに呼び出すのではなく)。もちろん、これにはある程度の時間とコードが必要です。

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

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

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

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

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

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

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


6
@JörgWMittagそれらはすべてインタプリタのものであり、C ++コンパイラによって生成されたバイナリコードよりもまだ遅いです。
サム

13
@JörgWMittagこれらの最適化は、主に間接的/遅延バインディングが不要な場合に(ほぼ)無料にするために存在します。これらの言語では、すべての呼び出しが技術的に遅延バインドされるためです。短時間に多くの異なる仮想メソッドを1つの場所から実際に呼び出す場合、これらの最適化は役に立たず、積極的に傷つけることはありません(無駄なコードを大量に作成する)。C ++の人は、状況が非常に異なるため、これらの最適化にあまり関心がありません

10
@JörgWMittag... C ++の人は、非常に異なる状況にあるため、これらの最適化にあまり興味がありません。 (テンプレートを介して)バインドされているため、AOT最適化を修正できます。最後に、これらの最適化を(コンパイル時に推測するだけでなく)適応的に行うには、実行時のコード生成が必要であり、これにより大量の頭痛が発生します。JITコンパイラーは、他の理由でこれらの問題をすでに解決しているため、気にしませんが、AOTコンパイラーはそれを避けたいと考えています。

3
すばらしい答え、+ 1。ただし、1つの注意点は、分岐の結果がコンパイル時に既知である場合があることです。たとえば、さまざまな用途をサポートする必要があるフレームワーククラスを記述しますが、アプリケーションコードがそれらのクラスとやり取りすると、特定の用途はすでにわかっています。この場合、仮想関数の代わりにC ++テンプレートを使用できます。:良い例では、任意のvtableをせずに仮想関数の動作をエミュレートCRTP、だろうen.wikipedia.org/wiki/Curiously_recurring_template_pattern
DXM

3
@ジェームズあなたにはポイントがあります。私が言おうとしたのは、どの間接化にも同じ問題があり、それはに固有のものではないということvirtualです。

23

以下は、それぞれ仮想関数呼び出しと非仮想呼び出しから実際に逆アセンブルされたコードです。

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

callq  0x4007aa

仮想呼び出しには、正しいアドレスを検索するために3つの追加の命令が必要ですが、非仮想呼び出しのアドレスはコンパイルできます。

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

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


1
理解すべきことは、ほとんどすべての場合、vtableルックアップと間接呼び出しは、呼び出されるメソッドの合計実行時間にほとんど影響を与えないということです。
ジョンR.ストローム

11
@ JohnR.Strohm一人の男の無視は別の男のボトルネック
ジェームズ

1
-0x8(%rbp)。ああ... AT&T構文。
アビックス

3つの追加命令」いいえ、2つだけ:vptrのロードと関数ポインターのロード
curiousguy

@curiousguyそれは実際には3つの追加の指示です。仮想メソッドが常にポインター呼び出されることを忘れてしまったため、最初にポインターをレジスターにロードする必要があります。要約すると、最初のステップは、ポインター変数が保持するアドレスをレジスタ%raxにロードし、レジスタのアドレスに従って、このアドレスのvtprをレジスタ%raxにロードし、次にこのアドレスに従って登録し、呼び出されるメソッドのアドレスを%raxにロードしてから、callq *%rax!を呼び出します。
Gab是好人

18

何より遅い?

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

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

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

つまり、C ++コンパイラは、仮想関数が仮想ディスパッチを必要としない場合に動作するため、通常、非仮想関数と比較してパフォーマンスを心配する必要はありません。

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


4
「何より遅い?」-必要のないメソッドを仮想化すると、かなり良い比較資料が得られます。
tdammers

2
仮想関数の呼び出しは常に動的ではないことを指摘していただきありがとうございます。ここの他のすべての応答は、状況に関係なく、関数virtualを宣言すると自動パフォーマンスヒットを意味するように見えます。
Syndog

12

仮想呼び出しは

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

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

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

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.