Mécanique langagière du profilage de la mémoire

Prélude



Il s'agit du troisième de quatre articles d'une série qui fournira un aperçu de la mécanique et de la conception des pointeurs, des piles, des tas, de l'analyse des échappements et de la sémantique des valeurs / pointeurs dans Go. Cet article concerne le profilage de la mémoire.



Table des matières du cycle d'articles:



  1. Language Mechanics On Stacks And Pointers ( traduction )
  2. Analyse de la mécanique du langage lors de l'évasion ( traduction )
  3. Mécanique du langage sur le profilage de la mémoire
  4. Philosophie de conception sur les données et la sémantique


Regardez cette vidéo pour voir une démo de ce code:

DGopherCon Singapore (2017) - Escape Analysis



introduction



Dans un article précédent, j'ai enseigné les bases de l'analyse d'échappement en utilisant un exemple qui divise une valeur sur une pile de goroutine. Je ne vous ai montré aucun autre scénario pouvant conduire à des valeurs de tas. Pour vous aider, je vais déboguer un programme qui effectue des allocations de manière inattendue.



Programme



Je voulais en savoir plus sur le paquet io, alors je me suis proposé une petite tâche pour moi-même. Étant donné un flux d'octets, écrivez une fonction qui peut trouver la chaîne elvis et remplacez-la par la chaîne Elvis en majuscule. Nous parlons d'un roi, donc son nom doit toujours être en majuscule.



Voici un lien vers une solution: play.golang.org/p/n_SzF4Cer4

Voici un lien vers des benchmarks: play.golang.org/p/TnXrxJVfLV



La liste montre deux fonctions différentes qui accomplissent cette tâche. Cet article se concentrera sur la fonction algOne car elle utilise le package io. Utilisez la fonction algTwo pour expérimenter vous-même les profils de mémoire et de processeur.



Voici l'entrée que nous allons utiliser et la sortie attendue de la fonction algOne.



Liste 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


Vous trouverez ci-dessous une liste de la fonction algOne.



Liste 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 }


Je veux savoir dans quelle mesure cette fonction fonctionne et quelle pression elle exerce sur le tas. Pour le savoir, exécutons un benchmark.



Benchmarking



J'ai écrit un benchmark qui appelle la fonction algOne pour effectuer le traitement sur le flux de données.



Liste 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 }


Nous pouvons exécuter ce benchmark en utilisant go test avec les commutateurs -bench, -benchtime et -benchmem.



Liste 4



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


Après avoir exécuté le benchmark, nous voyons que la fonction algOne alloue 2 valeurs avec un coût total de 117 octets par opération. C'est génial, mais nous devons savoir quelles lignes de code de la fonction sont à l'origine de ces allocations. Pour le savoir, nous devons générer des données de profilage pour ce test.



Profilage



Pour générer les données de profilage, exécutez à nouveau le benchmark, mais cette fois, nous interrogerons le profil de mémoire à l'aide du commutateur -memprofile.



Liste 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


Après avoir terminé le test de performance, l'outil de test a créé deux nouveaux fichiers.



Liste 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


Le code source se trouve dans le dossier memcpu dans la fonction algOne de stream.go et la fonction de référence dans stream_test.go. Les deux nouveaux fichiers créés sont nommés mem.out et memcpu.test. Le fichier mem.out contient les données de profil et le fichier memcpu.test, nommé d'après le dossier, contient le binaire de test dont nous avons besoin pour accéder aux symboles lorsque vous regardez les données de profil.



Une fois les données de profil et le binaire de test en place, nous pouvons exécuter l'outil pprof pour examiner les données de profil.



Liste 7



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


Lors du profilage de la mémoire et de la recherche de fruits à portée de main, vous pouvez utiliser l'option -alloc_space au lieu de l'option par défaut -inuse_space. Cela vous montrera où chaque allocation se produit, qu'elle soit en mémoire ou non lorsque vous récupérez le profil.



Dans la zone de saisie (pprof), nous pouvons vérifier la fonction algOne avec la commande list. Cette commande prend une expression régulière comme argument pour trouver la ou les fonctions que vous souhaitez afficher.



