24 votes

Le code devient plus lent au fur et à mesure qu'un plus grand nombre de tableaux de boîtes sont alloués.

Editar: Il s'avère que les choses en général (pas seulement les opérations sur les tableaux/réf) ralentissent au fur et à mesure que des tableaux sont créés, donc je suppose que cela pourrait simplement mesurer l'augmentation des temps GC et ne pas être aussi étrange que je le pensais. Mais j'aimerais vraiment savoir (et apprendre comment le découvrir) ce qui se passe ici, et s'il y a un moyen d'atténuer cet effet dans un code qui crée beaucoup de petits tableaux. La question originale suit.


En examinant des résultats d'analyse comparative bizarres dans une bibliothèque, je suis tombé sur un comportement que je ne comprends pas, bien qu'il puisse être vraiment évident. Il semble que le temps pris pour de nombreuses opérations (création d'un nouveau fichier MutableArray la lecture ou la modification d'un IORef ) augmente proportionnellement au nombre de tableaux en mémoire.

Voici le premier exemple :

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,()))
    ]

Nous créons un nouveau tableau et l'ajoutons à une pile. Au fur et à mesure que criterion fait plus d'échantillons et que la pile grandit, la création du tableau prend plus de temps, et cela semble croître linéairement et régulièrement :

slowdown

Encore plus étrange, IORef les lectures et les écritures sont affectées, et nous pouvons voir que les atomicModifyIORef' de plus en plus rapide, probablement parce que plus de tableaux sont 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,()))
    ]

enter image description here

L'une ou l'autre des deux lignes commentées permet de se débarrasser de ce comportement mais je ne sais pas trop pourquoi (peut-être qu'après avoir forcé la colonne de la liste, les éléments peuvent effectivement être collectés).

Questions

  • Qu'est-ce qui se passe ici ?
  • Est-ce un comportement attendu ?
  • Existe-t-il un moyen d'éviter ce ralentissement ?

Modifier : Je suppose que cela a quelque chose à voir avec le fait que la GC prend plus de temps, mais j'aimerais comprendre plus précisément ce qui se passe, notamment dans le premier benchmark.


Exemple de bonus

Enfin, voici un programme de test simple qui peut être utilisé pour pré-allouer un certain nombre de tableaux et chronométrer un certain nombre d'événements. atomicModifyIORef s. Cela semble présenter le comportement lent de 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)

Un profil de tas avec -hy montre surtout MUT_ARR_PTRS_CLEAN ce que je ne comprends pas tout à fait.

Si vous voulez reproduire, voici le fichier cabal que j'ai utilisé

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

Modifier : Voici un autre programme de test, qui peut être utilisé pour comparer le ralentissement avec des tas de tableaux de même taille par rapport à des tas de tableaux de même taille. [Integer] . Il faut faire des essais et des erreurs pour s'adapter n et en observant le profilage pour obtenir des résultats comparables.

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)

Il est intéressant de noter que lorsque je teste cette méthode, je constate que la même taille de tas de tableaux affecte davantage les performances que [Integer] . Par exemple

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

Conclusions (WIP)

  1. Ceci est très probablement dû au comportement du GC

  2. Mais les tableaux mutables sans boîte semblent conduire à des ralentissements plus sévères (voir ci-dessus). Réglage de +RTS -A200M ramène les performances de la version garbage array en ligne avec la version list, en soutenant que cela a à voir avec le GC.

  3. Le ralentissement est proportionnel au nombre de tableaux alloués, et non au nombre total de cellules dans le tableau. Voici un ensemble d'exécutions montrant, pour un test similaire à celui de main4 Les effets du nombre de tableaux alloués à la fois sur le temps d'allocation et sur une "charge utile" totalement indépendante. Ceci pour un total de 16777216 cellules (réparties sur un nombre illimité de tableaux) :

       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

    Et en exécutant ce même test sur 16777216*4 montre des temps de chargement pratiquement identiques à ceux indiqués ci-dessus, mais décalés de deux places vers le bas.

  4. D'après ce que j'ai compris du fonctionnement de GHC, et en regardant (3), je pense que ce surcoût pourrait être simplement dû au fait que des pointeurs vers tous ces tableaux restent dans le répertoire de l'utilisateur. jeu mémorisé (voir aussi : ici ), et tout ce que cela entraîne comme surcharge pour le GC.

5voto

Edward Z. Yang Points 13760

Vous payez une surcharge linéaire à chaque GC mineure par tableau mutable qui reste vivant et est promu à l'ancienne génération. Ceci est dû au fait que GHC place inconditionnellement tous les tableaux mutables sur la liste des mutables et parcourt la liste entière à chaque GC mineure. Voir https://ghc.haskell.org/trac/ghc/ticket/7662 pour plus d'informations, ainsi que la réponse de ma liste de diffusion à votre question : http://www.haskell.org/pipermail/glasgow-haskell-users/2014-May/024976.html

4voto

tibbe Points 3790

Je pense que vous voyez définitivement les effets du GC. J'ai eu un problème similaire avec le manioc ( https://github.com/tibbe/cassava/issues/49#issuecomment-34929984 ) où le temps GC augmente linéairement avec la taille du tas.

Essayez de mesurer comment le temps de GC et le temps de mutateur augmentent à mesure que vous conservez de plus en plus de tableaux en mémoire.

Vous pouvez réduire le temps de GC en jouant avec les +RTS options. Par exemple, essayez de définir -A à la taille de votre cache L3.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X