Funciones Go integradas


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.

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


All Articles