C ++クラスで仮想メソッドを使用する場合のパフォーマンスコストはどれくらいですか?


106

C ++クラス(またはその親クラスのいずれか)に少なくとも1つの仮想メソッドがあることは、クラスに仮想テーブルがあり、すべてのインスタンスに仮想ポインターがあることを意味します。

したがって、メモリコストは非常に明確です。最も重要なのは、インスタンスのメモリコストです(特に、インスタンスが小さい場合、たとえば、単に整数を含めることを意図している場合)。この場合、すべてのインスタンスに仮想ポインタがあると、インスタンスのサイズが2倍になる可能性があります。仮想テーブルが使用するメモリ領域。実際のメソッドコードが使用する領域と比較すると、通常は無視できると思います。

これは私に私の質問をもたらします:メソッドを仮想化するために測定可能なパフォーマンスコスト(つまり速度への影響)はありますか?すべてのメソッド呼び出し時に、実行時に仮想テーブルでルックアップが行われるため、このメソッドへの呼び出しが非常に頻繁であり、このメソッドが非常に短い場合、測定可能なパフォーマンスヒットがある可能性があります。それはプラットフォームに依存すると思いますが、誰かがいくつかのベンチマークを実行していますか?

私が尋ねている理由は、プログラマーが仮想メソッドの定義を忘れているために起こったバグに遭遇したからです。私がこの種の間違いを見たのはこれが初めてではありません。そして、私はそれが必要でないことが確実であるときに仮想キーワードを削除するのではなく、なぜ必要なときに仮想キーワードを追加するのかと考えました。パフォーマンスコストが低い場合は、チームで以下をお勧めします。デストラクタを含むすべてのメソッドをデフォルトで仮想化し、すべてのクラスで、必要な場合にのみ削除します。気が狂ってる?



7
バーチャルコールと非バーチャルコールを比較することは、完全ではありません。それらは異なる機能を提供します。仮想関数呼び出しをC相当と比較する場合は、仮想関数の同等の機能を実装するコードのコストを追加する必要があります。
マーティンヨーク

これは、switchステートメントまたはbig ifステートメントのいずれかです。賢い場合は、関数ポインターテーブルを使用して再実装できますが、それを誤る可能性ははるかに高くなります。
マーティンヨーク


7
問題は、仮想である必要がない関数呼び出しについてです。そのため、比較は意味があります。
Mark Ransom

回答:


103

3 GHzの順序付けられたPowerPCプロセッサでいくつかのタイミング実行しました。そのアーキテクチャーでは、仮想関数呼び出しのコストは、直接(非仮想)関数呼び出しよりも7ナノ秒長くなります。

そのため、関数が、インライン以外のものは一種の無駄なGet()/ Set()アクセサのようなものでない限り、コストについて心配する価値はありません。0.5nsにインライン化する関数の7nsオーバーヘッドは深刻です。関数の実行に500ミリ秒かかる7 nsのオーバーヘッドは意味がありません。

仮想関数の大きなコストは、実際にはvtable内の関数ポインターの検索(通常は1サイクルのみ)ではありませんが、間接ジャンプは通常、分岐予測できません。間接ジャンプ(関数ポインタを介した呼び出し)が終了し、新しい命令ポインタが計算されるまで、プロセッサは命令をフェッチできないため、これにより大きなパイプラインバブルが発生する可能性があります。したがって、仮想関数呼び出しのコストは、アセンブリを見た場合よりもはるかに大きくなりますが、それでもわずか7ナノ秒です。

編集: Andrew、Not Sure、および他の人も、仮想関数呼び出しが命令キャッシュミスを引き起こす可能性があるという非常に良い点を挙げています。キャッシュにないコードアドレスにジャンプすると、プログラム全体が完全に停止します。命令はメインメモリからフェッチされます。これは常に重要なストールです。キセノンでは、約650サイクル(私のテストによる)です。

