当查找列表的最后一个但第二个元素时,为什么使用"last"是其中最快的?



下面给出了3个函数,可以在列表中找到最后一个但第二个元素。使用last . init的那个似乎比其他的快得多。我似乎想不通为什么。

为了进行测试,我使用了[1..100000000](1 亿)的输入列表。最后一个几乎立即运行,而其他需要几秒钟。

-- slow
myButLast :: [a] -> a
myButLast [x, y] = x
myButLast (x : xs) = myButLast xs
myButLast _ = error "List too short"
-- decent
myButLast' :: [a] -> a
myButLast' = (!! 1) . reverse
-- fast
myButLast'' :: [a] -> a
myButLast'' = last . init

在研究速度和优化时,很容易获得非常错误的结果。在 特别是,你不能真的说一个变体比另一个变体快而不提到 编译器版本和基准测试设置的优化模式。即便如此,现代 处理器是如此复杂,以至于具有基于神经网络的分支预测器,而不是 提到各种缓存,因此,即使经过仔细设置,基准测试结果也会模糊不清。

话虽如此...

基准测试是我们的朋友。

criterion是一个提供高级基准测试工具的软件包。我很快起草了一份 像这样的基准测试:

module Main where
import Criterion
import Criterion.Main
-- slow
myButLast :: [a] -> a
myButLast [x, y] = x
myButLast (x : xs) = myButLast xs
myButLast _ = error "List too short"
-- decent
myButLast' :: [a] -> a
myButLast' = (!! 1) . reverse
-- fast
myButLast'' :: [a] -> a
myButLast'' = last . init
butLast2 :: [a] -> a
butLast2 (x :     _ : [ ] ) = x
butLast2 (_ : xs@(_ : _ ) ) = butLast2 xs
butLast2 _ = error "List too short"
setupEnv = do
let xs = [1 .. 10^7] :: [Int]
return xs
benches xs =
[ bench "slow?"   $ nf myButLast   xs
, bench "decent?" $ nf myButLast'  xs
, bench "fast?"   $ nf myButLast'' xs
, bench "match2"  $ nf butLast2    xs
]
main = defaultMain
[ env setupEnv $  xs -> bgroup "main" $ let bs = benches xs in bs ++ reverse bs ]

如您所见,我添加了一次明确匹配两个元素的变体,但除此之外 是逐字相同的代码。我还反向运行基准测试,以便了解应有的偏差 到缓存。所以,让我们跑着瞧吧!

% ghc --version
The Glorious Glasgow Haskell Compilation System, version 8.6.5

% ghc -O2 -package criterion A.hs && ./A
benchmarking main/slow?
time                 54.83 ms   (54.75 ms .. 54.90 ms)
1.000 R²   (1.000 R² .. 1.000 R²)
mean                 54.86 ms   (54.82 ms .. 54.93 ms)
std dev              94.77 μs   (54.95 μs .. 146.6 μs)
benchmarking main/decent?
time                 794.3 ms   (32.56 ms .. 1.293 s)
0.907 R²   (0.689 R² .. 1.000 R²)
mean                 617.2 ms   (422.7 ms .. 744.8 ms)
std dev              201.3 ms   (105.5 ms .. 283.3 ms)
variance introduced by outliers: 73% (severely inflated)
benchmarking main/fast?
time                 84.60 ms   (84.37 ms .. 84.95 ms)
1.000 R²   (1.000 R² .. 1.000 R²)
mean                 84.46 ms   (84.25 ms .. 84.77 ms)
std dev              435.1 μs   (239.0 μs .. 681.4 μs)
benchmarking main/match2
time                 54.87 ms   (54.81 ms .. 54.95 ms)
1.000 R²   (1.000 R² .. 1.000 R²)
mean                 54.85 ms   (54.81 ms .. 54.92 ms)
std dev              104.9 μs   (57.03 μs .. 178.7 μs)
benchmarking main/match2
time                 50.60 ms   (47.17 ms .. 53.01 ms)
0.993 R²   (0.981 R² .. 0.999 R²)
mean                 60.74 ms   (56.57 ms .. 67.03 ms)
std dev              9.362 ms   (6.074 ms .. 10.95 ms)
variance introduced by outliers: 56% (severely inflated)
benchmarking main/fast?
time                 69.38 ms   (56.64 ms .. 78.73 ms)
0.948 R²   (0.835 R² .. 0.994 R²)
mean                 108.2 ms   (92.40 ms .. 129.5 ms)
std dev              30.75 ms   (19.08 ms .. 37.64 ms)
variance introduced by outliers: 76% (severely inflated)
benchmarking main/decent?
time                 770.8 ms   (345.9 ms .. 1.004 s)
0.967 R²   (0.894 R² .. 1.000 R²)
mean                 593.4 ms   (422.8 ms .. 691.4 ms)
std dev              167.0 ms   (50.32 ms .. 226.1 ms)
variance introduced by outliers: 72% (severely inflated)
benchmarking main/slow?
time                 54.87 ms   (54.77 ms .. 55.00 ms)
1.000 R²   (1.000 R² .. 1.000 R²)
mean                 54.95 ms   (54.88 ms .. 55.10 ms)
std dev              185.3 μs   (54.54 μs .. 251.8 μs)

