Para os casos apresentados neste post, a vetorização melhorou o desempenho por um fator de 3 a 12.

 

Introdução

 

Muitos desenvolvedores escrevem softwares sensíveis ao desempenho. Afinal, essa é uma das principais razões pelas quais ainda escolhemos a linguagem C ou C++ nos dias de hoje.

Todos os processadores modernos são na verdade vetores sob o capô. Ao contrário dos processadores escalares, que processam dados individualmente, os modernos processadores vetores processam matrizes unidimensionais de dados. Se você quiser maximizar o desempenho, você precisa escrever código adaptado a esses vetores.

Toda vez que você escreve float s = a + b; você está deixando um monte de desempenho sobre a mesa. O processador poderia ter adicionado quatro números flutuantes a outros quatro números, ou até oito números a outros oito números se esse processador suportaSSE AVX. Da mesma forma, quando você escreve int i = j + k; para adicionar 2 números inteiros, você poderia ter adicionado quatro ou oito números em vez disso, com instruções SSE2 ou AVX2 correspondentes.

Designers de idiomas, desenvolvedores de compiladores e outras pessoas inteligentes têm tentado por muitos anos compilar código escalar em instruções vetoriais de uma maneira que aproveitaria o potencial de desempenho. Até agora, nenhum deles conseguiu completamente, e não estou convencido de que seja possível.

Uma abordagem para alavancar o hardware vetorial são os intrínsecos SIMD, disponíveis em todos os compiladores C ou C++modernos. SIMD significa “Instrução única, múltiplos dados”. As instruções simd estão disponíveis em muitas plataformas, há uma grande chance de seu smartphone ter também, através da extensão de arquitetura ARM NEON. Este artigo se concentra em PCs e servidores rodando em processadores AMD64 modernos.

Mesmo com o foco na plataforma AMD64, o tema é muito amplo para um único post no blog. As instruções modernas do SIMD foram introduzidas aos processadores Pentium com o lançamento do Pentium 3 em 1999 (esse conjunto de instruções é SSE, hoje em dia é às vezes chamado de SSE 1), mais deles foram adicionados desde então. Para uma introdução mais aprofundada, você pode ler meu outro artigo sobre o assunto. Ao contrário deste post no blog, que não se tem problemas práticos nem benchmarks, em vez disso, tenta fornecer uma visão geral do que está disponível.

 

O que são intrínsecos?

 

Para um programador, os intrínsecos se parecem com funções regulares da biblioteca; você inclui o cabeçalho relevante, e você pode usar o intrínseco. Para adicionar quatro números flutuantes a outros quatro números, use o _mm_add_ps intrínseco em seu código. No cabeçalho fornecido pelo compilador declarando que intrínseco, xmmintrin.h, você encontrará esta declaração (Supondo que esteja usando o compilador VC+++. No GCC você verá algo diferente, que fornece a mesma API para um usuário.):

 

extern __m128 _mm_add_ps( __m128 _A, __m128 _B );

 

Mas ao contrário das funções da biblioteca, os intrínsecos são implementados diretamente em compiladores. O _mm_add_ps SSE intrínseco tipicamente1 compila em uma única instrução, addps. Pelo tempo que a CPU leva para chamar uma função de biblioteca, ela pode ter completado uma dúzia dessas instruções.

1(Essa instrução pode buscar um dos argumentos da memória, mas não ambos. Se você chamá-lo de uma forma para que o compilador tenha que carregar ambos os argumentos da memória, como este __m128 sum = _mm_add_ps( *p1, *p2 ); o compilador emitirá duas instruções: a primeira a carregar um argumento da memória em um registro, a segunda a adicionar os quatro valores.)

O __m128 tipo de dados incorporado é um vetor de quatro números de pontos flutuantes; 32 bits cada, 128 bits no total. As CPUs possuem registros amplos para esse tipo de dados, 128 bits por registro. Desde que o AVX foi introduzido em 2011, nos atuais processadores de PC esses registros têm 256 bits de largura, cada um deles pode caber oito valores de flutuação, quatro valores flutuantes de dupla precisão ou um grande número de inteiros, dependendo do seu tamanho.

O código fonte que contém quantidades suficientes de intrínsecas vetoriais ou incorpora seus equivalentes de montagem é chamado de código vetorial manualmente. Compiladores e bibliotecas modernos já implementam um monte de coisas com eles usando intrínsecos, montagem ou uma combinação dos dois. Por exemplo, algumas implementações das rotinas de biblioteca memset, memcpyou memmove padrão C usam instruções SSE2 para melhor throughput. No entanto, fora de áreas de nicho como computação de alto desempenho, desenvolvimento de jogos ou desenvolvimento de compiladores, mesmo programadores C e C++ muito experientes não estão familiarizados com os intrínsecos SIMD.

Para ajudar a demonstrar, vou apresentar três problemas práticos e discutir como o SIMD ajudou.

 

Processamento de imagem: escala de cinza

 

Suponha que precisamos escrever uma função que converta a imagem RGB em escala de cinza. Alguém fez essa mesma pergunta recentemente..

Muitas aplicações práticas precisam de código como este. Por exemplo, quando você compacta dados de imagem bruta para JPEG ou dados de vídeo para H.264 ou H.265, o primeiro passo da compressão é bastante semelhante. Especificamente, os compressores convertem pixels RGB em espaço de cor YUV. O espaço de cores exato é definido nas especificações desses formatos — para vídeo, muitas vezes é ITU-R BT.709 nos dias de hoje Ver a seção 3, “Formato de sinal” dessa especificação.

 

Comparação de desempenho

 

Implementei algumas versões, vetoriamente vetoriamente e não as testei com imagens aleatórias. Mydesktop tem um AMD Ryzen 5 3600 conectado, meu laptop tem um Intel i3-6157U soldado. A coluna WSL tem resultados do mesmo desktop, mas para um binário Linux construído com GCC 7.4. As três colunas mais à direita da tabela contêm tempo em milissegundos (melhor de cinco corridas), para uma imagem de 3840×2160 pixels.