【问题标题】:Haskell Fibonacci sequence performance depending on methodologyHaskell Fibonacci 序列性能取决于方法
【发布时间】:2012-12-06 19:48:42
【问题描述】:

我尝试了不同的方法来获取斐波那契数列的给定索引处的数字,它们基本上可以分为两类:

  • 构建列表并查询索引
  • 使用变量(可能是单独的或元组的,没有列表)

我选择了两者的例子:

fibs1 :: Int -> Integer
fibs1 n = fibs1' !! n
    where fibs1' = 0 : scanl (+) 1 fibs1'

fib2 :: Int -> Integer
fib2 n = fib2' 1 1 n where
    fib2' _ b 2 = b
    fib2' a b n = fib2' b (a + b) (n - 1)

fibs1:

real    0m2.356s
user    0m2.310s
sys     0m0.030s

fibs2:

real    0m0.671s
user    0m0.667s
sys     0m0.000s

两者均使用 64 位 GHC 7.6.1 和 -O2 -fllvm 编译。它们的核心转储长度非常相似,但它们在我不太擅长解释的部分上有所不同。

fibs1 在 n = 350000 (Stack space overflow) 时失败,我并不感到惊讶。但是,我对它使用了这么多内存这一事实感到不舒服。

我想澄清一些事情:

  1. 为什么 GC 在整个计算过程中不处理列表的开头,即使其中大部分很快变得无用?
  2. 为什么 GHC 不将列表版本优化为可变版本,因为一次只需要其中的两个元素?

编辑:抱歉,我混合了速度结果,已修复。不过,我的三个疑问中有两个仍然有效;)。

【问题讨论】:

  • 无法重现,fib2 这里比fibs1 快很多。你如何运行代码?
  • 感谢您的关注。我有很多不同的版本,它们的命名不是很有想象力;)。
  • 您应该对fibs1 堆栈溢出感到惊讶。它这样做的原因与它使用这么多内存的原因相同。
  • 这远不是一个完整的答案,但它是一个数据点:scanl' f !y ls = case ls of { [] -> [y] ; (x:xs) -> y : scanl' f (f y x) xs }

标签: performance list haskell


【解决方案1】:

为什么 GC 不在整个计算过程中处理列表的开头,即使其中大部分很快变得无用?

fibs1 使用大量内存并且速度很慢,因为scanl 是惰性的,它不会评估列表元素,所以

fibs1' = 0 : scanl (+) 1 fibs1'

生产

