哈斯克尔执行where子句

时间:2019-04-29 11:55:42

标签: performance haskell time-complexity space-complexity

我正在分析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,但不是真的。

1 个答案:

答案 0 :(得分:6)

您可以使用-ddump-simpl检查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 =从to枚举)并调用++。这里没有内联发生。 GHC害怕内联list_s1nF,因为它被两次使用,并且在这种情况下内联定义可能是有害的。确实,如果内联let x = expensive in x+xexpensive可能会被重新计算两次,这很糟糕。 GHC在这里信任程序员,认为如果他们使用let / where,他们希望只计算一次。无法内联list_s1nF会阻止进一步的优化。

因此,这段代码分配了list = [1..n],然后将其复制到1:2:...:n:list中,在该[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 } 进行了进一步的优化:经过一些小的装箱操作

[1..n]

我们进入实际的工作程序功能。

++

此处,内联了第一个“从...到”的枚举,这也触发了go_a1lX的内联。所得的递归函数:仅依赖于n_a1lT和基本算术。递归结束后,将返回[1..n],这是第二个“从...枚举到” exam3 :: Int -> [Int] exam3 n = list1 ++ list2 where list1 = [1 .. n] list2 = [1 .. n] 。未内联,因为它不会触发更多优化。

在这里,没有生成列表然后将其复制,因此我们可以获得更好的性能。

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

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

此外,由于GHC不会自动缓存功能的结果,因此可以内联这些功能。

{{1}}