Playlists
Estas notas de aula são baseadas no livro “Parallel and Concurrent Programming in Haskell”, por Simon Marlow: https://simonmar.github.io/pages/pcph.html
A biblioteca Control.Parallel.Strategies
fornece os seguintes tipos e
funções para criar paralelismo:
data Eval a = ...
instance Monad Eval where ...
runEval :: Eval a -> a
rpar :: a -> Eval a
rseq :: a -> Eval a
A função rpar
indica que meu argumento pode ser executado em
paralelo.
A função rseq
diz meu argumento deve ser avaliado e o programa
deve esperar pelo resultado.
Em ambos os casos a avaliação é para WHNF . Além disso, o
argumento de rpar
deve ser uma expressão ainda não avaliada, ou
nada útil será feito.
Finalmente, a função runEval
executa uma expressão (em paralelo
ou não) e retorna o resultado dessa computação.
Note que a Monad Eval é pura e pode ser utilizada fora de
funções com IO
.
Para poder utilizar a Monad `Eval` e os exemplos desta página siga o seguintes passos:
Crie um projeto usando o Stack:
stack new paralelo simple
stack setup
Edite o arquivo paralelo.cabal
e na linha build-depends
acrescente as bibliotecas parallel, time
.
Na linha anterior a hs-source-dirs
acrescente a linha
ghc-options: -threaded -rtsopts -with-rtsopts=-N -eventlog
No arquivo Main.hs
acrescente:
import Control.Parallel.Strategies
import Control.Exception
import Data.Time.Clock
Vamos começar com um exemplo simples. Considere a implementação ingênua de Fibonacci:
fib :: Integer -> Integer
fib 0 = 0
fib 1 = 1
fib n = fib (n - 1) + fib (n - 2)
Digamos que queremos obter o resultado de fib 41
e fib 40
:
f = (fib 41, fib 40)
Podemos executar as duas chamadas de fib
em paralelo!
fparpar :: Eval (Integer, Integer)
fparpar = do a <- rpar (fib 41)
b <- rpar (fib 40)
return (a, b)
Altere a função main
para:
main :: IO ()
main = do
t0 <- getCurrentTime
-- evaluate força avaliação para WHNF
r <- evaluate (runEval fparpar)
t1 <- getCurrentTime
print (diffUTCTime t1 t0)
print r -- vamos esperar o resultado terminar
t2 <- getCurrentTime
print (diffUTCTime t2 t0)
Compile com stack build --profile
e execute com:
$ stack exec paralelo --RTS -- +RTS -N1
Explicando os parâmetros:
-threaded
: compile com suporte a multithreading-eventlog
: permite criar um log do uso de threads-rtsopts
: embute opções no seu programa+RTS
: flag para indicar opções embutidas-Nx
: quantas threads usar-s
: estatísticas de execução-ls
: gera log para o threadscopePara o parâmetro N1
a saída da execução retornará:
0.000002s
(165580141,102334155)
15.691738s
Para o parâmetro N2
a saída da execução retornará:
0.000002s
(165580141,102334155)
9.996815s
rpar-rpar
rpar-rpar
não aguarda o final da computação para
liberar a execução de outras tarefas:rpar-rseq
fparseq
e alterando a função main
para
utilizá-la:fparseq :: Eval (Integer, Integer)
fparseq = do a <- rpar (fib 41)
b <- rseq (fib 40)
return (a,b)
N2
:5.979055s
(165580141,102334155)
9.834702s
Agora runEval
aguarda a finalização do processamento de b
antes
de liberar para outros processos.
A estratégia rpar-rseq
aguarda a finalização do processamento seq
:
rpar-rpar-rseq-rseq
Finalmente podemos fazer:
fparparseq :: Eval (Integer, Integer)
fparparseq = do a <- rpar (fib 41)
b <- rpar (fib 40)
rseq a
rseq b
return (a,b)
E o resultado da execução com N2 é:
(165580141,102334155)
10.094287s
runEval
aguarda o resultado de todos os threads antes de
retornar:Escolhendo a estratégia
A escolha da combinação de estratégias depende muito do algoritmo que está sendo implementado.
Se pretendemos gerar mais paralelismo e não dependemos dos
resultados anteriores, rpar-rpar
faz sentido como estratégia.
Porém, se já geramos todo o paralelismo desejado e precisamos
aguardar o resultado rpar-rpar-rseq-rseq
pode ser a melhor
estratégia.
A biblioteca Control.Parallel.Strategies
define também o tipo:
type Strategies a = a -> Eval a
-- :: (a,b) -> Eval (a,b)
parPair :: Strategy (a,b)
parPair (a,b) = do a' <- rpar a
b' <- rpar b
return (a',b')
Dessa forma podemos escrever:
runEval (parPair (fib 41, fib 40))
Mas seria bom separar a parte sequencial da parte paralela para uma melhor manutenção do código.
Podemos então definir:
using :: a -> Strategy a -> a
x `using` s = runEval (s x)
Com isso nosso código se torna:
(fib 41, fib 40) `using` parPair
Estratégias parametrizadas
parPair
ainda é restritiva em relação a
estratégia adotada.
Uma generalização pode ser escrita como:
evalPair :: Strategy a -> Strategy b -> Strategy (a,b)
evalPair sa sb (a,b) = do a' <- sa a
b' <- sb b
return (a',b')
Nossa função parPair
pode ser reescrita como:
parPair :: Strategy (a,b)
parPair = evalPair rpar rpar
rpar
ou
rseq
.rdeepseq :: NFData a => Strategy a
rdeepseq x = rseq (force x)
rparWith :: Strategy a -> Strategy a
rparWith strat = parEval . strat
Dessa forma podemos fazer:
parPair :: Strategy a -> Strategy b -> Strategy (a,b)
parPair sa sb = evalPair (rparWith sa) (rparWith sb)
E podemos garantir uma estratégia paralela que avalia a estrutura por completo:
(fib 41, fib 40) `using` parPair rdeepseq rdeepseq
Como as listas representam uma estrutura importante no Haskell, a
biblioteca já vem com a estratégia parList
de tal forma que podemos
fazer:
map f xs `using` parList rseq
Essa é justamente a definição de parMap
:
parMap :: (a -> b) -> [a] -> [b]
parMap f xs = map f xs `using` parList rseq
Vamos definir a seguinte função que calcula a média dos valores de cada linha de uma matriz:
mean :: [[Double]] -> [Double]
mean xss = map mean' xss `using` parList rseq
where
mean' xs = sum xs / fromIntegral (length xs)
Cada elemento de xss
vai ser potencialmente avaliado em paralelo.
Compilando e executando esse código com o parâmetro -s
obtemos:
Total time 1.381s ( 1.255s elapsed)
O primeiro valor é a soma do tempo de máquina de cada thread, o segundo valor é o tempo total real de execução do programa.
O que houve?
Para instalar o programa threadscope faça o download em http://hackage.haskell.org/package/threadscope e:
$ tar zxvf threadscope-0.2.13.tar.gz
$ cd threadscope-0.2.13
$ stack install threadscope
Execute o programa da média incluindo o parâmetro -ls
e em seguida:
$ threadscope media.eventlog
Os gráficos em verde mostram o trabalho feito por cada core do computador:
Por que um core fez o dobro do trabalho?
rpar
cria um spark e
que é inserido no pool.Sinais de problemas:
Poucos sparks → pode ser paralelizado ainda mais
Muitos sparks → paralelizando demais
Muitos duds e fizzles → estratégia não otimizada.
Voltando ao nosso exemplo, se olharmos para a criação de sparks, percebemos que ocorreu overflow (parte vermelha), ou seja, criamos muitos sparks em um tempo muito curto:
Vamos tirar a estratégia…
mean :: [[Double]] -> [Double]
mean xss = map mean' xss
where
mean' xs = (sum xs) / (fromIntegral $ length xs)
E criar uma nova função que aplica a função mean
sequencial em
pedaços de nossa matriz:
meanPar :: [[Double]] -> [Double]
meanPar xss = concat medias
where
medias = map mean chunks `using` parList rseq
chunks = chunksOf 1000 xss
Agora criaremos menos sparks, pois cada spark vai cuidar de 1000
elementos de xss
.
O resultado:
Total time 1.289s ( 1.215s elapsed)
Não tem mais overflow! Mas…
A função mean
é aplicada em paralelo até encontrar a WHNF, ou seja,
apenas a promessa de calcular a média de cada linha!
Vamos usar a estratégia rdeepseq.
meanPar :: [[Double]] -> [Double]
meanPar xss = concat medias
where
medias = map mean chunks `using` parList rdeepseq
chunks = chunksOf 1000 xss
Total time 1.303s ( 0.749s elapsed)
😊
Estes slides foram preparados para os cursos de Paradigmas de Programação e Desenvolvimento Orientado a Tipos na UFABC.
Este material pode ser usado livremente desde que sejam mantidos, além deste aviso, os créditos aos autores e instituições.