避免堆栈溢出(使用F#的无限序列)

32

我有一段关于F#中morris序列的“学习代码”,它遇到了堆栈溢出问题,我不知道如何避免。 "morris"返回一个无限的“看见并说出”序列(即{{1},{1,1},{2,1},{1,2,1,1},{1,1,1,2,2,1},{3,1,2,2,1,1},...})。

    let printList l =
        Seq.iter (fun n -> printf "%i" n) l
        printfn ""

    let rec morris s = 
        let next str = seq {
            let cnt = ref 1  // Stack overflow is below when enumerating
            for cur in [|0|] |> Seq.append str |> Seq.windowed 2 do
                if cur.[0] <> cur.[1] then
                    yield!( [!cnt ; cur.[0]] )
                    cnt := 0
                incr cnt
        }
        seq {
        yield s
        yield! morris (next s) // tail recursion, no stack overflow
        }

    // "main"
    // Print the nth iteration
    let _ =  [1] |> morris |> Seq.nth 3125 |> printList 

你可以使用 Seq.nth 获取第n个迭代,但在遇到堆栈溢出之前只能获取有限的迭代。我唯一使用递归的地方是尾递归,它本质上构建了一个链接的枚举器集合。问题不在这里。当在第4000个序列上调用“enum”时,问题就出现了。请注意,这是使用F# 1.9.6.16时的情况,以前的版本在14000以上时会达到峰值。这是因为解析链接序列的方式。这些序列是惰性的,因此“递归”也是惰性的。也就是说,seq n 调用 seq n-1,后者调用 seq n-2,以此类推,以获取第一个项目(最初的#是最坏的情况)。
我明白 [|0|] |> Seq.append str |> Seq.windowed 2 正使我的问题更加严重,如果消除它,我可以生成三倍于现在的数量。实际上,代码已经足够好了。Morris的第3125次迭代将超过10^359个字符长度。
我真正要解决的问题是如何保留惰性计算并具有基于内存大小的无限制迭代选择。我正在寻找适当的F#习语来使限制基于内存大小。
更新Oct '10

经过学习F#和一点点Haskell,思考并调查了一年之后,我终于能回答自己的问题。但是像所有困难的问题一样,问题的起源在于它是错误的。问题不在于序列的序列——而实际上是由于递归定义的序列。我的函数式编程技能现在好了一些,所以更容易看出下面版本中发生了什么,但仍然会导致stackoverflow。

let next str = 
    Seq.append str [0]
    |> Seq.pairwise
    |> Seq.scan (fun (n,_) (c,v) ->
            if (c = v) then (n+1,Seq.empty)
            else (1,Seq.ofList [n;c]) ) (1,Seq.empty)
    |> Seq.collect snd

let morris = Seq.unfold(fun sq -> Some(sq,next sq))

基本上,这会创建一个非常长的 Seq 处理函数调用链来生成序列。F# 自带的 Seq 模块不能在不使用堆栈的情况下跟随该链。它针对追加和递归定义的序列使用了一种优化,但仅当递归实现一个追加时才有效。
因此,这将起作用。
let rec ints n = seq { yield n; yield! ints (n+1) }
printf "%A" (ints 0 |> Seq.nth 100000);;

这个会导致一个stackoverflow错误。

let rec ints n = seq { yield n; yield! (ints (n+1)|> Seq.map id) }
printf "%A" (ints 0 |> Seq.nth 100000);;

为了证明 F# 库是问题所在,我编写了自己的 Seq 模块,使用连续性实现了 append、pairwise、scan 和 collect,现在我可以开始生成并打印 50,000 个序列,而不会出现问题(它永远不会完成,因为它超过了 10^5697 位数)。
一些额外的说明:
- 连续性是我正在寻找的习惯用语,但在这种情况下,它们必须进入 F# 库,而不是我的代码。我从 Tomas Petricek 的《Real-World Functional Programming》书中了解到 F# 中的连续性。 - 我接受的惰性列表答案持有另一个习惯用语:惰性求值。在我重写的库中,我还必须利用惰性类型以避免堆栈溢出。 - 惰性列表版本有点运气(也许是有意设计的,但超出了我当前的能力来确定)——它在构建和迭代时使用的活动模式匹配会导致列表在需要递归太深之前计算值,因此它是惰性的,但不是那么惰性,需要连续性来避免堆栈溢出。例如,当第二个序列需要第一个序列的数字时,它已经被计算出来了。换句话说,LL 版本在序列生成方面不是严格的 JIT 惰性,只是列表管理。

你的算法需要多长时间来计算第60个莫里斯元素? - Dario
我不知道确切的时间。可能是4分钟左右。我的一个同事做的C++版本几乎是毫秒级的。我让它更加功能化,但速度变慢了。这都是因为对象的创建。上面的版本可以立即开始创建输出,即使在14000的情况下。 - Tony Lee
这个版本无论如何都不太实用。我用 Haskell 以纯函数式的方式编写了它,这样做既更简洁(只有列表和模式匹配),而且速度更快;-) - Dario
我首先创建了一个列表版本。它速度更快(60个数字用时34秒?),但消耗的内存太多,我无法计算大于64次迭代的任何内容。我确实制作了一个完全功能的版本(没有可变对象),但是到第5个序列时,每个数字的计算都需要几秒钟的时间。@Zifre - 感谢您更改标签,就在今天早上,我正在考虑该标签可能是错误的,但没有想到要修复它! - Tony Lee
一开始看到这个问题的时候,我还以为你花太多时间浏览这个网站了,需要找到避免它的方法 :) - Aistina
3个回答

