【问题标题】:GHC: Are there consistent rules for memoization for calls with fixed values?GHC:对于具有固定值的调用,是否有一致的记忆规则?
【发布时间】:2019-11-21 20:51:17
【问题描述】:

在我理解和利用 GHC 自动记忆的过程中,我碰壁了:当纯函数使用固定值(如 fib 42)调用时,再次调用它们时有时快有时慢。如果它们被简单地称为fib 42 或通过一些数学隐含地调用它们,它会有所不同,例如(\x -> fib (x - 1)) 43。这些案例似乎没有押韵或理由,所以我将呈现它们的目的是询问行为背后的逻辑是什么。

考虑一个缓慢的 Fibonacci 实现,这使得 memoization 工作时很明显:

slow_fib :: Int -> Integer
slow_fib n = if n < 2 then 1 else (slow_fib (n - 1)) + (slow_fib (n - 2))

我测试了三个基本问题,看看 GHC(8.2.2 版)是否会记忆带有固定参数的调用:

  1. slow_fib 可以访问以前对slow_fib 的顶级调用吗?
  2. 是否已为以后的重要(例如数学)顶级表达式存储先前的结果?
  3. 是否为以后相同的顶级表达式记住了以前的结果?

答案似乎是:

  1. 没有
  2. 没有
  3. 是的 [??]

最后一个案例有效的事实让我非常困惑:例如,如果我可以重新打印结果,那么我应该期望能够添加它们。这是显示这一点的代码:

main = do
  -- 1. all three of these are slow, even though `slow_fib 37` is 
  -- just the sum of the other two results. Definitely no memoization.
  putStrLn $ show $ slow_fib 35
  putStrLn $ show $ slow_fib 36
  putStrLn $ show $ slow_fib 37

  -- 2. also slow, definitely no memoization as well.
  putStrLn $ show $ (slow_fib 35) + (slow_fib 36) + (slow_fib 37)
  putStrLn $ show $ (slow_fib 35) + 1

  -- 3. all three of these are instant. Huh?
  putStrLn $ show $ slow_fib 35
  putStrLn $ show $ slow_fib 36
  putStrLn $ show $ slow_fib 37

然而奇怪的是,当结果嵌入到递归函数中时,对结果进行数学运算:这个从 Fib(40) 开始的斐波那契变体:

let fib_plus_40 n = if n <= 0
  then slow_fib 40
  else (fib_plus_40 (n - 1)) + (fib_plus_40 (n - 2))

如下所示:

main = do
  -- slow as expected
  putStrLn $ show $ fib_plus_40 0

  -- instant. Why?!
  putStrLn $ show $ fib_plus_40 1

我在 GHC 记忆化的任何解释中找不到任何理由,这通常会导致显式变量(例如 herehereand here)。这就是为什么我预计 fib_plus_40 无法记忆的原因。

【问题讨论】:

  • 好问题。不过,时间安排很棘手——你应该学会阅读这些实验的核心。如果我记得的话,用-ddump-simpl 编译
  • 我希望在您的第一个示例中,实际上不是编译器已提取 slow_fib 35 作为表达式在对 putStrLn 的两次调用之间共享;而是putStrLn $ show $ slow_fib 35 它自己 已被拉出来分享。这解释了为什么putStrLn $ show $ slow_fib35 + 1 仍然很慢:它是一个单独的(可能是记忆的!)动作,仍然必须进行计算。实际上,您可以验证,如果您输入第二个 putStrLn $ show $ slow_fib35 + 1,它也会立即执行,因此这不是您建议的“微不足道与非微不足道”的区别。
  • @DanielWagner 啊,是的,这同样令人惊讶。看起来它只是完全不可预测的。

标签: haskell ghc memoization


【解决方案1】:

您最后链接的所有示例都利用了相同的技术:它们不是直接实现函数f,而是首先引入一个列表,其内容是所有可能对f 进行的调用。该列表只计算一次,懒惰;然后将该列表中的简单查找用作面向用户的功能的实现。因此,它们不依赖于 GHC 的任何缓存。

您的问题不同:您希望调用某些函数会自动为您缓存,并且通常不会发生这种情况。真正的问题是为什么任何你的结果都很快。我不确定,但我认为这与Constant Applicative Forms(CAF)有关,GHC 可以自行决定在多个使用站点之间共享。

这里 CAF 最相关的特性是“常量”部分:GHC 只会为在整个程序运行期间值是常量的表达式引入这样的缓存,而不仅仅是针对某些特定范围。因此,您可以确定f x &lt;&gt; f x 永远不会重用f x 的结果(至少不是由于CAF 折叠;也许GHC 可以找到其他借口来为某些功能记住这一点,但通常不会)。

您的程序中不是 CAF 的两件事是slow_fib 的实现和fib_plus_40 的递归情况。 GHC 绝对不能对这些表达式的结果进行任何缓存。 fib_plus_40 的基本情况是 CAF,main 中的所有表达式和子表达式也是如此。因此,GHC 可以随意选择缓存/共享这些子表达式中的任何一个,而不是共享它们中的任何一个。也许它看到slow_fib 40“显然”简单到可以保存,但它不确定main 中的slow_fib 35 表达式是否应该共享。同时,听起来它确实出于任何原因决定分享 IO 操作putStrLn $ show $ slow_fib 35。对你我来说似乎是一个奇怪的选择,但我们不是编译器。