Annonce 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) _


Sur la base de ce profil, nous savons maintenant que l'entrée et le buf sont alloués sur le tas. Puisque input est une variable de pointeur, le profil indique vraiment que la valeur bytes.Buffer pointée par input est allouée. Concentrons-nous donc d'abord sur l'allocation des intrants et comprenons pourquoi cela se produit.



Nous pouvons supposer que l'allocation se produit parce que l'appel à bytes.NewBuffer partage la valeur bytes.Buffer qui crée la pile d'appels. Cependant, la présence de la valeur dans la colonne plate (la première colonne de la sortie pprof) m'indique que la valeur est allouée car la fonction algOne la divise de manière à l'empiler.



Je sais que la colonne plate représente les allocations dans une fonction, alors regardez ce que la commande list montre pour la fonction Benchmark qui appelle algOne.



Annonce 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) _


Puisqu'il n'y a qu'une valeur dans la colonne cum (deuxième colonne), cela m'indique que Benchmark n'alloue rien directement. Toutes les allocations proviennent d'appels de fonction exécutés dans cette boucle. Vous pouvez voir que tous les numéros d'allocation de ces deux appels à la liste sont tous les mêmes.



Nous ne savons toujours pas pourquoi la valeur bytes.Buffer est allouée. C'est là que la commande go build -gcflags "-m -m" est utile. Le profileur peut uniquement vous dire quelles valeurs sont déplacées vers le tas, tandis que build peut vous dire pourquoi.



Rapports du compilateur



Demandons au compilateur quelles décisions il prend pour l'analyse d'échappement dans le code.



Liste 10



$ go build -gcflags "-m -m"


Cette commande produit beaucoup de sortie. Nous avons juste besoin de rechercher dans la sortie tout ce que stream.go: 83 a, car stream.go est le nom du fichier qui contient ce code, et la ligne 83 contient la construction de valeur bytes.buffer. Après recherche, nous trouvons 6 lignes.



Liste 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


nous sommes intéressés par la première ligne trouvée en recherchant stream.go: 83.



Listing 12



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


Cela confirme que la valeur bytes.Buffer n'a pas disparu car elle a été poussée sur la pile d'appels. Cela s'est produit parce que l'appel bytes.NewBuffer ne s'est jamais produit, le code à l'intérieur de la fonction était en ligne.



Voici la ligne de code en question:



Listing 13



83     input := bytes.NewBuffer(data)


Parce que le compilateur a décidé d'insérer l'appel de fonction bytes.NewBuffer, le code que j'ai écrit se convertit en ceci:



Listing 14



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


Cela signifie que la fonction algOne crée directement la valeur bytes.Buffer. Alors maintenant, la question est: qu'est-ce qui fait sortir la valeur du cadre de la pile algOne? Cette réponse se trouve dans les 5 autres lignes que nous avons trouvées dans le rapport.



Annonce 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


Ces lignes nous indiquent que l'échappement du tas se produit à la ligne 93 du code. La variable d'entrée est affectée à la valeur d'interface.



Interfaces



Je ne me souviens pas du tout avoir fait l'attribution de valeur d'interface dans le code. Cependant, si vous regardez la ligne 93, ce qui se passe devient clair.



Annonce 16



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


L'appel io.ReadFull appelle l'affectation de l'interface. Si vous regardez la définition de la fonction io.ReadFull, vous pouvez voir qu'elle accepte une variable d'entrée via un type d'interface.



Annonce 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))
}


Cela ressemble à passer l'adresse bytes.Buffer dans la pile d'appels et à la stocker dans la valeur de l'interface Reader provoque un échappement. On sait maintenant que le coût d'utilisation d'une interface est élevé: allocation et indirection. Donc, si vous ne savez pas exactement comment une interface améliore votre code, vous n'avez probablement pas besoin de l'utiliser. Voici quelques directives que je suis pour tester l'utilisation des interfaces dans mon code.



Utilisez l'interface lorsque:



  • Les utilisateurs de l'API doivent fournir des détails d'implémentation.
  • L'API a plusieurs implémentations qu'ils doivent prendre en charge en interne.
  • Certaines parties de l'API ont été identifiées et peuvent changer et nécessiter une séparation.


