Otimização de Código: Fundamentos e Técnicas Avançadas
Bem-vindo ao guia completo sobre técnicas de otimização de código para compiladores. Aprenda como transformar código ineficiente em soluções de alto desempenho que economizam recursos computacionais.
Introdução à Otimização de Código
A otimização de código é um processo essencial no desenvolvimento de compiladores, voltado para melhorar o desempenho e eficiência do código gerado, sem alterar sua funcionalidade original.
Este processo transforma o código intermediário ou de máquina em versões mais eficientes, considerando fatores como velocidade de execução, tamanho do código e uso de recursos computacionais.
A otimização pode acontecer em diferentes fases da compilação, sendo mais comum durante a manipulação da representação intermediária (IR) ou após a geração do código final.
Por que Otimizar Código?
1
Desempenho
Código otimizado executa mais rapidamente, reduzindo o tempo de resposta de aplicações e melhorando a experiência do usuário.
2
Eficiência Energética
Programas otimizados consomem menos energia, aspecto crucial para dispositivos móveis e sistemas embarcados.
3
Tamanho do Código
A redução do tamanho do código gerado permite melhor uso da memória cache e diminui o espaço de armazenamento necessário.
4
Competitividade
Compiladores que geram código mais eficiente proporcionam vantagem competitiva para linguagens de programação no mercado.
Fases de Otimização
1
Análise Léxica e Sintática
Otimizações limitadas nesta fase, focadas principalmente na eficiência do próprio processo de análise.
2
Representação Intermediária (IR)
Principal fase de otimização, onde a maioria das técnicas é aplicada. O código em formato IR facilita transformações sem dependência de arquitetura.
3
Pré-Geração de Código
Otimizações independentes de máquina que preparam o código para a geração específica de plataforma.
4
Pós-Geração de Código
Ajustes finais específicos para a arquitetura alvo, como uso de registradores e instruções especializadas.
Otimizações Locais vs. Globais
Otimizações Locais
Aplicadas dentro de blocos básicos (sequências de instruções sem desvios), são mais simples de implementar e oferecem resultados rápidos.
  • Propagação de constantes em expressões simples
  • Eliminação de código morto em um único bloco
  • Simplificação algébrica de expressões
Otimizações Globais
Realizadas considerando o fluxo entre múltiplos blocos, exigem análise mais complexa, mas geram melhorias significativas.
  • Análise de fluxo de dados entre funções
  • Eliminação de código morto em todo o programa
  • Movimentação de código invariante para fora de loops
Otimizações Estáticas vs. Dinâmicas
Otimizações Estáticas
Realizadas durante o processo de compilação, antes da execução do programa.
  • Não adicionam overhead durante a execução
  • Baseiam-se apenas em informações disponíveis em tempo de compilação
  • São permanentes no código gerado
Otimizações Dinâmicas
Ocorrem durante a execução do programa, como em Just-In-Time compilers.
  • Consideram o comportamento real do programa em execução
  • Podem adaptar-se aos padrões de uso específicos
  • Adicionam algum overhead computacional
Análise de Custo-Benefício na Otimização
A otimização de código envolve sempre um compromisso entre diversos fatores. É fundamental avaliar o custo-benefício de cada técnica aplicada.
Fatores a considerar:
  • Tempo de compilação vs. ganho de desempenho
  • Complexidade da implementação vs. melhoria obtida
  • Consumo de memória durante a compilação
  • Aplicabilidade ao domínio específico do problema
Nem sempre mais otimização significa melhor resultado. Em alguns casos, otimizações agressivas podem até piorar o desempenho devido ao aumento da complexidade ou efeitos colaterais não previstos.
Quando Evitar Otimizações Excessivas
Depuração Dificultada
Código altamente otimizado pode perder a correspondência clara com o código-fonte original, dificultando a identificação e correção de bugs.
Tempo de Compilação
Otimizações complexas aumentam significativamente o tempo de compilação, prejudicando o ciclo de desenvolvimento, especialmente em projetos grandes.
Comportamento Imprevisível
Algumas otimizações podem introduzir comportamentos sutilmente diferentes em casos extremos ou condições de corrida em sistemas concorrentes.
Restrições de Hardware
Em sistemas embarcados com restrições estritas de tempo real, otimizações que alteram o timing podem comprometer a previsibilidade necessária.
Eliminação de Código Morto (DCE)
A Eliminação de Código Morto (Dead Code Elimination) é uma técnica que remove instruções que não afetam o resultado final do programa. Código morto pode surgir por diversas razões:
  • Variáveis calculadas mas nunca utilizadas
  • Condições que sempre avaliam para o mesmo resultado
  • Código inacessível após instruções de salto incondicional
  • Funções definidas mas nunca chamadas
