- SIMD não é uma técnica complexa reservada apenas a software de desempenho máximo, mas um meio cotidiano de otimização que acelera laços comuns ao processar dados sequenciais em vários valores por vez
- Um código SIMD típico segue uma estrutura de 5 etapas: broadcast de constantes, iteração por unidade vetorial, operação em paralelo, redução/armazenamento do resultado e tratamento da cauda escalar
- O loop de busca de codepoints do Ghostty compara 4, 8 ou 16
u32 de uma vez e, em teoria, pode aumentar a taxa de processamento em até 4x no ARM NEON, 8x no AVX2 e 16x no AVX-512
- Em um desktop Intel com AVX2, a vazão total do terminal ficou cerca de 5x mais rápida, e quando não há largura vetorial compatível ou sobra entrada, o loop escalar existente processa toda a entrada ou o restante
- A vetorização automática do compilador pode perder oportunidades mesmo em laços simples, então vale verificar primeiro a saída otimizada, mas loops quentes importantes podem manter comportamento e desempenho previsíveis com SIMD explícito
O que o SIMD faz
- SIMD permite que a CPU processe vários valores em paralelo com uma única instrução
- Em vez de comparar bytes um a um, é possível comparar 4, 8 ou mais de uma vez
- Há oportunidade de transformar laços como
for (byte in bytes), for (character in string), for (value in array) em processamento por largura vetorial
- Se os dados tiverem centenas, milhares ou milhões de bytes, é possível obter aceleração local de 4x, 8x ou mais, dependendo da largura paralela
- Se os dados tiverem apenas alguns ou algumas dezenas de valores, não vale a pena aplicar SIMD
- simdutf e simdjson usam técnicas SIMD complexas, mas o SIMD do dia a dia não precisa ser tão complexo assim
- Os exemplos usam Zig, mas a estrutura de 5 etapas também se aplica a outras linguagens, e cada linguagem difere na forma de suportar instruções SIMD
A estrutura recorrente de 5 etapas
- Fazer broadcast das constantes necessárias para todas as lanes e, se preciso, inicializar acumuladores vetoriais
- Percorrer a entrada em blocos do tamanho da largura vetorial por vez
- Executar comparações ou operações aritméticas em paralelo em todas as lanes
- Reduzir ou armazenar o resultado vetorial de acordo com o algoritmo
- Tratar o restante que não cabe em um vetor completo com o scalar tail existente
- Ao se acostumar com essa estrutura, fica possível decompor um laço comum nas mesmas 5 etapas, tornando escrever SIMD tão simples quanto escrever um loop escalar
- Se algo não puder ser expresso facilmente nessa estrutura, o mais apropriado é pular o uso de SIMD por enquanto
O loop de busca real do Ghostty
- O Ghostty consome dados de um array de codepoints decodificados até encontrar um valor menor ou igual a
0xF
- A maior parte dos dados de terminal são caracteres comuns a serem exibidos, então eles são processados em lote
- O loop encontra o fim do próximo trecho imprimível o mais rápido possível
- A implementação escalar original verifica os codepoints um por um
while (end < cps.len and cps[end] > 0xF) end += 1;
- A implementação vetorial usa vetores genéricos sem funções intrínsecas específicas de CPU, e adiciona 12 linhas de código em relação à implementação escalar
- O ganho esperado de vazão corresponde ao número de lanes do vetor
- ARM NEON e Apple Silicon: até 4x
- AVX2, suportado pela maioria das CPUs x86 modernas: até 8x
- AVX-512, suportado por algumas CPUs Intel e AMD Zen 4 ou superiores: até 16x
- Em um desktop Intel com AVX2, a vazão total medida desde a entrada do programa de terminal até o estado final do terminal ficou cerca de 5x mais rápida
- Não é possível obter toda a aceleração teórica por causa do trabalho ao redor do SIMD
- Caracteres de controle C0 também existem após
0xF, mas 0xF é o critério usado neste caminho de código do Ghostty
- ESC e outras sequências de controle são tratadas em outro caminho
Etapa 1: broadcast de constantes
if (simd.lanes(u32)) |lanes| {
const V = @Vector(lanes, u32);
const threshold: V = @splat(0xF);
simd.lanes(u32) no Ghostty retorna quantos u32 a CPU de destino consegue processar ao mesmo tempo
- Cada valor é chamado de lane
- ARM retorna 4, AVX2 retorna 8 e AVX-512 retorna 16
- Se não houver um tamanho vetorial utilizável, retorna
null e o código SIMD é ignorado
@Vector(lanes, u32) cria um tipo vetorial com aquela quantidade de lanes
- Se
lanes for 8, um V contém 8 u32 que podem ser processados em paralelo
- Comparações vetoriais exigem vetores dos dois lados, então
@splat(0xF) replica 0xF em todas as lanes
{ 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF }
- Este algoritmo não precisa de acumulador vetorial, mas outros algoritmos podem inicializá-lo nesta etapa
Etapa 2: iterar um vetor por vez
while (end + lanes <= cps.len) : (end += lanes) {
const values: V = cps[end..][0..lanes].*;
- Se
lanes for 8, o loop só entra quando restam pelo menos 8 valores, e carrega 8 valores em values
- Ao fim de cada iteração,
end é incrementado não em 1, mas no número de lanes
- É preciso conseguir carregar um vetor completo; portanto, se restarem apenas 5 valores, um vetor de 8 lanes não é lido
- Valores que não cabem no vetor são tratados pela cauda escalar da etapa 5
Etapa 3: comparação paralela em todas as lanes
const greater_than_threshold = values > threshold;
- Como
values e threshold são vetores, > compara cada lane correspondente em uma única operação vetorial
- Em 8 lanes, isso executa em paralelo 8 comparações equivalentes a
cps[end] > 0xF
values: { 0x41, 0x42, 0x43, 0x0A, 0x44, 0x45, 0x46, 0x47 }
threshold: { 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF }
greater_than_threshold: { true, true, true, false, true, true, true, true }
- Não há loop interno explícito, e o resultado é um vetor com booleanos por lane
- A mesma estrutura pode ser aplicada não só a comparações, mas também a soma, multiplicação, mínimo, máximo e outras operações suportadas pelo tipo vetorial
- A comparação em si é uma única operação vetorial, mas carregar o vetor, reduzir o resultado e localizar a lane que falhou exigem instruções adicionais
Etapa 4: redução do resultado vetorial
if (@reduce(.And, greater_than_threshold)) continue;
@reduce(.And, ...) combina todos os booleanos com and e produz um único booleano
- Se todas as lanes forem
true, passa para o próximo vetor; se ao menos uma for false, encontra a posição exata da falha
const mask: std.meta.Int(.unsigned, lanes) = @bitCast(greater_than_threshold);
end += @ctz(~mask);
break;
@bitCast converte o vetor booleano em uma máscara inteira com 1 bit por lane
1 significa que o valor é maior que 0xF
0 significa que a comparação falhou
- Ao inverter a máscara, as comparações que falharam viram
1, e @ctz conta quantos bits 0 existem antes do primeiro 1
values: { 0x41, 0x42, 0x43, 0x0A, 0x44, 0x45, 0x46, 0x47 }
greater_than_threshold: { true, true, true, false, true, true, true, true }
mask: { 1, 1, 1, 0, 1, 1, 1, 1 }
~mask: { 0, 0, 0, 1, 0, 0, 0, 0 }
- Neste exemplo,
@ctz(~mask) retorna 3, movendo end para a lane 3, onde está o primeiro caractere de controle 0x0A
- A redução do resultado é a parte que mais varia entre as 5 etapas, dependendo do algoritmo
- Uma soma pode reduzir o acumulador vetorial a um único número
- Uma transformação pode armazenar o vetor inteiro no buffer de saída
- Esta busca gera uma máscara de bits para localizar a posição de uma lane específica
Etapa 5: tratamento da cauda escalar
while (end < cps.len and cps[end] > 0xF) end += 1;
- Se o comprimento da entrada não for múltiplo exato da largura vetorial, o loop escalar original trata o restante
- Depois de um loop vetorial de 8 lanes, podem sobrar de 0 a 7 valores
- Em CPUs onde
simd.lanes(u32) retorna null, a parte SIMD é ignorada e o loop escalar trata toda a entrada
- A implementação original cuida ao mesmo tempo do tratamento do restante e do fallback de compatibilidade
- Vetores genéricos removem a sintaxe específica de cada CPU, mas não eliminam a geração de código específica por CPU
- O Zig converte operações vetoriais para o conjunto de instruções ativado no alvo
O que a vetorização automática deixa passar
- Compiladores podem fazer vetorização automática em código simples, como laços aritméticos regulares sem fluxo de controle complexo
- Antes de escrever SIMD manualmente, vale compilar a versão escalar com opções de otimização e verificar o código gerado
- Compiladores de produção frequentemente perdem oportunidades de vetorização, e embora a vetorização automática seja pesquisada há décadas, estudos recentes ainda partem desse problema
- Se o laço for importante o bastante para que um ganho de 5x faça diferença, pode valer a pena escrever a vetorização de forma explícita para manter o comportamento previsível
- Isso evita situações em que mudanças irrelevantes no código ou atualizações do compilador façam um loop vetorial voltar silenciosamente a ser escalar
Até onde desenvolvedores precisam dominar SIMD
- Ao encontrar hot loops que buscam, comparam, contam ou transformam grandes volumes de dados sequenciais, é preciso ao menos considerar processamento por largura vetorial
- O SIMD do dia a dia segue uma forma regular: preparação de constantes, carregamento vetorial, operação paralela, redução do resultado e cauda escalar
- Se a linguagem oferecer bom suporte a SIMD, é possível melhorar desempenho sem conhecer diretamente assembly ou detalhes específicos de CPU
- O nível necessário para todo desenvolvedor não é dominar técnicas complexas no estilo de
simdutf e simdjson, mas reconhecer oportunidades de aplicar SIMD e aproveitar sua estrutura comum
1 comentários
Comentários do Hacker News
É um bom texto, mas começar dizendo que SIMD é fácil de entender e tão fácil de escrever quanto um loop
fore já no primeiro exemplo transformar uma linha de código escalar em 12 linhas não convence muitoSeria melhor admitir com franqueza que SIMD é difícil, mas vale a pena. Se o público é iniciante, não deveria usar termos específicos de SIMD como
broadcastsem explicação logo no passo 1, e o passo 5, que explicou o tratamento da cauda escalar, foi uma boa escolhaÉ preciso descobrir quantos itens o hardware processa de uma vez, agrupar o trabalho nesse tamanho, desempacotar o resultado depois, tratar separadamente os itens restantes e até transformar constantes em vetores replicados. Nada disso é difícil por si só, mas aumenta o trabalho e deixa tudo mais incômodo
Meu recurso favorito era
par(; ; ), em que o compilador paralelizava automaticamente loopsforsob certas condições de contornoSe o tema de fato é complexo, o certo é dividi-lo em partes menores e mais simples, organizar bem a sequência para que a pessoa consiga subir a curva de aprendizado íngreme e convencê-la de que vale a pena
Um conselho melhor seria que todo mundo deveria conhecer programação de arrays. Em geral, a otimização com SIMD exige esse modo de pensar, e técnicas realmente específicas de SIMD empacotado são surpreendentemente raras
Programação de arrays facilita a vetorização automática pelo compilador, então mesmo sem usar SIMD diretamente costuma produzir código com bom desempenho
Não tenho tanta experiência, mas Julia parece ser a linguagem moderna e mais expressiva que chega mais perto de oferecer capacidades semelhantes de vetorização
Nos últimos dias otimizei operações de matriz de um projeto de bioinformática com AVX-512 e fiquei muito satisfeito
Na maioria das aplicações, o gargalo é ler grandes conjuntos de dados da memória, então em vez de reler várias vezes para várias operações, dá para fazer tudo de uma vez com registradores AVX e kernels fundidos. Ganhos de 5x são comuns; usei intrinsics diretamente, mas com o crate
wideoperações comuns ficam bem simples: https://docs.rs/wide/latest/wide/A esmagadora maioria dos desenvolvedores não precisa aprender SIMD de forma alguma. Fico me perguntando por que se passa a impressão de que todo desenvolvedor precisa saber disso para ser um desenvolvedor de verdade
O título seria melhor como “todo mundo deveria saber quando SIMD não está sendo aplicado”
Compiladores modernos vetorizam muito bem, mas de repente voltam para código escalar por causa de uma única suposição ou de um ramo com dependência de dados. Pode ser mais valioso saber consultar o relatório de otimização do compilador do que saber escrever SIMD
Se você só consegue identificar o problema, no fim das contas acaba em um “que pena”
No ano passado comecei a aprender SIMD em x86 e ARM ao criar um sintetizador de áudio: https://github.com/seclorum/SIMDSynth
A arquitetura de um sintetizador multitimbral e polifônico foi muito adequada para aprender os princípios de SIMD, porque aplica o mesmo processamento a vários fluxos de dados. Mas depurar foi bem difícil, e eu realmente senti falta de um simulador que permitisse entender o estado de cada pipeline de processamento; investigar ferramentas para SIMD parece exigir outro grande investimento
O texto é bom e seria ótimo se mais linguagens suportassem SIMD, mas é um pouco estranho dizer que “todo programador deveria saber disso” quando as duas linguagens mais populares não oferecem suporte nativo a SIMD
Mesmo que você não vá escrever SIMD à mão ou planeje deixar isso para a IA, ainda precisa saber que tipos de trabalho podem ficar mais rápidos com SIMD em certo hardware. Só assim dá para projetar algoritmos e estruturas de código de modo que a aplicação de SIMD seja possível
O efeito das dependências de dados, o custo de aumentar a largura dos elementos vetoriais e como evitá-lo, como transformar condições e desvios em máscaras e características como “não existe instrução de divisão” são coisas muito mais fáceis de internalizar depois de usar SIMD nem que seja um pouco
Funciona bem ao inspecionar ou transformar grandes blocos contíguos de dados de uma vez, mas se você precisa tomar uma decisão a cada poucos bytes de entrada, pode acabar igual ou até mais lento que a abordagem escalar. SIMD não é um botão mágico de aceleração
Este é um vídeo útil em que Casey Muratori explica como a equipe de desenvolvimento de The Witness resolveu um problema real de desempenho com SIMD: https://www.youtube.com/watch?v=Ge3aKEmZcqY
É um bom exemplo de integração vertical para desempenho: mostra por que abstrações gerais existem e por que precisam ser genéricas e, depois de entender isso, como em certos casos de uso é possível integrar verticalmente da definição do problema até o SIMD para obter grandes ganhos
Antes de entrar em micro-otimizações como SIMD, é preciso revisar seriamente as estruturas de dados e os padrões de acesso
No passado, apliquei SIMD em código Zig, mas o modelo de estrutura de dados era o oposto do que a otimização exigia, como colocar pneus de corrida de alto desempenho em uma lata-velha com o motor quebrado. Era um caso típico de otimização prematura, sem nem medir o desempenho e sem considerar onde a memória era alocada
Hoje, vejo os dados como tabelas SQL e projeto a estrutura em torno de possíveis chaves primárias e padrões de acesso. Antes, eu usava árvores apontando para outras structs no heap, acumulando ao mesmo tempo as desvantagens de listas encadeadas, a fragmentação de vários vetores no heap e o custo lento de criação e liberação. Só o
Dropjá consumia uma parte considerável do tempo de execuçãoComo árvores sempre podem ser linearizadas, avalio os padrões de acesso e inserção, se a estrutura realmente é uma árvore ou outro grafo, e se deve ser armazenada como
Vecou como uma struct de váriosVec. Como resultado, o código ficou mais rápido e mais simples, os dados passaram a ficar reunidos em arranjos homogêneos, o que facilita para o compilador aplicar otimizações SIMD e aproveitar o cache L1, e, quando necessário, também dá para escrever diretamente código SIMD sem desviosMaterial relacionado: https://hn.algolia.com/?dateRange=all&page=0&prefix=true&que...
Em gargalos reais, é preciso evitar alocação de memória, consultas a tabelas de funções virtuais e indireções excessivas. Até o
vectordo C++ nem sempre é a melhor opção se puder disparar alocações inesperadasEm contrapartida, código orientado a dados quase sempre dá suporte fácil a threading e SIMD
Curiosamente, no fim as rotinas de CPU também acabam sendo escritas no estilo de GPU, e uma abordagem é usar structs de arrays no estilo Parquet em vez de array de objetos