web-dev-qa-db-ja.com

C#での「直接」仮想呼び出しとインターフェイス呼び出しのパフォーマンス

このベンチマーク は、オブジェクト参照で仮想メソッドを直接呼び出す方が、このオブジェクトが実装するインターフェースへの参照で呼び出すよりも高速であることを示しているようです。

言い換えると:

_interface IFoo {
    void Bar();
}

class Foo : IFoo {
    public virtual void Bar() {}
}

void Benchmark() {
    Foo f = new Foo();
    IFoo f2 = f;
    f.Bar(); // This is faster.
    f2.Bar();    
}
_

C++の世界から来ると、これらの呼び出しは両方とも(単純な仮想テーブルルックアップとして)まったく同じように実装され、同じパフォーマンスになると期待していました。 C#は仮想呼び出しをどのように実装し、インターフェイスを介して呼び出したときに明らかに行われるこの「余分な」機能は何ですか?

---編集---

これまでに得た回答/コメントは、インターフェイスを介した仮想呼び出しの二重ポインター逆参照があるのに対し、オブジェクトを介した仮想呼び出しの逆参照は1つしかないことを示唆しています。

だから誰かがなぜそれが必要なのか説明してもらえますか? C#の仮想テーブルの構造は何ですか? 「フラット」(C++で一般的)かどうか。これにつながるC#言語設計で行われた設計のトレードオフは何でしたか?これが「悪い」デザインであると言っているのではなく、なぜそれが必要だったのか知りたいだけです。

一言で言えば、私はツールがフードの下で何をするかを理解して、より効果的に使用できるようにしたいと思います。そして、「あなたはそれを知らないはずです」または「別の言語を使用する」タイプの回答をこれ以上得られなかったなら、私はありがたいです。

---編集2 ---

明確にするために、ここでは動的ディスパッチを削除するJIT最適化のコンパイラーを扱っていません。最初の質問で述べたベンチマークを変更して、実行時に一方または他方をランダムにインスタンス化しました。インスタンス化はコンパイル後とアセンブリのロード/ JIT後に発生するため、どちらの場合も動的ディスパッチを回避する方法はありません。

_interface IFoo {
    void Bar();
}

class Foo : IFoo {
    public virtual void Bar() {
    }
}

class Foo2 : Foo {
    public override void Bar() {
    }
}

class Program {

    static Foo GetFoo() {
        if ((new Random()).Next(2) % 2 == 0)
            return new Foo();
        return new Foo2();
    }

    static void Main(string[] args) {

        var f = GetFoo();
        IFoo f2 = f;

        Console.WriteLine(f.GetType());

        // JIT warm-up
        f.Bar();
        f2.Bar();

        int N = 10000000;
        Stopwatch sw = new Stopwatch();

        sw.Start();
        for (int i = 0; i < N; i++) {
            f.Bar();
        }
        sw.Stop();
        Console.WriteLine("Direct call: {0:F2}", sw.Elapsed.TotalMilliseconds);

        sw.Reset();
        sw.Start();
        for (int i = 0; i < N; i++) {
            f2.Bar();
        }
        sw.Stop();
        Console.WriteLine("Through interface: {0:F2}", sw.Elapsed.TotalMilliseconds);

        // Results:
        // Direct call: 24.19
        // Through interface: 40.18

    }

}
_

---編集3 ---

誰かが興味を持っている場合は、Visual C++ 2010が他のクラスを多重継承するクラスのインスタンスをどのようにレイアウトするかを次に示します。

コード:

_class IA {
public:
    virtual void a() = 0;
};

class IB {
public:
    virtual void b() = 0;
};

class C : public IA, public IB {
public:
    virtual void a() override {
        std::cout << "a" << std::endl;
    }
    virtual void b() override {
        std::cout << "b" << std::endl;
    }
};
_

デバッガ:

_c   {...}   C
    IA  {...}   IA
        __vfptr 0x00157754 const C::`vftable'{for `IA'} *
            [0] 0x00151163 C::a(void)   *
    IB  {...}   IB
        __vfptr 0x00157748 const C::`vftable'{for `IB'} *
            [0] 0x0015121c C::b(void)   *
_

複数の仮想テーブルポインターが明確に表示され、sizeof(C) == 8(32ビットビルド)。

….

_C c;
std::cout << static_cast<IA*>(&c) << std::endl;
std::cout << static_cast<IB*>(&c) << std::endl;
_

..prints ...

_0027F778
0027F77C
_

...同じオブジェクト内の異なるインターフェイスへのポインタが実際にはそのオブジェクトの異なる部分を指していることを示します(つまり、それらは異なる物理アドレスを含んでいます)。

61

http://msdn.Microsoft.com/en-us/magazine/cc163791.aspx の記事があなたの質問に答えると思います。特に、セクション インターフェースVtableマップとインターフェースマップ 、および仮想ディスパッチの次のセクションを参照してください。

おそらく、JITコンパイラーが物事を理解し、単純なケースに合わせてコードを最適化することは可能です。しかし、一般的なケースではありません。

IFoo f2 = GetAFoo();

また、GetAFooIFooを返すものとして定義されているため、JITコンパイラーは呼び出しを最適化できません。

25
Jim Mischel

逆アセンブリは次のようになります(ハンスは正しい):

            f.Bar(); // This is faster.
00000062  mov         rax,qword ptr [rsp+20h] 
00000067  mov         rax,qword ptr [rax] 
0000006a  mov         rcx,qword ptr [rsp+20h] 
0000006f  call        qword ptr [rax+60h] 
            f2.Bar();
