struct元组的性能

Jon*_*rop 20 c# performance f# tuples algebraic-data-types

下面的F#程序定义了一个函数,该函数返回表示为struct tuples的两对int中的较小者,并且运行需要1.4s:

let [<EntryPoint>] main _ =
  let min a b : int = if a < b then a else b
  let min (struct(a1, b1)) (struct(a2, b2)) = struct(min a1 a2, min b1 b2)
  let mutable x = struct(0, 0)
  for i in 1..100000000 do
    x <- min x (struct(i, i))
  0
Run Code Online (Sandbox Code Playgroud)

如果我将CIL反编译为C#,我会得到以下代码:

    public static int MinInt(int a, int b)
    {
        if (a < b)
        {
            return a;
        }
        return b;
    }

    public static System.ValueTuple<int, int> MinPair(System.ValueTuple<int, int> _arg2, System.ValueTuple<int, int> _arg1)
    {
        int b = _arg2.Item2;
        int a = _arg2.Item1;
        int b2 = _arg1.Item2;
        int a2 = _arg1.Item1;
        return new System.ValueTuple<int, int>(MinInt(a, a2), MinInt(b, b2));
    }

    public static void Main(string[] args)
    {
        System.ValueTuple<int, int> x = new System.ValueTuple<int, int>(0, 0);
        for (int i = 1; i <= 100000000; i++)
        {
            x = MinPair(x, new System.ValueTuple<int, int>(i, i));
        }
    }
Run Code Online (Sandbox Code Playgroud)

使用C#编译器重新编译它只需0.3秒,比原始F#快4倍.

我不明白为什么一个程序比另一个程序快得多.我甚至将这两个版本反编译为CIL,看不出任何明显的原因.Min从F#调用C#函数会产生相同(差)的性能.调用者内环的CIL字面上完全相同.

谁能解释这种实质性的差异?

Jus*_*mer 9

您是否在同一架构中运行这两个示例.对于F#和C#代码,我在x64上获得~1.4秒,对于F#,在x86上获得~0.6sec,对于C#,在x86上获得~0.3sec.

正如您所说,在反编译程序集时,代码看起来非常相似,但在检查IL代码时会出现一些异议:

F# - let min (struct(a1, b1)) (struct(a2, b2)) ...

.maxstack 5
.locals init (
  [0] int32 b1,
  [1] int32 a1,
  [2] int32 b2,
  [3] int32 a2
)

IL_0000: ldarga.s _arg2
IL_0002: ldfld !1 valuetype [System.ValueTuple]System.ValueTuple`2<int32, int32>::Item2
IL_0007: stloc.0
IL_0008: ldarga.s _arg2
IL_000a: ldfld !0 valuetype [System.ValueTuple]System.ValueTuple`2<int32, int32>::Item1
IL_000f: stloc.1
IL_0010: ldarga.s _arg1
IL_0012: ldfld !1 valuetype [System.ValueTuple]System.ValueTuple`2<int32, int32>::Item2
IL_0017: stloc.2
IL_0018: ldarga.s _arg1
IL_001a: ldfld !0 valuetype [System.ValueTuple]System.ValueTuple`2<int32, int32>::Item1
IL_001f: stloc.3
IL_0020: nop
IL_0021: ldloc.1
IL_0022: ldloc.3
IL_0023: call int32 Program::min@8(int32, int32)
IL_0028: ldloc.0
IL_0029: ldloc.2
IL_002a: call int32 Program::min@8(int32, int32)
IL_002f: newobj instance void valuetype [System.ValueTuple]System.ValueTuple`2<int32, int32>::.ctor(!0, !1)
IL_0034: ret
Run Code Online (Sandbox Code Playgroud)

C# - MinPair

.maxstack 3
.locals init (
  [0] int32 b,
  [1] int32 b2,
  [2] int32 a2
)

IL_0000: ldarg.0
IL_0001: ldfld !1 valuetype [System.ValueTuple]System.ValueTuple`2<int32, int32>::Item2
IL_0006: stloc.0
IL_0007: ldarg.0
IL_0008: ldfld !0 valuetype [System.ValueTuple]System.ValueTuple`2<int32, int32>::Item1
IL_000d: ldarg.1
IL_000e: ldfld !1 valuetype [System.ValueTuple]System.ValueTuple`2<int32, int32>::Item2
IL_0013: stloc.1
IL_0014: ldarg.1
IL_0015: ldfld !0 valuetype [System.ValueTuple]System.ValueTuple`2<int32, int32>::Item1
IL_001a: stloc.2
IL_001b: ldloc.2
IL_001c: call int32 PerfItCs.Program::MinInt(int32, int32)
IL_0021: ldloc.0
IL_0022: ldloc.1
IL_0023: call int32 PerfItCs.Program::MinInt(int32, int32)
IL_0028: newobj instance void valuetype [System.ValueTuple]System.ValueTuple`2<int32, int32>::.ctor(!0, !1)
IL_002d: ret
Run Code Online (Sandbox Code Playgroud)

