Оптимизация структур в Golang для эффективного распределения памяти

Поговорим сегодня о выравнивании структур в Golang.

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

Хорошо, когда программа написана просто и логично и обладает хорошей гибкостью. Программы на Go быстры: в этом они сопоставимы с программами на C++.

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

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

Поэкспериментируем с размером структуры

Сначала создадим структуру без полей type Foo struct {} и посмотрим, сколько памяти занимает она и указатель на нее:

package main

import (
	"fmt"
	"unsafe"
)

type Foo struct {

}

func main() {
	x := &Foo{}
	y := Foo{}
  
	fmt.Println(unsafe.Sizeof(x)) // 8
	fmt.Println(unsafe.Sizeof(y)) // 0
}

Запустив программу в консоли, мы получим:

0

8

Теперь добавим пару полей:

type Foo struct {
	aaa bool
	bbb int32
}

fmt.Println(unsafe.Sizeof(x)) // 8
fmt.Println(unsafe.Sizeof(y)) // 8

Результат будет 8 и 8.

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

Теперь поиграем с количеством и последовательностью полей:

type Foo struct {
	aaa bool // 1 по умолчанию. Но с заполнением - 4.
	bbb int32 // 4 как максимальное в этой структуре.
	ссс bool // 1 по умолчанию. Но с заполнением - 4.
}

Результат будет 12.

В языке Си используется термин «машинное слово», размер которого соответствует 4 или 8 байтам (в зависимости от разрядности процессора компьютера  —  32 или 64).

А в Go используется «требуемое выравнивание». Его значение равно размеру памяти, требующемуся самому большому полю в структуре.

То есть, если в структуре есть только поля int32, то «требуемое выравнивание» составит 4 байта. А если есть и int32, и int64  —  то 8 байтов.

Поэтому пример можно записать в таком виде:

type Foo2 struct {
	aaa int32 // 4
	bbb int32 // 4
	ccc int32 // 4
}

Добавим в конец структуры еще одно поле  —  ddd bool.

И если не достичь размера «требуемого выравнивания» следующих друг за другом полей, эти наши тополя «свернутся» из-за смещений.

type Foo struct {
	aaa bool // 1
	bbb int32 // 4 (макс.)
	ссс bool // 1 
	ddd bool // 1
}

Здесь результат такой же  —  12. Потому что сработала оптимизация (из-за наличия смещений).

Поля ccc bool и ddd bool этой структуры «свернулись» до единичного «требуемого выравнивания», т. е. 4 байтов (из-за наличия int32).

type Foo struct {
	aaa bool
	bbb int64
	ссс bool
	ddd bool
}

Ответ: 24. Почему? Потому что теперь «требуемое выравнивание» для этой структуры составляет 8 байтов.

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

А теперь попробуйте догадаться, почему эта структура неоптимальна с точки зрения выравнивания?

type Foo struct {
    aaa [2]bool // 2 байта
    bbb int32   // 4 байта
    ccc [2]bool // 2 байта
}

Конструкция .Sizeof(Foo{}) из unsafe дала результат: 12.

Вот как она оптимизируется:

type Foo struct {
    aaa [2]bool // 2 байта
    ccc [2]bool // 2 байта
    bbb int32   // 4 байта
}

В этом случае unsafe.Sizeof(Foo {}) дала уже такой результат: 8.

Кстати, в пакете unsafe есть, кроме unsafe.Sizeof, еще несколько подобных функций.

Одна из них  —  unsafe.Offsetof  —  возвращает количество байтов между началом структуры и началом поля.

Объясним это в коде (https://play.golang.org/p/LG7azYGHX8M):

package main

import (
	"fmt"
	"unsafe"
)

type Foo struct {
	aaa [2]bool // смещение байтов: 0
	bbb int32   // смещение байтов: 4
	ccc [2]bool // смещение байтов: 8
}

type Bar struct {
	aaa [2]bool // смещение байтов: 0
	ccc [2]bool // смещение байтов: 2
	bbb int32   // смещение байтов: 4
}

func main() {
	ff := Foo{}
	bb := Bar{}
	fmt.Printf("offsets of fields: aaa: %+v; bbb: %+v; ccc: %+v\n", unsafe.Offsetof(ff.aaa), unsafe.Offsetof(ff.bbb), unsafe.Offsetof(ff.ccc))
	fmt.Printf("offsets of fields: aaa: %+v; ccc: %+v; bbb: %+v\n", unsafe.Offsetof(bb.aaa), unsafe.Offsetof(bb.ccc), unsafe.Offsetof(bb.bbb))
}

// с помощью go run запускается main.go
//
// смещения полей: aaa: 0; bbb: 4; ccc: 8
// смещения полей: aaa: 0; ccc: 2; bbb: 4

Весьма полезный инструмент получается из функции unsafe.Alignof, которая показывает «требуемое выравнивание», рассчитываемое для данной структуры.

В качестве примера рассчитаем «требуемое выравнивание» для структур из приведенного выше кода:

type Foo struct {
        aaa [2]bool
        bbb int32
        ccc [2]bool
    }
    
    type Bar struct {
        aaa [2]bool
        ccc [2]bool
        bbb int64
    }

    fmt.Printf("required alignment: %+v\n", unsafe.Alignof(Foo{})) // требуемое выравнивание: 4
    fmt.Printf("required alignment: %+v\n", unsafe.Alignof(Bar{})) // требуемое выравнивание: 8

Другие инструменты оптимизации

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

На самом деле существует несколько инструментов, которые помогают находить места (структуры) для оптимизации:

Используем aligncheck для наших структур Foo и Bar:

// $ aligncheck .

github.com/romanitalian/alignment: /Users/rmn/wks/src/github.com/romanitalian/alignment/main.go:8:6: struct Foo could have size 8 (currently 12)

И действительно, оптимизируем структуру Foo, и тогда она будет точно такой же, как Bar.

Теперь используем maligned для структур Foo и Bar:

// $ maligned .

/Users/rmn/wks/src/github.com/romanitalian/alignment/main.go:8:10: struct of size 12 could be 8

И снова отличный результат!

Такие инструменты очень удобно использовать в golangci-lint, превосходном средстве контроля качества кода для Go.

Для этого нужно просто подключить maligned в настройках golangci-lint, например из конфигурационного файла . golangci.example.yml:

linters-settings:
  maligned:
      # выводит структуру с более эффективной схемой распределения памяти или нет, false по умолчанию
      suggest-new: true

Подробнее здесь: https://golangci-lint.run/usage/configuration/.

Это средство контроля качества кода позволяет запускать проверку на наличие возможности для оптимизации не только в ручном режиме локально на компьютере разработчика, но и как встроенный инструмент в CI/CD.

Итак, мы выяснили, что такое выравнивание, и разобрались со спецификой его работы. Кое-что узнали о том, как оптимизировать потребление памяти, выделяемой для структур, и научились измерять разницу до и после оптимизации. А потом нашли инструменты, позволяющие автоматизировать поиск структур для оптимизации (aligncheck, maligned и golangci-lint).

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Roman Romadin: Structure size optimization in Golang (alignment/padding). More effective memory layout (linters).

Предыдущая статьяПаттерн проектирования «Наблюдатель»: объект под прицелом
Следующая статьяПять шаблонов проектирования, которые необходимо знать каждому разработчику