【问题标题】:Why to avoid Explicit recursion in Haskell?为什么要避免 Haskell 中的显式递归?
【发布时间】:2020-02-18 21:23:06
【问题描述】:

我是 Haskell 的新手。

在研究 foldr 时,许多人建议使用它并避免显式递归,这会导致内存效率低下的代码。 https://www.reddit.com/r/haskell/comments/1nb80j/proper_use_of_recursion_in_haskell/

当我运行上面链接中提到的示例时。我可以看到显式递归在内存方面做得更好。首先,我认为可能在 GHCi 上运行并不接近完美的基准,我尝试使用 stack ghc 编译它。顺便说一句,我如何通过堆栈 ghc 传递编译器优化标志。 Avoid Explicit Recursion 表达式中我缺少什么。

find p = foldr go Nothing
    where go x rest = if p x then Just x else rest

findRec :: (a -> Bool) -> [a] -> Maybe a
findRec _ [] = Nothing
findRec p (x:xs) = if p x then Just x else (findRec p xs)

main :: IO ()
main = print $ find (\x -> x `mod` 2 == 0) [1, 3..1000000] 
main = print $ findRec (\x -> x `mod` 2 == 0) [1, 3..1000000] 

-- find
Nothing
      92,081,224 bytes allocated in the heap
           9,392 bytes copied during GC
          58,848 bytes maximum residency (2 sample(s))
          26,704 bytes maximum slop
               0 MB total memory in use (0 MB lost due to fragmentation)

                                     Tot time (elapsed)  Avg pause  Max pause
  Gen  0        87 colls,     0 par    0.000s   0.000s     0.0000s    0.0001s
  Gen  1         2 colls,     0 par    0.000s   0.001s     0.0004s    0.0008s

  INIT    time    0.000s  (  0.000s elapsed)
  MUT     time    0.031s  (  0.043s elapsed)
  GC      time    0.000s  (  0.001s elapsed)
  EXIT    time    0.000s  (  0.000s elapsed)
  Total   time    0.031s  (  0.044s elapsed)

  %GC     time       0.0%  (0.0% elapsed)

  Alloc rate    2,946,599,168 bytes per MUT second

  Productivity 100.0% of total user, 96.8% of total elapsed

-- findRec
Nothing
      76,048,432 bytes allocated in the heap
          13,768 bytes copied during GC
          42,928 bytes maximum residency (2 sample(s))
          26,704 bytes maximum slop
               0 MB total memory in use (0 MB lost due to fragmentation)

                                     Tot time (elapsed)  Avg pause  Max pause
  Gen  0        71 colls,     0 par    0.000s   0.000s     0.0000s    0.0001s
  Gen  1         2 colls,     0 par    0.000s   0.001s     0.0004s    0.0007s

  INIT    time    0.000s  (  0.000s elapsed)
  MUT     time    0.031s  (  0.038s elapsed)
  GC      time    0.000s  (  0.001s elapsed)
  EXIT    time    0.000s  (  0.000s elapsed)
  Total   time    0.031s  (  0.039s elapsed)

  %GC     time       0.0%  (0.0% elapsed)

  Alloc rate    2,433,549,824 bytes per MUT second

  Productivity 100.0% of total user, 96.6% of total elapsed

【问题讨论】:

  • 显式递归通常只是意味着您正在解决其他人已经为您解决的问题(并且可能比您更正确或更有效)。
  • 你可以写成even,而不是\x -> x mod 2 == 0

标签: haskell recursion fold


【解决方案1】:

您正在测量 GHC 执行 50 万次模数运算的速度。正如您所料,无论您如何迭代,“一眨眼”就是答案。速度没有明显差异。

您声称您可以看到显式递归正在使用更少的内存,但您提供的堆分析数据显示相反:使用显式递归时分配更多并且最大驻留量更高。我认为差异并不显着,但如果是,那么您的证据将与您的主张相矛盾。

至于为什么要避免显式递归的问题,目前尚不清楚您阅读的该线程的哪一部分使您得出结论。你链接到一个巨大的线程,它本身链接到另一个巨大的线程,有许多相互竞争的意见。对我来说最突出的评论是it's not about efficiency, it's about levels of abstraction。通过尝试衡量其性能,您正在以错误的方式看待它。

【讨论】:

  • 对不起,我正在查看堆中分配的字节。最大居住权是什么意思?
  • downloads.haskell.org/~ghc/latest/docs/html/users_guide/… 特别是The maximum space actually used by your program is the “bytes maximum residency” figure. This is only checked during major garbage collections, so it is only an approximation; the number of samples tells you how many times it is checked.
  • 不要与The “bytes allocated in the heap” is the total bytes allocated by the program over the whole run. 混淆 其中还应注意,可以多次分配/释放/重新分配相同的字节-实际上通常是这种情况-因此您可以拥有一个常量在其生命周期内分配数 TB 内存的空间程序。
  • @PKChem“在堆中分配的字节数”对于foldr 的版本更小。这就是我在回答中所指的分配。
  • 我想你把它弄反了? find (with foldr): "92,081,224 字节分配在堆中,9,392 字节在 GC 期间复制,58,848 字节最大驻留”。 findRec (with recursion): "76,048,432 字节分配在堆中,13,768 字节在 GC 期间复制,42,928 字节最大驻留”。
