Sprachmechanik der Speicherprofilerstellung

Auftakt



Dies ist der dritte von vier Artikeln in einer Reihe, die Einblicke in die Mechanik und das Design von Zeigern, Stapeln, Haufen, Escape-Analysen und Wert- / Zeigersemantik in Go bieten. In diesem Beitrag geht es um die Erstellung von Speicherprofilen.



Inhaltsverzeichnis des Artikelzyklus:



  1. Sprachmechanik auf Stapeln und Zeigern ( Übersetzung )
  2. Sprachmechanik zur Fluchtanalyse ( Übersetzung )
  3. Sprachmechanik zur Speicherprofilerstellung
  4. Designphilosophie zu Daten und Semantik


Sehen Sie sich dieses Video an, um eine Demo dieses Codes zu sehen:

DGopherCon Singapore (2017) - Escape Analysis



Einführung



In einem früheren Beitrag habe ich die Grundlagen der Escape-Analyse anhand eines Beispiels vermittelt, das einen Wert auf einem Goroutine-Stapel aufteilt. Ich habe Ihnen keine anderen Szenarien gezeigt, die zu Heap-Werten führen könnten. Um Ihnen dabei zu helfen, werde ich ein Programm debuggen, das Zuweisungen auf unerwartete Weise vornimmt.



Programm



Ich wollte mehr über das io-Paket erfahren, also habe ich mir eine kleine Aufgabe ausgedacht. Schreiben Sie bei einem gegebenen Bytestrom eine Funktion, die den String elvis finden und durch den großgeschriebenen String elvis ersetzen kann. Wir sprechen von einem König, daher sollte sein Name immer groß geschrieben werden.



Hier ist ein Link zur Lösung: play.golang.org/p/n_SzF4Cer4

Hier ist ein Link zu Benchmarks: play.golang.org/p/TnXrxJVfLV



Die Liste zeigt zwei verschiedene Funktionen, die diese Aufgabe erfüllen. Dieser Beitrag konzentriert sich auf die algOne-Funktion, die das io-Paket verwendet. Verwenden Sie die Funktion algTwo, um selbst mit Speicher- und Prozessorprofilen zu experimentieren.



Hier ist die Eingabe, die wir verwenden werden, und die erwartete Ausgabe der algOne-Funktion.



Listing 1:



Input:
abcelvisaElvisabcelviseelvisaelvisaabeeeelvise l v i saa bb e l v i saa elvi
selvielviselvielvielviselvi1elvielviselvis

Output:
abcElvisaElvisabcElviseElvisaElvisaabeeeElvise l v i saa bb e l v i saa elvi
selviElviselvielviElviselvi1elviElvisElvis


Unten finden Sie eine Auflistung der algOne-Funktion.



Listing 2:



 80 func algOne(data []byte, find []byte, repl []byte, output *bytes.Buffer) {
 81
 82     // Use a bytes Buffer to provide a stream to process.
 83     input := bytes.NewBuffer(data)
 84
 85     // The number of bytes we are looking for.
 86     size := len(find)
 87
 88     // Declare the buffers we need to process the stream.
 89     buf := make([]byte, size)
 90     end := size - 1
 91
 92     // Read in an initial number of bytes we need to get started.
 93     if n, err := io.ReadFull(input, buf[:end]); err != nil {
 94         output.Write(buf[:n])
 95         return
 96     }
 97
 98     for {
 99
100         // Read in one byte from the input stream.
101         if _, err := io.ReadFull(input, buf[end:]); err != nil {
102
103             // Flush the reset of the bytes we have.
104             output.Write(buf[:end])
105             return
106         }
107
108         // If we have a match, replace the bytes.
109         if bytes.Compare(buf, find) == 0 {
110             output.Write(repl)
111
112             // Read a new initial number of bytes.
113             if n, err := io.ReadFull(input, buf[:end]); err != nil {
114                 output.Write(buf[:n])
115                 return
116             }
117
118             continue
119         }
120
121         // Write the front byte since it has been compared.
122         output.WriteByte(buf[0])
123
124         // Slice that front byte out.
125         copy(buf, buf[1:])
126     }
127 }


