Shopping cart

Subtotal $0.00

View cartCheckout

Building better devs

TnewsTnews
Programação

Go 1.27 SIMD: De 76ns para 14ns Sem Uma Linha de Assembly

Email : 4

O Go Finalmente Ganhou SIMD Portável

Enquanto Rust e C++ aproveitam SIMD há anos com bibliotecas como std::simd e Highway, o Go sempre ficou para trás nessa conversa. Quem precisava de performance vetorial tinha duas opções: escrever assembly na mão (sim, .s files dentro do repositório Go) ou aceitar que o compilador ia fazer o que pudesse com autovectorization, que na prática não fazia quase nada.

Isso mudou. O Go 1.26 trouxe o pacote archsimd com acesso direto a instruções AVX, AVX2, AVX-512 e NEON. E agora o Go 1.27 deu o passo que a comunidade esperava: um pacote simd portável, que funciona em qualquer arquitetura, sem você precisar saber a diferença entre um registrador YMM e um ZMM.

E os benchmarks? Um lookup que levava 76ns caiu para 14.5ns. Produto interno com 128 floats saiu de 124ns para 23ns. Raiz quadrada vetorial ficou 20x mais rápida que o loop escalar. Tudo isso sem escrever uma única linha de assembly.

Por Que SIMD Importa (e Por Que Você Provavelmente Não Usa)

SIMD significa Single Instruction, Multiple Data. Em vez de processar um número por vez, você processa 4, 8, 16 ou até 64 de uma vez. É a diferença entre lavar uma camisa por vez e jogar toda a pilha na máquina.

A maioria dos devs nunca tocou em SIMD diretamente. E faz sentido: até pouco tempo, usar SIMD significava escrever código específico para cada CPU. Quer suportar Intel e ARM? Escreva duas versões. Quer suportar AVX-512 e AVX2? Mais duas versões. WebAssembly? Outra versão.

O resultado é que SIMD ficou restrito a bibliotecas de criptografia, codecs de vídeo e engines de banco de dados. O dev normal nunca aproveitou essa capacidade que está ali, parada, dentro do processador.

Como o Pacote simd do Go Resolve Isso

A sacada do Go foi criar uma abstração que esconde o tamanho do vetor. Você não escreve código para vetores de 128 bits ou 256 bits. Você escreve código para “um vetor de float32”, e o compilador decide o tamanho ideal na hora do build.

Funciona assim:


package main

import "simd"

func innerProduct(x, y []float32) float32 {
    var a simd.Float32s
    var i int
    for i = 0; i < len(x)-a.Len()+1; i += a.Len() {
        u := simd.LoadFloat32s(x[i : i+a.Len()])
        v := simd.LoadFloat32s(y[i : i+a.Len()])
        a = u.MulAdd(v, a)
    }
    if i < len(x) {
        u, _ := simd.LoadFloat32sPart(x[i:])
        v, _ := simd.LoadFloat32sPart(y[i:])
        a = u.MulAdd(v, a)
    }
    return sum(a)
}

Esse código roda em x86 com AVX-512, em ARM com NEON, em WebAssembly com SIMD, e até em arquiteturas sem suporte nenhum (com emulação em software). Você escreve uma vez, e ele funciona em todo lugar.

Os tipos são intuitivos: simd.Float32s, simd.Int64s, simd.Uint8s. As comparações retornam máscaras (simd.Mask32s) que você usa para selecionar elementos condicionalmente com IfElse(). E a cereja: MulAdd() vira uma instrução FMA nativa onde disponível.

Os Benchmarks Que Chamaram Atenção

O pessoal do Gorse rodou benchmarks detalhados num Intel Core i7-11370H com suporte a AVX-512. Os resultados são reveladores:

Operação (128 floats) Escalar AVX-512 Go SIMD Speedup vs Escalar
— — — — —
Produto Interno 124.60 ns 11.43 ns 23.84 ns 5.23x
Multiplicação 125.90 ns 9.21 ns 29.52 ns 4.26x
Raiz Quadrada 484.00 ns 31.07 ns 36.24 ns 13.36x
Subtração 59.80 ns 7.21 ns 11.92 ns 5.02x

Dois pontos saltam aqui. Primeiro: o pacote simd portável é consistentemente mais rápido que o código escalar, com ganhos entre 3x e 20x dependendo da operação. Segundo: ele ainda fica atrás do assembly puro AVX-512, com uma penalidade média de 1.8x.

Essa penalidade faz sentido. O pacote portável precisa lidar com tail elements (quando o vetor não é múltiplo exato do tamanho do registrador), e o compilador ainda está amadurecendo as otimizações. Mas para 90% dos casos de uso, trocar 5x de speedup por não precisar manter assembly é uma troca que qualquer pessoa sã aceitaria.

Para vetores curtos (16 floats, ou seja, um registrador AVX-512), a história é diferente. O Go SIMD chega a ficar mais lento que o escalar em algumas operações por causa do overhead de dispatch. Então não saia vetorizando loops de 10 elementos achando que vai ganhar performance.

