Méthodes simples pour optimiser les programmes Go

Je me soucie toujours de la performance. Je ne sais pas exactement pourquoi. Mais je suis simplement énervé par la lenteur des services et des programmes. On dirait que je ne suis pas seul .

Dans les tests A / B, nous avons essayé de ralentir la sortie des pages par incréments de 100 millisecondes et avons constaté que même de très petits retards entraînaient une baisse significative des revenus. - Greg Linden, Amazon.com

Par expérience, une faible productivité se manifeste de deux manières:

  • Les opérations qui fonctionnent bien à petite échelle deviennent non viables avec un nombre croissant d'utilisateurs. Il s'agit généralement d'opérations O (N) ou O (N²). Lorsque la base d'utilisateurs est petite, tout fonctionne bien. Le produit est pressé de mettre sur le marché. À mesure que la base grandit, des situations pathologiques de plus en plus inattendues se produisent - et le service s'arrête.
  • De nombreuses sources individuelles de travail sous-optimal, «la mort de mille coupures».

Pendant la majeure partie de ma carrière, j'ai étudié la science des données avec Python ou créé des services sur Go. Dans le second cas, j'ai beaucoup plus d'expérience en optimisation. Go n'est généralement pas un goulot d'étranglement dans les services que j'écris - les programmes de base de données sont souvent limités par les E / S. Cependant, dans les pipelines de batch d'apprentissage automatique que j'ai développés, le programme est souvent limité par le CPU. Si Go utilise trop le processeur, il existe différentes stratégies.

Cet article explique certaines méthodes qui peuvent être utilisées pour augmenter considérablement la productivité sans trop d'effort. J'ignore délibérément les méthodes qui nécessitent des efforts importants ou des changements importants dans la structure du programme.

Avant de commencer


Avant d'apporter des modifications au programme, prenez le temps de créer une base de référence appropriée pour la comparaison. Si vous ne le faites pas, vous vous promènerez dans le noir, en vous demandant s'il y a un quelconque avantage à apporter les modifications apportées. Tout d'abord, écrivez des repères et prenez des profils à utiliser dans pprof. Il est préférable d’écrire la référence également sur Go : cela facilite l’utilisation de pprof et du profilage de la mémoire. Utilisez également benchcmp: un outil utile pour comparer les différences de performances entre les tests.

Si le code n'est pas très compatible avec les benchmarks, commencez simplement par quelque chose qui peut être mesuré. Vous pouvez profiler le code manuellement avec runtime / pprof .

Commençons donc!

Utilisez sync.Pool pour réutiliser les objets précédemment sélectionnés


sync.Pool implémente une liste de versions . Cela vous permet de réutiliser les structures précédemment allouées et amortit la distribution de l'objet sur de nombreuses utilisations, réduisant le travail du garbage collector. L'API est très simple. Implémentez une fonction qui alloue une nouvelle instance d'un objet. L'API renvoie le type de pointeur.

var bufpool = sync.Pool{ New: func() interface{} { buf := make([]byte, 512) return &buf }} 

Après cela, vous pouvez faire des objets Get() partir du pool et les remettre Put() lorsque vous avez terminé.

 // sync.Pool returns a interface{}: you must cast it to the underlying type // before you use it. b := *bufpool.Get().(*[]byte) defer bufpool.Put(&b) // Now, go do interesting things with your byte buffer. buf := bytes.NewBuffer(b) 

Il y a des nuances. Avant Go 1.13, le pool était effacé à chaque récupération de place. Cela peut nuire aux performances des programmes qui allouent beaucoup de mémoire. Au 1.13, il semble que plus d'objets survivent après le GC .

!!! Avant de renvoyer un objet dans le pool, assurez-vous de réinitialiser les champs de structure.

Si vous ne le faites pas, vous pouvez obtenir un objet sale du pool qui contient des données d'une utilisation précédente. Il s'agit d'un grave risque pour la sécurité!

 type AuthenticationResponse { Token string UserID string } rsp := authPool.Get().(*AuthenticationResponse) defer authPool.Put(rsp) // If we don't hit this if statement, we might return data from other users! if blah { rsp.UserID = "user-1" rsp.Token = "super-secret" } return rsp 

