.NET / C#为什么不优化尾调用递归?


111

我发现这个问题有关的语言优化尾递归。为什么C#尽可能不优化尾递归?

在具体情况下,为什么不将该方法优化为循环(如果需要的话,Visual Studio 2008 32位)?

private static void Foo(int i)
{
    if (i == 1000000)
        return;

    if (i % 100 == 0)
        Console.WriteLine(i);

    Foo(i+1);
}

我今天正在读一本关于数据结构的书,将递归函数分为两个部分preemptive(例如,阶乘算法)和Non-preemptive(例如,ackermann函数)。作者仅举了两个我提到的示例,而没有给出这种分歧背后的正当理由。这个分叉与尾和非尾递归函数一样吗?
RBT

5
乔恩·斯凯特(Jon skeet)和斯科特·汉塞尔曼(Scott Hanselman)在2016年youtu.be/H2KkiRbDZyc?t=3302
Daniel B,

@RBT:我认为是不同的。它指的是递归调用的数量。尾部调用是关于出现在尾部位置的调用,即函数做的最后一件事,因此它直接从被调用方返回结果。
JD

Answers:


84

JIT编译是一个棘手的平衡行为,既不要花费太多时间进行编译阶段(从而大大减缓了短命的应用程序),又没有进行足够的分析以通过标准的提前编译长期保持应用程序的竞争力。 。

有趣的是,NGen编译步骤的目标不是更积极地进行优化。我怀疑这是因为他们只是不想让错误依赖于JIT还是NGen负责机器代码而导致错误。

CLR本身不支持尾调用优化,但语言的编译器特定必须知道如何生成相关的操作码和JIT必须愿意尊重它。 F#的 fsc将生成相关的操作码(尽管对于简单的递归而言,它可能只是将整个过程while直接转换为循环)。C#的csc没有。

有关某些详细信息,请参见此博客文章(鉴于最近的JIT更改,现在可能已经过时了)。请注意,对于4.0的CLR更改,x86,x64和ia64将遵循它


2
另请参阅此帖子:social.msdn.microsoft.com/Forums/en-US/netfxtoolsdev/thread/…在这里,我发现拖尾比常规呼叫慢。E!
底座

77

Microsoft Connect反馈提交应回答您的问题。它包含Microsoft的官方回复,所以我建议您这样做。

谢谢你的建议。在C#编译器的开发过程中,我们考虑过在多个点发出尾部调用指令。但是,到目前为止,有一些细微的问题促使我们避免这种情况:1)在CLR中使用.tail指令实际上会产生不小的开销(因为尾调用最终会变成跳转指令,而不仅仅是跳转指令)在许多不太严格的环境中,例如功能语言运行时环境,其中对尾调用进行了优化。2)很少有真正的C#方法可以发出尾部调用(其他语言鼓励使用尾部递归更多的编码模式,许多严重依赖尾调用优化的人实际上都进行全局重写(例如Continuation Passing转换)以增加尾递归的数量。3)部分由于2),很少发生C#方法由于应成功进行的深度递归而导致堆栈溢出的情况。

综上所述,我们将继续研究此问题,并且在将来的编译器版本中,我们可能会发现一些发出.tail指令的模式。

顺便说一句,正如已经指出的,值得注意的是,尾递归在x64 进行了优化。


3
:您可能会发现这也有帮助weblogs.asp.net/podwysocki/archive/2008/07/07/...
Noldorin

没有问题,很高兴您发现它有帮助。
Noldorin

17
感谢您引用它,因为它现在是404!
罗曼·斯塔科夫

3
现在,链接已修复。
luksan

15

C#并未针对尾调用递归进行优化,因为这正是F#的目的!

有关阻止C#编译器执行尾部调用优化的条件的某些深度,请参见本文:JIT CLR尾部调用条件

C#和F#之间的互操作性

C#和F#的互操作性非常好,并且由于.NET公共语言运行库(CLR)在设计时就考虑到了这种互操作性,因此每种语言在设计时都针对其意图和目的进行了优化。有关显示从C#代码调用F#代码有多么容易的示例,请参见从C#代码调用F#代码。有关从F#代码调用C#函数的示例,请参见从F#调用C#函数