Dois Pacotes, Dois Níveis de Controle

O Go oferece duas camadas:

archsimd (Go 1.26+): acesso direto às instruções da arquitetura. Você trabalha com tipos como archsimd.Int8x16 (16 bytes em NEON) ou archsimd.Float32x16 (16 floats em AVX-512). Performance máxima, portabilidade zero.

simd (Go 1.27+): a camada portável. Tipos como simd.Float32s que o compilador transforma no tamanho ideal para a CPU alvo. Um pouco mais lento, mas roda em qualquer lugar.

A ponte entre os dois é elegante. Se você precisa de uma operação que o simd portável não oferece, converte para o tipo de arquitetura:


func OnesCount(v simd.Int8s) simd.Int8s {
    switch x := v.ToArch().(type) {
    case archsimd.Int8x16:
        return simd.Int8sFromArch(x.OnesCount())
    case archsimd.Int8x32:
        return simd.Int8sFromArch(x.OnesCount())
    default:
        return OnesCountEmulated(v)
    }
}

O compilador elimina os branches impossíveis em tempo de compilação. Numa máquina com AVX-512, só o case Int8x64 executa. Sem overhead de dispatch em runtime.

Como o Compilador Faz a Mágica

Aqui é onde fica interessante. O compilador do Go não faz simplesmente “dispatch em runtime” como você esperaria. Ele cria múltiplas cópias especializadas de cada função que usa tipos SIMD:

  • innerProduct@simd128 (para SSE/NEON)
  • innerProduct@simd256 (para AVX2)
  • innerProduct@simd512 (para AVX-512)
  • innerProduct@simd0 (emulação pura)

O dispatch acontece no ponto de entrada da função, uma única vez. Dentro da função, todas as chamadas são diretas para a versão especializada. Isso significa que o overhead de escolher a implementação certa é amortizado: dentro de um loop SIMD de mil iterações, não há nenhum branch de dispatch.

Você pode até controlar o comportamento via variável de ambiente:


GODEBUG=simd=0      # Emulação pura (para debug)
GODEBUG=simd=256    # Forçar 256 bits
GODEBUG=simd=512    # Forçar 512 bits

E para buildar com suporte SIMD experimental:


GOEXPERIMENT=simd go build .

Go SIMD vs Rust vs C++: Onde Cada Um Se Encaixa

Vamos ser honestos: Rust e C++ ainda são mais rápidos em SIMD puro. Os benchmarks mostram que Rust consegue ser 2x a 3x mais rápido que Go em workloads numéricos pesados, e o Highway do C++ é o padrão-ouro em portabilidade SIMD com performance de assembly.

Mas o Go não está tentando competir nessa arena. O público-alvo é outro.

C++ (Highway) Rust (std::simd) Go (simd)
— — — —
Performance máxima Referência ~95% do C++ ~60% do assembly
Portabilidade Excelente Boa Excelente
Facilidade de uso Complexa Média Simples
Ecossistema Maduro Crescendo Experimental
Caso de uso ideal Engines, codecs Infra de alta perf Microserviços, data pipelines

Se você está escrevendo um codec de vídeo, use C++ ou Rust. Mas se você tem um microserviço Go que faz matching de strings, cálculo de distância vetorial para busca semântica, ou processamento de séries temporais, agora você tem uma opção que não existia antes.

O caso de uso mais óbvio? Busca vetorial. Com a explosão de embeddings e RAG, todo mundo precisa calcular distâncias entre vetores. O Gorse (um sistema de recomendação em Go) já está testando o pacote SIMD para acelerar exatamente isso. E a diferença entre 76ns e 14.5ns por lookup se multiplica quando você tem milhões de vetores.

Operações Disponíveis (e as Que Faltam)

O pacote simd no Go 1.27 já cobre bastante coisa:

Aritmética: Add, Sub, Mul, Div, MulAdd (FMA), Abs, Neg, Min, Max, Sqrt, Average, AddSaturated, SubSaturated

Comparação: Equal, NotEqual, Less, LessEqual, Greater, GreaterEqual (retornam masks)

Lógica: And, Or, Xor, AndNot, Not, IfElse

Shifts: ShiftAllLeft, ShiftAllRight, RotateAllLeft, RotateAllRight

Cripto: CarrylessMultiplyEven, CarrylessMultiplyOdd

Load/Store: LoadFloat32s, LoadFloat32sPart, Store, BroadcastFloat32s

O que ainda falta para o Go 1.28:

  • ReduceSum (soma horizontal, essencial para dot products eficientes)
  • OnesCount (popcount)
  • Shuffle/permute de elementos
  • Suporte a SVE (ARM)
  • Operações de máscara mais avançadas

A ausência de ReduceSum é a mais sentida. Repare no exemplo do innerProduct: a função sum() precisa copiar o vetor para um slice e somar escalarmente. Com ReduceSum, isso viraria uma instrução.

ChaCha20: Go SIMD Bateu o Rust (Sim, Você Leu Certo)