0 : scanl (+) 1 (0 : more)
0 : 1 : let f2 = 1+0 in scanl (+) f2 (1 : more')
0 : 1 : let f2 = 1+0 in f2 : let f3 = f2+1 in scanl (+) f3 (f2 : more'')
0 : 1 : let f2 = 1+0 in f2 : let f3 = f2+1 in f3 : let f4 = f3+f2 in scanl (+) f4 (f3 : more''')

等等。所以你很快就会得到一个巨大的嵌套重击。在评估该 thunk 时,它会被压入堆栈,并且在 250000 到 350000 之间的某个时间点,它对于默认堆栈来说变得太大了。

并且由于每个列表元素在未评估时都包含对前一个元素的引用,因此列表的开头不能被垃圾收集。

如果您使用严格扫描,

fibs1 :: Int -> Integer
fibs1 n = fibs1' !! n
  where
    fibs1' = 0 : scanl' (+) 1 fibs1'
    scanl' f a (x:xs) = let x' = f a x in x' `seq` (a : scanl' f x' xs)
    scanl' _ a [] = [a]

当产生k-th 列表单元格时,它的值已经被评估,所以不引用先前的,因此列表可以被垃圾收集(假设没有其他东西持有对它的引用),因为它是遍历。

使用该实现,列表版本与fib2 一样快且精简(尽管如此,它需要分配列表单元,因此它分配了一点点,因此可能会慢一点点,但区别在于分钟,因为斐波那契数变得如此之大以至于列表构造开销变得可以忽略不计)。

scanl 的想法是其结果被增量消耗,因此消耗会强制元素并防止大型 thunk 的累积。

为什么 GHC 不将列表版本优化为可变版本,因为一次只需要两个元素?

它的优化器无法看穿算法来确定这一点。 scanl 对编译器是不透明的,它不知道 scanl 做了什么。

如果我们获取 scanl 的确切源代码(重命名它或从 Prelude 中隐藏 scanl,我选择重命名),

scans                   :: (b -> a -> b) -> b -> [a] -> [b]
scans f q ls            =  q : (case ls of
                                []   -> []
                                x:xs -> scans f (f q x) xs)

并编译导出它的模块(用-O2),然后用

查看生成的接口文件
ghc --show-iface Scan.hi

我们得到(例如,编译器版本之间的细微差别)

Magic: Wanted 33214052,
       got    33214052
Version: Wanted [7, 0, 6, 1],
         got    [7, 0, 6, 1]
Way: Wanted [],
     got    []
interface main:Scan 7061
  interface hash: ef57dac14815e2f1f897b42a007c0c81
  ABI hash: 8cfc8dab79de6a51fcad666f1869574f
  export-list hash: 57d6805e5f0b5f76f0dd8dfb228df988
  orphan hash: 693e9af84d3dfcc71e640e005bdc5e2e
  flag hash: 1e8135cb44ef6dd330f1f56943d1f463
  used TH splices: False
  where
exports:
  Scan.scans
module dependencies:
package dependencies: base* ghc-prim integer-gmp
orphans: base:GHC.Base base:GHC.Float base:GHC.Real
family instance modules:
import  -/  base:Prelude 1cb4b618cf45281dc97748b1831bf0cd
d79ca4e223c0de0a770a3b88a5e67687
  scans :: forall b a. (b -> a -> b) -> b -> [a] -> [b]
    {- Arity: 3, HasNoCafRefs, Strictness: LLL -}
vectorised variables:
vectorised tycons:
vectorised reused tycons:
scalar variables:
scalar tycons:
trusted: safe-inferred
require own pkg trusted: False

并且看到接口文件没有暴露函数的展开,只有它的类型、arity、严格性并且它不引用 CAF。

当一个模块导入被编译时,编译器所要经过的只是接口文件暴露的信息。

在这里,没有任何信息可以让编译器执行任何其他操作,但会发出对函数的调用。

如果展开被暴露,编译器就有机会内联展开并分析知道类型和组合函数的代码,以生成更多不构建thunk的急切代码。

然而,scanl 的语义是最大的惰性,输出的每个元素都会在检查输入列表之前发出。这导致 GHC 无法使添加变得严格,因为如果列表包含任何未定义的值,这会改变结果:

scanl (+) 1 [undefined] = 1 : scanl (+) (1 + undefined) [] = 1 : (1 + undefined) : []

同时

scanl' (+) 1 [undefined] = let x' = 1 + undefined in x' `seq` 1 : scanl' (+) x' []
                         = *** Exception: Prelude.undefined

一个人可以做一个变种

scanl'' f b (x:xs) = b `seq` b : scanl'' f (f b x) xs

这将为上述输入生成1 : *** Exception: Prelude.undefined,但如果列表包含未定义的值,任何严格性确实会改变结果,因此即使编译器知道展开,它也无法使评估严格 - 除非它可以证明列表中没有未定义的值,这一事实对我们来说是显而易见的,但编译器却不是[而且我认为教编译器认识到这一点并能够证明不存在未定义值并不容易].

【讨论】:

  • 我应该猜到是懒惰导致了堆栈溢出。不过,我并不怀疑scanl。我想我对Prelude 功能太信任了。您能否扩展 scanl 对编译器不透明的概念?
  • 我已经添加了一篇关于此的文章。在写这篇文章时,我看到即使它不是不透明的,编译器也无法使 scanl 严格[嗯,在某些情况下可以,但辨别这些将是一个令人惊讶的壮举]。
  • 感谢您的扩展信息;我将不得不调查界面的东西 - 它似乎有一些魔力:)。
猜你喜欢
  • 2017-02-18
  • 2015-02-16
  • 1970-01-01
  • 2010-12-02
  • 1970-01-01
  • 2014-03-14
  • 2019-11-29
  • 2016-07-17
  • 2013-11-05
相关资源
最近更新 更多