ただし、これは仮想関数に固有の問題ではありません。直接関数を呼び出したとしても、キャッシュにない命令にジャンプするとミスが発生するためです。重要なのは、関数が最近実行されたかどうか(キャッシュ内にある可能性が高くなるかどうか)、およびアーキテクチャが静的(仮想ではない)分岐を予測し、それらの命令を事前にキャッシュにフェッチできるかどうかです。私のPPCはサポートしていませんが、おそらくIntelの最新のハードウェアはサポートしています。

私のタイミングは、iCacheミスの実行への影響を制御しています(故意に、CPUパイプラインを個別に調べようとしていたため)。


3
サイクル単位のコストは、フェッチからブランチの終了までのパイプラインステージの数とほぼ同じです。それは取るに足らない費用ではありません、そして、それは合計することができます、しかし、あなたがタイトな高性能ループを書こうとしているのでない限り、あなたがフライするためのおそらくより大きなパフォーマンス魚があります。
Crashworks、

何より7ナノ秒長い。通常の呼び出しが1ナノ秒である場合、通常の呼び出しが70ナノ秒である場合、それは重要ではありません。
マーティンヨーク

タイミングを見ると、インラインで0.66nsのコストがかかる関数の場合、直接関数呼び出しの差分オーバーヘッドは4.8nsであり、仮想関数は12.3ns(インラインと比較して)でした。関数自体が1ミリ秒かかる場合、7 nsは何も意味しないということは良いことです。
Crashworks

2
600サイクルに似ていますが、それは良い点です。パイプラインのバブルとプロローグ/エピローグによるオーバーヘッドだけに興味があったので、タイミングから外しました。icacheミスは、直接の関数呼び出しと同じくらい簡単に発生します(Xenonにはicache分岐予測子がありません)。
Crashworks

2
マイナーな詳細ですが、「これは特定の問題ではありませんが...」に関しては、キャッシュに追加する必要がある追加のページ(ページの境界を越えた場合は2ページ)があるため、仮想ディスパッチの方が少し悪いです-クラスの仮想ディスパッチテーブル。
トニーデルロイ、2014年

18

仮想関数を呼び出すと、測定可能なオーバーヘッドが確実に発生します。呼び出しは、vtableを使用して、そのタイプのオブジェクトの関数のアドレスを解決する必要があります。余分な指示はあなたの心配の最小です。vtableは多くの潜在的なコンパイラの最適化を妨げるだけでなく(型はコンパイラの多態性のため)、I-Cacheをスラッシュする可能性もあります。

もちろん、これらのペナルティが重要かどうかは、アプリケーション、それらのコードパスが実行される頻度、および継承パターンによって異なります。

しかし私の意見では、デフォルトですべてを仮想として持つことは、他の方法で解決できる問題の包括的な解決策です。

おそらく、クラスがどのように設計/文書化/記述されているかを確認できます。一般に、クラスのヘッダーは、派生クラスによってオーバーライドできる関数とそれらの呼び出し方法を明確にする必要があります。プログラマーにこのドキュメントを書かせることは、それらが仮想として正しくマークされることを保証するのに役立ちます。

また、すべての関数を仮想として宣言すると、何かを仮想としてマークするのを忘れるだけでなく、多くのバグが発生する可能性があるとも言えます。すべての関数が仮想である場合、すべてを基本クラス(パブリック、保護、プライベート)に置き換えることができます-すべてが公平なゲームになります。偶然または意図的に、サブクラスは関数の動作を変更し、基本の実装で使用すると問題が発生する可能性があります。


最大の失われた最適化はインライン化です。特に、仮想関数がしばしば小さいか空である場合はそうです。
Zan Lynx

@Andrew:興味深い視点。ただし、最後の段落にはやや反対します。基本クラスに、基本クラスの関数saveの特定の実装に依存する関数がある場合、コーディングが不十分でwriteあるかsavewriteプライベートである必要があるようです。
MiniQuark 2009年

2
書き込みがプライベートであっても、上書きが妨げられることはありません。これは、デフォルトで仮想化しないことに対するもう1つの議論です。いずれにせよ、私は反対を考えていました-一般的でよく書かれた実装は、特定の互換性のない動作を持つものに置き換えられます。
アンドリューグラント

