118

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

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

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

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

4

9 に答える 9

118

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

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

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

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

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

私のタイミングは、実行時の icache ミスの影響を制御するため (CPU パイプラインを分離して調べようとしていたため、意図的に)、そのコストを割り引いています。

于 2009-03-20T19:43:26.607 に答える
20

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

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

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

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

また、すべての関数を仮想として宣言すると、何かを仮想としてマークするのを忘れるだけでなく、より多くのバグにつながる可能性があるとも言えます。すべての関数が仮想である場合、パブリック、プロテクト、プライベートなど、すべてを基本クラスに置き換えることができます。偶然または意図的に、サブクラスが関数の動作を変更し、それが基本実装で使用されたときに問題を引き起こす可能性があります。

于 2009-03-20T19:44:25.293 に答える
11

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

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

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

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

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

もちろん、ほとんどの場合、測定可能なパフォーマンスの違いが見られる可能性はほとんどありませ

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

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

すべてを仮想化すると、いくつかの潜在的なバグがなくなる可能性がありますが、新しいバグも発生します。

于 2009-03-21T01:44:36.817 に答える
8

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

ただし、必要がない場合にオーバーヘッドを負担することは、少し行き過ぎている可能性があります。また、ほとんどのクラスは継承されるようには設計されていません。適切な基本クラスを作成するには、関数を仮想化するだけでは不十分です。

于 2009-03-20T19:34:22.587 に答える
6

仮想ディスパッチは、いくつかの代替手段よりも桁違いに遅くなります-インライン化の防止ほど間接化によるものではありません。以下では、オブジェクトに「型 (識別) 番号」を埋め込み、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 など) のみであると言わなければなりません。その場合でも、コストを最小限に抑えながら、多くのものを一度に取得および設定する単一の関数を提供できる場合があります。人々は仮想ディスパッチについてあまりにも心配しすぎています。厄介な代替手段を見つける前にプロファイリングを行ってください。それらの主な問題は、行外の関数呼び出しを実行することですが、実行されたコードを非ローカライズして、キャッシュの使用パターンを変更します (良くも悪くも)。

于 2011-01-26T07:27:31.637 に答える
4

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

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


最適化について:
言語の構造の相対的なコストを知り、考慮することが重要です。Big O 記法は話の半分に過ぎません。つまり、アプリケーションはどのようにスケーリングしますか。残りの半分は、その前の一定の要素です。

経験則として、仮想関数がボトルネックであるという明確で具体的な兆候がない限り、仮想関数を避けるためにわざわざ行くことはありません。常にクリーンなデザインが優先されますが、過度に他の人を傷つけてはならないのは 1 つの利害関係者だけです。


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

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

于 2009-03-20T20:19:57.597 に答える
1

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

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

于 2009-03-20T19:38:21.847 に答える
0

仮想メソッドを呼び出すには、いくつかの追加の 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++ コンパイラ)

于 2009-03-20T19:55:53.707 に答える