Compiladores

O que todos os programadores devem saber sobre otimizações de compilador - 2ª parte

Hadi Brais

Baixar o código de exemplo

Bem-vindo à segunda parte da minha série sobre otimizações do compilador. No primeiro artigo (msdn.microsoft.com/magazine/dn904673), discuti a função inlining, unrolling de loop, movimentação de código de loop invariável, vetorização automática e otimizações de COMDAT. Neste segundo artigo, vou examinar a duas outras otimizações: registro da alocação e o agendamento de instrução. Como sempre, me concentrarei no compilador do Visual C++, com uma breve descrição de como as coisas funcionam no Microsoft .NET Framework. Usarei o Visual Studio 2013 para compilar o código. Vamos começar.

Alocação de registro

Alocação de registro é o processo de alocação de um conjunto de variáveis de registros disponíveis para que ele não precise ser alocado na memória. Esse processo normalmente é executado no nível de uma função inteira. No entanto, especialmente quando a geração de código Link-Time (/LTCG) é habilitada, o processo pode ser executado em funções, que pode resultar em uma alocação mais eficiente. (Nesta seção, todas as variáveis são automáticas, aquelas cujos tempos de vida são considerados sintaticamente, salvo indicação em contrário.)

A alocação de registro é uma otimização especialmente importante. Para entender isso, vamos ver qual o tempo necessário para acessar os diferentes níveis de memória. O acesso a um registro leva menos de um ciclo de processador. Acessar o cache é um pouco mais lento e demora de alguns até dezenas de ciclos. O acesso à memória DRAM (remota) é ainda mais lento. Por fim, acessar o disco rígido é tão lento e pode demorar milhões de ciclos! Além disso, um acesso à memória aumenta o tráfego aos caches compartilhados e memória principal. A alocação de registro reduz o número de acessos à memória utilizando os registros disponíveis tanto quanto possível.

O compilador tentará alocar um registro para cada variável, em condições ideais, até que todas as instruções que envolvam essa variável sejam executadas. Se isso não for possível, o que é comum por razões que discutirei em breve, uma ou mais variáveis precisam ser despejadas na memória para que devam ser carregadas e armazenadas com frequência. A demanda de registro se refere ao número de registros que foram despejados devido à indisponibilidade de registros. Maior demanda de registro significa mais acessos à memória e mais acessos à memória podem diminuir não apenas o próprio programa, mas também colocar o sistema inteiro em um rastreamento.

Os processadores x86 modernos oferecem os seguintes registros a serem alocados por compiladores: oito registros de uso geral de 32 bits, oito registros de ponto flutuante de 80 bits e oito registros de vetor de 128 bits. Todos os processadores x64 oferecem 16 registros de uso geral de 64 bits, oito registros de ponto flutuante de 80 bits e pelo menos 16 registros de vetor, cada um com pelo menos 128 bits de largura. Os processadores ARM de 32 bits modernos oferecem 15 registros de uso geral de 32 bits e 32 registros de ponto flutuante de 64 bits. Todos os processadores ARM de 64 bits oferecem 31 registros de uso geral de 64 bits, 32 registros de ponto flutuante de 128 bits e 16 registros de vetor de 128 bits (NEON). Todos eles estão disponíveis para alocação de registro (e você também pode adicionar à lista os registros oferecidos pela placa de vídeo). Quando uma variável local não pode ser atribuída a qualquer um dos registros disponíveis, ela deve ser alocada na pilha. Isso acontece em praticamente todas as funções por vários motivos, como vou discutir. Considere o programa na Figura 1. O programa não faz nada significativo, mas serve como um bom exemplo para demonstrar a alocação de registro.

Figura 1 Exemplo de Programa de alocação de registro

#include <stdio.h>
int main() {
  int n = 0, m;
  scanf_s("%d", &m);
  for (int i = 0; i < m; ++i){
    n += i;
  }
  for (int j = 0; j < m; ++j){
    n += j;
  }
  printf("%d", n);
  return 0;
}

