【问题标题】:How do I memoize?我如何记忆?
【发布时间】:2018-06-25 10:39:00
【问题描述】:

我已经编写了这个计算 Collat​​z 序列的函数,并且我看到根据我给它的自旋而不同的执行时间。显然它与所谓的“记忆化”有关,但我很难理解它是什么以及它是如何工作的,不幸的是,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 - 大约需要 22 秒来传递从 size 下方的任意点开始的最长 Collat​​z 序列的长度和种子。

现在,如果我进行这些更改:

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 大约一亿次,而修补过的版本——大约一50 万次。这一定是加速的原因,但我很难理解这个魔法的内部运作。我最好的想法是我们用 O(log n) 映射查找替换一部分昂贵的递归调用,但我不知道它是否正确以及为什么它如此依赖于一些被上帝遗忘的编译器标志,而在我看来,这种性能波动应该完全来自语言。

我能否解释一下这里发生了什么,以及为什么ghc -O2 和普通ghc 构建之间的性能差异如此之大?


附: 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 倍。

【问题讨论】:

  • 如果这篇文章很长而且写得不好,我很抱歉。我会努力打造自己的风格。
  • 看来您的问题不仅在于理解 Haskell 或优化的重要性,还在于 memoization 的概念(更广泛的编程概念),对吗?您是否查看过通用 CS 或 wikipedia/books 资源以获取记忆解释?
  • 我问了一个关于 Haskell 中 memoization 的问题,也许这个答案也可以给你一些指导。 stackoverflow.com/questions/11473130/…
  • @Kindaro 您将很多内容都包含在一个问题中,专注于一个问题而不是整个蠕虫罐头。优化很重要。时期。我会单独问这个。 Memoized fibs 很容易,因为您始终知道输入 n 将是比当前呼叫少的每个数字。 Memoized collat​​z 使用映射(通常)而不是列表,因为对 collat​​z 的迭代调用不仅仅是n-1n-2,而是一种相当不可预测的形式。最后,不要想,甚至不要说,fibs memoization 是魔法。如果操作不清楚,请先了解。

标签: haskell memoization


【解决方案1】:

另一种在某些情况下有效的记忆化方法,比如这个,是使用一个装箱的向量,它的元素是惰性计算的。用于初始化每个元素的函数可以在其计算中使用向量的其他元素。只要对向量元素的评估不循环并引用自身,只会评估它递归依赖的元素。一旦被评估,一个元素就会被有效地记忆,这还有一个好处,那就是向量中从未被引用的元素永远不会被评估。

Collat​​z 序列几乎是该技术的理想应用,但有一个复杂之处。从低于限制的值开始的下一个 Collat​​z 值可能在限制之外,这会在索引向量时导致范围错误。我通过遍历序列直到回到限制以下并计算步骤来解决这个问题。

以下程序运行未优化需要 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

【讨论】:

    【解决方案2】:

    我能否解释一下这里发生了什么,以及为什么 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 术语的含义,但没有说明计算该含义所需的运行时或内存性能。

    【讨论】:

    • 谢谢你,丹尼尔!阅读您的答案总是令人鼓舞。如果我可以请您阅读我刚刚添加到我的问题中的后记并对此发表评论?
    • 没有必要盯着Core来验证你的理论。我按照您的概述进行了修改,现在没有-O2 的构建运行大约 7 秒,而没有这些修改的时间长度不确定。这证明你是对的。
    • @Kindaro 您有与您的后记相关的具体问题吗?
    • 是的!为什么隐式记忆ghc 的执行速度应该比我的笨表慢得多?
    • @Kindaro GHC 没有隐式记忆,句号。
    猜你喜欢
    • 1970-01-01
    • 2011-09-18
    • 2014-03-31
    • 2020-01-16
    • 1970-01-01
    • 2019-10-02
    • 1970-01-01
    • 2014-04-11
    • 1970-01-01
    相关资源
    最近更新 更多