【问题标题】:Performance characteristics of length vs fold vs explicit recursion长度与折叠与显式递归的性能特征
【发布时间】:2020-12-30 07:53:50
【问题描述】:

我已经编写了length 函数的六个版本。一些性能差异是有道理的,但其中一些似乎与我读过的文章完全不同(例如this onethis one)。

-- len1 and lenFold1 should have equivalent performance, right?

len1 :: [a] -> Integer
len1 [] = 0
len1 (x:xs) = len1 xs + 1

lenFold1 :: [a] -> Integer
lenFold1 = foldr (\_ a -> a + 1) 0


-- len2 and lenFold2 should have equivalent performance, right?

len2 :: [a] -> Integer
len2 xs = go xs 0 where
  go [] acc = acc
  go (x:xs) acc = go xs (1 + acc)

lenFold2 :: [a] -> Integer
lenFold2 = foldl (\a _ -> a + 1) 0


-- len3 and lenFold3 should have equivalent performance, right?
-- And len3 should outperform len1 and len2, right?

len3 :: [a] -> Integer
len3 xs = go xs 0 where
  go [] acc = acc
  go (x:xs) acc = go xs $! (1 + acc)

lenFold3 :: [a] -> Integer
lenFold3 = foldl' (\a _ -> a + 1) 0

在我机器上的实际表现令人费解。

*Main Lib> :set +m +s
*Main Lib> xs = [1..10000000]
(0.01 secs, 351,256 bytes)
*Main Lib> len1 xs
10000000
(5.47 secs, 2,345,244,016 bytes)
*Main Lib> lenFold1 xs
10000000
(2.74 secs, 1,696,750,840 bytes)
*Main Lib> len2 xs
10000000
(6.02 secs, 2,980,997,432 bytes)
*Main Lib> lenFold2 xs
10000000
(3.97 secs, 1,776,750,816 bytes)
*Main Lib> len3 xs
10000000
(5.24 secs, 3,520,354,616 bytes)
*Main Lib> lenFold3 xs
10000000
(1.24 secs, 1,040,354,528 bytes)
*Main Lib> length xs
10000000
(0.21 secs, 720,354,480 bytes)

我的问题:

  1. 为什么每个函数的 fold 版本始终优于使用显式递归的版本?
  2. 尽管有this article 的警告,这些实现都没有在我的机器上达到堆栈溢出。为什么不呢?
  3. 为什么len3 的性能不如len1len2
  4. 为什么 Prelude 的 length 的性能比这些实现中的任何一个都好得多?

编辑:

感谢 Carl 的建议,我的第一个和第二个问题已通过 GHCI 默认解释代码这一事实得到解决。使用-fobject-code 再次运行它说明了显式递归和折叠之间的不同性能。新测量:

Prelude Lib Main> xs = [1..10000000]
(0.00 secs, 354,136 bytes)
Prelude Lib Main> len1 xs
10000000
(1.62 secs, 1,612,661,544 bytes)
Prelude Lib Main> lenFold1 xs
10000000
(1.62 secs, 1,692,661,552 bytes)
Prelude Lib Main> len2 xs
10000000
(2.46 secs, 1,855,662,888 bytes)
Prelude Lib Main> lenFold2 xs
10000000
(2.53 secs, 1,772,661,528 bytes)
Prelude Lib Main> len3 xs
10000000
(0.48 secs, 1,680,361,272 bytes)
Prelude Lib Main> lenFold3 xs
10000000
(0.31 secs, 1,040,361,240 bytes)
Prelude Lib Main> length xs
10000000
(0.18 secs, 720,361,272 bytes)

对此我还有几个问题。

  1. 为什么lenFold3 的表现优于len3?我跑了几次
  2. length 如何仍然优于所有这些实现?

【问题讨论】:

  • 您正在对解释代码和编译代码进行基准测试,并想知道为什么编译代码更快?
  • @Carl 这点很有用,但无论你是否有意,你都用居高临下的措辞,我认为这不是很有帮助。
  • @Carl 这是一种有趣的说法,“GHCI 默认解释代码。尝试使用-fobject-code 再次运行它。”不过谢谢你的建议!更新测量值。
  • 不要在 GHCi 内部进行基准测试。使用 -O2 编译并使用类似标准的东西。

标签: performance haskell recursion performance-testing fold


【解决方案1】:

我认为无论您尝试使用什么标志,您都无法正确测试 GHCi 的性能。

一般来说,对 Haskell 代码进行性能测试的最佳方法是使用 Criterion 基准测试库并使用 ghc -O2 进行编译。转换为 Criterion 基准,您的程序如下所示:

