关于Haskell性能的推理

时间:2010-01-14 13:43:47

标签: performance haskell functional-programming lazy-evaluation

以下两个用于计算Fibonacci序列第n项的Haskell程序具有非常不同的性能特征:

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,执行时间也会成为一个问题,这表明总是会评估递归步骤。参考透明度是否对Haskell的性能有贡献?我怎么能提前知道它是否会?

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


摘要:我接受了3lectrologos的答案,因为关于语言的性能,关于编译器的优化,你没有那么多理由这一点似乎在Haskell中非常重要 - 更多比我熟悉的任何其他语言都要多。我倾向于说编译器的重要性是区分懒惰,功能语言中的性能推理的因素,而不是推理任何其他类型的性能。


附录:对此问题发生的任何人可能希望查看来自the slides Johan Tibelltalk about high performance Haskell

5 个答案:

答案 0 :(得分:11)

在你特定的Fibonacci例子中,不难看出为什么第二个应该运行得更快(尽管你没有指定f2是什么)。

这主要是一个算法问题:

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

一般来说,对于像Haskell这样的惰性语言而言,要比一个急切的语言做出性能假设要困难得多。然而,如果您了解潜在机制(特别是对于懒惰)并收集一些体验,您将能够对性能做出一些“预测”。

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

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

如果你想了解更多关于Haskell的设计选择(懒惰,纯粹)背后的原因,我建议你阅读this

答案 1 :(得分:6)

在Haskell和懒惰语言中,关于性能的推理通常很难,尽管并非不可能。 Chris Okasaki的Purely Function Data Structures(以前版本中也可用online)涵盖了一些技巧。

确保绩效的另一种方法是使用注释或continuation passing style修复评估顺序。这样你就可以控制什么时候进行评估。

在您的示例中,您可以计算“自下而上”的数字,并将前两个数字传递给每次迭代:

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个结果,你可以使用这种技术。否则,您可能必须使用数组或完全不同的内容。

答案 2 :(得分:5)

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

Prelude> :set +s

如果它正在进行“两次”调用之间的记忆,则后续调用将更快并且不使用内存。拨打fib2 20000两次,亲眼看看。

通过比较,您可以定义更精确的数学标识:

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

memoFib n = fibs !! n

实际上确实使用了memoisation,如你所见。如果你运行memoFib 20000两次,你将看到第一次采取的时间和空间,然后第二次调用是瞬时的,不占用内存。没有任何魔法,也没有像评论那样隐含的记忆可能暗示过。

现在谈谈你原来的问题:在Haskell中优化和推理性能......

我不认为自己是Haskell的专家,我只使用了3年,其中2年在我的工作场所,但我确实需要优化并了解如何推断其性能。

如其他帖子中提到的懒惰是你的朋友,可以帮助你获得表现,但是你必须控制懒惰的评价和严格评价的内容。

检查foldl vs foldr

的比较

foldl实际上存储了“如何”来计算该值,即它是懒惰的。在某些情况下,你可以节省时间和空间,就像“无限”的纤维一样。无限的“纤维”不会产生所有这些但是知道如何。当你知道自己需要价值时,你也可以“严格地”说出来......这就是严格注释有用的地方,可以让你重新掌控。

我记得多次读过,在口齿不清中,你必须“尽量减少”。

了解严格评估的内容以及如何强制执行它很重要,但理解你对记忆的“捣乱”程度也是如此。记住Haskell是不可变的,这意味着更新“变量”实际上是在创建一个带有修改的副本。前缀为(:)比附加(++)更有效,因为(:)不会将内存复制到(++)。每当更新一个大的原子块时(即使是单个字符),整个块都需要被复制以表示“更新”版本。构建数据和更新数据的方式会对性能产生很大影响。 ghc profiler是你的朋友,可以帮助你发现这些。当然垃圾收集器很快但没有做任何事情更快!

干杯

答案 3 :(得分:2)

除了memoization问题,fib1还使用非tailcall递归。 Tailcall递归可以自动重新计算到一个简单的goto中并且执行得很好,但是fib1中的递归不能以这种方式优化,因为你需要来自fib1的每个实例的堆栈帧以便计算结果。如果你重写了fib1以传递一个运行总数作为参数,从而允许尾调用而不是需要保留最后添加的堆栈帧,性能将会极大地提高。但不如备忘的例子那么多,当然:))

答案 4 :(得分:1)

由于分配是任何功能语言的主要成本,理解性能的一个重要部分是了解何时分配对象,它们存活多长时间,何时死亡以及何时被回收。要获取此信息,您需要堆分析器。这是一个必不可少的工具,幸运的是GHC配备了一个很好的工具。

有关详情,请参阅Colin Runciman的论文。