关于性能:何时在Haskell中严格评估?

When to evaluate strictly in Haskell?

据我所知,!(称为bangs)用于表示表达式应严格计算。但对我来说,把它们放在哪里并不明显,或者根本不明显。

1
2
3
4
5
6
import qualified Data.Vector.Unboxed as V

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

mean :: V.Vector Double -> Double

不同版本的平均值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
-- 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这样的简单函数有意义吗?在这种函数中,所有的东西都应该一次性评估。


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

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

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

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

但是这个f就是行动的地方。在您的示例中,注释(len,acc)的不同方式似乎触发了编译器采用与f相关的微妙不同的视图。这就是为什么一切看起来都有点神秘。要做的是直接与f打交道。

主要的面包和黄油点是,在一个左折或积累环,所有积累的材料必须严格评估。否则,你只需要用foldl'建立一个大的表达式,然后当你最终对你积累的材料做一些事情时,要求它崩溃,这里是平均值的最终计算。

不过,在您的示例中,foldl'从来没有被赋予明确严格的折叠函数:累积的lenacc被困在一个普通的惰性haskell元组中。

严格性的问题出现在这里,因为您积累了不止一件事情,但需要将它们结合到一个论点中,用于您要传递给foldl'的f操作。这是一个典型的情况,写一个严格的类型或记录来做积累;这需要一个短行

1
data P = P !Int !Double

然后你可以写

1
2
3
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)

请注意,我没有用bang来标记(P len acc),因为它明显地处于弱头正常形式-您可以看到p,不需要让编译器找到它!/因此,f在第一个论点中是严格的。同样的情况也适用于您向f添加严格性的情况。

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

但是功能

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

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

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

而在我的机器上,你最好的办法是:

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


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

净效应是只要你不碰一个返回值,什么都不会被评估。一旦您严格地需要从中得到一点信息,整个结构就会立即被评估,这将导致比在整个执行过程中被懒惰地评估更可预测的内存/CPU使用模式。

Johan Tibell的性能指南最能指出细微之处:http://johan tibell.com/files/haskell performance patterns.html(1)。请注意,最近的GHC自动执行小而严格的字段解包,而不需要注释。另请参见严格的pragma。

关于什么时候引入严格的领域:从一开始就做正确的事情,因为回顾性地使用它要困难得多。您仍然可以使用懒惰的字段,但只有当您明确需要它们时才可以使用。

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

注2:有专门的libs可以让您处理严格的折叠(参见foldl),或者处理流计算(管道、管道)。

更新

对其原理进行了一点阐述,以便1)你知道这不仅仅是为了让橡皮鸭离开天空2)知道何时/为什么要偏离。

为什么评价严格?

如问题所述,一个例子是严格的积累。这也有一些不太明显的形式——比如统计在应用程序状态下发生的某些事件。如果不存储一个严格的计数,您可以得到一个很长的+1thunks链,它会毫无理由地消耗大量内存(而不是只存储更新的计数)。

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

另一种情况是并发计算,其中工作被划分为多个线程。现在,很容易遇到这样的情况:您认为您将一个计算分叉到一个单独的线程(使您的程序非常有效地并发),但后来才意识到并发线程只计算懒惰数据结构的最外层,当值为强迫。

解决这个问题的方法是使用Deepseq的NFData。但是,假设有一个最终的数据结构分层A (B (C)),其中每一层都由一个单独的线程计算,在返回之前对结构进行深度强制。现在,C被深度强制(实际上是在内存中遍历)三次,B两次。如果C是深/大结构,这是浪费。此时,您可以添加Once技巧,也可以使用非常严格的数据结构,其中对whnf执行浅强制(而不是对deep nf)具有与深强制相同的效果,但可以说,一次性技巧由编译器负责。

现在,如果您保持一致并意识到,使用deepseq+一次就可以了。

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