Compiladores

O que todos os programadores devem saber sobre otimizações de compilador

Hadi Brais

Baixar o código de exemplo

As linguagens de programação de alto nível oferecem muitas construções de programação abstrata tais como funções, instruções condicionais e loops que nos tornam incrivelmente produtivos. No entanto, uma desvantagem de escrever código em uma linguagem de programação de alto nível é a diminuição potencialmente significativa no desempenho. Idealmente, você deve escrever código compreensível e sustentável — sem comprometer o desempenho. Por esse motivo, os compiladores tentam otimizar automaticamente o código para melhorar seu desempenho e tornaram-se bastante sofisticados em fazê-lo hoje em dia. Eles podem transformar loops, instruções condicionais e funções recursivas; eliminar blocos inteiros de código; e aproveitar a arquitetura de conjunto de instruções (ISA) de destino para tornar o código rápido e compacto. É muito melhor focar-se na escrita de código compreensível do que efetuar otimizações manuais que resultam em código difícil de manter e críptico. Na verdade, otimizar manualmente o código pode impedir que o compilador execute otimizações adicionais ou mais eficientes.

Em vez de otimizar manualmente código, deve considerar aspectos de seu design, tais como usar algoritmos mais rápidos, incorporar paralelismo ao nível de thread e usar recursos específicos de estrutura (tal como usar construtores de movimento).

Este artigo refere-se às otimizações do compilador do Visual C++. Vou discutir as técnicas de otimização mais importantes e as decisões que um compilador tem de fazer para as aplicar. A finalidade não é dizer como otimizar manualmente o código, mas mostrar por que pode confiar no compilador para otimizar o código em seu nome. Este artigo não é, de modo algum, uma examinação completa das otimizações realizadas pelo compilador Visual C++. No entanto, demonstra as otimizações que você realmente deseja conhecer e como comunicar com o compilador para aplicá-las.

Há outras otimizações importantes que estão atualmente além das capacidades de qualquer compilador — por exemplo, substituir um algoritmo ineficiente por um eficiente ou mudar o layout de uma estrutura de dados para melhorar sua localidade. No entanto, essas otimizações estão fora do alcance deste artigo.

Definindo otimizações de compilador

Uma otimização é o processo de transformar uma parte de código em outra parte de código funcionalmente equivalente para a finalidade de melhorar uma ou mais de suas características. As duas características mais importantes são a velocidade e o tamanho do código. Outras características incluem a quantidade de energia necessária para executar o código, o tempo necessário para compilar o código e, no caso do código resultante necessitar de compilação Just-in-Time (JIT), o tempo necessário para JIT compilar o código.

Os compiladores estão melhorando constantemente em termos das técnicas que usam para otimizar o código. No entanto, eles não são perfeitos. Ainda assim, em vez de gastar tempo a ajustar manualmente um programa, usualmente é mais proveitoso usar recursos específicos fornecidos pelo compilador e deixar o compilador ajustar o código. 

Há quatro maneiras de ajudar o compilador a otimizar seu código com mais eficiência:

  1. Escreva código compreensível e sustentável. Não considere os recursos orientados a objetos do Visual C++ como os inimigos do desempenho. A versão mais recente do Visual C++ pode manter essa sobrecarga a um mínimo e, por vezes, eliminá-la completamente.
  2. Use diretivas de compilador. Por exemplo, indique ao compilador o uso de uma convenção de chamada de função que seja mais rápida que a padrão.
  3. Use funções intrínsecas do compilador. Uma função intrínseca é uma função especial cuja implementação é fornecida automaticamente pelo compilador. O compilador tem um conhecimento profundo da função e substitui a chamada de função por uma sequência de instruções extremamente eficiente que aproveita a ISA de destino. Atualmente, o Microsoft .NET Framework não suporta funções intrínsecas, pelo que nenhuma das linguagens gerenciadas as suporta. No entanto, o Visual C++ tem um suporte extenso para este recurso. Observe que embora o uso de funções intrínsecas possa melhorar o desempenho do código, reduz sua legibilidade e portabilidade.
  4. Use otimização orientada por perfis (PGO). Com esta técnica, o compilador sabe mais sobre como o código se vai comportar no tempo de execução e pode otimizá-lo em conformidade.

