Golang: spezifische Leistungsprobleme

Die Go-Sprache wird immer beliebter. So zuversichtlich, dass es immer mehr Konferenzen gibt, zum Beispiel GolangConf , und die Sprache zu den zehn am höchsten bezahlten Technologien gehört. Daher ist es bereits sinnvoll, über die spezifischen Probleme, beispielsweise die Leistung, zu sprechen. Zusätzlich zu den allgemeinen Problemen für alle kompilierten Sprachen hat Go seine eigenen. Sie sind dem Optimierer, dem Stapel, dem Typsystem und dem Multitasking-Modell zugeordnet. Lösungswege und Problemumgehungen sind manchmal sehr spezifisch.

Daniel Podolsky , obwohl der Evangelist von Go, trifft in ihm auch auf viele seltsame Dinge. Alles Seltsame und vor allem Interessante sammelt und testet und spricht dann in HighLoad ++ darüber. Das Protokoll des Berichts enthält Zahlen, Grafiken, Codebeispiele, Profilerergebnisse, einen Vergleich der Leistung derselben Algorithmen in verschiedenen Sprachen - und alles andere, wofür wir das Wort "Optimierung" so hassen. Das Transkript wird keine Enthüllungen enthalten - woher kamen sie in einer so einfachen Sprache - und alles, worüber in den Zeitungen gelesen werden kann.



Über die Lautsprecher. Daniil Podolsky : 26 Jahre Erfahrung, 20 Jahre in Betrieb, einschließlich des Gruppenleiters, 5 Jahre Programmierung auf Go. Kirill Danshin : Schöpfer von Gramework, Maintainer, Fast HTTP, Black Go-Magier.

Der Bericht wurde gemeinsam von Daniel Podolsky und Kirill Danshin erstellt, aber Daniel machte einen Bericht, und Kirill half mental.

Sprachkonstruktionen


Wir haben einen Leistungsstandard - direct . Dies ist eine Funktion, die eine Variable inkrementiert und nichts mehr tut.

 //   var testInt64 int64 func BenchmarkDirect(b *testing.B) { for i := 0; i < bN; i++ { incDirect() } } func incDirect() { testInt64++ } 

Das Ergebnis der Funktion ist 1,46 ns pro Operation . Dies ist die Mindestoption. Schneller als 1,5 ns pro Operation, wird wahrscheinlich nicht funktionieren.

Verschiebe, wie wir ihn lieben


Viele kennen und lieben es, das verzögerte Sprachkonstrukt zu verwenden. Sehr oft benutzen wir es so.

 func BenchmarkDefer(b *testing.B) { for i := 0; i < bN; i++ { incDefer() } } func incDefer() { defer incDirect() } 

Aber so kann man es nicht benutzen! Jeder Aufschub frisst 40 ns pro Operation.

 //   BenchmarkDirect-4 2000000000 1.46 / // defer BenchmarkDefer-4 30000000 40.70 / 

Ich dachte, vielleicht liegt das an Inline? Vielleicht ist Inline so schnell?

Direkt ist inline und die Verzögerungsfunktion kann nicht inline sein. Daher wurde eine separate Testfunktion ohne Inline kompiliert.

 func BenchmarkDirectNoInline(b *testing.B) { for i := 0; i < bN; i++ { incDirectNoInline() } } //go:noinline func incDirectNoInline() { testInt64++ } 

Nichts hat sich geändert, der Aufschub dauerte die gleichen 40 ns. Lieber, aber nicht katastrophal.

Wenn eine Funktion weniger als 100 ns benötigt, können Sie auf eine Verzögerung verzichten.

Wenn die Funktion jedoch länger als eine Mikrosekunde dauert, ist sie alle gleich - Sie können die Verzögerung verwenden.

Übergabe eines Parameters als Referenz


Betrachten Sie einen populären Mythos.

 func BenchmarkDirectByPointer(b *testing.B) { for i := 0; i < bN; i++ { incDirectByPointer(&testInt64) } } func incDirectByPointer(n *int64) { *n++ } 

