Skip to content

Analyse de performance

Lorsqu'un programme est terminé, nos exigences ne se limitent pas seulement à son exécution, nous souhaitons également qu'il s'agisse d'une application stable et efficace. Grâce à divers tests, nous pouvons garantir la plupart de la stabilité du programme. Pour déterminer si le programme est efficace, nous devons effectuer une analyse de performance. Dans le contenu précédent, le seul moyen d'analyse de performance était d'utiliser Benchmark pour tester le temps d'exécution moyen d'une unité fonctionnelle, la situation d'allocation de mémoire, etc. Cependant, dans la réalité, les besoins d'analyse de performance des programmes vont bien au-delà. Parfois, nous devons analyser l'utilisation globale du CPU du programme, l'utilisation de la mémoire, la situation d'allocation du tas, l'état des goroutines, les chemins de code critiques, etc., ce que Benchmark ne peut pas satisfaire. Heureusement, la chaîne d'outils Go intègre de nombreux outils d'analyse de performance à la disposition des développeurs. Nous allons les expliquer un par un.

Analyse d'échappement

En Go, l'allocation de mémoire des variables est décidée par le compilateur, généralement sur la pile ou sur le tas. Si une variable qui devrait être allouée sur la pile est allouée sur le tas, cette situation est appelée échappement. L'analyse d'échappement vise à analyser la situation d'allocation de mémoire dans le programme. Comme elle est effectuée pendant la compilation, il s'agit d'une analyse statique.

TIP

Consultez l'article Allocation de mémoire pour comprendre comment Go alloue la mémoire.

Référence de pointeur local

go
package main

func main() {
  GetPerson()
}

type Person struct {
  Name string
  Mom  *Person
}

func GetPerson() Person {
  mom := Person{Name: "lili"}
  son := Person{Name: "jack", Mom: &mom}
  return son
}

Dans la fonction GetPerson, la variable mom est créée. Comme elle est créée dans la fonction, elle devrait normalement être allouée sur la pile. Cependant, elle est référencée par le champ Mom de son, et son est retourné comme valeur de retour de la fonction. Le compilateur l'alloue donc sur le tas. Il s'agit d'un exemple très simple, donc la compréhension ne demande pas trop d'efforts. Mais s'il s'agit d'un projet plus important avec des dizaines de milliers de lignes de code, l'analyse manuelle n'est pas si facile. C'est pourquoi il est nécessaire d'utiliser des outils pour effectuer une analyse d'échappement. Comme mentionné précédemment, l'allocation de mémoire est dirigée par le compilateur, donc l'analyse d'échappement est également effectuée par le compilateur. L'utilisation est très simple, il suffit d'exécuter la commande suivante :

bash
$ go build -gcflags="-m -m -l"

gcflags correspond aux paramètres du compilateur gc :

  • -m, affiche les suggestions d'optimisation de code, l'apparition de deux rend la sortie plus détaillée
  • -l, désactive l'optimisation d'inlining

La sortie est la suivante :

bash
$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:13:2: mom escapes to heap:
./main.go:13:2:   flow: son = &mom:
./main.go:13:2:     from &mom (address-of) at ./main.go:14:35
./main.go:13:2:     from Person{...} (struct literal element) at ./main.go:14:15
./main.go:13:2:     from son := Person{...} (assign) at ./main.go:14:6
./main.go:13:2:   flow: ~r0 = son:
./main.go:13:2:     from return son (return) at ./main.go:15:2
./main.go:13:2: moved to heap: mom

Le compilateur nous indique clairement que la variable mom s'échappe, la raison étant que la valeur de retour contient un pointeur local de la fonction. Outre cette situation, d'autres cas peuvent également provoquer un échappement.

::: tips

Si les détails de l'analyse d'échappement vous intéressent, vous pouvez en apprendre davantage dans la bibliothèque standard cmd/compile/internal/escape/escape.go.

:::

Référence de closure

Si une closure référence une variable extérieure à la fonction, cette variable s'échappera également sur le tas, ce qui est facile à comprendre.

go
package main

func main() {
  a := make([]string, 0)
  do(func() []string {
    return a
  })
}

func do(f func() []string) []string {
  return f()
}

Sortie :

