【问题标题】:Tail call optimization in Mathematica?Mathematica 中的尾调用优化?
【发布时间】:2011-05-27 17:57:23
【问题描述】:

在制定answer to another SO question 时,我遇到了一些关于 Mathematica 中尾递归的奇怪行为。

Mathematica documentation 暗示可能会执行tail call optimization。但是我自己的实验给出了相互矛盾的结果。对比一下,例如下面两个表达式。第一个导致 7.0.1 内核崩溃,可能是由于堆栈耗尽:

(* warning: crashes the kernel! *)
Module[{f, n = 0},
  f[x_] := (n += 1; f[x + 1]);
  TimeConstrained[Block[{$RecursionLimit = Infinity}, f[0]], 300, n]
]

第二次运行完成,似乎利用尾调用优化来返回有意义的结果:

Module[{f, n = 0},
  f[x_] := Null /; (n += 1; False);
  f[x_] := f[x + 1];
  TimeConstrained[Block[{$IterationLimit = Infinity}, f[0]], 300, n]
]

两个表达式都定义了一个尾递归函数f。在第一个函数的情况下,Mathematica 显然认为复合语句的存在足以阻止任何尾调用优化的机会。另请注意,第一个表达式由$RecursionLimit 管理,第二个由$IterationLimit 管理——这表明 Mathematica 对这两个表达式的处理方式不同。 (注意:上面引用的 SO 答案有一个不那么做作的函数,可以成功利用尾调用优化)。

那么,问题是:有谁知道 Mathematica 在什么情况下执行递归函数的尾调用优化?最好参考 Mathematica 文档或其他 WRI 材料中的明确声明。也欢迎投机。

【问题讨论】:

  • 我发现Null /; 条件很神秘。您能解释一下想要的效果以及它是如何达到这种效果的吗?如果你把它关掉会发生什么?
  • @Reb.Cabin 我使用n 来计算调用次数。在第一个示例中,递增n 直接阻止了尾调用优化。因此,我将n+=1 移动到另一个定义中——但作为始终为假条件的一部分,因此该定义本质上是无操作的(尽管有更新n 的副作用)。我写了Null 作为一个任意选择,因为从不使用返回值。可以在单个定义中实现相同的结果:f[x_] := f[x + 1] /; (++n; True)
  • 我现在有一个运行时间很长的 MMA 程序。它一次运行六到九十六个小时,最后交付结果。我使用Fold 进行迭代。我认为Fold 相当于尾部调用,即替换累积变量的值而不是累积它们。如果我使用FoldListSow 输出中间结果,我的内存就会耗尽,这支持了我的假设。通过Fold,它肯定会做一些不同的事情。
  • @Reb.Cabin 因为Fold 内置在内核中,我怀疑它是作为用C 编写的简单命令式循环实现的——尤其是因为C 编译器很少执行尾调用优化。但是如果它递归实现的,我同意它必须被优化。 FoldList 不会溢出堆栈,而是会耗尽内存,因为它会保留每次迭代的结果副本。这可能是 96 小时之前的大量数据 :)

标签: recursion wolfram-mathematica tail-recursion tail-call-optimization


【解决方案1】:

我可以总结一下我的个人经验得出的结论,并声明以下内容可能不是完全正确的解释。答案似乎在于 Mathematica 调用堆栈与传统调用堆栈之间的差异,这源于 Mathematica 模式定义的函数是真正的规则。因此,没有真正的函数调用。 Mathematica 需要一个堆栈的原因不同:因为正常的计算是从表达式树的底部开始的,它必须保留中间表达式,以防(子)表达式的更深的部分由于规则应用而被替换(某些部分一个表达式从底部增长)。尤其是对于定义我们在其他语言中称为非尾递归函数的规则时,情况尤其如此。因此,再次重申,Mathematica 中的堆栈是中间表达式的堆栈,而不是函数调用。