キャッシングに賛成票を投じました-大規模なオブジェクト指向のコードベースでは、コード局所性のパフォーマンスプラクティスに従っていない場合、仮想呼び出しがキャッシュミスを引き起こしてストールを引き起こすのは非常に簡単です。
わからない

そして、icacheストールは非常に深刻になる可能性があります。私のテストでは600サイクルです。
Crashworks

9

場合によります。:)(他に何か期待していましたか?)

クラスが仮想関数を取得すると、それはもはやPODデータ型ではなくなります(これも以前のものではなかった可能性があり、その場合、違いはありません)。これにより、最適化の全範囲が不可能になります。

プレーンPODタイプのstd :: copy()は単純なmemcpyルーチンに頼ることができますが、非PODタイプはより注意深く処理する必要があります。

vtableを初期化する必要があるため、構築はかなり遅くなります。最悪の場合、PODデータ型と非PODデータ型のパフォーマンスの違いが大きくなる可能性があります。

最悪の場合、実行速度が5倍遅くなる可能性があります(この数値は、いくつかの標準ライブラリクラスを再実装するために最近行った大学のプロジェクトから取得されています。コンテナに格納されているデータ型がvtable)

もちろん、ほとんどの場合、測定可能なパフォーマンスの違いが見られることはほとんどありません。これは、一部の境界ケースではコストがかかる可能性があることを指摘するだけです。

ただし、ここではパフォーマンスを第一に考慮すべきではありません。すべてを仮想化することは、他の理由から完璧な解決策ではありません。

派生クラスですべてをオーバーライドできるようにすると、クラスの不変条件を維持することが非常に難しくなります。クラスは、そのメソッドのいずれかがいつでも再定義される可能性がある場合に、一貫した状態を保つことをどのように保証しますか?

すべてを仮想化することで、潜在的なバグをいくつか取り除くことができますが、新しいバグも発生します。


7

仮想ディスパッチの機能が必要な場合は、代金を支払う必要があります。C ++の利点は、非効率的なバージョンを自分で実装するのではなく、コンパイラーが提供する仮想ディスパッチの非常に効率的な実装を使用できることです。

ただし、オーバーヘッドが必要ない場合は、オーバーヘッドが発生するので、少しやり過ぎる可能性があります。また、ほとんどのクラスは継承元として設計されていません。適切な基本クラスを作成するには、その関数を仮想化する以上のことが必要です。


良い答えですが、IMO、後半ではそれほど強調されていません。オーバーヘッドが必要ない場合は、率直に言って、非常に骨の折れる作業です。使わない。」デフォルトですべてを仮想化することは、誰かがそれが非仮想であることができる/すべきである理由を正当化するまで、忌まわしい方針です。
underscore_d

5

仮想ディスパッチは、いくつかの代替案よりも桁違いに遅いです-インライン化の防止ほどの間接性のためではありません。以下では、仮想ディスパッチとオブジェクトに「タイプ(識別)番号」を埋め込む実装を対比し、switchステートメントを使用してタイプ固有のコードを選択することを示しています。これにより、関数呼び出しのオーバーヘッドが完全に回避されます。ローカルジャンプを行うだけです。タイプ固有の機能の強制的なローカリゼーション(スイッチ内)により、保守性、再コンパイルの依存関係などに潜在的なコストがかかります。


実装

#include <iostream>
#include <vector>

// virtual dispatch model...

struct Base
{
    virtual int f() const { return 1; }
};

struct Derived : Base
{
    virtual int f() const { return 2; }
};

// alternative: member variable encodes runtime type...

struct Type
{
    Type(int type) : type_(type) { }
    int type_;
};

struct A : Type
{
    A() : Type(1) { }
    int f() const { return 1; }
};

struct B : Type
{
    B() : Type(2) { }
    int f() const { return 2; }
};

struct Timer
{
    Timer() { clock_gettime(CLOCK_MONOTONIC, &from); }
    struct timespec from;
    double elapsed() const
    {
        struct timespec to;
        clock_gettime(CLOCK_MONOTONIC, &to);
        return to.tv_sec - from.tv_sec + 1E-9 * (to.tv_nsec - from.tv_nsec);
    }
};