这里的寓意是你根本不能指望这一点:如果你想确保你只计算一次值,你需要将它保存在某个地方的变量中,并引用该变量而不是重新计算它。


为了确认这一点,我听取了 luqui 的建议并查看了 -ddump-simpl 的输出。以下是一些显示显式缓存的 sn-ps:

-- RHS size: {terms: 2, types: 0, coercions: 0}
lvl1_r4ER :: Integer
[GblId, Str=DmdType]
lvl1_r4ER = $wslow_fib_r4EP 40#

Rec {
-- RHS size: {terms: 21, types: 4, coercions: 0}
Main.main_fib_plus_40 [Occ=LoopBreaker] :: Integer -> Integer
[GblId, Arity=1, Str=DmdType <S,U>]
Main.main_fib_plus_40 =
  \ (n_a1DF :: Integer) ->
    case integer-gmp-1.0.0.1:GHC.Integer.Type.leInteger#
           n_a1DF Main.main7
    of wild_a2aQ { __DEFAULT ->
    case GHC.Prim.tagToEnum# @ Bool wild_a2aQ of _ [Occ=Dead] {
      False ->
        integer-gmp-1.0.0.1:GHC.Integer.Type.plusInteger
          (Main.main_fib_plus_40
             (integer-gmp-1.0.0.1:GHC.Integer.Type.minusInteger
                n_a1DF Main.main4))
          (Main.main_fib_plus_40
             (integer-gmp-1.0.0.1:GHC.Integer.Type.minusInteger
                n_a1DF lvl_r4EQ));
      True -> lvl1_r4ER
    }
    }
end Rec }

这并没有告诉我们为什么 GHC 选择引入这个缓存 - 请记住,它可以做它想做的事。但它确实证实了机制,它引入了一个变量来保存重复计算。对于涉及较小数字的更长的main,我无法向您展示核心,因为当我编译它时,我会得到更多的共享:第 2 节中的表达式也为我缓存。

【讨论】:

    【解决方案2】:

    为了详细说明,以防@amalloy 的回答不清楚,问题是您在这里混淆了两件事 - 隐式的类似记忆的行为(人们在谈论 Haskell 的“自动记忆”时的意思,虽然它不是真正的记忆!)直接来自基于 thunk 的惰性评估,以及一种编译器优化技术,它基本上是一种常见的子表达式消除形式。前者或多或少是可以预见的;后者是编译器的心血来潮。

    回想一下,真实记忆是函数实现的一个属性:函数“记住”为某些参数组合计算的结果,并且可以重用这些结果,而不是在调用时从头重新计算它们多次使用相同的参数。当 GHC 为函数生成代码时,它不会自动生成代码来执行这种记忆。

    相反,为实现功能application而生成的GHC代码是不寻常的。不是将函数实际应用于参数以将最终结果生成为值,而是立即以 thunk 的形式构造“结果”,您可以将其视为暂停的函数调用或“承诺”以在稍后。

    当将来某个时候需要实际值时,会强制执行 thunk(这实际上会导致发生原始函数调用),并使用该值更新 thunk。如果稍后再次需要相同的值,则该值已经可用,因此不需要再次强制执行 thunk。这就是“自动记忆”。请注意,它发生在“结果”级别而不是“功能”级别——功能应用程序的结果会记住它的值;一个函数不记得它之前产生的结果。

    现在,函数应用程序记住其值的结果的概念通常是荒谬的。在严格的语言中,我们不担心在x = sqrt(10) 之后,重用x 会导致多次sqrt 调用,因为x 没有“记忆”它自己的值。也就是说,在严格的语言中,所有函数应用程序的结果都是“自动记忆”的,就像在 Haskell 中一样。

    不同之处在于惰性求值,它允许我们编写如下内容:

    stuff = map expensiveComputation [1..10000]
    

    立即返回一个 thunk 而不执行任何昂贵的计算。之后:

    f n = stuff !! n
    

    神奇地创建了一个记忆函数,不是因为 GHC 在f 的实现中生成代码以某种方式记忆调用f 1000,而是因为f 1000 强制(一堆列表构造函数然后)单个@987654330 @ 其返回值被“记忆”为列表stuff 中索引 1000 处的值——这是一个 thunk,但在被强制之后,它会记住自己的值,就像严格语言中的任何值一样。

    因此,鉴于您对 slow_fib 的定义,您的示例实际上都没有使用 Haskell 的自动记忆,在通常意义上的人们的意思。您看到的任何加速都是各种编译器优化的结果,这些优化是(或不是)识别常见的子表达式或内联/展开短循环。

    要编写一个记忆化的fib,您需要像使用严格的语言一样明确地执行它,通过创建一个数据结构来保存记忆化的值,尽管惰性求值和相互递归定义有时会使它看起来像它是“自动的”:

    import qualified Data.Vector as V
    import Data.Vector (Vector,(!))
    
    fibv :: Vector Integer
    fibv = V.generate 1000000 getfib
      where getfib 0 = 1
            getfib 1 = 1
            getfib i = fibv ! (i-1) + fibv ! (i-2)
    
    fib :: Int -> Integer
    fib n = fibv ! n
    

    【讨论】:

      猜你喜欢
      • 2012-09-30
      • 1970-01-01
      • 1970-01-01
      • 2019-05-29
      • 2015-02-18
      • 1970-01-01
      • 2014-03-05
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多