有关委托的互操作性,请参阅本文:委托F#,C#和Visual Basic之间的互操作性

C#和F#之间的理论和实践差异

本文涵盖了一些差异,并解释了C#和F#之间的尾调用递归的设计差异:在C#和F#中生成尾调用操作码

这是一篇有关C#,F#和C ++ \ CLI的示例的文章:C#,F#和C ++ \ CLI的尾递归历险

理论上的主要区别是C#是用循环设计的,而F#是根据Lambda微积分的原理设计的。有关Lambda演算原理的非常好的书,请参阅Abelson,Sussman和Sussman撰写的这本免费书籍:《计算机程序的结构和解释》

有关F#中的尾部调用的非常好的介绍性文章,请参见本文:F#中的尾部调用详细介绍。最后,这是一篇文章,介绍了非尾递归和尾调用递归之间的区别(在F#中):F sharp中的尾递归与非尾递归


8

最近有人告诉我,用于64位的C#编译器确实优化了尾递归。

C#也实现了这一点。之所以不总是应用它,是因为用于应用尾递归的规则非常严格。


8
x64 抖动
会这样做

感谢您的信息。这与我以前的想法有些不同。
亚历山大·布瑞斯布瓦

3
只是为了澄清这两个注释,C#从未发出CIL的“尾部”操作码,我相信这在2017年仍然是正确的。但是,对于所有语言,该操作码始终仅在各自的抖动(x86,x64 )将在不满足其他条件的情况下静默忽略它(好吧,除了可能的堆栈溢出之外,没有错误)。这就解释了为什么您被迫在“尾巴”后面加上“后退”,这就是这种情况。同时,当CIL中没有“ tail”前缀时,无论是否使用.NET语言,这些抖动也可以自由地应用优化。
格伦Slayden

3

您可以将蹦床技术用于C#(或Java)中的尾递归函数。但是,更好的解决方案(如果您只关心堆栈利用率)是使用这种小的辅助方法来包装同一递归函数的各个部分,并使它们迭代,同时保持函数的可读性。


蹦床具有侵入性(它们是对调用约定的全局更改),比正常的尾部调用消除速度慢约10倍,并且它们混淆了所有堆栈跟踪信息,从而使调试和配置文件代码更加困难
JD

1

就像其他答案提到的那样,CLR确实支持尾部调用优化,并且从历史上看似乎在逐步改进中。但是在C#中支持它在Proposalgit存储库中存在一个开放的问题,用于设计C#编程语言Support tail recursion#2544

您可以在此处找到一些有用的详细信息。例如提到的@jaykrell

让我提供我所知道的。

有时候,tailcall是一种双赢的表现。可以节省CPU。jmp比call / ret便宜,可以节省堆栈。减少堆栈数量可提高位置。

有时tailcall是性能损失,堆栈赢。CLR具有复杂的机制,在该机制中,传递给被调用方的参数多于接收到的调用方的参数。我的意思是专门用于参数的更多堆栈空间。太慢了 但是它可以节省堆栈。它只会用尾巴来做。字首。

如果调用者参数的堆栈大小大于被调用者参数的大小,那么通常这是一个非常容易的双赢转换。可能存在一些因素,例如参数位置从托管更改为整数/浮点,以及生成精确的StackMap等。

现在,存在另一个角度,即要求能够消除尾部调用的算法,以便能够处理固定/小堆栈的任意大数据。这与性能无关,而与运行能力有关。

还让我提到(作为额外信息),当我们使用System.Linq.Expressions命名空间中的表达式类生成编译的lambda时,有一个名为“ tailCall”的参数,如其注释中所述,它是

一个布尔值,指示在编译创建的表达式时是否将应用尾部调用优化。

我还没有尝试过,我不确定它对您的问题有什么帮助,但是可能有人可以尝试它,并且在某些情况下可能有用:


var myFuncExpression = System.Linq.Expressions.Expression.Lambda<Func<  >>(body:  , tailCall: true, parameters:  );

var myFunc =  myFuncExpression.Compile();

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.