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

Original author: Vincent Blanchon
  • Translation
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 не всегда хорошо. Чтобы выбрать хорошую семантику для ваших данных, я настоятельно рекомендую прочитать пост о семантике значения/указателя, написанной Биллом Кеннеди. Это даст вам лучшее представление о стратегиях, которые вы можете использовать со своими структурами и встроенными типами. Кроме того, профилирование использования памяти определенно поможет вам понять, что происходит с вашими аллокациями и кучей.

Similar posts

Ads
AdBlock has stolen the banner, but banners are not teeth — they will be back

More

Comments 27

    +1

    Активно изучаю Go, за последний месяц была масса годных статей (пусть часть из них переводная, но на степень годноты это не влияет).


    Либо попал на эффект "часто обращаешь внимание на то, о чём сейчас думаешь", либо на Хабре можно объявлять "месяц статей по Go" ;)

      +1

      Согласен, давно такого не было, а вот последний период активно начали писать. Реально "месяц статей по Go". Хочется только поблагодарить авторов.

        0
        Вы изучаете кор-либ уже целый месяц? Долго что-то.
          +1

          Нет, я уже месяц (на самом деле уже почти два) изучаю сам язык. Изучаю привычным способом — беру интересную и актуальную для меня задачу и начинаю её решать на новом языке.


          И нередко обнаруживаю совершенно неочевидные для меня косяки. К примеру, долго не мог получить загрузку CPU выше 60-70%, а потом благодаря одному ну совсем очевидному комментарию получил +30% производительности и нагрузку на проц "в полочку".

            +1
            Озвучте пожалуйста решение.
        +6

        На Go пишу недавно. Может, не понимаю чего… Объясните, пожалуйста, в чем смысл такого теста?


        Я бы понял, если бы сравнивали передачу функции в качестве параметра
        а) указателя на одну и ту же структуру
        с б) передачей структуры целиком.


        Но здесь вроде как по-другому: каждый раз в цикле создаётся новая структура и предаётся указатель на неё.


        Смысл такого действия мне понять сложно, может, я неправильно понимаю происходящее?

          0
          Тут смысл в том что автор хотел показать как на производительность влияет ескейп данных в кучу (отсюда и тест который выделяет в куче память b.N раз), но может создаться впечатление что на производительность влияют именно указатели (которые являются только одним из условий перемещения в кучу).
            0
            Вот именно — название статьи похоже на заголовок желтушной газетки.
            +1

            Тоже ожидал другое содержимое статьи, для совсем новичков. А тут скорее статья про аллокацию и деаллокацию...

            –5
            А скажите пожалуйста, неужели на хабре найдется хоть один человек, который НЕ ЗНАЕТ, чем и как отличаются передача параметров по значнию и по ссылке?
              +2

              Вопрос не в том, в чем разница между передачей по ссылке или по значению, а в том к каким накладным расходам это влечет в определенных ситуациях при написании кода на Go. Как заметили выше работа с указателями лишь одно из условий для перемещения данных в кучу.
              Хорошая заметка.

                0

                Чисто теоретически это понятно.


                Но, опять же, чисто теоретически — могло бы, например, быть, что при передаче по значению выделяется сплошной кусок памяти (сплошной блок адресов), а при передаче по ссылке — создаётся какая-то фрагментированная "теневая копия" в свободных блоках. Мало ли, как это реализовано… я вот какого-то такого подвоха ожидал что для экономии памяти тут что-то такое хитрое.

                  0

                  Вот только Go нету ссылок, только передача по значению.

                    0
                    BenchmarkMemoryStack(b *testing.B)
                    А это что?
                      +1

                      Указатель, который передается по значению.

                        +1
                        Везде указатель передается по-значению (по другому никак). Передается адрес в памяти.
                        Это и называют передачей по-ссылке.
                          0
                          Короч я всё напутал) Да, вы правы. Это указатель.
                          Заметил, много где в статьях про го, авторы считают ссылки и указатели как одно и тоже. Отсюда и путаница.
                    0
                    Круто, что для понимания того, что куча с gc работает медленнее чем стек, надо бенчмарки писать :)

                    Название статьи не отражает сути проблемы. Другая важная часть, которая напрямую относится к проблеме — escape analysis — вообще не затронута.

                    Тем, кто не в курсе таких проблем статья и так не поможет(потому что разница между стеком и хипом это довольно фундаментальная вещь, и скорее всего там кроме незнания кучи еще миллион пробелов в других областях), а для тех кто в курсе дела — просто смешная.
                      +1

                      Здесь не хватает ссылки, которая в конце оригинальной статьи:
                      https://www.ardanlabs.com/blog/2017/06/design-philosophy-on-data-and-semantics.html


                      Вот там очень подробно и про стек против кучи, и про указатели против значений.

                        +1

                        Она там же, где и в оригинальной статье, в выводах.

                      +1
                      а кто знает как делать такую трассировку go?
                      +1
                      В Go работа с памятью весьма тонка́, и эта тонкость является обратной стороной простоты. Даже и наоборот — для достижения максимальной производительности Go весьма непрост, ибо нужно учитывать скрытые нюансы, которые вроде как и не являются частью языка.

                      Немного переписав тест BenchmarkMemoryHeap получаем ту же производительность, что и для BenchmarkMemoryStack:

                      func BenchmarkMemoryHeap2(b *testing.B) {
                      
                      	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()
                      		if s.a != 1 {
                      			log.Fatalln("a!=1")
                      		}
                      	}
                      
                      	trace.Stop()
                      
                      	b.StopTimer()
                      
                      }
                      


                      BenchmarkMemoryStack-4   	159990848	         7.48 ns/op	       0 B/op	       0 allocs/op
                      BenchmarkMemoryHeap-4    	19594401	        61.1 ns/op	      96 B/op	       1 allocs/op
                      BenchmarkMemoryHeap2-4   	164374148	         7.31 ns/op	       0 B/op	       0 allocs/op


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

                      Обсуждаемый вопрос, соответственно, несколько «подвисает»…
                        0
                        Так а почему в последнем примере ситуация совершенно другая? Почему вызов метода и передачу аргументов в функцию лучше делать через указатель?
                          +1
                          Кстати, в комментариях оригинальной статьи этот вопрос также задавался. Vincent Blanchon, автор статьи, ответил на него примерно так:
                          Во втором примере мы больше не создаем структуры в стеке/куче. Мы только отслеживаем стоимость использования указателя/копии в качестве параметра. При использовании указателя Go просто делиться адресом на структуру. При копировании копируется вся структура для использования в качестве параметра, что медленнее.
                          –2

                          Данный вид бенчмаркинга — в чистом виде лукавство. Просто удивлён даже, насколько это лукаво и ужасно.


                          1. Считаю, что некорректно оставлять создание объекта (не важно каким способом) внутри измеряемого пространства. Поскольку в любом случае объект будет создан хотя бы один раз и ресурсы будут потрачены неизбежно и причём одинаковые.
                          2. При подобном подходе в написании бенчмарка вполне естественно, что возврат объекта в виде указателя будет нести с собой бОльший оверхед за счёт gc, это ясно как дважды два, зачем это делать — непонятно, и что таким образом хотелось продемонстрировать тоже не ясно.
                          3. Самое главное. Если следовать заданному в статье введению, то речь идёт именно о совместном использовании. А если говорить о совместном использовании, то надо говорить о передаче объекта в произвольную функцию. Здесь то и начинается самое важное. Если передавать объект по значению, то при данной операции неизбежно будет выполнена новая аллокация копии объекта целиком. Если объект состоит из двух полей int, то здесь и говорить не о чем, а теперь подумайте если объект содержит в одном из полей массив на 100 000 элементов. Есть смысл сравнивать аллокацию такого нового объекта с аллокацией переменной под указатель и временем для последующего сбора gc? Вопрос риторический.
                          4. Прочитав эту статью меня больше всего насторожило, что ни в одном комментарии никто не обратил на это внимание. Главное зло статьи в том, что у начинающих разработчиков может создаться иллюзия того, что не стоит заморачиваться и использовать указатели… «человек вон доказал бенчмарками, что разницы нет!». А доказательство это — чистой воды лукавство и ввод в заблуждение. Измените 3 строки в тесте и вы увидите реальное положение дел.
                          5. Данная статья годна только для ответа на вопрос «стоит ли создавать объект в виде указателя при инициализации, не планируя его совместное использование»… то есть, вопрос сам по себе довольно утопичен.

                          Боюсь не выберу время написать статью для развеивания данной статьи, но очень хочется...

                          Only users with full accounts can post comments. Log in, please.