12

你一定要看看这个链接:

http://research.microsoft.com/en-us/um/cambridge/projects/fsharp/manual/FSharp.PowerPack/Microsoft.FSharp.Collections.LazyList.html

但是稍后我会尝试发布一个更全面的答案。

更新

好的,下面是一个解决方案。它将Morris序列表示为int的LazyList的LazyList,因为我认为您希望它在“两个方向”上都是惰性的。

F# LazyList(在FSharp.PowerPack.dll中)有三个有用的属性:

  • 它是惰性的(直到首次要求第n个元素的计算才会发生)
  • 它不会重新计算(在同一对象实例上重新评估第n个元素不会重新计算它 - 它在第一次计算每个元素后缓存每个元素)
  • 您可以“忘记”前缀(当您“tail”进入列表时,不再引用的前缀可供垃圾回收)

第一个属性与seq(IEnumerable)相同,但其他两个属性是LazyList的独特之处,并且对于像本问题中提出的计算问题非常有用。

不再多说,上代码:

// print a lazy list up to some max depth
let rec PrintList n ll =
    match n with
    | 0 -> printfn ""
    | _ -> match ll with
           | LazyList.Nil -> printfn ""
           | LazyList.Cons(x,xs) ->
               printf "%d" x
               PrintList (n-1) xs

// NextMorris : LazyList<int> -> LazyList<int>
let rec NextMorris (LazyList.Cons(cur,rest)) = 
    let count = ref 1
    let ll = ref rest
    while LazyList.nonempty !ll && (LazyList.hd !ll) = cur do
        ll := LazyList.tl !ll
        incr count
    LazyList.cons !count
        (LazyList.consf cur (fun() ->
            if LazyList.nonempty !ll then
                NextMorris !ll
            else
                LazyList.empty()))

// Morris : LazyList<int> -> LazyList<LazyList<int>>
let Morris s =
    let rec MakeMorris ll =
        LazyList.consf ll (fun () ->
            let next = NextMorris ll
            MakeMorris next
        )
    MakeMorris s

// "main"
// Print the nth iteration, up to a certain depth
[1] |> LazyList.of_list |> Morris |> Seq.nth 3125 |> PrintList 10
[1] |> LazyList.of_list |> Morris |> Seq.nth 3126 |> PrintList 10
[1] |> LazyList.of_list |> Morris |> Seq.nth 100000 |> PrintList 35
[1] |> LazyList.of_list |> Morris |> Seq.nth 100001 |> PrintList 35

更新2

如果你只想计数,那也可以:

let LLLength ll =
    let rec Loop ll acc =
        match ll with
        | LazyList.Cons(_,rest) -> Loop rest (acc+1N)
        | _ -> acc
    Loop ll 0N

let Main() =
    // don't do line below, it leaks
    //let hundredth = [1] |> LazyList.of_list |> Morris |> Seq.nth 100
    // if we only want to count length, make sure we throw away the only
    // copy as we traverse it to count
    [1] |> LazyList.of_list |> Morris |> Seq.nth 100
        |> LLLength |> printfn "%A" 
Main()    

内存使用保持不变(在我的计算机上低于16M)...尚未完成运行,但我快速计算出了第55个长度,即使在我的慢计算机上也是如此,因此我认为这应该可以正常工作。还请注意,我对长度使用了“bignum”,因为我认为这会溢出“int”。