Um benchmark que causou barulho: uma implementação de ChaCha20 usando archsimd do Go 1.27 ficou 7.5x mais rápida que a implementação da stdlib em um AMD EPYC Zen5.

Mais que isso: ela bateu a implementação equivalente em Rust nesse workload específico. Antes que alguém surte, o contexto importa. O archsimd do Go dá acesso direto às instruções AVX-512, e a implementação testada era extremamente otimizada para esse cenário. Rust continua ganhando na média, mas é um sinal de que a diferença está diminuindo.

Isso é relevante porque criptografia é um dos casos de uso mais importantes de SIMD. TLS, hashing, autenticação: tudo isso roda mais rápido com vetorização. E se o Go consegue chegar perto (ou até ultrapassar) em cenários específicos, o argumento de “preciso de Rust para performance” fica mais difícil de defender.

Quem Já Está Usando

O garbage collector do Go, chamado Green Tea GC, já usa SIMD internamente para scanning de memória. Isso significa que todo programa Go já se beneficia de SIMD indiretamente, mesmo sem você saber.

Fora do runtime, os primeiros adotantes são previsíveis:

  • Gorse: sistema de recomendação, usando SIMD para distância de vetores
  • hnsw-go: busca de vizinhos mais próximos (ANN), já com PR aberto para migrar para o pacote SIMD
  • Bibliotecas de criptografia: ChaCha20, AES-GCM, SHA-256
  • Processamento de dados: parsing de JSON, CSV, compressão

A expectativa é que no Go 1.28, quando o pacote sair do experimental, a adoção exploda. Muitas bibliotecas estão esperando a flag GOEXPERIMENT=simd ser removida antes de adotar oficialmente.

Como Começar Hoje

Se você quer experimentar, o processo é simples:


# Instale Go 1.27
go install golang.org/dl/go1.27@latest
go1.27 download

# Crie um projeto
mkdir simd-test && cd simd-test
go mod init simd-test

# Builde com SIMD habilitado
GOEXPERIMENT=simd go build .

# Rode benchmarks comparando scalar vs SIMD
GOEXPERIMENT=simd go test -bench=. -benchmem

Uma dica: comece medindo. Antes de vetorizar qualquer coisa, rode um benchmark do código escalar. Depois compare. SIMD brilha em loops que processam arrays grandes de tipos numéricos. Se seu gargalo é I/O ou alocação de memória, SIMD não vai ajudar.

O Elefante na Sala: Ainda é Experimental

Preciso ser direto aqui. O pacote simd no Go 1.27 é experimental. Ele exige GOEXPERIMENT=simd no build. A API pode mudar. E o suporte a ARM SVE ainda não chegou, o que limita o ganho em chips como o Apple M-series e AWS Graviton (que usam SVE extensivamente).

O archsimd do Go 1.26 está mais estável, mas exige código específico por plataforma, o que derrota parcialmente o propósito.

Dito isso, a direção é clara. O time do Go está investindo pesado nisso. O design baseado no Highway (que é o estado da arte em C++) mostra maturidade. E o fato de já ter benchmarks competitivos com assembly escrito à mão mostra que a implementação não é brincadeira.

Para quem trabalha com Go em produção, minha recomendação: não use o pacote simd portável em prod ainda. Mas comece a experimentar, escreva benchmarks, reporte bugs. Quando sair do experimental (provavelmente Go 1.28 ou 1.29), você já vai ter código pronto para migrar.

Padrões Que Funcionam Bem com SIMD no Go

Nem todo código se beneficia de vetorização. Aqui vão os padrões onde SIMD realmente faz diferença:

Map sobre arrays numéricos: Aplicar a mesma operação em cada elemento de um slice grande. Normalização de vetores, scaling, clamp. O caso clássico.


func scaleAll(data []float32, factor float32) {
    var v simd.Float32s
    f := simd.BroadcastFloat32s(factor)
    for i := 0; i < len(data)-v.Len()+1; i += v.Len() {
        v = simd.LoadFloat32s(data[i : i+v.Len()])
        v.Mul(f).Store(data[i : i+v.Len()])
    }
}

Reduce (agregação): Soma, máximo, mínimo de um array. Mesmo sem ReduceSum nativo ainda, acumular em um vetor e reduzir no final já é muito mais rápido que um loop escalar.

Comparação e filtragem: Buscar elementos que atendem uma condição. As masks do SIMD permitem comparar 16 ou 32 elementos por ciclo e selecionar os que passam.

Processamento de bytes: Parsing, busca de caracteres, validação de UTF-8. O pacote simd.Uint8s processa 64 bytes por ciclo em AVX-512.

Os padrões que não funcionam bem: anything com branches dependentes de dados (if/else por elemento), acesso aleatório à memória (scatter/gather), ou operações com dependência entre iterações. SIMD é sobre paralelismo de dados, não de controle.

E para quem estava considerando reescrever aquele serviço Go em Rust “por performance”: talvez valha a pena esperar mais um release.


Fonte de inspiração: Platform-independent SIMD in Go (Go Blog)

Leave a Reply

Your email address will not be published. Required fields are marked *

Related Posts