Antes de alocar registros disponíveis para variáveis, o compilador primeiro analisa o uso de todas as variáveis declaradas dentro da função (ou em funções no caso de /LTCG) para determinar quais conjuntos de variáveis estão ativos ao mesmo tempo e calcula o número de vezes que cada variável que está sendo acessado. Duas variáveis de conjuntos diferentes podem ser alocadas no mesmo registro. Se não houver nenhum registro adequado para algumas variáveis do mesmo conjunto, essas variáveis precisam ser despejadas. O compilador tenta escolher as variáveis menos acessadas para serem despejadas numa tentativa de minimizar o número total de acessos à memória. Essa é a ideia geral. Mas há muitos casos especiais em que é possível encontrar uma alocação melhor. Os compiladores modernos são capazes de elaborar uma boa alocação, mas não a ideal. Acredito que é muito, mas muito difícil para um mortal fazer melhor.

Com isso em mente, eu vou compilar o programa na Figura 1 com as otimizações habilitadas e ver como o compilador alocará as variáveis locais para os registros. Há quatro variáveis a serem alocadas: n, m, i e j. Presumo que o destino, nesse caso, é a plataforma x86. Examinando o código do assembly gerado (/FA), observo que a variável n foi alocada para o registro ESI, a variável m foi alocada para o ECX e i e j foram alocadas para EAX. Observe como o compilador reutilizou o EAX de forma inteligente para duas as variáveis porque suas vidas úteis não se cruzam. Observe também que o compilador reservou um espaço na pilha para m porque seu endereço foi retirado. Na plataforma x64, a variável n será alocada para o registro EDI, a variável m será alocada para EDX, i para EAX e j para o EBX. Por algum motivo, o compilador não alocou i e j para o mesmo registro neste momento.

Foi uma alocação ideal? Não. O problema está no uso do ESI e EDI. Esses registros são registros salvos pelo receptor, o que significa que a função chamada precisa certificar-se de que os valores desses registros mantém na saída são os mesmos da entrada. É por isso que o compilador teve de emitir uma instrução na entrada da função para enviar por push ESI/EDI na pilha e outra instrução na saída para removê-los da pilha. O compilador poderia ter evitado isso nas duas plataformas usando um registro salvo pelo autor da chamada, como EDX. Essas deficiências no algoritmo de alocação de registro, às vezes, podem ser reduzidas pela função inlining. Muitas outras otimizações podem processar o código receptivo para uma alocação de registro mais eficiente, como eliminação de código inativo, eliminação de subexpressão comum e agendamento de instrução.

É realmente comum que variáveis tenham tempos de vida separados, de forma que a alocação do mesmo registro para todas é muito econômico. Mas, e se você ficar sem registros para acomodar qualquer uma delas? Você precisa despejá-las. No entanto, você pode fazer isso de maneira inteligente. Você despeja todas para o mesmo local na pilha. Essa otimização é chamada de empacotamento de pilha e é suportada pelo Visual C++. O empacotamento de pilha reduz o tamanho do registro de ativação e pode melhorar a taxa de acertos do cache de dados, resultando em um desempenho melhor.

