出于好奇,我试图使用C#生成尾调用操作码。 Fibinacci很简单,所以我的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 for this如下所示:
.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 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#是如何做到的?
答案 0 :(得分:48)
C#编译器没有为尾部调用优化提供任何保证,因为C#程序通常使用循环,因此它们不依赖于尾部调用优化。因此,在C#中,这只是一个可能会或可能不会发生的JIT优化(并且您不能依赖它)。
F#编译器旨在处理使用递归的功能代码,因此它确实为您提供了有关尾部调用的一些保证。这有两种方式:
如果你编写一个自我调用的递归函数(比如你的fib
),编译器会把它变成一个在体内使用循环的函数(这是一个简单的优化,生成的代码比使用尾部调用)
如果在更复杂的位置使用递归调用(当使用函数作为参数传递的连续传递样式时),编译器会生成一个尾调用指令,告诉JIT它必须使用尾调用。
作为第二种情况的示例,编译以下简单的F#函数(F#在调试模式下不执行此操作以简化调试,因此您可能需要发布模式或添加--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
答案 1 :(得分:26)
.Net中尾调用优化的情况非常复杂。据我所知,就像这样:
tail.
操作码,它也不会自己进行尾调用优化。tail.
操作码,有时通过发送不递归的IL来自行调用尾部优化。tail.
操作码(如果存在),即使操作码不存在,64位CLR有时也会进行尾调用。因此,在您的情况下,您没有在C#编译器生成的IL中看到tail.
操作码,因为它没有这样做。但该方法是尾调用优化的,因为即使没有操作码,CLR有时也会这样做。
在F#案例中,您发现f#编译器本身就进行了优化。
答案 2 :(得分:9)
与.NET(Roslyn语言)中执行的所有优化一样,尾调用优化是由抖动而不是编译器执行的工作。理念是将工作放在抖动上是有用的,因为任何语言都会从中受益,编写和调试代码优化器通常很困难,每个架构只需执行一次。
您必须查看生成的机器代码才能看到它正在完成,Debug + Windows + Disassembly。进一步要求您通过查看使用工具+选项,调试,常规,抑制JIT优化未生成的版本构建代码来实现此目的。
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。您将找到this answer中记录的抖动执行的其他优化。