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

时间:2011-08-29 01:14:18

标签: c# .net performance language-design

This benchmark似乎表明直接在对象引用上调用虚方法比在对象实现的接口的引用上调用它更快。

换句话说:

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#如何实现虚拟调用以及通过接口调用时显然可以完成的“额外”工作是什么?

---编辑---

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

那么可以请某人解释为什么是必要的? 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

    }

}

---编辑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;

..打印...

0027F778
0027F77C

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

5 个答案:

答案 0 :(得分:24)

我认为http://msdn.microsoft.com/en-us/magazine/cc163791.aspx上的文章会回答你的问题。特别是,请参阅Interface Vtable Map and Interface Map部分和以下有关虚拟调度的部分。

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

IFoo f2 = GetAFoo();

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

答案 1 :(得分: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] 

答案 2 :(得分: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 ++那样有关这一特定问题的记录。

答案 3 :(得分:1)

我认为纯虚函数案例可以使用简单的虚函数表,因为实现Foo的任何派生类Bar只会将虚函数指针更改为Bar

另一方面,调用接口函数IFoo:Bar无法对IFoo的虚函数表进行查找,因为IFoo的每个实现都不需要验证实现Foo所做的其他功能和接口。因此,来自另一个Bar的{​​{1}}的虚函数表条目位置必须与class Fubar: IFooBar的虚函数表条目位置不匹配。

因此,纯虚函数调用可以依赖于每个派生类中虚函数表内的函数指针的相同索引,而接口调用必须首先查找该索引。

答案 4 :(得分:0)

一般规则是:类很快。接口很慢。

这是建议“使用类构建层次结构并使用接口进行层次结构内行为”的原因之一。

对于虚拟方法,差异可能很小(如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