Аннотация: Использование переменных небольшого размера по значению может быть быстрее, чем использование указателей, благодаря кэшированию значений ЦП.
Отказ от ответственности: остерегайтесь преждевременной оптимизации; эта статья дает вам более полное представление об инструменте, с которым вы работаете, а не дает серебряную пулю.
Часто мы используем указатели при работе со структурами. Например, вот фрагмент кода, который вам вполне знаком:
type User struct { name string age uint } func (u *User) Name() string { return u.name }
Мы часто используем синтаксис (*Struct)
для получателей, как в этом примере, хотя в этом случае нам не нужен указатель, поскольку мы не планируем изменять структуру User
. Так почему же мы это делаем? Потому что нас учили, что работать с указателями гораздо быстрее, чем со значениями, что в большинстве случаев верно. Но не всегда.
Вы наверняка слышали о строках кэша процессора. Это память небольшого размера, которая на порядок (даже в десятки раз) быстрее основной памяти. Любые данные, с которыми вы работаете, кэшируются процессором для оптимизации операций.
Вот строки кэша (различные уровни кэша процессора) на моем Mac:
sudo sysctl -a | grep cache ... hw.l1icachesize: 32768 hw.l1dcachesize: 32768 hw.l2cachesize: 262144 hw.l3cachesize: 12582912
Это байтовые значения. Теперь представим, что наша структура помещается в кэш L1. Имеет ли смысл кэшировать указатель? Если кеш содержит указатель, кэширование не дает особых преимуществ при доступе к полю структуры, на которую указывает указатель. После получения указателя, указывающего на место в основной памяти, вам все равно потребуется доступ к основной памяти для получения данных. Следовательно, гораздо выгоднее передавать небольшие структуры по значению.
Например, рассмотрим следующую структуру:
type Small struct { str string // 16 bytes i int // 8 bytes b bool // 1 byte + 7 padding bytes }
Эта структура занимает в памяти всего 32 байта. И он впишется в L1! Следовательно, выгодно получить к нему доступ без использования указателя. Давайте проверим это.
func BenchmarkValue_L1(b *testing.B) { s := Small{"str", 1, true} // 32 bytes, fits L1 cache println(unsafe.Sizeof(s), unsafe.Offsetof(s.str), unsafe.Offsetof(s.i), unsafe.Offsetof(s.b)) // prepare data items := make([]Small, 0, 100) for j := 0; j < 100; j++ { items = append(items, s) } // bench for i := 0; i < b.N; i++ { processSmallByVal(items) } } func BenchmarkPointer_L1(b *testing.B) { s := &Small{"str", 1, true} // 32 bytes, fits L1 cache // prepare data items := make([]*Small, 0, 100) for j := 0; j < 100; j++ { items = append(items, s) } // bench for i := 0; i < b.N; i++ { processSmallByPointer(items) } } /* data manipulation */ func processSmallByVal(items []Small) { for _, item := range items { // get item value, item var reused to store values from slice for i := 0; i < 10000; i++ { item.i = i // access item's field from cache, mutate field, store in cache line directly item.str = "123" // same someint, somestr, somebool := item.i, item.str, item.b // read item's fields from cache _, _, _ = someint, somestr, somebool // stub } } } func processSmallByPointer(items []*Small) { for _, item := range items { // get pointer, store in 'item' for i := 0; i < 10000; i++ { item.i = i // go to the main mem item.str = "123" // go to the main mem again someint, somestr, somebool := item.i, item.str, item.b // go to the main mem again _, _, _ = someint, somestr, somebool // stub } } }
Полученные результаты:
go test -bench=. -benchtime=10s goos: darwin goarch: amd64 pkg: module05/fibonachi cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz BenchmarkValue_L1-12 44923 257923 ns/op BenchmarkPointer_L1-12 15602 775480 ns/op
Непосредственный доступ к значению без использования указателя осуществляется в три раза быстрее.
Если мы увеличим размер нашей структуры до размера более 32 КБ, но меньше 262 144 КБ, то мы будем работать со значениями из L2.
type Big struct { arr [5000]int // 40kb }
Эта структура слишком велика для L1 на моей машине, но вписывается в L2. Давайте напишем аналогичный тест:
func BenchmarkValue_L2(b *testing.B) { a := [5000]int{} // 40kb, fits L2 cache for i := 0; i < 5000; i++ { a[i] = i // fill array with values } s := Big{arr: a} // bench for i := 0; i < b.N; i++ { processBigByVal(s) } } func BenchmarkPointer_L2(b *testing.B) { a := [5000]int{} // 40kb, fits L2 cache for i := 0; i < 5000; i++ { a[i] = i } s := &Big{arr: a} // bench for i := 0; i < b.N; i++ { processBigByPointer(s) } } /* data manipulation */ func processBigByVal(item Big) { for i := 0; i < 5000; i++ { item.arr[i] = i somearr := item.arr _ = somearr } } func processBigByPointer(item *Big) { for i := 0; i < 5000; i++ { item.arr[i] = i somearr := item.arr _ = somearr } }
Полученные результаты:
go test -bench=. -benchtime=1s BenchmarkValue_L2-12 502042 2396 ns/op BenchmarkPointer_L2-12 468314 2855 ns/op
Результаты практически идентичны. Как вы могли заметить, на этот раз я использовал -benchtime=1s
вместо 10 с, как раньше, потому что чем дольше время теста, тем больше итераций с вызовом функции processBigByVal(s)
произойдет. Теперь, когда у нас есть относительно большая структура, ее копирование при передаче в функцию начинает затмевать все преимущества доступа к значению из кеша (то, что происходит внутри функции). Копировать указатель проще, чем копировать структуру, поскольку он меньше. Чем больше размер структур, которые мы используем, тем менее эффективным становится кэш для наших значений и тем дороже становится передача таких значений функциям и методам.
Наконец, давайте посмотрим, что происходит, когда структура больше L3.
type VeryBig struct { arr [500000]int }
В этом случае доступ к основной памяти через указатель обходится значительно дешевле (в 98 раз быстрее):
BenchmarkValue_MainMem-12 318 3207093 ns/op BenchmarkPointer_MainMem-12 35938 32765 ns/op
Поэтому, если вы имеете дело с объектами небольшого размера, вы можете рассмотреть возможность удаления указателей. Помимо потенциального увеличения производительности за счет эффективного использования кэша ЦП, вы также снизите нагрузку на сборщик мусора, которому придется очищать меньше объектов в куче.
Полный исходный код тестов:
https://gist.github.com/vadimInshakov/c938a913628e21cb282080f6c190ef06
Спасибо, что дочитали до конца. Пожалуйста, подумайте о том, чтобы подписаться на автора и эту публикацию. Посетите Stackademic, чтобы узнать больше о том, как мы демократизируем бесплатное образование в области программирования во всем мире.