GHC:对于具有固定值的呼叫,是否有一致的备忘录规则?

时间:2019-07-12 06:02:32

标签: haskell ghc memoization

在理解和利用GHC自动记忆方面,我碰壁了:用诸如fib 42之类的固定值调用纯函数时,有时它们又很快又很慢。无论是像fib 42一样简单地调用它们,还是通过某些数学方法(例如, (\x -> fib (x - 1)) 43。这些案件似乎没有押韵或理由,因此我将向他们提出这些案件的目的是询问行为背后的逻辑是什么。

考虑一个慢速的斐波那契实现,这使备忘录有效时显而易见:

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. 是[??]

最后一种情况有效的事实令我感到困惑:例如,如果我可以重新打印结果,那么我应该希望能够添加它们。这是显示此代码的代码:

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无法记住。

2 个答案:

答案 0 :(得分:4)

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

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

此处CAF最相关的功能是“常量”部分:GHC只会为这样的表达式引入这样的缓存,该表达式的值在程序的整个运行过程中都是恒定的,而不仅仅是在特定范围内。因此,您可以确定f x <> f x将永远不会重复使用f x的结果(至少不是由于CAF折叠;也许GHC可以找到其他借口来为某些功能记忆这一点,但通常这样做)不是)。

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

这里的道理是,您不能完全依靠它:如果要确保仅计算一次值,则需要将其保存在某个变量中,并引用该变量而不是重新计算。

>

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

-- 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节中的表达式也为我缓存了。

答案 1 :(得分:4)

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

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

相反,生成的GHC代码用于实现功能应用是不寻常的。无需将函数实际应用于参数以生成最终结果作为值,而是立即以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强制了(一堆列表构造函数的重击和然后)一个expensiveComputation,其返回值被“存储”为列表stuff中的索引1000的值–是一个重击,但是在被强制之后,它会记住自己的值,就像用严格的语言来表达任何价值。

因此,根据您对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