Desbloquear o desempenho máximo do seu código C++ pode ser assustador, exigindo perfis meticulosos, ajustes intrincados de acesso à memória e otimização de cache. Existe um truque para simplificar um pouco isso? Felizmente, existe um atalho para obter ganhos notáveis de desempenho com o mínimo de esforço — desde que você tenha os insights certos e saiba o que está fazendo. Insira otimizações do compilador que podem elevar significativamente o desempenho do seu código.
Os compiladores modernos servem como aliados indispensáveis nesta jornada em direção ao desempenho ideal, especialmente na paralelização automática. Essas ferramentas sofisticadas possuem a capacidade de examinar padrões de código intrincados, especialmente em loops, e executar otimizações perfeitamente.
Este artigo tem como objetivo destacar o poder das otimizações de compiladores, com foco nos compiladores Intel C++ — conhecidos por sua popularidade e uso generalizado.
Nesta história, desvendamos as camadas da magia do compilador que podem transformar seu código em uma obra-prima de alto desempenho, exigindo menos intervenção manual do que você imagina.
Destaques: O que são otimizações do compilador? | -Ligado | Arquitetura direcionada | Otimização Interprocedural | -fno-aliasing | Relatórios de otimização do compilador
As otimizações do compilador abrangem várias técnicas e transformações que um compilador aplica ao código-fonte durante a compilação. Mas por que? Para melhorar o desempenho, a eficiência e, em alguns casos, o tamanho do código de máquina resultante. Essas otimizações são fundamentais para influenciar vários aspectos da execução do código, incluindo velocidade, uso de memória e consumo de energia.
Qualquer compilador executa uma série de etapas para converter o código-fonte de alto nível em código de máquina de baixo nível. Estes envolvem análise lexical, análise de sintaxe, análise semântica, geração de código intermediário (ou IR), otimização e geração de código.
Durante a fase de otimização, o compilador busca meticulosamente maneiras de transformar um programa, visando uma saída semanticamente equivalente que utilize menos recursos ou execute mais rapidamente. As técnicas empregadas neste processo abrangem, mas não estão limitadas a dobramento constante, otimização de loop, inlining de função e eliminação de código morto .
Não vou discutir todas as opções disponíveis, mas como podemos instruir o compilador a fazer otimizações específicas que possam melhorar o desempenho do código. Então, a solução???? Sinalizadores do compilador.
Os desenvolvedores podem especificar um conjunto de sinalizadores do compilador durante o processo de compilação, uma prática familiar para aqueles que usam opções como “ -g” ou “-pg” com GCC para depuração e criação de perfil de informações. À medida que avançamos, discutiremos sinalizadores de compilador semelhantes que podemos usar ao compilar nosso aplicativo com o compilador Intel C++. Isso pode ajudá-lo a melhorar a eficiência e o desempenho do seu código.
Não vou me aprofundar na teoria seca nem inundá-lo com documentação tediosa listando todos os sinalizadores do compilador. Em vez disso, vamos tentar entender por que e como esses sinalizadores funcionam.
Como podemos fazer isso???
Pegaremos uma função C++ não otimizada responsável por calcular uma iteração de Jacobi e, passo a passo, desvendaremos o impacto de cada sinalizador do compilador. Ao longo desta exploração, mediremos a aceleração comparando sistematicamente cada iteração com a versão base — começando sem sinalizadores de otimização (-O0).
As acelerações (ou tempo de execução) foram medidas em uma máquina com processador Intel® Xeon® Platinum 8174 . Aqui, o método Jacobi resolve uma equação diferencial parcial 2D (equação de Poisson) para modelar a distribuição de calor em uma grade retangular.
você(x,y,t) é a temperatura no ponto (x,y) no tempo t.
Resolvemos o estado estável quando a distribuição não muda mais:
Um conjunto de condições de contorno de Dirichlet foi aplicado na fronteira.
Basicamente, temos uma codificação C++ executando as iterações de Jacobi em grades de tamanhos variáveis (que chamamos de resoluções). Basicamente, um tamanho de grade de 500 significa resolver uma matriz de tamanho 500x500 e assim por diante.
A função para realizar uma iteração de Jacobi é a seguinte:
/* * One Jacobi iteration step */ void jacobi(double *u, double *unew, unsigned sizex, unsigned sizey) { int i, j; for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { unew[i * sizex + j] = 0.25 * (u[i * sizex + (j - 1)] + // left u[i * sizex + (j + 1)] + // right u[(i - 1) * sizex + j] + // top u[(i + 1) * sizex + j]); // bottom } } for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { u[i * sizex + j] = unew[i * sizex + j]; } } }
Continuamos realizando a iteração de Jacobi até que o resíduo atinja um valor limite (dentro de um loop). O cálculo residual e a avaliação do limite são feitos fora desta função e não são de interesse aqui. Então, vamos falar sobre o elefante na sala agora!
Sem otimizações (-O0), obtemos os seguintes resultados:
Aqui, medimos o desempenho em termos de MFLOP/s. Esta será a base da nossa comparação.
MFLOP/s significa “Milhões de operações de ponto flutuante por segundo”. É uma unidade de medida usada para quantificar o desempenho de um computador ou processador em termos de operações de ponto flutuante. As operações de ponto flutuante envolvem cálculos matemáticos com números decimais ou reais representados em formato de ponto flutuante.
MFLOP/s é frequentemente usado como referência ou métrica de desempenho, especialmente em aplicações científicas e de engenharia onde prevalecem cálculos matemáticos complexos. Quanto maior o valor MFLOP/s, mais rápido o sistema ou processador executa operações de ponto flutuante.
Nota 1: Para fornecer um resultado estável, executo o executável 5 vezes para cada resolução e pego o valor médio dos valores MFLOP/s.
Nota 2: É importante observar que a otimização padrão no compilador Intel C++ é -O2. Portanto, é importante especificar -O0 ao compilar o código-fonte.
Vamos ver como esses tempos de execução variam à medida que tentamos diferentes sinalizadores do compilador!
Esses são alguns dos sinalizadores de compilador mais comumente usados quando se começa com otimizações do compilador. Em um caso ideal, o desempenho de Ofast > O3 > O2 > O1 > O0 . No entanto, isso não acontece necessariamente. Os pontos críticos dessas opções são os seguintes:
-O1:
-O2:
-O3:
-Orápido:
O guia oficial fala detalhadamente sobre quais otimizações essas opções oferecem.
Ao usar essas opções em nosso código Jacobi, obtemos estes tempos de execução:
É claramente evidente que todas essas otimizações são muito mais rápidas que nosso código base (com “-O0”). O tempo de execução é 2–3x menor que o caso base. E quanto aos MFLOP/s??
Bem, isso é alguma coisa!!!
Há uma grande diferença entre os MFLOP/s do caso base e aqueles com otimização.
No geral, embora apenas ligeiramente, “-O3” tem o melhor desempenho.
Os sinalizadores extras usados por “- Ofast ” (“ -no-prec-div -fp-model fast=2 ”) não estão proporcionando nenhuma aceleração adicional.
A arquitetura da máquina se destaca como um fator fundamental que influencia as otimizações do compilador. Pode melhorar significativamente o desempenho quando o compilador conhece os conjuntos de instruções disponíveis e as otimizações suportadas pelo hardware (como vetorização e SIMD).
Por exemplo, minha máquina Skylake possui 3 unidades SIMD: 1 unidade AVX 512 e 2 unidades AVX-2.
Posso realmente fazer algo com esse conhecimento???
A resposta está nos sinalizadores estratégicos do compilador. Experimentar opções como “ -xHost ” e, mais precisamente, “ -xCORE-AVX512 ” pode nos permitir aproveitar todo o potencial dos recursos da máquina e personalizar otimizações para desempenho ideal.
Aqui está uma rápida descrição do que são esses sinalizadores:
-xHost:
-xCORE-AVX512:
Objetivo: instruir explicitamente o compilador para gerar código que utiliza o conjunto de instruções Intel Advanced Vector Extensions 512 (AVX-512).
Principais recursos: AVX-512 é um conjunto avançado de instruções SIMD (instrução única, dados múltiplos) que oferece registros vetoriais mais amplos e operações adicionais em comparação com versões anteriores, como AVX2. Habilitar esse sinalizador permite que o compilador aproveite esses recursos avançados para otimizar o desempenho.
Considerações: A portabilidade é novamente a culpada aqui. Os binários gerados com instruções AVX-512 podem não funcionar de maneira ideal em processadores que não suportam este conjunto de instruções. Eles podem não funcionar de jeito nenhum!
As instruções do conjunto AVX-512 usam registros Zmm, que são um conjunto de registros de 512 bits de largura. Esses registradores servem de base para o processamento vetorial.
Por padrão, “ -xCORE-AVX512 ” assume que o programa provavelmente não se beneficiará do uso de registros zmm. O compilador evita usar registros zmm, a menos que seja garantido um ganho de desempenho.
Se alguém planeja usar os registradores zmm sem restrições, “ -qopt-zmm-usage ” pode ser definido como alto. É isso que faremos também.
Não se esqueça de verificar o guia oficial para obter instruções detalhadas.
Vamos ver como esses sinalizadores funcionam para nosso código:
Uau!
Agora cruzamos a marca de 1200 MFLOP/s para a menor resolução. Os valores MFLOP/s para outras resoluções também aumentaram.
A parte notável é que alcançamos esses resultados sem quaisquer intervenções manuais substanciais — simplesmente incorporando um punhado de flags do compilador durante o processo de compilação da aplicação.
Porém, é fundamental ressaltar que o executável compilado só será compatível com uma máquina que utilize o mesmo conjunto de instruções.
A compensação entre otimização versus portabilidade é evidente, pois o código otimizado para um conjunto de instruções específico pode sacrificar a portabilidade em diferentes configurações de hardware. Então, certifique-se de saber o que está fazendo!!
Nota: Não se preocupe se o seu hardware não suportar AVX-512. O compilador Intel C++ suporta otimizações para AVX, AVX-2 e até mesmo SSE. A documentação tem tudo que você precisa saber!
A Otimização Interprocedural envolve a análise e a transformação do código em múltiplas funções ou procedimentos, indo além do escopo das funções individuais.
O IPO é um processo de várias etapas que se concentra nas interações entre diferentes funções ou procedimentos dentro de um programa. O IPO pode incluir muitos tipos diferentes de otimizações, incluindo substituição direta, conversão de chamada indireta e Inlining.
O Intel Compiler oferece suporte a dois tipos comuns de IPO: compilação de arquivo único e compilação de vários arquivos (otimização de todo o programa) [ 3 ]. Existem dois sinalizadores de compilador comuns executando cada um deles:
-ipo:
Objetivo: Permite a otimização interprocedural, permitindo ao compilador analisar e otimizar todo o programa, além dos arquivos de origem individuais, durante a compilação.
Principais recursos: - Otimização de todo o programa: “ -ipo ” realiza análise e otimização em todos os arquivos de origem, considerando as interações entre funções e procedimentos em todo o programa. - Otimização entre funções e módulos cruzados: O sinalizador facilita funções inlining, sincronização de otimizações e análise de fluxo de dados em diferentes partes do programa.
Considerações: Requer uma etapa de link separada. Após compilar com “ -ipo ”, uma etapa específica do link é necessária para gerar o executável final. O compilador executa otimizações adicionais com base na visualização completa do programa durante a vinculação.
-ip:
Objetivo: permite a propagação de análise interprocedural, permitindo que o compilador execute algumas otimizações interprocedimentos sem exigir uma etapa de link separada.
Principais recursos: - Análise e propagação: “ -ip ” permite que o compilador realize pesquisas e propagação de dados em diferentes funções e módulos durante a compilação. No entanto, ele não executa todas as otimizações que exigem a visualização completa do programa.- Compilação mais rápida: Ao contrário de “ -ipo ”, “ -ip ” não necessita de uma etapa de vinculação separada, resultando em tempos de compilação mais rápidos. Isso pode ser benéfico durante o desenvolvimento, quando o feedback rápido é essencial.
Considerações: Ocorrem apenas algumas otimizações interprocedimentos limitadas, incluindo inlining de função.
-ipo geralmente fornece recursos de otimização interprocedimento mais extensos, pois envolve uma etapa de link separada, mas tem o custo de tempos de compilação mais longos. [ 4 ]
-ip é uma alternativa mais rápida que executa algumas otimizações interprocedimentos sem exigir uma etapa de link separada, tornando-o adequado para fases de desenvolvimento e teste.[ 5 ]
Como estamos falando apenas de desempenho e diferentes otimizações, tempos de compilação ou tamanho do executável não são nossa preocupação, vamos nos concentrar em “ -ipo ”.
Todas as otimizações acima dependem de quão bem você conhece seu hardware e de quanto você experimentaria. Mas isso não é tudo. Se tentarmos identificar como o compilador veria nosso código, poderemos identificar outras otimizações potenciais.
Vamos novamente dar uma olhada em nosso código:
/* * One Jacobi iteration step */ void jacobi(double *u, double *unew, unsigned sizex, unsigned sizey) { int i, j; for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { unew[i * sizex + j] = 0.25 * (u[i * sizex + (j - 1)] + // left u[i * sizex + (j + 1)] + // right u[(i - 1) * sizex + j] + // top u[(i + 1) * sizex + j]); // bottom } } for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { u[i * sizex + j] = unew[i * sizex + j]; } } }
A função jacobi() leva alguns ponteiros para dobrar como parâmetros e então faz algo dentro dos loops for aninhados. Quando qualquer compilador vê esta função no arquivo fonte, deve ter muito cuidado.
Por que??
A expressão para calcular unew usando u envolve a média de 4 valores de u vizinhos. E se você e unew apontarem para o mesmo local? Este se tornaria o problema clássico dos ponteiros com alias [ 7 ].
Os compiladores modernos são muito inteligentes e para garantir a segurança, eles assumem que o alias poderia ser possível. E para cenários como esse, evitam quaisquer otimizações que possam impactar a semântica e a saída do código.
No nosso caso, sabemos que u e unew são locais de memória diferentes e destinam-se a armazenar valores diferentes. Portanto, podemos facilmente informar ao compilador que não haverá nenhum alias aqui.
Como fazemos isso?
Existem dois métodos. A primeira é a palavra-chave “ restrict ” do C. Mas isso requer alteração do código. Não queremos isso por enquanto.
Algo simples? Vamos tentar “ -fno-alias ”.
-fno-alias:
Objetivo: Instruir o compilador a não assumir alias no programa.
Principais recursos: Supondo que não haja alias, o compilador pode otimizar o código com mais liberdade, melhorando potencialmente o desempenho.
Considerações: O desenvolvedor deve ter cuidado ao usar este sinalizador, pois no caso de qualquer alias injustificado, o programa pode fornecer resultados inesperados.
Mais detalhes podem ser encontrados na documentação oficial .
Como isso funciona para nosso código?
Bem, agora temos uma coisa!!!
Conseguimos uma aceleração notável aqui, quase 3x das otimizações anteriores. Qual é o segredo por trás desse impulso?
Ao instruir o compilador a não assumir aliasing, demos a ele a liberdade de liberar poderosas otimizações de loop.
Um exame mais detalhado do código assembly (embora não compartilhado aqui) e do relatório de otimização de compilação gerado (veja abaixo ) revela a aplicação inteligente do compilador de intercâmbio de loop e desenrolamento de loop . Estas transformações contribuem para um desempenho altamente otimizado, mostrando o impacto significativo das diretivas do compilador na eficiência do código.
É assim que todas as otimizações funcionam entre si:
O compilador Intel C++ fornece um recurso valioso que permite aos usuários gerar um relatório de otimização resumindo todos os ajustes feitos para fins de otimização [ 8 ]. Este relatório abrangente é salvo no formato de arquivo YAML, apresentando uma lista detalhada de otimizações aplicadas pelo compilador dentro do código. Para uma descrição detalhada, consulte a documentação oficial em “ -qopt-report ”.
Discutimos alguns sinalizadores de compilador que podem melhorar drasticamente o desempenho de nosso código sem que realmente façamos muito. O único pré-requisito: não faça nada cegamente; certifique-se de saber o que está fazendo!!
Existem centenas desses sinalizadores de compilador, e esta história fala sobre alguns deles. Portanto, vale a pena consultar o guia oficial do compilador do seu compilador preferido (especialmente a documentação relacionada à otimização).
Além desses sinalizadores do compilador, há um monte de técnicas como vetorização, intrínsecos SIMD, otimização guiada de perfil e paralelismo automático guiado , que podem melhorar surpreendentemente o desempenho do seu código.
Da mesma forma, os compiladores Intel C++ (e todos os mais populares) também suportam diretivas pragma, que são recursos muito interessantes. Vale a pena conferir alguns pragmas como ivdep, paralelo, simd, vetor, etc., no Intel-Specific Pragma Reference .
[1] Otimização e programação (intel.com)
[2] Computação de alto desempenho com “Elwetritsch” na Universidade de Kaiserslautern-Landau (rptu.de)
[3] Otimização interprocedural (intel.com)
[6] Intel Compiler, Optimization e outros sinalizadores para uso pelo SPEChpc
[7] Aliasing — Documentação IBM
[8] Relatórios de otimização do compilador Intel®
Foto em destaque de Igor Omilaev no Unsplash .
Também publicado aqui .