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.