Un moyen sûr de toujours garantir zéro mémoire est de le faire explicitement:

 // reset resets all fields of the AuthenticationResponse before pooling it. func (a* AuthenticationResponse) reset() { a.Token = "" a.UserID = "" } rsp := authPool.Get().(*AuthenticationResponse) defer func() { rsp.reset() authPool.Put(rsp) }() 

Le seul cas où ce n'est pas un problème est lorsque vous utilisez la mémoire exacte à laquelle vous avez écrit. Par exemple:

 var ( r io.Reader w io.Writer ) // Obtain a buffer from the pool. buf := *bufPool.Get().(*[]byte) defer bufPool.Put(&buf) // We only write to w exactly what we read from r, and no more. nr, er := r.Read(buf) if nr > 0 { nw, ew := w.Write(buf[0:nr]) } 

Évitez d'utiliser des structures contenant des pointeurs comme clés pour une grande carte


Fuh, j'étais trop bavard. Je suis désolé. Ils parlaient souvent (y compris mon ancien collègue Phil Pearl ) des performances de Go avec une grande taille de tas . Pendant le garbage collection, le runtime analyse les objets avec des pointeurs et les suit. Si vous avez une très grande carte map[string]int , GC doit vérifier chaque ligne. Cela se produit avec chaque garbage collection, car les lignes contiennent des pointeurs.

Dans cet exemple, nous écrivons 10 millions d'éléments pour map[string]int et mesurer la durée de la récupération de place. Nous allouons notre carte dans la zone du package pour garantir l'allocation de mémoire à partir du tas.

 package main import ( "fmt" "runtime" "strconv" "time" ) const ( numElements = 10000000 ) var foo = map[string]int{} func timeGC() { t := time.Now() runtime.GC() fmt.Printf("gc took: %s\n", time.Since(t)) } func main() { for i := 0; i < numElements; i++ { foo[strconv.Itoa(i)] = i } for { timeGC() time.Sleep(1 * time.Second) } } 

En exécutant le programme, nous verrons ce qui suit:

  inthash → aller installer && inthash
 gc a pris: 98.726321ms
 gc a pris: 105.524633ms
 gc a pris: 102.829451ms
 gc a pris: 102.71908ms
 gc a pris: 103.084104ms
 gc a pris: 104.821989ms 

C'est assez long dans un pays informatique!

Que peut-on faire pour optimiser? Supprimer des pointeurs partout est une bonne idée, afin de ne pas charger le ramasse-miettes. Il y a des pointeurs dans les lignes ; implémentons donc ceci comme map[int]int .

 package main import ( "fmt" "runtime" "time" ) const ( numElements = 10000000 ) var foo = map[int]int{} func timeGC() { t := time.Now() runtime.GC() fmt.Printf("gc took: %s\n", time.Since(t)) } func main() { for i := 0; i < numElements; i++ { foo[i] = i } for { timeGC() time.Sleep(1 * time.Second) } } 

En exécutant à nouveau le programme, nous voyons:

  inthash → aller installer && inthash
 gc a pris: 3.608993ms
 gc a pris: 3.926913ms
 gc a pris: 3.955706ms
 gc a pris: 4.063795ms
 gc a pris: 3.91519ms
 gc a pris: 3.75226ms 

Bien mieux. Nous avons accéléré la collecte des ordures de 35 fois. Lorsqu'il est utilisé en production, il sera nécessaire de hacher les chaînes en nombres entiers avant de les insérer dans la carte.

Soit dit en passant, il existe de nombreuses autres façons d'éviter le GC. Si vous allouez des tableaux gigantesques de structures sans signification, en octets ou en octets, le GC ne le scannera pas : c'est-à-dire que vous économisez du temps GC. De telles méthodes nécessitent généralement une révision substantielle du programme, donc aujourd'hui nous ne nous pencherons pas sur ce sujet.

