Систематическое использование указателей для передачи структур вместо их копирования для многих разработчиков 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 раз быстрее указателя.
Чтобы понять, почему, давайте взглянем на графики, сгенерированные с помощью trace:
График для структуры, переданной копированием
График для структуры, переданной по указателю
Первый график довольно прост, поскольку нет использования кучи, нет сборщика мусора и дополнительных горутин.
Что касается второго графика — использование указателей заставляет компилятор go переместить переменную в кучу и создать давление на сборщик мусора. Если мы увеличим масштаб графика, то увидим, что сборщик мусора играет важную роль во всем процессе:
На этом графике мы видим, что сборщик мусора должен отрабатывать каждые 4 мс.
Если мы увеличим масштаб еще немного, мы сможем получить подробную информацию о том, что именно происходит:
Синий, розовый и красный — это фазы сборки мусора, а коричневый — это выделение памяти в куче (на графике помечено как «runtime.bgsweep»):
Sweeping — это когда восстанавливается память, связанная со значениями в динамической памяти, которые не были помечены как используемые (in-use). Это действие происходит, когда приложение Goroutines пытается выделить новые значения в динамической памяти. Задержка свипинга добавляется к стоимости выделения памяти в куче и не связана ни с какими задержками, связанными со сборкой мусора.
https://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
Если тест с аллокацией в стеке не меняется, то показатели для кучи ухудшились с 75нс/оп до 114нс/оп.
Интенсивные вызовы функций
В этом юзкейсе мы добавим в нашу структуру два пустых метода с небольшой адаптацией для наших тестов:
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 — не всегда хорошая идея.
Чтобы выбрать хорошую семантику для ваших данных, я настоятельно рекомендую прочитать статью о семантике значения/указателя, написанную Биллом Кеннеди. Это даст вам лучшее представление о стратегиях, которые вы можете использовать со своей структурой и встроенными типами.
Кроме того, профилирование использования памяти определенно поможет вам выяснить, что происходит с вашими аллокациями и вашей кучей.
Материал подготовлен в преддверии старта курса Golang Developer. Professional
Комментарии (10)
zuborg
26.08.2021 18:09+4В первом тесте бенчится не передача структуры (в функцию) по значению или указателю, а возврат структуры из функции или указателя на неё. Разумеется, в первом случае (возврат структуры) можно (иногда) обойтись без аллокации (положить структуру в стек), а во втором (возврат указателя) без аллокации уже не обойтись.
В то время как передавать структуру в функцию (последний тест, уже без страшных графиков) быстрее всего по указателю, что вполне очевидно.
maxim_ge
26.08.2021 19:04+2func 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 { panic("a!=1") } } trace.Stop() b.StopTimer() }
BenchmarkMemoryStack-4 193849980 6.160 ns/op
BenchmarkMemoryHeap-4 18460486 62.46 ns/op
BenchmarkMemoryHeap2-4 195428566 6.141 ns/opСобственно, уже было
JekaMas
26.08.2021 23:04+1Говоря про типы по значению и по указателю в golang, я бы обязательно говорил, что эти понятия размыты донельзя, поскольку на типе по значению можно вызывать методы по указателю https://play.golang.org/p/IW2bLAfz1l8
Эта "фича" и ещё больше ослабила систему типов, к сожалению.
tyderh
Как-то однобоко:
Тест производится на небольшой структуре. Очевидно, что после определенного размера структуры ситуация изменится. После какого?
Производительность - это не только скорость, но и потрабление памяти/аллокации
khim
Вот и выросло очередное поколение, заново открывающее для себя числа, которые обязан знать каждый.
Да, понятно, что бывают пограничные случаи, когда сходу неясно что выгоднее — передача по значению или по ссылке. Но если вы будете помнить, что один проход по ссылке, которой не посчастливилось попасть ни в один из кешей эквивалентен пересылке 400-500 байт данных, то вы поймёте, что ссылки имеют смысл сильно реже, чам многим кажется.
powerman
Ссылка не открывается, вот альтернатива https://gist.github.com/jboner/2841832
Sap_ru
Очень смелое утверждение. Чтобы скопировать структуру её нужно прочитать, что приведет к её обновлению в кэше и той же потере времени, что и при обращении по ссылке. Но потом её ещё записать записать, что выпивает из кэша какие другие данные. И где профит?
powerman
Вы, вероятно, хотели ответить @khim
Politura
В данном случае проблема не в этих числах, а в том, что в куче по-одному создается миллион объектов, которые никак не используются и тут-же уничтожаются, что нагружает GC.
В реальном коде объект создается и какое-то время живет и используется, в этом случае если нам надо работать с оным объектом из разных методов - выгоднее передавать его по ссылке. Однако если это кратковременный объект, чистое ДТО исключительно для передачи каких-то данных из одной функции в другую, то тут может быть действительно выгоднее эти данные передавать через стек, а не через кучу управляемую GC - здесь и выгода, что объект создается и уничтожается сам, без затрат на GC, и дает больше простора всяким оптимизациям во время сборки.
И вот в статье взяли этот один единственный кейс, тестанули (причем даже это сделали неправильно: возвращаемый объект никак не используется, так что есть шанс, что оптимизатор более агрессивно соптимизировал код для случая возврата по-значению), ничего не пояснили и оставили читателя додумывать что ему захочется.
Imobile
В моих задачах еще бы пришлось решать вопрос обратного копирования. Даже подумать страшно, как это решать, интересно что бы сказал автор