我需要更深入地分析这个问题。实际上,我不想要缓存行为,所以如果我可以像你所说的那样摆脱它,这个解决方案就是我要求的。目前,printfn "%A" ([1] |> LazyList.of_list |> Morris |> Seq.nth 100 |> Seq.length) 看起来会耗尽内存(测试仍在运行,已经使用了1.1GB;全部在第二代堆中)。我会像你建议的那样去学习懒惰列表。感谢你的撰写! - Tony Lee
Seq.length 对于这种情况不好,因为它在使用枚举器时会缓存整个列表。请参见UPDATE2,您需要一个“length”函数,在计数时可以丢弃该列表。 - Brian
我的唯一失望是实现没有隐藏在一个序列后面。这正是我所要求的,所以再次感谢。 - Tony Lee

5
我认为这里有两个主要问题:
1. 懒惰非常低效,因此你可以预期懒惰的函数实现会运行几个数量级更慢。例如,这里描述的Haskell实现比下面给出的F#实现慢2400倍。如果你想找到一个解决方法,最好把计算合并成急切批次,并在需要时生成批次。
2. Seq.append函数实际上调用了来自IEnumerable的C#代码,因此它的尾调用不会被消除,并且每次遍历序列时会泄漏一些堆栈空间。
以下计算第50个子序列长度的代码比你的实现快80倍以上,但可能对你来说这不够懒惰:
let next (xs: ResizeArray<_>) =
  let ys = ResizeArray()
  let add n x =
    if n > 0 then
      ys.Add n
      ys.Add x
  let mutable n = 0
  let mutable x = 0
  for i=0 to xs.Count-1 do
    let x' = xs.[i]
    if x=x' then
      n <- n + 1
    else
      add n x
      n <- 1
      x <- x'
  add n x
  ys

let morris =
  Seq.unfold (fun xs -> Some(xs, next xs)) (ResizeArray [1])

这个函数的核心是对一个 ResizeArray 进行折叠,如果你使用结构体作为累加器,它可以被分解并在不太影响性能的情况下进行函数式使用。

是的,我不够懒,因为我正在使用无限列表。这仍然让我感到困惑,所以我不确定我能否绕过seq.append。就像我上面评论的那样,我的同事制作了一个C++版本,即使超过100个也是惰性的和亚秒级的。最终有一小部分唯一的序列片段不会影响它们的邻居,因此您只需跟踪片段编号并查找它生成的其他片段即可。C++代码会动态构建片段表,因此您不必从“1”开始。 - Tony Lee
我的代码确实生成了一个无限序列。唯一可能的问题是,读取第n个子序列中的第一个元素会强制计算所有包括第n个在内的子序列。你可以通过相对较小的改动,在命令式地按需计算所有内容而不必遭受类似Haskell的性能问题。 - J D
我的意思是一个既懒惰又无限的序列。我尝试了你的算法,使用 let _ = morris |> Seq.nth 3125 |> printList,但由于它有10^359个字符,所以内存不足了。 我想我明白你的意思了,我的 yield! 不是尾递归,这可能是我的问题所在。 - Tony Lee
顺便说一下,在VS2010版本中,Seq.append方法不调用C# IEnumerable。请参考附带F# powerpack的源代码,它现在专门针对递归调用的yield进行了优化! - Tony Lee
只是为了排除一些关于Haskell的恐惧:链接的解决方案之所以慢,是因为算法问题,而不是因为Haskell本身很慢。这里有一个更快的解决方案:https://gist.github.com/1224319 - Tener

0

只需保存您查找的上一个元素即可。

let morris2 data = seq {
    let cnt = ref 0
    let prev = ref (data |> Seq.nth 0)

     for cur in data do
        if cur <> !prev then
            yield! [!cnt; !prev]
            cnt := 1
            prev := cur
        else
            cnt := !cnt + 1

    yield! [!cnt; !prev]
}

let rec morrisSeq2 cur = seq {
    yield cur
    yield! morrisSeq2 (morris2 cur)
}

1
是的,我理解了,就像我在问题中提到的那样。您只是延迟了溢出。限制仍然基于堆栈,且发生在超过14000时。对我而言,您已经使用seq.nth杀死了惰性求值,因此我必须稍微重写一下才能运行它。我希望它不仅可以增加深度,而且还可以通过内存不足而不是堆栈溢出失败。 - Tony Lee

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