int main(int argc)
{
  for (int j = 0; j < 3; ++j)
  {
    typedef std::vector<Base*> V;
    V v;

    for (int i = 0; i < 1000; ++i)
        v.push_back(i % 2 ? new Base : (Base*)new Derived);

    int total = 0;

    Timer tv;

    for (int i = 0; i < 100000; ++i)
        for (V::const_iterator i = v.begin(); i != v.end(); ++i)
            total += (*i)->f();

    double tve = tv.elapsed();

    std::cout << "virtual dispatch: " << total << ' ' << tve << '\n';

    // ----------------------------

    typedef std::vector<Type*> W;
    W w;

    for (int i = 0; i < 1000; ++i)
        w.push_back(i % 2 ? (Type*)new A : (Type*)new B);

    total = 0;

    Timer tw;

    for (int i = 0; i < 100000; ++i)
        for (W::const_iterator i = w.begin(); i != w.end(); ++i)
        {
            if ((*i)->type_ == 1)
                total += ((A*)(*i))->f();
            else
                total += ((B*)(*i))->f();
        }

    double twe = tw.elapsed();

    std::cout << "switched: " << total << ' ' << twe << '\n';

    // ----------------------------

    total = 0;

    Timer tw2;

    for (int i = 0; i < 100000; ++i)
        for (W::const_iterator i = w.begin(); i != w.end(); ++i)
            total += (*i)->type_;

    double tw2e = tw2.elapsed();

    std::cout << "overheads: " << total << ' ' << tw2e << '\n';
  }
}

パフォーマンス結果

私のLinuxシステム:

~/dev  g++ -O2 -o vdt vdt.cc -lrt
~/dev  ./vdt                     
virtual dispatch: 150000000 1.28025
switched: 150000000 0.344314
overhead: 150000000 0.229018
virtual dispatch: 150000000 1.285
switched: 150000000 0.345367
overhead: 150000000 0.231051
virtual dispatch: 150000000 1.28969
switched: 150000000 0.345876
overhead: 150000000 0.230726

これは、インラインのタイプ番号切り替えアプローチが約(1.28-0.23)/(0.344-0.23)= 9.2倍高速であることを示唆しています。もちろん、これはテストされた正確なシステム/コンパイラフラグとバージョンなどに固有ですが、一般的には目安です。


仮想ディスパッチに関するコメント

ただし、仮想関数呼び出しのオーバーヘッドが重要になることはめったになく、(getterやsetterなどの)ささいな関数に限られます。それでも、一度に多くのことを取得して設定する単一の関数を提供して、コストを最小限に抑えることができる場合があります。人々は、仮想ディスパッチについてあまりにも心配しています-厄介な代替を見つける前にプロファイリングを行ってください。それらの主な問題は、行外の関数呼び出しを実行することですが、実行されたコードを非局所化して、キャッシュの利用パターンを(より良くまたは(より頻繁に)より悪く)変更します。


私は尋ねた質問私が使用していくつかの「奇妙な」結果を持っているので、あなたのコードについてをg++/ clang-lrt。将来の読者のためにここで言及する価値があると思いました。
Holt

@Holt:神秘的な結果を与えられた良い質問!機会があれば、数日中に詳しく見ていきます。乾杯。
Tony Delroy、2016年

3

追加のコストはほとんどのシナリオで事実上何もありません。(しゃれを許しなさい)。ejacはすでに賢明な相対測定値を投稿しています。

あきらめる最大のことは、インライン化による最適化の可能性です。関数が定数パラメーターを指定して呼び出された場合、これらは特に有効です。これが実際に大きな違いをもたらすことはめったにありませんが、いくつかのケースでは、これは非常に大きなものになることがあります。


最適化に関して:
言語の構成要素の相対的なコストを知り、考慮することが重要です。Big O表記は話の半分だけです - アプリケーションはどのようにスケーリングしますか。残りの半分は、その前にある一定の要因です。

