Construção de Compiladores II
Aula 10 – Geração de Código e Otimização
Andrei de A. Formiga
24 de maio de 2006
1
Introdução
2
Nı́veis de otimização
tido.
Este processo pode ser realizado em vários nı́veis
A geração de código pode ser simples, mas o re- de localidade. Algumas redundâncias só podem ser
sultado é invariavelmente ineficiente e desperdiça notadas se observarmos o código de uma rotina
os recursos computacionais disponı́veis, tanto em (função ou procedimento) inteira, enquanto para
termos de tempo quanto de espaço.
outros tipos de ineficiência mesmo isso não é o suUm módulo atual de geração de código deve ficiente.
tratar de dois problemas importantes: alocação
O nı́vel mais local de otimização que pode ser
de registradores e seleção de instruções. Como feito é analisar cada instrução do código gerado, ina resolução desses problemas envolve técnicas de dividualmente, procurando por ineficiências. Nesse
otimização, vale a pena estudar os dois tópicos de caso, são poucas as redundâncias que podem ser
geração de código nativo e otimização de forma observadas, pois a análise não pode nem comparar
conjunta. Um compilador pode inclusive juntar ou duas instruções próximas. Por isso mesmo, este
mesclar os dois módulos, o que é comum em várias tipo de análise extremamente localizada não cosorganizações das fases de sı́ntese.
tuma ser usada em compiladores reais. Os nı́veis
Nesta aula veremos as técnicas mais importantes que costumam ser observados em compiladores são
relacionadas à otimização e geração de código na- os seguintes:
tivo, principalmente em relação à alocação de registradores e seleção de instruções. Se o objetivo for
1. Otimização por janela
o de criar um módulo de geração de código mais
simples, que não faça a alocação de registradores
2. Otimização de blocos básicos
e seleção de instruções, pode-se utilizar as técnicas
já estudadas anteriormente, gerando código direta3. Otimização global (intra-procedural)
mente a partir da árvore sintática.
Começaremos estudando os nı́veis de otimização
4. Otimização inter-procedural
disponı́veis, e quais técnicas são comumente usadas
em cada nı́vel. Em seguida veremos como se faz a
5. Otimização de programa inteiro
alocação de registradores e seleção de instruções.
Cada nı́vel envolve uma análise mais global que
o anterior, e portanto consegue perceber mais redundâncias no código, e assim obter código de melhor qualidade. Em contrapartida, cada nı́vel é mais
complexo de implementar e torna a compilação
mais demorada, um fator que não deve ser desconsiderado. As seções a seguir detalham cada um
desses nı́veis, e que técnicas são usadas em cada
um deles.
O processo de otimização consiste em procurar redundâncias e ineficiências no código gerado e eliminar tais problemas, trocando trechos de código
problemático por versões melhores, mas equivalentes. A equivalência é uma necessidade absoluta,
claro, pois o significado do programa deve ser man1
2.1
R2 := R1 + #5
i := R2
R3 := i
R3 := R3 * #3
Otimização por janela
Neste nı́vel a análise é feita em um conjunto de instruções de número pré-determinado, normalmente
três ou mais. O compilador analisa cada grupo
de instruções separadamente, como se estivesse
deslizando uma janela pelo código. Embora ainda
bastante localizado, neste nı́vel já é possı́vel eliminar muitas redundâncias e ineficiências, e isso faz
com que muitos compiladores façam esse tipo de
otimização.
As técnicas usadas na otimização por janela são
muitas vezes usadas em outros nı́veis também, de
forma mais abrangente. Por isso, é interessante
ver exemplos destas técnicas primeiro no nı́vel mais
simples, e depois estudar como elas são aplicadas
em nı́veis mais abrangentes.
Em geral, cada técnica permite eliminar uma ou
mais instruções, em geral poucas, mesmo porque a
análise é bastante localizada. Mesmo assim, essas
melhorias podem resultar em uma diferença significativa no desempenho de um programa. Podemos
imaginar, por exemplo, que uma melhoria dessas
consiga eliminar apenas uma instrução de um loop
que é executado por um milhão de iterações; neste
caso, a otimização vai resultar na execução de um
milhão de instruções a menos.
A seguir, cada técnica é explicada e exemplificada.
R2 := R1 + #5
i := R2
R3 := R2 * #3
Figura 1: Eliminação de transferências
2.1.2
Desdobramento de constantes
Uma análise que pode ser feita mesmo no nı́vel das
instruções individuais, o desdobramento de constantes consiste em detectar expressões constantes,
que podem ser efetuadas em tempo de compilação,
e substituir as instruções para o cálculo da expressão pelo seu resultado constante, eliminando
uma ou mais instruções aritméticas e/ou lógicas.
Esta é uma otimização bastante simples mas que
pode bons ganhos de eficiência. Na figura 2 vemos
um exemplo simples: a expressão que é atribuı́da a
R3 a instrução à esquerda é constante, e pode ser
calculada durante a compilação, usando a instrução
à direita em seu lugar.
R3 := #2 * #3
R3 := #6
Figura 2: Desdobramento de constantes
2.1.3
Propagação de constantes
Em alguns casos a análise pode detectar que um
registrador ou variável terá um valor constante em
algum ponto do programa. Nestes casos, o comEm muitos casos, é possı́vel saber que uma in- pilador pode substituir ocorrências do registrador
strução de transferência da memória para um reg- ou variável pela constante, diretamente. Isso pode
istrador, ou de um registrador para a memória, é simplificar instruções de transferência e mesmo
desnecessária. Como a geração de código a par- eliminar algumas. Na figura 3, à esquerda, vemos
tir da árvore sintática é totalmente local, esse tipo um exemplo em que o registrador R2 recebe o valor
de redundância pode ocorrer com freqüencia. Ao constante 4 apenas para ser utilizado na próxima
analisar uma janela do código, o compilador pode instrução, que define o valor de R3. Como o compiremover essas instruções desnecessárias. A figura lador pode determinar que R2 tem o valor 4 na se1 mostra um exemplo deste processo: à esquerda gunda instrução, é possı́vel substituir a ocorrência
vemos o código antes da melhoria; é fácil notar que de R2 na expressão diretamente pelo seu valor. Em
R2 e R3 têm o mesmo valor, e que R3 depois re- seguida, detecta-se que o valor de R2 como 4 só
cebe outro valor, o que indica que a instrução de era usado nesta segunda instrução, e portanto toda
transferência para R3 é desnecessária. Na parte à a primeira instrução pode ser eliminada. O resuldireita da figura está o código melhorado, após a tado após a melhoria é mostrado na parte direita
eliminação da instrução de transferência para R3 da figura.
A técnica de propagação de constantes interage
e a alteração da última instrução para usar R2 ao
fortemente com o desdobramento de constantes.
invés de R3.
2.1.1
Eliminação de instruções de transferência redundantes
2
R2 := #4
R3 := R1 + R2
R2 := R4
R2 := R1 * #5
R2 := R2 + R3
R3 := R1 * #5
R3 := R1 + #4
R2 := R4
Figura 5: Eliminação de expressões comuns
Figura 3: Propagação de constantes
2.1.5
Muitas vezes, o uso de uma delas pode criar uma
oportunidade para usar a outra. Um exemplo pode
ser visto na figura 4, com o código original à esquerda. O primeiro passo é fazer propagação de
constantes, trocando a ocorrência de R1 na segunda
instrução pelo seu valor, 3. Em seguida, o desdobramento de constantes pode ser utilizado para
substituir a expressão 3 × 2 pelo seu resultado,
6; por fim, assumindo que o valor de R1 não é
necessário para instruções subseqüentes, a primeira
instrução pode ser eliminada, resultando no código
à direita. No total, uma instrução foi completamente eliminada e a outra foi simplificada de uma
operação aritmética para uma mais simples, apenas
de transferência.
R1 := #3
R2 := R1 * #2
Propagação de cópias
Mesmo em alguns casos quando não é possı́vel
determinar que um registrador terá valor constante em um ponto do programa, pode-se observar que ele terá o mesmo valor de algum outro
registrador. Neste caso, o compilador pode substituir os usos de um registrador pelo outro, desde
que nenhum dos dois tenha seu valor alterado. A
figura 6, à esquerda, mostra o código original, no
qual R1 e R2 possuem o mesmo valor, graças à
primeira instrução. Com isso, pode-se substituir
a ocorrência de R2 por R1 na segunda instrução;
adicionalmente, como o valor de R2 é redefinido
na terceira instrução, pode-se eliminar completamente a primeira, resultando no código melhorado
mostrado à direita na figura. O uso da propagação
de cópias pode reduzir o número total de registradores utilizados pelo código intermediário, o
que simplifica a tarefa do alocador de registradores
na geração de código nativo.
R2 := #6
Figura 4: Propagação e desdobramento de constantes
2.1.4
R4 := R1 * #5
R2 := R4 + R3
R3 := R4
R2 := R1
R3 := R1 + R2
R2 := #5
R3 := R1 + R1
R2 := #5
Figura 6: Propagação de cópias
Eliminação de sub-expressões comuns
Quando uma mesma sub-expressão ocorre duas
ou mais vezes dentro da janela de otimização, é
freqüentemente possı́vel fazer o cálculo só uma vez e
reaproveitar seu resultado. Na figura 5, à esquerda,
o código calcula a expressão R1 * #5 duas vezes,
uma vez na atribuição a R2 e outra na atribuição
a R3; uma melhoria, neste caso, é calcular a expressão apenas uma vez e reaproveitar seu resultado. Para isso, como o valor de R2 muda na segunda instrução, é necessário usar um outro registrador. O código à direita mostra a melhoria
aplicada, usando R4 para guardar o valor da subexpressão comum. É interessante observar que a
expressão só é calculada uma vez, mas foi necessário
usar um registrador a mais para isso.
2.1.6
Redução de força
Em muitas situações é possı́vel trocar uma instrução mais demorada por outra, mais rápida, mas
que tem o mesmo efeito. Por exemplo, multiplicar
R1 por 2 é o mesmo que somar R1 com ele mesmo;
o resultado é o mesmo, mas em muitas arquiteturas uma soma é mais rápida do que uma multiplicação. A mesma coisa pode ser feita com multiplicações e divisões por potências de 2, que podem ser substituı́das por deslocamentos de bits,
instruções muito mais eficientes. Um exemplo é
mostrado na figura 7. As duas instruções à esquerda são melhoradas para as instruções à direita:
uma multiplicação é trocada por uma soma, e uma
3
figura 8, e sua tradução para código intermediário
é mostrada novamente na figura 9.
outra multiplicação é substituı́da por um deslocamento à esquerda. A seqüência da direita executará
muito mais rápido.
R1 := R2 * #2
R3 := R4 * 4
int mdc(int a, int b)
{
while (a != b)
{
if (a > b)
a = a - b;
else
b = b - a;
}
R1 := R2 + R2
R3 := R4 << 2
Figura 7: Redução de força
2.2
Otimização por blocos básicos
A otimização por janela consegue bons resultados
para eliminar as redundâncias mais gritantes no
código gerado, mas não pode detectar problemas
no código que se mostram em instruções que caiam fora da mesma janela. Por exemplo, se uma
instrução apresenta uma redundância em relação
a uma outra instrução que está a 10 instruções de
distância, e o tamanho da janela é 5, nenhuma melhoria neste código será possı́vel.
O próximo nı́vel de otimização é o que analisa
blocos básicos, ao invés de janelas simples. Um
bloco básico é um trecho do código gerado que inicia
com uma instrução rotulada – ou seja, o destino de
algum desvio – e termina com algum desvio, condicional ou incondicional. A importância de um bloco
básico para a análise do código é que o controle só
entra em um bloco básico pelo inı́cio, e só deixa
o bloco básico no final. Assim, é possı́vel saber
exatamente quais instruções serão executadas no
bloco, e em que seqüencia. Se existissem desvios
condicionais dentro do bloco, seria impossı́vel determinar a próxima instrução após a execução do
desvio, e isso tornaria muito difı́cil determinar que
um registrador possui um valor constante, por exemplo, ou as outras informações necessárias para
qualquer otimização.
As regras para identificar os blocos básicos são
simples:
return a;
}
Figura 8: Cálculo do MDC na linguagem C
WHILE:
THEN:
FIMIF:
FIMW:
R0 := 100
R1 := 104
IF NOT R0 != R1 GOTO FIMW
R2 := 100
R3 := 104
IF R2 > R3 GOTO THEN
R4 := 100
R5 := 104
R6 := R4 - R5
100 := R6
GOTO FIMIF
R7 := 100
R8 := 104
R9 := R8 - R7
104 := R9
GOTO WHILE
RET
Figura 9: Cálculo do MDC em código intermediário
O começo do código na figura 9 marca o inı́cio
do primeiro bloco básico, que se extende até o
primeiro desvio condicional, na terceira linha. O
próximo bloco básico inicia na quarta linha e vai
até o próximo desvio condicional, e assim o processo
continua na delimitação dos blocos, até chegar à divisão em blocos mostrada na figura 10.
Depois de dividir o código em blocos básicos,
o compilador pode analisar cada bloco básico de
maneira indivisı́vel, já que o bloco sempre é execu-
1. Uma instrução rotulada marca o inı́cio de um
novo bloco básico, terminando o anterior
2. Um desvio marca o fim do bloco básico atual, começando um novo bloco na instrução
seguinte
Para um exemplo, retornamos à função para calcular o máximo divisor comum, mostrada na aula
passada. O código da função em C é repetido na
4
WHILE:
THEN:
FIMIF:
FIMW:
istrador, e em caso negativo, ela é gravada na tabela
associada ao registrador da instrução analisada.
Esse processo de numeração de valores identifica sub-expressões repetidas, realiza a propagação
de cópia e propagação de constantes dentro do
bloco básico, e ainda possibilita também a redução
de força de instruções. Também pode-se utilizar
a numeração de valores para guardar o resultado
de expressões constantes, ao invés de apenas registradores, com o intuito de aplicar o desdobramento de constantes. Finalmente, a eliminação
de instruções de transferência redundantes é conseguida naturalmente, pois o compilador sabe que
valores estão em que registradores, podendo eliminar transferências redundantes de valores já presentes na tabela.
Com esse tipo de análise por numeração
de valores, a otimização por janela torna-se
desnecessária, em geral, embora ainda possa ser usada principalmente para realizar algumas reduções
de força que não são detectadas pela análise por
numeração de valores.
R0 := 100
R1 := 104
IF NOT R0 != R1 GOTO FIMW
R2 := 100
R3 := 104
IF R2 > R3 GOTO THEN
R4 := 100
R5 := 104
R6 := R4 - R5
100 := R6
GOTO FIMIF
R7 := 100
R8 := 104
R9 := R8 - R7
104 := R9
GOTO WHILE
RET
Figura 10: Divisão do código para MDC em blocos
básicos
tado inteiramente. O objetivo das análises é minimizar o número de instruções de transferência e
identificar cálculos redundantes. Existem algumas
técnicas para fazer isso, mas para nossos propósitos
aqui podemos usar a numeração de valores.
Numeração de valores consiste em guardar, em
alguma estrutura de dados, todos os valores e expressões já vistos anteriormente pelo compilador.
Como, para executar qualquer operação, o resultado de todas as sub-expressões terá que ficar, em
algum momento, em um registrador, o compilador
guarda que registrador está associado a cada subexpressão; assim, quando essa sub-expressão for
vista novamente, o compilador pode substitui-la
pelo registrador associado. Por exemplo, se a instrução R2 := R0 + R1 aparecer no código, o compilador vai associar a expressão R0 + R1 ao registrador R2, e toda vez que essa mesma expressão
for vista dentro do bloco básico, ela pode ser substituı́da por R2.
Uma forma de guardar essas expressões já vistas,
e verificar se uma expressão já foi vista, é usar uma
tabela hash. Pode-se indexar a tabela pela string
da expressão vista, e para cada expressão associar
um registrador. Ao analisar uma nova instrução,
o compilador primeiro verifica se ela já foi vista;
em caso positivo, pode ser substituı́da por um reg-
2.3
Otimização
procedural
global
ou
intra-
A seção anterior mostrou como pode ser feita a
otimização dentro dos blocos básicos, mas ainda é
necessário analisar o código em nı́veis menos locais.
O próximo passo é analisar rotinas (funções e procedimentos) inteiras, e identificar melhorias e redundâncias que ocorrem entre blocos básicos diferentes. Esse é o papel da otimização global, que tem
esse nome por uma questão de tradição, mas que
é mais apropriadamente chamada de otimização
intra-procedural.
Para realizar a análise nesse nı́vel são utilizadas
duas técnicas: análise de fluxo de dados e transformação do código para a forma SSA (Static Single
Assignment).
A análise de fluxo de dados é geralmente feita
através do estabelecimento de equações de fluxo
de dados. Os blocos básicos são organizados em
um grafo de fluxo de controle, indicando que blocos podem desviar para que outros blocos; em
seguida, cada bloco no grafo é analisado em relação
às equações de fluxo de dados, considerando suas
entradas e saı́das em relação aos blocos adjacentes
no grafo. Eventualmente essa análise atinge um
ponto-fixo e a solução das equações é determinada.
5
Essas equações indicam os caminhos que os dados
seguem para formar os valores necessários em cada
bloco.
A tradução para forma SSA é feita para que cada
variável só seja atribuı́da uma única vez no programa, e identificando onde essa variável será usada
para construir valores futuros. Em situações onde
não é possı́vel determinar que valores especı́ficos
serão usados, constrói-se uma função de escolha,
normalmente chamada de φ. A construção da forma SSA e a decisão de quais valores são utilizados
nos pontos onde existem funções φ são processos
que podem ser realizados por análises de fluxo de
dados.
A partir da forma SSA é possı́vel realizar
propagação de constantes e de cópia, eliminação
de sub-expressões redundantes, e eliminação de instruções de transferência, tudo isso em um nı́vel que
analisa rotinas inteiras. Não entraremos em mais
detalhes aqui sobre essas técnicas; a análise de fluxo
de dados é usada em várias otimizações em vários
compiladores, e a forma SSA vem sendo adotada
nos últimos anos por compiladores otimizadores
mais avançados. A versão 4 do compilador GCC
utiliza a forma SSA para fazer melhoria do código
gerado.
Formas mais gerais de otimização inter-procedural são conseguidas extendendo as técnicas usadas para otimização intra-procedural, principalmente o uso de análise de fluxo de dados e da
forma SSA. Neste caso, as equações de fluxo de
dados se tornam mais complexas de chegar a uma
solução, mas algumas análises podem resultar em
equações possı́veis de resolver e que levarão a melhorias no código. Como no caso da otimização
intra-procedural, não entraremos em maiores detalhes sobre essas técnicas aqui.
2.5
Otimização de programa inteiro
O nı́vel mais global de análise e otimização
possı́vel é, obviamente, analisar todo o programa
de uma vez só. Embora isso não seja prático
para o processo incremental de desenvolvimento,
e muitas vezes se torna impraticável, pode-se usar
a otimização de programa inteiro para gerar uma
versão final de um programa, neste caso utilizando
o máximo de otimização que seja possı́vel.
O custo óbvio nesse caso, além da complexidade das análises, é um gasto de memória muito
maior, pois as estruturas de dados que guardarão
informações que permitam identificar redundâncias
e melhorias se tornarão bem maiores ao analisar o
programa completo.
2.4 Otimização inter-procedural
As técnicas utilizadas ainda são a análise de
Após otimizar rotinas inteiras, o próximo nı́vel é fluxo de dados e a forma SSA, embora mais uma
procurar por melhorias que se mostram ao anal- vez as equações se tornem mais complexas. A
isar várias rotinas e seus relacionamentos. Um ex- maior diferença para o caso da otimização interemplo de melhoria que pode ser feita neste nı́vel procedural é que aqui o compilador precisa ver
é a otimização de chamadas. O objetivo desta todo o programa, já que compilar módulos sepaotimização é eliminar algumas chamadas de função radamente pode evitar a detecção de algumas reque são desnecessárias, trocando-as por instruções dundâncias e oportunidades para melhoria. De
de desvio simples. Como uma instrução de cha- resto, os processos são praticamente os mesmos.
mada de função precisa criar uma pilha local,
guardar valores de registradores e outras tarefas
de manutenção, uma instrução de desvio simples 3
Alocação de registradores
é bem mais rápida de executar; a questão é que em
alguns casos não é necessário fazer todas essas tare- O código intermediário normalmente utilizado em
fas de manutenção, e o compilador pode detectar compiladores se vale de um número ilimitado de
registradores, para simplificar tanto o processo
isso.
Outra melhoria que pode ser feita nesse nı́vel é a de geração do código intermediário quanto sua
expansão em linha de funções pequenas. Para econ- otimização. Entretanto, numa máquina real o
omizar uma chamada de função, o compilador pode número de registradores é limitado, e em alguns
substituir diretamente o corpo da função no ponto casos bem pequeno.
Os registradores no código intermediário são
de chamada, usando os parâmetros da chamada no
chamados de registradores virtuais, enquanto que
lugar dos argumentos da função.
6
os da máquina-alvo são registradores reais. O
processo de alocação de registradores consiste em
mapear os registradores virtuais nos registradores
reais, na medida do possı́vel, e usar a memória
como armazenamento auxiliar quando não sobrarem mais registradores reais.
A alocação de registradores é feita determinando
quais registradores virtuais têm valores ativos ao
mesmo tempo no código. Esses registradores virtuais não poderão ser mapeados para um mesmo registrador real, pois ambos precisam estar ativos ao
mesmo tempo. Diz-se então que dois registradores
virtuais nessa situação interferem um com o outro.
O primeiro passo para determinar a interferência
entre registradores virtuais é obter as regiões em
que cada registrador virtual está ativo no código.
Isso pode ser feita com uma análise de fluxo de
dados, e não será estudado em detalhes aqui.
Sabendo-se as regiões de atividade de cada registrador, constroi-se um grafo de interferência, no
qual cada registrador virtual é um nó e dois nós
estão ligados por uma aresta se eles interferem um
com o outro, ou seja, estão ativos ao mesmo tempo
no código.
A partir daı́ o problema é equivalente a um problema conhecido como coloração de grafos: como
escolher cores para os nós de um grafo de forma
que dois nós adjacentes (ou seja, ligados por uma
aresta) nunca tenham a mesma cor. Esse problema é computacionalmente NP-completo, sendo
impraticável usar algoritmos para achar a melhor
solução, na maioria dos casos. Entretanto, compiladores mais avançados costumam usar uma série
de heurı́sticas para achar uma boa coloração do
grafo de interferência, o que equivale a achar uma
boa alocação dos registradores.
Vejamos um exemplo de como decorre o processo.
Voltando ao código intermediário para o cálculo
do MDC, mostrado na figura 9, é fácil levantar as
regiões de atividade de cada registrador virtual: sua
região começa quando ele é atribuı́do, e termina na
última instrução que utiliza seu valor. Ao levantar as regiões para cada registrador e construir o
grafo de interferência, obtemos o grafo mostrado na
figura 11. Pelo grafo, uma primeira constatação é
que serão necessários pelo menos três registradores
reais, pois esse é o tamanho do maior componente.
Além disso, registradores virtuais que fiquem em
nós de componentes diferentes podem ser alocados
para um mesmo registrador real. O resultado final
é que só precisamos de três registradores reais para
o código inteiro.
R0
R1
R2
R4
R5
R3
R7
R6
R8
R9
Figura 11: Grafo de interferência para código do
cálculo do MDC
Para ilustrar, usaremos aqui os registradores
da arquitetura IA-32 da Intel, reservando os registradores EAX, EBX e ECX para o código do
cálculo do MDC. Após a alocação de registradores,
o código fica como na figura 12. O código resultante poderia ser otimizado trivialmente usando
propagação de cópias, e o resultado é mostrado
na figura 13. A partir daı́ a tradução para código
nativo da arquitetura IA-32 seria trivial: basicamente, apenas mudar as instruções da sintaxe do
código intermediário para a sintaxe da linguagem
assembly da arquitetura, e desdobrar os IFs para
duas instruções (teste e desvio condicional). Isso
pode ser feito automaticamente pelo compilador,
com facilidade.
7
WHILE:
THEN:
FIMIF:
FIMW:
EAX := 100
EBX := 104
IF NOT EAX != EBX GOTO FIMW
EAX := 100
EBX := 104
IF EAX > EBX GOTO THEN
EAX := 100
EBX := 104
ECX := EAX - EBX
100 := ECX
GOTO FIMIF
EAX := 100
EBX := 104
ECX := EBX - EAX
104 := ECX
GOTO WHILE
RET
Figura 12: Cálculo do MDC com registradores alocados
WHILE:
THEN:
FIMIF:
FIMW:
EAX := 100
EBX := 104
IF NOT EAX != EBX GOTO FIMW
IF EAX > EBX GOTO THEN
ECX := EAX - EBX
100 := ECX
GOTO FIMIF
ECX := EBX - EAX
104 := ECX
GOTO WHILE
RET
Figura 13: Cálculo do MDC após propagação de
cópias
8
Download

Geraç˜ao de Código e Otimizaç˜ao