生成尾调用操作码

出于好奇,我试图用C#生成一个尾调用操作码。 斐波纳契是一个很容易的,所以我的c#例子看起来像这样:

private static void Main(string[] args) { Console.WriteLine(Fib(int.MaxValue, 0)); } public static int Fib(int i, int acc) { if (i == 0) { return acc; } return Fib(i - 1, acc + i); } 

如果我在发行版中构build它,并且在不进行debugging的情况下运行,我不会发生堆栈溢出。 debugging或运行它没有优化,我确实得到一个堆栈溢出,这意味着在优化(这是我所期望的)发布时,尾部调用正在工作。

这个MSIL看起来像这样:

 .method public hidebysig static int32 Fib(int32 i, int32 acc) cil managed { // Method Start RVA 0x205e // Code Size 17 (0x11) .maxstack 8 L_0000: ldarg.0 L_0001: brtrue.s L_0005 L_0003: ldarg.1 L_0004: ret L_0005: ldarg.0 L_0006: ldc.i4.1 L_0007: sub L_0008: ldarg.1 L_0009: ldarg.0 L_000a: add L_000b: call int32 [ConsoleApplication2]ConsoleApplication2.Program::Fib(int32,int32) L_0010: ret } 

我会希望看到一个尾部操作码,每个MSDN ,但它不在那里。 这让我想知道JIT编译器是否负责把它放在那里? 我尝试ngen程序集(使用ngen install <exe> ,导航到Windows程序集列表来获取它)并加载它在ILSpy备份,但它看起来是一样的我:

 .method public hidebysig static int32 Fib(int32 i, int32 acc) cil managed { // Method Start RVA 0x3bfe // Code Size 17 (0x11) .maxstack 8 L_0000: ldarg.0 L_0001: brtrue.s L_0005 L_0003: ldarg.1 L_0004: ret L_0005: ldarg.0 L_0006: ldc.i4.1 L_0007: sub L_0008: ldarg.1 L_0009: ldarg.0 L_000a: add L_000b: call int32 [ConsoleApplication2]ConsoleApplication2.Program::Fib(int32,int32) L_0010: ret } 

我仍然没有看到它。

我知道F#可以很好的处理尾部调用,所以我想比较一下F#和C#做了什么。 我的F#示例如下所示:

 let rec fibb i acc = if i = 0 then acc else fibb (i-1) (acc + i) Console.WriteLine (fibb 3 0) 

为fib方法生成的IL看起来像这样:

 .method public static int32 fibb(int32 i, int32 acc) cil managed { // Method Start RVA 0x2068 // Code Size 18 (0x12) .custom instance void [FSharp.Core]Microsoft.FSharp.Core.CompilationArgumentCountsAttribute::.ctor(int32[]) = { int32[](Mono.Cecil.CustomAttributeArgument[]) } .maxstack 5 L_0000: nop L_0001: ldarg.0 L_0002: brtrue.s L_0006 L_0004: ldarg.1 L_0005: ret L_0006: ldarg.0 L_0007: ldc.i4.1 L_0008: sub L_0009: ldarg.1 L_000a: ldarg.0 L_000b: add L_000c: starg.s acc L_000e: starg.si L_0010: br.s L_0000 } 

据ILSpy称,这相当于:

 [Microsoft.FSharp.Core.CompilationArgumentCounts(Mono.Cecil.CustomAttributeArgument[])] public static int32 fibb(int32 i, int32 acc) { label1: if !(((i != 0))) { return acc; } (i - 1); i = acc = (acc + i);; goto label1; } 

所以F#使用goto语句生成尾部调用? 这不是我所期待的。

我不想依赖任何地方的尾巴呼叫,但我只是好奇,那个操作码设置在哪里? C#如何做到这一点?

C#编译器不给你任何关于tail-call优化的保证,因为C#程序通常使用循环,所以它们不依赖于tail-call优化。 所以,在C#中,这只是一个JIT优化,可能会或可能不会发生(你不能依赖它)。

F#编译器被devise为处理使用recursion的function性代码,因此它可以为您提供有关尾部调用的某些保证。 这是通过两种方式完成的:

  • 如果你编写一个自我调用的recursion函数(比如你的fib ),编译器会把它变成一个函数,在函数体中使用循环(这是一个简单的优化,生成的代码比使用尾声更快)

  • 如果在更复杂的位置使用recursion调用(当使用继续传递样式(其中函数作为parameter passing))时,编译器会生成一个尾部调用指令,告诉JIT它必须使用尾部调用。

作为第二种情况的例子,编译下面这个简单的F#函数(F#在Debug模式下不会这样做,以简化debugging,所以你可能需要Release模式或者添加--tailcalls+ ):

 let foo a cont = cont (a + 1) 

函数简单地调用函数cont ,第一个参数加1。 在继续传递样式中,您有很长的这样的调用序列,所以优化是至关重要的(如果没有一些处理尾调用,就不能使用这个样式)。 生成IL代码如下所示:

 IL_0000: ldarg.1 IL_0001: ldarg.0 IL_0002: ldc.i4.1 IL_0003: add IL_0004: tail. // Here is the 'tail' opcode! IL_0006: callvirt instance !1 class [FSharp.Core] Microsoft.FSharp.Core.FSharpFunc`2<int32, !!a>::Invoke(!0) IL_000b: ret 

.Net中尾部呼叫优化的情况相当复杂。 据我所知,就是这样的:

  • C#编译器永远不会发出tail. 操作码,也不会自己做尾部调用优化。
  • F#编译器有时会发出tail. 操作码,有时通过发送不recursion的IL来自行优化尾部。
  • CLR会尊重tail. 操作码是否存在,即使操作码不存在,64位CLR有时也会进行尾部调用优化。

所以,就你而言,你没有看到tail. 在由C#编译器生成的IL中操作码,因为它不这样做。 但是该方法是tail-call优化的,因为即使没有操作码,CLR有时也会这样做。

而在F#的情况下,你发现f#编译器自己做了优化。

像在.NET(Roslyn语言)中执行的所有优化一样,尾部调用优化是由抖动执行的工作,而不是编译器。 我们的理念是把工作放在抖动上是很有用的,因为任何语言都会从中受益,编写和debugging代码优化器通常很困难的工作只能在每个架构上完成一次。

您必须查看生成的机器代码才能看到它正在完成,Debug + Windows + Disassembly。 有了进一步的要求,您可以通过查看使用Tools + Options,Debugging,General,Suppress JIT optimization unticked生成的Release构build代码。

x64代码如下所示:

  public static int Fib(int i, int acc) { if (i == 0) { 00000000 test ecx,ecx 00000002 jne 0000000000000008 return acc; 00000004 mov eax,edx 00000006 jmp 0000000000000011 } return Fib(i - 1, acc + i); 00000008 lea eax,[rcx-1] 0000000b add edx,ecx 0000000d mov ecx,eax 0000000f jmp 0000000000000000 // <== here!!! 00000011 rep ret 

注意标记的指令,跳转而不是调用。 这是工作中的尾部呼叫优化。 .NET中的怪癖是32位的x86抖动不能执行这种优化。 只是一个他们可能永远不会想到的待办事项。 哪些确实需要F#编译器编写者不要忽略问题并发出Opcodes.Tailcall。 你会发现在这个答案中logging的抖动执行的其他优化。