虚方法比非虚方法更快吗?

4

最近我阅读了关于《早期和经常性的性能测量,第2部分》的文章,它附带有源代码二进制文件

文章摘录:“我强调过,要可靠地创建高性能程序,你需要在设计过程中早期了解你使用的各个组件的性能。”

所以,我使用他的工具(版本为0.2.2)进行基准测试并尝试查看各个组件的性能。

在我的PC(x64)上,结果如下:

Name                                                                            Median  Mean    StdDev  Min     Max Samples
NOTHING [count=1000]                                                            0.14    0.177   0.164   0       0.651   10
MethodCalls: EmptyStaticFunction() [count=1000 scale=10.0]                      1       1.005   0.017   0.991   1.042   10
Loop 1K times [count=1000]                                                      85.116  85.312  0.392   84.93   86.279  10
MethodCalls: EmptyStaticFunction(arg1,...arg5) [count=1000 scale=10.0]          1.163   1.172   0.015   1.163   1.214   10
MethodCalls: aClass.EmptyInstanceFunction() [count=1000 scale=10.0]             1.009   1.011   0.019   0.995   1.047   10
MethodCalls: aClass.Interface() [count=1000 scale=10.0]                         1.112   1.121   0.038   1.098   1.233   10
MethodCalls: aSealedClass.Interface() (inlined) [count=1000 scale=10.0]         0       0.008   0.025   0       0.084   10
MethodCalls: aStructWithInterface.Interface() (inlined) [count=1000 scale=10.0] 0       0.008   0.025   0       0.084   10
MethodCalls: aClass.VirtualMethod() [count=1000 scale=10.0]                     0.674   0.683   0.025   0.674   0.758   10
MethodCalls: Class.ReturnsValueType() [count=1000 scale=10.0]                   2.165   2.16    0.033   2.107   2.209   10

我惊讶地发现虚方法(0.674)比非虚实例方法(1.009)或静态方法(1)更快。而接口的速度也不慢!(我本来期望接口至少会慢2倍)。
由于这些结果来自可信的来源,我想知道如何解释上述发现。
我认为文章并没有过时的问题,因为在文章本身中并没有关于读数的任何说明。它所做的只是提供一个基准测试工具。

6
接口调用就是虚拟调用。 - Marc Gravell
7
你提供的链接文章几乎可以肯定是旨在衡量你自己代码的性能,而不是调用机制。能够决定选择虚拟或非虚拟调用的特性几乎肯定比任何微小的性能差异更为重要。 - Robert Harvey
3
请注意博客文章中的一件事:它是在2008年5月发布的。在五年的时间里,很多事情都可能发生改变。 - Marc Gravell
2
我基本上不同意“早期和经常性能测量”的原则。在我看来,正确性应该高于所有其他品质。优化是最后才会出现的事情。 - spender
5
这取决于你正在做什么。我认为该博客更适用于图书馆作者而非应用程序开发人员。在编写库代码时,心态可能会有很大的不同。 - Marc Gravell
显示剩余6条评论
2个回答

5
我猜测他的示例中使用的基准测试方法存在缺陷。在LINQPad中运行以下代码,结果大约如预期所示:
/* This is a benchmarking template I use in LINQPad when I want to do a
 * quick performance test. Just give it a couple of actions to test and
 * it will give you a pretty good idea of how long they take compared
 * to one another. It's not perfect: You can expect a 3% error margin
 * under ideal circumstances. But if you're not going to improve
 * performance by more than 3%, you probably don't care anyway.*/
void Main()
{
    // Enter setup code here
    var foo = new Foo();
    var actions = new[]
    {
        new TimedAction("control", () =>
        {
            // do nothing
        }),
        new TimedAction("non-virtual instance", () =>
        {
            foo.DoSomething();
        }),
        new TimedAction("virtual instance", () =>
        {
            foo.DoSomethingVirtual();
        }),
        new TimedAction("static", () =>
        {
            Foo.DoSomethingStatic();
        }),
    };
    const int TimesToRun = 10000000; // Tweak this as necessary
    TimeActions(TimesToRun, actions);
}

public class Foo
{
    public void DoSomething() {}
    public virtual void DoSomethingVirtual() {}
    public static void DoSomethingStatic() {}
}


#region timer helper methods
// Define other methods and classes here
public void TimeActions(int iterations, params TimedAction[] actions)
{
    Stopwatch s = new Stopwatch();
    int length = actions.Length;
    var results = new ActionResult[actions.Length];
    // Perform the actions in their initial order.
    for(int i = 0; i < length; i++)
    {
        var action = actions[i];
        var result = results[i] = new ActionResult{Message = action.Message};
        // Do a dry run to get things ramped up/cached
        result.DryRun1 = s.Time(action.Action, 10);
        result.FullRun1 = s.Time(action.Action, iterations);
    }
    // Perform the actions in reverse order.
    for(int i = length - 1; i >= 0; i--)
    {
        var action = actions[i];
        var result = results[i];
        // Do a dry run to get things ramped up/cached
        result.DryRun2 = s.Time(action.Action, 10);
        result.FullRun2 = s.Time(action.Action, iterations);
    }
    results.Dump();
}

