为什么严格的长度函数表现得更快?

时间:2014-12-10 03:03:47

标签: performance haskell ghc lazy-evaluation evaluation

我玩弄定义以更好地理解评估模型,并为列表的长度写了两个。

天真的定义:

len :: [a] -> Int
len [] = 0
len (_:xs) = 1 + len xs

strict(和tail-recursive)定义:

slen :: [a] -> Int -> Int
slen [] n = n
slen (_:xs) !n = slen xs (n+1)

len [1..10000000]大约需要5-6秒才能完成 slen [1..10000000] 0需要大约3-4秒才能完成。

我很好奇为什么。在我检查表演之前,我很肯定他们会表现相同,因为len最多应该只有一个thunk来评估。出于演示目的:

len [a,b,c,d]
= 1 + len [b,c,d]
= 1 + 1 + len [c,d]
= 1 + 1 + 1 + len [d]
= 1 + 1 + 1 + 1 + len []
= 1 + 1 + 1 + 1 + 0
= 4

slen [a,b,c,d] 0
= slen [b,c,d] 1
= slen [c,d]   2
= slen [d]     3
= slen []      4
= 4

是什么让slen显着加快?

P.S。我还写了一个尾递归懒惰函数(就像slen但是懒惰)作为尝试接近原因 - 也许是因为它是尾递归 - 但是它表现得很好和天真的定义一样。

2 个答案:

答案 0 :(得分:9)

len的最后一步不是O(1)。将n个数加在一起是O(n)。 len也使用O(n)内存,而slen使用O(1)内存。

它使用O(n)内存的原因是每个thunk都占用了一些内存。所以当你有这样的事情时:

1 + 1 + 1 + 1 + len []

有五个未评估的thunk(包括len []

在GHCi中,我们可以使用:sprint命令更轻松地检查这种thunk行为。 :sprint命令打印给定值而不强制评估任何thunk(您可以从:help了解更多信息)。我将使用conses((:)),因为我们可以更容易地一次评估每个thunk,但原理是相同的。

λ> let ys = map id $ 1 : 2 : 3 : [] :: [Int] -- map id prevents GHCi from being too eager here
λ> :sprint ys
ys = _
λ> take 1 ys
[1]
λ> :sprint ys
ys = 1 : _
λ> take 2 ys
[1,2]
λ> :sprint ys
ys = 1 : 2 : _
λ> take 3 ys
[1,2,3]
λ> :sprint ys
ys = 1 : 2 : 3 : _
λ> take 4 ys
[1,2,3]
λ> :sprint ys
ys = [1,2,3]

未评估的thunk由_表示,您可以看到原始ys中有4个thunk堆叠在一起,每个部分对应一个列表(包括{ {1}})。

我知道在[]中看到这个并不是一个很好的方法,因为它的评估更全或没有,但它仍然以相同的方式构建嵌套的thunk。如果您能够看到这样的话,它的评估将如下所示:

Int

答案 1 :(得分:4)

David Young的回答给出了评估顺序差异的正确解释。您应该按照他概述的方式考虑Haskell评估。

让我告诉你如何看到核心的差异。我认为通过优化实际上它更加明显,因为评估最终是一个明确的case语句。如果您之前从未玩过Core,请参阅有关该主题的规范SO问题:Reading GHC Core

使用ghc -O2 -ddump-simpl -dsuppress-all -ddump-to-file SO27392665.hs生成核心输出。您会看到GHC将lenslen分成一个递归的"工作者"函数,$wlen$wslen,以及非递归的"包装器"功能。因为绝大部分时间都花在递归的工人身上,"专注于他们:

Rec {
$wlen
$wlen =
  \ @ a_arZ w_sOR ->
    case w_sOR of _ {
      [] -> 0;
      : ds_dNU xs_as0 ->
        case $wlen xs_as0 of ww_sOU { __DEFAULT -> +# 1 ww_sOU }
    }
end Rec }

len
len =
  \ @ a_arZ w_sOR ->
    case $wlen w_sOR of ww_sOU { __DEFAULT -> I# ww_sOU }

Rec {
$wslen
$wslen =
  \ @ a_arR w_sOW ww_sP0 ->
    case w_sOW of _ {
      [] -> ww_sP0;
      : ds_dNS xs_asW -> $wslen xs_asW (+# ww_sP0 1)
    }
end Rec }

slen
slen =
  \ @ a_arR w_sOW w1_sOX ->
    case w1_sOX of _ { I# ww1_sP0 ->
    case $wslen w_sOW ww1_sP0 of ww2_sP4 { __DEFAULT -> I# ww2_sP4 }
    }

您可以看到$wslen只有一个case,而$wlen有两个$wlen。如果你看看大卫的答案,你可以追踪[]中发生的事情:它在最外面的列表构造函数(: / $wlen xs_as0)上进行案例分析,然后制作递归调用len xs(即case),它也$wslen,即强制累积的thunk。

另一方面,在case中,只有一个(+# ww_sP0 1)语句。在递归分支中,只有一个未装箱的加法-O,它不会创建一个thunk。

(注意:此答案的先前版本已声明,$wslen GHC可以专门使用$wlen但不Int#来使用未装箱的{{1}}。'}事实并非如此。)