Recursos de Go incorporados


Go permite que você escreva no assembler. Mas os autores da linguagem escreveram uma biblioteca tão padrão que não precisaria ser feita. Existem maneiras de escrever código portátil e rápido ao mesmo tempo. Como Bem-vindo em corte.

Começar a escrever funções no assembler em go é muito simples. Por exemplo, declare (declaração direta) a função Add , que adiciona 2 int64:

 func Add(a int64, b int64) int64 

Essa é uma função normal, mas o corpo da função está ausente. O compilador jurará razoavelmente ao tentar compilar um pacote.

 % go build examples/asm ./decl.go:4:6: missing function body 

Adicione um arquivo com a extensão .s e implemente a função no assembler.

 TEXT ·Add(SB),$0-24 MOVQ a+0(FP), AX ADDQ b+8(FP), AX MOVQ AX, ret+16(FP) RET 

Agora você pode compilar, testar e usar Add como uma função normal. Isso é amplamente utilizado pelos próprios desenvolvedores de linguagem nos pacotes runtime, math, bytealg, syscall, reflect, crypto . Isso permite que você use otimizações do processador de hardware e comandos que não estão representados no idioma .

Mas há um problema - as funções no asm não podem ser otimizadas e integradas (inline). Sem isso, a sobrecarga pode ser proibitiva.

 var Result int64 func BenchmarkAddNative(b *testing.B) { var r int64 for i := 0; i < bN; i++ { r = int64(i) + int64(i) } Result = r } func BenchmarkAddAsm(b *testing.B) { var r int64 for i := 0; i < bN; i++ { r = Add(int64(i), int64(i)) } Result = r } 

 BenchmarkAddNative-8 1000000000 0.300 ns/op BenchmarkAddAsm-8 606165915 1.930 ns/op 

Havia várias sugestões para o assembler em linha, como a diretiva asm(...) no gcc. Nenhum deles foi aceito. Em vez disso, adicione funções intrínsecas .

As funções internas Go são escritas de forma simples. Mas o compilador sabe que eles podem ser substituídos por algo mais ideal. No Go 1.13, as funções incorporadas estão contidas em math/bits e sync/atomic .

As funções nesses pacotes possuem assinaturas sofisticadas. De fato, eles repetem as assinaturas dos comandos do processador. Isso permite que o compilador, se a arquitetura de destino suportar, substitua de forma transparente as chamadas de função pelas instruções do assembler.

Abaixo, quero falar sobre duas maneiras diferentes pelas quais o compilador go cria código mais eficiente usando funções internas.

Contagem da população


esse número de unidades na representação binária de um número é uma primitiva criptográfica importante. Como essa é uma operação importante, a maioria das CPUs modernas fornece implementação em hardware.
O pacote math/bits fornece essa operação nas funções OnesCount* . Eles são reconhecidos e substituídos pela POPCNT processador POPCNT .

Para ver como isso pode ser mais eficiente, vamos comparar três implementações. O primeiro é o algoritmo de Kernigan .

 func kernighan(x uint64) (count int) { for x > 0 { count++ x &= x - 1 } return count } 

O número de ciclos do algoritmo coincide com o número de bits definidos. Mais bits - maior tempo de execução, o que potencialmente leva ao vazamento de informações em canais de terceiros.

O segundo algoritmo é do Hacker's Delight .

 func hackersdelight(x uint64) uint8 { const m1 = 0b0101010101010101010101010101010101010101010101010101010101010101 const m2 = 0b0011001100110011001100110011001100110011001100110011001100110011 const m4 = 0b0000111100001111000011110000111100001111000011110000111100001111 const h1 = 0b0000000100000001000000010000000100000001000000010000000100000001 x -= (x >> 1) & m1 x = (x & m2) + ((x >> 2) & m2) x = (x + (x >> 4)) & m4 return uint8((x * h1) >> 56) } 