public class ActionResult
{
    public string Message {get;set;}
    public double DryRun1 {get;set;}
    public double DryRun2 {get;set;}
    public double FullRun1 {get;set;}
    public double FullRun2 {get;set;}
}

public class TimedAction
{
    public TimedAction(string message, Action action)
    {
        Message = message;
        Action = action;
    }
    public string Message {get;private set;}
    public Action Action {get;private set;}
}

public static class StopwatchExtensions
{
    public static double Time(this Stopwatch sw, Action action, int iterations)
    {
        sw.Restart();
        for (int i = 0; i < iterations; i++)
        {
            action();
        }
        sw.Stop();

        return sw.Elapsed.TotalMilliseconds;
    }
}
#endregion

结果:

                       DryRun1 DryRun2  FullRun1 FullRun2
 control               0.0361  0        47.82    47.1971 
 non-virtual instance  0.0858  0.0004   69.6178  68.7508 
 virtual instance      0.1676  0.0004   70.5103  69.2135 
 static                0.1138  0        66.6182  67.0308 

结论

这些结果表明,对虚拟实例进行方法调用仅比常规实例方法调用稍微慢一点点(在考虑控制因素后可能会增加2-3%),而常规实例方法调用仅比静态调用稍微慢一点。这正是我所期望的。

更新

在@colinfang评论添加[MethodImpl(MethodImplOptions.NoInlining)]属性到我的方法后,我进行了更多的尝试,可以得出的唯一结论是微观优化是很复杂的。以下是一些观察结果:

  • As @colinfang says, adding NoInlining to the methods does yield results more like what he described. It's not surprising that method inlining is one way the system can optimize non-virtual methods to go faster than virtual methods. But it is surprising that not inlining would actually make virtual methods take longer than non-virtual ones.
  • If I compile with /optimize+, the non-virtual instance call actually takes less time than the control, by over 20%.
  • If I eliminate the lambda functions, and pass the method group directly like so:

    new TimedAction("non-virtual instance", foo.DoSomething),
    new TimedAction("virtual instance", foo.DoSomethingVirtual),
    new TimedAction("static", Foo.DoSomethingStatic),
    

    ... then virtual and non-virtual calls end up taking about the same amount of time as each other, but the static method call takes significantly longer (upwards of 20%).

所以,很奇怪的事情。重点是:当你深入到这个优化级别时,由于编译器、JIT甚至硬件层面的任何优化,都可能出现意想不到的结果。我们看到的差异可能是CPU L2缓存策略等无法控制的因素导致的。这里存在着风险。


你能否在Foo下的所有方法中添加[MethodImpl(MethodImplOptions.NoInlining)]呢?添加后,我就可以重现我帖子中的类似结果了。 - colinfang

0

有很多原因可能导致出现违反直觉的结果。其中一个原因是虚拟调用有时(可能大部分时间)会发出callvirt IL指令,以确保进行空检查(可能是在搜索vtable时)。另一方面,如果JIT可以确定在虚拟调用点上只会调用一个特定的实现(并且在非null引用上),它很可能会尝试将其转换为静态调用。

我认为这是你的应用程序设计中真正不应该关心的少数事情之一。你应该考虑虚拟/密封语言结构,而不是运行时结构(让运行时做最好的事情)。如果一个方法需要对你的应用程序有虚拟的需求,那就把它设为虚拟的。如果它不需要是虚拟的,那就不要设为虚拟的。如果你确实会基于此来设计你的应用程序,那么就没有必要对其进行基准测试。(除了好奇心外。)


1
C#编译器在几乎所有调用实例方法的情况下都使用callvirt,无论目标方法是否为虚拟方法。我知道的唯一例外是当您使用base.Foo(...)显式调用基类实现时。 - Sam Harwell
@280Z28 有趣的是,规范似乎不允许这样做:规范要求即使是对 base.Foo(...) 的调用也需要进行空引用检查(只有在从 C# 以外的其他语言直接或间接调用时才能使用 nullthis 进行调用)。我相当确定这是规范中的错误,而不是编译器中的错误。 - user743382
@hvd base.Foo(...) 需要直接调用特定的方法,即使(尤其是)该方法已被覆盖。如果使用callvirt,你最终会得到方法一遍又一遍地调用自身的情况。 - Sam Harwell
@280Z28 规范中没有关于 call/callvirt 的说明,规范说在 null 上调用方法会导致 NullReferenceException。没有相关的异常。我知道 call 不会引起 NullReferenceException,但这只意味着 call 是不充分的,符合 C# 编译器应该在使用 call 之前发出显式的空检查并手动抛出一个 NullReferenceException。(再次强调,一个明智的实现不会这样做,但从技术上讲,规范确实要求这样做。) - user743382

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