Оптимизация структур в Go

Выравнивание полей и эффективное распределение памяти — 08.03.2021

🎯 Зачем это нужно

Производительность растёт, а старые методы оптимизации порой забываются. Один из них — выравнивание полей структур в памяти. Программы на Go быстры и сопоставимы с C++, но потребление памяти во время работы всё ещё можно уменьшить, правильно расположив поля.

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

Представьте полки, размеченные по 8 ячеек. Если класть коробки как попало, одна может лечь на границе двух полок — и чтобы её достать, придётся тянуться к обеим. Ровно уложенные коробки берутся одним движением.

📏 Экспериментируем с размером структуры

Начнём с пустой структуры и посмотрим, сколько занимает она сама и указатель на неё:

type Foo struct {}

func main() {
    x := &Foo{}
    y := Foo{}

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

Пустая структура занимает 0 байт, а указатель — 8: его размер постоянен и равен длине машинного слова (здесь 8 байт).

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

type Foo struct {
    aaa bool
    bbb int32
}

// Sizeof: 8

А теперь поиграем с количеством и порядком полей:

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

// Sizeof: 12

⚖️ «Требуемое выравнивание»

В языке C используется термин «машинное слово» размером 4 или 8 байт в зависимости от разрядности процессора. В Go используется «требуемое выравнивание» — оно равно размеру памяти, необходимой самому большому полю структуры.

Только int32

Если все поля — int32, требуемое выравнивание составит 4 байта.

Есть int64

Если в структуре есть int64, требуемое выравнивание вырастает до 8 байт.

Если поля не «дотягивают» до размера требуемого выравнивания, они сворачиваются за счёт смещений:

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

// Sizeof: 12 — ccc и ddd «свернулись» в одно выравнивание (4 байта)
Стоит заменить int32 на int64 — и требуемое выравнивание становится 8 байт, а размер той же структуры вырастает до 24.

🔧 Оптимизируем перестановкой полей

Главный приём: расположить поля так, чтобы соседние занимали не более одного машинного слова. Вот неоптимальный вариант:

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

// Sizeof: 12

Достаточно поставить два коротких поля рядом — и структура ужимается:

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

// Sizeof: 8
Те же коробки, но переставленные так, что мелкие лежат вплотную друг к другу и вместе занимают ровно одну ячейку полки. Ничего не свисает через край — места уходит меньше.

🔍 Инструменты пакета unsafe

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

1
unsafe.Sizeof — размер
Возвращает, сколько байт занимает значение структуры.
2
unsafe.Offsetof — смещение поля
Возвращает количество байт между началом структуры и началом конкретного поля.
3
unsafe.Alignof — выравнивание
Показывает «требуемое выравнивание», рассчитанное для данной структуры.

Сравним смещения полей у оптимальной и неоптимальной структур:

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
}

// смещения Foo: aaa: 0; bbb: 4; ccc: 8
// смещения Bar: aaa: 0; ccc: 2; bbb: 4

А функция unsafe.Alignof покажет требуемое выравнивание:

fmt.Printf("%+v\n", unsafe.Alignof(Foo{})) // 4
fmt.Printf("%+v\n", unsafe.Alignof(Bar{})) // 4

🤖 Автоматизируем поиск

Расставлять поля вручную утомительно, а порой и нерационально — особенно когда структур в проекте сотни или тысячи. На помощь приходят линтеры.

aligncheck
Находит структуры, которые могли бы занимать меньше. Например, укажет, что Foo размера 12 может быть размером 8.
maligned
Аналогично сообщает: «struct of size 12 could be 8», а с опцией suggest-new предложит более эффективную раскладку.

Оба удобно подключаются к golangci-lint — превосходному средству контроля качества кода. Например, из конфигурационного файла:

linters-settings:
  maligned:
      # выводит более эффективную схему структуры; false по умолчанию
      suggest-new: true
Плюс: проверку можно запускать не только вручную на машине разработчика, но и встроить в CI/CD как автоматический шаг контроля качества.

🗺 Коротко о главном

ПонятиеСуть
Выравнивание Расположение полей по адресам, кратным размеру слова, — для доступа за одну инструкцию
Требуемое выравнивание Равно размеру самого большого поля структуры (4 или 8 байт)
Приём оптимизации Ставить короткие поля рядом, чтобы соседние занимали не более машинного слова
Инструменты unsafe.Sizeof/Offsetof/Alignof, aligncheck, maligned, golangci-lint
Итог: мы разобрались, что такое выравнивание, научились измерять размер до и после оптимизации и нашли инструменты, автоматизирующие поиск структур для улучшения.