Infelizmente, as coisas não são simples. A partir de uma perspectiva teórica, a alocação de registro (quase) ideal pode ser obtida. No entanto, na prática, existem muitas razões por que isso pode não ser possível:

  • Os registros disponíveis em plataformas x86 e x64 (mencionadas anteriormente) e qualquer outra plataforma moderna (como ARM) não podem ser usados arbitrariamente. Há restrições complexas. Cada instrução impõe restrições em relação a quais registros podem ser usados como operandos. Portanto, se quiser usar uma instrução, você precisa usar os registros permitidos para passá-lo para os operandos necessários. Além disso, os resultados de algumas instruções são armazenados em registros predeterminados cujos valores são considerados pelas instruções a serem voláteis. Pode haver uma sequência diferente de instruções que executam o mesmo cálculo, mas permite que você execute uma alocação de registro mais eficiente. Os problemas da seleção da instrução, agendamento de instrução e alocação de registro é que são terrivelmente confusas.
  • Nem todas as variáveis são de tipos primitivos. Não é incomum ter matrizes e estruturas automáticas. Essas variáveis não podem ser consideradas diretamente para alocação de registro. No entanto, elas podem ser alocadas separadamente para registros. Os compiladores atuais ainda não são bons.
  • A convenção de chamada de uma função impõe uma alocação fixa para alguns argumentos durante o processamento de outros usuários não qualificados para alocação independentemente da disponibilidade de registros. Mais sobre esse problema posteriormente. Além disso, as noções de registros salvos pelo autor da chamada e salvos pelo receptor tornam as coisas mais complicadas.
  • Se o endereço de uma variável for executado, a variável é armazenada melhor em um local que tenha um endereço. Um registro não tem um endereço, então ele precisa ser armazenado na memória, se ele estiver disponível ou não.

Tudo isso pode parecer que os compiladores atuais são péssimos em alocação de registro. No entanto, eles são razoavelmente bons e estão ficando melhores, muito lentamente. Além disso, você pode imaginar a si mesmo escrevendo o código de assembly ao pensar sobre tudo isso?

Você pode ajudar o compilador a encontrar uma alocação melhor, permitindo /LTCG quando destinado a arquiteturas x86. Se você especificar a opção de compilador /GL, os arquivos OBJ gerados conterão o Código de idioma intermediário C (CIL) em vez do código de assembly. Convenções de chamada de função não são incorporadas no código CIL. Se uma função específica não está definida para ser exportada do executável de saída, o compilador pode violar sua convenção de chamada para melhorar seu desempenho. Isso é possível porque ele pode identificar todos os locais de chamada da função. O Visual C++ aproveita isso ao tornar todos os argumentos da função qualificados para alocação de registro, independentemente da convenção de chamada. Mesmo se a alocação de registro não puder ser melhorada, o compilador tentará reordenar parâmetros para um alinhamento mais econômico e até mesmo remover parâmetros não utilizados. Sem a opção /GL, os arquivos OBJ resultantes contêm código binário em que as convenções de chamada já foram consideradas. Se um arquivo de assembly OBJ tem um site de chamada para uma função em um arquivo CIL OBJ ou se o endereço da função é usado em qualquer lugar ou se ele é virtual, o compilador não pode otimizar sua convenção de chamada. Sem o /LTCG, por padrão, todas as funções e métodos têm vinculação externa, para que o compilador não possa aplicar essa técnica. Mas se uma função em um arquivo OBJ tiver sido explicitamente definida com vinculação interna, o compilador pode aplicar essa técnica para ele, mas somente dentro de um arquivo OBJ. Essa técnica, indicada pela documentação como uma convenção de chamada personalizada, é importante em arquiteturas x86 porque o padrão de convenção de chamada, ou seja, __cdecl, não é eficiente. Por outro lado, a convenção de chamada __fastcall na arquitetura x64 é muito eficiente porque os primeiros quatro argumentos são passados por meio de registros. Por esse motivo, a convenção de chamada personalizada é executada somente quando destinada para x86.

Observe que, mesmo se /LTCG estiver habilitado, a convenção de chamada de um método ou função exportada não pode ser violada porque é impossível para o compilador localizar todos os sites de chamada, assim como em todos os casos mencionados anteriormente.

A eficácia da alocação de registro depende da precisão do número estimado de acessos às variáveis. A maioria das funções contêm instruções condicionais, colocando em risco a precisão dessas estimativas. A otimização orientada por perfil pode ser usada para ajustar essas estimativas.

Quando o /LTCG está habilitado e a plataforma de destino é x64, o compilador executa a alocação de registro entre procedimentos. Isso significa que ele vai considerar as variáveis declaradas dentro de uma cadeia de funções e tentar encontrar uma melhor alocação dependendo das restrições impostas pelo código em cada função. Caso contrário, o compilador executa a alocação de registro global em que cada função que é processada separadamente (“global” aqui significa a função inteira).

