C#: 虚函数调用比委托调用更快?

17

关于一个代码设计问题,我只是碰巧遇到了这样的情况。比如说,我有一个"模板"方法,它调用一些可能会"改变"的函数。一个直观的设计是遵循"模板设计模式"。将改变函数定义为需要在子类中重载的"虚拟"函数。或者,我可以只使用委托函数而无需使用"虚拟"。将委托函数注入以便也可以进行自定义。

最初,我认为第二种"委托"方式比"虚拟"方式更快,但一些编码片段证明这是不正确的。

在下面的代码中,第一个DoSomething方法遵循"模板模式"。它调用了虚拟方法IsTokenChar。第二个DoSomthing方法不依赖于虚拟函数。相反,它具有传入的委托。在我的计算机上,第一个DoSomthing始终比第二个快。结果是1645:1780。

"虚拟调用"是动态绑定,应该比直接委托调用更费时间,对吗?但结果表明并非如此。

有人能解释一下吗?

using System;
using System.Diagnostics;

class Foo
{
    public virtual bool IsTokenChar(string word)
    {
        return String.IsNullOrEmpty(word);
    }

    // this is a template method
    public int DoSomething(string word)
    {
        int trueCount = 0;
        for (int i = 0; i < repeat; ++i)
        {
            if (IsTokenChar(word))
            {
                ++trueCount;
            }
        }
        return trueCount;
    }

    public int DoSomething(Predicate<string> predicator, string word)
    {
        int trueCount = 0;
        for (int i = 0; i < repeat; ++i)
        {
            if (predicator(word))
            {
                ++trueCount;
            }
        }
        return trueCount;
    }

    private int repeat = 200000000;
}

class Program
{
    static void Main(string[] args)
    {
        Foo f = new Foo();

        {
            Stopwatch sw = Stopwatch.StartNew();
            f.DoSomething(null);
            sw.Stop();
            Console.WriteLine(sw.ElapsedMilliseconds);
        }

        {
            Stopwatch sw = Stopwatch.StartNew();
            f.DoSomething(str => String.IsNullOrEmpty(str), null);
            sw.Stop();
            Console.WriteLine(sw.ElapsedMilliseconds);
        }
    }
}

2
这一次是为Jon Skeet庆祝的机会,我感觉很棒! ;) - Mitch Wheat
@Mitch:在回答之前我实际上没有看到你的评论,但我感到受宠若惊 :) - Jon Skeet
顺便说一下,我发现使用优化后的构建更加明显的差异。 - Jon Skeet
7个回答

21

考虑每种情况所需的内容:

虚方法调用

  • 检查是否为空
  • 从对象指针导航到类型指针
  • 在指令表中查找方法地址
  • (不确定 - 即使 Richter 也没有涉及此问题) 如果方法未覆盖,是否转到基本类型?递归直到找到正确的方法地址。(我不认为是这样的 - 请参见底部的编辑。)
  • 将原始对象指针推入堆栈("this")
  • 调用方法

委托调用

  • 检查是否为空
  • 从对象指针导航到调用数组(所有委托都可能是多播的)
  • 循环遍历数组,对于每个调用:
    • 获取方法地址
    • 确定是否将目标作为第一个参数传递
    • 将参数推送到堆栈上(可能已经完成 - 不确定)
    • 可选地(取决于调用开放还是关闭)将调用目标推送到堆栈上
    • 调用方法

可能会有一些优化,以便在单次调用的情况下不涉及循环,但即使如此,也会进行非常快速的检查。

但基本上与委托相关的间接操作一样多。鉴于我在虚方法调用中不确定的部分,调用深度类型层次结构中未重载的虚方法可能会更慢...我会尝试并编辑答案。

编辑:我尝试了使用接口(传递进来),这最终的性能与委托大致相同。


在虚拟调用中,没有对空值进行检查。此外,方法的虚函数表在编译时确定,因此不存在基类的运行时递归。编译器从适当的基类生成指向正确方法的指针,并将其放置在虚函数表的适当位置。 - Franci Penov
6
callvirt确实会检查null值——请参阅CLI规范第3部分的第4.2节或CLR via C#的P166。(如果引用为null,会调用哪个实现?)感谢对“无递归”部分的确认。这基本上是实验所暗示的。 - Jon Skeet
3
不递归加一分,虚函数表在编译时被展平。 - thinkbeforecoding

14

只是想对John Skeet的回答进行一些更正:

虚方法调用不需要进行空值检查(硬件陷阱自动处理)。

