Performance 为什么严格长度函数的执行速度明显更快?

Performance 为什么严格长度函数的执行速度明显更快?,performance,haskell,ghc,lazy-evaluation,evaluation,Performance,Haskell,Ghc,Lazy Evaluation,Evaluation,为了更好地理解评估模型,我玩弄了各种定义,并根据列表的长度写了两个 天真的定义: len :: [a] -> Int len [] = 0 len (_:xs) = 1 + len xs slen :: [a] -> Int -> Int slen [] n = n slen (_:xs) !n = slen xs (n+1) 严格(和尾部递归)定义: len :: [a] -> Int len [] = 0 len (_:xs) = 1 + len xs slen

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

天真的定义:

len :: [a] -> Int
len [] = 0
len (_:xs) = 1 + len xs
slen :: [a] -> Int -> Int
slen [] n = n
slen (_:xs) !n = slen xs (n+1)
严格(和尾部递归)定义:

len :: [a] -> Int
len [] = 0
len (_:xs) = 1 + len xs
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明显更快


另外,我还编写了一个tail recursive lazy函数(就像
slen
但是lazy),试图了解原因——可能是因为它是tail recursive的——但它的性能与原始定义大致相同。

David Young的回答正确解释了计算顺序的差异。你应该按照哈斯克尔概述的方式来考虑他的评估

让我来告诉你如何看到核心的不同。我认为,在上进行优化后,它实际上更为明显,因为评估结果是一个显式的
case
语句。如果您以前从未玩过Core,请参阅主题为的规范SO问题:

使用
ghc-O2-ddump simple-dsuppress all-ddump生成堆芯输出到文件SO27392665.hs
。您将看到GHC将
len
slen
拆分为递归的“worker”函数、
$wlen
$wslen
和非递归的“wrapper”函数。因为绝大多数时间都花在递归的“工作者”上,所以关注他们:

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
只有一个
案例
,而
$wlen
有两个案例。如果你去看David的答案,你可以追踪
$wlen
:它在最外层的列表构造函数上进行案例分析(
[]
/
),然后递归调用
$wlen xs_as0
(即
len xs
),它也
case
,即强制累积的thunk

另一方面,在
$wslen
中,只有一个
case
语句。在递归分支中,只有一个未绑定的加法,
(++ww#u sP0 1)
,它不会创建thunk


(注意:这个答案的前一个版本指出,使用
-O
GHC可以专门化
$wslen
,但不能
$wlen
使用unbox
Int#
s。事实并非如此。)

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

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

1 + 1 + 1 + 1 + len []
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
= 1 + 1 + 1 + 1       -- Here it stops building the thunks and starts evaluating them
= 1 + 1 + 2
= 1 + 3
= 4
有五个未评估的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由
\uuuu
表示,您可以看到,在原始
ys
中,有
4个Thunk相互嵌套,每个Thunk对应于列表的每个部分(包括
[]

据我所知,在
Int
中没有一种很好的方法可以看到这一点,因为它的求值更多的是全部或全无,但它仍然以相同的方式构建嵌套thunk。如果你可以这样看,它的评估结果会是这样的:

1 + 1 + 1 + 1 + len []
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
= 1 + 1 + 1 + 1       -- Here it stops building the thunks and starts evaluating them
= 1 + 1 + 2
= 1 + 3
= 4

len
的最后一步不是O(1)。把n个数加起来就是O(n)
slen
也使用O(n)内存,而
len
使用O(1)内存。@davidyong哦,我明白了!欢迎你写下来作为回答。你能解释一下内存消耗吗?(或者只是参考我可以进一步理解的地方)。谢谢!对不起,我倒过来了
len
使用O(n)内存,
slen
使用O(1)内存。首先非常感谢:)。即使没有任何优化(GHCi),原始定义的速度也较慢。GHC还会像那样解装箱吗?不,没有优化,GHC不会消除装箱和解装箱。尝试生成核心;您可以看到,
slen
每次都取消对累加器的装箱。在未优化的情况下,我认为它只是立即评估thunk,而不是建立它们。