Comme pour toute optimisation, l'effet peut varier. Voir le fil des tweets de Damian Gryski pour un exemple intéressant de la façon dont la suppression de lignes d'une grande carte au profit d'une structure de données plus intelligente a effectivement augmenté la consommation de mémoire. En général, lisez tout ce qu'il publie.

Marshaling de génération de code pour éviter la réflexion d'exécution


Le marshaling et le démarshaling de votre structure dans différents formats de sérialisation, tels que JSON, est une opération typique, en particulier lors de la création de microservices. Pour de nombreux microservices, c'est généralement le seul travail. Des fonctions comme json.Marshal et json.Unmarshal s'appuient sur la réflexion lors de l'exécution pour sérialiser les champs de structure en octets et vice versa. Cela peut fonctionner lentement: la réflexion n'est pas aussi efficace que le code explicite.

Cependant, il existe des options d'optimisation. La mécanique de marshaling JSON ressemble à ceci:

 package json // Marshal take an object and returns its representation in JSON. func Marshal(obj interface{}) ([]byte, error) { // Check if this object knows how to marshal itself to JSON // by satisfying the Marshaller interface. if m, is := obj.(json.Marshaller); is { return m.MarshalJSON() } // It doesn't know how to marshal itself. Do default reflection based marshallling. return marshal(obj) } 

Si nous connaissons le processus de marshalisation en JSON, nous avons un indice pour éviter la réflexion lors de l'exécution. Mais nous ne voulons pas écrire manuellement tout le code de marshalisation, alors que devons-nous faire? Laissez l'ordinateur générer ce code! Les générateurs de code comme easyjson examinent la structure et génèrent du code hautement optimisé qui est entièrement compatible avec les interfaces de marshaling existantes comme json.Marshaller .

Téléchargez le package et écrivez la commande suivante dans $file.go , qui contient les structures pour lesquelles vous souhaitez générer du code.

  easyjson -all $ file.go 

Le fichier $file_easyjson.go doit être généré. Puisque easyjson implémenté pour vous l'interface json.Marshaller , ces fonctions seront appelées par défaut au lieu de réflexion. Félicitations: vous venez d'accélérer votre code JSON trois fois. Il existe de nombreuses astuces pour augmenter encore la productivité.

Je recommande ce package car je l'ai déjà utilisé et avec succès. Mais attention. Veuillez ne pas prendre cela comme une invitation à entamer avec moi des débats agressifs sur les packages JSON les plus rapides.

Assurez-vous de recréer le code de marshaling lorsque la structure change. Si vous oubliez de le faire, les nouveaux champs ajoutés ne seront pas sérialisés, ce qui créera de la confusion! Vous pouvez utiliser go generate pour ces tâches. Pour maintenir la synchronisation avec les structures, je préfère placer generate.go à la racine du package, ce qui provoque la go generate pour tous les fichiers du package: cela peut aider lorsque vous avez de nombreux fichiers qui doivent générer un tel code. L'astuce principale: pour vous assurer que les structures sont mises à jour, appelez go generate dans le CI et vérifiez qu'il n'y a pas de différence avec le code enregistré.

Utilisez strings.Builder pour créer des chaînes


Dans Go, les chaînes sont immuables: considérez-les comme des octets en lecture seule. Cela signifie que chaque fois que vous créez une chaîne, vous allouez de la mémoire et créez potentiellement plus de travail pour le garbage collector.

Go 1.10 a implémenté strings.Builder comme un moyen efficace de créer des chaînes. En interne, il écrit dans un tampon d'octets. Ce n'est que lorsque vous appelez String() dans le générateur crée réellement une chaîne. Il s'appuie sur quelques astuces non sécurisées pour renvoyer les octets sous-jacents sous forme de chaîne avec une allocation nulle: consultez ce blog pour une étude plus approfondie sur la façon dont cela fonctionne.

