【问题标题】:Laziness and tail recursion in Haskell, why is this crashing?Haskell中的懒惰和尾递归,为什么会崩溃?
【发布时间】:2009-10-24 19:27:57
【问题描述】:

我有一个相当简单的函数来计算一个大列表的元素的平均值,使用两个累加器来保存到目前为止的总和和到目前为止的计数:

mean = go 0 0
    where
      go s l []     = s / fromIntegral l
      go s l (x:xs) = go (s+x) (l+1) xs

main = do
  putStrLn (show (mean [0..10000000]))

现在,在严格的语言中,这将是尾递归的,不会有任何问题。然而,由于 Haskell 是懒惰的,我的谷歌搜索让我明白 (s+x) 和 (l+1) 将作为 thunk 传递给递归。所以这整个事情崩溃和燃烧:

Stack space overflow: current size 8388608 bytes.

进一步谷歌搜索后,我找到了seq$!。我似乎不明白,因为我在这种情况下使用它们的所有尝试都被证明是徒劳的,错误消息说的是无限类型。

终于找到-XBangPatterns,通过改递归调用解决了这一切:

go !s !l (x:xs) = go (s+x) (l+1) xs

但我对此并不满意,因为 -XBangPatterns 目前是一个扩展。我想知道如何在不使用-XBangPatterns 的情况下使评估变得严格。 (也许还能学到一些东西!)

只是为了让你理解我缺乏理解,这是我尝试的(唯一编译的尝试,即):

go s l (x:xs) = go (seq s (s+x)) (seq l (l+1)) xs

据我了解, seq 应该在这里强制评估 s 和 l 参数,从而避免由 thunk 引起的问题。但我仍然得到堆栈溢出。

【问题讨论】:

    标签: performance optimization haskell lazy-evaluation ghc


    【解决方案1】:

    我在这方面写了很多文章:

    首先,是的,如果您想要求对累加器进行严格评估,请使用 seq 并留在 Haskell 98:

    mean = go 0 0
      where
        go s l []     = s / fromIntegral l
        go s l (x:xs) = s `seq` l `seq`
                          go (s+x) (l+1) xs
    
    main = print $ mean [0..10000000]
    
    *Main> main
    5000000.0
    

    其次:如果你给出一些类型注释,就会启动严格性分析,并使用 -O2 进行编译:

    mean :: [Double] -> Double
    mean = go 0 0
     where
      go :: Double -> Int -> [Double] -> Double
      go s l []     = s / fromIntegral l
      go s l (x:xs) = go (s+x) (l+1) xs
    
    main = print $ mean [0..10000000]
    
    $ ghc -O2 --make A.hs
    [1 of 1] Compiling Main             ( A.hs, A.o )
    Linking A ...
    
    $ time ./A
    5000000.0
    ./A  0.46s user 0.01s system 99% cpu 0.470 total
    

    因为 'Double' 是对严格原子类型 Double# 的封装,具有优化和精确类型,GHC 运行严格性分析并推断严格版本是可以的。

    import Data.Array.Vector
    
    main = print (mean (enumFromToFracU 1 10000000))
    
    data Pair = Pair !Int !Double
    
    mean :: UArr Double -> Double   
    mean xs = s / fromIntegral n
      where
        Pair n s       = foldlU k (Pair 0 0) xs
        k (Pair n s) x = Pair (n+1) (s+x)
    
    $ ghc -O2 --make A.hs -funbox-strict-fields
    [1 of 1] Compiling Main             ( A.hs, A.o )
    Linking A ...
    
    $ time ./A
    5000000.5
    ./A  0.03s user 0.00s system 96% cpu 0.038 total
    

    如上述 RWH 章节所述。

    【讨论】:

    • 好东西。很高兴了解 GHC 优化,感谢本书的链接,看起来是一个很好的资源。然而,当我看到某事的帖子时,让我感到震惊的是,在我看来,使用 seq 应该会破坏尾递归。 seq 必须在对 go 的递归调用被评估后才被评估,所以从我对尾递归的理解来看,它不应该再是尾递归,从而爆栈。这当然不会发生,所以这里发生了一些事情。 Haskell 会特别对待 seq 吗?还是我只是对尾递归感到困惑?
    • seq 在运行时不存在。这只是使用不同评估策略的提示。您将生成完全不同的代码。把它想象成一个 {-# STRICT_WHNF #-} pragma
    【解决方案2】:

    seq 函数在调用该函数后强制计算第一个参数。当您将seq s (s+x) 作为参数传递时,seq 函数不会立即调用,因为不需要评估该参数的值。您希望在递归调用之前评估对 seq 的调用,以便反过来强制评估其参数。

    通常这是完成的链接:

     go s l (x:xs) = s `seq` l `seq` go (s+x) (l+1) xs
    

    这是seq s (seq l (go (s+x) (l+1) xs)) 的语法变体。这里对seq 的调用是表达式中最外层的函数调用。由于 Haskell 的懒惰,这导致它们首先被求值:seq 被调用时使用仍然未求值的参数 sseq l (go (s+x) (l+1) xs),对参数的求值被推迟到有人实际尝试访问它们的值的时候。

    现在seq 可以在返回表达式的其余部分之前强制计算其第一个参数。然后评估的下一步将是第二个seq。如果对seq 的调用被隐藏在某个参数中的某个位置,它们可能很长时间都不会执行,从而无法达到目的。

    随着seqs 位置的改变,程序可以很好地执行,而不会使用过多的内存。

    该问题的另一个解决方案是在编译程序时简单地启用 GHC 中的优化(-O-O2)。优化器识别出可有可无的惰性并生成不分配不必要内存的代码。

    【讨论】:

    • 在没有 bang 模式的情况下,我喜欢这种方式,因为它将强制与递归调用分开,使其状态更清晰。
    【解决方案3】:

    您的理解是正确的,seq s (s+x) 强制评估 s。但它不会强制 s+x,因此您仍在构建 thunk。

    通过使用$!,您可以强制计算加法(两次,对于两个参数)。这实现了与使用 bang 模式相同的效果:

    mean = go 0 0
     where
        go s l []     = s / fromIntegral l
        go s l (x:xs) = ((go $! s+x) $! l+1) xs
    

    使用$! 函数会将go $! (s+x) 转换为等价于:

    let y = s+x 
    in seq y (go y)
    

    因此y首先被强制为弱头范式,这意味着应用了最外层的函数。在y 的情况下,最外层的函数是+,因此y 在传递给go 之前被完全评估为一个数字。


    哦,您可能收到了无限类型错误消息,因为您没有将括号放在正确的位置。当我第一次写下你的程序时,我遇到了同样的错误:-)

    因为$!运算符是右结合的,不带括号的go $! (s+x) $! (l+1)的意思和:go $! ((s+x) $! (l+1))一样,显然是错误的。

    【讨论】:

      猜你喜欢
      • 2015-09-06
      • 1970-01-01
      • 2015-09-06
      • 2022-01-13
      • 1970-01-01
      • 2016-10-21
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多