Parallel mapM sur les tableaux Repa


90

Dans mon récent travail avec Gibbs sampling, j'ai beaucoup utilisé le RVarqui, à mon avis, fournit une interface presque idéale pour la génération de nombres aléatoires. Malheureusement, je n'ai pas pu utiliser Repa en raison de l'incapacité d'utiliser des actions monadiques dans les cartes.

Bien que les cartes clairement monadiques ne puissent pas être parallélisées en général, il me semble que cela RVarpeut être au moins un exemple de monade où les effets peuvent être parallélisés en toute sécurité (du moins en principe; je ne suis pas très familier avec le fonctionnement interne de RVar) . À savoir, je veux écrire quelque chose comme ce qui suit,

drawClass :: Sample -> RVar Class
drawClass = ...

drawClasses :: Array U DIM1 Sample -> RVar (Array U DIM1 Class)
drawClasses samples = A.mapM drawClass samples

A.mapMressemblerait quelque chose comme,

mapM :: ParallelMonad m => (a -> m b) -> Array r sh a -> m (Array r sh b)

Bien que la manière dont cela fonctionnerait dépende de manière cruciale de la mise en œuvre de RVaret de ses sous RandomSource- jacents , en principe, on pourrait penser que cela impliquerait de tirer une nouvelle graine aléatoire pour chaque thread généré et de procéder comme d'habitude.

Intuitivement, il semble que cette même idée puisse se généraliser à d'autres monades.

Donc, ma question est: pourrait-on construire une classe ParallelMonadde monades pour lesquelles les effets peuvent être parallélisés en toute sécurité (vraisemblablement habités par, au moins RVar)?

À quoi cela pourrait-il ressembler? Quelles autres monades pourraient habiter cette classe? D'autres ont-ils envisagé comment cela pourrait fonctionner dans Repa?

Enfin, si cette notion d'actions monadiques parallèles ne peut être généralisée, y a-t-il quelqu'un qui voit une manière intéressante de faire fonctionner cela dans le cas spécifique de RVar(où cela serait très utile)? Renoncer RVarau parallélisme est un compromis très difficile.


1
Je suppose que le point de friction est de "dessiner une nouvelle graine aléatoire pour chaque thread engendré" - comment cette étape devrait-elle fonctionner, et comment les graines devraient-elles être fusionnées à nouveau une fois que tous les threads reviennent?
Daniel Wagner

1
L'interface RVar aurait presque certainement besoin d'ajouts pour permettre la création d'un nouveau générateur avec une graine donnée. Certes, on ne sait pas comment la mécanique de ce travail et cela semble assez RandomSourcespécifique. Ma tentative naïve de dessiner une graine serait de faire quelque chose de simple et probablement très mal, comme dessiner un vecteur d'éléments (dans le cas de mwc-random) et ajouter 1 à chaque élément pour produire une graine pour le premier travailleur, ajouter 2 pour le second ouvrier, etc. Malheureusement insuffisant si vous avez besoin d'une entropie de qualité cryptographique; j'espère bien si vous avez juste besoin d'une promenade aléatoire.
bgamari

3
J'ai rencontré cette question en essayant de résoudre un problème similaire. J'utilise MonadRandom et System.Random pour des calculs aléatoires monadiques en parallèle. Ceci n'est possible qu'avec la splitfonction System.Random . Il a l'inconvénient de produire des résultats différents (en raison de la nature de, splitmais cela fonctionne. Cependant, j'essaie d'étendre cela aux tableaux Repa et je n'ai pas beaucoup de chance. Avez-vous fait des progrès ou est-ce mort- fin?
Tom Savage

1
Monade sans séquençage et dépendances entre les calculs me semble plus applicative.
John Tyree

1
J'hésite. Comme le note Tom Savage, splitfournit une base nécessaire, mais notez le commentaire sur la source pour savoir comment splitest implémentée: "- pas de base statistique pour cela!". J'incline à penser que toute méthode de fractionnement d'un PRNG laissera une corrélation exploitable entre ses branches, mais je n'ai pas le contexte statistique pour le prouver. En ce qui concerne la question générale, je ne suis pas certain que
isturdy

Réponses:


7

