长度 vs 折叠 vs 显式递归的性能特征

dan*_*n p 3 recursion performance haskell performance-testing fold

我已经编写了该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
Run Code Online (Sandbox Code Playgroud)

我机器上的实际性能令人费解。

*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)
Run Code Online (Sandbox Code Playgroud)

我的问题:

  1. 为什么fold每个函数的版本始终优于使用显式递归的版本?
  2. 尽管有本文的警告,但这些实现都没有在我的机器上达到堆栈溢出。为什么不?
  3. 为什么len3不比len1or表现得更好len2
  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)
Run Code Online (Sandbox Code Playgroud)

关于这个我还有几个问题。

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

K. *_*uhr 12

我认为无论您尝试使用什么标志,您都无法正确测试 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)
  ]
Run Code Online (Sandbox Code Playgroud)

我机器上的缩写结果是:

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)
Run Code Online (Sandbox Code Playgroud)

请注意,这些结果与您在 GHCi 中运行这些测试时观察到的性能有很大不同,无论是绝对值还是相对值,以及使用或不使用-fobject-code. 为什么?甘拜下风。

无论如何,基于这个适当的基准,len1并且lenFold1具有几乎相同的性能。实际上,生成的CorelenFold1是:

lenFold1 = len1
Run Code Online (Sandbox Code Playgroud)

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

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

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

lenFold3 xs = foldl' (\a _ -> a + 1) 0 xs
Run Code Online (Sandbox Code Playgroud)

然后它的表现与len2and一样好len3

lenFold3             66.99 ms   (66.76 ms .. 67.30 ms)
Run Code Online (Sandbox Code Playgroud)

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

lenFold2 xs = foldl (\a _ -> a + 1) 0 xs
Run Code Online (Sandbox Code Playgroud)

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

lenFold2             66.64 ms   (66.58 ms .. 66.68 ms)
Run Code Online (Sandbox Code Playgroud)

需要明确的是,在对lenFold2and进行这两个更改后lenFold3,函数len2, len3, lenFold2, andlenFold3都是相同的,只是lenFold2andlenFold3应用+运算符的顺序不同。如果我们使用定义:

lenFold2 xs = foldl (\a _ -> 1 + a) 0 xs
lenFold3 xs = foldl' (\a _ -> 1 + a) 0 xs
Run Code Online (Sandbox Code Playgroud)

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

len2 = ...actual definition...
lenFold2 = len2
len3 = len2
lenFold3 = len2
Run Code Online (Sandbox Code Playgroud)

所以它们完全相同。

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

最后,length将它们全部吹走的原因是它使用 anInt而不是an来计算长度Integer。如果我将类型签名更改为:

len1 :: [a] -> Int
len2 :: [a] -> Int
Run Code Online (Sandbox Code Playgroud)

然后我得到:

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)
Run Code Online (Sandbox Code Playgroud)

len2(所以lenFold2len3、 和lenFold3)都和一样快length