24 dias de Hackage, 2015 - dia 19 - ghc-core-html, list-fusion-probe: As regras de reescrita do GHC que excluem estruturas de dados intermediárias
por Franklin Chen
traduzido por Pedro Yamada
Esse é um artigo escrito por Franklin Chen e traduzido para o português. Ler original.
Índice de toda a série
O índice de toda a série está no topo do artigo para o dia 1.
Encontro HaskellBR São Paulo
Vamos nos encontrar em São Paulo em Janeiro de 2016. Marque sua presença e comente se não puder vir.
Vote se o encontro terá workshops, um dojo ou palestras.
Dia 19
A funcionalidade mais legal de usar Haskell, para mim, é o fusion. O compilador GHC realiza essa otimização memorável, que pode apagar estruturas intermediárias inteiras inteiras da existência.
O que isso quer dizer, e como podemos saber que aconteceu? Hoje vou mostrar
como o ghc-core-html
e o
list-fusion-probe
podem ajudar a determinar o que o compilador de fato fez com nossas estruturas
de dados intermediárias.
Vou dar exemplos usando listas como os dados intermediários, mas também mencionar vetores porque ontem, no dia 18, brevemente mencionei fusão no contexto de vetores.
Imports para o código
Primeiro, vamos tirar os imports do caminho antes de mostrar alguns testes HSpec e QuickCheck:
O que são dados intemediários em uma pipeline?
Quando estamos programando de forma composicional, frequentemente criamos pipelines de fluxo de dados, onde dados em um estágio são transformados em dados para o próximo e assim por diante até o resultado final. O problema é que uma implementação ingênua de um pipeline vai resultar na construção de alguma estrutura de dados intermediária que existe somente para ser consumida pelo o próximo estágio.
Aqui está um teste HSpec ilustrando uma pipeline onde nós nomeamos cada lista intermediária:
Em uma implementação típica de uma linguagem de programação típica, código que parece com esse vai resultar em alocar e criar quatro listas (ou arrays, ou qualquer que seja o tipo para coleções idiomático e desejado), um depois do outro, e percorrendo três listas usando um filter e dois maps. Para fazer algo mais inteligente e evitar criar estruturas de dados intermediárias, podemos usar um tipo de dados “stream”, ou mais radicalmente, “transducers”. Essas técnicas nos permitem comprimir a computação da estrutura de dados resultante em uma única percorrida, sem alocações ou dados intermediários.
O ecossistema do Haskell contém muitas funções para realizar esses tipos de
otimizações, como o
foldl
,
mas elas estão fora do escopo desse artigo. Ao invés disso, para exemplos
simples como o acima, o GHC já automaticamente aplica fusão, por meio de
regras de reescrita
especiais, inclusas nas bibliotecas padrão.
Uma nota na sintaxe para pipelines e composição
Podemos escrever pipeslines de muitas formas diferentes. Aqui está uma forma
estilo OO usando &
(aplicação reversa de funções) e >>>
(composição da
esquerda para a direita):
Aqui está a mesma coisa mas com a composição estraída. Essa é minha forma preferida de escrever pipelines como valores de primeira classe:
Usando a composição tradicional (.)
:
O que quer dizer “reescrita”?
A coisa que reescritas fazem que outras abordagens não: a reescrita é um passe de preprocessamento do compilador que reescreve seu código para o otimizar. Nós não vamos entrar exatamente em como isso funciona. Há regras de reescrita que encontrar alguns construtos e os substituem com construtos semanticamente equivalentes, e basicamente a fusão acontece quando, durante o processo de reescrever algo repetidamente, alguns construtos se cancelam, e “poof” seus dados intermediários se foram.
Se você olhar o código fonte para o
map
você vai ver alguns comentários intimidadores e diretivas para o compilador que
se parecem com:
Alguns recursos sobre fusão
Aqui estão alguns artigos ilustrando o funsionamento de baixo-nível da fusão:
E como saber se a fusão funcionou?
Algo que sempre me incomodou é que não há uma forma fácil de saber se a fusão funcionou de fato ou dizer ao compilador, “eu quero que a fusão ocorra sobre o resultado dessa expressão, então imprima um erro se você não puder fazer algo, porque eu preciso de eficiência máxima aqui”. Por exemplo, há um ticket aberto sobre algo que alguém esperava que fosse “fundido”.
Quando estou escrevendo código de alto nível e esperando que o compilador faça otimizações para mim, eu considero importante que seja notificado quando algo não funciona como esperado.
Então foi interessante quando eu encontrei a biblioteca list-fusion-probe
que
aplica seus próprios macetes de reescrita para determinar se uma lista foi
fundida ou não. A forma de a usar com uma expressão com tipo de lista é só a
amarrar em uma chamada para fuseThis
e a biblioteca vai reescrever a chamada
de forma que se a fusão acontecer, só a lista é retornada, mas senão, ela gera
código que vai atirar uma exceção durante runtime. Isso é extremamente
primitivo (eu nunca gostaria de botar código que atira uma exceção “cannot
fuse” em produção), mas é uma prova de conceito útil para testes.
Então aqui estão alguns testes ilustrando como a inserção de fuseThis
em
pipelines verifica que a fusão de fato acontece (porque não há nenhuma
exceção em runtime):
Nós verificamos que a list2
e a list3
nunca existiram como dados
“materializados”.
Note, por exemplo, que a fusão de exemplo do foldl
resulta na lista
[0..1001]
nunca ser criada; ao invés disso, o código de máquina gerado é
basicamente um loop com índice de 0
a 1001
adicionando um acumulador.
Aqui está uma falha de fusão:
Aqui estamos passando myFoldl
escrito de tal forma que ele não bate com as
regras de reescrita:
Finalmente, por diversão, vamos escrever um teste QuickCheck que testa
predicados arbitrários e funções de transformação em um pipeline filter
e
map
:
Porque não relatar que uma fusão falhou em tempo de compilação?
Há um problema de interface nas otimizações como a fusão. Idealmente, eu gostaria de declarar a fusão desejada e ver uma falha de compilação.
No entanto, a situação é difícil, porque o código pode ficar frágil no meio de
configurações otimização diferentes. Por exemplo, se você roda os testes acima
no GHCi, você vai ver que o GHCi nunca relata que uma fusão falhou. Isso é
porque ele simplesmente não está fazendo nenhuma fusão: só ignora as regras de
reescrita, tanto na biblioteca padrão e em list-fusion-probe
.
Talvez a solução é ter diretivas condicionais. Eu acho que há uma opção de tratar um bug conhecido de performance como um erro de compilação. Não é prático viver com medo e de forma defensiva quando estamos escrevendo código que deve ter boa performance e decidindo se, na falta de feeback do compilador, devemos escrever código de baixo nível feio, porque não confiamos que o código elegante vai funcionar propriamente.
O que você acha? Que tipo de feedback você gostaria de receber do compilador sobre otimizações que você espera que sejam realizadas?
Checando código gerado no exemplo de vector
de ontem
Ontem, mencionei que a fusão com vetores acontece, mas eu não estava
completamente confiante em quanto dela de fato acontece. Eu encontrei uma
ferramenta útil ghc-core-html
que imprime uma versão bonitinha do código de
GHC Core que o GHC gera.
Para o usar, o instale globalmente:
Eu o rodei em um arquivo com o código fonte modificado para receber o resultado em HTML:
O arquivo de exemplo:
Não vou mostrar a saída, mas só comentar que quando estava olhando para ela, eu
vi duas chamadas para newByteArray#
. Já que um dos arrays criados é o array
final, mas há uma outra alocação, nós não conseguimos chegar no ponto de criar
só um array sem alocar nenhum outro, diferente da nossa implementação de ontem
usando um MVector
interno.
Isso é bom saber, porque eu não deveria ter que gerar código de baixo-nível e o inspecionar para ter que ter informação sobre onde a fusão acontece e com o quê.
Para mais informação
Um tutorial sobre o processo de compilação do GHC está aqui. Aqui está outro.
Um vídeo por Simon Peyton Jones sobre Core.
Leia qualquer e toda coisa por Johan Tibell sobre performance com Haskell.
Conclusão
Hoje eu mencionei a importância de fusão e minha frustração de não ser capaz de
conseguir o tipo de informação detalhada que eu gostaria sobre se o GHC
realizou fusões onde eu gostaria. No entanto, list-fusion-probe
ajuda em
escrever testes para detectar quando a fusão de listas falha e ghc-core-html
é uma útil ferramenta de propósito geral para examinar para examinar o código
gerado.
Todo o código
Todo o código para a série estará nesse repositório do GitHub.
Nota do tradutor
Se você quer ajudar com esse tipo de coisa, agora é a hora. Entre no
Slack ou no
IRC da HaskellBR e
contribua. Esse blog e outros projetos associados estão na
organização haskellbr
no GitHub e em
haskellbr.com/git.