【问题标题】:When to evaluate strictly in Haskell?何时在 Haskell 中严格评估?
【发布时间】:2016-09-11 23:38:12
【问题描述】:

据我所知,!(称为 bangs)用于表示应严格评估表达式。但对我来说,将它们放在哪里或根本就不是那么明显。

import qualified Data.Vector.Unboxed as V

main :: IO ()
main = print $ mean (V.enumFromTo 1 (10^9))

mean :: V.Vector Double -> Double

mean 的不同版本:

-- compiled with O2 ~ 1.14s
mean xs = acc / fromIntegral len
    where !(len, acc)    = V.foldl' f (0,0) xs :: (Int, Double)
          f (len, acc) x = (len+1, acc+x)

-- compiled with O2 ~ 1.18s
mean xs = acc / fromIntegral len
    where (!len, !acc)   = V.foldl' f (0,0) xs :: (Int, Double)
          f (len, acc) x = (len+1, acc+x)

-- compiled with O2 ~ 1.75s
mean xs = acc / fromIntegral len
    where (len, acc)      = V.foldl' f (0,0) xs :: (Int, Double)
          f !(len, acc) x = (len+1, acc+x)

-- compiled with O2 ~ 1.75s
mean xs = acc / fromIntegral len
    where (len, acc)      = V.foldl' f (0,0) xs :: (Int, Double)
          f (len, acc) x = (len+1, acc+x)

-- compiled without options ~ 6s
mean xs = acc / fromIntegral len
    where (len, acc)     = V.foldl' f (0,0) xs :: (Int, Double)
          f (len, acc) x = (len+1, acc+x)

-- compiled without options ~ 12s
mean xs = acc / fromIntegral len
    where !(len, acc)    = V.foldl' f (0,0) xs :: (Int, Double)
          f (len, acc) x = (len+1, acc+x)

其中一些在直觉上是有意义的,但我希望它不是一种反复试验的方法。

  • 是否有某种方法可以检测延迟评估何时会影响性能?除了严格测试之外。

  • 它是否只对像 mean 这样的简单函数有意义,因为它应该一次性评估所有内容?

【问题讨论】:

  • 如果你使用-O2 并且根本没有爆炸模式怎么办?
  • @dfeuer 刚刚添加了它。就像 1.75 秒的那个位置不好的爆炸
  • 你是如何调用这个函数的,用什么参数?我发现结果有点令人惊讶。我猜 GHC 的严格性分析在这里不是很好用!
  • @dfeuer 我用stack build --ghc-options -O2 && time stack exec <project>在shell中构建并运行它
  • 我最惊讶的是第一个,敲打最终结果对,会做任何有用的事情;最终的结果显然是严格的!

标签: performance haskell optimization lazy-evaluation


【解决方案1】:

不是一成不变的,但目前的最佳做法是使数据结构中的所有字段都严格,但采用函数参数并延迟返回结果(累加器除外)。

最终效果是,只要您不触及返回值,就不会评估任何内容。只要您严格需要其中的一点点,就会立即评估整个结构,从而产生比在整个执行过程中懒惰地评估更可预测的内存/cpu 使用模式。

Johan Tibell 的性能指南最能指出细微之处:http://johantibell.com/files/haskell-performance-patterns.html#(1)。请注意,最近的 GHC 无需注释即可自动执行小型严格字段解包。另请参阅Strict 编译指示。

关于何时引入严格字段:从一开始就做好,因为追溯起来更难。您仍然可以使用惰性字段,但仅在您明确需要它们时使用。

注意:[] 是惰性的,并且更多地用作预期内联的控制结构,而不是用作容器。后者使用vector 等。

注意 2:有专门的库可让您处理严格折叠(请参阅 foldl)或流计算(conduitpipes)。

更新

对基本原理进行一些详细说明,以便 1)您知道这不仅适用于天空中的橡皮鸭 2)知道何时/为什么要偏离。

为什么要评估严格?

如问题中所述,一种情况是严格累积。这也以不太明显的形式出现 - 例如记录应用程序状态中发生的某些事件。如果您不存储严格的计数,则可能会累积一长串 +1 thunk,这会无缘无故地消耗大量内存(与仅存储更新的计数相比)。

上面被非正式地称为memory leak,即使从技术上讲它不是泄漏(没有丢失内存,只是保存的时间比需要的时间长)。

