F# 与 OCaml:堆栈溢出

67

我最近找到了一份关于Python程序员的F#的演示文稿,看完后,我决定自己实现“蚂蚁难题”的解决方案。

有一只蚂蚁可以在平面网格上行走。蚂蚁每次可以向左、右、上或下移动一个空间。也就是说,从单元格(x,y),蚂蚁可以到达单元格(x+1,y)、(x-1,y)、(x,y+1)和(x,y-1)。当x和y坐标的数字之和大于25时,这些点对蚂蚁不可达。例如,点(59,79)不可达,因为5+9+7+9=30,大于25。问题是:如果蚂蚁从(1000,1000)开始,包括(1000,1000)本身,它能够访问多少个点?

我用OCaml first实现了我的解决方案,并尝试了一下:

$ ocamlopt -unsafe -rectypes -inline 1000 -o puzzle ant.ml
$ time ./puzzle
Points: 148848

real    0m0.143s
user    0m0.127s
sys     0m0.013s

我的结果和Leonardo的D和C++实现一样整洁。与Leonardo的C++实现相比,OCaml版本运行速度大约慢了两倍。这还好,考虑到Leonardo使用队列来消除递归。
然后,我将代码翻译成了F# ... 这是我的结果:
Thanassis@HOME /g/Tmp/ant.fsharp
$ /g/Program\ Files/FSharp-2.0.0.0/bin/fsc.exe ant.fs
Microsoft (R) F# 2.0 Compiler build 2.0.0.0
Copyright (c) Microsoft Corporation. All Rights Reserved.

Thanassis@HOME /g/Tmp/ant.fsharp
$ ./ant.exe

Process is terminated due to StackOverflowException.
Quit

Thanassis@HOME /g/Tmp/ant.fsharp
$ /g/Program\ Files/Microsoft\ F#/v4.0/Fsc.exe ant.fs
Microsoft (R) F# 2.0 Compiler build 4.0.30319.1
Copyright (c) Microsoft Corporation. All Rights Reserved.

Thanassis@HOME /g/Tmp/ant.fsharp
$ ./ant.exe

Process is terminated due to StackOverflowException

我在我的计算机上安装了两个版本的F#,结果遇到了栈溢出问题。 出于好奇,我将生成的二进制文件(ant.exe)在Arch Linux/Mono下运行:

$ mono -V | head -1
Mono JIT compiler version 2.10.5 (tarball Fri Sep  9 06:34:36 UTC 2011)

$ time mono ./ant.exe
Points: 148848

real    1m24.298s
user    0m0.567s
sys     0m0.027s

令人惊讶的是,它在Mono 2.10.5下运行(即没有堆栈溢出)-但需要84秒,即比OCaml慢587倍-糟糕。

所以这个程序...

  • 在OCaml下运行良好
  • .NET/F#下完全不起作用
  • 在Mono/F#下工作,但速度非常慢。

为什么?

编辑:奇怪的事情继续发生-使用“--optimize+ --checked-”可以解决问题,但仅适用于ArchLinux/Mono; 在Windows XP和Windows 7/64位下,即使是优化版本的二进制文件也会导致堆栈溢出。

最终编辑:我自己找到了答案-请参见下文。


1
@CarstenKönig的《Expert F# 2.0》中提到:“……使用--optimize编译您的最终代码,这将对您的代码应用最大化优化。这也是fsc.exe的默认优化设置。”在第164页上。这难道不意味着ttsiodras已经启用了所有优化吗? - Keith
5
walk 函数的递归调用不在尾部位置,这就解释了为什么会出现堆栈溢出错误。在其他情况下为什么没有出现堆栈溢出错误可能是因为这些系统设置了更大的堆栈空间,或者因为 mono JIT 和 OCAML 编译器做了一些聪明的事情。 - Joh
4
我认为这是一个好问题,很遗憾它被关闭了。最初的评论可以是“请详细说明你的问题”,这样就能解决问题了。或者,在ttsiodras修复问题后,它至少应该重新开放。 - Keith
8
我同意这是一个非常有趣的问题,并投票支持重新开放它。关于这个主题可以说的内容远远超出了评论框的限制... - J D
2
听起来你已经找到了答案。为什么还要设置悬赏?还有什么问题需要解决吗? - Daniel
显示剩余12条评论
2个回答

76

执行摘要:

  • 我编写了一个简单的算法实现,但它不是尾递归的。
  • 我在Linux下使用OCaml进行了编译。
  • 它运行良好,并在0.14秒内完成。

然后是时候将代码移植到F#了。

  • 我将代码(直接翻译)转换为F#。
  • 我在Windows下编译并运行它 - 我得到了堆栈溢出。
  • 我将二进制文件拿到Linux下,在Mono下运行它。
  • 它可以工作,但运行非常缓慢(84秒)。

然后我发布了一个问题到Stack Overflow - 但有些人决定关闭这个问题(叹气)。

  • 我尝试使用--optimize+ --checked-进行编译
  • 在Windows下,二进制文件仍然会发生堆栈溢出...
  • ...但在Linux/Mono下运行良好(并在0.5秒内完成)。