Nichts hat sich geändert - nichts ist es wert.

 //     BenchmarkDirectByPointer-4 2000000000 1.47 / BenchmarkDeferByPointer-4 30000000 43.90 / 

Mit Ausnahme von 3 ns pro Aufschub wird dieser jedoch für Schwankungen abgeschrieben.

Anonyme Funktionen


Manchmal fragen Neulinge: "Ist eine anonyme Funktion teuer?"

 func BenchmarkDirectAnonymous(b *testing.B) { for i := 0; i < bN; i++ { func() { testInt64++ }() } } 

Eine anonyme Funktion ist nicht teuer, sie dauert 40,4 ns.

Schnittstellen


Es gibt eine Schnittstelle und Struktur, die dies implementiert.

 type testTypeInterface interface { Inc() } type testTypeStruct struct { n int64 } func (s *testTypeStruct) Inc() { s.n++ } 

Es gibt drei Optionen für die Verwendung der Inkrementierungsmethode. Direkt von Struct: var testStruct = testTypeStruct{} .

Über die entsprechende konkrete Schnittstelle: var testInterface testTypeInterface = &testStruct .

Bei der Konvertierung der Laufzeitschnittstelle: var testInterfaceEmpty interface{} = &testStruct .

Im Folgenden finden Sie die Konvertierung und Verwendung der Laufzeitschnittstelle direkt.

 func BenchmarkInterface(b *testing.B) { for i := 0; i < bN; i++ { testInterface.Inc() } } func BenchmarkInterfaceRuntime(b *testing.B) { for i := 0; i < bN; i++ { testInterfaceEmpty.(testTypeInterface).Inc() } } 

Die Schnittstelle als solche kostet nichts.

 //  BenchmarkStruct-4 2000000000 1.44 / BenchmarkInterface-4 2000000000 1.88 / BenchmarkInterfaceRuntime-4 200000000 9.23 / 


Kosten für die Konvertierung der Laufzeitschnittstelle, aber nicht teuer - Sie müssen dies nicht ausdrücklich ablehnen. Aber versuchen Sie, wo immer möglich darauf zu verzichten.

Mythen:

  • Dereferenzierung - Dereferenzierungszeiger - frei.
  • Anonyme Funktionen sind kostenlos.
  • Schnittstellen sind kostenlos.
  • Konvertierung der Laufzeitschnittstelle - NICHT KOSTENLOS.

Wechseln, zuordnen und schneiden


Jeder Neuling fragt, was passiert, wenn Sie den Schalter durch eine Karte ersetzen. Wird es schneller sein?

Schalter gibt es in verschiedenen Größen. Ich habe drei Größen getestet: klein für 10 Fälle, mittel für 100 und groß für 1000 Fälle. Schalter für 1000 Fälle finden Sie im realen Produktionscode. Natürlich schreibt niemand sie mit seinen Händen. Dies ist automatisch generierter Code, normalerweise ein Typschalter. Getestet an zwei Typen: int und string. Es schien, als würde es klarer werden.

Kleiner Schalter. Die schnellste Option ist der eigentliche Schalter. Es folgt sofort ein Slice, in dem der entsprechende Integer-Index einen Verweis auf die Funktion enthält. Map ist weder in int noch in string ein Leader.
BenchmarkSwitchIntSmall-45000000003,26 ns / op
BenchmarkMapIntSmall-4100.000.00011,70 ns / op
BenchmarkSliceIntSmall-45000000003,85 ns / op
BenchmarkSwitchStringSmall-4100.000.00012,70 ns / op
BenchmarkMapStringSmall-4100.000.00015,60 ns / op

Das Einschalten von Strings ist erheblich langsamer als bei int. Wenn Sie nicht zu string, sondern zu int wechseln können, tun Sie dies.