$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:10:9: f does not escape
./main.go:4:2: main capturing by value: a (addr=false assign=false width=24)
./main.go:4:11: make([]string, 0) escapes to heap:
./main.go:4:11:   flow: a = &{storage for make([]string, 0)}:
./main.go:4:11:     from make([]string, 0) (spill) at ./main.go:4:11
./main.go:4:11:     from a := make([]string, 0) (assign) at ./main.go:4:4
./main.go:4:11:   flow: ~r0 = a:
./main.go:4:11:     from return a (return) at ./main.go:6:3
./main.go:4:11: make([]string, 0) escapes to heap
./main.go:5:5: func literal does not escape

Espace insuffisant

Lorsque l'espace de pile est insuffisant, un échappement se produit également. Le slice créé ci-dessous demande une capacité de 1<<15 :

go
package main

func main() {
  _ = make([]int, 0, 1<<15)
}

Sortie :

$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:4:10: make([]int, 0, 32768) escapes to heap:
./main.go:4:10:   flow: {heap} = &{storage for make([]int, 0, 32768)}:
./main.go:4:10:     from make([]int, 0, 32768) (too large for stack) at ./main.go:4:10
./main.go:4:10: make([]int, 0, 32768) escapes to heap

Longueur inconnue

Lorsque la longueur d'un slice est une variable, en raison de sa longueur inconnue, un échappement se produit (les maps ne le font pas) :

go
package main

func main() {
  n := 100
  _ = make([]int, n)
}

Sortie :

$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:5:10: make([]int, n) escapes to heap:
./main.go:5:10:   flow: {heap} = &{storage for make([]int, n)}:
./main.go:5:10:     from make([]int, n) (non-constant size) at ./main.go:5:10
./main.go:5:10: make([]int, n) escapes to heap

Il existe également un cas spécial où un échappement peut se produire lorsque les paramètres de fonction sont de type ...any :

go
package main

import "fmt"

func main() {
  n := 100
  fmt.Println(n)
}

Sortie :

$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:7:14: n escapes to heap:
./main.go:7:14:   flow: {storage for ... argument} = &{storage for n}:
./main.go:7:14:     from n (spill) at ./main.go:7:14
./main.go:7:14:     from ... argument (slice-literal-element) at ./main.go:7:13
./main.go:7:14:   flow: {heap} = {storage for ... argument}:
./main.go:7:14:     from ... argument (spill) at ./main.go:7:13
./main.go:7:14:     from fmt.Println(... argument...) (call parameter) at ./main.go:7:13
./main.go:7:13: ... argument does not escape
./main.go:7:14: n escapes to heap

La raison pour laquelle nous effectuons une analyse d'échappement et contrôlons l'allocation de mémoire de manière aussi détaillée est principalement de réduire la pression sur le GC. Cependant, Go n'est pas le langage C, le pouvoir de décision final sur l'allocation de mémoire reste entre les mains du compilateur. Sauf dans des cas de performances extrêmes, nous n'avons généralement pas besoin de nous concentrer trop sur les détails de l'allocation de mémoire, car le but du GC est de libérer les développeurs.

Petit détail

Pour certains types de référence, lorsqu'il est confirmé qu'ils ne seront plus utilisés, nous pouvons les définir à nil pour indiquer au GC qu'il peut les récupérer.

go
type Writer struct {
  buf []byte
}

func (w Writer) Close() error {
  w.buff = nil
  return nil
}

pprof

pprof (program profiling) est un outil puissant pour l'analyse de performance des programmes. Il effectue un échantillonnage partiel des données d'exécution du programme, couvrant le CPU, la mémoire, les goroutines, les verrous, les informations de pile, etc., puis utilise des outils pour analyser et afficher les résultats des données échantillonnées.

L'utilisation de pprof ne comporte donc que deux étapes :

  1. Collecte des données
  2. Analyse des résultats

Collecte

Il existe deux méthodes de collecte des données : automatique et manuelle, chacune ayant ses avantages et inconvénients. Avant cela, écrivons une fonction simple pour simuler la consommation de mémoire et de CPU :

go
func Do() {
  for i := 0; i < 10; i++ {
    slice := makeSlice()
    sortSlice(slice)
  }
}

func makeSlice() []int {
  var s []int
  for range 1 << 24 {
    s = append(s, rand.Int())
  }
  return s
}

func sortSlice(s []int) {
  slices.Sort(s)
}

Manuel

