在C#中,“直接”虚拟调用和接口调用的性能比较。

72

这个基准测试似乎表明,在对象引用上直接调用虚方法比在此对象实现的接口引用上调用虚方法要快。

换句话说:


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优化,从原始问题中提到的基准测试进行了修改,以在运行时随机实例化一个类或另一个类。由于实例化发生在编译和装配加载/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;

..打印输出...

0027F778
0027F77C

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


2
C++不一定强制进行虚函数查找。如果动态类型可以在编译时确定,正确的函数可以直接调用。 - Kerrek SB
19
接口方法调用需要双指针解引用。如果你计算纳秒,C# 或许不是你的首选语言。 C 和 C++ 是为此进行了优化的语言。 - Hans Passant
26
@Hans,我提出问题并不意味着我在某个具体的项目上“数秒钟”。我不能只是出于好奇吗? - Branko Dimitrijevic
4
你的问题没有很好地表达出你的兴趣。 - Hans Passant
5
“简单”调用性能下降60%在大多数情况下可能会被其他性能因素所掩盖,我同意这一点。然而,我不认为在所有情况下它都是微不足道的,所以我认为有眼光的程序员应该意识到这一点。 - Branko Dimitrijevic
显示剩余10条评论
5个回答

31

6
虽然这篇文章是比较老的(.NET 1.1),但我想很多内容仍然与现在有关并且对我的问题有帮助。显然,即使从多个接口继承,C# 永远不会为每个对象存储多个(相当于)虚拟表指针。因此,调用者不能像在典型的C++中那样简单地使用其特定的“预先处理好”的vtable指针-而必须通过查找正确的虚拟“子表”来进行一些性能损耗的过程。总的来说,这是一篇有趣的阅读材料,感谢提供链接! - Branko Dimitrijevic
请注意,上面的文章是MSDN杂志2005年5月份的一期。当前下载该期的链接在这里:http://download.microsoft.com/download/3/a/7/3a7fa450-1f33-41f7-9e6d-3aa95b5a6aea/MSDNMagazineMay2005en-us.chm - Tom Spilman
作为答案的补充,这里是一篇链接,描述了为什么在 .Net 中没有多重继承的原因(在我看来,这就是为什么只有一个虚拟表指针的原因)。 MSDN 博客 - Sergey.quixoticaxis.Ivanov
3
这些链接已经失效了。Tom提供的存档链接已经损坏。这里是互联网档案馆: Wayback Machine,可以找到回答发布后不久的文章内容。顺便说一下,这篇文章的标题是“JIT and Run: Drill Into .NET Framework Internals to See How the CLR Creates Runtime Objects”。 - Zach Mierzejewski
您还可以从https://msdn.microsoft.com/magazine/msdn-magazine-issues下载2005年5月份的CHM文件(在查看之前必须解除阻止)。 - NetMage

23

这是反汇编的样子(Hans是正确的):

            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]

1
谢谢你的回答,但我更感兴趣的是更“高层次”的答案或对这种行为的“原理”。 - Branko Dimitrijevic
2
当通过接口访问对象时,接口函数必须与实际对象的函数“匹配”。这需要更多的时间和代码。除非你正在编写编译器,否则我不会花太多时间在这上面。还有7500万其他要学习的东西。 - Steve Wellens
9
即使我不是在“编写编译器”,了解C ++中的虚拟表机制也很简单且有用。让我感到惊讶的是C#处理方式不同,这激起了我的好奇心。顺便说一句,这是在另一个问题的背景下提出的:在C#中泛型与接口的实际优势 - Branko Dimitrijevic
4
您能否进一步解释为什么是这样的呢?谢谢! - Austin Henley

12

我尝试了你的测试,在我的机器上,在特定的情况下,结果实际上是相反的。

我运行的是 Windows 7 x64,并创建了一个 Visual Studio 2010控制台应用程序 项目,将你的代码复制进去。如果我以Debug模式x86平台作为编译目标编译该项目,则输出将如下所示:

直接调用:48.38 通过接口调用:42.43

实际上,每次运行应用程序都会提供稍微不同的结果,但接口调用始终更快。我认为由于应用程序编译为x86,因此将通过WoW在操作系统上运行。

完整参考文献如下,包括其他编译配置和目标组合的结果。

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

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

