C#中虚函数调用的速度与C++相比如何?

8

我记得在某处读到过,在C#中虚拟调用的成本相对较低,而在C++中则不然。这是真的吗?如果是,为什么呢?

9个回答

8
C#虚拟调用必须检查“this”是否为空,而C++虚拟调用则不需要。因此,我通常看不出C#虚拟调用为什么会更快。在特殊情况下,C#编译器(或JIT编译器)可能能够比C++编译器更好地内联虚拟调用,因为C#编译器可以访问更好的类型信息。调用方法指令有时可能在C++中较慢,因为C# JIT可以使用更快的指令,只处理小偏移量,因为它比C++编译器更了解运行时内存布局和处理器模型。

然而,我们最多只谈论一小部分处理器指令。在现代超标量处理器上,很可能“null check”指令与“call method”同时运行,因此不需要时间。

如果在循环中进行调用,则所有处理器指令很可能已经在一级缓存中。但是数据不太可能被缓存,这些天从主存读取数据值的成本与从一级缓存中运行数百条指令的成本相同。因此,在实际应用程序中,虚拟调用的成本甚至在非常少的地方也无法测量。

当然,C#代码使用更多指令将减少可以适合缓存的代码量,这种影响是不可预测的。

(如果C++类使用多重继承,则成本更高,因为必须修补“this”指针。同样,在C#中,接口添加了另一层重定向。)


5
原始问题如下:
引用: 我似乎记得在某个地方读到过,C#中的虚拟调用成本相对较低,相对于C++而言。
请注意强调。换句话说,问题可能被重新表述为:
引用: 我似乎记得在某个地方读到过,在C#中,虚拟调用和非虚拟调用的速度相同,而在C++中,虚拟调用比非虚拟调用慢...
因此,提问者并没有声称在任何情况下C#都比C++更快。
这可能是一个无用的分歧,但这引起了我的好奇心,关于使用没有C++/CLI扩展的/clr:pure的C++技术。编译器生成IL,由JIT转换为本机代码,尽管它是纯C++。因此,在这里,我们有一种方法可以看到标准C++实现在与C#运行在同一平台上时的操作方式。
对于非虚方法:
struct Plain
{
    void Bar() { System::Console::WriteLine("hi"); }
};

这段代码:
Plain *p = new Plain();
p->Bar();

...会导致call操作码被发出,传递特定的方法名称,并将隐式this参数传递给Bar。

call void <Module>::Plain.Bar(valuetype Plain*)

与继承层次相比较:
struct Base
{
    virtual void Bar() = 0;
};

struct Derived : Base
{
    void Bar() { System::Console::WriteLine("hi"); }
};

现在如果我们执行以下操作:
Base *b = new Derived();
b->Bar();

这会生成calli操作码,然后跳转到一个计算机地址,因此在调用之前有大量的IL代码。我们将其转换回C#代码,以便更好地理解:

**(*((int*) b))(b);

换句话说,将b的地址转换为指向int的指针(它恰好与指针大小相同),并获取该位置的值,即vtable的地址,然后获取vtable中的第一项,即要跳转到的地址,对其进行解引用并调用它,将隐式的this参数传递给它。
我们可以调整虚拟示例以使用C++/CLI扩展。
ref struct Base
{
    virtual void Bar() = 0;
};

ref struct Derived : Base
{
    virtual void Bar() override { System::Console::WriteLine("hi"); }
};

Base ^b = gcnew Derived();
b->Bar();

这会生成callvirt操作码,就像在C#中一样:

callvirt instance void Base::Bar()

因此,在编译以面向CLR(公共语言运行时)为目标时,微软当前的C++编译器在优化方面与C#使用各自标准特性时并没有相同的可能性。对于一个标准的C++类层次结构,C++编译器会生成包含硬编码逐个遍历vtable的逻辑的代码,而对于一个ref类,则由JIT来找出最佳实现。


这是关于在CLR之上使用C ++,在我看来这并不公平。 - DevSolar
此外,在这个上下文中,“fair”表示什么意思? - Daniel Earwicker
我喜欢这个。我故意让问题的措辞有点开放性。 - Johann Gerell
我并不是有意冒犯。也许我的大脑皮层没有足够长的时间来理解你文章中的免责声明。;-) - DevSolar
没关系 - 我并没有生气,实际上我更担心自己浪费了其他人和自己的时间... :) - Daniel Earwicker
显示剩余2条评论

