【问题标题】:Haskell recursion efficencyHaskell 递归效率
【发布时间】:2013-06-17 19:32:23
【问题描述】:

我正在做一些Project Euler 项目(不是作为家庭作业,只是为了好玩/学习),我正在学习 Haskell。问题之一是找到起始数小于 100 万的最大 Collat​​z 序列 (http://projecteuler.net/problem=14)

所以无论如何,我能够做到,并且我的算法在编译时可以运行并很快得到正确的答案。但是,它使用了 1000000 深度递归。

所以我的问题是:我这样做对吗?照原样,是正确的 Haskell 方法吗?我怎样才能让它更快?此外,对于内存使用,递归实际上是如何在低级实现的?至于它是如何使用内存的?

剧透警告:如果您想在不看答案的情况下自行解决 Project Euler 的问题 #14,请不要看这个。

--haskell 脚本 --problem: 找出小于 200 万的最长 collat​​z 链。

collatzLength x| x == 1 = 1
               | otherwise = 1 + collatzLength(nextStep x)


longestChain (num, numLength) bound counter
           | counter >= bound = (num, numLength)
           | otherwise = longestChain (longerOf (num,numLength)
             (counter,   (collatzLength counter)) ) bound (counter + 1)
           --I know this is a messy function, but I was doing this problem just 
           --for myself, so I didn't bother making some utility functions for it.
           --also, I split the big line in half to display on here nicer, would
           --it actually run with this line split?


longerOf (a1,a2) (b1,b2)| a2 > b2 = (a1,a2)
                        | otherwise = (b1,b2)

nextStep n | mod n 2 == 0 = (n `div` 2)
           | otherwise = 3*n + 1

main = print (longestChain (0,0) 1000000 1)

使用 -O2 编译时,程序运行时间约为 7.5 秒。

那么,有什么建议/建议吗?我想尝试让程序以更少的内存使用更快地运行,并且我想以非常 Haskellian(应该是一个词)的方式来做。

提前致谢!

【问题讨论】:

  • 我会尝试使collatzLength 迭代(尾递归)。现在您使用递归1 + (1 + (1 + ...)) 构建结果。应用与 longestChain 相同的尾递归技术。

标签: haskell memory recursion


【解决方案1】:

编辑以回答问题

我做对了吗?

几乎,正如 cmets 所说,您构建了一大堆 1+(1+(1+...)) - 改用严格的累加器或为您处理事情的更高级别的函数。还有其他一些小事情,比如定义一个函数来比较第二个元素,而不是使用maximumBy (comparing snd),但这更具风格。

按原样,是正确的 Haskell 方法吗?

这是可以接受的惯用 Haskell 代码。

我怎样才能让它更快?

请参阅我的以下基准。欧拉性能问题的最常见答案是:

  • 使用 -O2(和以前一样)
  • 尝试 -fllvm(GHC NCG 次优)
  • 使用 worker/wrappers 来减少参数,或者在您的情况下,利用累加器。
  • 使用快速/不可装箱的类型(如果您可以使用 Int 代替 Integer,则使用 Int64(如果需要可移植性等)。
  • 当所有值都是正数时,使用rem 而不是mod。对于您的情况,了解或发现 div 倾向于编译成比 quot 慢的东西也很有用。

另外,对于内存使用,递归实际上是如何在底层实现的? 至于它是如何使用内存的?

这两个问题都非常广泛。完整的答案可能需要解决惰性评估、尾调用优化、工作人员转换、垃圾收集等问题。我建议您随着时间的推移更深入地探索这些答案(或者希望这里有人给出我正在避免的完整答案)。

原始帖子 - 基准数字

原文:

$ ghc -O2 so.hs ; time ./so
[1 of 1] Compiling Main             ( so.hs, so.o )
Linking so ...
(837799,525)

real    0m5.971s
user    0m5.940s
sys 0m0.019s

collatzLength 使用带有累加器的辅助函数:

$ ghc -O2 so.hs ; time ./so
[1 of 1] Compiling Main             ( so.hs, so.o )
Linking so ...
(837799,525)

real    0m5.617s
user    0m5.590s
sys 0m0.012s

使用Int 而不是默认为Integer - 使用类型签名也更容易阅读!

$ ghc -O2 so.hs ; time ./so
[1 of 1] Compiling Main             ( so.hs, so.o )
Linking so ...
(837799,525)

real    0m2.937s
user    0m2.932s
sys 0m0.001s

使用rem 而不是mod

$ ghc -O2 so.hs ; time ./so
[1 of 1] Compiling Main             ( so.hs, so.o )
Linking so ...
(837799,525)

real    0m2.436s
user    0m2.431s
sys 0m0.001s

使用quotRem 而不是rem 然后div

$ ghc -O2 so.hs ; time ./so
[1 of 1] Compiling Main             ( so.hs, so.o )
Linking so ...
(837799,525)

real    0m1.672s
user    0m1.669s
sys 0m0.002s

这和之前的问题很像:Speed comparison with Project Euler: C vs Python vs Erlang vs Haskell

编辑:是的,正如 Daniel Fischer 所建议的那样,使用 .&.shiftR 的位操作改进了 quotRem

$ ghc -O2 so.hs ; time ./so
(837799,525)

real    0m0.314s
user    0m0.312s
sys 0m0.001s

或者您可以只使用 LLVM 并让它发挥作用(注意这个版本仍然使用 quotRem

$ time ./so
(837799,525)

real    0m0.286s
user    0m0.283s
sys 0m0.002s

LLVM 实际上做得很好,只要你避免 mod 的丑陋,并使用 remeven 优化基于守卫的代码,与手动优化的 .&. 和 @987654351 一样好@。

获得比原来快约 20 倍的结果。

编辑:人们很惊讶 quotRem 在Int 面前表现得和位操作一样好。代码已包含在内,但我不清楚这一惊喜:仅仅因为某些事情可能是负面的,并不意味着您无法使用非常相似的位操作来处理它,这些位操作在正确的硬件上可能具有相同的成本。 nextStep 的所有三个版本的性能似乎相同(ghc -O2 -fforce-recomp -fllvm、ghc 版本 7.6.3、LLVM 3.3、x86-64)。

{-# LANGUAGE BangPatterns, UnboxedTuples #-}

import Data.Bits

collatzLength :: Int -> Int
collatzLength x| x == 1    = 1
               | otherwise = go x 0
 where
    go 1 a  = a + 1
    go x !a = go (nextStep x) (a+1)


longestChain :: (Int, Int) -> Int -> Int -> (Int,Int)
longestChain (num, numLength) bound !counter
   | counter >= bound = (num, numLength)
   | otherwise = longestChain (longerOf (num,numLength) (counter, collatzLength counter)) bound (counter + 1)
           --I know this is a messy function, but I was doing this problem just 
           --for myself, so I didn't bother making some utility functions for it.
           --also, I split the big line in half to display on here nicer, would
           --it actually run with this line split?


longerOf :: (Int,Int) -> (Int,Int) -> (Int,Int)
longerOf (a1,a2) (b1,b2)| a2 > b2 = (a1,a2)
                        | otherwise = (b1,b2)
{-# INLINE longerOf #-}

nextStep :: Int -> Int
-- Version 'bits'
nextStep n = if 0 == n .&. 1 then n `shiftR` 1 else 3*n+1
-- Version 'quotRem'
-- nextStep n = let (q,r) = quotRem n 2 in if r == 0 then q else 3*n+1
-- Version 'almost the original'
-- nextStep n | even n = quot n 2
--            | otherwise  = 3*n + 1
{-# INLINE nextStep #-}


main = print (longestChain (0,0) 1000000 1)

【讨论】:

  • 现在使用n .&. 1 == 0n `shiftR` 1 代替quotRem
  • @DanielFischer 我读过很多次,一个像样的编译器应该自己做这样的优化。你知道 GHC(可能是 LLVM 后端)的情况吗?使用显式位移操作真的有帮助吗?
  • @PetrPudlák GHC 还没有学习过很多这样的优化,从事它的人太少了,他们的时间花在了更高级别的优化和类型系统技巧上,它本身并没有做到这一点(至今)。当您使用正确的类型时,LLVM 后端会进行优化(最后我检查了,仅针对 quot/rem,而不针对 div/mod)。但是当然,对于Int,它必须考虑到负数的可能性,所以你可以使用只有正数出现的领域知识来击败它(不是很多)[除非Int是32位,在这种情况下你有溢出]。
  • 至少在我的机器上,即使使用 LLVM 后端,简单地移动也优于 LLVM 优化(以惊人的大幅度,~25%),因为 LLVM 不知道不会出现负数。使用Word 时差异消失。
  • @Satvik 他们无法区分。我假设 LLVM 识别出与 quotRem x 2 等效的程序集并重写它。
猜你喜欢
  • 2018-12-29
  • 1970-01-01
  • 2015-07-18
  • 2012-03-12
  • 2019-04-27
  • 1970-01-01
  • 2012-02-12
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多