Go vous permet d'écrire dans l'assembleur. Mais les auteurs de la langue ont écrit une bibliothèque tellement standardisée que cela n'aurait pas à être fait. Il existe des moyens d'écrire du code portable et rapide en même temps. Comment? Bienvenue sous coupe.
Commencer à écrire des fonctions dans l'assembleur de go est très simple. Par exemple, déclarez (déclaration directe) la fonction
Add
, qui ajoute 2 int64:
func Add(a int64, b int64) int64
Il s'agit d'une fonction normale, mais le corps de la fonction est manquant. Le compilateur jurera raisonnablement en essayant de compiler un paquet.
% go build examples/asm ./decl.go:4:6: missing function body
Ajoutez un fichier avec l'extension .s et implémentez la fonction dans l'assembleur.
TEXT ·Add(SB),$0-24 MOVQ a+0(FP), AX ADDQ b+8(FP), AX MOVQ AX, ret+16(FP) RET
Vous pouvez maintenant compiler, tester et utiliser
Add
comme une fonction normale. Ceci est largement utilisé par les développeurs de langage eux-mêmes dans les packages
runtime, math, bytealg, syscall, reflect, crypto . Cela vous permet d'utiliser des optimisations de processeur matériel et des
commandes qui ne sont pas représentées dans la langue .
Mais il y a un problème - les fonctions sur asm ne peuvent pas être optimisées et intégrées (en ligne). Sans cela, les frais généraux peuvent être prohibitifs.
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
Il y avait plusieurs suggestions pour l'assembleur en ligne, comme la directive
asm(...)
dans gcc. Aucun d'eux n'a été accepté. Au lieu de cela, allez ajouter
des fonctions
intrinsèques .
Les fonctions intégrées de Go sont écrites en clair. Mais le compilateur sait qu'ils peuvent être remplacés par quelque chose de plus optimal. Dans Go 1.13, les fonctions intégrées sont contenues dans
math/bits
et
sync/atomic
.
Les fonctions de ces packages ont des signatures fantaisistes. En fait, ils répètent les signatures des commandes du processeur. Cela permet au compilateur, si l'architecture cible prend en charge, de remplacer de manière transparente les appels de fonction par des instructions d'assembleur.
Ci-dessous, je veux parler de deux façons différentes dont le compilateur go crée du code plus efficace en utilisant des fonctions intégrées.
Chiffre de population
ce nombre d'unités dans la représentation binaire d'un nombre est une primitive cryptographique importante. Comme il s'agit d'une opération importante, la plupart des processeurs modernes fournissent une implémentation matérielle.
Le package
math/bits
fournit cette opération dans les fonctions
OnesCount*
. Ils sont reconnus et remplacés par l'
POPCNT
processeur
POPCNT
.
Pour voir comment cela peut être plus efficace, comparons 3 implémentations. Le premier est
l'algorithme de Kernigan .
func kernighan(x uint64) (count int) { for x > 0 { count++ x &= x - 1 } return count }
Le nombre de cycles de l'algorithme coïncide avec le nombre de bits définis. Plus de bits - temps d'exécution plus long, ce qui conduit potentiellement à une fuite d'informations sur les canaux tiers.
Le deuxième algorithme provient 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 stratégie de division et de conquête permet à cette version de fonctionner pour O (log₂) à partir d'un nombre long et pendant un temps constant à partir du nombre de bits, ce qui est important pour la cryptographie. Comparons les performances avec
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) }
Pour être honnête, nous passons les mêmes paramètres aux fonctions: une séquence de 0 à bN Ceci est plus vrai pour la méthode Kernigan, puisque son temps d'exécution augmente avec le nombre de bits de l'argument d'entrée.
➚ BenchmarkKernighan-4 100000000 12.9 ns/op BenchmarkPopcnt-4 485724267 2.63 ns/op BenchmarkMathBitsOnesCount64-4 1000000000 0.673 ns/op
math/bits.OnesCount64
gagne en vitesse 4 fois. Mais utilise-t-il vraiment une implémentation matérielle, ou le compilateur a-t-il simplement mieux optimisé l'algorithme de Hackers Delight? Il est temps d'entrer dans l'assembleur.
go test -c
Il existe un utilitaire simple pour démonter l'objdump de l'outil go, mais moi (contrairement à l'auteur de l'article d'origine), j'utiliserai l'IDA.
Il se passe beaucoup de choses ici. Le plus important: l'instruction
POPCNT
x86 est intégrée dans le code du test lui-même, comme nous l'avions espéré. Cela rend le banchmark plus rapide que les alternatives.
Cette ramification est intéressante.
cmp cs:runtime_x86HasPOPCNT, 0 jz lable
Oui, c'est polyphile sur assembleur. Tous les processeurs ne prennent
POPCNT
charge
POPCNT
. Lorsque le programme démarre, avant votre
main
, la fonction
runtime.cpuinit
vérifie s'il existe une instruction nécessaire et l'enregistre dans
runtime.x86HasPOPCNT
. Chaque fois que le programme vérifie s'il est possible d'utiliser
POPCNT
ou d'utiliser un polyfichier. Étant donné que la valeur de
runtime.x86HasPOPCNT
ne change pas après l'initialisation, la prédiction de la branche du processeur est relativement précise.
Compteur atomique
Les fonctions intrinsèques sont du code Go normal; elles peuvent être intégrées dans un flux d'instructions. Par exemple, nous ferons une abstraction d'un compteur avec des méthodes à partir des signatures étranges des fonctions du package atomique.
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() }
Quelqu'un pensera qu'un tel POO ajoutera des frais généraux. Mais Go n'est pas Java - le langage n'utilise pas de liaison dans le runtime sauf si vous utilisez explicitement des interfaces. Le code ci-dessus sera réduit en un flux efficace d'instructions du processeur. À quoi ressemblera le principal?
En ordre.
c.inc
transforme en
lock xadd [rax], 1
- addition atomique de x86.
c.get
devient l'instruction
mov
habituelle, qui est déjà atomique en x86.
c.reset
devient l'échange atomique de
xchg
entre un registre nul et la mémoire.
Conclusion
Les fonctions intégrées sont une solution soignée qui permet d'accéder à des opérations de bas niveau sans étendre la spécification du langage. Si l'architecture n'a pas de primitives de synchronisation / atomiques spécifiques (comme certaines variantes ARM), ou d'opérations à partir de maths / bits, alors le compilateur insère un polyfile à la volée.