【解决方案2】:

首先,不要尝试使用优化编译以外的任何方式来了解 GHC 编译代码的性能:

$ stack ghc -- -O2 Find.hs
$ ./Find +RTS -s

使用 -O2 标志(和 GHC 版本 8.6.4),您的 find 执行如下:

      16,051,544 bytes allocated in the heap
          14,184 bytes copied during GC
          44,576 bytes maximum residency (2 sample(s))
          29,152 bytes maximum slop
               0 MB total memory in use (0 MB lost due to fragmentation)

但是,这是非常具有误导性的。这些内存使用都不是由于foldr 执行的循环。而是因为使用了盒装的Integers。如果您切换到使用编译器可以拆箱的普通Ints

main = print $ find (\x -> x `mod` 2 == 0) [1::Int, 3..1000000]
                                             ^^^^^

内存性能发生巨大变化,并展示了foldr的真实内存成本:

      51,544 bytes allocated in the heap
       3,480 bytes copied during GC
      44,576 bytes maximum residency (1 sample(s))
      25,056 bytes maximum slop
           0 MB total memory in use (0 MB lost due to fragmentation)

如果您像这样用Ints 测试findRec

 main = print $ findRec (\x -> x `mod` 2 == 0) [1::Int, 3..1000000]

你会看到更糟糕的内存性能:

  40,051,528 bytes allocated in the heap
      14,992 bytes copied during GC
      44,576 bytes maximum residency (2 sample(s))
      29,152 bytes maximum slop
           0 MB total memory in use (0 MB lost due to fragmentation)

这似乎是一个令人信服的案例,即应优先避免递归而不是 foldr,但这也是非常具有误导性的。您在这里看到的是不是递归的内存成本,而是“列表构建”的内存成本。

看,foldr 和表达式[1::Int, 3..1000000] 都包含一些称为“列表融合”的魔法。这意味着当它们一起使用时(即,当 foldr 应用于 [1::Int 3..1000000] 时),可以执行优化以完全消除 Haskell 列表的创建。至关重要的是,foldr 代码,即使使用列表融合,也会编译成递归代码,如下所示:

main_go
  = \ x ->
      case gtInteger# x lim of {
        __DEFAULT ->
          case eqInteger# (modInteger x lvl) lvl1 of {
            __DEFAULT -> main_go (plusInteger x lvl);
                      -- ^^^^^^^ - SEE?  IT'S JUST RECURSION
            1# -> Just x
          };
        1# -> Nothing
      }
end Rec }

因此,使findfindRec 更快的是列表融合,而不是“避免递归”。

您可以通过考虑以下方面的性能来了解这一点:

find1 :: Int -> Maybe Int
find1 n | n >= 1000000 = Nothing
        | n `mod` 2 == 0 = Just n
        | otherwise = find1 (n+2)

main :: IO ()
main = print $ find1 1

尽管这使用递归,但它不会生成列表(或使用装箱的Integers),因此它的运行方式与foldr 版本一样:

      51,544 bytes allocated in the heap
       3,480 bytes copied during GC
      44,576 bytes maximum residency (1 sample(s))
      25,056 bytes maximum slop
           0 MB total memory in use (0 MB lost due to fragmentation)

那么,带回家的课程是什么?

  • 始终使用 ghc -O2 对 Haskell 代码进行基准测试,从不使用 GHCi 或 ghc 而不使用优化标志。
  • 在任何 Reddit 线程中,只有不到 10% 的人知道他们在说什么。
  • 当可以应用列表融合等特殊优化时,foldr 有时可以比显式递归执行得更好。
  • 但在一般情况下,显式递归的性能与 foldr 或其他专用构造一样好。
  • 此外,优化 Haskell 代码也很困难。

实际上,这是一个更好(更严肃)的带回家的课程。特别是当您开始使用 Haskell 时,请尽一切可能避免考虑“优化”您的代码。与我所知道的任何其他语言相比,您编写的代码和编译器生成的代码之间存在巨大的鸿沟,所以现在甚至不要试图弄清楚。相反,编写清晰、直接和惯用的代码。如果您现在尝试学习高性能代码的“规则”,那么您将全都搞错了,并且学到了非常糟糕的编程风格。

【讨论】:

  • 实际上,这是一个更好的带回家的课程。特别是当您开始使用 Haskell 时,请尽一切可能避免考虑“优化”您的代码。与我所知道的任何其他语言相比,您编写的代码和编译器生成的代码之间存在巨大的鸿沟,所以现在甚至不要试图弄清楚。相反,编写清晰、直接和惯用的代码。如果您现在尝试学习高性能代码的“规则”,那么您将全都错了,并且会学到非常糟糕的编程风格。
  • 我建议将此评论移至答案中。评论是短暂的;答案是永恒的。
  • 非常感谢您启发我。您能否建议我可以包含这种解释级别的资源。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2021-06-25
  • 1970-01-01
  • 2015-01-13
  • 2018-11-13
  • 2011-11-15
  • 1970-01-01
  • 2011-10-31
相关资源
最近更新 更多