A estratégia de dividir e conquistar permite que esta versão funcione para O (log₂) a partir de um número longo e por um tempo constante a partir do número de bits, o que é importante para a criptografia. Vamos comparar o desempenho com math/bits.OnesCount64 .

 func BenchmarkKernighan(b *testing.B) { var r int for i := 0; i < bN; i++ { r = kernighan(uint64(i)) } runtime.KeepAlive(r) } func BenchmarkPopcnt(b *testing.B) { var r int for i := 0; i < bN; i++ { r = hackersdelight(uint64(i)) } runtime.KeepAlive(r) } func BenchmarkMathBitsOnesCount64(b *testing.B) { var r int for i := 0; i < bN; i++ { r = bits.OnesCount64(uint64(i)) } runtime.KeepAlive(r) } 

Para ser sincero, passamos os mesmos parâmetros para as funções: uma sequência de 0 a bN Isso é mais verdadeiro para o método Kernigan, pois seu tempo de execução aumenta com o número de bits do argumento de entrada.

 BenchmarkKernighan-4 100000000 12.9 ns/op BenchmarkPopcnt-4 485724267 2.63 ns/op BenchmarkMathBitsOnesCount64-4 1000000000 0.673 ns/op 

math/bits.OnesCount64 ganha em velocidade 4 vezes. Mas ele realmente usa uma implementação de hardware ou o compilador apenas melhorou o algoritmo do Hackers Delight? É hora de entrar em montador.

 go test -c #     

Existe um utilitário simples para desmontar o objdump da ferramenta go, mas eu (ao contrário do autor do artigo original), usarei o IDA.


Há muita coisa acontecendo aqui. Mais importante: a instrução x86 POPCNT está embutida no código do teste, como esperávamos. Isso torna a marca de banca mais rápida que as alternativas.

Essa ramificação é interessante.

 cmp cs:runtime_x86HasPOPCNT, 0 jz lable 

Sim, isso é polyphile no assembler. Nem todos os processadores suportam POPCNT . Quando o programa é iniciado, antes da sua main , a função runtime.cpuinit verifica se há uma instrução necessária e a salva em runtime.x86HasPOPCNT . Cada vez que o programa verifica se é possível usar POPCNT ou usar um polyfile. Como o valor de runtime.x86HasPOPCNT não muda após a inicialização, a previsão de ramificação do processador é relativamente precisa.

Contador atômico


As funções intrínsecas são um código Go normal; elas podem estar embutidas em um fluxo de instruções. Por exemplo, faremos uma abstração de um contador com métodos das assinaturas estranhas das funções do pacote atômico.

 package main import ( "sync/atomic" ) type counter uint64 func (c *counter) get() uint64 { return atomic.LoadUint64((*uint64)(c)) } func (c *counter) inc() uint64 { return atomic.AddUint64((*uint64)(c), 1) } func (c *counter) reset() uint64 { return atomic.SwapUint64((*uint64)(c), 0) } func F() uint64 { var c counter c.inc() c.get() return c.reset() } func main() { F() } 

Alguém pensará que esse POO aumentará a sobrecarga. Mas o Go não é Java - a linguagem não usa ligação em tempo de execução, a menos que você use explicitamente interfaces. O código acima será recolhido em um fluxo eficiente de instruções do processador. Como será a aparência principal?


Em ordem. c.inc transforma em lock xadd [rax], 1 - adição atômica de x86. c.get se torna a instrução mov usual, que já é atômica em x86. c.reset se torna a troca atômica de xchg entre um registro nulo e a memória.

Conclusão


As funções incorporadas são uma solução interessante que fornece acesso a operações de baixo nível sem expandir a especificação de idioma. Se a arquitetura não possui primitivas de sincronização / atômica específicas (como algumas variantes do ARM) ou operações de math / bits, o compilador insere um polyfile em movimento puro.

Source: https://habr.com/ru/post/pt467673/


All Articles