Haskell一直让我惊喜不已。例如,我遇到了一段关于递归fib数组的代码,而不是规范的递推fib函数,同时又不牺牲优雅、简洁和简单。
ghci> fib = 0 .. 1 .. zipWith (+) fib (tail fib)
尽管我相信递归fib数组应该表现得更好,但我还是故意在回溯计算机上运行它,观察它的实际运行情况,
ghci> :set +s
ghci> fib !! 1000000
(107.07 secs, 44,216,491,400 bytes)
一个CPU核心全速运行,而内存消耗没有注意到任何变化。显然,GC在已实现的fib数组上做得不错。
不仅语言的设计,而且语言的实现都不乏智慧之珠。我想知道除了最终的用户指南之外,是否还有关于GHC实施的整体叙述。
@EDIT
为了遵守发布指南并重新打开问题,我试图澄清并总结以下内容;
- 这不仅仅是为了寻找一些参考链接,我怀疑谷歌傅是否能找到一个。我当然也很高兴听到,除此之外
- 如果需要分类的话,我会把这个问题归结为启发式类型
- 非常感谢大家围绕这个问题发表了宝贵而富有洞察力的评论和回答。哦,他们在下面的问题上启发了我更具体的东西
是否涉及GHC抢占式优化来实现上述递归fib数组?GHC是如何确定它是否应该优化的?
最好,您能提供GHC实施的高级别概述,并专注于评估策略和优化技术的整体叙述吗?
当我们用Haskell编写一段代码时,什么样的声音指示器适合积极的GHC优化,最好是惯用的?
提前感谢!
代码中的头号性能成本是不理解Haskell的评估模型。在使用优化进行编译时,GHC非常努力地保护程序员免受这种形式的错误的影响,但ghci不应用优化。
这里的问题来自于zipWith
不适合生成列表,其中后面的元素对前面的元素具有计算依赖性。当以这种方式使用时,它会导致产生递归数据类型的#1 Haskell性能错误,该递归数据类型可以通过允许构建嵌套的未赋值表达式的方式进行遍历。当然,(!!)
恰好以这种方式遍历列表——它在倒计时时只计算(:)
构造函数。使用(!!)
通常是一个错误,原因有很多,但在这种情况下,我不能责怪它。适当的评估规则要求生产商确保没有不良的消费模式,而zipWith
无法确保这一点。
ghci> :set +s
ghci> let zipWith' f (x:xs) (y:ys) = let z = f x y in z `seq` z:zipWith' f xs ys; zipWith' _ _ _ = []
(0.01 secs, 33,216 bytes)
ghci> fib = 0:1:zipWith' (+) fib (tail fib)
(0.00 secs, 33,200 bytes)
ghci> fib !! 1000000
<extremely long result not included>
(6.91 secs, 43,747,100,560 bytes)
ghci> (fib !! 1000000) * 0
0
(3.77 secs, 43,545,996,856 bytes)
FWIW,6.91秒包括打印结果所需的时间,这在我的终端上非常慢。当它只需要打印一个数字时,您可以看到执行时间差。尽管如此,3.77秒对于该计算来说是慢。实际上编译它会执行得更好。
您还可以看到输出中显示的评论。fib
的不同使用之间没有计算共享,因为它是一个类型类多态值。当多次使用它时,这是相关的,但这不是您遇到的性能问题。通过(定义和)使用zipWith'
巧妙地解决了您遇到的性能问题,这使调用方能够在遍历结果列表中的(:)
构造函数时防止未求值表达式的堆积。
你对fib
的定义本质上是fib = 0 : 1 : let x1 = 0 + 1 in x1 : let x2 = 1 + x1 in x2 : let x3 = x1 + x2 in x3 : let x4 = x2 + x3 in x4 : ...
。当用(!!)
遍历其结果时,在将整个值相加之前,不会对中间的xN
值进行求值。这在垃圾收集器中引入了大量的阻力,因为有一百万个(+)
的嵌套应用程序从未被评估过——它们只是位于内存中。另一方面,我的zipWith'
函数使每个xN
在包含它的(:)
构造函数被求值时被求值。这实际上与fib
不共享的事实很好地配合,因为它允许ghci丢弃xN
的旧值。工作集保持较小,垃圾收集非常快,运行时间主要用于必要的工作。
如果zipWith'
更好,为什么我必须自己定义它?好吧,因为只有当你像fib
的定义那样打结时,它才会更好。在更常见的情况下,zipWith
不会生成任何类型的未求值表达式的嵌套,因此zipWith'
提供的额外求值可能会适得其反。此外,在涉及打结的更复杂的情况下,zipWith'
并不能神奇地让事情变得更好。您仍然需要确保传递给它的函数进行适当的求值。
一般来说,理解Haskell中的评估依赖性是一项需要从头开始学习的新技能。在某些模式下,让一切都在当地变得好,就意味着一切都在全球变得好。这可能会让很多人感到惊讶,但我上面描述的规则是一个好的开始:确保递归数据没有遍历模式,可以构建嵌套的未赋值表达式。如果生产商这样做,所有消费者只需要在本地正确,一切才能在全球正确。但你仍然需要学习如何做到这一点。你需要学会思考评估依赖关系,seq
实际做了什么,库函数做了什么和不做什么。有很多,当它来自基本上任何其他编程语言时,都是非常陌生的。
更困难的是,GHC的优化经常掩盖这些错误。如果按照编写的方式进行评估,编写性能会很差的代码是很容易的,但GHC会识别出问题并重写它以使其性能良好。这可能导致很难预测GHC何时会修复您的代码,何时不会。有些人直到事情变得太复杂,GHC看不到问题,才知道他们在使用糟糕的模式编写代码——在这一点上,他们得到了既机械复杂又从未学会如何思考的东西。这些东西最好早点学。尤其不要将-XStrict
、-XStrictData
或-XUnliftedDataTypes
视为神奇的解决方案。这些扩展之所以存在,是因为它们解决了某些问题,但如果你不了解事情是如何工作的,它们的使用模式仍然会偏离轨道。