A finalidade deste artigo é mostrar por que pode confiar no compilador demonstrando as otimizações executas em código ineficiente, mas compreensível (aplicando o primeiro método). Além disso, farei uma breve introdução à otimização orientada por perfis e mencionarei algumas das diretivas de compilador que permitem ajustar partes de seu código.

Há muitas técnicas de otimização do compilador desde simples transformações, como dobra constante, a transformações extremas, como agendamento de instrução. Contudo, neste artigo, limitarei a discussão a algumas das otimizações mais importantes – aquelas que podem melhorar significativamente o desempenho (com uma porcentagem de dois dígitos) e reduzir o tamanho do código: inlining de função, otimizações COMDAT e otimizações de loop. Vou discutir os dois primeiros na próxima seção e, em seguida, mostrar como pode controlar as otimizações realizadas pelo Visual C++. Por fim, posso faz uma pequena análise das otimizações no .NET Framework. Ao longo deste artigo, irei usar o Visual Studio 2013 para criar o código.

Geração de código em tempo de vinculação

A geração de código em tempo de vinculação (LTCG) é uma técnica para executar otimizações de programa total (WPO) em código C/C++. O compilador C/C++ compila cada arquivo de origem separadamente e produz o arquivo de objeto correspondente. Isto significa que o compilador somente pode aplicar otimizações em um único arquivo de origem em vez de no programa total. No entanto, algumas otimizações importantes podem ser executadas somente observando o programa total. É possível aplicar estas otimizações em tempo de vinculação em vez de no tempo de compilação, pois o vinculador tem uma vista completa do programa.

Quando LTCG está ativada (especificando o comutador de compilador /GL), o driver do compilador (cl.exe) invoca somente o front-end do compilador (c1.dll ou c1xx.dll) e adia o trabalho de back-end (c2.dll) até ao tempo de vinculação. Os arquivos de objeto resultantes contêm código de representação intermediária C (CIL) em vez de código do assembly dependente da máquina. Então, quando o vinculador (link.exe) é invocado, vê que os arquivos de objeto contêm código CIL e invoca o back-end do compilador, que executa WPO, gera os arquivos de objeto binário e retorna ao vinculador para juntar todos os arquivos de objeto e produzir o executável.

Na verdade, o front-end executar algumas otimizações, tais como dobra constante, independente de as otimizações estarem ativadas ou desativadas. No entanto, todas as otimizações importantes são executadas no back-end do compilador e podem ser controladas usando comutadores de compilador.

LTCG permite que o back-end execute muitas otimizações agressivamente (especificando /GL junto com os comutadores de compilador /O1 ou /O2 e /Gw e as opções de vinculador /OPT:REF e /OPT:ICF). Neste artigo, discutirei apenas inlining de função e otimizações de COMDAT. Para obter uma lista completa de otimizações LTCG, consulte a documentação. Observe que o vinculador pode executar a LTCG em arquivos de objeto nativo, arquivos de objeto nativo/gerenciado mistos, arquivos de objeto gerenciado puro, arquivos de objeto gerenciado seguro e .netmodules seguros.

Criarei um programa consistindo de dois arquivos de origem (source1.c e source2.c) e um arquivo de cabeçalho (source2.h). Os arquivos source1.c e source2.c são mostrados na Figura 1 e Figura 2, respectivamente. O arquivo de cabeçalho, que contém os protótipos de todas as funções em source2.c, é bastante simples, pelo que não o mostrarei aqui.

Figura 1 O arquivo source1.c

#include <stdio.h> // scanf_s and printf.
#include "Source2.h"
int square(int x) { return x*x; }
main() {
  int n = 5, m;
  scanf_s("%d", &m);
  printf("The square of %d is %d.", n, square(n));
  printf("The square of %d is %d.", m, square(m));
  printf("The cube of %d is %d.", n, cube(n));
  printf("The sum of %d is %d.", n, sum(n));
  printf("The sum of cubes of %d is %d.", n, sumOfCubes(n));
  printf("The %dth prime number is %d.", n, getPrime(n));
}

Figura 2 O arquivo source2.c

#include <math.h> // sqrt.
#include <stdbool.h> // bool, true and false.
#include "Source2.h"
int cube(int x) { return x*x*x; }
int sum(int x) {
  int result = 0;
  for (int i = 1; i <= x; ++i) result += i;
  return result;
}
int sumOfCubes(int x) {
  int result = 0;
  for (int i = 1; i <= x; ++i) result += cube(i);
  return result;
}
static
bool isPrime(int x) {
  for (int i = 2; i <= (int)sqrt(x); ++i) {
    if (x % i == 0) return false;
  }
  return true;
}
int getPrime(int x) {
  int count = 0;
  int candidate = 2;
  while (count != x) {
    if (isPrime(candidate))
      ++count;
  }
  return candidate;
}

