【问题标题】:Code becomes slower as more boxed arrays are allocated随着分配更多盒装数组,代码变得更慢
【发布时间】:2014-05-04 21:52:42
【问题描述】:

编辑: 事实证明,随着创建的数组越多,事情(不仅仅是数组/引用操作)通常会减慢速度,所以我猜这可能只是测量增加的 GC 时间和可能没有我想的那么奇怪。但是我真的很想知道(并学习如何找出)这里发生了什么,以及是否有某种方法可以在创建大量小型数组的代码中减轻这种影响。原始问题如下。


在调查库中一些奇怪的基准测试结果时,我偶然发现了一些我不理解的行为,尽管它可能非常明显。许多操作(创建新的MutableArray、读取或修改IORef)所花费的时间似乎与内存中数组的数量成正比。

这是第一个例子:

module Main
    where 

import Control.Monad
import qualified Data.Primitive as P
import Control.Concurrent
import Data.IORef
import Criterion.Main
import Control.Monad.Primitive(PrimState)

main = do 
  let n = 100000
  allTheArrays <- newIORef []
  defaultMain $
    [ bench "array creation" $ do
         newArr <- P.newArray 64 () :: IO (P.MutableArray (PrimState IO) ())
         atomicModifyIORef'  allTheArrays (\l-> (newArr:l,()))
    ]

我们正在创建一个新数组并将其添加到堆栈中。随着标准执行更多样本并且堆栈增长,数组创建需要更多时间,而且这似乎线性且有规律地增长:

更奇怪的是,IORef 的读取和写入受到影响,我们可以看到 atomicModifyIORef' 变得更快,大概是因为更多的数组被 GC'd。

main = do 
  let n = 1000000
  arrs <- replicateM (n) $ (P.newArray 64 () :: IO (P.MutableArray (PrimState IO) ()))
  -- print $ length arrs -- THIS WORKS TO MAKE THINGS FASTER
  arrsRef <- newIORef arrs
  defaultMain $
    [ bench "atomic-mods of IORef" $
    -- nfIO $           -- OR THIS ALSO WORKS
        replicateM 1000 $
          atomicModifyIORef' arrsRef (\(a:as)-> (as,()))
    ]

被注释的两行中的任何一个都摆脱了这种行为,但我不确定为什么(也许在我们强制列表的脊椎之后,元素实际上可以被收集)。

问题

  • 这里发生了什么?
  • 这是预期的行为吗?
  • 有什么方法可以避免这种减速吗?

编辑:我认为这与 GC 需要更长的时间有关,但我想更准确地了解正在发生的事情,尤其是在第一个基准测试中。


奖励示例

最后,这里有一个简单的测试程序,可以用来预先分配一些数组并为atomicModifyIORefs 计时。这似乎表现出缓慢的 IORef 行为。

import Control.Monad
import System.Environment

import qualified Data.Primitive as P
import Control.Concurrent
import Control.Concurrent.Chan
import Control.Concurrent.MVar
import Data.IORef
import Criterion.Main
import Control.Exception(evaluate)
import Control.Monad.Primitive(PrimState)

import qualified Data.Array.IO as IO
import qualified Data.Vector.Mutable as V

