C#中"直接"虚拟呼叫与接口呼叫的性能

Bra*_*vic 60 .net c# performance language-design

此基准测试似乎表明直接在对象引用上调用虚方法比在对象实现的接口的引用上调用它更快.

换一种说法:

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();    
}
Run Code Online (Sandbox Code Playgroud)

来自C++世界,我原本预计这两个调用都将以相同的方式实现(作为简单的虚拟表查找)并具有相同的性能.C#如何实现虚拟调用以及通过接口调用时显然可以完成的"额外"工作是什么?

---编辑---

好的,答案/评论我到目前为止暗示了通过接口进行虚拟呼叫的双指针解除引用,而不是通过对象进行虚拟呼叫的一个取消引用.

那么请有人解释为什么这是必要的?C#中虚拟表的结构是什么?是否"平坦"(对于C++来说是典型的)或不是?在C#语言设计中做出的设计权衡导致了什么?我不是说这是一个"糟糕"的设计,我只是好奇为什么它是必要的.

简而言之,我想了解我的工具在引擎盖下做了什么,这样我就可以更有效地使用它.如果我不再获得"你不应该知道"或"使用其他语言"类型的答案,我将不胜感激.

---编辑2 ---

为了说清楚,我们没有在这里处理一些JIT优化的编译器,它删除了动态调度:我修改了原始问题中提到的基准,以在运行时随机实例化一个类或另一个类.由于实例化在编译之后和程序集加载/ JITing之后发生,因此在这两种情况下都无法避免动态调度:

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

    }

}
Run Code Online (Sandbox Code Playgroud)

---编辑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;
    }
};
Run Code Online (Sandbox Code Playgroud)

调试器:

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)   *
Run Code Online (Sandbox Code Playgroud)

多个虚拟表指针清晰可见,并且sizeof(C) == 8(以32位构建).

该...

C c;
std::cout << static_cast<IA*>(&c) << std::endl;
std::cout << static_cast<IB*>(&c) << std::endl;
Run Code Online (Sandbox Code Playgroud)

..prints ...

0027F778
0027F77C
Run Code Online (Sandbox Code Playgroud)

...表示指向同一对象内不同接口的指针实际上指向该对象的不同部分(即它们包含不同的物理地址).

Jim*_*hel 25

我认为http://msdn.microsoft.com/en-us/magazine/cc163791.aspx上的文章将回答您的问题.特别是,请参阅" 接口可映射映射和接口映射 "一节以及有关虚拟调度的以下部分.

JIT编译器可能会为您的简单案例弄清楚并优化代码.但不是一般情况.

IFoo f2 = GetAFoo();
Run Code Online (Sandbox Code Playgroud)

并且GetAFoo定义为返回a IFoo,然后JIT编译器将无法优化调用.

  • 虽然它是一篇相当古老的文章(.NET 1.1),但我可以想象它现在仍然很多,并且可能会有疑问.显然,C#**永远不会**每个对象存储多个(相当于)虚拟表指针,即使从多个接口继承.因此,调用者不能简单地使用其特定的"预先烹饪"的vtable指针(如在典型的C++中) - 而是必须经历一个寻找正确的虚拟"子表"的过程,这会花费一些性能.完美的阅读,谢谢你的链接! (5认同)
  • 链接已死。Tom链接到的档案已损坏。[此为Internet存档:Wayback Machine](https://web.archive.org/web/20111005000627/http://msdn.microsoft.com/zh-cn/magazine/cc163791.aspx)链接,位于此答案之后已发布。顺便说一句,本文的标题是“ JIT和运行:深入到.NET Framework内部,以了解CLR如何创建运行时对象”。 (2认同)

Ste*_*ens 19

这是拆卸装置的样子(汉斯是正确的):

            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] 
Run Code Online (Sandbox Code Playgroud)

  • 即使我不是"编写编译器",C++中的虚拟表机制也非常简单有用.令我感到惊讶的是,C#做了不同的事情,我很好奇,就是这样.顺便说一句,这是另一个问题的背景:[泛型与C#中接口的实际优势] [1] [1]:http://stackoverflow.com/questions/7224675/practical-advantage-of-generics-vs γ-接口-在-C (8认同)
  • 你能用这种方式给出更多解释吗?谢谢! (4认同)
  • 通过接口访问对象时,接口函数必须与实际对象的功能"匹配".这需要更多时间和更多代码.除非你正在编写编译器,否则我不会花很多时间在这上面.还有7500万件其他东西需要学习. (2认同)

Flo*_*scu 11

我尝试了你的测试,在我的机器上,在特定的环境中,结果实际上是相反的.

我正在运行Windows 7 x64,我已经创建了一个Visual Studio 2010控制台应用程序项目,我已将其复制到您的代码中.如果在调试模式下编译项目并且平台目标为x86,则输出将如下所示:

直接电话:48.38
通过接口:42.43

实际上,每次运行应用程序时,它都会提供稍微不同的结果,但接口调用总是会更快.我假设由于应用程序编译为x86,它将由操作系统通过WOW运行.

有关完整参考,以下是其余编译配置和目标组合的结果.

发布模式和x86目标
直接调用:23.02
通过接口:32.73

调试模式和x64目标
直接调用:49.49
通过接口:56.97

发布模式和x64目标
直接呼叫:19.60
通过接口:26.45

所有上述测试均使用.Net 4.0作为编译器的目标平台.当切换到3.5并重复上述测试时,通过接口的呼叫总是比直接呼叫长.

因此,上述测试相当复杂,因为您发现的行为似乎并非总是发生.

最后,冒着让你心烦意乱的风险,我想补充一些想法.许多人补充说,性能差异非常小,在现实世界的编程中你不应该关心它们,我同意这种观点.这有两个主要原因.

第一个也是最广告的一个是.Net建立在更高层次上,以使开发人员能够专注于更高级别的应用程序.数据库或外部服务调用比虚拟方法调用慢几千甚至几百万倍.拥有良好的高级架构并专注于大性能消费者将始终在现代应用程序中带来更好的结果,而不是避免双指针解除引用.

第二个也是比较模糊的是.Net团队通过在更高层次上构建框架实际上引入了一系列抽象级别,即时编译器能够在不同平台上进行优化.他们为底层提供的访问权限越多,开发人员就能够针对特定平台进行优化,但运行时编译器能够为其他平台执行的操作越少.至少这是理论,这就是为什么事情没有像C++那样有关这个特定问题的记录.