O arquivo source1.c contém duas funções: a função square, que pega em um número inteiro e devolve sua raiz, e a função principal do programa. A função principal chama a função square e todas as funções de source2.c exceto isPrime. O arquivo source2.c contém cinco funções: a função cube que devolve o cubo de determinado número inteiro; a função sum retorna a soma de todos os números inteiros de 1 a determinado número inteiro; a função sumOfcubes retorna a soma dos cubos de todos os números inteiros de 1 a determinado número inteiro; a função isPrime determina se determinado número inteiro é primo; e a função getPrime, que devolve o x.º número primo. Omiti a verificação de erros, pois não é de interesse neste artigo.

O código é simples, mas útil. Há diversas funções que executam cálculos simples; algumas requerem simples para loops. A função getPrime é a mais complexa pois contém um loop while e, no loop, chama a função isPrime, que também contém um loop. Usarei este código para demonstrar uma das mais importantes otimizações de compilador, nomeadamente inlining de função, e algumas outras otimizações.

Vou criar o código em três configurações diferentes e examinar os resultados para determinar como foi transformado pelo compilador. Se acompanhar, necessitará do arquivo de saída do montador (produzido com o comutador de compilador /FA[s]) para examinar o código do assembly resultante e o arquivo de mapa (produzido com a opção de vinculador /MAP) para determinar as otimizações COMDAT que foram executadas (o vinculador também pode relatá-lo se usar as opções /verbose:icf e /verbose:ref). Certifique-se que estas opções são especificadas em todas as configurações seguintes que discutir. Além disso, também estarei usando o compilador C (/TC) para que o código gerado seja mais fácil de examinar. No entanto, tudo o que discutir aqui também se aplica ao código C++.

A configuração de depuração

A configuração de depuração é usada principal pois todas as otimizações de back-end estão desativadas quando você especifica o comutador de compilador /Od sem especificar o comutador /GL. Ao criar o código nesta configuração, os arquivos de objeto resultantes conterão um código binário que corresponde exatamente ao código de origem. É possível examinar os arquivos de saída do montador resultantes e o arquivo de mapa para confirmá-lo. Esta configuração é equivalente à configuração de depuração do Visual Studio.

A configuração do lançamento da geração do código do tempo de compilação

Esta configuração é semelhante à configuração Lançamento na qual as otimizações são permitidas (especificando os computadores de compilador /O1, /O2 ou /Ox), mas sem especificar o comutador de compilação /GL. Nesta configuração, os arquivos de objeto resultantes conterão código binário otimizado. No entanto, não são executadas otimizações ao nível do programa total.

Ao examinar o arquivo de listagem do assembly gerado do source1.c, observará que foram executadas duas otimizações. Primeiro, a primeira chamada para a função square, square(n), na Figura 1 foi completamente eliminada avaliando o cálculo no tempo de compilação. Como isso aconteceu? O compilador determinou que a função square é pequena, pelo que deve ser embutida. Depois do inlining, o compilador determinou que o valor da variável local n é conhecido e não muda entre a instrução de atribuição e a chamada de função. Portanto, conclui-se que é seguro executar a multiplicação e substituir o resultado (25). Na segunda otimização, a segunda chamada para a função square, square(m), também foi embutida. No entanto, como o valor de m não é conhecido no tempo de compilação, o compilador não pode avaliar o cálculo, pelo que o código atual é emitido.

Agora vou examinar o arquivo de listagem do assembly do source2.c, que é muito mais interessante. A chamada para a função cube em sumOfCubes foi embutida. Isto por sua vez permitiu que o compilador execute otimizações significativas no loop (como pode ver na seção "Otimizações de loop"). Além disso, o conjunto de instruções SSE2 está sendo usado na função isPrime para converter de número inteiro em duplo ao chamar a função sqrt e também para converter de duplo em número inteiro ao devolver de sqrt. E sqrt é chamado somente uma vez depois do loop iniciar. Observe que se nenhuma opção /arch for especificada no compilador, o compilador x86 usa SSE2 por padrão. Os processadores x86 mais implantados, bem como todos os processadores x86-64, suportam SSE2.