Cela fait 7 ans que cette question a été posée, et il semble toujours que personne n'ait trouvé une bonne solution à ce problème. Repa n'a pas de fonction mapM/ traverselike, même celle qui pourrait fonctionner sans parallélisation. De plus, compte tenu de l'ampleur des progrès accomplis ces dernières années, il semble peu probable que cela se produise non plus.

En raison de l'état obsolète de nombreuses bibliothèques de tableaux dans Haskell et de mon mécontentement général concernant leurs ensembles de fonctionnalités, j'ai mis en avant quelques années de travail dans une bibliothèque de tableaux massiv, qui emprunte certains concepts à Repa, mais l'amène à un niveau complètement différent. Assez avec l'intro.

Avant aujourd'hui, il y avait trois carte monadique comme fonctions massiv(sans compter les synonymes comme fonctions: imapM, forM. Et al):

  • mapM- la cartographie habituelle dans un arbitraire Monad. Non parallélisable pour des raisons évidentes et est également un peu lent (comme d'habitude mapMsur une liste lente)
  • traversePrim- ici, nous sommes limités à PrimMonad, ce qui est nettement plus rapide que mapM, mais la raison en est peu importante pour cette discussion.
  • mapIO- celui-ci, comme son nom l'indique, est limité à IO(ou plutôt MonadUnliftIO, mais ce n'est pas pertinent). Parce que nous sommes dans, IOnous pouvons automatiquement diviser le tableau en autant de morceaux qu'il y a de cœurs et utiliser des threads de travail séparés pour mapper l' IOaction sur chaque élément de ces morceaux. Contrairement à pure fmap, qui est également parallélisable, nous devons être IOici en raison du non-déterminisme de l'ordonnancement combiné aux effets secondaires de notre action de cartographie.

Donc, une fois que j'ai lu cette question, je me suis dit que le problème était pratiquement résolu massiv, mais pas si vite. Les générateurs de nombres aléatoires, tels que in mwc-randomet d'autres dans random-fune peuvent pas utiliser le même générateur sur de nombreux threads. Ce qui signifie que la seule pièce du puzzle qui me manquait était: "dessiner une nouvelle graine aléatoire pour chaque fil généré et procéder comme d'habitude". En d'autres termes, j'avais besoin de deux choses:

  • Une fonction qui initialiserait autant de générateurs qu'il y aura de threads de travail
  • et une abstraction qui donnerait de manière transparente le générateur correct à la fonction de mappage en fonction du thread dans lequel l'action s'exécute.

C'est donc exactement ce que j'ai fait.

Je vais d'abord donner des exemples en utilisant les fonctions randomArrayWSet les initWorkerStatesfonctions spécialement conçues , car elles sont plus pertinentes pour la question et je passerai plus tard à la carte monadique plus générale. Voici leurs signatures de type:

randomArrayWS ::
     (Mutable r ix e, MonadUnliftIO m, PrimMonad m)
  => WorkerStates g -- ^ Use `initWorkerStates` to initialize you per thread generators
  -> Sz ix -- ^ Resulting size of the array
  -> (g -> m e) -- ^ Generate the value using the per thread generator.
  -> m (Array r ix e)
initWorkerStates :: MonadIO m => Comp -> (WorkerId -> m s) -> m (WorkerStates s)

Pour ceux qui ne sont pas familiers avec massiv, l' Compargument est une stratégie de calcul à utiliser, les constructeurs notables sont:

  • Seq - exécuter le calcul séquentiellement, sans forger de threads
  • Par - faites tourner autant de threads qu'il y a de capacités et utilisez-les pour faire le travail.

J'utiliserai le mwc-randompackage comme exemple initialement et je passerai plus tard à RVarT:

λ> import Data.Massiv.Array
λ> import System.Random.MWC (createSystemRandom, uniformR)
λ> import System.Random.MWC.Distributions (standard)
λ> gens <- initWorkerStates Par (\_ -> createSystemRandom)

Ci-dessus, nous avons initialisé un générateur séparé par thread en utilisant le caractère aléatoire du système, mais nous aurions tout aussi bien pu utiliser un unique par thread en le dérivant de l' WorkerIdargument, qui est un simple Intindex du worker. Et maintenant, nous pouvons utiliser ces générateurs pour créer un tableau avec des valeurs aléatoires:

λ> randomArrayWS gens (Sz2 2 3) standard :: IO (Array P Ix2 Double)
Array P Par (Sz (2 :. 3))
  [ [ -0.9066144845415213, 0.5264323240310042, -1.320943607597422 ]
  , [ -0.6837929005619592, -0.3041255565826211, 6.53353089112833e-2 ]
  ]

En utilisant la Parstratégie, la schedulerbibliothèque répartira uniformément le travail de génération entre les ouvriers disponibles et chaque ouvrier utilisera son propre générateur, le rendant ainsi sûr pour les threads. Rien ne nous empêche de réutiliser le même WorkerStatesnombre arbitraire de fois tant que cela n'est pas fait simultanément, ce qui sinon entraînerait une exception:

λ> randomArrayWS gens (Sz1 10) (uniformR (0, 9)) :: IO (Array P Ix1 Int)
Array P Par (Sz1 10)
  [ 3, 6, 1, 2, 1, 7, 6, 0, 8, 8 ]

Maintenant, en mettant mwc-randomde côté, nous pouvons réutiliser le même concept pour d'autres cas d'utilisation possibles en utilisant des fonctions comme generateArrayWS:

generateArrayWS ::
     (Mutable r ix e, MonadUnliftIO m, PrimMonad m)
  => WorkerStates s
  -> Sz ix --  ^ size of new array
  -> (ix -> s -> m e) -- ^ element generating action
  -> m (Array r ix e)

et mapWS:

mapWS ::
     (Source r' ix a, Mutable r ix b, MonadUnliftIO m, PrimMonad m)
  => WorkerStates s
  -> (a -> s -> m b) -- ^ Mapping action
  -> Array r' ix a -- ^ Source array
  -> m (Array r ix b)

Voici l'exemple promis sur la façon d'utiliser cette fonctionnalité avec rvar, random-fuet les mersenne-random-pure64bibliothèques. Nous aurions pu utiliser randomArrayWSici aussi, mais à titre d'exemple, disons que nous avons déjà un tableau avec des RVarTs différents , auquel cas nous avons besoin d'un mapWS:

λ> import Data.Massiv.Array
λ> import Control.Scheduler (WorkerId(..), initWorkerStates)
λ> import Data.IORef
λ> import System.Random.Mersenne.Pure64 as MT
λ> import Data.RVar as RVar
λ> import Data.Random as Fu
λ> rvarArray = makeArrayR D Par (Sz2 3 9) (\ (i :. j) -> Fu.uniformT i j)
λ> mtState <- initWorkerStates Par (newIORef . MT.pureMT . fromIntegral . getWorkerId)
λ> mapWS mtState RVar.runRVarT rvarArray :: IO (Array P Ix2 Int)
Array P Par (Sz (3 :. 9))
  [ [ 0, 1, 2, 2, 2, 4, 5, 0, 3 ]
  , [ 1, 1, 1, 2, 3, 2, 6, 6, 2 ]
  , [ 0, 1, 2, 3, 4, 4, 6, 7, 7 ]
  ]

Il est important de noter que, malgré le fait que la mise en œuvre pure de Mersenne Twister est utilisée dans l'exemple ci-dessus, nous ne pouvons pas échapper à l'IO. Ceci est dû à l'ordonnancement non déterministe, ce qui signifie que nous ne savons jamais lequel des ouvriers manipulera quel morceau du tableau et par conséquent quel générateur sera utilisé pour quelle partie du tableau. Du côté positif, si le générateur est pur et divisible, comme splitmix, alors nous pouvons utiliser la fonction de génération pure, déterministe et parallélisable :, randomArraymais c'est déjà une histoire à part.


Au cas où vous voudriez voir quelques benchmarks: alexey.kuleshevi.ch/blog/2019/12/21/random-benchmarks
lehins

4

Ce n'est probablement pas une bonne idée de le faire en raison de la nature intrinsèquement séquentielle des PRNG. Au lieu de cela, vous souhaiterez peut-être effectuer la transition de votre code comme suit:

  1. Déclarez une fonction IO ( mainou quoi que ce soit).
  2. Lisez autant de nombres aléatoires que nécessaire.
  3. Passez les nombres (maintenant purs) à vos fonctions de repa.

Serait-il possible de graver chaque PRNG dans chaque thread parallèle pour créer une indépendance statistique?
J. Abrahamson

@ J.Abrahamson oui, ce serait possible. Voyez ma réponse.
lehins le
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.