【问题标题】:Haskell Fibonacci: Tail-Recursive slower than the classic lazy list approach?Haskell Fibonacci:尾递归比经典的惰性列表方法慢?
【发布时间】:2017-02-18 22:12:30
【问题描述】:

免责声明:前面可能有一些可疑的 Haskell。

我已经实现了 2 个版本的 Fibonacci,一个是线性和尾递归的(包括一个 banged 变体),另一个是使用经典的惰性列表方法:

{-# LANGUAGE BangPatterns #-}

-- Linear
fibLinear :: Int -> Integer
fibLinear 0 = 0
fibLinear 1 = 1
fibLinear x = fibLin' 2 1 2
  where
      fibLin' i y z | i == x = y
      fibLin' i y z          = fibLin' (i+1) z (z+y)

-- Linear banged
fibLinearBang :: Int -> Integer
fibLinearBang 0 = 0
fibLinearBang 1 = 1
fibLinearBang x = fibLin' 2 1 2
  where
      fibLin' (!i) (!y) (!z) | i == x = y
      fibLin' (!i) (!y) (!z)          = fibLin' (i+1) z (z+y)

-- Haskeller classic lazy
fibClassic :: Int -> Integer
fibClassic n = fib' !! n
    where fib' = 0:1:zipWith (+) fib' (tail fib')

当使用 criterion 对其进行基准测试 (code) 时,我得到了一些不直观的结果:fibLinear 的 Fib 30 需要 443.0 ns,fibLinearBang 需要 279.5 ns,而 fibClassic 需要 73.51 ns。这很奇怪,因为我认为 fibLinearfibLinearBang 可以优化成循环或跳转之类的东西。

我唯一的猜测是 fibClassic 中的 fib' 可能被记忆为一个常量列表,尽管它位于函数定义中,以便函数的后续调用重用相同的列表进行查找。

有人可以对我的基准测试结果给出明确的解释吗?

对于那些感兴趣的人,我已将项目放在Github

【问题讨论】:

  • 我会尝试使fibLin' 的所有论点严格并再次进行基准测试。你可以这样做,例如在第一种情况下使用fibLin' (!_) (!_) (!_) | ...,并启用相对扩展BangPatterns
  • 您的优化级别是多少?尾递归仅在严格时才像循环性能一样起作用,而 GHC 可能只会使您的代码在某些优化级别上严格。懒惰的版本绝对不会被记忆,我不确定这有多大帮助(你不需要重新计算列表的开头,但你仍然需要迭代它和计算不需要那么多时间)。默认情况下它相对较快。
  • 还有为什么fibLin' 会产生一个元组?我没有看到你使用过它的第二个元素。
  • 严格化i 是没有意义的,因为在每一步都与x 进行比较,因此是强制的。但是其他参数确实可能是大笨蛋,但是如果 Core 包含 Int#s 而不是 Ints 并且因此刘海无济于事,我不会感到惊讶。但是您还在每个递归步骤中将x01 进行比较,这是多余的:您可以只做一个。而且完全懒惰真的不能记住fib'吗?不过,我认为这样的记忆不会提高性能,甚至根本不会。
  • 您的基准测试并不是您认为的基准测试 - 尝试fib 100k 或其他一些较大的值。对于单次调用,“经典惰性”版本分配更多内存,耗时 5 倍(在我的机器上,n=80k)并且有近 80% 的 GC 时间(相比之下,fibBang 为 5%。标准报告说@ 987654346@ 运行得更快,因为fib' 浮动到顶层并保留给fibClassic 的所有调用(看看核心!)。如果这个结果仍然令人惊讶,请回想一下 GHC Haskell 已针对惰性数据进行了高度优化结构。

标签: haskell recursion optimization tail-recursion microbenchmark


【解决方案1】:

tl;dr:fib' 确实被跨调用重用了

感谢评论者,我了解到full-laziness 不是(仅)用于描述 Haskell 评估方式的术语,而是一个实际的编译器优化术语,默认情况下启用,但可以禁用(通过使用-fno-full-laziness GHC-option 标志。

使用该标志重新运行基准测试,我们看到表格已经转变:

  • fibLinearBang 259.8 ns
  • fibClassic 738.4 ns

这种性能以及this guide on GHC compiler options 是非常令人信服的证据,证明fib' 确实被转换为类似顶级引用的东西,在方法的不同调用中被重用(并且是一个列表,被记忆)。

该指南和 paper it links to describing let-lifting 都承认 let-lifting 可能会导致内存驻留增加,这对我来说有点可怕(不要使用 fibClassic 编写 fib-as-a-service 并将其暴露给互联网...),但我可能会考虑它..

【讨论】:

  • “非常有说服力的证据”似乎是一个弱条件,因为您可以查看核心并确定。编译的结果取决于编译器版本、标志、优化、猜测会发生什么以及哪些“优化”触发通常会导致像这样的长期头痛。我不确定您为什么认为-fno-full-laziness 是一个“解决方案”——您使fibClassic 的性能更差,而fibLinearBang 的性能却没有任何变化。使用编译器标志让一个程序比另一个程序更糟糕,因为你认为它应该这样做似乎完全适得其反。
  • ““当你只看核心并确定知道时,“相当有说服力的证据”似乎是一个弱条件。”:关键是找出为什么 fibClassic (恕我直言,违反直觉)击败fibLinearBangfno-full-laziness 影响了前者而不是后者,这一事实表明我们可能已经找到了原因。如果您有更好的证据证明 fib' 正在被重用,或者有其他解释性能差异的原因,则可以从提供答案开始。我对您所说的“查看核心”以及如何在此处应用它感兴趣。
  • 您查看核心,在其中您会看到由您的代码实际生成的程序,并观察到在此程序中fib' :: [Integer] 浮动到顶层,使其成为 CAF(常量申请表),这意味着它(即单个副本)将在程序执行期间保留。 “完全懒惰”不可能影响fibLinearBang,因为即使您要手动将函数浮动到顶层,它的计算频率也不会降低 - 它只被fibLinearBang 调用一次。
  • 你一直在说“看看核心”,但我还没有看到你描述在这种情况下如何做到这一点。此外,你可以继续批评这个问题,但它仍然让 SO 和 Haskell 分数比你自己高的评论者感到困惑,这表明完全惰性优化并不直观,而且并不是每个人都知道他们何时可以依赖它(在 @987654323 @, cases 导致头痛/抓伤)。
猜你喜欢
  • 2011-04-22
  • 2015-01-18
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2015-02-16
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多