编译委托为什么比声明委托更快?

10

首先,这与为什么从Expression<>创建的Func<>比直接声明的Func<>慢不同,它出人意料地恰好相反。此外,我在研究这个问题时发现所有链接和问题都源自2010-2012年,因此我决定在这里开一个新问题,看看是否有关于.NET生态系统中委托行为的讨论。

话虽如此,我正在使用.NET Core 2.0和.NET 4.7.1,并且在涉及编译表达式生成的委托与作为CLR对象描述和声明的委托之间存在一些有趣的性能指标差异。

为了介绍我如何发现这个问题,我进行了一个包含1,000和10,000个对象数组的数据选择测试,并注意到如果我使用编译表达式,则在所有测试中结果都更快。我设法将其简化为一个非常简单的项目,可以在此处找到:

https://github.com/Mike-EEE/StackOverflow.Performance.Delegates

对于测试,我使用了两组基准测试,每个基准测试包括一个编译的委托和一个声明的委托,共计四个核心基准测试。

第一组委托由一个返回null字符串的空委托组成。第二组是具有简单表达式的委托。我想证明,这个问题不仅出现在最简单的委托中,而且还会在其中包含定义体的委托中发生。

然后,在CLR运行时和.NET Core运行时上运行这些测试,使用优秀的Benchmark.NET性能产品进行测试,从而产生了八个基准测试。此外,我还使用同样出色的Benchmark.NET反汇编诊断工具来发出在基准测量期间遇到的JIT过程中的反汇编指令。下面是其结果。

以下是运行基准测试的代码。您可以看到它非常简单:

[CoreJob, ClrJob, DisassemblyDiagnoser(true, printSource: true)]
public class Delegates
{
    readonly DelegatePair<string, string> _empty;
    readonly DelegatePair<string, int>    _expression;
    readonly string                       _message;

    public Delegates() : this(new DelegatePair<string, string>(_ => default, _ => default),
                              new DelegatePair<string, int>(x => x.Length, x => x.Length)) {}

    public Delegates(DelegatePair<string, string> empty, DelegatePair<string, int> expression,
                     string message = "Hello World!")
    {
        _empty      = empty;
        _expression = expression;
        _message    = message;
        EmptyDeclared();
        EmptyCompiled();
        ExpressionDeclared();
        ExpressionCompiled();
    }

    [Benchmark]
    public void EmptyDeclared() => _empty.Declared(default);

    [Benchmark]
    public void EmptyCompiled() => _empty.Compiled(default);

    [Benchmark]
    public void ExpressionDeclared() => _expression.Declared(_message);

    [Benchmark]
    public void ExpressionCompiled() => _expression.Compiled(_message);
}

这些是我在 Benchmark.NET 中看到的结果:

BenchmarkDotNet=v0.10.14, OS=Windows 10.0.16299.371 (1709/FallCreatorsUpdate/Redstone3)
Intel Core i7-4820K CPU 3.70GHz (Haswell), 1 CPU, 8 logical and 8 physical cores
.NET Core SDK=2.1.300-preview2-008533
  [Host] : .NET Core 2.0.7 (CoreCLR 4.6.26328.01, CoreFX 4.6.26403.03), 64bit RyuJIT
  Clr    : .NET Framework 4.7.1 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.2633.0
  Core   : .NET Core 2.0.7 (CoreCLR 4.6.26328.01, CoreFX 4.6.26403.03), 64bit RyuJIT


             Method |  Job | Runtime |      Mean |     Error |    StdDev |