A implementação desta técnica geralmente utiliza análise de fluxo de controle e análise de liveness para identificar instruções que podem ser seguramente removidas.
Propagação de Constantes
1
Identificação
Detectar variáveis que recebem valores constantes ou que podem ser determinadas em tempo de compilação.
x = 5 y = x + 2 z = y * 3
2
Substituição
Substituir referências a essas variáveis pelos seus valores constantes.
x = 5 y = 5 + 2 z = y * 3
3
Propagação
Continuar o processo enquanto for possível determinar novos valores constantes.
x = 5 y = 7 z = 7 * 3
4
Resultado Final
Código simplificado com valores constantes determinados em tempo de compilação.
x = 5 y = 7 z = 21
Constant Folding (Dobragem de Constantes)
Constant Folding é o processo de avaliação de expressões constantes em tempo de compilação, substituindo a expressão pelo seu resultado calculado.
Exemplos:
// Antes da otimização int x = 5 * 60; int y = 10 + 20 * 3; double z = Math.PI * 2; // Após Constant Folding int x = 300; int y = 70; double z = 6.28318;
Esta técnica reduz o número de operações em tempo de execução e pode habilitar outras otimizações, como eliminação de código morto e propagação de constantes adicional.
Strength Reduction (Redução de Força)
Conceito
Substituição de operações computacionalmente caras por equivalentes mais simples e eficientes, sem alterar o resultado final.
Multiplicação por Potências de 2
Substituir multiplicações por deslocamentos à esquerda:
x * 8 → x << 3
Divisão por Potências de 2
Substituir divisões por deslocamentos à direita:
x / 4 → x >> 2
Multiplicação por Constantes
Decompor em operações mais simples:
x * 10 → (x << 3) + (x << 1)
Expressões em Loops
Converter multiplicações em adições incrementais para índices de arrays em loops.
Common Subexpression Elimination (CSE)
A Eliminação de Subexpressões Comuns identifica cálculos repetidos no código e armazena o resultado em uma variável temporária, evitando recálculos desnecessários.
Exemplo:
// Antes da otimização a = b * c + d; e = b * c * f; // Após CSE temp = b * c; a = temp + d; e = temp * f;
Esta técnica é particularmente eficaz em expressões matemáticas complexas, cálculos de endereços de arrays e expressões booleanas em condicionais.
Exemplo: Eliminação de Código Morto com TAC
Código Original
t1 = a + b t2 = c * d t3 = t1 + t2 t4 = e - f t5 = g / h // t3 e t5 nunca são usados x = t4
Código Otimizado
// t1, t2, t3 são eliminados // t5 é eliminado t4 = e - f x = t4
Após análise de liveness, todas as instruções que calculam valores não utilizados são removidas.
Exemplo: Propagação de Constantes
Código Original
a = 5 b = 10 c = a d = c + b e = d * 2
Passo 1: Propagar "a"
a = 5 b = 10 c = 5 d = c + b e = d * 2
Passo 2: Propagar "c"
a = 5 b = 10 c = 5 d = 5 + b e = d * 2
Passo 3: Propagar "b"
a = 5 b = 10 c = 5 d = 5 + 10 e = d * 2
Código Final Otimizado
a = 5 b = 10 c = 5 d = 15 e = 30
Exemplo: Constant Folding em Expressões
Antes da Otimização
t1 = 3.14159 * 2 t2 = 10 / 5 t3 = t1 * (4 * 4) t4 = t3 + t2 if (5 > 3) goto L1 goto L2 L1: x = 10 L2: y = 20
Após Constant Folding
t1 = 6.28318 t2 = 2 t3 = 100.53088 t4 = 102.53088 goto L1 L1: x = 10 L2: y = 20
Note que a expressão condicional também foi avaliada, eliminando o desvio condicional desnecessário.
Exemplo: Substituição de Subexpressões Repetidas
Código Original
a[i] = b[i] * (c[i] + d[i]) e[i] = (c[i] + d[i]) * 2
A subexpressão (c[i] + d[i]) é calculada duas vezes, o que representa um desperdício de recursos computacionais.
Código Otimizado
temp = c[i] + d[i] a[i] = b[i] * temp e[i] = temp * 2
A expressão comum é calculada apenas uma vez e seu resultado é reutilizado, reduzindo o número de operações e potencialmente melhorando o desempenho.
Otimização Visual: Antes vs. Depois
Tempo de Execução
Redução média de 35% no tempo de execução após aplicação de técnicas de otimização combinadas.
Consumo de Memória
Diminuição de 20% no uso de memória devido à eliminação de variáveis temporárias desnecessárias.
Tamanho do Código
Redução de 15% no tamanho do código binário gerado, melhorando o uso de cache e carregamento.
Eficiência Energética
Economia de 25% no consumo de energia em dispositivos móveis com o código otimizado.
Analisando Código Intermediário para Otimizar
A representação intermediária (IR) é o formato ideal para aplicar a maioria das otimizações. Vamos examinar como identificar oportunidades de otimização em código TAC (Three-Address Code):
  1. Identifique variáveis que recebem valores constantes
  1. Procure por padrões de acesso a variáveis para detectar valores não utilizados
  1. Observe expressões repetidas que podem ser calculadas uma única vez
  1. Analise o fluxo de controle para encontrar código inacessível
