Ir le permite escribir en ensamblador. Pero los autores del lenguaje escribieron una biblioteca tan estándar que no tendría que hacerse. Hay formas de escribir código portátil y rápido al mismo tiempo. Como? Bienvenido bajo corte.
Comenzar a escribir funciones en ensamblador en go es muy simple. Por ejemplo, declare (declaración de reenvío) la función
Add
, que agrega 2 int64:
func Add(a int64, b int64) int64
Esta es una función normal, pero falta el cuerpo de la función. El compilador jurará razonablemente cuando intente compilar un paquete.
% go build examples/asm ./decl.go:4:6: missing function body
Agregue un archivo con la extensión .s e implemente la función en ensamblador.
TEXT ·Add(SB),$0-24 MOVQ a+0(FP), AX ADDQ b+8(FP), AX MOVQ AX, ret+16(FP) RET
Ahora puede compilar, probar y usar
Add
como una función normal. Esto es ampliamente utilizado por los propios desarrolladores de lenguaje en los paquetes
runtime, math, bytealg, syscall, reflect, crypto . Esto le permite utilizar optimizaciones de procesador de hardware y
comandos que no están representados en el idioma .
Pero hay un problema: las funciones en asm no se pueden optimizar ni incorporar (en línea). Sin esto, los gastos generales pueden ser prohibitivos.
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
Hubo varias sugerencias para el ensamblador en línea, como la directiva
asm(...)
en gcc. Ninguno de ellos fue aceptado. En lugar de esto, vaya agregando funciones
intrínsecas .
Las funciones integradas de Go están escritas en simple go. Pero el compilador sabe que pueden reemplazarse por algo más óptimo. En Go 1.13, las funciones integradas están contenidas en
math/bits
y
sync/atomic
.
Las funciones en estos paquetes tienen firmas elegantes. De hecho, repiten las firmas de los comandos del procesador. Esto permite que el compilador, si la arquitectura de destino lo admite, reemplace de manera transparente las llamadas a funciones con instrucciones de ensamblador.
A continuación, quiero hablar sobre dos formas diferentes en que el compilador go crea un código más eficiente utilizando funciones integradas.
Recuento de la población
Este número de unidades en la representación binaria de un número es una primitiva criptográfica importante. Dado que esta es una operación importante, la mayoría de las CPU modernas proporcionan implementación en hardware.
El paquete
math/bits
proporciona esta operación en las funciones
OnesCount*
. Se reconocen y reemplazan con la
POPCNT
procesador
POPCNT
.
Para ver cómo esto puede ser más eficiente, comparemos 3 implementaciones. El primero es
el algoritmo Kernigan .
func kernighan(x uint64) (count int) { for x > 0 { count++ x &= x - 1 } return count }
El número de ciclos del algoritmo coincide con el número de bits establecidos. Más bits: mayor tiempo de ejecución, lo que potencialmente conduce a la fuga de información en canales de terceros.
El segundo algoritmo es de
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) }
La estrategia de dividir y conquistar permite que esta versión funcione para O (log₂) desde un número largo y durante un tiempo constante desde el número de bits, lo cual es importante para la criptografía. Comparemos el rendimiento con
math/bits.OnesCount64
.
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, pasamos los mismos parámetros a las funciones: una secuencia de 0 a bN Esto es más cierto para el método Kernigan, ya que su tiempo de ejecución aumenta con el número de bits del 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
gana en velocidad 4 veces. Pero, ¿realmente usa una implementación de hardware o el compilador mejoró mejor el algoritmo de Hackers Delight? Es hora de entrar en ensamblador.
go test -c
Hay una sencilla utilidad para desarmar la herramienta go objdump, pero yo (a diferencia del autor del artículo original), usaré la IDA.
Están pasando muchas cosas aquí. Lo más importante: la instrucción x86
POPCNT
está integrada en el código de la prueba en sí, como esperábamos. Esto hace que el punto de referencia sea más rápido que las alternativas.
Esta ramificación es interesante.
cmp cs:runtime_x86HasPOPCNT, 0 jz lable
Sí, esto es polifilo en ensamblador. No todos los procesadores admiten
POPCNT
. Cuando se inicia el programa, antes de su
main
, la función
runtime.cpuinit
verifica si hay una instrucción necesaria y la guarda en
runtime.x86HasPOPCNT
. Cada vez que el programa verifica si es posible usar
POPCNT
o usar un archivo polifónico. Dado que el valor de
runtime.x86HasPOPCNT
no cambia después de la inicialización, la predicción de la ramificación del procesador es relativamente precisa.
Contador atómico
Las funciones intrínsecas son códigos Go regulares; pueden estar en línea en una secuencia de instrucciones. Por ejemplo, haremos una abstracción de un contador con métodos de las firmas extrañas de las funciones del paquete 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() }
Alguien pensará que tal OOP agregará gastos generales. Pero Go no es Java: el lenguaje no usa el enlace en tiempo de ejecución a menos que explícitamente use interfaces. El código anterior se colapsará en una secuencia eficiente de instrucciones del procesador. ¿Cómo será el aspecto principal?
En orden
c.inc
convierte en
lock xadd [rax], 1
- adición atómica de x86.
c.get
convierte en la instrucción
mov
habitual, que ya es atómica en x86.
c.reset
convierte en el intercambio atómico de
xchg
entre un registro nulo y la memoria.
Conclusión
Las funciones integradas son una solución ordenada que proporciona acceso a operaciones de bajo nivel sin ampliar la especificación del lenguaje. Si la arquitectura no tiene primitivas de sincronización / atómicas específicas (como algunas variantes ARM), u operaciones de matemáticas / bits, entonces el compilador inserta un archivo polivinílico sobre la marcha.