它也不需要遍历继承链以查找未被覆盖的方法(这就是虚方法表的作用)。

虚方法调用本质上是在调用时多了一个额外的间接层。由于需要进行表查找和随后的函数指针调用,因此比普通调用慢。

委托调用也涉及到一个额外的间接层。

除非您使用DynamicInvoke方法执行动态调用,否则调用委托不涉及将参数放入数组中。

委托调用涉及调用方法在委托类型上生成的Invoke方法。对predicator(value)的调用被转换为predicator.Invoke(value)。

Invoke方法反过来由JIT实现,以调用函数指针(存储在委托对象内部)。

在您的示例中,您传递的委托应该被实现为编译器生成的静态方法,因为实现不访问任何实例变量或局部变量,因此从堆中访问“this”指针不应该成为问题。

委托(delegate)和虚函数(virtual function)调用之间的性能差异应该大致相同,您的性能测试表明它们非常接近。

差异可能是由于多播(multicast)需要进行额外的检查+分支(如John所建议的)。另一个原因可能是JIT编译器没有内联Delegate.Invoke方法,并且Delegate.Invoke的实现处理参数不如执行虚方法调用时的实现。


9
虚函数调用是在内存中的已知偏移量处取消引用两个指针。它实际上不是动态绑定;没有运行时代码来反映元数据以发现正确的方法。编译器生成了几条指令来进行调用,基于this指针。事实上,虚函数调用是一个单一的IL指令。
谓词调用是创建一个匿名类来封装谓词。必须实例化该类,并且会生成一些代码来检查谓词函数指针是否为空。
我建议您查看两者的IL结构。使用简化版本的源代码,在每个DoSomething中进行一次调用。然后使用ILDASM查看每种模式的实际代码。
(我相信我会因为没有使用正确的术语而被点踩 :-))

在我看来,“在运行时决定要调用的实际方法”被称为“动态绑定”。 - Morgan Cheng
一个 Predicate 只是一个委托实例。它是一个实例,但我不确定是否创建了“匿名类”。在 C#/.NET 中是否有“匿名类”概念? - Morgan Cheng
1
当我说“匿名类”时,我指的是编译器生成的辅助类,用于包装委托调用。LINQ引入了另一个匿名类的概念。 - Franci Penov
2
这个答案存在一个根本性的缺陷:由C#编译器生成的IL代码无法告诉您代码运行的速度;JIT输出的汇编代码将是更可靠的衡量执行速度的方法。这是因为(1)IL基于一个抽象的堆栈机器,它很可能与底层(通常是基于寄存器的)计算机体系结构不同,因此IL必须在执行之前进行相当大的转换;以及因为(2)优化显然主要是在IL之后而不是之前进行的(即由JIT而不是由C#编译器)。 - stakx - no longer contributing
关于上面评论中的最终声明,请参阅Eric Lippert的博客文章《“优化开关”是什么?》(http://blogs.msdn.com/b/ericlippert/archive/2009/06/11/what-does-the-optimize-switch-do.aspx)。 - stakx - no longer contributing
显示剩余4条评论

3

1

由于您没有任何覆盖虚方法的方法,JIT 可能会识别到这一点并使用直接调用。

对于这样的情况,通常最好像您所做的那样进行测试,而不是尝试猜测性能将如何。如果您想了解更多有关委托调用工作原理的信息,我建议阅读 Jeffrey Richter 的优秀著作《CLR Via C#》。


不仅如此,如果存储/调用委托比虚拟调度更高效,那么框架几乎肯定会存储和调用委托而不是使用虚拟调度。鉴于它没有这样做,很可能不是这样的情况(除了在少数非正常情况下)。 - technophile
我认为无论是在父类中使用"virtual"还是在子类中使用"override",都不会有区别。如果使用"virtual",它总是动态绑定的。 - Morgan Cheng

1

我怀疑这并不是你们差异的全部原因,但我能想到的其中一个可能会导致部分差异的原因是虚方法调度已经准备好了this指针。当通过委托进行调用时,this指针必须从委托中获取。

请注意,根据这篇博客文章,在.NET v1.x中差异甚至更大。


0

虚拟重写具有某种重定向表或类似东西,这是在编译时硬编码和完全优化的。它是一成不变的,非常快速。

委托是动态的,总会有开销,它们似乎也是对象,因此会增加开销。

除非为军事开发性能关键软件,否则您不应该担心这些小的性能差异,对于大多数目的而言,良好的代码结构胜过优化。


快速更正:我认为你想说的是“虚拟覆盖”,而不是“虚拟重载”。 - Jon Skeet

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