Ferramentas de análise estática podem auxiliar neste processo, destacando automaticamente potenciais oportunidades de otimização com base em padrões conhecidos.
Exemplo Prático 1: Código Redundante
1
Código Original
// Cálculo do perímetro e área de um retângulo a = 5 b = 7 p1 = 2 * a p2 = 2 * b perimetro = p1 + p2 area = a * b diagonal = sqrt(a*a + b*b) area2 = a * b // Redundante
2
Identificação de Redundâncias
Observe que "area" e "area2" contêm exatamente o mesmo cálculo (a * b). Esta é uma oportunidade clara para eliminação de código redundante.
3
Código Otimizado
a = 5 b = 7 p1 = 2 * a p2 = 2 * b perimetro = p1 + p2 area = a * b diagonal = sqrt(a*a + b*b) area2 = area // Reutilização do cálculo
Exemplo Prático 2: Constante Propagada
Código Original
// Cálculo de juros compostos principal = 1000 taxa = 0.05 tempo = 3 t1 = 1 + taxa t2 = t1 ^ tempo montante = principal * t2
Após Propagação e Folding
principal = 1000 taxa = 0.05 tempo = 3 t1 = 1.05 t2 = 1.157625 // 1.05^3 montante = 1157.625 // 1000 * 1.157625
A propagação de constantes seguida por constant folding permite calcular o resultado em tempo de compilação para valores conhecidos.
Mini-linguagem: Exemplo de Otimização
Código Original
a = 3 b = 2 c = a + b d = c * 2 e = a + b f = e + d
Propagação de Constantes
a = 3 b = 2 c = 3 + 2 d = c * 2 e = 3 + 2 f = e + d
Constant Folding
a = 3 b = 2 c = 5 d = c * 2 e = 5 f = e + d
Eliminação de Subexpressões Comuns
a = 3 b = 2 c = 5 d = c * 2 e = c // Reutiliza c em vez de recalcular f = e + d
Propagação Adicional
a = 3 b = 2 c = 5 d = 10 e = 5 f = 15
Transformação Passo a Passo das Otimizações
Análise
Identificação de oportunidades de otimização através de análise de fluxo de dados e dependências.
Planejamento
Determinação da sequência ótima de transformações para maximizar o benefício sem interferências negativas.
Aplicação
Implementação das transformações escolhidas sobre a representação intermediária do código.
Verificação
Validação da equivalência semântica entre o código original e o otimizado para garantir a preservação do comportamento.
Implementando Otimizações em Python
Python é uma linguagem excelente para implementar e experimentar algoritmos de otimização de código, graças à sua sintaxe clara e às ricas bibliotecas disponíveis.
Um compilador didático em Python pode utilizar o módulo AST (Abstract Syntax Tree) para manipular a representação intermediária do código:
  • ast.parse(): converte código fonte em árvore sintática
  • ast.NodeVisitor: permite percorrer e analisar a árvore
  • ast.NodeTransformer: permite modificar a árvore