------------------- |----- |-------- |----------:|----------:|----------:|
      EmptyDeclared |  Clr |     Clr | 1.3691 ns | 0.0302 ns | 0.0282 ns |
      EmptyCompiled |  Clr |     Clr | 1.1851 ns | 0.0381 ns | 0.0357 ns |
 ExpressionDeclared |  Clr |     Clr | 1.3805 ns | 0.0314 ns | 0.0294 ns |
 ExpressionCompiled |  Clr |     Clr | 1.1431 ns | 0.0396 ns | 0.0371 ns |
      EmptyDeclared | Core |    Core | 1.5733 ns | 0.0329 ns | 0.0308 ns |
      EmptyCompiled | Core |    Core | 0.9326 ns | 0.0275 ns | 0.0244 ns |
 ExpressionDeclared | Core |    Core | 1.6040 ns | 0.0394 ns | 0.0368 ns |
 ExpressionCompiled | Core |    Core | 0.9380 ns | 0.0485 ns | 0.0631 ns |

请注意,利用已编译的委托进行基准测试的结果始终更快。

最后,以下是遇到每个基准测试时遇到的反汇编结果:

<style type="text/css">
 table { border-collapse: collapse; display: block; width: 100%; overflow: auto; }
 td, th { padding: 6px 13px; border: 1px solid #ddd; }
 tr { background-color: #fff; border-top: 1px solid #ccc; }
 tr:nth-child(even) { background: #f8f8f8; }
</style>
</head>
<body>
<table>
<thead>
<tr><th colspan="2">Delegates.EmptyDeclared</th></tr>
<tr>
<th>.NET Framework 4.7.1 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.2633.0</th>
<th>.NET Core 2.0.7 (CoreCLR 4.6.26328.01, CoreFX 4.6.26403.03), 64bit RyuJIT</th>
</tr>
</thead>
<tbody>
<tr>
<td style="vertical-align:top;"><pre><code>
00007ffd`4f8f0ea0 StackOverflow.Performance.Delegates.Delegates.EmptyDeclared()
  public void EmptyDeclared() => _empty.Declared(default);
                                 ^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`4f8f0ea4 4883c110        add     rcx,10h
00007ffd`4f8f0ea8 488b01          mov     rax,qword ptr [rcx]
00007ffd`4f8f0eab 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`4f8f0eaf 33d2            xor     edx,edx
00007ffd`4f8f0eb1 ff5018          call    qword ptr [rax+18h]
00007ffd`4f8f0eb4 90              nop

</code></pre></td>
<td style="vertical-align:top;"><pre><code>
00007ffd`39c8d8b0 StackOverflow.Performance.Delegates.Delegates.EmptyDeclared()
  public void EmptyDeclared() => _empty.Declared(default);
                                 ^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`39c8d8b4 4883c110        add     rcx,10h
00007ffd`39c8d8b8 488b01          mov     rax,qword ptr [rcx]
00007ffd`39c8d8bb 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`39c8d8bf 33d2            xor     edx,edx
00007ffd`39c8d8c1 ff5018          call    qword ptr [rax+18h]
00007ffd`39c8d8c4 90              nop

</code></pre></td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr><th colspan="2">Delegates.EmptyCompiled</th></tr>
<tr>
<th>.NET Framework 4.7.1 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.2633.0</th>
<th>.NET Core 2.0.7 (CoreCLR 4.6.26328.01, CoreFX 4.6.26403.03), 64bit RyuJIT</th>
</tr>
</thead>
<tbody>
<tr>
<td style="vertical-align:top;"><pre><code>
00007ffd`4f8e0ef0 StackOverflow.Performance.Delegates.Delegates.EmptyCompiled()
  public void EmptyCompiled() => _empty.Compiled(default);
                                 ^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`4f8e0ef4 4883c110        add     rcx,10h
00007ffd`4f8e0ef8 488b4108        mov     rax,qword ptr [rcx+8]
00007ffd`4f8e0efc 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`4f8e0f00 33d2            xor     edx,edx
00007ffd`4f8e0f02 ff5018          call    qword ptr [rax+18h]
00007ffd`4f8e0f05 90              nop

</code></pre></td>
<td style="vertical-align:top;"><pre><code>
00007ffd`39c8d900 StackOverflow.Performance.Delegates.Delegates.EmptyCompiled()
  public void EmptyCompiled() => _empty.Compiled(default);
                                 ^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`39c8d904 4883c110        add     rcx,10h
00007ffd`39c8d908 488b4108        mov     rax,qword ptr [rcx+8]
00007ffd`39c8d90c 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`39c8d910 33d2            xor     edx,edx
00007ffd`39c8d912 ff5018          call    qword ptr [rax+18h]
00007ffd`39c8d915 90              nop

</code></pre></td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr><th colspan="2">Delegates.ExpressionDeclared</th></tr>
<tr>
<th>.NET Framework 4.7.1 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.2633.0</th>
<th>.NET Core 2.0.7 (CoreCLR 4.6.26328.01, CoreFX 4.6.26403.03), 64bit RyuJIT</th>
</tr>
</thead>
<tbody>
<tr>
<td style="vertical-align:top;"><pre><code>
00007ffd`4f8e0f20 StackOverflow.Performance.Delegates.Delegates.ExpressionDeclared()
  public void ExpressionDeclared() => _expression.Declared(_message);
                                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`4f8e0f24 488d5120        lea     rdx,[rcx+20h]
00007ffd`4f8e0f28 488b02          mov     rax,qword ptr [rdx]
00007ffd`4f8e0f2b 488b5108        mov     rdx,qword ptr [rcx+8]
00007ffd`4f8e0f2f 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`4f8e0f33 ff5018          call    qword ptr [rax+18h]
00007ffd`4f8e0f36 90              nop

</code></pre></td>
<td style="vertical-align:top;"><pre><code>
00007ffd`39c9d930 StackOverflow.Performance.Delegates.Delegates.ExpressionDeclared()
  public void ExpressionDeclared() => _expression.Declared(_message);
                                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`39c9d934 488d5120        lea     rdx,[rcx+20h]
00007ffd`39c9d938 488b02          mov     rax,qword ptr [rdx]
00007ffd`39c9d93b 488b5108        mov     rdx,qword ptr [rcx+8]
00007ffd`39c9d93f 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`39c9d943 ff5018          call    qword ptr [rax+18h]
00007ffd`39c9d946 90              nop

</code></pre></td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr><th colspan="2">Delegates.ExpressionCompiled</th></tr>
<tr>
<th>.NET Framework 4.7.1 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.2633.0</th>
<th>.NET Core 2.0.7 (CoreCLR 4.6.26328.01, CoreFX 4.6.26403.03), 64bit RyuJIT</th>
</tr>
</thead>
<tbody>
<tr>
<td style="vertical-align:top;"><pre><code>
00007ffd`4f8f0f70 StackOverflow.Performance.Delegates.Delegates.ExpressionCompiled()
  public void ExpressionCompiled() => _expression.Compiled(_message);
                                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`4f8f0f74 488d5120        lea     rdx,[rcx+20h]
00007ffd`4f8f0f78 488b4208        mov     rax,qword ptr [rdx+8]
00007ffd`4f8f0f7c 488b5108        mov     rdx,qword ptr [rcx+8]
00007ffd`4f8f0f80 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`4f8f0f84 ff5018          call    qword ptr [rax+18h]
00007ffd`4f8f0f87 90              nop

</code></pre></td>
<td style="vertical-align:top;"><pre><code>
00007ffd`39c9d980 StackOverflow.Performance.Delegates.Delegates.ExpressionCompiled()
  public void ExpressionCompiled() => _expression.Compiled(_message);
                                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`39c9d984 488d5120        lea     rdx,[rcx+20h]
00007ffd`39c9d988 488b4208        mov     rax,qword ptr [rdx+8]
00007ffd`39c9d98c 488b5108        mov     rdx,qword ptr [rcx+8]
00007ffd`39c9d990 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`39c9d994 ff5018          call    qword ptr [rax+18h]
00007ffd`39c9d997 90              nop

</code></pre></td>
</tr>
</tbody>
</table>

看起来,声明的委托与编译的委托反汇编之间唯一的区别是它们各自的第一个 mov 操作中使用的 rcx vs. rcx+8。我在反汇编方面还不太善于言辞,所以非常感谢能够提供相关背景知识。乍一看,这似乎不会导致差异/改进,如果是这样,本机声明的委托也应该具有相同特性(换句话说,是个漏洞)。

总结一下,对我而言,显而易见的问题是:

  1. 这是一个已知的问题和/或漏洞吗?
  2. 我在这里完全偏离了轨道吗?(猜测这应该是第一个问题。:))
  3. 那么,指导意见是尽可能始终使用编译的委托吗?正如我之前提到的,似乎已经在声明的委托中嵌入了编译委托中发生的魔法,因此这有点令人困惑。

为了完整起见,这里是本示例中使用的所有代码:

sealed class Program
{
    static void Main()
    {
        BenchmarkRunner.Run<Delegates>();
    }
}

[CoreJob, ClrJob, DisassemblyDiagnoser(true, printSource: true)]
public class Delegates
{
    readonly DelegatePair<string, string> _empty;
    readonly DelegatePair<string, int>    _expression;
    readonly string                       _message;

    public Delegates() : this(new DelegatePair<string, string>(_ => default, _ => default),
                              new DelegatePair<string, int>(x => x.Length, x => x.Length)) {}

    public Delegates(DelegatePair<string, string> empty, DelegatePair<string, int> expression,
                     string message = "Hello World!")
    {
        _empty      = empty;
        _expression = expression;
        _message    = message;
        EmptyDeclared();
        EmptyCompiled();
        ExpressionDeclared();
        ExpressionCompiled();
    }

    [Benchmark]
    public void EmptyDeclared() => _empty.Declared(default);

    [Benchmark]
    public void EmptyCompiled() => _empty.Compiled(default);

    [Benchmark]
    public void ExpressionDeclared() => _expression.Declared(_message);

    [Benchmark]
    public void ExpressionCompiled() => _expression.Compiled(_message);
}

public struct DelegatePair<TFrom, TTo>
{
    DelegatePair(Func<TFrom, TTo> declared, Func<TFrom, TTo> compiled)
    {
        Declared = declared;
        Compiled = compiled;
    }

    public DelegatePair(Func<TFrom, TTo> declared, Expression<Func<TFrom, TTo>> expression) :
        this(declared, expression.Compile()) {}

    public Func<TFrom, TTo> Declared { get; }

    public Func<TFrom, TTo> Compiled { get; }
}

非常感谢您提供的任何帮助!


也许 expression.Compile() 返回的委托分配了一个比 declared 分配的更方便的内存位置,以便将该委托加载到堆栈并调用所需的时间更少。 - Bob Dust
那是一个好的理论,@BobDust。在.NET中是否可能实现呢?也就是说,是否可以将对象放置在首选位置?作为VIP堆? :) 我确实在LambdaExpression.Compile方法中进行了一些检查,唯一找到的是有一个extern方法调用Delegate.InternalAlloc,它返回一个MulticastDelegate。由于它是extern,所以无法知道该值如何在外部存储,因此您可能会发现一些东西。但我从未听说过首选堆。欢迎提供相关资源/链接。 :) - Mike-E
在像这样的紧密循环基准测试情况下,我希望每个委托在被基准测试时都能保留在L1/指令缓存中。它在物理内存中的位置不应该有影响,因为在基准测试运行时,它不太可能从缓存中被驱逐出去。 - Mike Strobel
1个回答

