Já se passaram 7 anos desde que essa pergunta foi feita e ainda parece que ninguém apareceu com uma boa solução para esse problema. Repa não tem função mapM
/ traverse
like, mesmo que funcione sem paralelização. Além disso, considerando o progresso que houve nos últimos anos, parece improvável que isso aconteça.
Por causa do estado obsoleto de muitas bibliotecas de array em Haskell e minha insatisfação geral com seus conjuntos de recursos, coloquei alguns anos de trabalho em uma biblioteca de array massiv
, que pega emprestado alguns conceitos do Repa, mas o leva a um nível completamente diferente. Chega de introdução.
Antes de hoje, houve três mapa monadic como funções em massiv
(não contando o sinónimo como funções: imapM
, forM
. Et ai):
mapM
- o mapeamento usual em um arbitrário Monad
. Não é paralelizável por razões óbvias e também é um pouco lento (ao longo das linhas de costume mapM
em uma lista lenta)
traversePrim
- aqui estamos restritos a PrimMonad
, que é significativamente mais rápido do que mapM
, mas a razão para isso não é importante para esta discussão.
mapIO
- este, como o nome sugere, é restrito a IO
(ou melhor MonadUnliftIO
, mas isso é irrelevante). Como estamos dentro IO
, podemos dividir automaticamente a matriz em tantos pedaços quantos forem os núcleos e usar threads de trabalho separados para mapear a IO
ação sobre cada elemento nesses pedaços. Ao contrário de puro fmap
, que também é paralelizável, temos que estar IO
aqui por causa do não determinismo do agendamento combinado com os efeitos colaterais de nossa ação de mapeamento.
Então, depois de ler essa pergunta, pensei comigo mesmo que o problema estava praticamente resolvido massiv
, mas não tão rápido. Geradores de números aleatórios, como in mwc-random
e outros in random-fu
não podem usar o mesmo gerador em muitos threads. O que significa que a única peça do quebra-cabeça que faltava era: "desenhar uma nova semente aleatória para cada thread gerado e procedendo normalmente". Em outras palavras, eu precisava de duas coisas:
- Uma função que inicializaria tantos geradores quanto houvesse threads de trabalho
- e uma abstração que forneceria perfeitamente o gerador correto para a função de mapeamento, dependendo de qual thread a ação está sendo executada.
Então foi exatamente isso que eu fiz.
Primeiro, darei exemplos usando as funções randomArrayWS
e especialmente criadas initWorkerStates
, pois são mais relevantes para a questão e, posteriormente, passarei para o mapa monádico mais geral. Aqui estão as assinaturas 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 aqueles que não estão familiarizados com o massiv
, o Comp
argumento é uma estratégia de computação a ser usada, os construtores notáveis são:
Seq
- executa a computação sequencialmente, sem bifurcar quaisquer threads
Par
- acione tantos threads quantos forem os recursos e use-os para fazer o trabalho.
Vou usar o mwc-random
pacote como exemplo inicialmente e, posteriormente, mover para RVarT
:
λ> import Data.Massiv.Array
λ> import System.Random.MWC (createSystemRandom, uniformR)
λ> import System.Random.MWC.Distributions (standard)
λ> gens <- initWorkerStates Par (\_ -> createSystemRandom)
Acima, inicializamos um gerador separado por thread usando a aleatoriedade do sistema, mas poderíamos também ter usado uma semente única por thread derivando-a do WorkerId
argumento, que é um mero Int
índice do trabalhador. E agora podemos usar esses geradores para criar uma matriz com valores aleatórios:
λ> 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 ]
]
Usando a Par
estratégia, a scheduler
biblioteca irá dividir uniformemente o trabalho de geração entre os workers disponíveis e cada worker usará seu próprio gerador, tornando-o thread-safe. Nada nos impede de reutilizar o mesmo WorkerStates
número arbitrário de vezes, desde que não seja feito simultaneamente, o que, de outra forma, resultaria em uma exceção:
λ> 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 ]
Agora, deixando mwc-random
de lado, podemos reutilizar o mesmo conceito para outros casos de uso possíveis, usando funções 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)
e 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)
Aqui está o exemplo prometido sobre como utilizar esta funcionalidade com rvar
, random-fu
e mersenne-random-pure64
bibliotecas. Poderíamos ter usado randomArrayWS
aqui também, mas para fins de exemplo, digamos que já temos uma matriz com RVarT
s diferentes , caso em que precisamos de 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 ]
]
É importante observar que, apesar do fato de que a implementação pura de Mersenne Twister está sendo usada no exemplo acima, não podemos escapar do IO. Isso se deve ao escalonamento não determinístico, o que significa que nunca sabemos qual dos trabalhadores estará lidando com qual pedaço do array e, consequentemente, qual gerador será usado para qual parte do array. Por outro lado, se o gerador é puro e divisível, como splitmix
, então podemos usar a função de geração pura, determinística e paralelizável:, randomArray
mas isso já é uma história separada.