如何从多次运行Haskell基准测试中获得更有意义的统计数据
我正在使用该benchpress库运行一些相当简单的基准测试。我一直在使用bench :: Int -> IO a -> IO ()界面。但是,似乎如果我运行给定的函数n次数,第一个之后的所有运行都非常快。
作为一个简单的例子,bench 1 (seq (sum [1..100000]) (return ()))可能需要 10 秒左右。但是,bench 5 (seq (sum [1..100000]) (return ()))会产生这样的报告:
Times (ms)
min mean +/-sd median max
0.001 2.657 5.937 0.001 13.277
Percentiles (ms)
50% 0.001
66% 0.002
75% 0.002
80% 0.002
90% 13.277
95% 13.277
98% 13.277
99% 13.277
100% 13.277
由于平均值是 2.6,我可以推断出第一次运行需要 13 秒,其他 4 秒非常快。
为什么会发生这种情况?如何确保基准测试的所有运行都具有代表性?该库还具有更细粒度的界面:benchmark :: Int -> IO a -> (a -> IO b) -> (a -> IO c) -> IO (Stats, Stats). 这将让我提供设置和拆卸功能——我可以使用这个界面来获得更有意义的结果吗?
回答
我建议使用criterion. 它经过精心设计,具有为纯计算计时的设施(正如您所发现的,这可能很棘手)。我不熟悉benchpress,但它似乎没有开箱即用的相同功能,并且似乎主要针对 IO 操作进行基准测试。
对您的示例进行基准测试criterion将如下所示:
import Criterion.Main
main = defaultMain
[ bench "my summation" $ whnf sum [1..100000] ]
从 GHCi 运行且ghc没有优化标志的基准测试在很大程度上毫无意义,因此使用ghc -O2. 运行它将产生输出:
benchmarking my summation
time 9.393 ms (9.271 ms .. 9.498 ms)
0.998 R² (0.997 R² .. 0.999 R²)
mean 9.385 ms (9.292 ms .. 9.483 ms)
std dev 268.7 ?s (208.4 ?s .. 334.0 ?s)
您可以在此处看到时间从最小 9.3 毫秒到 9.5 毫秒不等,因此没有大的异常值。但是,Criterion 会自动放弃初始运行,以确保仅在第一次运行代码时产生的成本(GHC 代码的常见情况)不会包含在计时中。
该whnf函数是一个神奇的函数,它确保即使它的两个参数在第一次运行后可能被完全评估并因此在内存中完全形成,它的第一个参数对其第二次运行的应用将在每次运行时真正重复,并且评估将继续进行到足以将结果置于“弱磁头范式”中。数字的弱头范式(如一堆整数的总和)是数字本身,因此对于此基准测试,时间用于评估实际数字总和。
了解此计算的哪些部分未进行基准测试非常重要。该表达式[1..100000]构造一个列表。如果列表没有被优化掉(在这个基准测试中它不是),列表的构造,作为一个Integer完全保存在内存中的盒装s的单链表,在第一次被丢弃的迭代中执行,并且计时这里的基准是遍历构造的列表以求和它的元素。您可以将列表的构建和汇总时间与:
bench "construct and sum" $ whnf (n -> sum [1..n]) 100000
但这会产生出乎意料的更快结果:
benchmarking construct and sum
time 1.299 ms (1.288 ms .. 1.314 ms)
0.999 R² (0.999 R² .. 1.000 R²)
mean 1.290 ms (1.285 ms .. 1.297 ms)
std dev 20.77 ?s (14.74 ?s .. 27.59 ?s)
因为列表是通过列表融合优化的,你现在正在对一个紧密的求和循环进行基准测试。
如果您真的想对显式列表的构建和求和进行计时,您可以使用sum不内联的副本来防止列表融合:
sum' :: (Num a) => [a] -> a
{-# NOINLINE sum' #-}
sum' = sum
...bench "construct and sum w/o fusion" $ whnf (n -> sum' [1..n]) 100000...
也就是说,对 GHC 代码进行基准测试很棘手,但使用criterion几乎是强制性的。
一个完整的例子:
import Criterion.Main
{-# NOINLINE sum' #-}
sum' :: (Num a) => [a] -> a
sum' = sum
main = defaultMain
[ bench "sum an in-memory list" $ whnf sum [1..100000]
, bench "construct and sum w/ fusion" $ whnf (n -> sum [1..n]) 100000
, bench "construct and sum w/o fusion" $ whnf (n -> sum' [1..n]) 100000
, bench "Int (vs. Integer) and fusion" $ whnf (n -> sum[(1::Int)..n]) 100000
]
我得到的时间大致ghc -O2是 9ms、1ms、14ms 和 47?s。请注意,Int与Integers相比,s非常快,如果您没有使用显式类型签名并无意中默认为Integer.
在这里,差异与数据类型本身无关,而与拆箱和融合的组合有关。最终的基准测试被编译成一个相当紧密的汇编循环,在寄存器中添加从 1 到 100000 的数字。
实际上,本机代码生成器在这方面做得不好。LLVM 后端 ( ghc -O2 -fllvm) 将Int版本降低到 100 纳秒。当您获得这么小的时间时,最好将问题按比例放大,以确保您实际测量的是您认为要测量的内容。如果我将列表长度按比例放大 10 倍,则计时都按比例放大 10 倍,因此我可以合理地确信我正在按预期对实际求和计时。