Ich möchte wissen, wie gut diese Funktion funktioniert und wie viel Druck sie auf den Haufen ausübt. Um dies herauszufinden, führen wir einen Benchmark durch.



Benchmarking



Ich habe einen Benchmark geschrieben, der die algOne-Funktion aufruft, um die Verarbeitung des Datenstroms durchzuführen.



Listing 3:



15 func BenchmarkAlgorithmOne(b *testing.B) {
16     var output bytes.Buffer
17     in := assembleInputStream()
18     find := []byte("elvis")
19     repl := []byte("Elvis")
20
21     b.ResetTimer()
22
23     for i := 0; i < b.N; i++ {
24         output.Reset()
25         algOne(in, find, repl, &output)
26     }
27 }


Wir können diesen Benchmark mithilfe des Go-Tests mit den Switches -bench, -benchtime und -benchmem ausführen.



Listing 4:



$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem
BenchmarkAlgorithmOne-8        2000000          2522 ns/op       117 B/op            2 allocs/op


Nach dem Ausführen des Benchmarks sehen wir, dass die algOne-Funktion 2 Werte mit Gesamtkosten von 117 Bytes pro Operation zuweist. Das ist großartig, aber wir müssen wissen, welche Codezeilen in der Funktion diese Zuweisungen verursachen. Um dies herauszufinden, müssen wir Profildaten für diesen Test generieren.



Profilerstellung



Führen Sie den Benchmark erneut aus, um Profildaten zu generieren. Dieses Mal werden wir jedoch das Speicherprofil mit dem Schalter -memprofile abfragen.



Listing 5:



$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem -memprofile mem.out
BenchmarkAlgorithmOne-8        2000000          2570 ns/op       117 B/op            2 allocs/op


Nach Abschluss des Benchmarks erstellte das Testtool zwei neue Dateien.



Listing 6:



~/code/go/src/.../memcpu
$ ls -l
total 9248
-rw-r--r--  1 bill  staff      209 May 22 18:11 mem.out       (NEW)
-rwxr-xr-x  1 bill  staff  2847600 May 22 18:10 memcpu.test   (NEW)
-rw-r--r--  1 bill  staff     4761 May 22 18:01 stream.go
-rw-r--r--  1 bill  staff      880 May 22 14:49 stream_test.go


Der Quellcode befindet sich im Ordner memcpu in der Funktion algOne der Datei stream.go und in der Benchmark-Funktion in der Datei stream_test.go. Die beiden neu erstellten Dateien heißen mem.out und memcpu.test. Die Datei mem.out enthält die Profildaten, und die Datei memcpu.test, benannt nach dem Ordner, enthält die Testbinärdatei, die wir benötigen, um auf die Symbole zuzugreifen, wenn wir die Profildaten betrachten.



Mit den Profildaten und der Testbinärdatei können wir das pprof-Tool ausführen, um die Profildaten zu untersuchen.



Listing 7:



$ go tool pprof -alloc_space memcpu.test mem.out
Entering interactive mode (type "help" for commands)
(pprof) _


Wenn Sie Speicherprofile erstellen und nach niedrig hängenden Früchten suchen, können Sie die Option -alloc_space anstelle der Standardoption -inuse_space verwenden. Dies zeigt Ihnen, wo jede Zuordnung stattfindet, ob sie sich im Speicher befindet oder nicht, wenn Sie das Profil abrufen.



Im Eingabefeld (pprof) können wir die Funktion algOne mit dem Befehl list überprüfen. Dieser Befehl akzeptiert einen regulären Ausdruck als Argument, um die Funktion (en) zu finden, die Sie anzeigen möchten.



Listing 8:



(pprof) list algOne
Total: 335.03MB
ROUTINE ======================== .../memcpu.algOne in code/go/src/.../memcpu/stream.go
 335.03MB   335.03MB (flat, cum)   100% of Total
        .          .     78:
        .          .     79:// algOne is one way to solve the problem.
        .          .     80:func algOne(data []byte, find []byte, repl []byte, output *bytes.Buffer) {
        .          .     81:
        .          .     82: // Use a bytes Buffer to provide a stream to process.
 318.53MB   318.53MB     83: input := bytes.NewBuffer(data)
        .          .     84:
        .          .     85: // The number of bytes we are looking for.
        .          .     86: size := len(find)
        .          .     87:
        .          .     88: // Declare the buffers we need to process the stream.
  16.50MB    16.50MB     89: buf := make([]byte, size)
        .          .     90: end := size - 1
        .          .     91:
        .          .     92: // Read in an initial number of bytes we need to get started.
        .          .     93: if n, err := io.ReadFull(input, buf[:end]); err != nil || n < end {
        .          .     94:       output.Write(buf[:n])
(pprof) _


Basierend auf diesem Profil wissen wir jetzt, dass Eingabe und Puffer auf dem Heap zugeordnet sind. Da die Eingabe eine Zeigervariable ist, zeigt das Profil tatsächlich an, dass der Byte.Pufferwert, auf den der Eingabezeiger zeigt, zugewiesen ist. Konzentrieren wir uns also zuerst auf die Eingabezuweisung und verstehen, warum dies geschieht.



Wir können davon ausgehen, dass die Zuordnung erfolgt, weil der Aufruf von bytes.NewBuffer den Wert bytes.Buffer gemeinsam nutzt, wodurch der Aufrufstapel erstellt wird. Wenn Sie jedoch einen Wert in der flachen Spalte (der ersten Spalte in der Ausgabe von pprof) haben, wird mir mitgeteilt, dass der Wert zugewiesen wird, da die Funktion algOne ihn so aufteilt, dass er sich stapelt.



Ich weiß, dass die flache Spalte Zuordnungen in einer Funktion darstellt. Sehen Sie sich also an, was der Befehl list für die Benchmark-Funktion anzeigt, die algOne aufruft.



Listing 9:



(pprof) list Benchmark
Total: 335.03MB
ROUTINE ======================== .../memcpu.BenchmarkAlgorithmOne in code/go/src/.../memcpu/stream_test.go
        0   335.03MB (flat, cum)   100% of Total
        .          .     18: find := []byte("elvis")
        .          .     19: repl := []byte("Elvis")
        .          .     20:
        .          .     21: b.ResetTimer()
        .          .     22:
        .   335.03MB     23: for i := 0; i < b.N; i++ {
        .          .     24:       output.Reset()
        .          .     25:       algOne(in, find, repl, &output)
        .          .     26: }
        .          .     27:}
        .          .     28:
(pprof) _


Da die Spalte cum (zweite Spalte) nur einen Wert enthält, weist dies darauf hin, dass Benchmark nichts direkt zuweist. Alle Zuordnungen stammen aus Funktionsaufrufen, die innerhalb dieser Schleife ausgeführt werden. Sie können sehen, dass alle Zuordnungsnummern dieser beiden Aufrufe zur Liste alle gleich sind.



Wir wissen immer noch nicht, warum der Wert bytes.Buffer zugewiesen wird. Hier bietet sich der Befehl go build -gcflags "-m -m" an. Der Profiler kann Ihnen nur sagen, welche Werte auf den Heap verschoben werden, während der Build Ihnen sagen kann, warum.



Compiler-Berichterstellung



Fragen wir den Compiler, welche Entscheidungen er für die Escape-Analyse im Code trifft.



Listing 10:



$ go build -gcflags "-m -m"


Dieser Befehl erzeugt viel Ausgabe. Wir müssen nur die Ausgabe nach stream.go: 83 durchsuchen, da stream.go der Name der Datei ist, die diesen Code enthält, und Zeile 83 das Konstrukt bytes.buffer enthält. Nach der Suche finden wir 6 Zeilen.



Listing 11:



./stream.go:83: inlining call to bytes.NewBuffer func([]byte) *bytes.Buffer { return &bytes.Buffer literal }

./stream.go:83: &bytes.Buffer literal escapes to heap
./stream.go:83:   from ~r0 (assign-pair) at ./stream.go:83
./stream.go:83:   from input (assigned) at ./stream.go:83
./stream.go:83:   from input (interface-converted) at ./stream.go:93
./stream.go:83:   from input (passed to call[argument escapes]) at ./stream.go:93


Wir interessieren uns für die erste Zeile, die wir bei der Suche nach stream.go: 83 gefunden haben.



Listing 12



./stream.go:83: inlining call to bytes.NewBuffer func([]byte) *bytes.Buffer { return &bytes.Buffer literal }


Dies bestätigt, dass der Wert bytes.Buffer nicht verschwunden ist, als er auf den Aufrufstapel verschoben wurde. Dies geschah, weil der Aufruf von bytes.NewBuffer nie stattfand und der Code in der Funktion inline war.



Hier ist die fragliche Codezeile:



Listing 13



83     input := bytes.NewBuffer(data)


Da der Compiler beschlossen hat, den Funktionsaufruf bytes.NewBuffer einzubinden, wird der von mir geschriebene Code wie folgt konvertiert:



Listing 14



input := &bytes.Buffer{buf: data}


Dies bedeutet, dass die Funktion algOne den Wert bytes.Buffer direkt erstellt. Die Frage ist nun, warum der Wert aus dem algOne-Stack-Frame herausspringt. Diese Antwort steht in den anderen 5 Zeilen, die wir im Bericht gefunden haben.



Listing 15:



./stream.go:83: &bytes.Buffer literal escapes to heap
./stream.go:83:   from ~r0 (assign-pair) at ./stream.go:83
./stream.go:83:   from input (assigned) at ./stream.go:83
./stream.go:83:   from input (interface-converted) at ./stream.go:93
./stream.go:83:   from input (passed to call[argument escapes]) at ./stream.go:93


Diese Zeilen sagen uns, dass Heap-Escape in Zeile 93 des Codes auftritt. Die Eingangsvariable ist dem Schnittstellenwert zugeordnet.



Schnittstellen



Ich kann mich nicht erinnern, überhaupt eine Schnittstellenwertzuweisung im Code vorgenommen zu haben. Wenn Sie sich jedoch Zeile 93 ansehen, wird klar, was los ist.



Listing 16:



 93     if n, err := io.ReadFull(input, buf[:end]); err != nil {
 94         output.Write(buf[:n])
 95         return
 96     }


Der Aufruf von io.ReadFull ruft die Zuweisung der Schnittstelle auf. Wenn Sie sich die Definition der Funktion io.ReadFull ansehen, können Sie sehen, dass sie eine Eingabevariable über einen Schnittstellentyp akzeptiert.



Listing 17:



type Reader interface {
      Read(p []byte) (n int, err error)
}

func ReadFull(r Reader, buf []byte) (n int, err error) {
      return ReadAtLeast(r, buf, len(buf))
}


Es sieht so aus, als würden die Bytes übergeben. Die Pufferadresse im Aufrufstapel und das Speichern im Wert der Reader-Schnittstelle führt zu einem Escape. Wir wissen jetzt, dass die Kosten für die Verwendung einer Schnittstelle hoch sind: Zuweisung und Indirektion. Wenn also nicht genau klar ist, wie eine Schnittstelle Ihren Code verbessert, müssen Sie ihn wahrscheinlich nicht verwenden. Hier sind einige Richtlinien, die ich befolge, um die Verwendung von Schnittstellen in meinem Code zu testen.



Verwenden Sie die Schnittstelle, wenn:



  • API-Benutzer müssen Implementierungsdetails angeben.
  • Die API verfügt über mehrere Implementierungen, die intern unterstützt werden müssen.
  • Es wurden Teile der API identifiziert, die sich ändern können und eine Trennung erfordern.


Verwenden Sie nicht die Schnittstelle:



  • um die Schnittstelle zu nutzen.
  • um den Algorithmus zu verallgemeinern.
  • wenn Benutzer ihre eigenen Schnittstellen deklarieren können.


Jetzt können wir uns fragen, ob dieser Algorithmus wirklich die Funktion io.ReadFull benötigt. Die Antwort lautet Nein, da der Typ bytes.Buffer eine Reihe von Methoden enthält, die wir verwenden können. Die Verwendung von Methoden gegen den Wert, den die Funktion besitzt, kann Zuordnungen verhindern.



Ändern Sie den Code, um das io-Paket zu entfernen, und verwenden Sie die Read-Methode direkt für die Eingabevariable.



Durch diese Codeänderung muss das io-Paket nicht mehr importiert werden. Um alle Zeilennummern gleich zu halten, verwende ich eine leere Kennung, um das io-Paket zu importieren. Dadurch bleiben die Importe in der Liste.



Listing 18:



 12 import (
 13     "bytes"
 14     "fmt"
 15     _ "io"
 16 )

 80 func algOne(data []byte, find []byte, repl []byte, output *bytes.Buffer) {
 81
 82     // Use a bytes Buffer to provide a stream to process.
 83     input := bytes.NewBuffer(data)
 84
 85     // The number of bytes we are looking for.
 86     size := len(find)
 87
 88     // Declare the buffers we need to process the stream.
 89     buf := make([]byte, size)
 90     end := size - 1
 91
 92     // Read in an initial number of bytes we need to get started.
 93     if n, err := input.Read(buf[:end]); err != nil || n < end {
 94         output.Write(buf[:n])
 95         return
 96     }
 97
 98     for {
 99
100         // Read in one byte from the input stream.
101         if _, err := input.Read(buf[end:]); err != nil {
102
103             // Flush the reset of the bytes we have.
104             output.Write(buf[:end])
105             return
106         }
107
108         // If we have a match, replace the bytes.
109         if bytes.Compare(buf, find) == 0 {
110             output.Write(repl)
111
112             // Read a new initial number of bytes.
113             if n, err := input.Read(buf[:end]); err != nil || n < end {
114                 output.Write(buf[:n])
115                 return
116             }
117
118             continue
119         }
120
121         // Write the front byte since it has been compared.
122         output.WriteByte(buf[0])
123
124         // Slice that front byte out.
125         copy(buf, buf[1:])
126     }
127 }


Wenn wir diese Codeänderung vergleichen, sehen wir, dass es keine Zuordnung mehr für die Bytes gibt. Pufferwert.



Listing 19:



$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem -memprofile mem.out
BenchmarkAlgorithmOne-8        2000000          1814 ns/op         5 B/op            1 allocs/op


Wir sehen auch eine Leistungsverbesserung von rund 29%. Die Zeit wurde von 2570 ns / op auf 1814 ns / op geändert. Nachdem dies behoben ist, können wir uns darauf konzentrieren, eine Hilfsscheibe für buf zuzuweisen. Wenn wir den Profiler erneut für die neuen Profildaten verwenden, die wir gerade erstellt haben, können wir feststellen, was genau die verbleibenden Zuordnungen verursacht.



Listing 20:



$ go tool pprof -alloc_space memcpu.test mem.out
Entering interactive mode (type "help" for commands)
(pprof) list algOne
Total: 7.50MB
ROUTINE ======================== .../memcpu.BenchmarkAlgorithmOne in code/go/src/.../memcpu/stream_test.go
     11MB       11MB (flat, cum)   100% of Total
        .          .     84:
        .          .     85: // The number of bytes we are looking for.
        .          .     86: size := len(find)
        .          .     87:
        .          .     88: // Declare the buffers we need to process the stream.
     11MB       11MB     89: buf := make([]byte, size)
        .          .     90: end := size - 1
        .          .     91:
        .          .     92: // Read in an initial number of bytes we need to get started.
        .          .     93: if n, err := input.Read(buf[:end]); err != nil || n < end {
        .          .     94:       output.Write(buf[:n])


Die einzige verbleibende Zuordnung befindet sich in Zeile 89, die zum Erstellen einer Hilfsscheibe dient.



Stapelrahmen



Wir möchten wissen, warum die Zuordnung für die Hilfsscheibe für buf erfolgt. Führen Sie den Build erneut mit der Option -gcflags "-m -m" aus und suchen Sie nach stream.go: 89.



Listing 21



$ go build -gcflags "-m -m"
./stream.go:89: make([]byte, size) escapes to heap
./stream.go:89:   from make([]byte, size) (too large for stack) at ./stream.go:89


Der Bericht besagt, dass das Hilfsarray "zu groß für den Stapel" ist. Diese Nachricht ist irreführend. Der Punkt ist nicht, dass das Array zu groß ist, sondern dass der Compiler nicht weiß, wie groß das Hilfsarray zur Kompilierungszeit ist.



Werte können nur auf den Stapel verschoben werden, wenn der Compiler die Größe des Werts zur Kompilierungszeit kennt. Dies liegt daran, dass die Größe jedes Stapelrahmens für jede Funktion zur Kompilierungszeit berechnet wird. Wenn der Compiler die Größe eines Werts nicht kennt, wird er gehäuft.



Um dies zu zeigen, codieren wir die Slice-Größe vorübergehend auf 5 und führen den Benchmark erneut aus.



Listing 22:



89     buf := make([]byte, 5)


Diesmal gibt es keine Zuweisungen mehr.



Listing 23:



$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem
BenchmarkAlgorithmOne-8        3000000          1720 ns/op         0 B/op            0 allocs/op


Wenn Sie sich den Compiler-Bericht noch einmal ansehen, können Sie sehen, dass nichts auf den Heap verschoben wird.



Listing 24:



$ go build -gcflags "-m -m"
./stream.go:83: algOne &bytes.Buffer literal does not escape
./stream.go:89: algOne make([]byte, 5) does not escape


Offensichtlich können wir die Slice-Größe nicht fest codieren, daher müssen wir für diesen Algorithmus mit 1 Zuordnung leben.



Zuweisungen und Leistung



Vergleichen Sie die Leistungssteigerungen, die wir mit jedem Refactoring erzielt haben.



Listing 25:



Before any optimization
BenchmarkAlgorithmOne-8        2000000          2570 ns/op       117 B/op            2 allocs/op

Removing the bytes.Buffer allocation
BenchmarkAlgorithmOne-8        2000000          1814 ns/op         5 B/op            1 allocs/op

Removing the backing array allocation
BenchmarkAlgorithmOne-8        3000000          1720 ns/op         0 B/op            0 allocs/op


Wir haben eine Leistungssteigerung von ca. 29% erzielt, da wir die Bytes entfernt haben. Pufferzuordnung und eine Beschleunigung von ~ 33% nach dem Entfernen aller Zuordnungen. Bei Zuweisungen kann die Anwendungsleistung beeinträchtigt werden.



Fazit



Go verfügt über einige erstaunliche Tools, die Ihnen helfen, die Entscheidungen zu verstehen, die der Compiler in Bezug auf die Escape-Analyse trifft. Basierend auf diesen Informationen können Sie Ihren Code umgestalten, um Werte auf dem Stapel zu behalten, die sich nicht auf dem Heap befinden sollten. Sie sollten kein Programm mit null Zuordnungen schreiben, aber Sie sollten sich bemühen, die Zuweisungen nach Möglichkeit zu minimieren.



Machen Sie die Leistung beim Schreiben von Code nicht zur obersten Priorität, da Sie nicht erraten möchten, was die Leistung sein soll. Schreiben Sie den Code und optimieren Sie ihn, um die Leistung für die Aufgabe mit der ersten Priorität zu erzielen. Dies bedeutet, sich in erster Linie auf Integrität, Lesbarkeit und Einfachheit zu konzentrieren. Wenn Sie ein Arbeitsprogramm haben, stellen Sie fest, ob es schnell genug ist. Wenn nicht, verwenden Sie die von der Sprache bereitgestellten Tools, um Leistungsprobleme zu finden und zu beheben.



All Articles