経験則として、それがボトルネックであることを明確かつ具体的に示さない限り、仮想関数を回避するために自分の道を離れることはありません。常にクリーンなデザインが第一ですが、他人を不当に傷つけてはならないのは1人の関係者だけです。


不自然な例:100万個の小さな要素の配列にある空の仮想デストラクタが、少なくとも4MBのデータを通過し、キャッシュを破壊する可能性があります。そのデストラクタをインライン化できる場合、データは変更されません。

ライブラリー・コードを作成するとき、そのような考慮事項は時期尚早ではありません。関数の周りにループがいくつ配置されるかは決してわかりません。


2

他の誰もが仮想メソッドなどのパフォーマンスについては正しいのですが、本当の問題は、チームがC ++での仮想キーワードの定義を知っているかどうかだと思います。

このコードを検討してください、出力は何ですか?

#include <stdio.h>

class A
{
public:
    void Foo()
    {
        printf("A::Foo()\n");
    }
};

class B : public A
{
public:
    void Foo()
    {
        printf("B::Foo()\n");
    }
};

int main(int argc, char** argv)
{    
    A* a = new A();
    a->Foo();

    B* b = new B();
    b->Foo();

    A* a2 = new B();
    a2->Foo();

    return 0;
}

ここで驚くべきことはありません:

A::Foo()
B::Foo()
A::Foo()

仮想のものは何もないので。virtualキーワードがAクラスとBクラスの両方でFooの前に追加されている場合、出力では次のようになります。

A::Foo()
B::Foo()
B::Foo()

だいたい誰もが期待すること。

さて、あなたは誰かが仮想キーワードを追加するのを忘れたのでバグがあると述べました。したがって、このコードを検討してください(virtualキーワードはAクラスに追加されますが、Bクラスには追加されません)。次に、出力は何ですか?

#include <stdio.h>

class A
{
public:
    virtual void Foo()
    {
        printf("A::Foo()\n");
    }
};

class B : public A
{
public:
    void Foo()
    {
        printf("B::Foo()\n");
    }
};

int main(int argc, char** argv)
{    
    A* a = new A();
    a->Foo();

    B* b = new B();
    b->Foo();

    A* a2 = new B();
    a2->Foo();

    return 0;
}

回答:仮想キーワードがBに追加された場合と同じですか?その理由は、B :: FooのシグネチャがA :: Foo()と正確に一致することと、AのFooは仮想であり、Bも仮想であるためです。

次に、B's Fooが仮想で、A'sが仮想ではない場合を考えます。次に、出力は何ですか?この場合、出力は

A::Foo()
B::Foo()
A::Foo()

virtualキーワードは、上向きではなく、下向きに機能します。基本クラスのメソッドを仮想化することはありません。階層内で最初に仮想メソッドが検出されるのは、ポリモーフィズムが始まるときです。後のクラスが前のクラスに仮想メソッドを持たせる方法はありません。

仮想メソッドは、このクラスが将来のクラスにその動作の一部をオーバーライド/変更できるようにすることを意味することを忘れないでください。

したがって、仮想キーワードを削除するルールがある場合、意図した効果が得られない可能性があります。

C ++の仮想キーワードは強力な概念です。チームの各メンバーがこのコンセプトを本当に理解していることを確認して、設計どおりに使用できるようにする必要があります。


こんにちはトミー、チュートリアルをありがとう。発生したバグは、基本クラスのメソッドに「仮想」キーワードが欠落していることが原因でした。ところで、私はすべての機能を(反対ではなく)仮想化し、それが明らかに必要でない場合は、「仮想」キーワードを削除すると言っています。
MiniQuark 2009年

@MiniQuark:Tommy Huiは、すべての関数を仮想化すると、プログラマーが派生クラスのキーワードを削除し、効果がないことに気付かない可能性があると述べています。仮想キーワードの削除が常に基本クラスで行われるようにするための何らかの方法が必要になります。
M.ダドリー、

1