这里的区别在于C#编译器通过在堆栈上推送中间结果来避免引入一些局部变量.无论如何,当局部变量在堆栈上分配时,很难理解为什么这会导致更高效的代码.

其他功能非常相似.

拆解x86会产生这样的结果:

F# - 循环

; F#
; struct (i, i) 
01690a7e 8bce            mov     ecx,esi
01690a80 8bd6            mov     edx,esi
; Loads x (pair) onto stack
01690a82 8d45f0          lea     eax,[ebp-10h]
01690a85 83ec08          sub     esp,8
01690a88 f30f7e00        movq    xmm0,mmword ptr [eax]
01690a8c 660fd60424      movq    mmword ptr [esp],xmm0
; Push new tuple on stack
01690a91 52              push    edx
01690a92 51              push    ecx
; Loads pointer to x into ecx (result will be written here)
01690a93 8d4df0          lea     ecx,[ebp-10h]
; Call min
01690a96 ff15744dfe00    call    dword ptr ds:[0FE4D74h]
; Increase i
01690a9c 46              inc     esi
01690a9d 81fe01e1f505    cmp     esi,offset FSharp_Core_ni+0x6be101 (05f5e101)
; Reached the end?
01690aa3 7cd9            jl      01690a7e
Run Code Online (Sandbox Code Playgroud)

C# - 循环

; C#
; Loads x (pair) into ecx, eax
02c2057b 8d55ec          lea     edx,[ebp-14h]
02c2057e 8b0a            mov     ecx,dword ptr [edx]
02c20580 8b4204          mov     eax,dword ptr [edx+4]
; new System.ValueTuple<int, int>(i, i) 
02c20583 8bfe            mov     edi,esi
02c20585 8bd6            mov     edx,esi
; Push x on stack
02c20587 50              push    eax
02c20588 51              push    ecx
; Push new tuple on stack
02c20589 52              push    edx
02c2058a 57              push    edi
; Loads pointer to x into ecx (result will be written here)
02c2058b 8d4dec          lea     ecx,[ebp-14h]
; Call MinPair
02c2058e ff15104d2401    call    dword ptr ds:[1244D10h]
; Increase i
02c20594 46              inc     esi
; Reached the end?
02c20595 81fe00e1f505    cmp     esi,5F5E100h
02c2059b 7ede            jle     02c2057b
Run Code Online (Sandbox Code Playgroud)

很难理解为什么F#代码在这里应该表现得更差.代码看起来大致相当于有关如何x在堆栈上加载的异常.直到有人提出一个很好的解释,为什么我要推测它因为movq延迟时间比较差,push并且由于所有指令都操纵堆栈,CPU无法重新排序指令以减少延迟movq.

为什么抖动选择movq了F#代码而不是我目前不知道的C#代码.

对于x64,性能似乎变得更糟,因为方法前奏中的开销更多,并且由于混叠而导致更多停顿.这主要是对我的推测,但是从汇编代码中很难看出除了停止可能会使x64的性能降低4倍.

通过标记min为内联x64和x86在~0.15秒内运行.毫不奇怪,因为它消除了方法前奏和读取和写入堆栈的所有开销.

标记F#方法进行积极内联(with [MethodImpl (MethodImplOptions.AggressiveInlining)])不起作用,因为F#编译器删除了所有这些属性,这意味着抖动永远不会看到它,但标记C#方法进行积极内联使得C#代码运行在~0.15秒内.

所以最终x86抖动从某种原因选择了不同的jit代码,即使IL代码看起来非常相似.可能这些方法的属性会影响抖动,因为它们有点不同.

x64抖动可能在以更有效的方式推送堆栈上的参数方面做得更好.我想使用pushx86抖动是可取的,mov因为语义push更受限制,但这只是我的猜测.

在这样的情况下,当方法很便宜时,将它们标记为内联可能是好的.

说实话,我不确定这有助于OP,但希望它有点有趣.

PS.我在i5 3570K上运行.NET 4.6.2上的代码