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-rparrpar-rpar não aguarda o final da computação para
liberar a execução de outras tarefas:
Figure 1: rpar-rpar
rpar-rseqfparseq 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:

Figure 2: rpar-rseq
rpar-rpar-rseq-rseqFinalmente 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:
Figure 3: rpar-rpar-rseq-rseq
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.