Han pasado 7 años desde que se hizo esta pregunta, y todavía parece que a nadie se le ocurrió una buena solución a este problema. Repa no tiene una función mapM
/ traverse
like, ni siquiera una que pueda ejecutarse sin paralelización. Además, considerando la cantidad de progreso que hubo en los últimos años, parece poco probable que suceda.
Debido al estado obsoleto de muchas bibliotecas de matrices en Haskell y a mi insatisfacción general con sus conjuntos de funciones, he realizado un par de años de trabajo en una biblioteca de matrices massiv
, que toma prestados algunos conceptos de Repa, pero lo lleva a un nivel completamente diferente. Suficiente con la intro.
Antes de hoy, hubo tres funciones mapa monádica como en massiv
(sin contar el sinónimo como funciones: imapM
, forM
et al.):
mapM
- el mapeo habitual de forma arbitraria Monad
. No se puede paralelizar por razones obvias y también es un poco lento (en la línea de lo habitual mapM
en una lista lenta)
traversePrim
- aquí estamos restringidos a PrimMonad
, que es significativamente más rápido que mapM
, pero la razón de esto no es importante para esta discusión.
mapIO
- este, como su nombre indica, está restringido a IO
(o más bien MonadUnliftIO
, pero eso es irrelevante). Debido a que estamos en IO
, podemos dividir automáticamente la matriz en tantos fragmentos como núcleos y usar subprocesos de trabajo separados para mapear la IO
acción sobre cada elemento en esos fragmentos. A diferencia de pure fmap
, que también es paralelizable, tenemos que estar IO
aquí debido al no determinismo de la programación combinado con los efectos secundarios de nuestra acción de mapeo.
Entonces, una vez que leí esta pregunta, pensé que el problema está prácticamente resuelto massiv
, pero no tan rápido. Los generadores de números aleatorios, como in mwc-random
y otros en random-fu
, no pueden usar el mismo generador en muchos subprocesos. Lo que significa que la única pieza del rompecabezas que me faltaba era: "dibujar una nueva semilla aleatoria para cada hilo generado y proceder como de costumbre". En otras palabras, necesitaba dos cosas:
- Una función que inicializaría tantos generadores como subprocesos de trabajo
- y una abstracción que daría sin problemas el generador correcto a la función de mapeo dependiendo del hilo en el que se esté ejecutando la acción.
Así que eso es exactamente lo que hice.
Primero daré ejemplos usando las funciones randomArrayWS
y especialmente diseñadas initWorkerStates
, ya que son más relevantes para la pregunta y luego pasaré al mapa monádico más general. Aquí están sus firmas de tipo:
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)
Para aquellos que no están familiarizados con massiv
el Comp
argumento es una estrategia de cálculo para usar, los constructores notables son:
Seq
- ejecutar el cálculo secuencialmente, sin bifurcar ningún hilo
Par
- Gire tantos hilos como capacidades haya y utilícelos para hacer el trabajo.
Usaré el mwc-random
paquete como ejemplo inicialmente y luego pasaré a RVarT
:
λ> import Data.Massiv.Array
λ> import System.Random.MWC (createSystemRandom, uniformR)
λ> import System.Random.MWC.Distributions (standard)
λ> gens <- initWorkerStates Par (\_ -> createSystemRandom)
Arriba inicializamos un generador separado por hilo usando la aleatoriedad del sistema, pero también podríamos haber usado una semilla única por hilo derivándola del WorkerId
argumento, que es un mero Int
índice del trabajador. Y ahora podemos usar esos generadores para crear una matriz con valores aleatorios:
λ> 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 ]
]
Al usar la Par
estrategia, la scheduler
biblioteca dividirá uniformemente el trabajo de generación entre los trabajadores disponibles y cada trabajador usará su propio generador, lo que lo hará seguro para subprocesos. Nada nos impide reutilizar la misma WorkerStates
cantidad arbitraria de veces siempre que no se haga al mismo tiempo, lo que de lo contrario resultaría en una excepción:
λ> 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 ]
Ahora dejando mwc-random
de lado podemos reutilizar el mismo concepto para otros posibles casos de uso usando funciones como 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)
y 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)
Aquí está el ejemplo prometido sobre cómo utilizar esta funcionalidad con rvar
, random-fu
y mersenne-random-pure64
bibliotecas. También podríamos haber usado randomArrayWS
aquí, pero por ejemplo, digamos que ya tenemos una matriz con diferentes RVarT
s, en cuyo caso necesitamos 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 ]
]
Es importante tener en cuenta que, a pesar de que en el ejemplo anterior se está utilizando la implementación pura de Mersenne Twister, no podemos escapar del IO. Esto se debe a la programación no determinista, lo que significa que nunca sabemos cuál de los trabajadores manejará qué parte de la matriz y, en consecuencia, qué generador se utilizará para qué parte de la matriz. Por el lado positivo, si el generador es puro y divisible, como splitmix
, entonces podemos usar la función de generación pura, determinista y paralelizable:, randomArray
pero eso ya es una historia separada.