【问题标题】:Why does a strict length function perform noticeably faster?为什么严格长度函数的执行速度明显更快?
【发布时间】:2014-12-10 03:03:47
【问题描述】:

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

天真的定义:

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 [a,b,c,d] 0
= slen [b,c,d] 1
= slen [c,d]   2
= slen [d]     3
= slen []      4
= 4

是什么让slen 明显更快?

附:我还写了一个尾递归惰性函数(就像 slen 但惰性)作为尝试关闭原因 - 也许是因为它是尾递归 - 但它的执行与天真的定义大致相同.

【问题讨论】:

  • len 的最后一步不是 O(1)。将 n 个数字相加是 O(n)。 slen 也使用 O(n) 内存,而 len 使用 O(1) 内存。
  • @DavidYoung 哦,我明白了!欢迎您将其写为答案。你能解释一下内存消耗吗? (或者只是参考我可以进一步理解的地方)。非常感谢!
  • 抱歉,我弄反了。 len 使用 O(n) 内存,slen 使用 O(1) 内存。

标签: performance haskell ghc lazy-evaluation evaluation


【解决方案1】:

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 相互嵌套,列表的每个部分(包括 [])都有一个。

据我所知,在Int 中没有一种好方法可以看到这一点,因为它的评估更多的是全有或全无,但它仍然以相同的方式构建了一个嵌套的 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
= 1 + 1 + 1 + 1       -- Here it stops building the thunks and starts evaluating them
= 1 + 1 + 2
= 1 + 3
= 4

【讨论】:

    【解决方案2】:

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

    让我向您展示如何查看 Core 的不同之处。我认为优化后它实际上更明显,因为评估最终成为明确的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(即@ 987654336@),它也是cases,即强制累积的thunk。

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

    (注意:此答案的先前版本已声明,使用 -O GHC 可以专门化 $wslen 而不是 $wlen 以使用未装箱的 Int#s。事实并非如此。)

    【讨论】:

    • 非常感谢,首先:)。即使没有任何优化(GHCi),天真的定义也会变慢。 GHC 还会像那样拆箱 Int 吗?
    • 不,如果没有优化,GHC 不会消除装箱和拆箱。尝试生成核心;您可以看到slen 每次都将累加器拆箱。在未优化的情况下,我认为它只是立即评估 thunk 而不是构建它们。
    猜你喜欢
    • 1970-01-01
    • 2019-01-11
    • 2020-05-14
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2022-11-12
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多