import Criterion.Main
import GHC.List
import Prelude hiding (foldr, foldl, foldl', length)

len1 :: [a] -> Integer
len1 [] = 0
len1 (x:xs) = len1 xs + 1

lenFold1 :: [a] -> Integer
lenFold1 = foldr (\_ a -> a + 1) 0

len2 :: [a] -> Integer
len2 xs = go xs 0 where
  go [] acc = acc
  go (x:xs) acc = go xs (1 + acc)

lenFold2 :: [a] -> Integer
lenFold2 = foldl (\a _ -> a + 1) 0

len3 :: [a] -> Integer
len3 xs = go xs 0 where
  go [] acc = acc
  go (x:xs) acc = go xs $! (1 + acc)

lenFold3 :: [a] -> Integer
lenFold3 = foldl' (\a _ -> a + 1) 0

testLength :: ([Int] -> Integer) -> Integer
testLength f = f [1..10000000]

main = defaultMain
  [ bench "lenFold1" $ whnf testLength lenFold1
  , bench "len1" $ whnf testLength len1
  , bench "len2" $ whnf testLength len2
  , bench "lenFold2" $ whnf testLength lenFold2
  , bench "len3" $ whnf testLength len3
  , bench "lenFold3" $ whnf testLength lenFold3
  , bench "length" $ whnf testLength (fromIntegral . length)
  ]

我机器上的缩写结果是:

len1                 190.9 ms   (136.8 ms .. 238.6 ms)
lenFold1             207.8 ms   (151.6 ms .. 248.6 ms)
len2                 69.96 ms   (69.09 ms .. 71.63 ms)
lenFold2             1.191 s    (917.1 ms .. 1.454 s)
len3                 69.26 ms   (69.20 ms .. 69.35 ms)
lenFold3             87.14 ms   (86.95 ms .. 87.35 ms)
length               26.78 ms   (26.50 ms .. 27.08 ms)

请注意,这些结果与您在 GHCi 运行这些测试时观察到的性能完全不同,无论是绝对还是相对,无论是否使用 -fobject-code。为什么?打败我。

无论如何,基于这个适当的基准,len1lenFold1 具有几乎相同的性能。其实为lenFold1生成的Core是:

lenFold1 = len1

所以它们是相同的功能。不过,我的基准测试中的明显差异是真实的,并且似乎是某些缓存/对齐问题的结果。如果我在main 中重新排序len1lenFold1,性能差异会翻转(因此len1 是“慢速”)。

len2len3 也具有相同的性能,因为它们是相同的功能。 (事实上​​,len3 的生成代码是len3 = len2。)GHC 的严格分析器确定表达式1 + acc 可以严格计算,即使没有显式的$! 运算符。

lenFold3 稍微慢一些,因为foldl' 没有内联,所以组合函数每次都需要显式调用。这可以说是here 报告的错误。我们可以通过更改lenFold3 的定义来明确地为foldl' 提供三个参数来解决它:

lenFold3 xs = foldl' (\a _ -> a + 1) 0 xs

然后它的性能与len2len3 一样好:

lenFold3             66.99 ms   (66.76 ms .. 67.30 ms)

lenFold2 的糟糕表现是同样问题的表现。如果没有内联,GHC 就无法进行适当的优化。如果我们将定义更改为:

lenFold2 xs = foldl (\a _ -> a + 1) 0 xs

它的表现和其他的一样好:

lenFold2             66.64 ms   (66.58 ms .. 66.68 ms)

需要明确的是,在对lenFold2lenFold3进行这两个更改后,函数len2len3lenFold2lenFold3除了lenFold2和@之外都是相同的987654359@ 以不同的顺序应用+ 运算符。如果我们使用定义:

lenFold2 xs = foldl (\a _ -> 1 + a) 0 xs
lenFold3 xs = foldl' (\a _ -> 1 + a) 0 xs

那么生成的Core(可以用ghc -O2 -ddump-simpl -dsuppress-all -dsuppress-uniques -fforce-recomp查看)实际上是:

len2 = ...actual definition...
lenFold2 = len2
len3 = len2
lenFold3 = len2

所以它们都完全相同。

它们与len1(或等效的lenFold1)真正不同,因为len1 建立了大量堆栈帧,然后它需要在到达列表末尾并“发现”一个空列表的长度为零。没有堆栈溢出的原因是很多关于 Haskell 堆栈溢出的博客文章似乎已经过时或基于 GHCi 测试。在使用现代 GHC 版本编译的代码中,maximum stack size 默认为 80% 的物理内存,因此您可以使用千兆字节的堆栈而不会真正注意到。在这种情况下,使用+RTS -hT 进行的一些快速分析表明,单个len1 [1..10000000] 的堆栈增长到大约60-70 兆字节,不足以溢出任何东西。相比之下,len2 家族没有积累任何明显的堆栈。

最后,length 让他们大吃一惊的原因是它使用Int 而不是Integer 来计算长度。如果我将类型签名更改为:

len1 :: [a] -> Int
len2 :: [a] -> Int

然后我得到:

len1                 144.7 ms   (121.8 ms .. 157.9 ms)
len2                 27.38 ms   (27.31 ms .. 27.44 ms)
length               27.50 ms   (27.45 ms .. 27.54 ms)

len2(还有lenFold2len3lenFold3)都和length一样快。

【讨论】:

    猜你喜欢
    • 2019-04-27
    • 1970-01-01
    • 2012-04-13
    • 2020-10-27
    • 2012-10-20
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2018-12-28
    相关资源
    最近更新 更多