13 de março de 2012

Almoço grátis de paralelismo em Haskell

Hoje irei exagerar um pouco e verificar o quão grátis é o almoço de paralelismo servido pelo ecossistema Haskell.

Para fins de estudo, irei criar um algoritmo bastante simples e ideal para utilizar paralelismo: duas funções distintas com cargas de trabalho parecidas que não possuem dependência entre si.

Uma destas funções calculará qual é o menor valor de uma lista e a outra calculará o maior valor. Sendo assim, as duas precisarão consultar todos os elementos da lista e fazer a mesma quantidade de comparações. Os dois valores serão somados e impressos ao usuário:

module Main where

import Data.List (foldl1')

main = print result
  where result = a + b
        a = foldl1' min list
        b = foldl1' max list
        list = [1..19999999]

Compilei o código com ghc -rtsopts e executei ele com +RTS -s para verificar como ele se comportou:

20000000
     803,172,764 bytes allocated in the heap
     860,135,516 bytes copied during GC
     233,464,876 bytes maximum residency (9 sample(s))
      30,086,296 bytes maximum slop
             483 MB total memory in use (0 MB lost due to fragmentation)

  Generation 0:  1523 collections,     0 parallel,  0.80s,  0.97s elapsed
  Generation 1:     9 collections,     0 parallel,  0.92s,  1.30s elapsed

  INIT  time    0.00s  (  0.00s elapsed)
  MUT   time    0.90s  (  0.98s elapsed)
  GC    time    1.72s  (  2.27s elapsed)
  EXIT  time    0.00s  (  0.00s elapsed)
  Total time    2.62s  (  3.25s elapsed)

  %GC time      65.5%  (69.8% elapsed)

  Alloc rate    887,674,003 bytes per MUT second

  Productivity  34.5% of total user, 27.8% of total elapsed

Esta saída significa que o programa levou 3,25 segundos para executar e ocupou 483 MB de memória. Um valor um tanto absurdo para algo que deveria ser simples e rápido em Haskell. O problema do algoritmo é que durante o cálculo do valor mínimo, o coletor de lixo não coleta os itens já passados pois o cálculo do valor máximo possui referência para o inicio da lista.

Para resolver o problema é possível gerar listas diferentes, uma para cada cálculo na forma de uma função constante:

module Main where

import Data.List (foldl1')

main = print result
  where result = a + b
        a = foldl1' min $ list ()
        b = foldl1' max $ list ()
        list _ = [1..19999999]

Esta versão termina em 1,6 segundos e consome apenas 1 MB de memória. Atenção: se o programa for compilado com -O2, o GHC irá otimizar a declaração de função deixando a referencia para a lista no lugar e o programa continuará a usar os 483 MB de memória.

Este é o momento de refatorar o algoritmo para evitar duplicidade de código. Para um programa deste tamanho, acredito que normalmente não é feito, porém para que a transição para a utilização de paralelismo fique mais suave, é um passo necessário:

module Main where

import Data.List (foldl1')
import Control.Arrow ((&&&))

main = print result
  where result = (+) `uncurry` constMinMax list
        list _ = [1..19999999]

(.:) f g a b = f a (g b)
constApp = ($ ())
constMinMax = constMinList &&& constMaxList
constFold = foldl1' .: constApp
constMinList = constFold min
constMaxList = constFold max

O resultado e o tempo do algoritmo continuam os mesmos. Agora como tornamos este algoritmo preparado para rodar em paralelo? Simples: trocamos a utilização da função uncurry por parUncurry:

module Main where

import Data.List (foldl1')
import Control.Arrow ((&&&))
import Control.Parallel (par, pseq)

main = print result
  where result = (+) `parUncurry` constMinMax list
        list _ = [1..19999999]

(.:) f g a b = f a (g b)
constApp = ($ ())
constMinMax = constMinList &&& constMaxList
constFold = foldl1' .: constApp
constMinList = constFold min
constMaxList = constFold max
parUncurry f (a, b) = a `par` b `pseq` f a b

Adicionamos à compilação do programa o argumento -threaded e para executar utilizamos +RTS -s -N2 para que o programa utilize duas threads do sistema operacional. O resultado:

20000000
   1,606,300,044 bytes allocated in the heap
          74,364 bytes copied during GC
          28,352 bytes maximum residency (1 sample(s))
          24,896 bytes maximum slop
               2 MB total memory in use (0 MB lost due to fragmentation)

  Generation 0:  1566 collections,  1565 parallel,  0.06s,  0.02s elapsed
  Generation 1:     1 collections,     1 parallel,  0.00s,  0.00s elapsed

  Parallel GC work balance: 1.79 (15355 / 8588, ideal 2)

                        MUT time (elapsed)       GC time  (elapsed)
  Task  0 (worker) :    0.00s    (  0.81s)       0.00s    (  0.00s)
  Task  1 (worker) :    0.75s    (  0.81s)       0.06s    (  0.01s)
  Task  2 (bound)  :    0.83s    (  0.81s)       0.00s    (  0.00s)
  Task  3 (worker) :    0.00s    (  0.00s)       0.00s    (  0.00s)

  SPARKS: 1 (1 converted, 0 pruned)

  INIT  time    0.00s  (  0.00s elapsed)
  MUT   time    1.58s  (  0.81s elapsed)
  GC    time    0.06s  (  0.02s elapsed)
  EXIT  time    0.00s  (  0.00s elapsed)
  Total time    1.64s  (  0.83s elapsed)

  %GC time       3.8%  (1.9% elapsed)

  Alloc rate    1,019,478,198 bytes per MUT second

  Productivity  96.2% of total user, 189.6% of total elapsed

gc_alloc_block_sync: 78
whitehole_spin: 0
gen[0].sync_large_objects: 0
gen[1].sync_large_objects: 0

Conseguimos reduzir o tempo de execução para 0,83 segundos: a metade do tempo da versão sequencial. Tudo isto com a mudança de uma chamada (partindo do principio de que o programa está bem escrito).

Qual seria o tamanho da mudança necessária na linguagem que você utiliza hoje para ter este mesmo ganho de desempenho?

Apesar do exemplo deste post ser levemente exagerado, este tipo de ganho com apenas uma mudança é algo real em Haskell. A apresentação The Future is Parallel, and the Future of Parallel is Declarative feita por Simon Peyton Jones dá exemplos de aplicações que se beneficiaram deste paralelismo grátis.

2 de março de 2012

Convenções e obrigações

Aviso. Este post apenas levanta algumas questões sem oferecer nenhuma resposta, apenas sugestões.

Na programação orientada a objetos, os principios SOLID são bem difundidos. São cinco principios reunindo boas práticas em forma de convenções que tornam a programação orientada a objetos melhor, porém não são exigências do paradigma, i.e. os principios melhoram a vida e deveriam ser aplicados, mas não existe maneira de assegurar que eles foram. São eles:

  1. Single responsibility principle (SRP): Cada classe deve ter apenas uma responsabilidade e apenas uma razão para que ela sofra mudanças.
  2. Open/closed principle (OCP): As classes devem estar abertas para extensão e fechadas para modificações. Funcionalidades são adicionadas ao sistema através de código novo e não através de modificações de código existente.
  3. Liskov substitution principle (LSP): Uma classe só pode ser subclasse de outra se o funcionamento do código que utilize esta hierarquia não for alterado ao substituir uma pela outra.  Isto é, se uma função depende de uma coleção de objetos, não importa se o argumento é uma lista ou uma árvore, o resultado deve ser o mesmo.
  4. Interface segregation principle (ISP): Contratos devem ser resumidos, funções devem depender de contratos que reflitam apenas sua necessidade e nada mais.  Vários contratos pequenos são melhores do que um alguns contratos grandes.
  5. Dependency inversion principle (DIP): Métodos devem depender de contratos e não de tipos concretos. 

Além destes existe um principio que vale para todos os paradigmas: "Don't repeat yourself" (DRY).  O nome deste é bem intuitivo: não repita código.

Apesar de todas estas convenções serem boas, todas elas possuem a mesma falha: são convenções. Convenções são facilmente esquecidas, postergadas e até mesmo ignoradas. Nem todos os programadores possuem o mesmo nível de apreço pelo código produzido. O respeito a cada uma das convenções é medido de forma qualitativa e não quantitativa. Não existe fornecimento de segurança apenas utilizando convenções.

Esta falta de segurança resulta em dividas técnicas para as bases de código. O sistema inteiro vira um espagueti, quanto mais o código é trabalhado, mais enrolado ele fica.

Assim como utilizamos equipamentos de segurança para garantir a integridade de algum objeto ou entidade, por que não asseguramos a integridade de nossas bases de código? Fábricas de software asseguram a integridade do ambiente de trabalho utilizando catracas, cancelas, guaritas, etc. Mas utilizam o quê para assegurar a integridade do seu produto (código)? Sistemas de controle de versão apenas garantem a versão atual do seu sistema caso uma cagada colossal aconteça em uma versão futura.

Dividas técnicas custam caro no fechamento das contas: quanto mais espagueti o código está, mais tempo e empenho serão gastos para novas funcionalidades e correções. Então por quê não previnimos isto de alguma forma? Como poderíamos prevenir?

Recentemente li o artigo Why Concatenative Programming Matters de Jon Purdy e notei a ligação direta entre o paradigma de programação concatenativa e o principio DRY. Pelo fato da programação concatenativa ser livre de contexto, é possível eliminar todo código duplicado com um simples find/replace. Sendo assim, este é o paradigma de programação ideal para que o principio DRY seja aplicado. É possível que o próprio compilador dê sugestões para eliminar ou obrigue a eliminação de repetições no código, garantindo um código melhor para todos.

Com a leitura do artigo de Jon Purdy, comecei a ponderar sobre as questões lançadas neste post e percebi que as type classes da linguagem Haskell se encaixam como uma solução brilhante para OCP. Elas permitem que contratos sejam definidos de forma aberta, aceitando extensões de forma fácil e com garantia em tempo de compilação (nada de monkey patching).

Se olharmos as type classes, podemos perceber que elas sempre abstraem um único tipo de dado e suas funções. Garantindo coesão de código e o cumprimento da convenção ISP, pois cada type class se restringe a um tipo de operação.

A linguagem Haskell possui inferencia de tipos, o que permite que o compilador produza assinaturas dos tipos mais abstratos possíveis, garantindo o cumprimento da DIP de forma automática.

Sobre a LSP, acredito que linguagens como Coq podem garantir o cumprimento desta convenção. Porém, não me aventurei muito sobre este tipo de linguagem ainda, então não posso garantir. Mas vale a pena dar uma verificada no assunto.

tl;dr Convenções podem ser checadas de forma estática:

  • DRY: Concatenative programming.
  • OCP & ISP: Haskell type classes.
  • DIP: Haskell type inference.
  • LSP: Coq.