A configuração do lançamento da geração do código em tempo de vinculação

A configuração do lançamento LTCG é idêntica à configuração Lançamento no Visual Studio. Nesta configuração, as otimizações são permitidas e o comutador de compilador /GL é especificado. Este compilador é implicitamente especificado ao usar /O1 ou /O2. Diz ao compilador para emitir os arquivos de objeto CIL em vez de arquivos de objeto do assembly. Desta forma, o vinculador invoca o back-end do compilador para executar WPO como descrito anteriormente. Agora discutirei várias otimizações WPO para mostrar o enorme benefício da LTCG. As listagens de código do assembly geradas com esta configuração estão disponíveis online.

Enquanto o inlining de função estiver ativado (/Ob, que é ativado sempre que solicitar otimizações), o comutador /GL permite que o compilador embuta funções definidas em outras unidades de conversão independente de o comutador de compilador /Gy (discutido um pouco mais tarde) estar especificado. A opção de vinculador /LTCG é opcional e fornece orientação somente ao vinculador.

Ao examinar o arquivo de listagem do assembly do source1.c, é possível ver que todas as chamadas de função exceto scanf_s foram embutidas. Como resultado, o compilador foi capaz de executar os cálculos de cube, sum e sumOfCubes. Somente a função isPrime não foi embutida. No entanto, se tiver sido embutido manualmente em getPrime, o compilador continuar a embutir getPrime primeiro.

Como pode observar, o inlining de função é importante não só por otimizar uma chamada de função, mas também por permitir que o compilador execute muitas outras otimizações como resultado. Embutir uma função usualmente melhora o desempenho às custas do aumento do tamanho do código. O uso excessivo desta otimização leva a um fenômeno conhecido como inchaço de código. Em site de chamada, o compilador executa uma análise custo/benefício e decide se embute a função.

Devido a importância de inlining, o compilador do Visual C++ fornece muito mais suporte do que o que determina o padrão sobre o controle de inlining. É possível indicar ao compilador para nunca embutir uma gama de funções usando o pragma auto_inline. É possível indicar ao compilador para nunca embutir uma função ou método específico marcando-o com __declspec(noinline). É possível marcar uma função com a palavra-chave inline para dar uma dica ao compilador para embutir a função (embora o compilador possa optar por ignorar esta dica se o inlining fosse uma perda líquida). A palavra-chave inline está disponível desde a primeira versão do C++ — foi introduzida em C99. É possível usar a palavra-chave específica da Microsoft __inline no código C e C++; é útil quando está usando uma versão antiga de C que não suporta esta palavra-chave. Além disso, é possível usar a palavra-chave __forceinline (C e C++) para forçar o compilador a embutir uma função sempre que possível. E por fim, mas não menos importante, é possível indicar ao compilador para desdobrar uma função recursiva em uma profundidade específica ou indefinida embutindo-a usando o pragma inline_recursion. Observe que o compilador atualmente não oferece nenhuns recursos que permitam controlar o inlining no site de chamada em vez de na definição da função.

O comutador /Ob0 desativa completamente o inlining, que tem efeito por padrão. Você deve usar este comutador ao depurar (é especificado automaticamente na configuração de depuração do Visual Studio). O comutador /Ob1 indica ao compilador para somente considerar funções para inlining que estejam marcadas com inline, __inline ou __forceinline. O comutador /Ob2, que tem efeito ao especificar /O[1|2|x], indica ao compilador para considerar qualquer função para inlining. Na minha opinião, o único motivo para usar as palavras-chave inline ou __inline é controlar inlining com o comutador /Ob1.

O compilador não poderá embutir uma função em certas condições. Um exemplo é ao chamar uma função virtual virtualmente; a função não pode ser embutida pois o compilador pode não saber que função vai ser chamada. Outro exemplo é ao chamar uma função através de um ponteiro para a função em vez de usar o respectivo nome. Deve se esforçar para evitar essas condições para permitir inlining. Consulte a documentação MSDN para obter uma lista completa dessas condições.

Inlining de função é a única otimização que é mais eficaz quando aplicada ao nível do programa total. De fato, a maioria das otimizações fica mais eficaz nesse nível. No resto desta seção, discutirei uma classe específica dessas otimizações chamadas otimizações COMDAT.