プラットフォームによっては、仮想呼び出しのオーバーヘッドが非常に望ましくない場合があります。すべての関数virtualを宣言することで、基本的にはすべて関数ポインターを介してそれらを呼び出します。少なくともこれは余分な逆参照ですが、一部のPPCプラットフォームでは、これを実現するためにマイクロコード化された命令またはその他の遅い命令を使用します。

この理由であなたの提案に反対することをお勧めしますが、それがバグを防ぐのに役立つなら、トレードオフの価値があるかもしれません。私には仕方がないのですが、見つける価値のある中立の立場があるに違いないと思います。


-1

仮想メソッドを呼び出すには、追加のasm命令がいくつか必要です。

しかし、fun(int a、int b)にfun()と比較していくつかの追加の「プッシュ」命令があることを心配しているとは思いません。ですから、特別な状況にいて、それが本当に問題につながることを確認するまで、仮想についても心配しないでください。

PS仮想メソッドがある場合は、仮想デストラクタがあることを確認してください。このようにして、起こり得る問題を回避します


「xtofl」および「Tom」のコメントへの応答。私は3つの関数で小さなテストを行いました:

  1. バーチャル
  2. 正常
  3. 3つのintパラメータを持つ通常

私のテストは単純な反復でした:

for(int it = 0; it < 100000000; it ++) {
    test.Method();
}

そしてここに結果:

  1. 3,913秒
  2. 3,873秒
  3. 3,970秒

デバッグモードでVC ++によってコンパイルされました。メソッドごとに5つのテストのみを実行し、平均値を計算しました(結果はかなり不正確になる可能性があります)...いずれにしても、1億回の呼び出しを想定すると、値はほぼ等しくなります。そして、3つの追加のプッシュ/ポップを使用する方法はより低速でした。

主なポイントは、プッシュ/ポップのアナロジーが気に入らない場合は、コード内の追加のif / elseについて考えてみることです。if / elseを追加するときにCPUパイプラインについて考えますか;-)また、コードが実行されるCPUがわからない...通常のコンパイラは、あるCPUには最適で、他のCPUには最適でないコードを生成できます(Intel C ++コンパイラ


2
追加のasmはページ違反を引き起こすだけかもしれません(非仮想関数の場合は存在しません)-問題を非常に単純化しすぎていると思います。
xtofl 2009年

2
xtoflさんのコメントに+1。仮想関数は間接参照を導入し、パイプラインの「バブル」を導入し、キャッシュ動作に影響を与えます。
トム

1
デバッグモードでタイミングをとっても意味がありません。MSVCはデバッグモードで非常に遅いコードを作成し、ループオーバーヘッドはおそらく違いのほとんどを隠します。高いパフォーマンスを目指している場合、はい、高速パスでif / else分岐を最小化すること検討する必要があります。低レベルのx86パフォーマンス最適化の詳細については、agner.org / optimizeを参照してください。(また、x86タグwikiの
Peter Cordes

1
@Tom:ここでの重要な点は、非仮想関数はインライン化できるが仮想化できないことです(コンパイラーが仮想化を解除できない限り、たとえばfinal、オーバーライドで使用し、基本型ではなく派生型へのポインターがある場合) )。このテストは毎回同じ仮想関数を呼び出すため、完全に予測されました。限られたcallスループットを除いて、パイプラインのバブルは他にありません。そして、その間接callは、さらに2、3のuopsかもしれません。分岐予測は、特に常に同じ宛先に向かう場合は特に、間接分岐でもうまく機能します。
Peter Cordes

これは、マイクロベンチマークの一般的なトラップに該当します。ブランチ予測が熱く、他に何も起こっていない場合、高速に見えます。予測ミスのオーバーヘッドはcall、直接よりも間接の方が高くなりcallます。(そして、はい、通常のcall命令も予測が必要です。フェッチステージは、このブロックがデコードされる前にフェッチする次のアドレスを知っている必要があるため、命令アドレスではなく、現在のブロックアドレスに基づいて次のフェッチブロックを予測する必要があります。同様にこのブロックのどこに分岐命令があるかを予測します...)
Peter Cordes
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.