5
对于JIT编译语言(我不知道CLR是否这样做,Sun的JVM是这样做的),将仅有两个或三个实现的虚拟调用转换为类型测试和直接或内联调用序列是一种常见的优化。
这样做的好处是,现代流水线CPU可以使用分支预测和直接调用的预取,但间接调用(在高级语言中表示为函数指针)通常会导致流水线停顿。
在极端情况下,当虚拟调用只有一个实现且调用体足够小的时候,虚拟调用被简化为纯inline code。这种技术在Self language运行时中被使用,而JVM则从中演变而来。
大多数C++编译器不执行所需的整个程序分析以执行此优化,但像LLVM这样的项目正在研究这样的整个程序优化。

你确定它总是会导致流水线停顿吗?CPU没有理由不能预取间接调用(我想这是英特尔专门针对的问题...)。如果可以预取,则虚函数的开销将为零。 - Jimmy J
几年前我最后一次检查,所以我可能错了。我在英特尔上找到的唯一有关预测间接分支的参考资料是在他们的配置文件指导编译中;他们的大部分文档只是说它们“非常难以预测”,而其他研究表明99%的停顿来自间接调用。 - Pete Kirkham
我认为第二次从同一调用站点进行相同的间接调用时,不会出现停顿。例如,如果循环对许多相同类型的对象进行虚拟调用,则没有问题。 - Ian Ringrose
英特尔 VTune 将检测到这种停顿,因此如果您拥有它,可以运行测试。同时,我已经将“总是”弱化为“经常”。 - Pete Kirkham

3

我猜这个假设是基于JIT编译器的,意味着C#可能会在实际使用之前将虚拟调用转换为简单的方法调用。

但这基本上是理论性的,我不会打赌!


即使是这种情况;“转换为简单的方法调用”也不是免费的,不是吗? - DevSolar
不,当然不是。但是在实际通话时,您将没有任何需要支付的余额(就像预先支付一样)。 - Benoît
重点是,下次您进行该调用时,您必须再次检查对象。一般来说,这可能会发生变化,因此obj.foo()每次都指向不同的foo。请注意,如果在编译时知道对象类型,C++编译器通常也可以将虚拟调用转换为普通调用。 - MSalters

2

C++中虚函数调用的成本是通过指针(vtbl)进行函数调用的成本。我怀疑C#能否更快地执行此操作,并且仍然能够在运行时确定对象类型...

编辑:正如Pete Kirkham所指出的那样,一个好的JIT可能能够内联C#调用,避免管道停顿;这是大多数C++编译器目前无法做到的。另一方面,Ian Ringrose提到了对缓存使用的影响。除非在实际工作负载下在目标机器上进行了性能分析,否则我个人不会真的介意。这只是微小的优化。


1

关于完整框架我不确定,但在紧凑框架中,它会更慢,因为CF没有虚拟调用表,尽管它会缓存结果。这意味着,在CF中进行虚拟调用时,第一次调用会更慢,因为它必须进行手动查找。如果应用程序内存不足,则每次调用都可能很慢,因为缓存的查找可能被清除。


0
在C#中,通过分析代码可能有可能将虚函数转换为非虚函数。但实际上这种情况发生的频率不够高,对结果影响不大。

0

C#会将虚函数表展开并内联祖先调用,因此您不需要在继承层次结构中链接任何内容来解决问题。


0

这可能并不完全是你问题的答案,但是尽管 .NET JIT 像大家之前说的那样优化了虚拟调用,但是 Visual Studio 2005 和 2008 中的分析指导优化通过插入一个直接调用到最有可能的目标函数来进行虚拟调用猜测,将调用内联,这样权重可能是相同的。


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