La collecte manuelle est contrôlée par le code. Ses avantages sont la contrôlabilité, la flexibilité et la personnalisation. L'utilisation directe de pprof dans le code nécessite l'importation du package runtime/pprof :

go
package main

import (
  "log"
  "os"
  "runtime/pprof"
)

func main() {
    Do()
  w, _ := os.Create("heap.pb")
  heapProfile := pprof.Lookup("heap")
  err := heapProfile.WriteTo(w, 0)
  if err != nil {
    log.Fatal(err)
  }
}

Les paramètres pris en charge par pprof.Lookup sont les suivants :

go
profiles.m = map[string]*Profile{
    "goroutine":    goroutineProfile,
    "threadcreate": threadcreateProfile,
    "heap":         heapProfile,
    "allocs":       allocsProfile,
    "block":        blockProfile,
    "mutex":        mutexProfile,
}

Cette fonction écrit les données collectées dans un fichier spécifié. Le nombre transmis lors de l'écriture a les significations suivantes :

  • 0, écrit les données Protobuf compressées, sans lisibilité
  • 1, écrit les données au format texte, lisibles, c'est le type de données retourné par l'interface HTTP
  • 2, disponible uniquement pour goroutine, indique l'impression des informations de pile au style panic

La collecte des données CPU nécessite l'utilisation de la fonction pprof.StartCPUProfile, qui nécessite un certain temps pour l'échantillonnage et dont les données brutes ne sont pas lisibles, comme suit :

go
package main

import (
  "log"
  "os"
  "runtime/pprof"
  "time"
)

func main() {
    Do()
  w, _ := os.Create("cpu.out")
  err := pprof.StartCPUProfile(w)
  if err != nil {
    log.Fatal(err)
  }
  time.Sleep(time.Second * 10)
  pprof.StopCPUProfile()
}

La collecte des données trace est similaire :

go
package main

import (
  "log"
  "os"
  "runtime/trace"
  "time"
)

func main() {
    Do()
  w, _ := os.Create("trace.out")
  err := trace.Start(w)
  if err != nil {
    log.Fatal(err)
  }
  time.Sleep(time.Second * 10)
  trace.Stop()
}

Automatique

Le package net/http/pprof encapsule les fonctions d'analyse ci-dessus en interfaces HTTP et les enregistre dans les routes par défaut, comme suit :

go
package pprof

import ...

func init() {
    http.HandleFunc("/debug/pprof/", Index)
    http.HandleFunc("/debug/pprof/cmdline", Cmdline)
    http.HandleFunc("/debug/pprof/profile", Profile)
    http.HandleFunc("/debug/pprof/symbol", Symbol)
    http.HandleFunc("/debug/pprof/trace", Trace)
}

Cela nous permet d'exécuter la collecte de données pprof en un clic :

go
package main

import (
  "net/http"
    // N'oubliez pas d'importer ce package
  _ "net/http/pprof"
)

func main() {
    go func(){
        http.ListenAndServe(":8080", nil)
    }
    for {
        Do()
    }
}

À ce stade, en ouvrant le navigateur et en accédant à http://127.0.0.1:8080/debug/pprof, la page suivante apparaîtra :

Plusieurs options sont disponibles sur la page, représentant respectivement :

  • allocs : échantillonnage d'allocation de mémoire
  • block : suivi des blocages des primitives de synchronisation
  • cmdline : appel en ligne de commande du programme actuel
  • goroutine : suivi de toutes les goroutines
  • heap : échantillonnage d'allocation de mémoire pour les objets vivants
  • mutex : suivi des informations relatives aux mutex
  • profile : analyse CPU, analyse pendant un certain temps et télécharge un fichier
  • threadcreate : analyse des raisons de la création de nouveaux threads OS
  • trace : suivi de l'exécution du programme actuel, télécharge également un fichier

La plupart des données ici n'ont pas une lisibilité élevée, elles sont principalement destinées à être analysées par des outils, comme illustré ci-dessous :

Le travail d'analyse spécifique sera effectué ultérieurement. À l'exception des deux options profile et trace, si vous souhaitez télécharger des fichiers de données dans la page Web, vous pouvez supprimer le paramètre de requête debug=1. Vous pouvez également intégrer ces interfaces dans vos propres routes au lieu d'utiliser les routes par défaut, comme suit :

go
package main

