我目前正在优化一个低级库,发现了一个违反直觉的案例。导致这个问题的提交是 here .
有一个代表
public delegate void FragmentHandler(UnsafeBuffer buffer, int offset, int length, Header header);
和一个实例方法
public void OnFragment(IDirectBuffer buffer, int offset, int length, Header header)
{
_totalBytes.Set(_totalBytes.Get() + length);
}
关于 this line ,如果我将该方法用作委托(delegate),程序会为临时委托(delegate)包装器分配许多 GC0,但性能会快 10%(但不稳定)。
var fragmentsRead = image.Poll(OnFragment, MessageCountLimit);
如果我改为将方法缓存在循环外的委托(delegate)中,如下所示:
FragmentHandler onFragmentHandler = OnFragment;
然后程序根本不分配,数字非常稳定但速度慢得多。
我查看了生成的 IL,它正在做同样的事情,但在后一种情况下,newobj
只被调用一次,如果加载则调用局部变量。
使用缓存委托(delegate) 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
使用临时分配 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
为什么带分配的代码在这里更快?需要什么来避免分配但保持性能?
(在两台不同的机器上测试 .NET 4.5.2/4.6.1,x64,Release)
更新
这是一个按预期运行的独立示例:缓存委托(delegate)的执行速度提高了 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;
}
}
}
}
最佳答案
因此,在过快地阅读问题并认为它在问其他问题之后,我终于有时间坐下来玩一下有问题的 Aeoron 测试。
我尝试了一些东西,首先我比较了生成的 IL 和汇编程序,发现无论是在我们调用 Poll()
的地方还是在实际上调用了处理程序。
其次,我尝试注释掉 Poll()
方法中的代码,以确认缓存版本确实运行得更快(确实如此)。
第三,我尝试查看 VS 分析器中的 CPU 计数器(缓存未命中、指令失效和分支预测错误),但除了委托(delegate)构造函数显然被调用多次这一事实外,我看不出两个版本之间的任何差异.
这让我想到了我们在移植过程中遇到的一个类似案例 Disruptor-net我们的测试运行速度比 Java 版本慢,但我们确信我们没有做任何更昂贵的事情。测试“缓慢”的原因是我们实际上更快,因此批处理更少,因此我们的吞吐量更低。
如果您在调用 Poll()
之前插入 Thread.SpinWait(5),您将看到与非缓存版本相同或更好的性能。
我当时认为的问题的原始答案是“为什么使用实例方法委托(delegate)比手动缓存委托(delegate)慢”:
线索就在问题中。它是一个实例方法,因此它隐式地捕获了 this
成员,而 this 被捕获的事实意味着它不能被缓存。鉴于 this
在缓存委托(delegate)的生命周期内永远不会改变,它应该是可缓存的。
如果将方法组扩展为 (first, second) => this.Add(first, second)
,捕获将变得更加明显。
请注意,Roslyn 团队正在努力解决此问题:https://github.com/dotnet/roslyn/issues/5835
关于C# 为什么使用实例方法作为委托(delegate)分配 GC0 临时对象但比缓存委托(delegate)快 10%,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/37372719/