import System.CPUTime
import System.Mem(performGC)
import System.Environment
main :: IO ()
main = do
  [n] <- fmap (map read) getArgs
  arrs <- replicateM (n) $ (P.newArray 64 () :: IO (P.MutableArray (PrimState IO) ()))
  arrsRef <- newIORef arrs

  t0 <- getCPUTimeDouble

  cnt <- newIORef (0::Int)
  replicateM_ 1000000 $
    (atomicModifyIORef' cnt (\n-> (n+1,())) >>= evaluate)

  t1 <- getCPUTimeDouble

  -- make sure these stick around
  readIORef cnt >>= print
  readIORef arrsRef >>= (flip P.readArray 0 . head) >>= print

  putStrLn "The time:"
  print (t1 - t0)

带有-hy 的堆配置文件主要显示MUT_ARR_PTRS_CLEAN,我不完全理解。

如果你想复现,这里是我一直在使用的 cabal 文件

name:                small-concurrency-benchmarks
version:             0.1.0.0       
build-type:          Simple
cabal-version:       >=1.10

executable small-concurrency-benchmarks
  main-is:             Main.hs 
  build-depends:       base >=4.6
                     , criterion
                     , primitive

  default-language:    Haskell2010
  ghc-options: -O2  -rtsopts

编辑:这是另一个测试程序,可用于比较具有相同大小数组的堆与[Integer] 的减速。调整n 并观察分析以获得可比较的运行需要一些试验和错误。

main4 :: IO ()
main4= do
  [n] <- fmap (map read) getArgs
  let ns = [(1::Integer).. n]
  arrsRef <- newIORef ns
  print $ length ns

  t0 <- getCPUTimeDouble
  mapM (evaluate . sum) (tails [1.. 10000])
  t1 <- getCPUTimeDouble

  readIORef arrsRef >>= (print . sum)

  print (t1 - t0)

有趣的是,当我对此进行测试时,我发现相同堆大小的数组对性能的影响比[Integer] 更大。例如

         Baseline  20M   200M
Lists:   0.7       1.0    4.4
Arrays:  0.7       2.6   20.4

结论(WIP)

  1. 这很可能是由 GC 行为引起的

  2. 但可变的未装箱数组似乎会导致更多的服务器减速(见上文)。设置+RTS -A200M 使数组垃圾版本的性能与列表版本一致,支持这与GC有关。

  3. 减速与分配的数组数量成正比,而不是与数组中的总单元数成正比。这是一组运行,显示了与main4 类似的测试,分配的数组数量对分配时间的影响,以及完全不相关的“有效负载”。这是总共 16777216 个单元格(分为许多数组):

       Array size   Array create time  Time for "payload":
       8            3.164           14.264
       16           1.532           9.008
       32           1.208           6.668
       64           0.644           3.78
       128          0.528           2.052
       256          0.444           3.08
       512          0.336           4.648
       1024         0.356           0.652
    

    16777216*4 单元上运行相同的测试,显示出与上述基本相同的有效负载时间,仅向下移动了两个位置。

  4. 1234563 ),以及导致 GC 的任何开销。

【问题讨论】:

    标签: arrays performance haskell garbage-collection ghc


    【解决方案1】:

    您为每个保持活跃并被提升到老年代的可变数组的每个次要 GC 支付线性开销。这是因为 GHC 无条件地将所有可变数组放在可变列表中,并在每次次要 GC 时遍历整个列表。请参阅https://ghc.haskell.org/trac/ghc/ticket/7662 了解更多信息,以及我的邮件列表对您的问题的回复:http://www.haskell.org/pipermail/glasgow-haskell-users/2014-May/024976.html

    【讨论】:

      【解决方案2】:

      我认为您肯定会看到 GC 效果。我在木薯 (https://github.com/tibbe/cassava/issues/49#issuecomment-34929984) 中遇到了一个相关问题,其中 GC 时间随着堆大小的增加而线性增加。

      当你在内存中持有越来越多的数组时,尝试测量 GC 时间和 mutator 时间如何增加。

      您可以通过使用+RTS 选项来减少GC 时间。例如,尝试将 -A 设置为您的 L3 缓存大小。

      【讨论】:

      • 感谢您的意见!我会调查的。我刚刚看到的一件事很有趣:如果我将使用[Integer] 作为垃圾来填充堆的变体进行分析,那么我发现时间受堆大小的影响要小得多(参见底部的编辑)。
      • ...对我来说,使用 +RTS -A200M 运行会使列表和数组垃圾版本之间的差异消失(当比较运行与大致相同的最大堆使用量时)。
      • @jberryman 设置 - 足够高的值会使 GC 永远不会运行,但不要依赖这些数字来处理现实生活中的应用程序。在高 -A 中,将下一次 GC 推到足够远的地方,使其超出基准测试的运行时间。
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2017-10-09
      • 1970-01-01
      • 2017-11-30
      • 1970-01-01
      相关资源
      最近更新 更多