12

这里可能存在一些误解,你看到的反汇编代码只是用于基准测试方法的: 加载委托及其参数所需的指令,然后调用该委托。它不包括每个委托的主体。

这就是为什么唯一的区别在于一个mov指令中的相对偏移量:其中一个委托位于结构体的偏移量0处,而另一个委托位于偏移量8处。交换CompiledDeclared的声明顺序,看看反汇编如何改变。

我不知道有没有办法让Benchmark.NET输出更深层次调用的反汇编代码。文档建议在[DisassemblyDiagnoser]上设置recursiveDepth为某个值n>1,但在这种情况下似乎无效。


是的,您看不到委托主体的反汇编代码。如果它们的编译方式有所不同,那么差异将会显现在这里。

委托主体并不一定相同。对于基于Expression的lambda表达式,C#编译器不会发出描述的表达式的IL;相反,它会发出一系列Expression工厂调用,以在运行时构造表达式树。该表达式树描述的代码应该与生成它的C#表达式在功能上等效,但它是由LambdaCompiler在调用Compile()时在运行时编译的。LINQ表达式树旨在与任何语言兼容,并且不一定与C#编译器生成的表达式完全相同。因为lambda表达式由不同(且较不成熟)的编译器编译,所以生成的IL可能与C#编译器生成的有些不同。例如,lambda编译器倾向于生成比C#编译器更多的临时局部变量,或者至少在我最后一次查看源代码时是这样。