Por padrão, ao completar uma unidade de conversão, todo o código será armazenado em uma única seção no arquivo de objeto resultante. O vinculador opera ao nível de seção. Ou seja, pode remover seções, combinar seções e reordenar seções. Isto impede o vinculador de executar três otimizações que podem reduzir significativamente (porcentagem de dois dígitos) o tamanho do executável e melhorar seu desempenho. A primeira é eliminar funções não referenciadas e variáveis globais. A segunda é dobrar funções idênticas e variáveis globais constantes. A terceira é reordenar funções e variáveis globais para que as funções que ficam no mesmo caminho de execução e as variáveis que são acessadas junto estejam fisicamente localizadas próximas na memória para melhorar a localidade.

Para habilitar estas otimizações de vinculador, é possível indicar ao compilador para empacotar funções e variáveis em seções separadas especificando os comutadores de compilador /Gy (vinculação ao nível de função) e /Gw (otimização de dados global), respectivamente. Essas seções são chamadas COMDATs. Também é possível marcar uma variável de dados globais particular com __declspec( selectany) para indicar ao compilador para empacotar a variável em uma COMDAT. Então, ao especificar a opção de vinculador /OPT:REF, o vinculador eliminará funções não referenciadas e variáveis globais. Além disso, ao especificar a opção /OPT:ICF, o vinculador dobrará funções idênticas e variáveis constantes globais. (ICF significa dobra de COMDAT idêntica.) Com a opção de vinculador /ORDER, é possível instruir o vinculador a colocar COMDATs na imagem resultante em uma ordem específica. Observe que todas estas otimizações são otimizações de vinculador e não necessitam do comutador de compilador /GL. As opções /OPT:REF e /OPT:ICF devem ser desativadas ao depurar por motivos óbvios.

Deve usar LTCG sempre que possível. O único motivo para não usar LTCG é quando quer distribuir o objeto resultante e arquivos de biblioteca. Lembre-se que estes arquivos contêm código CIL em vez de código do assembly. O código CIL pode ser consumido apenas pelo compilador/vinculador da mesma versão que o produziu, o que pode limitar significativamente a usabilidade dos arquivos de objeto, pois os desenvolvedores têm de ter a mesma versão do compilador para usar estes arquivos. Neste caso, a menos que esteja disposto a distribuir os arquivos de objeto para cada versão de compilador, deve usar a geração de código no tempo de compilação. Além da usabilidade limitada, estes arquivos de objeto são muitas vezes maiores em tamanho do que os arquivos de objeto do montador correspondentes. No entanto, tenha em mente o grande benefício dos arquivos de objeto CIL, que é habilitar a WPO.

Otimizações de loop

O compilador do Visual C++ suporta várias otimizações de loop, mas discutirei somente três: desenrolamento de loop, vetorização automática e movimento de código de invariável de loop. Se modificar o código na Figura 1 para que m seja transmitido para sumOfCubes em vez de n, o compilador não poderá determinar o valor do parâmetro, pelo que deve compilar a função para manipular qualquer argumento. A função resultante está altamente otimizada e seu tamanho é bastante grande, pelo que o compilador não a embutirá.

Compilar o código com o comutador /O1 resulta no código de assembly que está otimizado para espaço. Neste caso, nenhumas otimizações serão executadas na função sumOfCubes. Compilar com o comutador /O2 resulta no código que está otimizado para velocidade. O tamanho do código será significativamente maior, porém significativamente mais rápido, pois o loop em sumOfCubes foi desenrolado e vetorizado. É importante compreender que a vetorização não seria possível sem embutir a função cube. Além disso, o desenrolamento do loop não seria tão eficaz sem o inlining. Uma representação gráfica simplificada do código de assembly resultante é mostrada na Figura 3. O gráfico de fluxo é o mesmo para as arquiteturas x86 e x86-64.

Gráfico de fluxo de controle de sumOfCubes
Figura 3 Gráfico de fluxo de controle de sumOfCubes

