Mit Go können Sie in Assembler schreiben. Aber die Autoren der Sprache haben eine solche Standardbibliothek geschrieben, dass dies nicht nötig wäre. Es gibt Möglichkeiten, tragbaren und schnellen Code gleichzeitig zu schreiben. Wie? Willkommen unter Schnitt.
Das Schreiben von Funktionen in Assembler in go ist sehr einfach. Deklarieren Sie beispielsweise die
Add
Funktion (Forward-Deklaration), die 2 int64 hinzufügt:
func Add(a int64, b int64) int64
Dies ist eine normale Funktion, aber der Funktionskörper fehlt. Der Compiler wird vernünftigerweise schwören, wenn er versucht, ein Paket zu kompilieren.
% go build examples/asm ./decl.go:4:6: missing function body
Fügen Sie eine Datei mit der Erweiterung .s hinzu und implementieren Sie die Funktion in Assembler.
TEXT ·Add(SB),$0-24 MOVQ a+0(FP), AX ADDQ b+8(FP), AX MOVQ AX, ret+16(FP) RET
Jetzt können Sie
Add
als normale Funktion kompilieren, testen und verwenden. Dies wird von den Sprachentwicklern selbst in den Paketen
Laufzeit, Mathematik, Bytealg, Syscall, Reflect, Crypto häufig verwendet . Auf diese Weise können Sie Hardwareprozessoroptimierungen und
Befehle verwenden, die nicht in der Sprache dargestellt werden .
Es gibt jedoch ein Problem: Funktionen auf asm können nicht optimiert und integriert werden (Inline). Ohne dies kann Overhead unerschwinglich sein.
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
Es gab mehrere Vorschläge für Inline-Assembler, wie die
asm(...)
Direktive in gcc. Keiner von ihnen wurde akzeptiert. Stattdessen werden
intrinsische Funktionen hinzugefügt.
Die in Go integrierten Funktionen sind in Plain Go geschrieben. Der Compiler weiß jedoch, dass sie durch etwas Optimaleres ersetzt werden können. In Go 1.13 sind eingebettete Funktionen in
math/bits
und
sync/atomic
.
Die Funktionen in diesen Paketen haben ausgefallene Signaturen. Tatsächlich wiederholen sie die Signaturen von Prozessorbefehlen. Auf diese Weise kann der Compiler, sofern die Zielarchitektur dies unterstützt, Funktionsaufrufe transparent durch Assembler-Anweisungen ersetzen.
Im Folgenden möchte ich auf zwei verschiedene Arten eingehen, wie der go-Compiler mithilfe integrierter Funktionen effizienteren Code erstellt.
Bevölkerungszahl
Diese Anzahl von Einheiten in der binären Darstellung einer Zahl ist ein wichtiges kryptografisches Grundelement. Da dies ein wichtiger Vorgang ist, bieten die meisten modernen CPUs eine Implementierung in Hardware.
Das
math/bits
Paket bietet diese Operation in den
OnesCount*
-Funktionen. Sie werden erkannt und durch den
POPCNT
Prozessorbefehl ersetzt.
Um zu sehen, wie dies effizienter sein kann, vergleichen wir drei Implementierungen. Der erste ist
der Kernigan-Algorithmus .
func kernighan(x uint64) (count int) { for x > 0 { count++ x &= x - 1 } return count }
Die Anzahl der Zyklen des Algorithmus stimmt mit der Anzahl der gesetzten Bits überein. Mehr Bits - längere Ausführungszeit, die möglicherweise zu Informationsverlusten auf Kanälen von Drittanbietern führt.
Der zweite Algorithmus stammt von
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) }
Die Divide and Conquer-Strategie ermöglicht es dieser Version, für O (log₂) von einer langen Zahl und für eine konstante Zeit von der Anzahl der Bits zu arbeiten, was für die Kryptographie wichtig ist. Vergleichen wir die Leistung mit
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) }
Um ehrlich zu sein, übergeben wir die gleichen Parameter an die Funktionen: eine Sequenz von 0 bis bN Dies gilt mehr für die Kernigan-Methode, da ihre Ausführungszeit mit der Anzahl der Bits des Eingabearguments zunimmt.
➚ BenchmarkKernighan-4 100000000 12.9 ns/op BenchmarkPopcnt-4 485724267 2.63 ns/op BenchmarkMathBitsOnesCount64-4 1000000000 0.673 ns/op
math/bits.OnesCount64
gewinnt viermal an Geschwindigkeit. Aber verwendet es wirklich eine Hardware-Implementierung oder hat der Compiler den Algorithmus von Hackers Delight besser optimiert? Es ist Zeit, sich mit Assembler zu beschäftigen.
go test -c
Es gibt ein einfaches Dienstprogramm zum Zerlegen des go-Tools objdump, aber ich (im Gegensatz zum Autor des Originalartikels) werde die IDA verwenden.
Hier ist viel los. Am wichtigsten: Die x86-
POPCNT
Anweisung ist, wie wir gehofft hatten, in den Code des Tests selbst integriert. Dies macht Banchmark schneller als Alternativen.
Diese Verzweigung ist interessant.
cmp cs:runtime_x86HasPOPCNT, 0 jz lable
Ja, dies ist Polyphile auf Assembler. Nicht alle Prozessoren unterstützen
POPCNT
. Wenn das Programm vor Ihrem
main
runtime.cpuinit
wird, überprüft die Funktion
runtime.cpuinit
, ob eine erforderliche Anweisung vorhanden ist, und speichert sie in
runtime.x86HasPOPCNT
. Jedes Mal, wenn das Programm prüft, ob
POPCNT
oder eine Polydatei verwendet werden kann. Da sich der Wert von
runtime.x86HasPOPCNT
nach der Initialisierung nicht ändert, ist die Vorhersage der Prozessorverzweigung relativ genau.
Atomzähler
Intrinsische Funktionen sind regulärer Go-Code und können in einem Anweisungsstrom inline sein. Zum Beispiel werden wir einen Zähler mit Methoden aus seltsamen Signaturen von Funktionen des Atompakets abstrahieren.
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() }
Jemand wird denken, dass eine solche OOP zusätzlichen Aufwand verursacht. Go ist jedoch kein Java - die Sprache verwendet zur Laufzeit keine Bindung, es sei denn, Sie verwenden explizit Schnittstellen. Der obige Code wird zu einem effizienten Strom von Prozessoranweisungen zusammengefasst. Wie wird das Hauptbild aussehen?
In Ordnung.
c.inc
wird zu
lock xadd [rax], 1
- atomare Addition von x86.
c.get
wird zur üblichen
mov
Anweisung, die in x86 bereits atomar ist.
c.reset
wird zum atomaren Austausch von
xchg
zwischen einem
xchg
und einem Speicher.
Fazit
Eingebettete Funktionen sind eine übersichtliche Lösung, die den Zugriff auf Operationen auf niedriger Ebene ermöglicht, ohne die Sprachspezifikation zu erweitern. Wenn die Architektur keine spezifischen Synchronisations- / Atomprimitive (wie einige ARM-Varianten) oder Operationen aus Mathe / Bits hat, fügt der Compiler beim reinen Start eine Polydatei ein.