关于Haskell性能的推理

Reasoning about performance in Haskell

以下两个用于计算斐波那契数列第n项的haskell程序具有很大的不同性能特征:

1
2
3
4
5
6
7
8
fib1 n =
  case n of
    0 -> 1
    1 -> 1
    x -> (fib1 (x-1)) + (fib1 (x-2))

fib2 n = fibArr !! n where
  fibArr = 1:1:[a + b | (a, b) <- zip fibArr (tail fibArr)]

它们在数学上非常相似,但fib2使用列表表示法来记忆其中间结果,而fib1具有显式递归。尽管中间结果可能缓存在fib1中,但即使对于fib1 25来说,执行时间也会成为一个问题,这意味着总是对递归步骤进行评估。参考透明度对哈斯克尔的表现有什么影响吗?我怎么能提前知道会不会?

这只是我担心的事情的一个例子。我想听听关于克服对一种执行缓慢的函数式编程语言的性能进行推理所固有的困难的任何想法。

小结:我接受3electologos的回答,因为你对语言的性能,以及编译器的优化没有太多的理由,这一点在haskell中似乎非常重要,比我熟悉的任何其他语言都重要。我倾向于说,编译器的重要性是区分在懒惰的函数语言中关于性能的推理与任何其他类型的性能推理的因素。

附录:任何遇到这个问题的人都可能想看看a href="http://blog.johan tibell.com/2010/09/slides-from-my-high-performance-haskell.html">slides/aa from a href="http://blog.johantibell.com/">johan tibell/aa's a href="http://cufp.org/conference/sessions/2010/high-performance-haskell">Talk about high-performance-haskell{a/a}。


在特定的斐波那契例子中,不难理解为什么第二个应该运行得更快(尽管您没有指定F2是什么)。

这主要是一个算法问题:

  • fib1实现了纯递归算法,并且(据我所知)haskell没有"隐式记忆化"机制。
  • fib2使用显式memoization(使用fibarr列表存储以前计算的值)。

一般来说,对于像haskell这样的懒惰语言,要做出性能假设要比对渴望的语言作出性能假设困难得多。然而,如果您了解底层机制(尤其是对于懒惰),并收集一些经验,您将能够对性能做出一些"预测"。

参考透明度以两种方式(至少)提高(潜在)绩效:

  • 首先,您(作为程序员)可以确保对同一个函数的两个调用总是返回相同的结果,因此您可以在各种情况下利用这一点来提高性能。
  • 其次(更重要的是),haskell编译器可以确保上述事实,这可能会启用许多不能用不纯语言启用的优化(如果您曾经编写过编译器或对编译器优化有任何经验,您可能会意识到这一点的重要性)。

如果你想更多地了解哈斯克尔设计选择(懒惰,纯粹)背后的推理,我建议你阅读这个。


在haskell和lazy语言中,关于性能的推理通常很难,尽管并非不可能。一些技术包含在ChrisOkasaki的纯函数数据结构中(在以前的版本中也可以在线获得)。

另一种确保性能的方法是使用注释或连续传递样式来修复评估顺序。这样你就可以控制评估的时间。

在您的示例中,您可以计算"自底向上"的数字,并将前两个数字传递给每个迭代:

1
2
3
4
5
fib n = fib_iter(1,1,n)
    where
      fib_iter(a,b,0) = a
      fib_iter(a,b,1) = a
      fib_iter(a,b,n) = fib_iter(a+b,a,n-1)

这就产生了线性时间算法。

每当有一个动态编程算法,其中每个结果都依赖于前面的n个结果时,就可以使用此技术。否则,您可能必须使用数组或完全不同的东西。


fib2的实现使用memoization,但每次调用fib2时,它都会重建"整体"结果。打开GHCI时间和大小分析:

1
Prelude> :set +s

如果它在两次通话之间进行记忆,那么随后的通话会更快,而且不会占用内存。打2万国际篮联两次电话,自己看看。

通过比较一个更惯用的版本,您可以定义精确的数学恒等式:

1
2
3
4
-- the infinite list of all fibs numbers.
fibs = 1 : 1 : zipWith (+) fibs (tail fibs)

memoFib n = fibs !! n

事实上,使用记忆法,如你所见。如果运行memofib 20000两次,您将看到第一次占用的时间和空间,那么第二次调用是即时的,不占用内存。没有任何魔术,也没有像评论那样的含蓄记忆。

关于你最初的问题:优化和推理哈斯克尔的表现…

我不会称自己为哈斯克尔的专家,我只使用它3年,其中2年是在我的工作场所,但我必须优化并理解如何对它的性能做出一些解释。

正如其他后懒惰中提到的,是你的朋友,可以帮助你获得绩效,但是你必须控制什么是懒惰的评价,什么是严格的评价。

检查foldl与folder的比较

foldl实际上存储了计算值的"how",也就是说,它是懒惰的。在某些情况下,你会因为懒惰而节省时间和空间,就像"无限"的谎言。无限的"谎言"并不是全部产生的,而是知道如何产生的。当你知道你需要价值的时候,你也可以"严格"地说…这就是严格注释的用处所在,它可以让您返回控制权。

我记得我读过很多次,在Lisp中,你必须"最小化"考虑。

理解被严格评估的内容以及如何强制它是很重要的,但是理解你对记忆的"破坏"程度也是很重要的。记住haskell是不可变的,这意味着更新一个"变量"实际上是用修改创建一个副本。加(:)比加(++)要有效得多,因为(:)不会将内存复制到(++)。每当一个大的原子块被更新时(即使对于单个字符),需要复制整个块来表示"更新"的版本。您构造数据和更新数据的方式会对性能产生很大影响。GHC探查器是您的朋友,将帮助您发现这些。当然垃圾收集器很快,但是不让它做任何事情都会更快!

干杯


除了记忆化问题,fib1还使用非尾调用递归。tailcall递归可以自动重新分解为一个简单的goto并执行得很好,但是fib1中的递归不能这样优化,因为您需要fib1每个实例的堆栈帧来计算结果。如果重写fib1以传递一个正在运行的total作为参数,从而允许尾部调用而不需要为最后添加保留堆栈帧,那么性能将大大提高。当然,并没有记忆中的例子那么多。


由于分配是任何函数语言的主要成本,理解性能的一个重要部分是理解对象何时被分配、生存时间、死亡时间以及何时被回收。要获取此信息,需要一个堆分析器。它是一个必不可少的工具,幸运的是,GHC提供了一个好的工具。

有关更多信息,请阅读科林·伦西曼的论文。