确定每个委托实际反汇编的最佳方法可能是在调试器中加载SOS.dll。我尝试过自己做到这一点,但似乎无法在VS2017中使其正常工作。过去我从来没有遇到过问题。我还没有完全适应VS2017中的新项目模型,无法弄清楚如何启用非托管调试。


好的,我已经用WinDbg加载了SOS.dll,并经过一些谷歌搜索,现在能够查看IL和反汇编代码了。首先,让我们看一下lambda主体的方法描述符。这是Declared版本:


0:000> !DumpMD 000007fe97686148

Method Name:  StackOverflow.Performance.Delegates.Delegates+<>c.<.ctor>b__3_2(System.String)
Class:        000007fe977d14d0
MethodTable:  000007fe97686158
mdToken:      000000000600000e
Module:       000007fe976840c0
IsJitted:     yes
CodeAddr:     000007fe977912b0
Transparency: Critical

以下是编译后的版本:

0:000> !DumpMD 000007fe97689390

Method Name:  DynamicClass.lambda_method(System.Runtime.CompilerServices.Closure, System.String)
Class:        000007fe97689270
MethodTable:  000007fe976892e8
mdToken:      0000000006000000
Module:       000007fe97688af8
IsJitted:     yes
CodeAddr:     000007fe977e0150
Transparency: Transparent
我们可以转储IL并查看它实际上是相同的:

我们可以转储IL并查看它实际上是相同的:

0:000> !DumpIL 000007fe97686148

IL_0000: ldarg.1 
IL_0001: callvirt 6000002 System.String.get_Length()
IL_0006: ret 

0:000> !DumpIL 000007fe97689390

IL_0000: ldarg.1 
IL_0001: callvirt System.String::get_Length 
IL_0006: ret

同样的,解体也是如此:

0:000> !U 000007fe977912b0

Normal JIT generated code
StackOverflow.Performance.Delegates.Delegates+<>c.<.ctor>b__3_2(System.String)
Begin 000007fe977912b0, size 4
W:\dump\DelegateBenchmark\StackOverflow.Performance.Delegates\Delegates.cs @ 14:

000007fe`977912b0 8b4208          mov     eax,dword ptr [rdx+8]
000007fe`977912b3 c3              ret

0:000> !U 000007fe977e0150

Normal JIT generated code
DynamicClass.lambda_method(System.Runtime.CompilerServices.Closure, System.String)
Begin 000007fe977e0150, size 4

000007fe`977e0150 8b4208          mov     eax,dword ptr [rdx+8]
000007fe`977e0153 c3              ret

因此,我们具有相同的IL和相同的程序集。差异来自哪里?让我们看一下实际的委托实例。我指的不是 lambda 主体,而是我们用于调用 lambda 的Delegate对象。

0:000> !DumpVC /d 000007fe97686040 0000000002a84410

