你是否在相同的架构上运行这两个示例?我在x64上同时针对F#和C#代码获得了约1.4秒,而在x86上针对F#获得了约0.6秒,针对C#获得了约0.3秒。
正如你所说,反编译程序集时,代码看起来非常相似,但是当检查 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
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
C#编译器的区别在于,它通过将中间结果推送到堆栈上来避免引入一些本地变量。由于本地变量无论如何都会分配在堆栈上,因此很难看出这为什么会导致更高效的代码。
其他函数非常类似。
反汇编x86得到以下结果:
F# - 循环
01690a7e 8bce mov ecx,esi
01690a80 8bd6 mov edx,esi
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
01690a91 52 push edx
01690a92 51 push ecx
01690a93 8d4df0 lea ecx,[ebp-10h]
01690a96 ff15744dfe00 call dword ptr ds:[0FE4D74h]
01690a9c 46 inc esi
01690a9d 81fe01e1f505 cmp esi,offset FSharp_Core_ni+0x6be101 (05f5e101)
01690aa3 7cd9 jl 01690a7e
C# - 循环
02c2057b 8d55ec lea edx,[ebp-14h]
02c2057e 8b0a mov ecx,dword ptr [edx]
02c20580 8b4204 mov eax,dword ptr [edx+4]
02c20583 8bfe mov edi,esi
02c20585 8bd6 mov edx,esi
02c20587 50 push eax
02c20588 51 push ecx
02c20589 52 push edx
02c2058a 57 push edi
02c2058b 8d4dec lea ecx,[ebp-14h]
02c2058e ff15104d2401 call dword ptr ds:[1244D10h]
02c20594 46 inc esi
02c20595 81fe00e1f505 cmp esi,5F5E100h
02c2059b 7ede jle 02c2057b
很难理解为什么 F# 代码应该表现得更差。除了在如何加载栈上的 x
的异常情况下,代码看起来大致相同。在有人想出一个好的解释之前,我只能猜测这是因为 movq
的延迟比 push
更糟糕,由于所有指令都操作堆栈,CPU 无法重排指令以减轻 movq
的延迟。
至于为什么 JIT 编译器选择在 F# 代码中使用 movq
而不是 C# 代码,我目前不知道原因。
对于 x64,性能似乎会变差,因为方法前导部分的开销更大,并且由于别名而更容易出现停顿。这主要是我的推测,但从汇编代码中很难看出除了停顿之外,什么会将 x64 的性能降低 4 倍。
通过将 min
标记为内联方式(inline),x64 和 x86 的运行时间约为 0.15 秒。这并不奇怪,因为它可以消除方法前导部分的所有开销,以及对堆栈的大量读写。
将 F# 方法标记为积极内联([MethodImpl(MethodImplOptions.AggressiveInlining)]
)不起作用,因为 F# 编译器会删除所有这样的属性,意味着 JIT 编译器永远看不到它。但将 C# 方法标记为积极内联可以使 C# 代码运行在约 0.15 秒的时间内。
所以最终,x86 JIT 编译器出于某种原因选择了不同的 jit 代码,即使 IL 代码看起来非常相似。可能方法上的属性会影响 JIT 编译器,因为它们有点不同。
x64 JIT 编译器可能可以更好地将参数推送到堆栈上,以更有效地进行处理。我猜使用 push
作为 x86 JIT 更可取,因为 push
的语义更受限制,但这只是我的猜测。
像这种方法很廉价的情况下,将它们标记为内联是有好处的。
说实话,我不确定这是否对 OP 有帮助,但希望这些信息会有点意思。
PS:我在 i5 3570K 上的 .NET 4.6.2 上运行此代码。