代码之家  ›  专栏  ›  技术社区  ›  trpnd

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

  •  0
  • trpnd  · 技术社区  · 4 年前

    我正在运行一些相当简单的基准测试 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) 。这将使我能够提供设置和拆卸功能——我可以使用此界面获得更有意义的结果吗?

    0 回复  |  直到 4 年前
        1
  •  5
  •   K. A. Buhr    4 年前

    我建议使用 criterion 。它经过精心设计,具有对纯计算进行计时的功能(正如你所发现的,这可能很棘手)。我不熟悉 benchpress ,但它似乎没有开箱即用的相同设施,似乎主要是为了对IO操作进行基准测试。

    以你的例子为基准 标准 看起来像这样:

    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代码进行基准测试很棘手,但使用 标准 几乎是强制性的。

    完整示例:

    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和47s。请注意 Int 整数 s、 如果您没有使用显式类型签名并且无意中默认为 整数 .

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

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