没有目标的.net委托比目标慢.net委托

时间:2017-02-08 19:44:54

标签: c# .net performance delegates

当我在我的机器上以释放模式执行以下代码时,具有非null目标的委托的执行总是比委托具有空目标(我希望它等效或更慢)时快一些。

我真的不是在寻找微优化,但我想知道为什么会出现这种情况?

static void Main(string[] args)
{
    // Warmup code

    long durationWithTarget = 
        MeasureDuration(() => new DelegatePerformanceTester(withTarget: true).Run());

    Console.WriteLine($"With target: {durationWithTarget}");

    long durationWithoutTarget = 
        MeasureDuration(() => new DelegatePerformanceTester(withTarget: false).Run());

    Console.WriteLine($"Without target: {durationWithoutTarget}");
}

/// <summary>
/// Measures the duration of an action.
/// </summary>
/// <param name="action">Action which duration has to be measured.</param>
/// <returns>The duration in milliseconds.</returns>
private static long MeasureDuration(Action action)
{
    Stopwatch stopwatch = Stopwatch.StartNew();

    action();

    return stopwatch.ElapsedMilliseconds;
}

class DelegatePerformanceTester
{
    public DelegatePerformanceTester(bool withTarget)
    {
        if (withTarget)
        {
            _func = AddNotStatic;
        }
        else
        {
            _func = AddStatic;
        }
    }
    private readonly Func<double, double, double> _func;

    private double AddNotStatic(double x, double y) => x + y;
    private static double AddStatic(double x, double y) => x + y;

    public void Run()
    {
        const int loops = 1000000000;
        for (int i = 0; i < loops; i++)
        {
            double funcResult = _func.Invoke(1d, 2d);
        }
    }
}

1 个答案:

答案 0 :(得分:7)

我会写这篇文章,其后面有相当不错的编程建议,这对任何关心编写快速代码的C#程序员来说都很重要。我一般谨慎使用微基准测试,由于现代CPU核心上代码执行速度的不可预测性,15%或更低的差异在统计上并不显着。降低测量不存在的几率的好方法是重复测试至少10次以消除缓存效应并交换测试,以便消除代码对齐效果。

但是你看到的是真实的,调用静态方法的委托实际上更慢。在x86代码中效果非常小,但在x64代码中效果要差一些,请务必修改项目&gt;属性&gt;构建标签&gt;首选32位和平台目标设置以尝试这两种设置。

了解速度慢的原因需要查看抖动产生的机器代码。在代理的情况下,该代码非常隐藏得很好。使用Debug&gt;查看代码时,您将看不到它。 Windows&gt;拆卸。而且你甚至无法单步执行代码,编写托管调试程序来隐藏它并完全拒绝显示它。我将不得不描述一种技术来放置&#34; visual&#34;回到Visual Studio。

我必须谈谈&#34;存根&#34;。除了抖动生成的代码之外,存根是CLR动态创建的机器代码的一小部分。存根用于实现接口,它们提供了灵活性,使得方法表中方法的方法顺序不必与接口方法的顺序相匹配。它们对代表来说很重要,这是这个问题的主题。存根对即时编译也很重要,存根中的初始代码指向抖动的入口点以获取在调用时编译的方法。之后更换存根,现在调用jitted目标方法。它是使静态方法调用更慢的存根,静态方法目标的存根比实例方法的存根更精细。

要查看存根,您必须调试调试器以强制它显示其代码。需要进行一些设置:首先使用工具&gt;选项&gt;调试&gt;一般。解开&#34; Just My Code&#34;复选框,取消勾选&#34;抑制JIT优化&#34;复选框。如果您使用VS2015,然后勾选&#34;使用托管兼容模式&#34;,VS2015调试器非常错误,并且认真对待此类调试,此选项通过强制VS2010托管调试器引擎提供解决方法用过的。切换到发布配置。然后项目&gt;属性&gt;调试,勾选&#34;启用本机代码调试&#34;复选框。和项目&gt;属性&gt;构建,取消选择&#34;首选32位&#34;复选框和&#34;平台目标&#34;应该是AnyCPU。

