在研究速度和优化时,很容易得到极其错误的结果 https://stackoverflow.com/a/48412705。在
特别是,你不能真正说一个变体比另一个变体更快,而不提及
编译器版本和基准测试设置的优化模式。即便如此,现代
处理器非常复杂,具有基于神经网络的分支预测器,而不是
提到各种缓存,因此,即使仔细设置,基准测试结果也会模糊。
话虽如此...
基准测试是我们的朋友。
criterion https://hackage.haskell.org/package/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)
看起来像我们的"slow"版本一点也不慢!模式匹配的复杂性并不
添加任何东西。(我们在两次连续运行之间看到了轻微的加速match2
我归因于
缓存的影响。)
有一种方法可以获得更多“科学”数据:我们可以-ddump-simpl https://downloads.haskell.org/~ghc/latest/docs/html/users_guide/debugging.html#ghc-flag--ddump-simpl看看
编译器看到我们的代码的方式。
中间结构的检查是我们的朋友。
"Core"是 GHC 的内部语言。每个 Haskell 源文件都被简化为 Core 之前
被转换为最终的功能图以供运行时系统执行。如果我们看
在这个中间阶段,它会告诉我们myButLast
and butLast2
是等价的。它
确实需要查看,因为在重命名阶段,我们所有好的标识符都被随机破坏。
% 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)
看起来A1
and A4
是最相似的。彻底的检查将表明,确实
中的代码结构A1
and A4
是相同的。那A2
and A3
相似也是合理的
因为两者都被定义为两个函数的组合。
如果您要检查core
广泛输出,这也是有意义的供应标志 https://stackoverflow.com/q/10693638例如-dsuppress-module-prefixes
and -dsuppress-uniques
。它们使它更容易阅读。
我们的敌人也有一个简短的名单。
那么,基准测试和优化可能会出现什么问题呢?
-
ghci
专为交互式游戏和快速迭代而设计,将 Haskell 源代码编译为某种风格的字节代码,而不是最终的可执行文件,并且避免了昂贵的优化,有利于更快的重新加载。
- Profiling seems like a nice tool to look into performance of individual bits and pieces of a complex program, but it can wreck compiler optimizations so badly, the results will be orders of magnitude off base.
- 您的保护措施是将每一小段代码分析为单独的可执行文件,并使用其自己的基准运行程序。
- 垃圾收集是可调的。就在今天,发布了一项新的主要功能。 https://www.reddit.com/r/haskell/comments/dmci6o/merge_nonmoving_garbage_collector_7f72b540/垃圾收集的延迟将以难以直接预测的方式影响性能。
- 正如我提到的,不同的编译器版本会构建具有不同性能的不同代码,因此您必须知道编译器版本是什么user在做出任何承诺之前,您的代码可能会用于构建它,并对其进行基准测试。
这可能看起来很悲伤。但大多数时候,这确实不是 Haskell 程序员应该关心的事情。真实的故事:我有一个朋友最近刚刚开始学习 Haskell。他们编写了一个数值积分程序,但速度非常慢。于是我们坐下来写了一篇分类的 https://en.wikipedia.org/wiki/Category_theory算法的描述,带有图表和其他内容。当他们重新编写代码以与抽象描述保持一致时,它神奇地变得像猎豹一样快,而且内存也很薄。我们很快就计算出了 π。故事的道德启示?完美的抽象结构,你的代码会自我优化。