Comparez les performances des deux approches:

 // main.go package main import "strings" var strs = []string{ "here's", "a", "some", "long", "list", "of", "strings", "for", "you", } func buildStrNaive() string { var s string for _, v := range strs { s += v } return s } func buildStrBuilder() string { b := strings.Builder{} // Grow the buffer to a decent length, so we don't have to continually // re-allocate. b.Grow(60) for _, v := range strs { b.WriteString(v) } return b.String() } 

 // main_test.go package main import ( "testing" ) var str string func BenchmarkStringBuildNaive(b *testing.B) { for i := 0; i < bN; i++ { str = buildStrNaive() } } func BenchmarkStringBuildBuilder(b *testing.B) { for i := 0; i < bN; i++ { str = buildStrBuilder() } 

Voici les résultats sur mon Macbook Pro:

  strbuild -> allez tester -bench =.  -benchmem
 goos: darwin
 goarch: amd64
 pkg: github.com/sjwhitworth/perfblog/strbuild
 BenchmarkStringBuildNaive-8 5,000,000 255 ns / op 216 B / op 8 allocs / op
 BenchmarkStringBuildBuilder-8 20 000 000 54,9 ns / op 64 B / op 1 allocs / op 

Comme vous pouvez le voir, strings.Builder est 4,7 fois plus rapide, provoque huit fois moins d'allocations et occupe quatre fois moins de mémoire.

Lorsque les performances sont importantes, utilisez strings.Builder . En général, je recommande de l'utiliser partout, sauf dans les cas les plus triviaux de construction de chaînes.

Utilisez strconv au lieu de fmt


fmt est l'un des packages les plus connus de Go. Vous l'avez probablement utilisé dans votre premier programme pour afficher «bonjour, monde». Mais quand il s'agit de convertir des entiers et des flottants en chaînes, ce n'est pas aussi efficace que son frère cadet strconv . Ce package affiche des performances décentes avec très peu de modifications de l'API.