00000072  mov         r11,7FF000400A0h 
0000007c  mov         qword ptr [rsp+38h],r11 
00000081  mov         rax,qword ptr [rsp+28h] 
00000086  cmp         byte ptr [rax],0 
00000089  mov         rcx,qword ptr [rsp+28h] 
0000008e  mov         r11,qword ptr [rsp+38h] 
00000093  mov         rax,qword ptr [rsp+38h] 
00000098  call        qword ptr [rax] 
19
Steve Wellens

私はあなたのテストを試しました、そして私のマシンで、特定のコンテキストでは、結果は実際には逆です。

私はWindows 7 x64を実行していますが、コードをコピーしたVisual Studio 2010コンソールアプリケーションプロジェクトを作成しました。プロジェクトをデバッグモードでコンパイルし、プラットフォームターゲットをx86としてコンパイルすると、出力は次のようになります。

直接電話:48.38
インターフェース経由:42.43

実際には、アプリケーションを実行するたびに少し異なる結果が得られますが、インターフェースの呼び出しは常に高速になります。アプリケーションはx86としてコンパイルされているため、WOWを介してOSによって実行されると思います。

完全な参照として、コンパイル構成とターゲットの組み合わせの残りの結果を以下に示します。

Releaseモードおよびx86ターゲット
直接電話:23.02
インターフェースを介して:32.73

Debugモードおよびx64ターゲット
直接電話:49.49
インターフェース経由:56.97

Releaseモードおよびx64ターゲット
直接電話:19.60
インターフェース経由:26.45

上記のテストはすべて、コンパイラのターゲットプラットフォームとして.Net 4.0を使用して行われました。 3.5に切り替えて上記のテストを繰り返すと、インターフェイスを介した呼び出しは常に直接呼び出しよりも長くなりました。

したがって、上記のテストでは、発見した動作が常に発生するとは限らないため、かなり複雑になります。

最後に、あなたを混乱させるリスクがあるので、いくつかの考えを付け加えたいと思います。多くの人がパフォーマンスの違いはごくわずかであり、実際のプログラミングではそれらを気にする必要はないというコメントを追加しました。私はこの観点に同意します。これには主に2つの理由があります。

最初に最も宣伝されているのは、開発者がより高いレベルのアプリケーションに集中できるようにするために、.Netがより高いレベルに基づいて構築されたことです。データベースまたは外部サービスの呼び出しは、仮想メソッドの呼び出しより数千倍、場合によっては数百万倍遅いです。優れた高レベルのアーキテクチャを備え、大きなパフォーマンスのコンシューマーに焦点を合わせると、ダブルポインター逆参照を回避するよりも、常に最新のアプリケーションでより良い結果が得られます。

2つ目以上のあいまいな点は、上位レベルでフレームワークを構築することで.Netチームが実際にジャストインタイムコンパイラーがさまざまなプラットフォームでの最適化に使用できる一連の抽象化レベルを導入したことです。彼らが下層に与えるアクセスが多ければ多いほど、より多くの開発者が特定のプラットフォーム用に最適化できるようになりますが、ランタイムコンパイラーが他のためにできることは少なくなります。それが少なくとも理論であり、そのため、この特定の問題に関してC++ほど文書化されていません。

11

一般的なルールは次のとおりです。クラスは高速です。インターフェースが遅い。

これが、「クラスを使用して階層を構築し、階層内動作のインターフェイスを使用する」という推奨の理由の1つです。

仮想メソッドの場合、違いはわずか(10%など)です。しかし、非仮想メソッドとフィールドの場合、その違いは非常に大きくなります。このプログラムを検討してください。

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace InterfaceFieldConsoleApplication
{
    class Program
    {
        public abstract class A
        {
            public int Counter;
        }

        public interface IA
        {
            int Counter { get; set; }
        }

        public class B : A, IA
        {
            public new int Counter { get { return base.Counter; } set { base.Counter = value; } }
        }

        static void Main(string[] args)
        {
            var b = new B();
            A a = b;
            IA ia = b;
            const long LoopCount = (int) (100*10e6);
            var stopWatch = new Stopwatch();
            stopWatch.Start();
            for (int i = 0; i < LoopCount; i++)
                a.Counter = i;
            stopWatch.Stop();
            Console.WriteLine("a.Counter: {0}", stopWatch.ElapsedMilliseconds);
            stopWatch.Reset();
            stopWatch.Start();
            for (int i = 0; i < LoopCount; i++)
                ia.Counter = i;
            stopWatch.Stop();
            Console.WriteLine("ia.Counter: {0}", stopWatch.ElapsedMilliseconds);
            Console.ReadKey();
        }
    }
}

出力:

a.Counter: 1560
ia.Counter: 4587
2
Johan Nilsson

純粋な仮想関数の場合は、単純な仮想関数テーブルを使用できると思います。Fooを実装するBarの派生クラスは、仮想関数ポインターをBarに変更するだけです。

一方、インターフェイス関数IFoo:Barを呼び出すと、IFooの仮想関数テーブルのようなものを検索できませんでした。IFooのすべての実装は、必ずしも他の関数を実装する必要がないためですFooが行うインターフェースも。したがって、別のclass Fubar: IFooからのBarの仮想関数テーブルエントリの位置は、class Foo:IFooBarの仮想関数テーブルエントリの位置と一致してはなりません。

したがって、純粋な仮想関数呼び出しは、すべての派生クラスの仮想関数テーブル内の関数ポインターの同じインデックスに依存できますが、インターフェース呼び出しは最初にこのインデックスを検索する必要があります。

1
dronus