如何从 haskell 基准测试的多次运行中获得更有意义的统计数据

How to get more meaningful statistics from multiple runs of a haskell benchmark

我正在 运行使用 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 函数是一个神奇的函数,它确保即使它的两个参数可能在第一个 运行 之后被完全计算并因此在内存中完全形成,它的第一个参数的应用对于每个运行,它的第二个将真正重复,并且评估将进行得足够远以将结果置于“弱头部范式”。一个数的weak head normal form(比如一堆整数的和)就是这个数本身,所以对于这个benchmark来说,时机是为了评估实际的数值和。

了解此计算的哪些部分没有 进行基准测试很重要。表达式 [1..100000] 构造一个列表。如果列表没有被优化掉(在这个基准测试中它没有),列表的构造,作为一个完全保存在内存中的装箱 Integers 的单链表,在第一个被丢弃的时候执行迭代,这里进行基准测试的时间是遍历构造列表以求和其元素。您可以将列表的构造和求和时间与:

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。请注意 Ints 与 Integers 相比非常快,如果您没有使用显式类型签名并且无意中默认为 Integer.

,请记住这一点

在这里,差异与数据类型本身关系不大,而与拆箱和融合的组合关系更大。最终的基准测试被编译成一个相当紧凑的汇编循环,在寄存器中添加从 1 到 100000 的数字。

实际上,本机代码生成器在这里做得不好。 LLVM 后端 (ghc -O2 -fllvm) 将 Int 版本缩短到 100 纳秒。当你得到这么小的时间时,最好扩大问题的规模,以确保你实际上在测量你认为你在测量的东西。如果我将列表长度扩大 10 倍,则所有时间都增加 10 倍,因此我可以有理由相信我正在按预期对实际求和进行计时。