Setembro de 2015

Volume 30 - Número 9

Otimizações do compilador: Como simplificar o código usando Otimização Guiada por Perfil nativa

Hadi Brais | Setembro de 2015

Geralmente um compilador pode tomar decisões de otimização equivocadas que não melhoram o desempenho do código ou, ainda pior, que o prejudicam na execução. As otimizações discutidas nos dois primeiros artigos são essenciais para o desempenho do aplicativo.

Este artigo aborda uma técnica importante chamada otimização guiada por perfil (PGO), que pode ajudar o back-end do compilador a otimizar o código com mais eficiência. Resultados experimentais mostram aprimoramentos de desempenho na ordem de 5% a 35%. Além disso, quando usada com cuidado, essa técnica nunca prejudica o desempenho do código.

Este artigo se baseia nas duas primeiras partes (msdn.microsoft.com/magazine/dn904673 e msdn.microsoft.com/magazine/dn973015). Se você não estiver familiarizado com o conceito de PGO, é recomendável ler primeiro a postagem do Blog da Equipe do Visual C++ em bit.ly/1fJn1DI.

Apresentando a PGO

Uma das otimizações mais importantes que um compilador executa é a função inlining. Por padrão, o compilador do Visual C++ embute uma função desde que o tamanho do chamador não fique muito grande. Muitas chamadas de função são expandidas, no entanto, então isso só é útil quando a chamada ocorre com frequência. Caso contrário, ele simplesmente aumenta o tamanho do código, desperdiçando espaço de instrução e caches unificados e aumentando o conjunto de trabalho do aplicativo. E como o compilador poderia saber se a chamada ocorre com frequência? Ele acaba dependendo dos argumentos passados para a função.

A maioria das otimizações não tem a heurística confiável necessária para se tomar boas decisões. Já vi muitos casos de alocação incorreta do registro, que leva a uma degradação significativa de desempenho. Ao compilar o código, tudo o que você pode fazer é esperar que os aprimoramentos de desempenho e as degradações de todas as otimizações gerem um resultado positivo em termos de velocidade. Quase sempre é assim, mas o processo pode gerar um executável grande demais.

Seria bom eliminar tais degradações. Se você puder dizer ao compilador como o código vai se comportar em tempo de execução, ele poderá otimizar melhor o código. O processo de gravação de informações sobre o comportamento de um programa em tempo de execução é chamado de criação de perfil, e as informações geradas, de perfil. Você pode fornecer um ou mais perfis para o compilador, que irá usá-los como guias nas otimizações. É disso que trata a técnica PGO.

Você pode usar a PGO em código nativo ou gerenciado. As ferramentas, no entanto, são diferentes, por isso vou discutir aqui apenas a PGO nativa, deixando a gerenciada para outro artigo. O restante desta seção discute como aplicar a PGO em um aplicativo.

A PGO é uma técnica excelente. No entanto, como todo o resto, ela tem uma desvantagem. A PGO demanda tempo (dependendo do tamanho do aplicativo) e esforço. Felizmente, como veremos depois, a Microsoft fornece ferramentas que podem reduzir significativamente o tempo de execução de PGO em um aplicativo. Existem três etapas de execução de PGO em um aplicativo — compilação de instrumentação, treinamento e compilação de PGO.

Compilação de instrumentação

Há várias maneiras de criar o perfil de um programa em execução. O compilador do Visual C++ usa instrumentação binária estática, que gera perfis mais precisos, porém leva mais tempo. Usando a instrumentação, o compilador insere um pequeno número de instruções de máquina em locais de interesse em todas as funções do seu código, conforme mostrado na Figura 1. Essas instruções gravam quando a parte associada do seu código é executada e incluem essas informações no perfil gerado.

Compilação de instrumentação de um aplicativo de Otimização Guiada por Perfil
Figura 1 Compilação de instrumentação de um aplicativo de Otimização Guiada por Perfil

Há várias etapas para a criação de uma versão instrumentada de um aplicativo. Primeiro, você precisa compilar todos os arquivos de código fonte com a opção /GL para habilitar a Otimização de Programa Inteiro (WPO). A WPO é necessária para instrumentar o programa (não é uma exigência técnica, mas ajuda a tornar o perfil gerado muito mais útil). Somente os arquivos compilados com /GL serão instrumentados.

