O GHC tem muitas otimizações que pode executar, mas não sei o que são, nem qual a probabilidade de serem executadas e em que circunstâncias.
Minha pergunta é: que transformações posso esperar que seja aplicada toda vez ou quase isso? Se eu olhar para um pedaço de código que será executado (avaliado) com frequência e meu primeiro pensamento for "hmm, talvez eu deva otimizar isso", em quais casos meu segundo pensamento será ", nem pense nisso, GHC conseguiu isso "?
Eu estava lendo o artigo Stream Fusion: De listas a fluxos a nada , e a técnica que eles usaram para reescrever o processamento de listas em uma forma diferente que as otimizações normais do GHC otimizariam de maneira confiável em loops simples eram novas para mim. Como posso saber quando meus próprios programas são elegíveis para esse tipo de otimização?
Há algumas informações no manual do GHC, mas isso apenas parte do caminho para responder à pergunta.
Edição: Estou começando uma recompensa. O que eu gostaria é de uma lista de transformações de nível inferior, como lambda / let / case-floating, especialização de argumento de tipo / construtor / função, análise de rigidez e unboxing, worker / wrapper e qualquer outra coisa significativa que o GHC faça que eu tenha deixado de fora , juntamente com explicações e exemplos de código de entrada e saída e, idealmente, ilustrações de situações em que o efeito total é maior que a soma de suas partes. E, idealmente, alguma menção de quando as transformações nãoacontecer. Não estou esperando explicações completas de todas as transformações, algumas frases e exemplos de código de linha única podem ser suficientes (ou um link, se não for para vinte páginas de artigos científicos), desde que o quadro geral seja claro até o final. Eu quero ser capaz de analisar um pedaço de código e fazer um bom palpite sobre se ele será compilado em um loop apertado, ou por que não, ou o que eu precisaria mudar para fazer isso. (Não estou muito interessado aqui nas grandes estruturas de otimização, como a fusão por fluxo (acabei de ler um artigo sobre isso); mais no tipo de conhecimento que as pessoas que escrevem essas estruturas têm.)
fonte
Respostas:
Esta página do GHC Trac também explica os passes bastante bem. Esta página explica a ordem de otimização, porém, como a maioria do Trac Wiki, está desatualizada.
Para detalhes, a melhor coisa a fazer é provavelmente ver como um programa específico é compilado. A melhor maneira de ver quais otimizações estão sendo executadas é compilar o programa verbalmente, usando o
-v
sinalizador. Tomando como exemplo o primeiro pedaço de Haskell que encontrei no meu computador:Olhando desde o primeiro
*** Simplifier:
ao último, onde todas as fases de otimização acontecem, vemos bastante.Primeiro de tudo, o simplificador funciona entre quase todas as fases. Isso facilita a escrita de muitos passes. Por exemplo, ao implementar muitas otimizações, elas simplesmente criam regras de reescrita para propagar as alterações, em vez de fazê-las manualmente. O simplificador abrange várias otimizações simples, incluindo inlining e fusão. A principal limitação disso que eu sei é que o GHC se recusa a incorporar funções recursivas e que as coisas precisam ser nomeadas corretamente para que a fusão funcione.
Em seguida, vemos uma lista completa de todas as otimizações realizadas:
Especializar-se
A idéia básica da especialização é remover o polimorfismo e a sobrecarga, identificando os locais onde a função é chamada e criando versões da função que não são polimórficas - elas são específicas para os tipos com as quais são chamadas. Você também pode dizer ao compilador para fazer isso com o
SPECIALISE
pragma. Como exemplo, considere uma função fatorial:Como o compilador não conhece nenhuma propriedade da multiplicação a ser usada, ele não pode otimizar isso. Se, no entanto, ele for usado em um
Int
, agora ele poderá criar uma nova versão, diferindo apenas no tipo:Em seguida, as regras mencionadas abaixo podem ser acionadas e você acaba trabalhando com algo que não está na caixa
Int
s sem , o que é muito mais rápido que o original. Outra maneira de analisar a especialização é a aplicação parcial em dicionários de classes de tipo e variáveis de tipo.A fonte aqui tem várias notas.
Flutuar
Edição: Eu aparentemente entendi errado isso antes. Minha explicação mudou completamente.
A idéia básica disso é mover cálculos que não devem ser repetidos fora de funções. Por exemplo, suponha que tenhamos o seguinte:
No lambda acima, toda vez que a função é chamada,
y
é recalculada. Uma função melhor, que flutua produz, éPara facilitar o processo, outras transformações podem ser aplicadas. Por exemplo, isso acontece:
Mais uma vez, o cálculo repetido é salvo.
A fonte é muito legível neste caso.
No momento, as ligações entre duas lambdas adjacentes não são flutuadas. Por exemplo, isso não acontece:
Indo a
Flutuar para dentro
Citando o código fonte,
O principal objetivo de
floatInwards
é flutuar nas ramificações de um caso, para não alocarmos as coisas, salvá-las na pilha e descobrir que elas não são necessárias na ramificação escolhida.Como exemplo, suponha que tenhamos esta expressão:
Se
v
avaliamosFalse
, então, alocandox
, o que é presumivelmente um grande problema, perdemos tempo e espaço. Flutuar para dentro corrige isso, produzindo o seguinte:, que é posteriormente substituído pelo simplificador por
Este artigo , embora cubra outros tópicos, apresenta uma introdução bastante clara. Observe que, apesar de seus nomes, flutuar dentro e fora não entra em um loop infinito por dois motivos:
case
declarações, enquanto float out lida com funções.Análise de demanda
A análise de demanda ou análise de rigidez é menos uma transformação e mais, como o nome sugere, um passe de coleta de informações. O compilador encontra funções que sempre avaliam seus argumentos (ou pelo menos alguns deles) e passa esses argumentos usando chamada por valor, em vez de chamada por necessidade. Como você evita as sobrecargas dos thunks, isso geralmente é muito mais rápido. Muitos problemas de desempenho no Haskell surgem da falha dessa passagem ou do código simplesmente não sendo suficientemente rigoroso. Um exemplo simples é a diferença entre usar
foldr
,foldl
efoldl'
para somar uma lista de números inteiros - a primeira causa o estouro da pilha, a segunda causa o estouro da pilha e a última é executada corretamente, devido ao rigor. Este é provavelmente o mais fácil de entender e melhor documentado de todos eles. Eu acredito que o polimorfismo e o código CPS frequentemente derrotam isso.Vinculações de Wrapper do Trabalhador
A idéia básica da transformação de trabalhador / invólucro é fazer um loop apertado em uma estrutura simples, convertendo para e a partir dessa estrutura nas extremidades. Por exemplo, considere esta função, que calcula o fatorial de um número.
Usando a definição de
Int
no GHC, temosObserve como o código é coberto em
I#
s? Podemos removê-los fazendo o seguinte:Embora esse exemplo específico também possa ter sido feito pelo SpecConstr, a transformação de trabalhador / wrapper é muito geral nas coisas que ele pode fazer.
Subexpressão comum
Essa é outra otimização realmente simples que é muito eficaz, como a análise de rigidez. A idéia básica é que, se você tiver duas expressões iguais, elas terão o mesmo valor. Por exemplo, se
fib
for uma calculadora de número de Fibonacci, o CSE transformarápara dentro
que corta o cálculo pela metade. Infelizmente, isso pode ocasionalmente atrapalhar outras otimizações. Outro problema é que as duas expressões devem estar no mesmo lugar e que devem ser sintaticamente iguais, não iguais em valor. Por exemplo, o CSE não será acionado no seguinte código sem muita inlining:
No entanto, se você compilar via llvm, poderá obter parte disso combinado, devido ao seu passe de numeração de valor global.
Liberar caso
Essa parece ser uma transformação terrivelmente documentada, além do fato de poder causar explosão de código. Aqui está uma versão reformatada (e um pouco reescrita) da pequena documentação que encontrei:
Este módulo passa por cima
Core
e procura porcase
variáveis livres. O critério é: se houver umacase
variável livre na rota para a chamada recursiva, a chamada recursiva será substituída por um desdobramento. Por exemplo, emo interior
f
é substituído. fazerObserve a necessidade de sombreamento. Simplificando, temos
Este é um código melhor, porque
a
é livre dentro do interiorletrec
, em vez de precisar de projeçãov
. Observe que isso lida com variáveis livres , ao contrário de SpecConstr, que lida com argumentos de forma conhecida.Veja abaixo mais informações sobre o SpecConstr.
SpecConstr - isso transforma programas como
para dentro
Como um exemplo estendido, considere esta definição de
last
:Nós o transformamos primeiro em
Em seguida, o simplificador é executado e temos
Observe que o programa agora está mais rápido, pois não estamos repetidamente encaixotando e descompactando a frente da lista. Observe também que o inlining é crucial, pois permite que as novas definições mais eficientes sejam realmente usadas, além de melhorar as definições recursivas.
SpecConstr é controlado por várias heurísticas. Os mencionados no artigo são os seguintes:
a
.No entanto, as heurísticas quase certamente mudaram. De fato, o artigo menciona uma sexta heurística alternativa:
Especialize-se em um argumento
x
apenas sex
for examinado apenas por acase
e não for passado para uma função comum ou retornado como parte do resultado.Este era um arquivo muito pequeno (12 linhas) e, portanto, possivelmente não desencadeou tantas otimizações (embora eu ache que tenha feito todas elas). Isso também não diz por que ele escolheu esses passes e por que os colocou nessa ordem.
fonte
Preguiça
Não é uma "otimização de compilador", mas é algo garantido pela especificação da linguagem, para que você possa sempre contar com isso. Essencialmente, isso significa que o trabalho não é realizado até que você "faça algo" com o resultado. (A menos que você faça uma de várias coisas para deliberadamente desativar a preguiça.)
Obviamente, esse é um tópico inteiro e, portanto, o SO já tem muitas perguntas e respostas.
Na minha experiência limitada, tornar seu código muito preguiçoso ou muito rigoroso tem penalidades de desempenho muito maiores (no tempo e no espaço) do que qualquer outra coisa sobre a qual estou prestes a falar ...
Análise de rigidez
Preguiça é evitar o trabalho, a menos que seja necessário. Se o compilador puder determinar que um determinado resultado será "sempre" necessário, não será necessário armazenar o cálculo e executá-lo posteriormente; apenas executará diretamente, porque é mais eficiente. Isso é chamado de "análise de rigidez".
O problema, obviamente, é que o compilador nem sempre pode detectar quando algo pode ser feito estrito. Às vezes, você precisa dar pequenas dicas ao compilador. (Não conheço nenhuma maneira fácil de determinar se a análise de rigidez fez o que você pensa que tem, além de analisar a saída do Core.)
Inlining
Se você chamar uma função e o compilador puder dizer para qual função você está chamando, ele poderá tentar "incorporar" essa função - ou seja, substituir a chamada de função por uma cópia da própria função. A sobrecarga de uma chamada de função geralmente é muito pequena, mas o inlining geralmente permite que outras otimizações ocorram, o que não teria acontecido de outra forma, portanto o inlining pode ser uma grande vitória.
As funções são incorporadas apenas se forem "suficientemente pequenas" (ou se você adicionar um pragma especificamente solicitando inlining). Além disso, as funções só podem ser incorporadas se o compilador puder dizer qual função você está chamando. Há duas maneiras principais que o compilador pode não conseguir dizer:
Se a função que você está chamando for passada de outro lugar. Por exemplo, quando o
filter
função é compilada, você não pode incorporar o predicado de filtro, porque é um argumento fornecido pelo usuário.Se a função que você está chamando é um método de classe e o compilador não sabe que tipo está envolvido. Por exemplo, quando a
sum
função é compilada, o compilador não pode incorporar a+
função, porquesum
funciona com vários tipos de números diferentes, cada um com um+
função .No último caso, você pode usar o
{-# SPECIALIZE #-}
pragma para gerar versões de uma função codificada para um tipo específico. Por exemplo,{-# SPECIALIZE sum :: [Int] -> Int #-}
compilaria uma versão dosum
código embutido para oInt
tipo, o que significa que+
pode ser incorporado nesta versão.Note, no entanto, que nossa nova
sum
função especial só será chamada quando o compilador puder dizer que estamos trabalhandoInt
. Caso contrário, o original, polimórficosum
é chamado. Novamente, a sobrecarga real da chamada de função é bastante pequena. São as otimizações adicionais que o inlining pode permitir que são benéficas.Eliminação de subexpressão comum
Se um determinado bloco de código calcular o mesmo valor duas vezes, o compilador poderá substituí-lo por uma única instância da mesma computação. Por exemplo, se você fizer
o compilador pode otimizar isso para
Você pode esperar que o compilador sempre faça isso. No entanto, aparentemente, em algumas situações, isso pode resultar em pior desempenho, nem melhor, portanto o GHC nem sempre faz isso. Francamente, eu realmente não entendo os detalhes por trás deste. Mas o ponto principal é que, se essa transformação é importante para você, não é difícil fazer isso manualmente. (E se não é importante, por que você está se preocupando com isso?)
Expressões de caso
Considere o seguinte:
Todas as três primeiras equações verificam se a lista está vazia (entre outras coisas). Mas verificar a mesma coisa três vezes é um desperdício. Felizmente, é muito fácil para o compilador otimizar isso em várias expressões de caso aninhadas. Nesse caso, algo como
Isso é bem menos intuitivo, mas mais eficiente. Como o compilador pode facilmente fazer essa transformação, você não precisa se preocupar com isso. Basta escrever sua correspondência de padrões da maneira mais intuitiva possível; o compilador é muito bom em reordenar e reorganizar isso para torná-lo o mais rápido possível.
Fusão
O idioma padrão Haskell para o processamento de listas é encadear funções que levam uma lista e produzem uma nova lista. O exemplo canônico sendo
Infelizmente, embora a preguiça garanta pular o trabalho desnecessário, todas as alocações e desalocações para o desempenho intermediário da lista diminuem. "Fusão" ou "desmatamento" é onde o compilador tenta eliminar essas etapas intermediárias.
O problema é que a maioria dessas funções é recursiva. Sem a recursão, seria um exercício elementar inline alinhar todas as funções em um grande bloco de código, executar o simplificador sobre ele e produzir um código realmente ótimo sem listas intermediárias. Mas por causa da recursão, isso não vai funcionar.
Você pode usar
{-# RULE #-}
pragmas para corrigir um pouco disso. Por exemplo,Agora, toda vez que o GHC vê o
map
pedidomap
, ele o esmaga em uma única passagem pela lista, eliminando a lista intermediária.O problema é que isso funciona apenas para
map
seguido demap
. Existem muitas outras possibilidades -map
seguidas porfilter
,filter
seguidas pormap
etc. Em vez de codificar manualmente uma solução para cada uma delas, a chamada "fusão de fluxo" foi inventada. Esse é um truque mais complicado, que não descreverei aqui.O mais longo e mais curto é: Estes são todos os truques especiais de otimização escritos pelo programador . O próprio GHC não sabe nada sobre fusão; está tudo nas bibliotecas de listas e outras bibliotecas de contêineres. Portanto, quais otimizações acontecem depende de como as bibliotecas de contêiner são gravadas (ou, mais realista, quais bibliotecas você escolhe usar).
Por exemplo, se você trabalha com matrizes Haskell '98, não espere qualquer tipo de fusão. Mas entendo que a
vector
biblioteca possui amplos recursos de fusão. É tudo sobre as bibliotecas; o compilador apenas fornece oRULES
pragma. (A propósito, o que é extremamente poderoso. Como autor de uma biblioteca, você pode usá-lo para reescrever o código do cliente!)Meta:
Concordo com as pessoas que dizem "codifique primeiro, perfil segundo, otimize terceiro".
Também concordo com as pessoas que dizem "é útil ter um modelo mental para quanto custa uma determinada decisão de projeto".
Equilíbrio em todas as coisas, e tudo o que ...
fonte
it's something guaranteed by the language specification ... work is not performed until you "do something" with the result.
- não exatamente. A especificação da linguagem promete semântica não estrita ; não promete nada sobre se o trabalho supérfluo será ou não executado.Se uma ligação let v = rhs for usada em apenas um lugar, você poderá contar com o compilador para incorporá-lo, mesmo se rhs for grande.
A exceção (que quase não é uma no contexto da pergunta atual) são as lambdas que correm o risco de duplicar o trabalho. Considerar:
lá inlining v seria perigoso porque o uso (sintático) seria traduzido em 99 avaliações extras de rhs. No entanto, nesse caso, é improvável que você deseje incorporá-lo manualmente. Então, basicamente você pode usar a regra:
Se você considerar incluir um nome que apareça apenas uma vez, o compilador fará isso de qualquer maneira.
Como um corolário feliz, usar uma ligação let simplesmente para decompor uma declaração longa (com esperança de ganhar clareza) é essencialmente gratuito.
Isso vem de community.haskell.org/~simonmar/papers/inline.pdf, que inclui muito mais informações sobre inlining.
fonte