fmt prend fondamentalement l' interface{} comme arguments de fonction. Il y a deux inconvénients:

  • Vous perdez la sécurité du type. Pour moi, c'est très important.
  • Cela peut augmenter la quantité de sécrétions nécessaires. Passer un type sans pointeur comme interface{} entraîne généralement une allocation de segment de mémoire. Ce billet de blog explique pourquoi il en est ainsi.
  • Le programme suivant montre la différence de performances:

     // main.go package main import ( "fmt" "strconv" ) func strconvFmt(a string, b int) string { return a + ":" + strconv.Itoa(b) } func fmtFmt(a string, b int) string { return fmt.Sprintf("%s:%d", a, b) } func main() {} 

     // main_test.go package main import ( "testing" ) var ( a = "boo" blah = 42 box = "" ) func BenchmarkStrconv(b *testing.B) { for i := 0; i < bN; i++ { box = strconvFmt(a, blah) } a = box } func BenchmarkFmt(b *testing.B) { for i := 0; i < bN; i++ { box = fmtFmt(a, blah) } a = box } 

    Benchmarks sur Macbook Pro:

      strfmt → go test -bench =.  -benchmem
     goos: darwin
     goarch: amd64
     pkg: github.com/sjwhitworth/perfblog/strfmt
     BenchmarkStrconv-8 30 000 000 39,5 ns / op 32 B / op 1 allocs / op
     Indice de référence Fmt-8 10000000143 ns / op 72 B / op 3 allocs / op 

    Comme vous pouvez le voir, l'option strconv est 3,5 fois plus rapide, provoque trois fois moins d'allocations et occupe deux fois moins de mémoire.

    Allouer le réservoir de tranches avec make pour éviter la redistribution


    Avant de passer à l'amélioration des performances, mettons à jour rapidement les informations découpées en mémoire. Une tranche est une construction très utile dans Go. Il fournit un tableau évolutif avec la possibilité d'accepter différentes vues dans la même mémoire de base sans réallocation. Si vous regardez sous le capot, la tranche se compose de trois éléments:

     type slice struct { // pointer to underlying data in the slice. data uintptr // the number of elements in the slice. len int // the number of elements that the slice can // grow to before a new underlying array // is allocated. cap int } 

    Quels sont ces domaines?

    • data : pointeur vers les données sous-jacentes dans la tranche
    • len : nombre actuel d'éléments dans la tranche
    • cap : nombre d'éléments qu'une tranche peut atteindre avant de redistribuer

    Les sections sous le capot sont des réseaux de longueur fixe. Lorsque la valeur maximale ( cap ) est atteinte, un nouveau tableau avec une valeur double est alloué, la mémoire est copiée de l'ancienne tranche vers la nouvelle et l'ancien tableau est supprimé.

    Je vois souvent quelque chose comme ce code où une tranche avec une capacité limite zéro est allouée si la capacité de la tranche est connue à l'avance:

     var userIDs []string for _, bar := range rsp.Users { userIDs = append(userIDs, bar.ID) } 

    Dans ce cas, la tranche commence par la taille zéro len et le cap capacité limite zéro. Après avoir reçu la réponse, nous ajoutons des éléments à la tranche, en même temps que nous atteignons la capacité limite: un nouveau tableau de base est sélectionné, où le cap doublé et les données y sont copiées. Si nous obtenons 8 éléments dans la réponse, cela conduit à 5 redistributions.

    La méthode suivante est beaucoup plus efficace:

     userIDs := make([]string, 0, len(rsp.Users)) for _, bar := range rsp.Users { userIDs = append(userIDs, bar.ID) } 

    Ici, nous avons explicitement alloué la capacité de la tranche à l'aide de make. Nous pouvons désormais y ajouter des données en toute sécurité, sans redistribution ni copie supplémentaires.

    Si vous ne savez pas combien de mémoire allouer, car la capacité est dynamique ou calculée ultérieurement dans le programme, mesurez la distribution finale de la taille de tranche après l'exécution du programme. Je prends habituellement le 90e ou 99e centile et je code dur la valeur dans le programme. Dans les cas où le CPU est plus cher que la RAM pour vous, définissez cette valeur plus haut que vous pensez nécessaire.

    L'astuce s'applique également aux cartes: make(map[string]string, len(foo)) allouera suffisamment de mémoire pour éviter la redistribution.

    Consultez cet article sur le fonctionnement réel des tranches.

    Utiliser des méthodes pour transférer des tranches d'octets


    Lorsque vous utilisez des paquets, utilisez des méthodes qui permettent la transmission d'une tranche d'octets: ces méthodes donnent généralement plus de contrôle sur la distribution.

    Un bon exemple consiste à comparer time.Format et time.AppendFormat . Le premier renvoie une chaîne. Sous le capot, cela sélectionne une nouvelle tranche d'octets et appelle time.AppendFormat dessus. Le second prend un tampon d'octets, écrit une représentation temporelle formatée et retourne une tranche d'octets étendue. Cela se trouve souvent dans d'autres packages de la bibliothèque standard: voir strconv.AppendFloat ou bytes.NewBuffer .

    Pourquoi cela augmente-t-il la productivité? Eh bien, maintenant vous pouvez passer les tranches d'octets que vous avez reçues de sync.Pool , au lieu d'allouer un nouveau tampon à chaque fois. Ou vous pouvez augmenter la taille de tampon initiale à une valeur qui convient mieux à votre programme afin de réduire le nombre de copies répétées de la tranche.

    Résumé


    Vous pouvez appliquer toutes ces méthodes à votre base de code. Au fil du temps, vous construirez un modèle mental pour raisonner sur les performances des programmes Go. Cela aidera grandement dans leur conception.

    Mais utilisez-les en fonction de la situation. Ce sont des conseils, pas l'évangile. Mesurez et vérifiez tout avec des repères.

    Et sachez quand vous arrêter. Augmenter la productivité est un bon exercice: la tâche est intéressante et les résultats sont immédiatement visibles. Cependant, l'utilité d'augmenter la productivité dépend fortement de la situation. Si votre service donne une réponse en 10 ms et que le retard du réseau est de 90 ms, vous ne devriez probablement pas essayer de réduire ces 10 ms à 5 ms: vous avez encore 95 ms. Même si vous optimisez le service au maximum jusqu'à 1 ms, le retard total sera toujours de 91 ms. Mangez probablement de plus gros poissons.

    Optimisez judicieusement!

    Les références


    Si vous souhaitez plus d'informations, voici de grandes sources d'inspiration:

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


All Articles