Performance 关于Haskell中性能的推理

Performance 关于Haskell中性能的推理,performance,haskell,functional-programming,lazy-evaluation,Performance,Haskell,Functional Programming,Lazy Evaluation,以下两个用于计算斐波那契序列第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)] fib1n= 案例n 0 -> 1 1 -> 1 x->(

以下两个用于计算斐波那契序列第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)]
fib1n=
案例n
0 -> 1
1 -> 1
x->(fib1(x-1))+(fib1(x-2))
fib2n=fibArr!!n在哪里

fibArr=1:1:[a+b |(a,b)在您特定的Fibonacci示例中,不难理解为什么第二个应该运行得更快(尽管您没有指定f2是什么)

这主要是一个算法问题:

  • fib1实现了纯粹的递归算法,而且(据我所知)Haskell没有“隐式记忆”机制
  • fib2使用显式记忆(使用fibArr列表存储以前计算的值)
一般来说,像Haskell这样懒惰的语言要比热切的语言更难做出性能假设。然而,如果你了解潜在的机制(尤其是懒惰),并收集一些经验,你将能够对性能做出一些“预测”

参考透明度通过(至少)两种方式提高(潜在)性能:

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

如果你想阅读更多关于Haskell设计选择背后的推理(惰性、纯粹性),我建议你阅读。

关于性能的推理在Haskell和惰性语言中通常是很难的,尽管不是不可能的。Chris Okasaki的(也在以前的版本中提供)中介绍了一些技巧

另一种确保性能的方法是固定评估顺序,可以使用注释,也可以使用注释。这样可以控制评估的时间

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

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实现使用记忆,但每次调用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
实际上,要使用记忆,正如你所看到的那样明确。如果你运行两次memoFib 20000,你会看到第一次调用占用的时间和空间,那么第二次调用是即时的,不会占用内存。没有魔术,也不会像注释所暗示的那样隐式记忆

现在谈谈您最初的问题:优化和推理Haskell中的性能

我不会说自己是Haskell的专家,我只使用了3年,其中2年是在我的工作场所,但我必须优化并理解如何对其性能进行某种程度的推理

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

检查以下各项的比较:

foldl实际上存储了“如何”计算值,即它是惰性的。在某些情况下,你会节省时间和空间,就像“无限”谎言一样。无限“谎言”不会生成所有这些谎言,但知道如何生成。当你知道你需要值时,你最好“严格地”得到它说到这里,严格的注释是有用的,它可以让你重新控制

我记得读过很多次,在lisp中,你必须“最小化”考虑

理解什么是严格计算的以及如何强制计算很重要,但了解您对内存的“破坏”程度也很重要。记住Haskell是不可变的,这意味着更新“变量”实际上是在修改后创建一个副本。使用(:)预加比使用(++)附加要高效得多,因为(:)不将内存复制到(++)。每当更新一个大的原子块(即使是单个字符)时,整个块都需要复制以表示“已更新”版本。您构造数据和更新数据的方式会对性能产生很大影响。ghc profiler是您的朋友,可以帮助您发现这些。确保垃圾收集器速度快,但不让它做任何事情会更快


干杯

因为分配是任何函数式语言的主要成本,了解性能的一个重要部分是了解对象何时被分配、它们的寿命、它们何时死亡以及何时被回收。要获得这些信息,您需要一个堆分析器。它是一个必不可少的工具,幸运的是GHC提供了有一个好的


欲了解更多信息,请阅读的论文。

除了备忘录问题,fib1还使用非tailcall递归。tailcall递归可以自动重新分解为一个简单的goto,并且执行得非常好,但是fib1中的递归不能以这种方式优化,因为您需要来自fib1每个实例的堆栈框架来计算计算结果。如果您重写fib1以将运行总计作为参数传递,从而允许尾部调用,而无需保留堆栈帧以进行最终添加,则性能将大大提高。但不如