我编写了这个函数来计算Collatz序列,我看到执行的时间差别很大,这取决于我给它的旋转。显然它与“memoization”有关,但我很难理解它是什么以及它是如何工作的,不幸的是,关于HaskellWiki的相关文章以及它链接到的论文都被证明不是很容易克服。他们讨论了高度非外行 - 无差别树构造的相对表现的复杂细节,而我想念的必须是一些非常基本的,非常微不足道的观点,这些来源忽略了。
这是代码。这是一个完整的程序,随时可以构建和执行。
module Main where
import Data.Function
import Data.List (maximumBy)
size :: (Integral a) => a
size = 10 ^ 6
-- Nail the basics.
collatz :: Integral a => a -> a
collatz n | even n = n `div` 2
| otherwise = n * 3 + 1
recollatz :: Integral a => a -> a
recollatz = fix $ \f x -> if (x /= 1)
then f (collatz x)
else x
-- Now, I want to do the counting with a tuple monad.
mocollatz :: Integral b => b -> ([b], b)
mocollatz n = ([n], collatz n)
remocollatz :: Integral a => a -> ([a], a)
remocollatz = fix $ \f x -> if x /= 1
then f =<< mocollatz x
else return x
-- Trivialities.
collatzLength :: Integral a => a -> Int
collatzLength x = (length . fst $ (remocollatz x)) + 1
collatzPairs :: Integral a => a -> [(a, Int)]
collatzPairs n = zip [1..n] (collatzLength <$> [1..n])
longestCollatz :: Integral a => a -> (a, Int)
longestCollatz n = maximumBy order $ collatzPairs n
where
order :: Ord b => (a, b) -> (a, b) -> Ordering
order x y = snd x `compare` snd y
main :: IO ()
main = print $ longestCollatz size
使用ghc -O2
大约需要17秒,没有ghc -O2
- 从size
以下的任何点开始传递长度和最长的Collatz序列的种子约22秒。< / p>
现在,如果我做出这些改变:
diff --git a/Main.hs b/Main.hs
index c78ad95..9607fe0 100644
--- a/Main.hs
+++ b/Main.hs
@@ -1,6 +1,7 @@
module Main where
import Data.Function
+import qualified Data.Map.Lazy as M
import Data.List (maximumBy)
size :: (Integral a) => a
@@ -22,10 +23,15 @@ recollatz = fix $ \f x -> if (x /= 1)
mocollatz :: Integral b => b -> ([b], b)
mocollatz n = ([n], collatz n)
-remocollatz :: Integral a => a -> ([a], a)
-remocollatz = fix $ \f x -> if x /= 1
- then f =<< mocollatz x
- else return x
+remocollatz :: (Num a, Integral b) => b -> ([b], a)
+remocollatz 1 = return 1
+remocollatz x = case M.lookup x (table mutate) of
+ Nothing -> mutate x
+ Just y -> y
+ where mutate x = remocollatz =<< mocollatz x
+
+table :: (Ord a, Integral a) => (a -> b) -> M.Map a b
+table f = M.fromList [ (x, f x) | x <- [1..size] ]
-- Trivialities.
- 然后ghc -O2
只需要4秒钟,但如果没有ghc -O2
,我就不会活得足够长。
使用ghc -prof -fprof-auto -O2
查看成本中心的详细信息后发现,第一个版本输入collatz
大约一亿次,而修补后的版本大约一百五十万次。这一定是加速的原因,但我很难理解这种魔法的内在运作。我最好的想法是我们用O(log n)映射查找替换一部分昂贵的递归调用,但我不知道它是否为真,为什么它在很大程度上取决于一些被遗忘的编译器标志,而我认为,这种表演波动应该完全遵循语言。
我可以解释一下这里发生了什么,以及为什么ghc -O2
和普通ghc
版本之间的性能差异很大?
P.S。实现Stack Overflow上其他地方突出显示的自动记忆有两个要求:
将功能记录为顶级名称。
将一个函数记忆为一个单形的函数。
根据这些要求,我重建了remocollatz
,如下所示:
remocollatz :: Int -> ([Int], Int)
remocollatz 1 = return 1
remocollatz x = mutate x
mutate :: Int -> ([Int], Int)
mutate x = remocollatz =<< mocollatz x
现在它是顶级和单态的。运行时间约为11秒,而类似的单态table
版本:
remocollatz :: Int -> ([Int], Int)
remocollatz 1 = return 1
remocollatz x = case M.lookup x (table mutate) of
Nothing -> mutate x
Just y -> y
mutate :: Int -> ([Int], Int)
mutate = \x -> remocollatz =<< mocollatz x
table :: (Int -> ([Int], Int)) -> M.Map Int ([Int], Int)
table f = M.fromList [ (x, f x) | x <- [1..size] ]
- 在不到4秒的时间内运行。
我想知道为什么memoization ghc
在第一种情况下应该执行的速度几乎比我哑巴表慢3倍。
答案 0 :(得分:5)
我可以解释一下这里发生了什么,以及为什么ghc -O2和普通ghc构建之间的性能差别很大?
免责声明:这是猜测,未通过查看GHC核心输出进行验证。仔细回答这样做是为了验证下面概述的猜想。您可以尝试自己查看它:将-ddump-simpl
添加到您的编译行,您将获得丰富的输出,详细说明GHC对您的代码所做的工作。
你写道:
remocollatz x = {- ... -} table mutate {- ... -}
where mutate x = remocollatz =<< mocollatz x
表达式table mutate
实际上并不依赖于x
;但它出现在等式的右边,以x
为参数。因此,在没有优化的情况下,每次调用remocollatz
时都会重新计算此表(大概甚至可能来自table mutate
的计算内部。)
通过优化,GHC注意到table mutate
不依赖于x
,并将其浮动到自己的定义,有效地产生:
fresh_variable_name = table mutate
where mutate x = remocollatz =<< mocollatz x
remocollatz x = case M.lookup x fresh_variable_name of
{- ... -}
因此,对于整个程序运行,该表只计算一次。
不知道为什么它[性能]在很大程度上取决于一些被遗忘的编译器标志,而在我看来,这样的性能波动应该完全遵循语言。
抱歉,但Haskell并不是这样的。语言定义清楚地说明给定Haskell术语的含义是什么,但没有说明计算该含义所需的运行时或内存性能。
答案 1 :(得分:2)
另一种在某些情况下工作的memoization方法,比如这个,就是使用一个盒装向量,其元素是懒惰计算的。用于初始化每个元素的函数可以在其计算中使用向量的其他元素。只要对向量元素的求值不循环并引用自身,就会评估它递归依赖的元素。一旦被评估,元素就会被有效地记忆,这样做的另一个好处就是永远不会评估从未引用的向量元素。
Collatz序列是这种技术的近乎理想的应用,但有一个复杂因素。从限制下的值开始的下一个Collatz值可能超出限制,这会在索引向量时导致范围错误。我通过迭代序列直到回到极限并计算步骤来解决这个问题。
以下程序在未优化时运行0.77秒,在优化时运行0.30秒:
import qualified Data.Vector as V
limit = 10 ^ 6 :: Int
-- The Collatz function, which given a value returns the next in the sequence.
nextCollatz val
| odd val = 3 * val + 1
| otherwise = val `div` 2
-- Given a value, return the next Collatz value in the sequence that is less
-- than the limit and the number of steps to get there. For example, the
-- sequence starting at 13 is: [13, 40, 20, 10, 5, 16, 8, 4, 2, 1], so if
-- limit is 100, then (nextCollatzWithinLimit 13) is (40, 1), but if limit is
-- 15, then (nextCollatzWithinLimit 13) is (10, 3).
nextCollatzWithinLimit val = (firstInRange, stepsToFirstInRange)
where
firstInRange = head rest
stepsToFirstInRange = 1 + (length biggerThanLimit)
(biggerThanLimit, rest) = span (>= limit) (tail collatzSeqStartingWithVal)
collatzSeqStartingWithVal = iterate nextCollatz val
-- A boxed vector holding Collatz length for each index. The collatzFn used
-- to generate the value for each element refers back to other elements of
-- this vector, but since the vector elements are only evaluated as needed and
-- there aren't any loops in the Collatz sequences, the values are calculated
-- only as needed.
collatzVec :: V.Vector Int
collatzVec = V.generate limit collatzFn
where
collatzFn :: Int -> Int
collatzFn index
| index <= 1 = 1
| otherwise = (collatzVec V.! nextWithinLimit) + stepsToGetThere
where
(nextWithinLimit, stepsToGetThere) = nextCollatzWithinLimit index
main :: IO ()
main = do
-- Use a fold through the vector to find the longest Collatz sequence under
-- the limit, and keep track of both the maximum length and the initial
-- value of the sequence, which is the index.
let (maxLength, maxIndex) = V.ifoldl' accMaxLen (0, 0) collatzVec
accMaxLen acc@(accMaxLen, accMaxIndex) index currLen
| currLen <= accMaxLen = acc
| otherwise = (currLen, index)
putStrLn $ "Max Collatz length below " ++ show limit ++ " is "
++ show maxLength ++ " at index " ++ show maxIndex