另一种情况是并发计算,其中工作被分配到多个线程中。现在,很容易遇到您认为将计算分叉到单独线程(使您的程序非常有效地并发)的情况,后来才意识到并发线程只计算惰性数据结构的最外层,并且强制值时,大部分计算仍然发生在您的主线程上。

解决方法是使用来自deepseqNFData。但是想象一下有一个分层的最终数据结构A (B (C)),其中每一层都由一个单独的线程计算,在返回之前深度强制该结构。现在C 被深度强制(实际上在内存中遍历)三次,B 两次。如果C 是一个深/大的结构,这是一种浪费。此时您可以添加Once trick,或者只使用非常严格的数据结构,其中对WHNF(而不是对深度NF)进行浅层强制具有与深层强制相同的效果,但要注意Once-trick由编译器,可以这么说。

现在,如果您始终如一且有意识,那么使用 deepseq+Once 可能会做得很好。

注意:与并发评估非常相似的用例是在 纯错误 的可怕情况下的单线程评估,例如 undefinederror。理想情况下不使用这些,但如果使用,解决问题的方法与上面概述的非常相似(顺便请参阅spoon 包)。

【讨论】:

  • 拥有惰性字段通常很有帮助。如果您想在其中存储函数,通常应该这样做。惰性结构比严格的结构更能支持某些操作。通常是 fmap(<*>)traverse 之类的任何东西,包括镜头。
  • 不应该反过来吗?显式地引入严格性和隐式地引入惰性?
  • dfeuer: 1) “存储函数” - 你的意思是存储 thunk/延迟计算?是的,如果这是您的明确意图,为什么不这样做。 2)我猜你的意思是因为你可以减少数据复制?但是融合不应该减少一点差异(至少fmap)?
  • TomTom:不确定你指的是什么。我建议的方式是明确注释严格性(但默认情况下这样做)。
【解决方案2】:

在您的示例中,爆炸模式围绕平均值的最终计算,或者更确切地说是其成分:

where (!len, !acc)   = V.foldl' f (0,0) xs :: (Int, Double)
where !(len, acc)   = V.foldl' f (0,0) xs :: (Int, Double)

但是(有一个明显的例外)不是第二项,折叠功能本身:

       f (len, acc) x = (len+1, acc+x)

但是这个f 是行动所在。在您的示例中,您注释 (len,acc) 的不同方式似乎正在触发编译器对如何处理 f 采取微妙的不同观点。这就是为什么一切都显得有些神秘的原因。要做的就是直接处理f

主要的要点是在左折叠或累积循环中,必须严格评估所有累积的材料。否则你基本上只是用foldl' 构建一个大表达式,然后在你最终用你积累的材料做某事时要求它折叠起来——在这里,最后计算平均值。

不过,在您的示例中,foldl' 从未被赋予明确严格的折叠函数:累积的 lenacc 被困在一个常规的惰性 Haskell 元组中。

这里出现了严格的问题,因为您要积累不止一件事,但需要将它们绑定到一个参数中,以便传递给 foldl' 的 f 操作。这是写严格类型或记录做累加的典型案例;这需要一条短线

data P = P !Int !Double

那你就可以写了

mean0 xs = acc / fromIntegral len
    where P len acc    = V.foldl' f (P 0 0) xs 
          f (P len acc) !x = P (len+1) (acc+x)

请注意,我没有用爆炸标记(P len acc),因为它明显处于弱头正常形式 - 您可以看到 P 并且不需要让编译器使用 !/seq 找到它 - 因此f 在第一个参数中是严格的。在将严格性添加到 f 的一种情况下也是如此

          f !(len, acc) x = (len+1, acc+x)

但功能

          f (len, acc) x = (len+1, acc+x)

在第一个参数中已经是严格的,因为你可以看到最外面的构造函数(,),并且不需要严格注释(它基本上只是告诉编译器找到它。)但是构造函数只是构造一个惰性元组,所以它在 lenacc 中并不(明确)严格

$ time ./foldstrict
5.00000000067109e8
real    0m1.495s

而在我的机器上,你最好的意思是:

$ time ./foldstrict
5.00000000067109e8
real    0m1.963s

【讨论】:

  • 你之前对我的一个问题写了一个很长的答案,但你把它删除了。我找到你是因为我只是想说谢谢你的努力。它真的帮助了我!