这意味着,如果作为规则应用的结果,一个(子)表达式可以被整体重写,则表达式分支不需要保留在表达式堆栈上。这可能是 Mathematica 中所谓的尾调用优化 - 这就是为什么在这种情况下我们使用迭代而不是递归(这是规则应用程序和函数调用之间差异的一个很好的例子)。像f[x_]:=f[x+1] 这样的规则就是这种类型。但是,如果某些子表达式被重写,产生更多的表达式结构,那么表达式必须存储在堆栈中。规则f[x_ /; x < 5] := (n += 1; f[x + 1]) 属于这种类型,在我们回忆起() 代表CompoundExpression[] 之前,它有点隐藏。在这里发生的示意图是f[1] -> CompoundExpression[n+=1, f[2]] -> CompoundExpression[n+=1,CompoundExpression[n+=1,f[3]]]->etc。即使每次对 f 的调用都是最后一次,它发生在完整的 CompoundExpression[] 执行之前,所以它仍然必须保存在表达式堆栈中。也许有人会争辩说,这是一个可以进行优化的地方,为 CompoundExpression 设置一个例外,但这可能并不容易实现。

现在,为了说明我上面示意性描述的堆栈累积过程,让我们限制递归调用的数量:

Clear[n, f, ff, fff];
n = 0;
f[x_ /; x < 5] := (n += 1; f[x + 1]);

ff[x_] := Null /; (n += 1; False);
ff[x_ /; x < 5] := ff[x + 1];

fff[x_ /; x < 5] := ce[n += 1, fff[x + 1]];

跟踪评估:

In[57]:= Trace[f[1],f]
Out[57]= {f[1],n+=1;f[1+1],{f[2],n+=1;f[2+1],{f[3],n+=1;f[3+1],{f[4],n+=1;f[4+1]}}}}

In[58]:= Trace[ff[1],ff]
Out[58]= {ff[1],ff[1+1],ff[2],ff[2+1],ff[3],ff[3+1],ff[4],ff[4+1],ff[5]}

In[59]:= Trace[fff[1],fff]
Out[59]= {fff[1],ce[n+=1,fff[1+1]],{fff[2],ce[n+=1,fff[2+1]],{fff[3],ce[n+=1,fff[3+1]],   
{fff[4],ce[n+=1,fff[4+1]]}}}}

从中可以看出,表达式堆栈为ffff 累积(后者仅用于表明这是一种通用机制,ce[] 只是一些任意头部),但不是针对ff,因为出于模式匹配的目的,ff 的第一个定义是尝试但不匹配的规则,而第二个定义将 ff[arg_] 整个重写,并且不会生成需要的更深的子部分进一步重写。所以,底线似乎你应该分析你的函数,看看它的递归调用是否会从底部增长评估的表达式。如果是,就 Mathematica 而言,它不是尾递归的。

如果不展示如何手动进行尾调用优化,我的回答将是不完整的。例如,让我们考虑 Select 的递归实现。我们将与 Mathematica 链表一起工作,使其相当高效,而不是一个玩具。下面是非尾递归实现的代码:

Clear[toLinkedList, test, selrecBad, sel, selrec, selTR]
toLinkedList[x_List] := Fold[{#2, #1} &, {}, Reverse[x]];
selrecBad[fst_?test, rest_List] := {fst,If[rest === {}, {}, selrecBad @@ rest]};
selrecBad[fst_, rest_List] := If[rest === {}, {}, selrecBad @@ rest];
sel[x_List, testF_] := Block[{test = testF}, Flatten[selrecBad @@ toLinkedList[x]]]

我使用 Block 和 selrecBad 的原因是为了更容易使用 Trace。现在,这会破坏我机器上的堆栈:

Block[{$RecursionLimit = Infinity}, sel[Range[300000], EvenQ]] // Short // Timing

您可以在小列表中查找原因:

In[7]:= Trace[sel[Range[5],OddQ],selrecBad]

Out[7]= {{{selrecBad[1,{2,{3,{4,{5,{}}}}}],{1,If[{2,{3,{4,{5,{}}}}}==={},{},selrecBad@@{2,{3,{4, 
{5,{}}}}}]},{selrecBad[2,{3,{4,{5,{}}}}],If[{3,{4,{5,{}}}}==={},{},selrecBad@@{3,{4,{5, 
{}}}}],selrecBad[3,{4,{5,{}}}],{3,If[{4,{5,{}}}==={},{},selrecBad@@{4,{5,{}}}]},{selrecBad[4,
{5,{}}],If[{5,{}}==={},{},selrecBad@@{5,{}}],selrecBad[5,{}],{5,If[{}==={},{},selrecBad@@{}]}}}}}}

结果会在列表中越来越深地累积。解决方案是不增加结果表达式的深度,实现这一目标的一种方法是让 selrecBad 接受一个额外的参数,即累积结果的(链接)列表:

selrec[{fst_?test, rest_List}, accum_List] := 
    If[rest === {}, {accum, fst}, selrec[rest, {accum, fst}]];
selrec[{fst_, rest_List}, accum_List] := 
    If[rest === {}, accum, selrec[rest, accum]]

并相应修改主函数:

selTR[x_List, testF_] := Block[{test = testF}, Flatten[selrec[toLinkedList[x], {}]]]

这将很好地通过我们的功率测试:

In[14]:= Block[{$IterationLimit= Infinity},selTR[Range[300000],EvenQ]]//Short//Timing

Out[14]= {0.813,{2,4,6,8,10,12,14,16,18,20,
<<149981>>,299984,299986,299988,299990,299992,299994,299996,299998,300000}}

(请注意,这里我们必须修改 $IterationLimit,这是一个好兆头)。使用 Trace 揭示了原因:

In[15]:= Trace[selTR[Range[5],OddQ],selrec]

Out[15]= {{{selrec[{1,{2,{3,{4,{5,{}}}}}},{}],If[{2,{3,{4,{5,{}}}}}==={},{{},1},selrec[{2,{3,{4, 
{5,{}}}}},{{},1}]],selrec[{2,{3,{4,{5,{}}}}},{{},1}],If[{3,{4,{5,{}}}}==={},{{},1},selrec[{3, 
{4,{5,{}}}},{{},1}]],selrec[{3,{4,{5,{}}}},{{},1}],If[{4,{5,{}}}==={},{{{},1},3},selrec[{4, 
{5,{}}},{{{},1},3}]],selrec[{4,{5,{}}},{{{},1},3}],If[{5,{}}==={},{{{},1},3},selrec[{5, 
{}},{{{},1},3}]],selrec[{5,{}},{{{},1},3}],If[{}==={},{{{{},1},3},5},selrec[{},{{{{},1},3},5}]]}}}

也就是说,这个版本不累积中间表达式的深度,因为结果保存在一个单独的列表中。

【讨论】:

  • +1 非常好的分析。我同意 CompoundExpression 是优化的候选者,尽管我能够设计一种情况,即子表达式将定义添加到 CompoundExpression,从而改变其语义(优化会阻止)。强调“设计”——我不确定这在实践中是否会成为问题。
  • 很好的解释!现在$RecursionLimit$IterationLimit 之间的区别变得清晰了。并且stack 是什么变得更加清晰了。
【解决方案2】:

这个答案的想法是用一个不会使我们的表达式增长的包装器替换括号 ()。请注意,我们正在寻找替代的函数实际上是 CompoundExpression,因为 OP 正确地指出该函数破坏了尾递归(另请参见 Leonid 的答案)。提供了两种解决方案。这定义了第一个包装器

SetAttributes[wrapper, HoldRest];
wrapper[first_, fin_] := fin
wrapper[first_, rest__] := wrapper[rest]

然后我们就有了

Clear[f]
k = 0;
mmm = 1000;
f[n_ /; n < mmm] := wrapper[k += n, f[n + 1]];
f[mmm] := k + mmm
Block[{$IterationLimit = Infinity}, f[0]]

正确计算 Total[Range[1000]]。

--------注意-----

请注意,设置会产生误导

wrapper[fin_] := fin;

如题

f[x_]:= wrapper[f[x+1]]

不会发生尾递归(因为具有 HoldRest 的 wrapper 将在应用与 wrapper[fin_] 关联的规则之前评估单数参数)。

再一次,上面对 f 的定义没有用,因为我们可以简单地写

f[x_]:= f[x+1]

并具有所需的尾递归。

------另一个说明-----

如果我们为包装器提供很多参数,它可能会比必要的慢。用户可以选择写

f[x_]:=wrapper[g1;g2;g3;g4;g5;g6;g7  , f[x+1]]

第二个包装器

第二个包装器将其参数提供给 CompoundExpression,因此如果提供了许多参数,它将比第一个包装器更快。这定义了第二个包装器。

SetAttributes[holdLastWrapper, HoldAll]
holdLastWrapper[fin_] := fin
holdLastWrapper[other_, fin_] := 
 Function[Null, #2, HoldRest][other, fin]
holdLastWrapper[others__, fin_] := 
 holdLastWrapper[
  Evaluate[CompoundExpression[others, Unevaluated[Sequence[]]]], fin]

注意:返回(空)序列通常在递归中可能非常有用。也可以在这里查看我的答案

https://mathematica.stackexchange.com/questions/18949/how-can-i-return-a-sequence

请注意,如果只提供一个参数,此函数仍然有效,因为它具有属性 HoldAll 而不是 HoldRest,因此设置

f[x]:= holdLastWrapper[f[x+1]]

会产生一个尾递归(包装器没有这种行为)。

速度比较

让我们创建一个很长的指令列表(实际上是一个带有 Head Hold 的表达式)

nnnn = 1000;
incrHeld = 
  Prepend[DeleteCases[Hold @@ ConstantArray[Hold[c++], nnnn], 
    Hold, {2, Infinity}, Heads -> True], Unevaluated[c = 0]];

对于这些说明,我们可以将包装器的性能(和结果)与 CompoundExpression 进行比较

holdLastWrapper @@ incrHeld // Timing
CompoundExpression @@ incrHeld // Timing
wrapper @@ incrHeld // Timing

--> {{0.000856, 999}, {0.000783, 999}, {0.023752, 999}}

结论

如果您不确定何时会发生尾递归或您将向包装器提供多少参数,则第二个包装器会更好。如果您打算为包装器提供 2 个参数,例如,在您意识到第二个包装器所做的所有事情都是向 CompoundExpression 提供的情况下,您决定自己执行此操作,那么第一个包装器会更好。

-----最后注----

在 CompoundExpression[args, Unevaluated[expr]] 中,expr 仍然在 CompoundExpression 被剥离之前被求值,所以这种类型的解决方案没有用。

【讨论】:

  • 这很好! +1。这似乎解决了CompoundExpression 的问题。然而,在许多情况下,这还不够,例如,对于这样一个 f[x_]:={f[x-1],f[x-2]} - 围绕调用的容器不是 CompoundExpression(而是,例如,List)。不过,这似乎是一个非常好的成就。我必须进行更多测试,但现在它似乎是CompoundExpression 的解决方案。从某种意义上说,这与我所做的类似,因为它将其分为两个规则-但您的解决方案是通用的。如果/当我们测试并确信它通常有效时,它将使...
  • ... 提出“自动尾调用优化的编程工具”之类的问题并将您的建议作为答案之一是有意义的。我还依稀记得@Rojo 有一些基于 Trott-Strzebonski 技术的尾调用优化方法。
  • @Leonid Shifrin Woohoo :)。谢谢你。我对进一步研究这些事情非常感兴趣。我还将研究 Trott-Strzebonski 技术,因为昨天和今天我通过遵循您布置的路径学到了很多 :)。
  • @Leonid Shifrin 我想知道如果一个函数在其主体中多次调用自身,尾递归意味着什么,因为在这种情况下,似乎有必要将我们必须返回到原始函数的堆栈放入称呼。调查一下:)。
  • 看起来不错!照顾ModuleWithBlock 也很好,可能出现在 r.h.s. 的顶层。或多或少是自动的。我认为@Rojo 提到他有一个使用 Trott-Strzebonski 技术的解决方案,但我没有时间仔细研究它。有了这个,可以构造自定义赋值运算符,它会自动使广泛的递归函数类成为尾递归。那真是太好了。
猜你喜欢
  • 1970-01-01
  • 2012-08-19
  • 2019-04-20
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2011-03-31
  • 2014-04-06
  • 1970-01-01
相关资源
最近更新 更多