看起来我们的"慢">版本一点也不慢!而图案匹配的复杂性不会 添加任何内容。(我们在连续两次match2之间看到略有加快,我归因于 缓存的影响。

有一种方法可以获得更多"科学">数据:我们可以-ddump-simpl并查看 编译器看到我们的代码的方式。

中间结构的检测是我们的朋友。

"核心">是GHC的内部语言。每个Haskell源文件都简化为Core 之前 转换为运行时系统要执行的最终功能图。如果我们看 在这个中间阶段,它将告诉我们myButLastbutLast2是等价的。它 确实需要查看,因为在重命名阶段,我们所有的 nice 标识符都是随机破坏的。

% for i in `seq 1 4`; do echo; cat A$i.hs; ghc -O2 -ddump-simpl A$i.hs > A$i.simpl; done
module A1 where
-- slow
myButLast :: [a] -> a
myButLast [x, y] = x
myButLast (x : xs) = myButLast xs
myButLast _ = error "List too short"
module A2 where
-- decent
myButLast' :: [a] -> a
myButLast' = (!! 1) . reverse
module A3 where
-- fast
myButLast'' :: [a] -> a
myButLast'' = last . init
module A4 where
butLast2 :: [a] -> a
butLast2 (x :     _ : [ ] ) = x
butLast2 (_ : xs@(_ : _ ) ) = butLast2 xs
butLast2 _ = error "List too short"
% ./EditDistance.hs *.simpl
(("A1.simpl","A2.simpl"),3866)
(("A1.simpl","A3.simpl"),3794)
(("A2.simpl","A3.simpl"),663)
(("A1.simpl","A4.simpl"),607)
(("A2.simpl","A4.simpl"),4188)
(("A3.simpl","A4.simpl"),4113)

看来A1A4是最相似的。彻底的检查将表明,确实A1A4中的代码结构是相同的。A2A3相似也是合理的 因为两者都被定义为两个函数的组合。

如果要广泛检查core输出,则同时提供诸如-dsuppress-module-prefixes-dsuppress-uniques之类的标志是有意义的。它们使阅读变得更加容易。

我们的敌人的简短名单也是。

那么,基准测试和优化会出什么问题呢?

  • ghci,专为交互式游戏和快速迭代而设计,将Haskell源代码编译为某种风格的字节码,而不是最终的可执行文件,并且避免了昂贵的优化,有利于更快的重新加载。
  • 分析似乎是研究复杂程序各个部分性能的好工具,但它可能会严重破坏编译器优化,结果将偏离基础几个数量级。
    • 您的保护措施是将每一小段代码分析为单独的可执行文件,并具有自己的基准运行程序。
  • 垃圾回收是可调的。就在今天,发布了一个新的主要功能。垃圾回收的延迟将以不容易预测的方式影响性能。
  • 正如我所提到的,不同的编译器版本将构建具有不同性能的不同代码,因此在做出任何承诺之前,您必须知道代码的用户可能会使用哪个版本来构建它,并以此进行基准测试。

这可能看起来很可悲。但大多数时候,这真的不是Haskell程序员应该关心的事情。真实故事:我有一个朋友最近刚开始学习哈斯克尔。他们编写了一个数值积分程序,而且速度很慢。所以我们坐下来,写了一个算法的分类描述,包括图表和其他东西。当他们重新编写代码以与抽象描述保持一致时,它神奇地变得像猎豹一样快速,并且内存也很小。我们很快就计算了π。故事的寓意?完美的抽象结构,你的代码将自我优化。

最新更新