【问题标题】:Performance of "direct" virtual call vs. interface call in C#“直接”虚拟调用与 C# 中的接口调用的性能
【发布时间】:2017-07-17 04:48:15
【问题描述】:

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

--- 编辑 ---

好的,到目前为止我得到的答案/cmets 暗示通过接口的虚拟调用有一个双指针取消引用,而通过对象的虚拟调用只有一个取消引用。

那么请有人解释一下为什么这是必要的吗? 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

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

【问题讨论】:

  • C++ 不一定强制进行虚拟查找。如果可以在编译时确定动态类型,则可以直接调用正确的函数。
  • 接口方法调用需要双指针解引用。如果您计算纳秒,C# 可能不应该是您选择的语言。 C 和 C++ 是为此优化的语言。
  • @Hans,我提出这个问题并不意味着我在任何具体项目上都“计算纳秒”。我就不能好奇吗?
  • 你的问题没有很好地表达这种兴趣。
  • @Jeremy “简单”调用的性能降低了约 60%,这在大多数情况下会被性能的其他方面所淹没,我同意。但是,我不同意它在所有情况下都是微不足道的,所以我认为有眼光的编码人员应该意识到这一点。

标签: c# .net performance language-design


【解决方案1】:

这是反汇编的样子(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]

【讨论】:

  • 感谢您的回答,但我真的对更“高级”的答案或此类行为的“基本原理”更感兴趣。
  • 通过接口访问对象时,接口功能必须与实际对象的功能“匹配”。这需要更多时间和更多代码。除非您正在编写编译器,否则我不会花很多时间在这上面。还有 7500 万件其他事情需要学习。
  • 即使我不是“编写编译器”,C++ 中的虚拟表机制也非常简单且有用。 C# 以不同的方式做事让我感到惊讶,我很好奇,仅此而已。顺便说一句,这是在另一个问题的背景下提出的:[泛型与 C# 中的接口的实际优势][1] [1]:stackoverflow.com/questions/7224675/…
  • 您能否详细解释一下为什么会这样?谢谢!
【解决方案2】:

我认为文章 Drill Into .NET Framework Internals to See How the CLR Creates Runtime Objects 会回答你的问题。特别是,请参阅 *Interface Vtable Map and Interface Map- 部分和以下有关虚拟调度的部分。

JIT 编译器可能会为您的简单案例找出问题并优化代码。但一般情况下不会。

IFoo f2 = GetAFoo();

GetAFoo 被定义为返回IFoo,那么JIT 编译器将无法优化调用。

【讨论】:

  • 虽然是一篇相当老的文章(.NET 1.1),但我可以想象其中的很多内容在今天仍然相关并且可能会提出疑问。显然,C#从不为每个对象存储多个(等价的)虚拟表指针,即使是从多个接口继承的。因此,调用者不能简单地使用其特定的“预煮”vtable 指针(如在典型的 C++ 中)——相反,它必须通过查找正确的虚拟“子表”的过程,这会消耗一些性能。令人着迷的阅读,感谢您的链接!
  • 请注意,上面的文章是 MSDN 杂志 2005 年 5 月号。当前问题的下载链接在这里:download.microsoft.com/download/3/a/7/…
  • 除了答案之外,这里是描述 why 背后原因的链接,在 .Net 中没有多重继承(恕我直言,这就是为什么只有一个虚拟表的原因指针)MSDN blog
  • 链接已失效。 Tom 链接到的存档已损坏。 Here's the Internet Archive: Wayback Machine 在此答案发布后的链接。顺便说一句,文章的标题是“JIT 和运行:深入了解 .NET Framework 内部结构以了解 CLR 如何创建运行时对象”。
  • 您也可以从msdn.microsoft.com/magazine/msdn-magazine-issues 以 CHM 文件的形式下载 2005 年 5 月的问题(必须取消阻止才能查看)。
【解决方案3】:

我试过你的测试,在我的机器上,在特定的上下文中,结果实际上是相反的。

我正在运行 Windows 7 x64,并且我创建了一个 Visual Studio 2010 Console Application 项目,我已将您的代码复制到该项目中。如果在 Debug 模式 下编译项目,并且平台目标为 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++ 那样详细的原因。

【讨论】:

  • 感谢您的回答,我实际上同意您的“哲学”观点。我完全理解选择正确的数据结构和算法,而不是依赖未记录的行为,如果比微优化更重要的话;这只是我的编程心理迫使我抓挠;)顺便说一句,我很抱歉没有指定我的所有基准测试都是在发布配置中进行的(我认为对永远不会使用的调试版本进行基准测试没有多大意义生产中)。鉴于此,我的结果实际上与您的结果一致。
  • @Branko,很抱歉无法为您实际搜索的内容带来太多价值。我的回答实际上更多是我想在其中包含 .Net 中更高级别方法的目的的观察集合,以便其他访问此页面的人也能获得对事物的看法。另一方面,基于编译模式的混合结果显示了可变的 .Net 与这些低级方面的关系。
【解决方案4】:

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

另一方面,调用接口函数 IFoo:Bar 无法查找类似 @​​987654324@ 的虚函数表,因为 IFoo 的每个实现都不需要实现其他函数,也不需要Foo 的接口。所以Bar从另一个class Fubar: IFoo的虚函数表入口位置不能与Barclass Foo:IFoo的虚函数表入口位置匹配。

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

【讨论】:

  • 您是正确的,接口必须有自己的虚拟表条目,这对于 C++ 和 C# 都是正确的。但是,有一些方法可以以与“直接”调用相同的效率实现接口调用(请参阅我与 Alan 的讨论以及我的问题中的 --- EDIT 3 ---)。我的问题实际上是关于为什么在 C# 中没有使用这些“更有效的方法”(我并不是说它们作为一个整体架构实际上更好,只是在调用本身时)。
  • 我不太清楚如何优化这个。实际的方法调用取决于 - 如果没有检测到明显的快捷方式 - 使用的接口和当前的对象类。但是,该接口不能提供在任何对象中选择单个 vtable 的通用方法,因为要实现的接口方法没有“固定槽”。您的 C++ 示例可以选择对象的许多 vtable 之一,因为转换的源和目标类是已知的:“C”和“IA”的组合唯一地标识 C 中的一个 vtable。对于像“IFoo”这样的接口指针,它不是案例。
  • 给定对象中的虚拟表指针始终指向特定于该对象的给定接口实现的 vtable。换句话说,没有“接口”vtable 这样的东西——只有一个“类实现的接口”vtable。接口指针总是指向对象的“右”部分,因此正确的 vtptr - if 我们正确地执行了我们的转换。本质上,“选择单个 vtable”所需的信息被编码在指针中包含的物理地址中。
【解决方案5】:

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

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

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

【讨论】:

  • 没关系,但我已经知道了(从问题的表述中可以明显看出)。我感兴趣的是这种行为的技术原因,我认为Jim Mischel 提供了答案。
  • 只是想为这个主题添加材料,关于这个主题的讨论并不多。
猜你喜欢
  • 2016-06-18
  • 1970-01-01
  • 1970-01-01
  • 2012-05-09
  • 2012-08-08
  • 2011-02-04
  • 1970-01-01
  • 2012-06-15
相关资源
最近更新 更多