MapM paralelo em matrizes Repa

90

Em meu trabalho recente com o Gibbs sampling, tenho feito grande uso do RVarque, na minha opinião, fornece uma interface quase ideal para geração de números aleatórios. Infelizmente, não consegui usar Repa devido à impossibilidade de usar ações monádicas em mapas.

Embora mapas claramente monádicos não possam ser paralelizados em geral, parece-me que RVarpode ser pelo menos um exemplo de uma mônada onde os efeitos podem ser paralelizados com segurança (pelo menos em princípio; não estou terrivelmente familiarizado com o funcionamento interno de RVar) . Ou seja, quero escrever algo como o seguinte,

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

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

onde A.mapMficaria algo como,

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

Embora claramente como isso funcionaria depende crucialmente da implementação RVare de sua base RandomSource, em princípio, alguém poderia pensar que isso envolveria desenhar uma nova semente aleatória para cada thread gerado e proceder normalmente.

Intuitivamente, parece que essa mesma ideia pode se generalizar para algumas outras mônadas.

Portanto, minha pergunta é: Alguém poderia construir uma classe ParallelMonadde mônadas para a qual os efeitos possam ser paralelizados com segurança (presumivelmente habitada por, pelo menos, RVar)?

Como pode ser? Que outras mônadas podem habitar esta classe? Outros já consideraram a possibilidade de como isso funcionaria em Repa?

Finalmente, se essa noção de ações monádicas paralelas não pode ser generalizada, alguém vê alguma maneira legal de fazer isso funcionar no caso específico de RVar(onde seria muito útil)? Desistir RVardo paralelismo é uma troca muito difícil.

Bgamari
fonte
1
Acho que o ponto principal é "desenhar uma nova semente aleatória para cada segmento gerado" - como essa etapa deve funcionar e como as sementes devem ser mescladas novamente quando todos os segmentos retornarem?
Daniel Wagner
1
A interface RVar quase certamente precisaria de alguns acréscimos para acomodar a geração de um novo gerador com uma determinada semente. Reconhecidamente, não está claro como a mecânica desse trabalho e parece bastante RandomSourceespecífico. Minha tentativa ingênua de desenhar uma semente seria fazer algo simples e provavelmente muito errado, como desenhar um vetor de elementos (no caso de mwc-random) e adicionar 1 a cada elemento para produzir uma semente para o primeiro trabalhador, adicionar 2 para o segundo trabalhador, etc. Completamente inadequado se você precisar de entropia de qualidade criptográfica; esperançosamente bem se você só precisa de um passeio aleatório.
bgamari
3
Eu me deparei com essa pergunta ao tentar resolver um problema semelhante. Estou usando MonadRandom e System.Random para cálculos aleatórios monádicos em paralelo. Isso só é possível com a splitfunção System.Random . Tem a desvantagem de produzir resultados diferentes (devido à natureza de, splitmas funciona. No entanto, estou tentando estender isso para matrizes Repa e não estou tendo muita sorte. Você fez algum progresso com isso ou está morto? fim?
Tom Savage
1
Monad sem sequenciamento e dependências entre cálculos soa mais como um aplicativo para mim.
John Tyree
1
Estou hesitante. Como observa Tom Savage, splitfornece uma base necessária, mas observe o comentário na fonte de como splité implementado: "- nenhuma base estatística para isso!". Inclino-me a pensar que qualquer método de divisão de um PRNG deixará uma correlação explorável entre seus ramos, mas não tenho o background estatístico para provar isso. Em relação à questão geral, não tenho certeza se
é

Respostas:

7

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/ traverselike, 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 mapMem 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 IOação sobre cada elemento nesses pedaços. Ao contrário de puro fmap, que também é paralelizável, temos que estar IOaqui 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-randome outros in random-funã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 randomArrayWSe 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 Compargumento é 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-randompacote 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 WorkerIdargumento, 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 Parestratégia, a schedulerbiblioteca 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 WorkerStatesnú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-randomde 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-fue mersenne-random-pure64bibliotecas. Poderíamos ter usado randomArrayWSaqui também, mas para fins de exemplo, digamos que já temos uma matriz com RVarTs 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:, randomArraymas isso já é uma história separada.

Lehins
fonte
Caso você queira ver alguns benchmarks: alexey.kuleshevi.ch/blog/2019/12/21/random-benchmarks
lehins
4

Provavelmente não é uma boa ideia fazer isso devido à natureza sequencial inerente dos PRNGs. Em vez disso, você pode querer fazer a transição de seu código da seguinte maneira:

  1. Declare uma função IO ( mainou o que você quiser).
  2. Leia quantos números aleatórios você precisar.
  3. Passe os números (agora puros) para as funções de reparo.
Mcandre
fonte
Seria possível gravar em cada PRNG em cada thread paralela para criar independência estatística?
J. Abrahamson
@ J.Abrahamson sim, seria possível. Veja minha resposta.
lehins