生成尾部调用操作码

出于好奇,我试图用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);
    }

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

这个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.s i
    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#程序通常使用循环,所以C#编译器不会为您提供有关尾部调用优化的任何保证,因此它们不依赖于尾部调用优化。 所以,在C#中,这只是一个JIT优化,可能会或可能不会发生(并且您不能依赖它)。

F#编译器设计用于处理使用递归的功能代码,因此它可以为您提供有关尾部调用的某些保证。 这通过两种方式完成:

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

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

  • 作为第二种情况的例子,编译以下简单的F#函数(F#在调试模式下不会执行此操作以简化调试,因此您可能需要释放模式或添加--tailcalls+ ):

    let foo a cont = cont (a + 1)
    

    该函数简单地调用第一个参数递增1的函数cont 。 在延续传递风格中,你有很长的这种调用序列,所以优化是至关重要的(如果没有处理尾部调用,你就不能使用这种风格)。 生成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. 操作码,有时通过发送不递归的IL来自行优化尾部。
  • CLR将会尊重tail. 操作码是否存在,即使操作码不存在,64位CLR有时也会进行尾部调用优化。
  • 所以,就你而言,你没有看到tail. 操作码在C#编译器生成的IL中,因为它不这样做。 但是该方法是tail-call优化的,因为即使没有操作码,CLR有时也会这样做。

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


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

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

    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。 您会发现由此答案中记录的抖动执行的其他优化。

    链接地址: http://www.djcxy.com/p/2281.html

    上一篇: Generate tail call opcode

    下一篇: F# Tail Recursive Function Example