Na Figura 3, o diamante verde é o ponto de entrada e os retângulos vermelhos são os pontos de saída. Os losangos azuis representam condições que estão sendo executadas como parte da função sumOfCubes no tempo de execução. Se SSE4 for suportado pelo processador e x for maior ou igual a oito, as instruções SSE4 serão usadas para executar quatro multiplicações ao mesmo tempo. O processo de executar a mesma operação em múltiplos valores em simultâneo é chamado vetorização. Além disso, o compilador desenrolará o loop duas vezes; ou seja, o corpo do loop será repetido duas vezes em cada iteração. O efeito combinado é que oito multiplicações serão executadas para cada iteração. Quando x é inferior a oito, serão usadas instruções tradicionais para executar o resto dos cálculos. Observe que o compilador emitiu três pontos contendo epílogos separados na função em vez de somente um. Isto reduz o número de saltos.

O desenrolamento de loop é o processo de repetir o corpo do loop no loop para que seja executada mais de uma iteração do loop em uma única iteração do loop desenrolado. O motivo pelo qual isto melhora o desempenho é o fato de as instruções de controle de loop serem executadas com menos frequência. Talvez mais importante, pode permitir que o compilador execute muitas outras otimizações, tais como vetorização. A desvantagem do desenrolamento é que aumenta o tamanho do código e a pressão de registro. No entanto, dependendo do corpo do loop, pode melhorar o desempenho em uma porcentagem de dois dígitos.

Ao contrário dos processadores x86, todos os processadores x86-64 suportam SSE2. Além disso, pode aproveitar os conjuntos de instruções AVX/AVX2 das mais recentes microarquiteturas x86-64 da Intel e AMD especificando a opção /arch. Especificar /arch:AVX2 permite que o compilador também use os conjuntos de instruções FMA e BMI.

Atualmente, o compilador do Visual C++ não permite que controle o desenrolamento de loop. No entanto, pode emular esta técnica usando modelos junto com a palavra-chave __forceinline. É possível desativar a vetorização automática em um loop específico usando o pragma loop com a opção no_vector.

Ao observar o código de assembly gerado, um olho treinado observaria que o código pode ser otimizado um pouco mais. No entanto, o compilador já fez um ótimo trabalho e não despenderá muito mais tempo analisando o código e aplicando otimizações menores.

someOfCubes não é a única função cujo loop foi desenrolado. Se modificar o código para que m seja transmitido para a função sum em vez de n, o compilador não poderá avaliar a função e, portanto, tem de emitir este código. Nesse caso, o loop será desenrolado duas vezes.

A última otimização que discutirei é o movimento de código de invariável de loop. Considere a seguinte parte do código:

int sum(int x) {
  int result = 0;
  int count = 0;
  for (int i = 1; i <= x; ++i) {
    ++count;
    result += i;
  }
  printf("%d", count);
  return result;
}

A única alteração aqui é que tenho uma variável adicional que está sendo incrementada em cada iteração e, depois, impressa. Não é difícil ver que este código pode ser otimizado movendo o incremento da variável count para fora do loop. Ou seja, posso simplesmente atribuir x à variável count. Esta otimização é chamada movimento de código de invariável de loop. A parte da invariável de loop indica claramente que esta técnica somente funciona quando o código não depende de qualquer expressão no cabeçalho do loop.

Aqui temos o problema: Se aplicar esta otimização manualmente, o código resultante pode apresentar um desempenho reduzido em certas condições. Consegue ver porquê? Considere o que acontece quando x é não positivo. O loop nunca é executado, o que significa que na versão não otimizada a variável count não será tocada. No entanto, na versão otimizada manualmente, uma atribuição desnecessária de x a count é executada fora do loop! Além disso, se x for negativo, count conteria o valor errado. Tanto os humanos como os compiladores estão suscetíveis a essas armadilhas. Felizmente, o compilador do Visual C++ é suficientemente esperto para consegui-lo emitindo a condição do loop antes da atribuição, resultando em um desempenho melhorado de todos os valores de x.

Em resumo, se não for um compilador nem um especialista em otimizações de compilador, deve evitar efetuar transformações manuais ao código apenas para que seja mais rápido. Mantenha as suas mãos limpas e confie no compilador para otimizar seu código.

Controlando otimizações

Além dos comutadores de compilador /O1, /O2 e /Ox, é possível controlar otimizações para funções específicas usando o pragma optimize, que se parece com o seguinte:

#pragma optimize( "[optimization-list]", {on | off} )

A lista de otimizações pode estar vazia ou conter um ou mais dos seguintes valores: g, s, t e y. Estes correspondem aos comutadores de compilador /Og, /Os, /Ot e /Oy, respectivamente.