Mittlerer Schalter. Switch selbst regiert immer noch int, aber Slice hat es ein bisschen überholt. Karte ist immer noch schlecht. Bei einem String-Schlüssel ist die Map jedoch schneller als der Switch - wie erwartet.
BenchmarkSwitchIntMedium-43000000004,55 ns / op
BenchmarkMapIntMedium-4100.000.00017,10 ns / op
BenchmarkSliceIntMedium-43000000003,76 ns / op
BenchmarkSwitchStringMedium-450.000.00028,50 ns / op
BenchmarkMapStringMedium-4100.000.00020.30 ns / op

Großer Schalter. Tausend Fälle zeigen den bedingungslosen Sieg der Karte in der Nominierung „Switch by String“. Theoretisch hat Slice gewonnen, aber in der Praxis empfehle ich, hier denselben Schalter zu verwenden. Die Karte ist immer noch langsam, selbst wenn man bedenkt, dass die Karte Ganzzahlschlüssel mit einer speziellen Hash-Funktion hat. Im Allgemeinen macht diese Funktion nichts. Das int selbst hat einen Hash für int.
BenchmarkSwitchIntLarge-4100.000.00013,6 ns / op
BenchmarkMapIntLarge-450.000.00034,3 ns / op
BenchmarkSliceIntLarge-4100.000.00012,8 ns / op
BenchmarkSwitchStringLarge-420.000.000100,0 ns / op
BenchmarkMapStringLarge-43000000037,4 ns / op

Schlussfolgerungen Die Karte ist nur bei großen Mengen besser und nicht bei einer ganzzahligen Bedingung. Ich bin sicher, dass es sich unter allen Bedingungen außer int genauso verhält wie auf string. Slice lenkt immer, wenn die Bedingungen ganzzahlig sind. Verwenden Sie diese Option, wenn Sie Ihr Programm um 2 ns beschleunigen möchten.

Interroutine Interaktion


Das Thema ist komplex, ich habe viele Tests durchgeführt und werde die aufschlussreichsten vorstellen. Wir kennen die folgenden Mittel zur Interaktion zwischen Agenturen .

  • Atomic Dies sind Mittel mit eingeschränkter Anwendbarkeit - Sie können den Zeiger ersetzen oder int verwenden.
  • Mutex ist seit Java weit verbreitet.
  • Der Kanal ist einzigartig für GO.
  • Gepufferter Kanal - gepufferte Kanäle.

Natürlich habe ich an einer deutlich größeren Anzahl von Goroutinen getestet, die um eine Ressource konkurrieren. Aber er wählte drei als Indikator: ein wenig - 100, ein mittleres - 1000 und viel - 10000.

Das Lastprofil ist unterschiedlich . Manchmal möchten alle Gorutins in eine Variable schreiben, aber das ist selten. Normalerweise schreiben schließlich einige, andere lesen. Von den meisten Lesern - 90% lesen, von denen, die schreiben - schreiben 90%.

Dies ist der Code, der verwendet wird, damit die Goroutine, die den Kanal bedient, sowohl das Lesen als auch das Schreiben in eine Variable ermöglicht.

 go func() { for { select { case n, ok := <-cw: if !ok { wgc.Done() return } testInt64 += n case cr <- testInt64: } } }() 

Wenn eine Nachricht über den Kanal, über den wir schreiben, bei uns eintrifft, führen wir sie aus. Wenn der Kanal geschlossen ist, beenden wir Goroutin. Wir sind jederzeit bereit, in den Kanal zu schreiben, der von anderen Goroutinen zum Lesen verwendet wird.
Benchmarkmutex-4100.000.00016.30 ns / op
Benchmarkatomic-42000000006,72 ns / op
Benchmarkcan-45.000.000239,00 ns / op

Dies sind Daten für eine Goroutine. Der Kanaltest wird an zwei Goroutinen durchgeführt: Eine verarbeitet den Kanal, die andere schreibt in diesen Kanal. Und diese Optionen wurden an einem getestet.

  • Direktes Schreiben in eine Variable.
  • Mutex nimmt ein Protokoll, schreibt in eine Variable und gibt ein Protokoll frei.
  • Atomic schreibt über Atomic in eine Variable. Es ist nicht kostenlos, aber immer noch deutlich billiger als Mutex auf einem Garutin.

