C#为什么使用实例方法作为委托分配GC0临时对象,但比缓存委托快10%

V.B*_*.B. 7 .net c# performance inline

我目前正在优化一个低级库,并发现了一个反直觉的案例.导致此问题的提交就在这里.

有代表

public delegate void FragmentHandler(UnsafeBuffer buffer, int offset, int length, Header header);
Run Code Online (Sandbox Code Playgroud)

和一个实例方法

public void OnFragment(IDirectBuffer buffer, int offset, int length, Header header)
{
    _totalBytes.Set(_totalBytes.Get() + length);
}
Run Code Online (Sandbox Code Playgroud)

这一行中,如果我将该方法用作委托,程序会为临时委托包装器分配许多GC0,但性能提高10%(但不稳定).

var fragmentsRead = image.Poll(OnFragment, MessageCountLimit);
Run Code Online (Sandbox Code Playgroud)

如果我改为将方法缓存在循环外的委托中,如下所示:

FragmentHandler onFragmentHandler = OnFragment;
Run Code Online (Sandbox Code Playgroud)

然后程序根本没有分配,数字非常稳定,但速度要慢得多.

我查看了生成的IL并且它正在执行相同的操作,但在后一种情况下newobj只调用一次然后加载局部变量.

使用缓存的委托IL_0034:

IL_002d: ldarg.0
IL_002e: ldftn instance void Adaptive.Aeron.Samples.IpcThroughput.IpcThroughput/Subscriber::OnFragment(class [Adaptive.Agrona]Adaptive.Agrona.IDirectBuffer, int32, int32, class [Adaptive.Aeron]Adaptive.Aeron.LogBuffer.Header)
IL_0034: newobj instance void [Adaptive.Aeron]Adaptive.Aeron.LogBuffer.FragmentHandler::.ctor(object, native int)
IL_0039: stloc.3
IL_003a: br.s IL_005a
// loop start (head: IL_005a)
    IL_003c: ldloc.0
    IL_003d: ldloc.3
    IL_003e: ldsfld int32 Adaptive.Aeron.Samples.IpcThroughput.IpcThroughput::MessageCountLimit
    IL_0043: callvirt instance int32 [Adaptive.Aeron]Adaptive.Aeron.Image::Poll(class [Adaptive.Aeron]Adaptive.Aeron.LogBuffer.FragmentHandler, int32)
    IL_0048: stloc.s fragmentsRead
Run Code Online (Sandbox Code Playgroud)

使用临时分配IL_0037:

IL_002c: stloc.2
IL_002d: br.s IL_0058
// loop start (head: IL_0058)
    IL_002f: ldloc.0
    IL_0030: ldarg.0
    IL_0031: ldftn instance void Adaptive.Aeron.Samples.IpcThroughput.IpcThroughput/Subscriber::OnFragment(class [Adaptive.Agrona]Adaptive.Agrona.IDirectBuffer, int32, int32, class [Adaptive.Aeron]Adaptive.Aeron.LogBuffer.Header)
    IL_0037: newobj instance void [Adaptive.Aeron]Adaptive.Aeron.LogBuffer.FragmentHandler::.ctor(object, native int)
    IL_003c: ldsfld int32 Adaptive.Aeron.Samples.IpcThroughput.IpcThroughput::MessageCountLimit
    IL_0041: callvirt instance int32 [Adaptive.Aeron]Adaptive.Aeron.Image::Poll(class [Adaptive.Aeron]Adaptive.Aeron.LogBuffer.FragmentHandler, int32)
    IL_0046: stloc.s fragmentsRead
Run Code Online (Sandbox Code Playgroud)

为什么分配代码更快?需要什么来避免分配但保持性能?

(在两台不同的机器上测试.NET 4.5.2/4.6.1,x64,Release)

更新

这是独立的示例,其行为与预期相同:缓存委托的执行速度提高2倍以上,4秒对11秒.所以问题是特定于引用的项目 - JIT编译器或其他什么微妙的问题可能导致意外的结果?

using System;
using System.Diagnostics;

namespace TestCachedDelegate {

    public delegate int TestDelegate(int first, int second);

    public static class Program {
        static void Main(string[] args)
        {
            var tc = new TestClass();
            tc.Run();
        }

        public class TestClass {

            public void Run() {
                var sw = new Stopwatch();
                sw.Restart();
                for (int i = 0; i < 1000000000; i++) {
                    CallDelegate(Add, i, i);
                }
                sw.Stop();
                Console.WriteLine("Non-cached: " + sw.ElapsedMilliseconds);
                sw.Restart();
                TestDelegate dlgCached = Add;
                for (int i = 0; i < 1000000000; i++) {
                    CallDelegate(dlgCached, i, i);
                }
                sw.Stop();
                Console.WriteLine("Cached: " + sw.ElapsedMilliseconds);
                Console.ReadLine();
            }

            public int CallDelegate(TestDelegate dlg, int first, int second) {
                return dlg(first, second);
            }

            public int Add(int first, int second) {
                return first + second;
            }

        }
    }
}
Run Code Online (Sandbox Code Playgroud)

Slu*_*art 2

因此,在太快地阅读问题并认为它在问其他问题之后,我终于有时间坐下来玩有问题的 Aeoron 测试。

我尝试了一些事情,首先我比较了产生的IL和Assembler,发现无论是我们调用的站点Poll()还是实际调用处理程序的站点基本上没有区别。

其次,我尝试注释掉Poll()方法中的代码,以确认缓存版本实际上运行得更快(确实如此)。

第三,我尝试在 VS 分析器中查看 CPU 计数器(缓存未命中、指令退休和分支错误预测),但除了委托构造函数显然被调用更多次这一事实之外,看不到两个版本之间的任何差异。

这让我想到了我们在移植Disruptor-net时遇到的类似情况,其中我们进行了一个运行速度比 java 版本慢的测试,但我们确信我们没有做任何成本更高的事情。测试“缓慢”的原因是我们实际上速度更快,因此批处理较少,因此我们的吞吐量较低。

如果您在调用之前插入 Thread.SpinWait(5),Poll()您将看到与非缓存版本相同或更好的性能。

我当时认为的问题的原始答案是“为什么使用实例方法委托比手动缓存委托慢”:

线索就在问题里。它是一个实例方法,因此它隐式捕获该this成员,并且捕获该成员的事实意味着它无法被缓存。鉴于this在缓存委托的生命周期内永远不会改变,它应该是可缓存的。

如果扩大方法组,(first, second) => this.Add(first, second)捕获就变得更加明显。

请注意,Roslyn 团队正在努力解决此问题:https ://github.com/dotnet/roslyn/issues/5835