Uma lista vazia com o parâmetro off faz com que todas estas otimizações sejam desativadas independente dos comutadores de compilador especificados. Uma lista vazia com o parâmetro on faz com que o compilador especificado muda para ter efeito.

O comutador /Og permite otimizações globais, que são todas aquelas que podem ser executadas observando somente a função sendo otimizada, não todas as funções que chama. Se LTCG estiver ativada, /Og permite WPO.

O pragma optimize é útil que pretende a otimização de diferentes funções de formas diferentes – algumas em termos de espaço e outras em termos de velocidade. No entanto, se desejar realmente esse nível de controle, deve considerar a otimização orientada por perfis (PGO), que é o processo para otimizar o código usando um perfil que contém informações comportamentais registradas ao executar uma versão instrumentada do código. O compilador usa o perfil para tomar decisões melhores sobre como otimizar o código. O Visual Studio fornece as ferramentas necessárias para aplicar essa técnica em código nativo e gerenciado.

Otimizações no .NET

Não há nenhum vinculador envolvido no modelo de compilação do .NET. No entanto, há um compilador de código-fonte (compilador de C#) e um compilador JIT. O compilador de código-fonte executa somente otimizações menores. Por exemplo, não executa inlining de função nem otimizações de loop. Em vez disso, essas otimizações são manipuladas pelo compilador JIT. O compilador JIT fornecido com todas as versões do .NET Framework até à 4.5 não suporta instruções SIMD. No entanto, o compilador JIT fornecido com o .NET Framework 4.5.1 e versões posteriores, chamado RyuJIT, suportam SIMD.

Qual é a diferença entre RyuJIT e Visual C++ em termos de capacidades de otimização? Como faz seu trabalho no tempo de execução, RyuJIT pode executar otimizações que o Visual C++ não pode. Por exemplo, no tempo de execução, RyuJIT pode determinar que a condição de uma instrução if nunca é verdadeira nesta execução particular do aplicativo e, como tal, pode ser otimizada. Além disso, RyuJIT pode aproveitar as capacidades do processador no qual está sendo executado. Por exemplo, se o processador suporta SSE4.1, o compilador JIT somente emitirá instruções SSE4.1 para a função sumOfcubes, tornando o código gerado muito mais compacto. No entanto, não pode despender muito tempo otimizando o código, pois o tempo necessário para a compilação JIT afeta o desempenho do aplicativo. Por outro lado, o compilador do Visual C++ pode despender muito mais tempo para identificar outras oportunidades de otimização e aproveitá-las. Uma nova tecnologia fantástica da Microsoft, chamada .NET Native, permite compilar código gerenciado em executáveis autocontidos otimizados usando o back-end do Visual C++. Atualmente, esta tecnologia suporta somente aplicativos da Windows Store.

A habilidade de controlar otimizações de código gerenciado é atualmente limitada. Os compiladores C# e Visual Basic somente fornecem a habilidade de ativar ou desativar otimizações usando o comutador /optimize. Para controlar otimizações JIT, é possível aplicar o atributo System.Runtime.Compiler­Services.MethodImpl em um método com uma opção de MethodImplOptions especificada. A opção NoOptimization desativa otimizações, a opção NoInlining impede que o método seja embutido e a opção AggressiveInlining (.NET 4.5) faz uma recomendação (mais do que somente uma dica) ao compilador JIT para embutir o método.

Conclusão

Todas as técnicas de otimização discutidas neste artigo podem melhorar significativamente o desempenho de seu código em uma porcentagem de dois dígitos e são todas suportadas pelo compilador Visual C++. O que torna estas técnicas importantes é que, quando aplicadas, permitem que o compilador execute outras otimizações. Não é, de todo, uma discussão abrangente das otimizações do compilador executadas pelo Visual C++. No entanto, espero ter dado uma apreciação das capacidades do compilador. O Visual C++ pode fazer mais, muito mais, pelo que fique atento à parte 2.


Hadi Brais é um doutor acadêmico no Instituto Indiano de Tecnologia de Deli (IITD), pesquisando otimizações de compilador para a tecnologia de memória de próxima geração. Ele passa maior parte de seu tempo escrevendo código em C/C ++/C# e se aprofundando em CLR e CRT. Ele mantém um blog em hadibrais.wordpress.com. Entre em contato com ele em hadi.b@live.com.

Agradecemos ao seguinte especialista técnico da Microsoft pela revisão deste artigo: Jim Hogg