【问题标题】:Haskell; performance of where clause哈斯克尔; where子句的执行
【发布时间】:2019-04-29 11:55:41
【问题描述】:

我正在分析 where 子句对 Haskell 程序性能的影响。

Haskell, The craft of functional programming, Thomspson,第 20.4 章中,我找到了以下示例:

exam1 :: Int -> [Int]
exam1 n = [1 .. n] ++ [1 .. n]

exam2 :: Int -> [Int]
exam2 n = list ++ list
  where list = [1 .. n]

而且,我引用,

计算 [exam1] 所需的时间将是 O(n),使用的空间将是 O(1),但我们将不得不计算表达式 [1 .. n] 两次

...

[exam2] 的作用是计算一次列表[1 .. n],这样我们计算后保存它的值,以便再次使用。

...

如果我们通过在where 子句中引用它来保存某些东西,我们必须为它占用的空间付出代价。

所以我发疯了,认为-O2 标志必须处理这个问题并为我选择最佳行为。我使用 Criterion 分析这两个示例的时间复杂度。

import Criterion.Main

exam1 :: Int -> [Int]
exam1 n = [1 .. n] ++ [1 .. n]

exam2 :: Int -> [Int]
exam2 n = list ++ list
  where list = [1 .. n]

m :: Int
m = 1000000

main :: IO ()
main = defaultMain [ bench "exam1" $ nf exam1 m
                   , bench "exam2" $ nf exam2 m
                   ]

我用-O2编译,发现:

