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é.
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)
Un moyen sûr de toujours garantir zéro mémoire est de le faire explicitement:
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 )
É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:
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:
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 {
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: