Saiu o SIMD portátil do Go 1.27, medi em dois sistemas de produção: um deu 2,7× o outro zero e um grande risco encontrado
O Go 1.27 saiu com um pacote SIMD portátil (GOEXPERIMENT=simd): você escreve o laço uma vez e o compilador gera uma versão para cada largura de vetor (4 faixas no NEON do Mac, 8 no AVX2, 16 no AVX-512), escolhida quando o programa sobe. O 1.26 só tinha SIMD para amd64; agora tem arm64 e Wasm também.
Peguei três trechos quentes de dois sistemas meus e medi. Não foi benchmark sintético: usei 182.976 medições reais de vibração de um rolamento (dataset CWRU, 12 kHz). Resumo do que achei, com os números, e depois o que me preocupou de verdade.
O que acelerou (e o que não)
| Trecho | Escalar | Com SIMD | Resultado |
|---|---|---|---|
| Conferência do codec (maior desvio entre original e reconstruído) | 3,87 ns/medição | 1,74 ns | 2,2× mais rápido, bit a bit idêntico |
| Histograma das variações (128 caixas) | 6,6 ns | 7,2 ns | nada (dentro do ruído) |
| Distância entre vetores (128 dim, 1.024 centroides) | 335 µs | 126 µs | 2,7× mais rápido |
O histograma não ganhou porque o trabalho pesado ali é bins[i]++, uma posição de memória diferente por valor, e o pacote ainda não tem scatter. Também não tem redução (ReduceSum vem no 1.28) nem alargamento float32→float64. É experimental e a API pode mudar.
Já o coração do codec, onde de fato está o custo (~53 ns/amostra), não vetoriza nem vai vetorizar: cada previsão depende da reconstrução anterior, e o range coder é serial. Lei de Amdahl: o teto de ganho no encode inteiro é de poucos %.
A surpresa: o modo de emulação
Quando o chip não tem SIMD, ou alguém deixa um GODEBUG=simd=0 esquecido no ambiente, o Go emula as instruções em software. A documentação diz que a emulação é competente. Medi:
- conferência: 37 ns/medição, ~10× mais lenta que o escalar (não que o SIMD: que o código comum, um número por vez);
- histograma: 26 ns, ~4× mais lento.
Ou seja: uma variável de ambiente errada num servidor vira uma regressão grande e silenciosa. Emulação não é plano B. Se você adotar, mantenha o caminho escalar escrito e escolhido de propósito.
O que me preocupou: os bits mudam com a máquina
Meu codec de telemetria promete uma coisa acima de tudo: o mesmo arquivo, byte a byte, em qualquer máquina, porque o arquivo é selado com SHA-256 e conferido em outro lugar.
Passei as somas de cada bloco para SIMD e comparei com a versão escalar: o resultado mudou em 98,7% dos blocos. Em float, (a+b)+c nem sempre é a+(b+c), e somar em 4 subtotais paralelos arredonda em outros lugares. Os números que vão para o arquivo saíram iguais nos 304 blocos porque o arredondamento final absorveu, mas isso foi sorte com esse sinal, não garantia.
Pior: usei MulAdd (multiplica e soma num passo só). O mesmo binário, com o mesmo dado, deu um hash no ARM (que funde a operação) e outro no amd64 sob Rosetta 2 (que não funde). Como a largura do vetor também muda com o chip (4, 8 ou 16 faixas), toda redução em ponto flutuante passa a ter ordem de soma dependente do hardware. Uma API "agnóstica de largura" é, por construção, agnóstica de bits.
As regras que adotei
- SIMD só em operação que não depende de ordem: máximo, mínimo, comparação, subtração e divisão elemento a elemento. Soma de float que vira byte, hash ou selo fica de fora.
- Emulação não é fallback. Caminho escalar explícito.
- Onde a soma é inevitável, conferir perto da fronteira. Meu índice de busca promete recall 1,0 (se está dentro do raio, aparece). Calculei o erro máximo da distância em float32 (~8 milionésimos da distância, para 128 dimensões) e testei contra a conta exata em 3.000 pares: nunca passou. Aí plantei 20 mil pontos rente ao raio: o float32 sozinho errou de 9 a 38 deles. Com uma faixa de recheck em precisão total perto do raio: zero erros, ao custo de 16 recálculos em 200 mil (0,008%). Os 2,7× sobrevivem quase inteiros.
- Medir na máquina de produção. O tamanho do bloco muda com o chip, e o resultado também.
Onde SIMD rende, em geral
Rende muito quando a mesma conta se repete sobre dados independentes, já enfileirados na memória, com poucas decisões no meio e números pequenos (com bytes cabem 16 a 64 por bloco; com float64, 2 a 8). Por isso parsing de JSON/CSV, validação de UTF-8, base64, CRC, AES e distância entre vetores inteiros ganham tanto, e cálculo científico em float64 ganha pouco.
Rende pouco ou atrapalha: laços em que cada passo depende do anterior (compressores de entropia, predição adaptativa), acesso espalhado (grafos, tabelas), trechos curtos e, de novo, somas de float que precisam dar o mesmo resultado em qualquer máquina.
Ressalvas honestas
- Medi num Mac ARM, mediana de 6 repetições, sem isolar o núcleo nem fixar frequência. Tem ruído; a diferença do histograma cabe nele.
- Não medi AVX2 nem AVX-512 em servidor real (o Rosetta não emula AVX-512). É a próxima rodada, num Linux com
taskset. - Os 2,7× são de um trecho com dados na cache. No índice inteiro, com milhões de vetores, a memória pode comer boa parte.
- Nada disso vai para produção enquanto o pacote for experimental. O que já vale é a regra 1.
O texto completo, com os gráficos e os hashes lado a lado, está no meu caderno de pesquisa: https://stickybit.com.br/caderno/simd-outros-bits/
Pergunta para quem já usa: alguém mediu o pacote portátil em AVX-512 de verdade? E como vocês tratam determinismo em redução vetorizada, quando o resultado precisa ser reprodutível entre máquinas: recheck como fiz, soma em ordem fixa (pairwise/Kahan), ou simplesmente não vetorizam?