Aller: Dois-je utiliser un pointeur au lieu d'une copie de ma structure?

image
Illustration créée pour A Journey With Go à partir d'un gopher original créé par René French.

En termes de performances, l'utilisation systématique de pointeurs au lieu de copier la structure elle-même pour partager des structures pour de nombreux développeurs Go semble la meilleure option. Afin de comprendre l'effet de l'utilisation d'un pointeur au lieu d'une copie de la structure, nous considérerons deux cas d'utilisation.

Distribution intensive des données


Regardons un exemple simple lorsque vous souhaitez partager une structure pour accéder à ses valeurs:

type S struct {
  a, b, c int64
  d, e, f string
  g, h, i float64
}

Voici la structure de base, dont l'accès peut être partagé par copie ou pointeur:

func byCopy() S {
  return S{
     a: 1, b: 1, c: 1,
     e: "foo", f: "foo",
     g: 1.0, h: 1.0, i: 1.0,
  }
}

func byPointer() *S {
  return &S{
     a: 1, b: 1, c: 1,
     e: "foo", f: "foo",
     g: 1.0, h: 1.0, i: 1.0,
  }
}

Sur la base de ces deux méthodes, nous pouvons écrire 2 repères. Le premier est l'endroit où la structure est transmise avec une copie:

func BenchmarkMemoryStack(b *testing.B) {
  var s S

  f, err := os.Create("stack.out")
  if err != nil {
     panic(err)
  }
  defer f.Close()

  err = trace.Start(f)
  if err != nil {
     panic(err)
  }

  for i := 0; i < b.N; i++ {
     s = byCopy()
  }

  trace.Stop()

  b.StopTimer()

  _ = fmt.Sprintf("%v", s.a)
}

Le second - très similaire au premier - où la structure est passée par pointeur:

func BenchmarkMemoryHeap(b *testing.B) {
  var s *S

  f, err := os.Create("heap.out")
  if err != nil {
     panic(err)
  }
  defer f.Close()

  err = trace.Start(f)
  if err != nil {
     panic(err)
  }

  for i := 0; i < b.N; i++ {
     s = byPointer()
  }

  trace.Stop()

  b.StopTimer()

  _ = fmt.Sprintf("%v", s.a)
}

Lançons les repères:

go test ./... -bench=BenchmarkMemoryHeap -benchmem -run=^$ -count=10 > head.txt && benchstat head.txt
go test ./... -bench=BenchmarkMemoryStack -benchmem -run=^$ -count=10 > stack.txt && benchstat stack.txt

Nous obtenons les statistiques suivantes:

name          time/op
MemoryHeap-4  75.0ns ± 5%

name          alloc/op
MemoryHeap-4   96.0B ± 0%

name          allocs/op
MemoryHeap-4    1.00 ± 0%

------------------

name           time/op
MemoryStack-4  8.93ns ± 4%

name           alloc/op
MemoryStack-4   0.00B

name           allocs/op
MemoryStack-4    0.00

Utiliser une copie de la structure était 8 fois plus rapide que d'utiliser un pointeur vers celle-ci!

Pour comprendre pourquoi, regardons les graphes générés par la trace: le

image
graphe de la structure passée par la copie le

image
graphe de la structure passée par le pointeur

Le premier graphe est assez simple. Comme le tas n'est pas utilisé, il n'y a pas de ramasse-miettes et de gorutine en excès.

Dans le second cas, l'utilisation de pointeurs oblige le compilateur Go à déplacer la variable vers le segment de mémoire et à exécuter le garbage collector. Si nous augmentons l'échelle du graphique, nous verrons que le garbage collector occupe une partie importante du processus:

image

Ce graphique montre que le garbage collector démarre toutes les 4 ms.

Si nous zoomons à nouveau, nous pouvons obtenir des informations détaillées sur ce qui se passe exactement:

image

les bandes bleues, roses et rouges sont les phases du ramasse-miettes et les brunes sont associées à l'allocation dans le tas (marquée «runtime.bgsweep» sur le graphique):

Le balayage est la libération du tas de sections de mémoire liées aux données qui ne sont pas marquées comme utilisées. Cette action se produit lorsque des goroutines tentent d'isoler de nouvelles valeurs dans la mémoire de segment de mémoire. Le délai de balayage est ajouté au coût de l'exécution de l'allocation dans la mémoire du tas et ne s'applique pas aux retards associés à la récupération de place.

www.ardanlabs.com/blog/2018/12/garbage-collection-in-go-part1-semantics.html

Même si cet exemple est un peu extrême, nous voyons comment il peut être coûteux d'allouer une variable sur le tas plutôt que sur la pile. Dans notre exemple, la structure est beaucoup plus rapidement allouée sur la pile et copiée que créée sur le tas et son adresse est partagée.

Si vous n'êtes pas familier avec la pile / tas et si vous voulez en savoir plus sur leurs détails internes, vous pouvez trouver de nombreuses informations sur Internet, par exemple, cet article de Paul Gribble.

Cela pourrait être encore pire si nous limitons le processeur à 1 en utilisant GOMAXPROCS = 1:

name        time/op
MemoryHeap  114ns ± 4%

name        alloc/op
MemoryHeap  96.0B ± 0%

name        allocs/op
MemoryHeap   1.00 ± 0%

------------------

name         time/op
MemoryStack  8.77ns ± 5%

name         alloc/op
MemoryStack   0.00B

name         allocs/op
MemoryStack    0.00

Si la référence pour le placement sur la pile n'a pas changé, alors l'indicateur sur le tas est passé de 75ns / op à 114ns / op.

Appels de fonction intensifs


Nous allons ajouter deux méthodes vides à notre structure et adapter un peu nos benchmarks:

func (s S) stack(s1 S) {}

func (s *S) heap(s1 *S) {}

Le benchmark avec le placement sur la pile va créer la structure et lui passer une copie:

func BenchmarkMemoryStack(b *testing.B) {
  var s S
  var s1 S

  s = byCopy()
  s1 = byCopy()
  for i := 0; i < b.N; i++ {
     for i := 0; i < 1000000; i++  {
        s.stack(s1)
     }
  }
}

Et la référence pour le tas passera la structure par pointeur:

func BenchmarkMemoryHeap(b *testing.B) {
  var s *S
  var s1 *S

  s = byPointer()
  s1 = byPointer()
  for i := 0; i < b.N; i++ {
     for i := 0; i < 1000000; i++ {
        s.heap(s1)
     }
  }
}

Comme prévu, les résultats sont complètement différents maintenant:

name          time/op
MemoryHeap-4  301µs ± 4%

name          alloc/op
MemoryHeap-4  0.00B

name          allocs/op
MemoryHeap-4   0.00

------------------

name           time/op
MemoryStack-4  595µs ± 2%

name           alloc/op
MemoryStack-4  0.00B

name           allocs/op
MemoryStack-4   0.00

Conclusion


Utiliser un pointeur au lieu d'une copie de la structure dans go n'est pas toujours une bonne chose. Pour sélectionner une bonne sémantique pour vos données, je recommande fortement de lire un article sur la sémantique valeur / pointeur écrit par Bill Kennedy . Cela vous donnera une meilleure idée des stratégies que vous pouvez utiliser avec vos structures et types intégrés. De plus, le profilage de l'utilisation de la mémoire vous aidera certainement à comprendre ce qui se passe avec vos allocations et vos tas.

All Articles