import (
  "net/http"
  "net/http/pprof"
)

func main() {
  mux := http.NewServeMux()
  mux.HandleFunc("/trace", pprof.Trace)
  servre := &http.Server{
    Addr:    ":8080",
    Handler: mux,
  }
  servre.ListenAndServe()
}

De cette manière, ils peuvent également être intégrés dans d'autres frameworks Web, tels que gin, iris, etc.

Analyse

Après avoir obtenu les fichiers de données collectées, il existe deux méthodes d'analyse : en ligne de commande ou via une page Web. Les deux nécessitent l'outil en ligne de commande pprof, qui est intégré par défaut dans Go, donc aucun téléchargement supplémentaire n'est nécessaire.

Adresse open source de pprof : google/pprof: pprof is a tool for visualization and analysis of profiling data (github.com)

Ligne de commande

Utilisez le fichier de données collecté comme paramètre :

bash
$ go tool pprof heap.pb

Si les données sont collectées via le Web, remplacez le nom de fichier par l'URL Web :

bash
$ go tool pprof -http :8080 http://127.0.0.1/debug/pprof/heap

Une ligne de commande interactive apparaîtra ensuite :

bash
15:27:38.3266862 +0800 CST
Type: inuse_space
Time: Apr 15, 2024 at 3:27pm (CST)
No samples were found with the default sample value type.
Try "sample_index" command to analyze different sample values.
Entering interactive mode (type "help" for commands, "o" for options)
(pprof)

Entrez help pour afficher d'autres commandes :

  Commands:
    callgrind        Outputs a graph in callgrind format
    comments         Output all profile comments
    disasm           Output assembly listings annotated with samples
    dot              Outputs a graph in DOT format
    eog              Visualize graph through eog
    evince           Visualize graph through evince
  ...

Pour afficher les données en ligne de commande, utilisez généralement la commande top. Vous pouvez également utiliser la commande traces, mais sa sortie est très longue. La commande top donne simplement une vue d'ensemble.

(pprof) top 5
Showing nodes accounting for 117.49MB, 100% of 117.49MB total
      flat  flat%   sum%        cum   cum%
  117.49MB   100%   100%   117.49MB   100%  main.makeSlice (inline)
         0     0%   100%   117.49MB   100%  main.Do
         0     0%   100%   117.49MB   100%  main.main
         0     0%   100%   117.49MB   100%  runtime.main

Voici une brève introduction de certains indicateurs (identique pour le CPU) :

  • flat, représente les ressources consommées par la fonction actuelle
  • cum, somme totale des ressources consommées par la fonction actuelle et sa chaîne d'appels suivante
  • flat%, flat/total
  • cum%, cum/total

Nous pouvons clairement voir que l'occupation de mémoire de toute la pile d'appels est de 117,49 Mo. Comme la fonction Do ne fait rien elle-même, elle appelle simplement d'autres fonctions, donc son indicateur flat est 0. La création du slice est gérée par la fonction makeSlice, donc son indicateur flat est de 100%.