import ast class ConstantFolder(ast.NodeTransformer): def visit_BinOp(self, node): node = self.generic_visit(node) if isinstance(node.left, ast.Constant) and \ isinstance(node.right, ast.Constant): # Simplificar operações entre constantes ...
Exemplo com AST: Dobrando Constantes
import ast import operator class ConstantFolder(ast.NodeTransformer): def visit_BinOp(self, node): # Primeiro, processe recursivamente os nós filhos node = self.generic_visit(node) # Verifique se ambos operandos são constantes if isinstance(node.left, ast.Constant) and isinstance(node.right, ast.Constant): # Mapeamento de operadores para funções do Python op_func = { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv } if type(node.op) in op_func: try: # Calcule o resultado da operação result = op_func[type(node.op)](node.left.value, node.right.value) # Retorne um nó constante com o resultado return ast.Constant(value=result) except: # Em caso de erro (divisão por zero, etc.), mantenha o nó original pass return node
Este exemplo implementa um transformador AST que identifica operações binárias entre constantes e as substitui pelo resultado calculado, realizando constant folding.
Criação de Função para Eliminar Código Morto
def eliminar_codigo_morto(codigo_tac): # Passo 1: Identificar variáveis utilizadas variaveis_usadas = set() # Percorre o código de baixo para cima for i in range(len(codigo_tac) - 1, -1, -1): instrucao = codigo_tac[i] # Analisa uso de variáveis na instrução atual vars_usadas_instr = obter_vars_usadas(instrucao) variaveis_usadas.update(vars_usadas_instr) # Se a instrução define uma variável não usada, # marca para remoção if eh_atribuicao(instrucao): var_definida = obter_var_definida(instrucao) if var_definida not in variaveis_usadas: instrucao['remover'] = True else: # Remove da lista após encontrar sua definição variaveis_usadas.remove(var_definida)
# Passo 2: Remover instruções marcadas codigo_otimizado = [] for instrucao in codigo_tac: if not instrucao.get('remover', False): codigo_otimizado.append(instrucao) return codigo_otimizado # Funções auxiliares def obter_vars_usadas(instrucao): # Extrai variáveis usadas no lado direito da instrução # ... def eh_atribuicao(instrucao): # Verifica se a instrução é uma atribuição # ... def obter_var_definida(instrucao): # Obtém a variável definida no lado esquerdo # ...
Exercício Prático: Otimize o Trecho TAC
Código Original
t1 = 5 t2 = 3 t3 = t1 + t2 t4 = t1 + t2 t5 = t4 * 2 t6 = t3 * t3 t7 = t5 + 10 t8 = t7 // Nunca usado resultado = t6
Aplicar Estas Otimizações:
  1. Eliminação de código morto
  1. Eliminação de subexpressões comuns
  1. Propagação de constantes
  1. Constant folding
Solução Otimizada
t1 = 5 t2 = 3 t3 = 8 t6 = 64 resultado = 64
Observe como várias otimizações combinadas reduziram significativamente o código, mantendo o mesmo resultado final.
Desafio: Identifique e Aplique 3 Otimizações em Sequência
Código Original
a = 10 b = 20 c = a + b d = 30 e = c + d f = a + b g = f + d h = e * 2 i = g * 2 j = i - h if (1 > 0): k = 100 else: k = 200 l = j + k
Sua Tarefa:
  1. Identifique subexpressões comuns
  1. Aplique constant folding onde possível
  1. Elimine código redundante
  1. Simplifique condicionais constantes
  1. Documente cada transformação aplicada
Dica: comece identificando que "a + b" aparece duas vezes e que a condição if é sempre verdadeira.
Ferramentas para Análise de Otimizações
Grafos de Fluxo de Controle
Representações visuais do fluxo de execução do programa, facilitando a identificação de caminhos redundantes e código inacessível.
Análise de Liveness
Ferramentas que rastreiam quais variáveis estão "vivas" (serão usadas no futuro) em cada ponto do programa, essencial para eliminar código morto.
Analisadores Estáticos
Softwares que examinam o código sem executá-lo, identificando automaticamente padrões de ineficiência e sugerindo otimizações.
Profilers
Ferramentas que monitoram a execução do programa, identificando pontos críticos onde otimizações teriam maior impacto no desempenho geral.
Resumo: Principais Técnicas de Otimização
1
Análise
Identificação de padrões e oportunidades de otimização através de análise estática e dinâmica do código.
2
Simplificação
Redução da complexidade através de constant folding, propagação de constantes e eliminação de código morto.
3
Reaproveitamento
Eliminação de cálculos redundantes através da identificação de subexpressões comuns e reutilização de resultados.
4
Especialização
Adaptação do código para explorar características específicas da arquitetura alvo através de redução de força e outras técnicas.
5
Verificação
Garantia da preservação semântica e avaliação do impacto real das otimizações aplicadas no desempenho final.
Na próxima aula, avançaremos para a geração de código final e montagem, onde veremos como o código otimizado é transformado em instruções específicas para a arquitetura alvo.