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

Отказ от ответственности: остерегайтесь преждевременной оптимизации; эта статья дает вам более полное представление об инструменте, с которым вы работаете, а не дает серебряную пулю.

Часто мы используем указатели при работе со структурами. Например, вот фрагмент кода, который вам вполне знаком:

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, чтобы узнать больше о том, как мы демократизируем бесплатное образование в области программирования во всем мире.