Tanto C e C++ oferecem a palavra-chave do registro, permitindo ao programador fornecer uma dica para o compilador sobre quais variáveis armazenar em registros. Na verdade, a primeira versão do C introduziu essa palavra-chave e era útil naquele momento (aproximadamente em 1972) porque ninguém sabia como executar alocação de registro com eficiência. (Um compilador FORTRAN IV desenvolvido pela IBM Corporation no final da década de 60 para a série 360/S podia executar alocação de registro simples. A maioria dos modelos S/360 ofereciam 16 registros de uso geral de 32 bits e quatro registros de ponto flutuante de 64 bits!) Além disso, como com muitos outros recursos do C, a palavra-chave do registro facilita escrever compiladores C. Quase uma década depois, o C++ foi criado e oferecia a palavra-chave do registro porque C foi considerada para ser um subconjunto do C++. (Infelizmente, há muitas diferenças sutis.) Desde o início dos anos 80, muitos algoritmos de alocação de registro foram implementados, portanto a existência da palavra-chave criou muita confusão até hoje. A maioria das linguagens de produção que foi criada desde então não oferece uma palavra-chave (incluindo C# e Visual Basic). Essa palavra-chave tem sido preterida posteriormente ao C++11, mas não na versão mais recente do C, C11. Essa palavra-chave deve ser usada somente para gravação de benchmarks. O compilador do Visual C++ respeita essa palavra-chave, se possível. O C não permite que o endereço de uma variável do registro seja obtido. O C++, entretanto, permite, mas, em seguida, o compilador precisa armazenar a variável em um local endereçável em vez de em um registro, violando a sua classe de armazenamento especificada manualmente.

Ao direcionar o CLR, o compilador precisa emitir o código de Idioma intermediário comum (CIL) que modela uma máquina de pilha. Nesse caso, o compilador não realizará a alocação de registro (embora se algum código emitido for nativo, a alocação de registro será executada, é claro) e a adiará até o tempo de execução ser executado pelo compilador just-in-time (JIT) (ou back-end do Visual C++ no caso de compilação nativa do .NET). O RyuJIT, o compilador JIT que acompanha o .NET Framework 4.5.1 e versões posteriores, implementa um algoritmo de alocação de registro bem satisfatório.

Agendamento de instrução

A alocação de registro e o agendamento de instrução estão entre as últimas otimizações realizadas pelo compilador antes dele emitir o binário.

Tudo, exceto as instruções mais simples, são executadas em vários estágios, onde cada estágio é tratado por uma unidade específica do processador. Para utilizar todas essas unidades tanto quanto possível, o processador emite várias instruções em um modo de pipeline, de modo que diferentes instruções estão em execução em diferentes estágios ao mesmo tempo. Isso pode melhorar significativamente o desempenho. No entanto, se uma dessas instruções não estiver pronta para execução por algum motivo, o pipeline inteiro é paralisado. Isso pode ocorrer por várias razões, incluindo a espera por outra instrução para confirmar seu resultado, a espera por dados provenientes da memória ou disco, ou a espera pela conclusão de uma operação de e/s.

O agendamento de instrução é uma técnica que pode atenuar esse problema. Existem dois tipos de agendamento de instrução:

  • Baseados em compilador: O compilador analisa as instruções de uma função para determinar aquelas instruções que podem parar o pipeline. Em seguida, ele tenta encontrar uma ordem diferente das instruções para minimizar o custo das pausas esperadas e, ao mesmo tempo, preservar a exatidão do programa. Isso é chamado de reordenação de instrução.
  • Com base em hardware: A maioria dos modernos processadores x86, x64 e ARM são capazes de olhar adiante no fluxo de instruções (micro-ops, para ser preciso) e emitir as instruções cujos operandos e a unidade funcional necessária estão disponíveis para execução. Isso é chamado fora de ordem (OoOE ou 3OE) ou execução dinâmica. O resultado é que o programa está sendo executado em uma ordem diferente da original.

Existem outros motivos que podem fazer com que o compilador reordene algumas instruções. Por exemplo, o compilador pode reordenar loops aninhados para que o código apresente melhor localidade de referência (essa otimização é chamada de intercâmbio de loop). Outro exemplo é reduzir os custos do despejo de registro tornando instruções que usam o mesmo valor carregado da memória consecutiva para que o valor seja carregado apenas uma vez. Ainda outro exemplo, é reduzir a perda de dados e cache de instruções.

Como um programador, você não precisa saber como um processador ou um compilador realiza o agendamento de instrução. No entanto, você deve estar ciente das ramificações dessa técnica e como lidar com elas.

Enquanto o agendamento de instrução preserva a correção da maioria dos programas, ele pode produzir alguns resultados não intuitivos e surpreendentes. A Figura 2 mostra um exemplo onde o agendamento de instrução faz com que o compilador emita um código incorreto. Para ver isso, compile o programa como um código C (/TC) no Modo de versão. Você pode definir a plataforma de destino como x86 ou x64. Como você está indo examinar o código do assembly resultante, especifique o /FA para que o compilador emita um listagem de assembly.

Figura 2 Programa de exemplo de agendamento de instrução

#include <stdio.h>
#include <time.h>
__declspec(noinline) int compute(){
  /* Some code here */
  return 0;
}
int main() {
  time_t t0 = clock();
  /* Target location */
  int result = compute();
  time_t t1 = clock(); /* Function call to be moved */
  printf("Result (%d) computed in %lld ticks.", result, t1 - t0);
  return 0;
}

Neste programa, desejo medir o tempo de execução da função de computação. Para fazer isso, geralmente encapsulo a chamada para a função por chamadas para uma função de tempo como relógio. Em seguida, computando a diferença nos valores do relógio, obtenho uma estimativa do tempo que a função levou para ser executada. Observe que o objetivo desse código não é mostrar a melhor maneira para medir o desempenho de uma parte do código, mas para demonstrar os riscos do agendamento de instrução.

Como esse é o código C e o programa é muito simples, é fácil de entender o código do assembly resultante. Ao examinar o código de assembly e focalizando as instruções de chamada, você observará que a segunda chamada para a função de relógio precede a chamada para a função de computação (ela foi movido para o “local de destino”), tornando a medida completamente errada.

Observe que essa reordenação não viola os requisitos mínimos impostos pelo padrão em implementações em conformidade, por isso é legal.

Mas por que o compilador faria isso? O compilador pensa que a segunda chamada para o relógio não depende da chamada para computação (na verdade, para o compilador, essas funções não afetam umas as outras de modo algum). Além disso, após a primeira chamada para relógio, é provável que o cache de instrução contenha algumas das instruções dessa função e o cache de dados contenha os dados necessários para essas instruções. A chamada de computação pode causar a substituição dessas instruções e dados, de forma que o compilador reordenou o código adequadamente.

O compilador do Visual C++ não oferece uma opção para desativar o agendamento de instruções enquanto mantém todas as outras otimizações. Além disso, esse problema pode ocorrer devido à execução dinâmica se a função de computação estava embutida. Dependendo de como será a execução da função de computação e quanto um processador pode olhar à frente, um processador 3OE pode decidir começar a executar a segunda chamada de relógio antes da função de computação ser concluída. Assim como ocorre com o compilador, a maioria dos processadores não permitem que você desative a execução dinâmica. Mas, para ser honesto, é muito improvável que esse problema ocorra devido à execução dinâmica. Como você pode saber se ele aconteceu, mesmo assim?

O compilador do Visual C++ é realmente muito cauteloso ao executar essa otimização. Ele é tão cauteloso que há muitas coisas que impedem a reordenação de uma instrução (por exemplo, a instrução de chamada). Tenho percebido as seguintes situações que fazem com que o compilador não mova a chamada de função do relógio para um local específico (o local de destino):

  • Chamada de uma função importada de qualquer uma das funções que está sendo chamada entre o local da chamada de função e o local de destino. Como mostra este código, chamar qualquer função importada da função de computação faz com que o compilador não mova a segunda chamada para o relógio:
__declspec(noinline) int compute(){
  int x;
  scanf_s("%d", &x); /* Calling an imported function */
  return x;
}
  • A chamada de uma função importada entre a chamada para computar e a segunda chamada para o relógio:
int main() {
  time_t t0 = clock();
  int result = compute();
  printf("%d", result); /* Calling an imported function */
  time_t t1 = clock();
  printf("Result (%d) computed in %lld.", result, t1 - t0);
  return 0;
}
  • O acesso de qualquer variável global ou estática de qualquer uma das funções que está sendo chamada entre o local da chamada de função e o local de destino. Isso retém se a variável estiver sendo lida ou gravada. Veja a seguir que o acesso a uma variável global da função de computação faz com que o compilador não mova a segunda chamada para o relógio:
int x = 0;
__declspec(noinline) int compute(){
  return x;
}
  • Marcar t1 como volátil.

Há outras situações que impedem o compilador de reordenar as instruções. É tudo sobre a regra como se do C++, que informa ao compilador que pode transformar um programa que não contém operações indefinidas da forma que gostaria, assim como o comportamento observável é assegurado de permanecer o mesmo. O Visual C++ não apenas adere a essa regra, mas também é muito mais conservador para reduzir o tempo necessário para compilar o código. Uma função importada pode causar efeitos colaterais. Funções de e/s da biblioteca e o acesso à variáveis voláteis efeitos causam efeitos colaterais.

Volátil, restringir e /favor

Qualificar uma variável com a palavra-chave volátil afeta a alocação de registro e reordenação de instrução. Primeiro, a variável não será alocada para qualquer registro. (A maioria das instruções exige que alguns dos seus operandos sejam armazenados em registros, o que significa que a variável será carregada em um registro, mas somente para executar algumas das instruções que usam essa variável). Ou seja, a leitura ou gravação para a variável sempre causa um acesso de memória. Em segundo lugar, a gravação em uma variável volátil tem semântica de liberação, o que significa que todos os acessos de memória que ocorrem sintaticamente antes da gravação para a variável acontecerá antes dele. Em terceiro lugar, a leitura de uma variável volátil tem semântica de aquisição, o que significa que todos os acessos de memória sintaticamente que ocorrerem após a leitura dessa variável acontecerão depois dela. Mas, há um problema: Essas garantias de reordenação são oferecidas apenas especificando a opção /volatile:ms. Por outro lado, a opção /volatile:iso informa ao compilador para cumprir o idioma padrão, o que não oferece nenhuma dessas garantias através dessa palavra-chave. Para o ARM, a /volatile:iso entra em vigor por padrão. Para outras arquiteturas, o padrão é /volatile:ms. Antes do C++11, a opção /volatile:ms foi útil porque o padrão não oferecia tudo para os programas multithread. No entanto, começando com o C11/C++11, o uso de /volatile:ms torna seu código não portátil e é altamente desaconselhável e você deveria usar armas nucleares em vez disso. Vale a pena observar que se seu programa funcionar corretamente sob o /volatile:iso, ele funcionará corretamente em /volatile:ms. Mais importante, no entanto, se ele funcionar corretamente em /volatile:ms ele pode não funcionar corretamente em /volatile:iso porque o primeiro oferece garantias mais fortes que o último.

A opção /volatile:ms implementa a semântica de aquisição e liberação. Isto não é o suficiente para manter esses em tempo de compilação. O compilador pode (dependendo da plataforma de destino) emitir instruções extras (como mfence e xchg) para informar a um processador 3OE para manter essa semântica ao mesmo tempo em que executa o código. Portanto, as variáveis voláteis degradam o desempenho não apenas porque as variáveis não são armazenados em cache nos registros, mas também pelas instruções adicionais que são emitidas.

A semântica da palavra-chave volátil de acordo com a especificação da linguagem C# é semelhante à oferecida pelo compilador do Visual C++ com a opção /volatile:ms especificada. No entanto, há uma diferença. A palavra-chave volátil em C# implementa a semântica de aquisição/relação sequencialmente consistente (SC), enquanto a palavra-chave volátil em C/C++ em /volatile:ms implementa a semântica de aquisição/relação pura. Lembre-se que a volátil do C/C++ em /volatile:iso não tem nenhuma semântica de aquisição/relação. Os detalhes estão fora da abrangência deste artigo. Em geral, os limites de memória podem impedir que o compilador execute muitas otimizações entre elas.

É muito importante entender que se o compilador não oferece essas garantias em primeiro lugar, em seguida, qualquer garantia correspondente oferecida pelo processador é automaticamente evitada.

A palavra-chave __restrict (ou restrita) também afeta a eficácia tanto da alocação de registro quanto do agendamento de instrução. No entanto, ao contrário à volátil, restringir pode melhorar significativamente essas otimizações. Uma variável de ponteiro marcada com essa palavra-chave em um escopo indica que não há nenhuma outra variável que aponta para o mesmo objeto, criado fora do escopo e usado para modificá-lo. Essa palavra-chave também pode habilitar o compilador para executar muitas otimizações em ponteiros, inclusive com certeza otimizações de loop e vetorização automática, e reduz o tamanho do código gerado. Você pode pensar que a restrição da palavra-chave é como uma arma ultrassecreta, de alta-tecnologia, anti-anti-otimização. Ela merece um artigo inteiro só para ela. No entanto, ela não será discutida aqui.

Se uma variável é marcada tanto como volátil quanto __restrict, a palavra-chave volátil terá precedência ao tomar decisões sobre como otimizar o código. Na verdade, o compilador pode ignorar totalmente a restrita, mas deve respeitar a volátil.

A opção /favor pode habilitar o compilador para executar o agendamento de instrução que está ajustado para a arquitetura especificada. Ele também pode reduzir o tamanho do código gerado porque o compilador pode ter a capacidade de não emitir instruções que verificam se um recurso específico é suportado pelo processador. Isso conduz a uma taxa melhorada de acertos do cache de instrução e melhor desempenho. O padrão é /favor:blend, o que resulta em código com bom desempenho em processadores x86 e x64 da Intel Corp. e AMD.

Conclusão

Discuti duas importantes otimizações realizadas pelo compilador Visual C++: alocação de registro e agendamento de instrução.

A alocação de registro é a otimização mais importante realizada pelo compilador pois o acesso a um registro é muito mais rápido do que acessar mesmo o cache. O agendamento de instrução também é importante. No entanto, processadores recentes possuem recursos de execução dinâmica pendentes, tornando o agendamento de instrução menos significativo do que era antes. Ainda assim, o compilador pode ver todas as instruções de uma função, não importa o seu tamanho, enquanto um processador só pode ver um número limitado de instruções. Além disso, o hardware de execução fora da ordem é bastante faminto por potência porque ele está sempre trabalhando desde que o núcleo está funcionando. Além disso, os processadores x86 e x64 implementam um modelo de memória que é mais forte que o modelo de memória do C11/C++ 11 e impede determinadas reordenações de instruções que podem melhorar o desempenho. Assim, ainda é extremamente importante para dispositivos de consumo de energia limitado o agendamento de instruções baseadas em compilador.

Várias palavras-chave e opções de compilador podem afetar o desempenho de forma negativa ou positiva, dessa maneira, certifique-se de usá-los adequadamente para garantir que seu código é executado mais rápido possível e produza resultados corretos. Ainda há muito mais para falar sobre otimizações — fique ligado!


Hadi Brais é um acadêmico com Ph.D. no Instituto Indiano de Tecnologia de Deli (IITD), pesquisando otimizações de compilador para a tecnologia de memória da 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 pela revisão deste artigo: Jim Hogg (equipe do Microsoft Visual C++)