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

—编辑—

OK,回答/评论我到目前为止意味着有一个通过接口的虚拟调用的双指针取消引用,而不是虚拟调用通过对象的一个​​取消引用。

那么请有人解释为什么这是必要的? C#中的虚拟表的结构是什么? 它是“平坦的”(就像C ++的典型)或不? 在C#语言devise中做出的devise折衷是什么导致了这一点? 我不是说这是一个“坏”的devise,我只是好奇为什么这是必要的。

简而言之,我想了解我的工具在引擎盖下的function,以便更有效地使用它。 如果我没有再得到“你不应该知道的”或“使用另一种语言”types的答案,我将不胜感激。

—编辑2 —

只是为了清楚说明,我们并没有在这里处理一些JIT优化编译器,它将删除dynamic分派:我修改了原始问题中提到的基准,以便在运行时随机实例化一个类或另一个类。 由于实例化是在编译之后和assembly加载/ JITing之后发生的,所以在两种情况下都无法避免dynamic分派:

 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如何展示一个乘法类的实例 – inheritance其他类:

码:

 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; } }; 

debugging器:

 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位构build中)。

该…

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

..prints …

 0027F778 0027F77C 

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

我想在http://msdn.microsoft.com/en-us/magazine/cc163791.aspx的文章将回答你的问题。; 具体请参见“ 接口Vtable映射和接口映射 ”一节以及虚拟调度的以下部分。

对于JIT编译器来说,可能可以找出问题并针对您的简单情况优化代码。 但不是在一般情况下。

 IFoo f2 = GetAFoo(); 

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

这是拆装看起来像(汉斯是正确的):

  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] 

我试过了,在我的机器上,在特定的环境下,结果实际上是相反的。

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

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

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

有关完整的参考资料,下面是其余编译configuration和目标组合的结果。

发布模式和x86目标
直拨电话:23.02
通过接口:32.73

debugging模式和x64目标
直拨电话:49.49
通过界面:56.97

发布模式和x64目标
直拨电话:19.60
通过界面:26.45

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

所以,上面的testing比较复杂,因为看起来你发现的行为并不总是发生。

最后,有可能让你感到不安,我想补充一些想法。 许多人补充说,性能差异是相当小的,在现实世界的编程,你不应该关心他们,我同意这一观点。 这主要有两个原因。

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

第二个更晦涩的是,.Net团队通过在更高层次上构build框架,实际上已经引入了一系列的抽象层次,即时编译器将能够在不同的平台上进行优化。 他们给底层的访问权越多,开发人员就可以针对特定平台进行优化,但运行时编译器能够为其他人做的更less。 至less这是一个理论,这就是为什么事情没有像C ++那样logging在这个特定的问题上。

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

另一方面,调用接口函数IFoo:Bar无法查找类似IFoo的虚拟函数表,因为IFoo每个实现都不需要严格执行其他函数,也不需要Foo所做的接口。 因此,来自另一个class Fubar: IFooBar的虚函数表条目位置不能与class Foo:IFooBar的虚函数表条目位置相匹配。

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

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

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

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