Name:        StackOverflow.Performance.Delegates.DelegatePair`2[[System.String, mscorlib],[System.Int32, mscorlib]]
MethodTable: 000007fe97686040
EEClass:     000007fe977d12d0
Size:        32(0x20) bytes
File:        W:\dump\DelegateBenchmark\StackOverflow.Performance.Delegates\bin\Release\net461\StackOverflow.Performance.Delegates.exe
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
000007fef692e400  4000001        0 ...Int32, mscorlib]]  0 instance 0000000002a8b4d8 <Declared>k__BackingField
000007fef692e400  4000002        8 ...Int32, mscorlib]]  0 instance 0000000002a8d3f8 <Compiled>k__BackingField
我们有两个委托值:在我的情况下,Declared 存在于 02a8b4d8,而 Compiled 存在于 02a8d3f8(这些地址是属于我的进程的)。如果我们使用 !DumpObject 命令转储这些地址,并查找 _methodPtr 值,我们可以看到编译方法的地址。然后我们可以使用 !U 命令来转储程序集:
0:000> !U 7fe977e0150 

Normal JIT generated code
DynamicClass.lambda_method(System.Runtime.CompilerServices.Closure, System.String)
Begin 000007fe977e0150, size 4

000007fe`977e0150 8b4208          mov     eax,dword ptr [rdx+8]
000007fe`977e0153 c3              ret

好的,对于编译后的版本,我们可以看到我们直接调用了 lambda 函数体。很不错。但是当我们转储 声明的版本的反汇编代码时,我们会看到一些不同之处:

0:000> !U 7fe977901d8 

Unmanaged code

000007fe`977901d8 e8f326635f      call    clr!PrecodeFixupThunk (000007fe`f6dc28d0)
000007fe`977901dd 5e              pop     rsi
000007fe`977901de 0400            add     al,0
000007fe`977901e0 286168          sub     byte ptr [rcx+68h],ah
000007fe`977901e3 97              xchg    eax,edi
000007fe`977901e4 fe07            inc     byte ptr [rdi]
000007fe`977901e6 0000            add     byte ptr [rax],al
000007fe`977901e8 0000            add     byte ptr [rax],al
000007fe`977901ea 0000            add     byte ptr [rax],al
000007fe`977901ec 0000            add     byte ptr [rax],al

你好。我记得在Matt Warren的博客文章中看到了对clr!PrecodeFixupThunk的引用。我的理解是,一个普通的IL方法(而不是像我们的基于LINQ的动态方法)的入口调用一个修复方法,在第一次调用时调用JIT,然后在后续调用中调用JITed方法。在调用“声明”委托时产生的额外开销似乎是原因。编译的委托没有这样的thunk;委托直接指向编译的lambda体。


嗨,Mike,我在我的答案中尝试回答了你的后续问题。 - Mike Strobel
太棒了!这非常有启发性和深刻。感谢您提供的背景信息。我现在给它点赞。不想挑剔,但在将其标记为答案之前,我希望对您的理论/怀疑百分之百确定。希望您能理解。 :) 我会研究一下 SOS 并尝试让它工作。否则,如果您可以确定地验证(并演示差异的输出),那将是我的首选。我主要关心的是,您说它是一个不太复杂的编译器,但它比 .NET Core 2.0 编译器更快。 - Mike-E
非常感谢你,Mike(嘿,好名字!)!你的努力真是太出色了。如果我能把这个标记为答案两次,我一定会的。:)非常棒! - Mike-E
1
非常愉快。我学到了一些有趣的东西。我希望我知道为什么声明版本有thunk而动态版本没有。如果我找到了答案,我会更新我的回答。 - Mike Strobel
1
有趣且相关的内容:(1) http://mattwarren.org/2017/01/25/How-do-.NET-delegates-work/ 和 (2) https://github.com/dotnet/coreclr/blob/master/Documentation/botr/method-descriptor.md#precode - Mike Strobel
显示剩余2条评论

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