Para a próxima etapa funcionar da melhor maneira possível, evite usar quaisquer opções de compilador que resultem em código extra. Desative, por exemplo, a função inlining (/Ob0). Desabilite também as verificações de segurança (/GS-) e remova as verificações de tempo de execução (no /RTC). Isso significa que você não deve usar os modos padrão de Versão e Depuração do Visual Studio. Arquivos não compilados com /GL devem ser otimizados para velocidade (/O2). Para código instrumentado, especifique pelo menos /Og.

Em seguida, vincule os arquivos de objeto gerados e as bibliotecas estáticas necessárias com a opção /LTCG:PGI. Isso faz com que o vinculador desempenhe três tarefas. Ele diz ao back-end do compilador para instrumentar o código e gerar um arquivo de banco de dados de PGO (PGD). Isso será usado na terceira etapa para armazenar todos os perfis. Neste ponto, o arquivo PGD não contém perfis. Ele tem apenas informações para identificar os arquivos de objeto usados para detectar se foram alterados no momento em que o arquivo PGD foi usado. Por padrão, o nome do arquivo PGD usa o nome do executável. Você também pode especificar um nome de arquivo PGD usando a opção de vinculador /PGD. A terceira tarefa é vincular a biblioteca de importação pgort.lib. A saída executável depende do DLL de tempo de execução de PGO pgortXXX.dll, em que XXX é a versão do Visual Studio.

O resultado final dessa etapa é um arquivo executável (EXE ou DLL) inflado com código de instrumentação e um arquivo PGD vazio a ser preenchido e usado na terceira etapa. Você só pode ter uma biblioteca estática instrumentada se essa biblioteca estiver vinculada a um projeto a ser instrumentados. Além disso, a mesma versão do compilador deve produzir todos os arquivos OBJ CIL; caso contrário, o vinculador emitirá um erro.

Sondas de criação de perfil

Antes de passar para a próxima etapa, eu gostaria de discutir o código que o compilador insere para analisar o código. Isso permite estimar a sobrecarga adicionada ao programa e entender as informações que estão sendo coletadas em tempo de execução.

Para gravar um perfil, o compilador insere determinada quantidade de sondas em cada função compilada com /GL. Uma sondo é uma pequena sequência de instruções (duas a quatro) que consiste em uma série de instruções push e uma instrução de chamada para um manipulador de investigação no final. Quando necessário, uma sonda é encapsulada por duas chamadas de função para salvar e restaurar todos os registros de XMM. Existem três tipos de sonda:

  • Sondas de contagem: É o tipo mais comum de sonda. Ela conta o número de vezes que um bloco de código é executado ao incrementar um contador sempre que ocorre uma execução. Também é a melhor sonda em termos de tamanho e velocidade. Cada contador tem 8 bytes de tamanho em x64 e 4 bytes em x86.
  • Sondas de entrada: O compilador insere uma sonda de entrada no início de cada função. A finalidade dessa sonda é dizer a outras na mesma função para usar contadores associados a ela. Isso é necessário porque os manipuladores de sonda são compartilhados entre sondas em funções distintas. A sonda de entrada da função principal inicializa o tempo de execução da PGO. Uma sonda de entrada também é uma sonda de contagem. Esta é a sonda mais lenta.
  • Sondas de valor: São inseridas antes de cada chamada de função virtual e instrução switch e usadas para registrar um histograma dos valores. Uma sonda de valor também é uma sonda de contagem porque conta o número de vezes que cada valor é exibido. Esta á a maior sonda em tamanho.

Uma função não será instrumentada por qualquer sonda se tiver apenas um bloco básico (uma sequência de instruções com uma entrada e saída). Na verdade, ela pode ser embutida apesar da opção /Ob0. Além da sonda de valor, cada instrução switch faz com que o compilador crie uma seção COMDAT constante para descrevê-la. O tamanho desta seção é aproximadamente igual ao número de casos multiplicado pelo tamanho da variável que controla o comutador.

Cada sonda termina com uma chamada para o manipulador de sondas. A sonda de entrada da função principal cria um vetor de (8 bytes em x64 e 4 bytes em x86) ponteiros de manipulador de sonda em que cada entrada aponta para um manipulador de sonda diferente. Na maioria dos casos, haverá apenas alguns manipuladores de sonda. A sondas são inseridas em cada função nos seguintes locais:

  • Uma sonda de entrada na entrada da função
  • Uma sonda de contagem em cada bloco básico terminando em uma chamada ou instrução ret
  • Uma sonda de valor logo antes de cada instrução switch
  • Uma sonda de valor logo antes de cada chamada de função virtual

Assim, a quantidade de sobrecarga de memória do programa instrumentado é determinada pela quantidade de sondas, de casos em todas as instruções switch, de instruções switch e de chamadas de função virtual.

Todos os manipuladores de sonda avançam o contador uma vez, em algum momento, para registrar a execução do bloco de código correspondente. O compilador usa a instrução ADD para incrementar um contador de 4 bytes por um e, no x64, a instrução ADC para adicionar a carga ao máximo de 4 bytes do contador. Essas instruções não são thread-safe. Isso significa que, por padrão, nenhuma sonda é thread-safe. Se pelo menos uma das funções puder ser executada por mais de um thread ao mesmo tempo, os resultados não serão confiáveis. Nesse caso, você pode usar a opção de vinculador /pogosafemode. Isso faz com que o compilador prefixe as instruções com LOCK, tornando todas as sondas thread-safe. Obviamente, isso as torna mais lentas, também. Infelizmente, não é possível aplicar este recurso de maneira seletiva.

Se seu aplicativo consiste em mais de um projeto cuja saída é um executável ou um arquivo DLL para PGO, será preciso repetir o processo para cada um deles.

A etapa de treinamento

Após essa etapa, você terá uma versão instrumentada do executável e um arquivo PGD. A segunda etapa é o treinamento, em que o executável irá gerar um ou mais perfis a ser armazenados em um arquivo de contagem de PGO (PGC) separado. Você usará esses arquivos na terceira etapa para otimizar o código.

Esta é a fase mais importante, porque o a precisão do perfil é crucial para o êxito de todo o processo. Para ser útil, um perfil deve refletir um cenário de uso comum do programa. O compilador otimizará o programa supondo que os cenários exercitados são comuns. Se não era o caso, o programa pode ter uma execução ainda pior em campo. Um perfil gerado a partir de um cenário de uso comum ajuda o compilador a conhecer os afunilamentos para otimizar a velocidade e os caminhos sem interesse para otimizar o tamanho, conforme mostrado na Figura 2.

Etapa de treinamento na criação de um aplicativo de PGO
Figura 2 Etapa de treinamento na criação de um aplicativo de PGO

A complexidade dessa fase depende do número de cenários de uso e da natureza do programa. O treinamento é fácil se o programa não exigir qualquer entrada do usuário. Se houver muitos cenários de uso, gerar perfis em sequência para cada cenário talvez não seja a maneira mais rápida.

No cenário de treinamento complexo mostrado na Figura 2, pgosweep.exe é uma ferramenta de linha de comando que permite controlar o conteúdo do perfil mantido pelo tempo de execução de PGO, quando executado. É possível pode gerar mais de uma instância do programa e aplicar cenários de uso simultaneamente.

Imagine que você tenha duas instâncias em execução nos processos X e Y. Quando um cenário está prestes a iniciar o processo X, chame pgosweep e passe para ele a ID do processo e a opção /onlyzero. Isso faz com que o tempo de execução de PGO limpe a parte do perfil em memória apenas para este processo. Se a ID do processo não for especificada, todo o perfil de PGC será limpo. O cenário pode então ser iniciado. O cenário de uso 2 pode ser iniciado de maneira semelhante no processo Y.

O arquivo de PCG só será gerado quando todas as instâncias em execução do programa terminarem. No entanto, se o programa tiver um tempo de inicialização longo e você não quiser executá-lo para todos os cenários, é possível forçar o tempo de execução para gerar um perfil e limpar o perfil em memória para prepará-lo para outro cenário na mesma execução. Faça isso executando pgosweep.exe e passando a ID do processo, o nome do arquivo executável e o nome do arquivo PGC.

Por padrão, o arquivo PGC será gerado no mesmo diretório do executável. É possível alterar esse padrão com a variável de ambiente VCPROFILE_PATH, que precisa ser definida antes que a primeira instância do programa seja executada.

Discutimos aqui a sobrecarga de dados e instruções ao instrumentar código. Na maioria dos casos, essa sobrecarga pode ser acomodada. Por padrão, o consumo de memória do tempo de execução de PGO não excede determinado limite. Caso seja necessário usar mais memória, ocorrerá um erro. Neste caso, você pode usar a variável de ambiente VCPROFILE_ALLOC_SCALE para aumentar esse limite.

Compilação de PGO

Depois de exercitar todos os cenários de uso comum, você terá um conjunto de arquivos PGC que podem ser usados para criar a versão otimizada do programa. Você pode descartar qualquer arquivo PGC que não quiser usar.

A primeira etapa na compilação da versão de PGO é mesclar todos os arquivos PGC com uma ferramenta de linha de comando chamada pgomgr.exe. Você também pode usá-la para editar um arquivo PGD. Para mesclar os dois arquivos PGC no arquivo PGD gerado na primeira etapa, execute pgomgr e passe a opção /merge e o nome do arquivo PGD. Isso vai mesclar no diretório atual todos os arquivos PGC cujos nomes correspondam ao nome do arquivo PGD especificado, seguido por ! # e por um número. O compilador e o vinculador podem usar o arquivo PGD resultante para otimizar o código.

É possível capturar um cenário de uso mais comum ou importante com a ferramenta pgomgr. Para fazer isso, passe o nome do arquivo PGC correspondente e a opção /merge:n, em que n é um inteiro positivo que indica o número de cópias do arquivo PGC para incluir no arquivo PGD. Por padrão, n é 1. Essa multiplicidade faz com que um perfil específico influencie as otimizações a seu favor.

A segunda etapa é executar o vinculador passando o mesmo conjunto de arquivos de objeto da etapa 1. Desta vez, use a opção /LTCG:PGO. O vinculador procura um arquivo PGD com o mesmo nome que o executável no diretório atual. Ele garantirá que os arquivos CIL OBJ não mudaram desde a geração do arquivo PGD na etapa 1 e, em seguida, vai passá-los para o compilador, que vai usar e otimizar o código. Esse processo é mostrado na Figura 3. Você pode usar a opção de vinculador /PGD para especificar um arquivo PGD. Não se esqueça de habilitar a função inlining para esta etapa.

Compilação de PGO - Etapa 3
Figure 3 Compilação de PGO - Etapa 3

A maioria das otimizações de compilador e vinculador será guiada por perfil. O resultado final desta etapa é um executável altamente otimizado em termos de tamanho e velocidade. Neste momento, é aconselhável medir os ganhos de desempenho.

Manter a base de código

Se você fizer alterações em qualquer arquivo de entrada passado para o vinculador com a opção /LTCG:PGI, este se recusará a usar o arquivo PGD quando /LTCG:PGO for especificado. Isso ocorre porque essas alterações podem afetar significativamente a utilidade do arquivo PGD.

Uma opção é repetir as três etapas discutidas anteriormente para gerar outro PGD compatível. No entanto, se as alterações forem pequenas (tais como adicionar um pequeno número de funções, chamar uma função com maior ou menor frequência, ou adicionar um recurso que não seja comumente usado), não seria prático repetir todo o processo. Nesse caso, você pode usar a opção /LTCG:PGU em vez de /LTCG:PGO. Isso informa o vinculador para ignorar as verificações de compatibilidade relacionadas ao arquivo PGD.

Essas pequenas alterações se acumularão ao longo do tempo. Você acabará chegando a um ponto em que será útil instrumentar o aplicativo novamente. Para determinar quando esse ponto foi atingido, observe a saída do compilador quando a PGO estiver compilando o código. A saída mostrará a extensão da cobertura de PGD em relação à base de código. Se a cobertura do perfil cair para menos de 80% (como mostrado na Figura 4), é recomendável instrumentar o código novamente. Essa porcentagem, no entanto, é altamente dependente da natureza do aplicativo.

PGO em ação

A PGO orienta as otimizações empregadas pelo compilador e pelo vinculador. Vou usar o simulador NBody para demonstrar alguns benefícios desse tipo de otimização. Você pode baixar o aplicativo em bit.ly/1gpEaCY. Você também precisará também baixar e instalar o SDK do DirectX em bit.ly/1LQnKge para compilar o aplicativo.

Primeiro, vou compilar o aplicativo no modo de Versão para compará-lo com a versão de PGO. Para criar a versão de PGO do aplicativo, você pode usar o item Otimização Guiada por Perfil do menu do Visual Studio.

Você também pode habilitar a saída do assembler usando a opção de compilador /FA[c] (não use /FA[c]s para esta demonstração). Para esse aplicativo simples, basta treinar o aplicativo instrumentado uma vez para gerar um arquivo PGC e usá-lo para otimizar o aplicativo. Fazendo isso, você terá dois executáveis: um otimizado às cegas e outra PGO. Confira se você consegue acessar o arquivo PGD final, porque você precisará dele mais tarde.

Agora, se executar os dois executáveis um após o outro e comparar as contagens GFLOP obtidas, você perceberá que eles tiveram desempenho semelhante. Aparentemente, aplicar a PGO no aplicativo foi perda de tempo. Investigando mais, descobre-se que o tamanho do aplicativo foi reduzido de 531KB (para o aplicativo cegamente otimizado) para 472KB (para o aplicativo com base em PGO), ou 11%. Portanto, quando você aplica a PGO neste aplicativo, o que se vê é uma redução de tamanho sem perda de desempenho. Como isso aconteceu?

Considere a função de 200 linhas DXUTParseCommandLine do arquivo DXUT/Core/DXUT.CPP. Ao examinar o código assembly da compilação da Versão, você pode ver que o tamanho do código binário é de aproximadamente 2700 bytes. Por outro lado, o tamanho do código binário da compilação de PGO é de no máximo 1650 bytes. Você pode ver o motivo dessa diferença na instrução de montagem que verifica a condição do loop a seguir:

for( int iArg = iArgStart; iArg < nNumArgs; iArg++ ) { ... }

A compilação otimizada às cegas gerou o seguinte código:

0x044 jge block1
; Fall-through code executed when iArg < nNumArgs
; Lots of code in between
0x362 block1:
; iArg >= nNumArgs
; Lots of other code

Por outro lado, a compilação de PGO gerou o seguinte código:

0x043 jl   block1
; taken 0(0%), not-taken 1(100%)
block2:
; Fall-through code executed when iArg >= nNumArgs
0x05f ret  0
; Scenario dead code below
0x000 block1:
; Lots of other code executed when iArg < nNumArgs

Muitos usuários preferem especificar os parâmetros na GUI, em vez de passá-los na linha de comando. Dessa forma, o cenário comum, conforme indicado pelas informações de perfil, é o loop nunca iterar. Sem um perfil, seria impossível para o compilador saber disso. Sendo assim, ele segue em frente e otimiza o código agressivamente dentro do loop. Ele expande muitas funções, o que resulta em um inchaço de código sem sentido. Na compilação de PGO, o perfil fornecido ao compilador diz que o loop nunca foi executado. A partir disso, o compilador entende que não faz sentido embutir qualquer função chamada do corpo do loop.

Outra diferença interessante pode ser vista em trechos de código do assembly. No executável otimizado às cegas, a ramificação que raramente é executada está no caminho errado da instrução condicional. A ramificação que quase sempre é executada está localizada a 800 bytes de distância da instrução condicional. Isso não só faz com que o indicador de ramificação do processador falhe, mas também gera uma perda no cache da instrução.

A compilação de PGO evita ambos os problemas, trocando os locais das ramificações. O que a compilação fez foi mover a ramificação que raramente é executada para uma seção separada do executável, melhorando a localidade do conjunto de trabalho. Essa otimização é chamada de separação de código morto. Seria impossível executá-la sem um perfil. Funções raramente chamadas, tais como pequenas diferenças no código binário, podem causar uma diferença significativa de desempenho.

Ao compilar o código de PGO, o compilador mostrará quantas funções foram compiladas para melhorar a velocidade de todas as funções instrumentadas. O compilador também mostra isso na janela de saída do Visual Studio. Normalmente, não mais do que 10% das funções serão compiladas para aumentar a velocidade (inlining agressivo), enquanto o restante é compilado para melhorar o tamanho (inlining parcial ou nenhum).

Considere uma função ligeiramente mais interessante, DXUTStatic­WndProc, definida no mesmo arquivo. As estrutura de controle das funções é mais ou menos assim:

if (condition1) { /* Lots of code */ }
if (condition2) { /* Lots of code */ }
if (condition3) { /* Lots of code */ }
switch (variable) { /* Many cases with lots of code in each */ }
if-else statement
return

O código otimizado às cegas emite cada bloco de código na mesma ordem do código-fonte. No entanto, o código na compilação de PGO foi inteligentemente reorganizado usando a frequência de execução de cada bloco e a hora em que cada bloco foi executado. As duas primeiras condições raramente foram executadas. Assim, para melhorar o cache e a utilização de memória, os blocos de código correspondente estão agora em uma seção separada. Além disso, as funções que reconhecidamente caíram no afunilamento (tais como DXUTIsWindowed) agora estão em linha:

if (condition1) { goto dead-code-section }
if (condition2) { goto dead-code-section }
if (condition3) { /* Lots of code */ }
{/* Frequently executed cases pulled outside the switch statement */}
if-else statement
return
switch(variable) { /* The rest of cases */ }

A maioria das otimizações se beneficia de um perfil confiável e outras puderam ser executadas. Se não resultar em uma melhoria notável no desempenho, a estão certamente reduzirá o tamanho dos executáveis gerados, diminuindo a sobrecarga no sistema de memória.

Bancos de dados de PGO

Os benefícios do perfil PGD vão muito além de guiar as otimizações do compilador. Embora você possa usar o pgomgr.exe para mesclar vários arquivos PGC, ele também serve a uma finalidade diferente. Ele oferece três opções que permitem exibir o conteúdo do arquivo PGD para entender a fundo o comportamento do seu código em relação os cenários exercitados. A primeira opção /summary diz à ferramenta para emitir um resumo em texto do conteúdo do arquivo PGD. A segunda opção /detail, usada em conjunto com a primeira, diz à ferramenta para emitir uma descrição textual detalhada do perfil. A opção /unique diz à ferramenta para tirar a decoração dos nomes de função (particularmente útil para bases de código C++).

Controle programático

Há um último recurso que vale a pena mencionar. O arquivo de cabeçalho pgobootrun.h declara uma função chamada PgoAutoSweep. Você pode chamar essa função para gerar um arquivo PGC e limpar o perfil em memória de forma programática, preparando tudo para o próximo arquivo PGC. A função usa um argumento do tipo char*, que se refere ao nome do arquivo PGC. Para usar essa função, é preciso vincular a biblioteca estática pgobootrun.lib. Atualmente, este é o único suporte programático relacionado a PGO.

Conclusão

A PGO é uma técnica de otimização que ajuda o compilador e vinculador a tomar decisões de otimização melhores ao se referirem a um perfil confiável sempre que for preciso encontrar uma solução de compensação entre tamanho e velocidade. O Visual Studio fornece acesso visual a essa técnica por meio do menu Compilar ou do menu de contexto do projeto.

No entanto, você pode obter um conjunto de recursos ainda mais completo usando o plug-in de PGO, que pode ser baixado em bit.ly/1Ntg4Be. Esse plug-in também está bem documentado em bit.ly/1RLjPDi. Lembre-se do limite de cobertura da Figura 4, a maneira mais fácil de fazer é com o plug-in, conforme descrito na documentação. No entanto, se você preferir usar as ferramentas de linha de comando, consulte o artigo em bit.ly/1QYT5nO para ver vários exemplos. Se você tiver uma base de código nativa, vale a pena experimentar essa técnica. Se for o caso, fique à vontade para me informar como o tamanho e a velocidade do seu aplicativo foram afetados.

Ciclo de manutenção de base de código de PGO
Figure 4 Ciclo de manutenção de base de código de PGO

Recursos adicionais

Para obter mais informações sobre bancos de dados de otimização guiada por perfil, confira a postagem no blog de Hadi Brais em bit.ly/1KBcffQ.


Hadi Brais é acadêmico com Ph.D. do Instituto Indiano de Tecnologia de Déli (IITD) e pesquisa otimizações de compilador para a próxima geração de tecnologia de memória. Ele passa a maior parte do tempo escrevendo código em C/C++/C# e se aprofundando em tempos de execução e frameworks de compilador. 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: Ankit Asthana