Nous pouvons convertir le format de visualisation. pprof prend en charge de nombreux formats, tels que pdf, svg, png, gif, etc. (nécessite l'installation de Graphviz).

(pprof) png
Generating report in profile001.png

Grâce à l'image, nous pouvons voir plus clairement la situation de mémoire de toute la pile d'appels.

Utilisez la commande list pour afficher sous forme de code source :

(pprof) list Do
Total: 117.49MB
ROUTINE ======================== main.Do in D:\WorkSpace\Code\GoLeran\golearn\example\main.go
         0   117.49MB (flat, cum)   100% of Total
         .          .     21:func Do() {
         .          .     22:   for i := 0; i < 10; i++ {
         .   117.49MB     23:           slice := makeSlice()
         .          .     24:           sortSlice(slice)
         .          .     25:   }
         .          .     26:}
         .          .     27:
         .          .     28:func makeSlice() []int {

Pour les images et le code source, vous pouvez également utiliser les commandes web et weblist pour les afficher dans le navigateur.

Page Web

Avant cela, pour des données plus diversifiées, modifions légèrement la fonction de simulation :

go
func Do1() {
  for i := 0; i < 10; i++ {
    slice := makeSlice()
    sortSlice(slice)
  }
}

func Do2() {
  for i := 0; i < 10; i++ {
    slice := makeSlice()
    sortSlice(slice)
  }
}

func makeSlice() []int {
  var s []int
  for range 1 << 12 {
    s = append(s, rand.Int())
  }
  return s
}

func sortSlice(s []int) {
  slices.Sort(s)
}

L'analyse Web permet de visualiser les résultats, évitant ainsi de manipuler manuellement la ligne de commande. Pour utiliser l'analyse Web, exécutez simplement la commande suivante :

bash
$ go tool pprof -http :8080 heap.pb

Si les données sont collectées via le Web, remplacez le nom de fichier par l'URL Web :

bash
$ go tool pprof -http :8080 http://127.0.0.1:9090/debug/pprof/heap
$ go tool pprof -http :8080 http://127.0.0.1:9090/debug/pprof/profile
$ go tool pprof -http :8080 http://127.0.0.1:9090/debug/pprof/goroutine

TIP

Pour savoir comment analyser les données, consultez pprof: How to read the graph pour en savoir plus.

Il y a au total 6 éléments consultables sur la page Web :

  • Top, identique à la commande top
  • Graph, diagramme linéaire
  • Flame Graph, diagramme en flamme
  • Peek,
  • Source, afficher le code source
  • Disassemble, désassembler pour afficher

Pour la mémoire, quatre dimensions peuvent être analysées :

  • alloc_objects : nombre total d'objets alloués, y compris ceux déjà libérés
  • alloc_spcae : espace mémoire total alloué jusqu'à présent, y compris celui déjà libéré
  • inuse_objects : nombre d'objets en cours d'utilisation
  • inuse_space : espace mémoire en cours d'utilisation

Analyse de mémoire

Les nœuds feuilles blancs en bas de l'image ci-dessus représentent l'occupation d'objets de différentes tailles.

Analyse CPU

Concernant le diagramme linéaire, voici quelques points à noter :

  • Plus la couleur du bloc est foncée, plus l'occupation est élevée ; plus la ligne est épaisse, plus l'occupation est élevée
  • Les lignes pleines représentent les appels directs, les lignes pointillées représentent les chaînes d'appels ignorées.

Diagramme en flamme de mémoire

Diagramme en flamme CPU

Pour le diagramme en flamme, de haut en bas se trouve la chaîne d'appels, de gauche à droite se trouve le pourcentage d'occupation cum.

trace

pprof est principalement responsable de l'analyse de l'utilisation des ressources du programme, tandis que trace est plus adapté pour suivre les détails d'exécution du programme. Ses fichiers de données sont incompatibles avec ceux de pprof, et l'analyse associée est effectuée par la commande go tool trace.

Si les données sont collectées manuellement, vous pouvez utiliser le nom de fichier comme paramètre :

$ go tool trace trace.out

Si les données sont collectées automatiquement, c'est la même chose :

bash
$ curl http://127.0.0.1:8080/debug/pprof/trace > trace.out && go tool trace trace.out

Après exécution, un serveur Web sera démarré :

2024/04/15 17:15:40 Preparing trace for viewer...
2024/04/15 17:15:40 Splitting trace for viewer...
2024/04/15 17:15:40 Opening browser. Trace viewer is listening on http://127.0.0.1:51805

Après ouverture, la page ressemblera approximativement à ceci :

Cela comprend principalement les sections suivantes. La compréhension de ces données n'est pas facile.

  • Event timelines for running goroutines

    • trace by proc : affiche la chronologie des goroutines s'exécutant sur ce processeur à chaque instant

    • trace by thread : affiche la chronologie des goroutines s'exécutant sur les threads OS à chaque instant

    • Goroutine analysis : affiche les informations statistiques relatives aux goroutines pour chaque groupe de fonctions principales

  • Profiles

    • Network blocking profile : informations sur les goroutines bloquées en raison d'IO réseau
    • Synchronization blocking profile : informations sur les goroutines bloquées en raison de primitives de synchronisation
    • Syscall profile : informations sur les goroutines bloquées en raison d'appels système
  • User-defined tasks and regions

    • User-defined tasks : informations sur les goroutines relatives aux tâches définies par l'utilisateur
    • User-defined regions : informations sur les goroutines relatives aux zones de code définies par l'utilisateur
  • Garbage collection metrics

    • Minimum mutator utilization : affiche le temps maximum du GC récent

Golang by www.golangdev.cn edit