benchmarking exam1
time                 15.11 ms   (15.03 ms .. 15.16 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 15.11 ms   (15.08 ms .. 15.14 ms)
std dev              83.20 μs   (53.18 μs .. 122.6 μs)

benchmarking exam2
time                 76.27 ms   (72.84 ms .. 82.75 ms)
                     0.987 R²   (0.963 R² .. 0.997 R²)
mean                 74.79 ms   (70.20 ms .. 77.70 ms)
std dev              6.204 ms   (3.871 ms .. 9.233 ms)
variance introduced by outliers: 26% (moderately inflated)

有什么不同!为什么会这样?我认为exam2 应该更快但内存效率低(根据上面的引用)。但是不,它实际上要慢得多(并且可能内存效率更低,但需要测试)。

也许它比较慢,因为[1 .. 1e6] 必须存储在内存中,这需要很多时间。你怎么看?

PS:我找到了a possibly related question,但不是真的。

【问题讨论】:

  • 看起来在第二个示例中编译器未能内联 list 所以它实际上计算它,存储在内存中,然后从内存中读取它。
  • @talex Inlining let x = expensiveComputation in f x x 在一般情况下可能是有害的,因为它可能导致 x 被计算两次。使用where 我们可能会遇到类似的问题。我认为 GHC 在这种情况下对内联非常保守,因为它可能会导致性能灾难(例如,进行两次递归调用而不是一次可能会导致指数级爆炸)。
  • @chi 是的,但在这种情况下,内联根本不会导致计算。它将即时计算。
  • @talex 我同意,但我不希望 GHC 理解在这种情况下,它可以安全地内联。检测什么时候可以内联,什么时候不能内联看起来并不简单。

标签: performance haskell time-complexity space-complexity


【解决方案1】:

您可以使用-ddump-simpl 检查 GHC Core 并观察 GHC 生成的优化代码。 Core 的可读性不如 Haskell,但通常人们仍然可以了解发生了什么。

对于exam2,我们得到简单无聊的代码:

exam2
  = \ (n_aX5 :: Int) ->
      case n_aX5 of { GHC.Types.I# y_a1lJ ->
      let {
        list_s1nF [Dmd=<S,U>] :: [Int]
        [LclId]
        list_s1nF = GHC.Enum.eftInt 1# y_a1lJ } in
      ++ @ Int list_s1nF list_s1nF
      }

大致上,这将list_s1nF 定义为[1..n]eftInt = enum from to)并调用++。这里没有发生内联。 GHC 害怕内联list_s1nF,因为它被使用了两次,在这种情况下内联定义可能是有害的。事实上,如果 let x = expensive in x+x 被内联,expensive 可能会被重新计算两次,这很糟糕。这里 GHC 信任程序员,认为如果他们使用 let / where,他们希望只计算一次。未能内联 list_s1nF 会阻止进一步优化。

因此,这段代码分配了list = [1..n],然后将其复制到1:2:...:n:list,其中尾部指针指向原始列表。 复制任意列表需要遵循指针链并为新列表分配单元格,这在直观上比 [1..n] 更昂贵,[1..n] 只需为新列表分配单元格并保持计数器。

相反,exam1 被进一步优化:经过一些小的拆箱

exam1
  = \ (w_s1os :: Int) ->
      case w_s1os of { GHC.Types.I# ww1_s1ov ->
      PerfList.$wexam1 ww1_s1ov
      }

我们得到了实际的工作函数。

PerfList.$wexam1
  = \ (ww_s1ov :: GHC.Prim.Int#) ->
      let {
        n_a1lT :: [Int]
        [LclId]
        n_a1lT = GHC.Enum.eftInt 1# ww_s1ov } in
      case GHC.Prim.># 1# ww_s1ov of {
        __DEFAULT ->
          letrec {
            go_a1lX [Occ=LoopBreaker] :: GHC.Prim.Int# -> [Int]
            [LclId, Arity=1, Str=<L,U>, Unf=OtherCon []]
            go_a1lX
              = \ (x_a1lY :: GHC.Prim.Int#) ->
                  GHC.Types.:
                    @ Int
                    (GHC.Types.I# x_a1lY)
                    (case GHC.Prim.==# x_a1lY ww_s1ov of {
                       __DEFAULT -> go_a1lX (GHC.Prim.+# x_a1lY 1#);
                       1# -> n_a1lT
                     }); } in
          go_a1lX 1#;
        1# -> n_a1lT
      }

在这里,第一个“枚举 from to”[1..n] 被内联,这也触发了 ++ 的内联。生成的递归函数 go_a1lX 仅依赖于 : 和基本算术。当递归结束时,返回n_a1lT,这是第二个“从到枚举”[1..n]。这不是内联的,因为它不会触发更多优化。

这里没有生成列表然后复制,所以我们得到了更好的性能。

请注意,这也会产生优化的代码:

exam3 :: Int -> [Int]
exam3 n = list1 ++ list2
  where list1 = [1 .. n]
        list2 = [1 .. n]

还有这个,因为 GHC 不会自动缓存函数的结果,所以可以内联。

exam4 :: Int -> [Int]
exam4 n = list () ++ list ()
  where list () = [1 .. n]

【讨论】:

  • 谢谢!您对使用 let/where 子句有何看法?我经常在给子表达式命名有助于提高可读性和减少代码冗余的情况下使用它们;在这些情况下,我根本不考虑性能(实际上,我认为在考试 2 中使用 where 会带来速度优势)。或者,有没有办法编写出与考试1 一样快的可读性更高、冗余更少的代码(如考试2)?
  • @DominikSchrempf 我首先使用 let/where 来构造代码以提高可读性。然后,如果性能是一个问题,我可以做一些调整。如果let x = ...只使用一次,没有区别,编译器很可能会内联。如果它被使用两次或更多,人们不得不问自己“我想要这些信息被存储和重复使用吗?还是重新计算它更便宜?”。通常,如果x 具有固定的内存大小(例如,它是单个 int),则最好重用它。如果相反,它是一个列表/树/什么,这取决于。在您的代码中,我可能会使用上面的 [1..n]++[1..n]exam3exam4
  • @DominikSchrempf Haskell 的表现无疑是一种黑色艺术。看到发生了什么并不那么明显,因为优化器可能会做得很好,也可能会意外地失败。懒惰也使估计性能变得更加困难。尽管如此,有了一些经验,人们很快就会了解最常见的性能缺陷,以及如何避免它们。例如。众所周知,如果您经常使用++,将大列表作为第一个参数传递,尤其是递归地,最好使用“差异列表”而不是普通列表。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2023-03-24
相关资源
最近更新 更多