Go: Должен ли я использовать указатель вместо копии моей структуры?

в 13:18, , рубрики: benchmark, Go, heap, memory, pointer, stack, Программирование

image
Иллюстрация, созданная для «A Journey With Go», из оригинального гофера, созданного Рене Френч.

С точки зрения производительности систематическое использование указателей вместо копирования самой структуры для совместного использования структур многим Go разработчикам представляется наилучшим вариантом. Для того чтобы понять влияние использования указателя вместо копии структуры мы рассмотрим два варианта использования.

Интенсивное распределение данных

Давайте рассмотрим простой пример, когда вы хотите поделиться структурой для доступа к ее значениям:

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

Вот базовая структура, доступ к которой может быть разделен копией или указателем:

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

Основываясь на этих двух методах мы можем написать 2 бенчмарка. Первый — где структура передается копией:

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

Второй — очень похожий на первый — где структура передается по указателю:

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

Давайте запустим бенчмарки:

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

Получаем вот такую статистику:

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

Использование копии структуры оказалось в 8 раз быстрее, чем использование указателя на нее!

Чтобы понять почему, давайте посмотрим на графики, генерируемые трассировкой:

image
график для структуры, переданной копией

image
график для структуры, переданной указателем

Первый график довольно прост. Поскольку не используется куча, нет сборщика мусора и лишней горутины.

Во втором случае использование указателей заставляет компилятор Go перемещать переменную в кучу и работать сборщику мусора. Если мы увеличим масштаб графика, то увидим, что сборщик мусора занимает важную часть процесса:

image

На этом графике видно, что сборщик мусора запускается каждые 4 мс.

Если мы снова увеличим масштаб, мы можем получить подробную информацию о том, что именно происходит:

image

Синие, розовые и красные полосы являются фазами сборщика мусора, а коричневые связаны с аллоцированием в куче (на графике помечено как «runtime.bgsweep»):

Sweeping — это освобождение из кучи связанных с данными участков памяти, не помеченных как используемые. Это действие происходит, когда горутины пытаются выделить новые значения в памяти кучи. Задержка Sweeping добавляется к стоимости выполнения выделения в памяти кучи и не относится к каким-либо задержкам, связанным со сборкой мусора.

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

Даже если этот пример немного экстремален, мы видим, как может быть дорого выделять переменную в куче, а не в стеке. В нашем примере структура намного быстрее аллоцируется в стеке и копируется, чем создается в куче и разделяется ее адрес.

Если вы не знакомы со стеком/кучей, и если вы хотите больше узнать о их внутренних деталях, вы можете найти много информации в интернете, например, эту статью Пола Гриббла.

Все может быть даже еще хуже, если мы ограничим процессор до 1 с помощью 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

Если бенчмарк размещения в стеке не изменился, то показатель в куче уменьшился с 75ns/op до 114ns/op.

Интенсивные вызовы функций

Мы добавим два пустых метода в нашу структуру и немного адаптируем наши бенчмарки:

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

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

Бенчмарк с размещением в стеке создаст структуру и передаст ее копией:

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

И бенчмарк для кучи передаст структуру по указателю:

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

Как и ожидалось, результаты сейчас совсем другие:

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

Вывод

Использование указателя вместо копии структуры в go не всегда хорошо. Чтобы выбрать хорошую семантику для ваших данных, я настоятельно рекомендую прочитать пост о семантике значения/указателя, написанной Биллом Кеннеди. Это даст вам лучшее представление о стратегиях, которые вы можете использовать со своими структурами и встроенными типами. Кроме того, профилирование использования памяти определенно поможет вам понять, что происходит с вашими аллокациями и кучей.

Автор: Pavel Korotkiy

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js