在Run()方法上设置断点,请注意断点在优化代码中不是很准确。在方法头上设置是最好的。一旦命中,请使用Debug&gt; Windows&gt;反汇编,查看抖动产生的机器代码。在Haswell核心上,委托调用看起来像这样,如果你有一个不支持AVX的旧处理器,可能与你看到的不一致:

                funcResult += _func.Invoke(1d, 2d);
0000001a  mov         rax,qword ptr [rsi+8]               ; rax = _func              
0000001e  mov         rcx,qword ptr [rax+8]               ; rcx = _func._methodBase (?)
00000022  vmovsd      xmm2,qword ptr [0000000000000070h]  ; arg3 = 2d
0000002b  vmovsd      xmm1,qword ptr [0000000000000078h]  ; arg2 = 1d
00000034  call        qword ptr [rax+18h]                 ; call stub

64位方法调用传递寄存器中的前4个参数,任何其他参数都通过堆栈传递(不在此处)。这里使用XMM寄存器,因为参数是浮点数。此时,抖动还不知道方法是静态的还是实例的,在该代码实际执行之前无法找到。存根的作用是隐藏差异。它假设它将是一个实例方法,这就是为什么我注释了arg2和arg3。

在CALL指令上设置断点,第二次命中(因此在存根不再指向抖动之后),您可以查看它。这必须手工完成,使用Debug&gt; Windows&gt;注册并复制RAX寄存器的值。调试&gt; Windows&gt;记忆&gt; Memory1并粘贴值,put&#34; 0x&#34;在它前面并添加0x18。右键单击该窗口并选择&#34; 8字节整数&#34;,复制第一个显示的值。这是存根代码的地址。

现在的诀窍是,此时托管调试引擎仍在使用,不允许您查看存根代码。您必须强制进行模式切换,以便控制非托管调试引擎。使用Debug&gt; Windows&gt;调用Stack并双击底部的方法调用,如RtlUserThreadStart。强制调试器切换引擎。现在你很高兴可以将地址粘贴到地址框中,放入&#34; 0x&#34;在它面前。 Out弹出存根代码:

  00007FFCE66D0100  jmp         00007FFCE66D0E40  

非常简单,直接跳转到委托目标方法。这将是快速代码。抖动在实例方法中正确猜测,并且委托对象已经在RCX寄存器中提供了this参数,因此不需要做任何特殊操作。

继续进行第二次测试并完成同样的事情来查看实例调用的存根。现在存根非常不同:

000001FE559F0850  mov         rax,rsp                 ; ?
000001FE559F0853  mov         r11,rcx                 ; r11 = _func (?)
000001FE559F0856  movaps      xmm0,xmm1               ; shuffle arg3 into right register
000001FE559F0859  movaps      xmm1,xmm2               ; shuffle arg2 into right register
000001FE559F085C  mov         r10,qword ptr [r11+20h] ; r10 = _func.Method 
000001FE559F0860  add         r11,20h                 ; ?
000001FE559F0864  jmp         r10                     ; jump to _func.Method

代码有点不稳定而且不是最优的,微软可能会在这里做得更好,而且我并不是100%确定我正确地注释了它。我想不必要的mov rax,rsp指令只与存根有超过4个参数的方法有关。不知道为什么添加指令是必要的。最重要的细节是XMM寄存器移动,它必须重新洗牌,因为静态方法没有this参数。正是这种重新调整的要求使代码变慢。

您可以使用x86抖动执行相同的练习,静态方法存根现在看起来像:

04F905B4  mov         eax,ecx  
04F905B6  add         eax,10h  
04F905B9  jmp         dword ptr [eax]      ; jump to _func.Method

比64位存根简单得多,这就是32位代码几乎没有受到减速影响的原因。其中一个非常不同的原因是32位代码通过FPU堆栈上的浮点并且它们不必重新洗牌。当你使用整数或对象参数时,这不一定会更快。

非常晦涩,希望我还没有让所有人都入睡。请注意,我可能会得到一些错误的注释,我不完全理解存根以及CLR烹饪委托对象成员以尽可能快地编写代码的方式。但这里肯定有不错的编程建议。你真的喜欢将实例方法作为委托目标,使static 进行优化。