web-dev-qa-db-ja.com

C ++クラスに仮想メソッドを持つことのパフォーマンスコストはいくらですか?

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

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

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

私が質問している理由は、プログラマが仮想メソッドを定義するのを忘れたために起こったバグに遭遇したからです。この種の間違いを目にするのは初めてではありません。そして、私は考えました:なぜaddremovingの代わりに必要なときに仮想キーワードnot必要であることが確実である場合に仮想キーワード?パフォーマンスコストが低い場合、チームで次のことをお勧めします。デストラクタを含むすべてのクラスで、デフォルトで単純にeveryメソッドを作成し、必要な場合にのみ削除します。それはあなたに夢中に聞こえますか?

99
MiniQuark

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

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

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

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

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

私のタイミングは、実行時のicacheミスの影響を制御します(意図的に、CPUパイプラインを単独で調査しようとしていたため)。

92
Crashworks

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

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

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

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

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

18
Andrew Grant

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

クラスが仮想関数を取得すると、それはもはやPODデータ型になることはできません(どちらも前のものではなかった可能性があり、その場合、これは違いを生じません)。

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

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

最悪の場合、5倍遅い実行を見るかもしれません(その数は、いくつかの標準ライブラリクラスを再実装するために私が最近行った大学プロジェクトから取られています。 vtable)

もちろん、ほとんどの場合、測定可能なパフォーマンスの違いはほとんど見られません。これは、some borderの場合、コストがかかる可能性があることを指摘するためです。

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

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

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

9
jalf

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

ただし、必要がない場合はオーバーヘッドで自分自身をぶらぶらさせることは、多すぎるかもしれません。そして、ほとんどのクラスは、継承されるように設計されていません-適切な基本クラスを作成するには、その機能を仮想化する以上のものが必要です。

7
anon

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


[〜#〜] implementation [〜#〜]

#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倍の速さであることを示唆しています。もちろん、それはテストされた正確なシステム/コンパイラのフラグとバージョンなどに固有ですが、一般的に示しています。


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

ただし、仮想関数呼び出しのオーバーヘッドはめったに意味のないものであり、よく呼ばれる些細な関数(ゲッターやセッターなど)にのみ当てはまると言わなければなりません。それでも、単一の関数を提供して、多くのことを一度に取得および設定できるため、コストを最小限に抑えることができます。人々は仮想ディスパッチ方法を心配しすぎています-厄介な選択肢を見つける前にプロファイリングを行います。それらの主な問題は、アウトオブラインの関数呼び出しを実行することです。ただし、実行されるコードを非局在化し、キャッシュの使用パターンを変更します(より良いまたはより頻繁に)。

5
Tony Delroy

ほとんどのシナリオでは、追加費用は事実上ゼロです。 (しゃれを許して)。射精はすでに賢明な相対尺度を掲示しています。

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


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

経験則として、ボトルネックであるという明確で具体的な兆候がない限り、仮想機能を避けるために邪魔にならないでしょう。常にクリーンなデザインが最初に来ます-しかし、それは過度に他の人を傷つけるべきではない唯一の利害関係者です。


不自然な例:100万個の小さな要素の配列にある空の仮想デストラクタは、少なくとも4MBのデータを処理し、キャッシュをスラッシングします。そのデストラクタをインライン展開できる場合、データは変更されません。

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

3
peterchen

他の誰もが仮想メソッドのパフォーマンスなどについて正しいのですが、本当の問題は、チームが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()

何も仮想的ではありません。仮想クラスがAクラスとBクラスの両方でFooの前に追加された場合、出力で次のようになります。

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

ほぼ誰もが期待するもの。

さて、誰かが仮想キーワードを追加するのを忘れたためにバグがあると述べました。したがって、このコードを検討してください(仮想キーワードは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のFooが仮想で、AのFooが仮想ではない場合を考えます。そのときの出力は何ですか?この場合、出力は

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

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

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

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

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

2
Tommy Hui

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

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

1
Dan Olson