bytes.Buffer in Go: Optimierungen, die nicht funktionieren

Viele Go- Programmierer sind mit Bytes vertraut. Einer der Vorteile besteht darin, dass Sie die Zuweisung von Speicher auf dem Heap auf die gleiche Weise wie bei der Optimierung kleiner Puffer / Größen vermeiden können:


type Buffer struct { bootstrap [64]byte //        // ...   } 

Es gibt nur ein Problem. Diese Optimierung funktioniert nicht .


Am Ende dieses Artikels erfahren Sie, warum diese Optimierung nicht funktioniert und was wir dagegen tun können.


Wie beabsichtigt, "Small Buffer Optimization"


Lassen Sie uns eine leicht vereinfachte Definition von bytes.Buffer einführen. bytes.Buffer :


 const smallBufSize int = 64 type Buffer struct { bootstrap [smallBufSize]byte buf []byte } 

Wenn wir beispielsweise Aktionen für den Buffer ausführen, rufen Sie die Buffer.Write Methode auf. Der Datensatz wird immer in buf . Vor diesem Datensatz wird jedoch Buffer.grow(n) in jeder ähnlichen Methode gestartet, um sicherzustellen, dass in diesem Slice genügend Speicherplatz für vorhanden ist nächste n Bytes.


Wachsen kann ungefähr so ​​aussehen:


 func (b *Buffer) grow(n int) { //         bytes.Buffer. l := len(b.buf) //   Buffer need := n + l have := cap(b.buf) - l if have >= need { b.buf = b.buf[:need] return } if need <= smallBufSize { //     , //   . b.buf = b.bootstrap[:] } else { // growFactor -     . //     need  need*2. newBuf := make([]byte, need, growFactor(need)) copy(newBuf, b.buf) b.buf = newBuf } } 

Annahmen, die bei unserer Implementierung von Buffer.grow verwendet wurden


Wir gehen davon aus, dass len(b.buf) die tatsächliche len(b.buf) in Buffer ist, für die Write Append-Methoden verwenden müsste, um dem Slice neue Bytes hinzuzufügen. Dies ist in bytes.Buffer nicht der Fall. bytes.Buffer aus der Standardbibliothek, aber als Beispiel ist dies ein irrelevantes Implementierungsdetail.




Wenn b auf dem Stapel zugewiesen b wird der darin enthaltene bootstrap auf dem Stapel zugewiesen, was bedeutet, dass das Slice b.buf den Speicher in b wiederverwendet, ohne dass eine zusätzliche Zuordnung erforderlich ist.


Wenn das grow zeigt, dass das bootstrap Array bereits nicht ausreicht, wird ein neues, "echtes" Slice erstellt, in das Elemente aus dem vorherigen Speicher (aus dem "kleinen Puffer") kopiert werden. Danach verliert Buffer.bootstrap seine Relevanz. Wenn Buffer.Reset wird, bleibt cap(b.buf) gleich und es wird kein bootstrap Array mehr benötigt.


Speicher läuft im Haufen weg


Es wird weiterhin erwartet, dass der Leser zumindest oberflächlich mit der Fluchtanalyse in Go vertraut ist.


Betrachten Sie die folgende Situation:


 func f() *Buffer { var b bytes.Buffer // leak.go:11:6: moved to heap: b return &b // leak.go:12:9: &b escapes to heap } 

Hier wird b auf dem Heap zugeordnet. Der Grund dafür ist der undichte Zeiger auf b :


 $ go tool compile -m leak.go leak.go:12:9: &b escapes to heap leak.go:11:6: moved to heap: b 

Terminologie


In diesem Artikel werden "undicht" und "Flucht" fast synonym verwendet.


Es gibt einige Unterschiede im Compiler selbst, zum Beispiel den Wert "Escape to Heap", aber die Funktionsparameter sind "Leaking Param x".


Ein undichter Parameter bedeutet, dass das übergebene Argument für diesen Parameter auf dem Heap zugewiesen wird. Mit anderen Worten, der undichte Parameter bewirkt, dass die Argumente in einen Heap übergehen.




Das Obige war ein offensichtlicher Fall, aber was ist damit:


 func length() int { var b bytes.Buffer b.WriteString("1") return b.Len() } 

Hier brauchen wir nur 1 Byte, alles passt in den bootstrap , der Puffer selbst ist lokal und "entkommt" nicht der Funktion. Sie werden überrascht sein, aber das Ergebnis ist das gleiche, Zuordnung b auf dem Heap.



Um sicherzugehen, können Sie dies anhand des Benchmarks überprüfen:


 BenchmarkLength-8 20000000 90.1 ns/op 112 B/op 1 allocs/op 

Benchmark-Listung


 package p import ( "bytes" "testing" ) func length() int { var b bytes.Buffer b.WriteString("1") return b.Len() } func BenchmarkLength(b *testing.B) { for i := 0; i < bN; i++ { _ = length() } } 



Erläuterung 112 B / op


Wenn die Laufzeit den Allokator nach N Bytes fragt, müssen nicht genau N Bytes zugewiesen werden.


Alle folgenden Ergebnisse beziehen sich auf die Kombination von GOOS=linux und GOARCH=AMD64 .

 package benchmark import "testing" //go:noinline func alloc9() []byte { return make([]byte, 9) } func BenchmarkAlloc9(b *testing.B) { for i := 0; i < bN; i++ { _ = alloc9() } } 

Wenn Sie ausführen go test -bench=. -benchmem go test -bench=. -benchmem mit diesem Test:


 BenchmarkAlloc9-8 50000000 33.5 ns/op 16 B/op 1 allocs/op 

9 Bytes angefordert, 16 zugewiesen. Jetzt zurück zu den Bytes. bytes.Buffer :


 fmt.Println(unsafe.Sizeof(bytes.Buffer{})) => 104 

Schauen wir uns $ GOROOT / src / runtime / sizeclasses.go an :


 // class bytes/obj bytes/span objects tail waste max waste // 1 8 8192 1024 0 87.50% // 2 16 8192 512 0 43.75% // 3 32 8192 256 0 46.88% // 4 48 8192 170 32 31.52% // 5 64 8192 128 0 23.44% // 6 80 8192 102 32 19.07% // 7 96 8192 85 32 15.95% // 8 112 8192 73 16 13.56% // ...  

Es passt nicht in 96 Bytes, 112 ist ausgewählt.




Aber warum passiert das?


Was passiert und warum?


Eine Analyse der Situation findet sich in dem eingangs erwähnten Thema .
Es gibt auch einen einfachen Wiedergabegerät .


Der Problemort befindet sich nur in der Zuweisung b.buf = b.bootstrap[:] . Bei diesem Code wird bei der Escape-Analyse davon ausgegangen, dass b.bootstrap "wegläuft". Da es sich um ein Array handelt, wird es im Objekt selbst gespeichert. Dies bedeutet, dass alle b auf dem Heap zugewiesen werden müssen.


Wenn Bootstrap ein Slice und kein Array wäre, würde dies nicht passieren, da es eine Ad-hoc-Optimierung für die Zuweisung von Slices vom Objekt zum Objekt selbst gibt:


 //   ,     , // object      . object.buf1 = object.buf2[a:b] 

Die Antwort, warum diese Optimierung für Arrays nicht funktioniert, wurde bereits oben formuliert. Hier ist jedoch ein Auszug aus esc.go # L835-L866 selbst (der gesamte Optimierungscode wird durch Bezugnahme hervorgehoben):


 // Note, this optimization does not apply to OSLICEARR, // because it does introduce a new pointer into b that was not already there // (pointer to b itself). After such assignment, if b contents escape, // b escapes as well. If we ignore such OSLICEARR, we will conclude // that b does not escape when b contents do. 

Es ist erwähnenswert, dass es für den Zeigeranalysator mehrere Ebenen von "Lecks" gibt, die wichtigsten davon:


  1. Das Objekt selbst entkommt (b entkommt). In diesem Fall muss das Objekt selbst auf dem Heap zugewiesen werden.
  2. Die Elemente des Objekts (b Inhalt entkommen) entweichen. In diesem Fall werden die Zeiger im Objekt als maskiert betrachtet.

Der Fall mit dem Array ist insofern besonders, als wenn das Array undicht ist, das Objekt, das es enthält, ebenfalls undicht sein muss.


Die Escape-Analyse entscheidet, ob es möglich ist, ein Objekt auf dem Stapel zu platzieren oder nicht, und stützt sich dabei nur auf Informationen, die im Hauptteil der analysierten Funktion verfügbar sind. Die Buffer.grow Methode verwendet b Zeiger, daher muss eine geeignete Stelle zum Platzieren berechnet werden. Da wir im Fall eines Arrays "b escape" von "b contents escape" , müssen wir pessimistischer sein und zu dem Schluss kommen, dass b nicht sicher auf dem Stapel platziert werden kann.


Angenommen, das self-assignment für Arrays genauso aufgelöst wie für Slices:


 package example var sink interface{} type bad struct { array [10]byte slice []byte } func (b *bad) bug() { b.slice = b.array[:] // ignoring self-assignment to b.slice sink = b.array // b.array escapes to heap // b does not escape } 

Die Entscheidung, b in dieser Situation auf den Stapel zu legen, führt zu einer Katastrophe: Nach dem Verlassen der Funktion, in der b erstellt wurde, ist der Speicher, auf den sich die sink bezieht, nichts anderes als Müll.


Array-Zeiger


Stellen Sie sich vor, unser Buffer wurde etwas anders deklariert:


 const smallBufSize int = 64 type Buffer struct { - bootstrap [smallBufSize]byte + bootstrap *[smallBufSize]byte buf []byte } 

Im Gegensatz zu einem normalen Array speichert ein Zeiger auf ein Array nicht alle Elemente im Buffer selbst. Dies bedeutet, dass, wenn die bootstrap Zuweisung auf dem Heap keine Buffer auf dem Heap beinhaltet. Da die Escape-Analyse nach Möglichkeit Zeigerfelder auf dem Stapel zuordnen kann, können wir davon ausgehen, dass eine solche Buffer erfolgreicher ist.


Das ist aber theoretisch. In der Praxis hat ein Zeiger auf ein Array nicht viel Verarbeitung und fällt in dieselbe Kategorie wie ein Slice aus einem regulären Array, was nicht ganz korrekt ist. CL133375: cmd / compile / internal / gc: Selbstzuweisung von Array-Slice-Handles in esc.go soll diese Situation korrigieren.


Angenommen, diese Änderung wurde in den Go-Compiler übernommen.


Nullwert haben wir verloren


Leider hat der Übergang von [64]byte zu *[64]byte ein Problem: Jetzt können wir bootstrap nicht mehr verwenden, ohne es explizit zu initialisieren. Ein Nullwert von Buffer nicht mehr nützlich. Wir benötigen einen Konstruktor.


 func NewBuffer() Buffer { return Buffer{bootstrap: new(*[smallBufSize]byte)} } 

Wir geben Buffer , nicht *Buffer , um Probleme bei der Analyse von Zeigern zu vermeiden (dies ist in Go sehr konservativ). Unter Berücksichtigung der Tatsache, dass NewBuffer immer an der Stelle eines Aufrufs NewBuffer , wird nicht unnötig kopiert.


Nach dem Einbetten des NewBuffer Körpers an die Stelle des Escape-Aufrufs kann die Analyse versuchen zu beweisen, dass new(*[smallBufSize]byte) die Lebensdauer des Frames der Funktion, in der es aufgerufen wird, nicht überschreitet. Wenn ja, befindet sich die Zuordnung auf dem Stapel.


Intel Bytebuf


Die oben beschriebene Optimierung wird im Paket intel-go / bytebuf angewendet .


Diese Bibliothek exportiert den Typ bytebuf.Buffer , der 99,9% bytes.Buffer . Alle Änderungen werden auf die Einführung eines Konstruktors ( bytebuf.New ) und eines Zeigers auf ein Array anstelle eines regulären Arrays reduziert:


 type Buffer struct { buf []byte // contents are the bytes buf[off : len(buf)] off int // read at &buf[off], write at &buf[len(buf)] - bootstrap [64]byte // helps small buffers avoid allocation. + bootstrap *[64]byte // helps small buffers avoid allocation. lastRead readOp // last read operation (for Unread*). } 

Hier ist ein Leistungsvergleich mit bytes.Buffer :


 name old time/op new time/op delta String/empty-8 138ns ±13% 24ns ± 0% -82.94% (p=0.000 n=10+8) String/5-8 186ns ±11% 60ns ± 1% -67.82% (p=0.000 n=10+10) String/64-8 225ns ±10% 108ns ± 6% -52.26% (p=0.000 n=10+10) String/128-8 474ns ±17% 338ns ±13% -28.57% (p=0.000 n=10+10) String/1024-8 889ns ± 0% 740ns ± 1% -16.78% (p=0.000 n=9+10) name old alloc/op new alloc/op delta String/empty-8 112B ± 0% 0B -100.00% (p=0.000 n=10+10) String/5-8 117B ± 0% 5B ± 0% -95.73% (p=0.000 n=10+10) String/64-8 176B ± 0% 64B ± 0% -63.64% (p=0.000 n=10+10) String/128-8 368B ± 0% 256B ± 0% -30.43% (p=0.000 n=10+10) String/1024-8 2.16kB ± 0% 2.05kB ± 0% -5.19% (p=0.000 n=10+10) name old allocs/op new allocs/op delta String/empty-8 1.00 ± 0% 0.00 -100.00% (p=0.000 n=10+10) String/5-8 2.00 ± 0% 1.00 ± 0% -50.00% (p=0.000 n=10+10) String/64-8 2.00 ± 0% 1.00 ± 0% -50.00% (p=0.000 n=10+10) String/128-8 3.00 ± 0% 2.00 ± 0% -33.33% (p=0.000 n=10+10) String/1024-8 3.00 ± 0% 2.00 ± 0% -33.33% (p=0.000 n=10+10) 

Alle anderen Informationen finden Sie in README .


Aufgrund der Unfähigkeit, den Wert Null zu verwenden und an die Konstruktionsfunktion New zu bytes.Buffer , ist es nicht möglich, diese Optimierung auf bytes.Buffer .


Ist dies der einzige Weg, um schnellere bytes.Buffer zu bytes.Buffer . Die Antwort ist nein. Dies ist jedoch definitiv eine Methode, die nur minimale Änderungen in der Implementierung erfordert.


Fluchtanalysepläne


In der aktuellen Form ist die Escape-Analyse in Go ziemlich schwach. Fast jede Operation mit Zeigerwerten führt zu Zuweisungen auf dem Heap, auch wenn dies keine vernünftige Entscheidung ist.


Ich werde versuchen, die meiste Zeit, die ich dem Golang / Go- Projekt widme, zu lenken , um diese Probleme zu lösen, sodass in der kommenden Version (1.12) einige Verbesserungen möglich sind.


Über die Ergebnisse und Details der internen Struktur dieses Teils des Compilers können Sie in einem meiner nächsten Artikel lesen. Ich werde versuchen, eine Reihe von Empfehlungen bereitzustellen, die in einigen Fällen helfen, den Code so zu strukturieren, dass er weniger unerwünschte Speicherzuordnungen aufweist.

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


All Articles