Mit einer kleinen Menge Goroutine ist der Atomic eine effektive und schnelle Möglichkeit zur Synchronisierung, was nicht überraschend ist. Direkt ist nicht hier, weil wir eine Synchronisation brauchen, die es nicht bietet. Aber Atomic hat natürlich Mängel.
BenchmarkMutexFew-43000055894 ns / op
BenchmarkAtomicFew-4100.00014585 ns / op
BenchmarkChanFew-45000323859 ns / op
BenchmarkChanBufferedFew-45000341321 ns / op
BenchmarkChanBufferedFullFew-42000070052 ns / op
BenchmarkMutexMostlyReadFew-43000056402 ns / op
BenchmarkAtomicMostlyReadFew-41.000.0002094 ns / op
BenchmarkChanMostlyReadFew-43000442689 ns / op
BenchmarkChanBufferedMostlyReadFew-43000449.666 ns / op
BenchmarkChanBufferedFullMostlyReadFew-45000442.708 ns / op
BenchmarkMutexMostlyWriteFew-42000079708 ns / op
BenchmarkAtomicMostlyWriteFew-4100.00013358 ns / op
BenchmarkChanMostlyWriteFew-43000449.556 ns / op
BenchmarkChanBufferedMostlyWriteFew-43000445423 ns / op
BenchmarkChanBufferedFullMostlyWriteFew-43000414626 ns / op

Als nächstes kommt Mutex. Ich habe erwartet, dass Channel ungefähr so ​​schnell ist wie Mutex, aber nein.

Channel ist eine Größenordnung teurer als Mutex.

Darüber hinaus werden Channel und gepufferter Channel ungefähr zum gleichen Preis angeboten. Und es gibt einen Kanal, in dem der Puffer niemals überläuft. Es ist eine Größenordnung billiger als dasjenige, dessen Puffer überläuft. Nur wenn der Puffer im Kanal nicht voll ist, kostet er ungefähr die gleichen Größenordnungen wie Mutex. Das habe ich vom Test erwartet.

Dieses Bild mit der Verteilung der Kosten wird in jedem Lastprofil wiederholt - sowohl in MostlyRead als auch in MostlyWrite. Darüber hinaus kostet der vollständige MostlyRead-Kanal das gleiche wie der unvollständige. Und der gepufferte Kanal von MostlyWrite, in dem der Puffer nicht voll ist, kostet das gleiche wie der Rest. Ich kann nicht sagen, warum dies so ist - ich habe dieses Problem noch nicht untersucht.

Parameter übergeben


Wie kann man Parameter schneller übergeben - als Referenz oder nach Wert? Lass es uns überprüfen.

Ich habe Folgendes überprüft - verschachtelte Typen von 1 bis 10 erstellt.

 type TP001 struct { I001 int64 } type TV002 struct { I001 int64 S001 TV001 I002 int64 S002 TV001 } 

Der zehnte verschachtelte Typ hat 10 int64-Felder, und die verschachtelten Typen der vorherigen Verschachtelung sind ebenfalls 10.

Dann schrieb er Funktionen, die eine Art Verschachtelung erzeugen.

 func NewTP001() *TP001 { return &TP001{ I001: rand.Int63(), } } func NewTV002() TV002 { return TV002{ I001: rand.Int63(), S001: NewTV001(), I002: rand.Int63(), S002: NewTV001(), } } 

Zum Testen habe ich drei Optionen des Typs verwendet: klein mit Verschachtelung 2, mittel mit Verschachtelung 3, groß mit Verschachtelung 5. Ich musste nachts einen sehr großen Test mit Verschachtelung 10 durchführen, aber dort ist das Bild genau das gleiche wie für 5.