N'utilisez pas l'interface:



  • pour le plaisir d'utiliser l'interface.
  • pour généraliser l'algorithme.
  • lorsque les utilisateurs peuvent déclarer leurs propres interfaces.


Maintenant, nous pouvons nous demander si cet algorithme a vraiment besoin de la fonction io.ReadFull? La réponse est non, car le type bytes.Buffer a un ensemble de méthodes que nous pouvons utiliser. L'utilisation de méthodes par rapport à la valeur d'une fonction peut empêcher les allocations.



Modifions le code pour supprimer le package io et utilisons la méthode Read directement sur la variable d'entrée.



Ce changement de code supprime la nécessité d'importer le package io, donc pour garder tous les numéros de ligne identiques, j'utilise un identifiant vide pour importer le package io. Cela gardera les importations sur la liste.



Annonce 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 }


Lorsque nous comparons ce changement de code, nous pouvons voir qu'il n'y a plus d'allocation pour la valeur bytes.Buffer.



Annonce 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


Nous constatons également une amélioration de la performance d'environ 29%. L'heure est passée de 2570 ns / op à 1814 ns / op. Maintenant que cela est résolu, nous pouvons nous concentrer sur l'allocation d'une tranche auxiliaire pour buf. Si nous utilisons à nouveau le profileur pour les nouvelles données de profil que nous venons de créer, nous pouvons déterminer la cause exacte des allocations restantes.



Annonce 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])


La seule allocation restante est sur la ligne 89, qui est pour créer une tranche auxiliaire.



Cadres de pile



Nous voulons savoir pourquoi l'allocation se produit pour la tranche auxiliaire pour buf? Exécutons à nouveau la construction en utilisant l'option -gcflags "-m -m" et recherchons 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


Le rapport indique que le tableau auxiliaire est «trop grand pour la pile». Ce message est trompeur. Le fait n'est pas que le tableau est trop grand, mais que le compilateur ne sait pas quelle taille le tableau auxiliaire est au moment de la compilation.



Les valeurs ne peuvent être poussées sur la pile que si le compilateur connaît la taille de la valeur au moment de la compilation. En effet, la taille de chaque frame de pile pour chaque fonction est calculée au moment de la compilation. Si le compilateur ne connaît pas la taille d'une valeur, elle est entassée.



Pour le montrer, codons temporairement en dur la taille de la tranche à 5 et réexécutons le benchmark.



Annonce 22



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


Il n'y a plus d'allocations cette fois.



Annonce 23



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


Si vous regardez à nouveau le rapport du compilateur, vous pouvez voir que rien n'est déplacé vers le tas.



Annonce 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


De toute évidence, nous ne pouvons pas coder en dur la taille de la tranche, nous devrons donc vivre avec 1 allocation pour cet algorithme.



Allocations et performances



Comparez les gains de performances que nous avons obtenus à chaque refactorisation.



Annonce 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


Nous avons obtenu une augmentation des performances d'environ 29% en raison du fait que nous avons supprimé l'allocation bytes.Buffer et une accélération d'environ 33% après la suppression de toutes les allocations. Les allocations sont celles où les performances des applications peuvent souffrir.



Conclusion



Go propose des outils incroyables pour vous aider à comprendre les décisions prises par le compilateur concernant l'analyse des échappements. Sur la base de ces informations, vous pouvez refactoriser votre code pour aider à conserver les valeurs sur la pile qui ne devraient pas être sur le tas. Vous ne devez pas écrire un programme avec aucune allocation, mais vous devez vous efforcer de minimiser les allocations autant que possible.



Ne faites pas de la performance une priorité lors de l'écriture de code, car vous ne voulez pas deviner ce qui devrait être performant. Écrivez le code et optimisez-le pour obtenir des performances pour la première tâche prioritaire. Cela signifie se concentrer principalement sur l'intégrité, la lisibilité et la simplicité. Une fois que vous avez un programme de travail, déterminez s'il est assez rapide. Sinon, utilisez les outils fournis par la langue pour rechercher et résoudre les problèmes de performances.



All Articles