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

vc *_* 74 2 .net c# 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);
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

Han*_*ant 7

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

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

知道速度慢的原因需要查看抖动产生的机器代码.在代理的情况下,该代码非常隐蔽.使用Debug> Windows> Disassembly查看代码时,您将看不到它.而你甚至无法单步执行代码,编写托管调试程序来隐藏它并完全拒绝显示它.我将不得不描述一种将"视觉"重新放回Visual Studio的技术.

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


要查看存根,您必须调试调试器以强制它显示其代码.需要进行一些设置:首先使用工具>选项>调试>常规.取消选中"Just My Code"复选框,取消选中"Suppress JIT optimization"复选框.如果您使用VS2015然后勾选"使用托管兼容模式",则VS2015调试器非常错误并且严重妨碍此类调试,此选项通过强制使用VS2010托管调试器引擎来提供解决方法.切换到发布配置.然后选择Project> Properties> Debug,勾选"启用本机代码调试"复选框.和Project> Properties> Build,取消选中"Prefer 32-bit"复选框,"Platform target"应为AnyCPU.

在Run()方法上设置断点,请注意断点在优化代码中不是很准确.在方法头上设置是最好的.一旦命中,使用Debug> Windows> Disassembly查看抖动生成的机器代码.在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
Run Code Online (Sandbox Code Playgroud)

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

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

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

  00007FFCE66D0100  jmp         00007FFCE66D0E40  
Run Code Online (Sandbox Code Playgroud)

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

继续第二个测试并执行完全相同的操作来查看实例调用的存根.现在存根非常不同:

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
Run Code Online (Sandbox Code Playgroud)

代码有点不稳定而且不是最优的,微软可能会在这里做得更好,而且我并不是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
Run Code Online (Sandbox Code Playgroud)

比64位存根简单得多,这就是为什么32位代码几乎不会受到减速的影响.它之所以如此不同的一个原因是32位代码在FPU堆栈上传递浮点并且它们不必重新洗牌.使用整数或对象参数时,这不一定会更快.


非常晦涩,希望我没有让所有人都入睡.注意我可能有一些注释错误,我不完全理解存根以及CLR烹饪委托对象成员以尽可能快地编写代码的方式.但这里肯定有不错的编程建议.你真的有利于实例方法为代表的目标,使它们static不是一种优化.