In Funktionen ist das Übergeben von Werten mindestens doppelt so schnell wie das Übergeben von Referenzen . Dies liegt an der Tatsache, dass durch Übergeben eines Werts die Escape-Analyse nicht geladen wird. Dementsprechend befinden sich die Variablen, die wir zuweisen, auf dem Stapel. Es ist wesentlich billiger für die Laufzeit, für Garbage Collector. Obwohl er möglicherweise keine Zeit hat, sich zu verbinden. Diese Tests dauerten einige Sekunden - der Müllsammler schlief wahrscheinlich noch.
BenchmarkCreateSmallByValue-4200.0008942 ns / op
BenchmarkCreateSmallByPointer-4100.00015985 ns / op
BenchmarkCreateMediuMByValue-42000862317 ns / op
BenchmarkCreateMediuMByPointer-420001228130 ns / op
BenchmarkCreateLargeByValue-43047398456 ns / op
BenchmarkCreateLargeByPointer-42061928751 ns / op

Schwarze Magie


Wissen Sie, was dieses Programm ausgeben wird?

 package main type A struct { a, b int32 } func main() { a := new(A) aa = 0 ab = 1 z := (*(*int64)(unsafe.Pointer(a))) fmt.Println(z) } 

Das Ergebnis des Programms hängt von der Architektur ab, auf der es ausgeführt wird. Auf Little Endian, z. B. AMD64, wird das Programm angezeigt 232. Auf Big Endian, einer. Das Ergebnis ist anders, da diese Einheit bei Little Endian in der Mitte der Zahl und bei Big Endian am Ende erscheint.

Es gibt immer noch Prozessoren auf der Welt, auf denen Endian-Switches verwendet werden, z. B. Power PC. Sie müssen herausfinden, welches Endian beim Start auf Ihrem Computer konfiguriert ist, bevor Sie Rückschlüsse darauf ziehen, was unsichere Tricks bewirken. Wenn Sie beispielsweise einen Go-Code schreiben, der auf einem IBM Multiprozessorserver ausgeführt wird.

Ich habe diesen Code zitiert, um zu erklären, warum ich alle unsichere schwarze Magie betrachte. Sie müssen es nicht verwenden. Aber Cyril glaubt, dass es notwendig ist. Und hier ist warum.

Es gibt eine Funktion, die dasselbe tut wie GOB - Go Binary Marshaller. Dies ist Encoder, aber auf unsicher.

 func encodeMut(data []uint64) (res []byte) { sz := len(data) * 8 dh := (*header)(unsafe.Pointer(&data)) rh := &header{ data: dh.data, len: sz, cap: sz, } res = *(*[]byte)(unsafe.Pointer(&rh)) return } 

Tatsächlich benötigt es ein Stück Speicher und zieht ein Array von Bytes daraus.

Dies ist nicht einmal eine Bestellung - dies sind zwei Bestellungen. Daher zögert Cyril Danshin, wenn er einen Hochleistungscode schreibt, nicht, sich auf die Eingeweide seines Programms einzulassen und es unsicher zu machen.

Benchmark Gob-4200.0008466 ns / op120,94 MB / s
BenchmarkUnsafeMut-450.000.00037 ns / op27691,06 MB / s
Wir werden am 7. Oktober auf der GolangConf spezifischere Funktionen von Go diskutieren - eine Konferenz für diejenigen, die Go in der beruflichen Entwicklung einsetzen, und für diejenigen, die diese Sprache als Alternative betrachten. Daniil Podolsky ist nur ein Mitglied des Programmausschusses. Wenn Sie mit diesem Artikel streiten oder verwandte Themen aufdecken möchten, reichen Sie einen Antrag für einen Bericht ein.

Für alles andere, was die hohe Leistung betrifft , natürlich HighLoad ++ . Wir akzeptieren dort auch Bewerbungen. Melden Sie sich für den Newsletter an und bleiben Sie über die Neuigkeiten aller unserer Konferenzen für Webentwickler auf dem Laufenden.

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


All Articles