Release模式和x64目标
直接调用:19.60
通过接口调用:26.45

所有上述测试都是以 .NET 4.0 作为编译器的目标平台进行的。当切换到 3.5 并重复上述测试时,通过接口调用始终比直接调用慢。

因此,上述测试使事情变得更加复杂,因为你发现的行为并不总是发生。

最后,有可能会让您感到不快,但我想补充一些想法。很多人评论说性能差别非常小,在现实世界的编程中你不应该关心它们,我同意这个观点。这其中有两个主要原因。

第一个原因是.NET构建在更高层次上,以便使开发人员专注于应用程序的更高层次。数据库或外部服务调用比虚方法调用慢数千倍,有良好的高级架构并专注于大量性能消耗者总是会带来更好的结果,而不是避免双指针解引用。

第二个更加隐晦的原因是,.NET团队通过在更高层次上构建框架,实际上引入了一系列抽象层,即及时编译器可以在不同平台上进行优化。他们为底层提供的访问越多,开发人员就越能够优化特定平台,但是运行时编译器就越不能为其他平台做出优化。至少这就是理论,这也是为什么在这个特定问题上事情没有C++那么好记录的原因。


1
谢谢你的回答,我实际上同意你的“哲学”观点。我完全理解选择正确的数据结构和算法,而不是依赖于未经记录的行为,比微调更重要;这只是我的编程心理强迫我去挠痒痒;) 顺便说一句,我很抱歉没有指定所有基准测试都是在发布配置中进行的(我认为基准测试一个永远不会用于生产的调试版本没有太多意义)。考虑到这一点,我的结果实际上与你的相符。 - Branko Dimitrijevic
@Branko,很抱歉我无法为您寻找到的内容提供太多价值。我的回答实际上更多是一些观察的集合,我想在其中包括.Net中高级方法的目的,以便其他人在访问此页面时也能获得这种看法。另一方面,基于编译模式的混合结果显示了与这些低级方面相关的.Net可以有多么不稳定。 - Florin Dumitrescu

4

一般规则是:类速度快,接口速度慢。

这也是“使用类构建层次结构,并在层次内使用接口进行行为定义”的推荐原因之一。

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

1
没问题,但我已经知道了(从问题的表述中显然可以看出)。我感兴趣的是这种行为的技术原因,我认为 Jim Mischel 给出了答案。 - Branko Dimitrijevic
1
只是想添加一些关于这个主题的材料,因为目前很少有讨论。 - Johan Nilsson

1

我认为纯虚函数可以使用一个简单的虚函数表,因为任何实现BarFoo派生类只需将虚函数指针更改为Bar

另一方面,调用接口函数IFoo:Bar不能在像IFoo的虚函数表中查找,因为IFoo的每个实现不需要实现Foo实现的其他函数或接口。因此,来自另一个class Fubar: IFooBar的虚函数表条目位置必须不匹配class Foo:IFooBar的虚函数表条目位置。

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


1
你说得对,接口必须有自己的虚拟表项,这对C++和C#都是正确的。然而,有办法实现与“直接”调用同样高效的接口调用(请参见我与Alan和**--- EDIT 3 ---**的讨论)。我的问题实际上是关于为什么在C#中没有使用这些“更有效”的方法(我并不是说它们在整体架构上实际上更好,只是在调用本身方面更好)。 - Branko Dimitrijevic
我不太确定如何进行一般优化。实际的方法调用取决于所使用的接口和当前对象的类,如果没有检测到明显的快捷方式,则会调用该方法。然而,接口无法提供一种通用的方式来选择任何对象中的单个vtable,因为接口方法没有固定的插槽来实现。您的C++示例可以选择对象的多个vtable之一,因为已知转换的源类和目标类:组合'C'和'IA'唯一地标识了C内部的一个vtable。对于像“IFoo”这样的接口指针,情况并非如此。 - dronus
给定对象中的虚拟表指针总是指向特定于该对象实现的给定接口的虚拟表。换句话说,不存在“接口”虚拟表 - 只有“由类实现的接口”虚拟表。接口指针始终指向对象的“正确”部分,因此右侧的vtptr - 如果我们正确执行了转换。基本上,“选择单个虚拟表”的必要信息编码在指针中包含的物理地址中。 - Branko Dimitrijevic

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接