是时候检查堆栈大小了:在Windows下,另一个SO帖子指出默认设置为1MB。在Linux下,“uname -s”和一个测试程序的编译清楚地显示它是8MB。

这解释了为什么程序在Linux下工作而在Windows下不工作(程序使用了超过1MB的堆栈)。这并没有解释为什么优化版本在Mono下运行得比非优化版本好得多:0.5秒对84秒(尽管--optimize+似乎是默认设置,参见Keith的“Expert F#”摘录中的注释)。可能与Mono的垃圾回收器有关,第一版某种程度上将其推向了极端。

Linux/OCaml和Linux/Mono/F#执行时间之间的差异(0.14 vs 0.5)是因为我测量时间的简单方式:“time ./binary…”也测量了启动时间,这对于Mono/.NET来说很重要(至少对于这个简单的小问题来说很重要)。

无论如何,为了彻底解决这个问题,我编写了一个尾递归版本 - 在函数末尾的递归调用被转换为循环(因此,理论上不需要使用堆栈)。

新版本在Windows下也可以运行良好,并在0.5秒内完成。

所以,故事的寓意:

  • 注意你的堆栈使用情况,特别是如果你使用大量堆栈并在Windows下运行。使用EDITBIN与/STACK选项将您的二进制文件设置为更大的堆栈大小,或者更好的方法是编写不依赖于使用过多堆栈的代码。
  • OCaml在尾递归消除方面可能比F#更好 - 或者它的垃圾收集器在这个特定问题上做得更好。
  • 不要因为粗鲁的人关闭您的Stack Overflow问题而绝望,好心人最终会抵消他们的影响 - 如果问题真的很好 :-)

P.S. 来自Jon Harrop博士的一些额外输入:

...你只是幸运的,OCaml也没有溢出。 您已经确定实际堆栈大小在平台之间变化。 同一问题的另一个方面是,不同的语言实现 以不同的速度消耗堆栈空间,并在深度堆栈存在时具有不同的性能 特征。OCaml、Mono和.NET都使用不同的数据表示和GC算法, 影响这些结果...(a) OCaml使用标记整数来区分指针, 给出紧凑的堆栈帧,并将遍历堆栈上的所有内容查找指针。标记实际上传达了足够的信息, 以使OCaml运行时能够遍历堆(b)Mono将堆栈上的字视为指针:如果作为指针,一个字会指向 堆分配的块,则该块被视为可到达的。(c)我不知道.NET的算法,但如果它吃掉堆栈, 并仍然遍历堆栈上的每个字,我也不会感到惊讶(如果与深度堆栈无关的线程具有这种情况, 则其GC肯定会受到病态性能的影响!)...此外,您使用堆分配的元组意味着您将填充nursery代(例如gen0), 从而经常导致GC遍历这些深堆栈...


8
让我来总结一下答案。
有三个要点:
- 问题:递归函数导致堆栈溢出 - 它只在Windows下发生:在Linux上,对于检查的问题大小,它可以工作 - 在OCaml中相同(或类似)的代码可以工作 - 对于检查的问题大小,优化+编译器标志可以工作
堆栈溢出异常很常见是由于递归调用引起的。如果调用处于尾部位置,则编译器可以识别并应用尾递归优化,因此递归调用将不会占用堆栈空间。 尾递归优化可能在F#、CLR或两者中都发生:
CLR尾部优化1 F#递归(更通用)2

F#尾调用 3

"在Windows上失败,在Linux上不会"的正确解释是,如其他人所说,两个操作系统上默认保留的堆栈空间不同。或者更好地说,编译器在这两个操作系统下使用的保留堆栈空间不同。默认情况下,VC++仅保留1MB的堆栈空间。CLR(可能)是使用VC++编译的,因此它有此限制。保留堆栈空间可以在编译时增加,但我不确定是否可以在已编译的可执行文件上进行修改。

编辑:事实证明它是可以做到的(请参见此博客文章http://www.bluebytesoftware.com/blog/2006/07/04/ModifyingStackReserveAndCommitSizesOnExistingBinaries.aspx)。我不建议这样做,但在极端情况下至少是可能的。

OCaml版本可能可以工作,因为它在Linux下运行过。 然而,在Windows下测试OCaml版本也是很有趣的。我知道OCaml编译器在尾递归优化方面比F#更加激进... 它甚至能从您的原始代码中提取一个尾递归函数吗?

我猜"--optimize+"仍会导致代码递归,因此在Windows下仍会失败,但它将通过使可执行文件运行更快来缓解问题。

最终,使用尾递归(通过重写代码或依赖于激进的编译器优化)是解决递归函数堆栈溢出问题的好方法。


2
“OCaml编译器在尾调用优化方面比F#更加积极。”并不完全准确。在适当的情况下,OCaml和F#都保证消除尾部位置的所有调用。然而,OCaml可能使用比.NET或Mono更小的堆栈帧。 - J D

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接