golang

Как 4 байта выравнивания ускоряют очистку массива на 49%

  • пятница, 23 октября 2026 г. в 00:00:15
https://habr.com/ru/articles/1052032/

«П‑с‑с‑с, хочешь, чтобы функция очистки массива была на 49% быстрее? Сдвинь массив на 4 байта».

Рассмотрим следующий код на Go:

const words = 1 << 19

type block struct {
	n    uint32
	data [words]uint32
}

func (b *block) reset() {
	b.data = [words]uint32{}
}

Что, если я скажу вам, что всего лишь добавив 4 байта заполнения (padding) между n и data, вы ускорите функцию reset на 49% (по крайней мере, на машинах с Intel)? Звучит безумно?

Давайте проведём бенчмарк. Даже попробуем разные заполнители с шагом в 4 байта:

type (
	blk00 struct {
		_    [0]byte
		data [words]uint32
	}
	blk04 struct {
		_    [4]byte
		data [words]uint32
	}
	// ...
	blk28 struct {
		_    [28]byte
		data [words]uint32
	}
)

func clear00(b *blk00) { b.data = [words]uint32{} }
// ...
func clear28(b *blk28) { b.data = [words]uint32{} }

Что же у нас тут получилось? (Примечание о методологии бенчмарка: перед его выполнением я избавился от шума машины: закрепил выполнение за определённым ядром процессора, отключил Turbo Boost, зафиксировал частоту uncore, отключил SMT на соседнем логическом процессоре и отключил THP. Подробности можно прочитать на Github).

goos: linux
goarch: amd64
pkg: clearalign
cpu: 12th Gen Intel(R) Core(TM) i5-12500
                    │ bench.intel.txt │
                    │       B/s       │
Clear/off=00/mod8=0      28.22Gi ± 0%
Clear/off=04/mod8=4      18.94Gi ± 0%
Clear/off=08/mod8=0      28.22Gi ± 0%
Clear/off=12/mod8=4      18.94Gi ± 0%
Clear/off=16/mod8=0      28.22Gi ± 0%
Clear/off=20/mod8=4      18.94Gi ± 0%
Clear/off=24/mod8=0      28.22Gi ± 0%
Clear/off=28/mod8=4      18.93Gi ± 0%

Видите полосатый паттерн? Каждый заполнитель, кратный 8, обеспечивает гораздо большую пропускную способность.

Увидим ли мы то же поведение на чипах AMD? Да! Но эффект слабее, всего примерно 9% (Причины я не исследовал, возможно, AMD просто лучше).

goos: linux
goarch: amd64
pkg: clearalign
cpu: AMD Ryzen 5 7535HS with Radeon Graphics
                    │ bench.amd.txt │
                    │      B/s      │
Clear/off=00/mod8=0    62.78Gi ± 0%
Clear/off=04/mod8=4    57.67Gi ± 0%
Clear/off=08/mod8=0    62.74Gi ± 0%
Clear/off=12/mod8=4    57.61Gi ± 0%
Clear/off=16/mod8=0    62.74Gi ± 0%
Clear/off=20/mod8=4    57.65Gi ± 0%
Clear/off=24/mod8=0    62.73Gi ± 0%
Clear/off=28/mod8=4    57.57Gi ± 0%

Я даже арендовал инстанс AWS c6g.large, чтобы проверить, воспроизводится ли это на чипах ARM. Не воспроизводится.

Результаты ARM
goos: linux
goarch: arm64
pkg: clearalign
                    │ bench.arm.txt │
                    │      B/s      │
Clear/off=00/mod8=0    34.25Gi ± 0%
Clear/off=04/mod8=4    34.18Gi ± 0%
Clear/off=08/mod8=0    34.15Gi ± 0%
Clear/off=12/mod8=4    34.19Gi ± 0%
Clear/off=16/mod8=0    34.25Gi ± 0%
Clear/off=20/mod8=4    34.17Gi ± 0%
Clear/off=24/mod8=0    34.19Gi ± 0%
Clear/off=28/mod8=4    34.16Gi ± 0%
geomean                34.19Gi

Что происходит, Intel? Заглянем в ассемблерный код. Вот самая важная строка:

REP; STOSQ AX, ES:0(DI)

Весь процесс очистки управляется одной командой: REP (повтор) STOSQ для указанного диапазона адресов. Что особенного в REP STOSQ и почему она неоптимальна без выравнивания по 8 байт? В Intel Optimization Reference говорится следующее:

3.7.6.4 Особенности memset

Если целевой буфер не выровнен, то для процессоров на микроархитектуре Ivy Bridge производительность memset() при использовании Enhanced REP MOVSB и STOSB может деградировать примерно на 20% по сравнению с выровненным случаем.

Отлично! Мы нашли подтверждение тому, что отсутствие выравнивания приводит к деградации производительности. Но что такое «Enhanced REP MOVSB и STOSB» (или ERMSB)? Насколько я понял, ERMSB — это специальная обработка, изначально добавленная Intel для ускорения REP STOSB. (В документации Intel говорится о STOSB, но, как мы увидим, это относится и к STOSQ)

Как же именно Intel улучшает REP STOSB? Ну, в Intel Optimization Reference это чётко не прописано (боже, его так сложно читать). Но, кажется, мне удалось догадаться. Эту тему я поднял, когда начал исследовать регрессию в моей библиотеке brotli. В то время я исследовал счётчики perf и заметил, что невыровненная по 8 байтам очистка показывала увеличение числа запросов Read For Ownership (RFO) к L2 на 139% и на 65% тактов по сравнению с выровненной. Поэтому я предполагаю, что улучшение в основном состоит из записи полных линий кэша без их первоначального чтения. Мне кажется, это объясняет, почему вредит отсутствие 8-байтного выравнивания: путь без RFO работает с целыми линиями кэша, но при 4-байтном смещении одна из 8-байтных операций сохранения пересекает каждую 64-байтную границу, поэтому ни одна линия не охватывается целиком, а исполнение не идёт по быстрому пути.

И ещё один аспект, касающийся выравнивания: заполнение управляет смещением внутри struct, но что насчёт самой struct? Что, если она оказывается по адресу, не выровненному по 8 байтам? Оказывается, что такого быть не может: распределитель Go всегда обеспечивает выравнивание по страницам. Я проверил это при помощи целевого теста.

STOSB и STOSQ

Команда STOSB (B=Byte, очистка по одному байту за раз) упоминается в Optimization Reference как Enhanced, но компилятор Go выбирает STOSQ (Q=Quadword, очистка по 8 за раз). Очевидно, когда в Optimization Reference говорится о STOSB, то это относится ко всему семейству, включая STOSQ. Как бы то ни было, стоит провести бенчмарк того, даёт ли STOSB лучшую скорость при записи по выровненному адресу:

stosq    28,21 ГиБ/с ± 0%
stosb    28,29 ГиБ/с ± 0%   +0,28%

Разницы в скорости нет, поэтому предположу, что Intel «улучшает» оба варианта. Версия со stosb лишь ненамного быстрее, и это, скорее всего, просто шум. Но это не важно, поскольку мы всё равно не хотим использовать stosb в коде на Go: компилятор Go выбирает stosq, имея на то вескую причину (https://go.dev/src/runtime/memclr_amd64.s):

// STOSQ используется, чтобы гарантировать, что подсистема памяти увидит обнулённое слово размером с указатель целиком, как того требует сборщик мусора (GC).

Уверен, что следующий вопрос мне зададут много раз, так что стоит прояснить его сразу.

Но что насчёт SIMD?

Или я слишком глупый, чтобы это понять, или в Intel Optimization Reference говорится, что ERMSB настолько улучшена, что обгоняет SIMD, но это зависит от размера области назначения.

В Go 1.26 появилась экспериментальная поддержка SIMD‑интринсиков, поэтому попробуем их в нашем микробенчмарке на массиве размером 2 МиБ. Я также написал вручную на ассемблере версии с SIMD, чтобы узнать, какую пропускную способность они обеспечивают, например, при устранении проверок границ, которые присутствуют в экспериментальной реализации интринсиков.

stosq           28.21 ГиБ/с

// интринсики
simd128_sse     26.24 ГиБ/с    -6.97%
simd256_avx2    28.17 ГиБ/с    -0.14%

// вручную написанный код (с кэшированием)
avx2_hand       28.40 ГиБ/с    +0.67%
avx2_hand_pft0  20.30 ГиБ/с   -28.02%
avx2_hand_pfw   27.69 ГиБ/с    -1.82%

// вручную написанный код (с потоковой записью)
// ⚠ Результаты невоспроизводимы, см. ниже
nt128_sse       31.77 ГиБ/с   +12.64%
nt256_avx2      32.07 ГиБ/с   +13.68%

Что здесь подразумевается под «кэшированием» и «потоковой записью»? В варианте «с кэшированием» используется SIMD‑команда VMOVDQU, сначала выполняющая запись в кэш CPU. В варианте «с потоковой записью» используются NT‑команды (non‑temporal, то есть не задействующие временное кэширование) MOVNTDQ/VMOVNTDQ, которые выполняют запись непосредственно в память.

Версия на intrinsics simd256_avx2 (кстати, в ней используется вариант VMOVDQU с кэшированием) показала ту же скорость, что и стандартная версия с stosq. Интересно, что стандартная stosq работает быстрее 128-битного варианта SIMD. Версия, написанная вручную, показала те же результаты. У меня появилось смелое предположение о том, что производительность всей операции ограничена скоростью доступа к памяти, поэтому может помочь предварительная выборка данных (prefetch). Но это оказалось не так — см. варианты _pft0 и _pfw.

Наилучшие результаты продемонстрировали SIMD‑варианты с потоковой записью. НО только на этом прогоне.

// прогон 2
nt256_avx2   19.60 ГиБ/с

// прогон 3
nt256_avx2   35.76 ГиБ/с

В следующих прогонах возникали сильные колебания. Это происходило только в случае nt‑вариантов с потоковой записью.

Не буду утомлять вас всеми промежуточными этапами, которые потребовались мне, чтобы разобраться в происходящем. Оказалось, что первопричина в том, передаёт ли нам ядро сплошной или разрозненный блок памяти. Чтобы убедиться в этом, я создал целевой тест. Тест выполняет бенчмарки в двух режимах: a) разрозненные страницы по 4 КиБ и b) одна сплошная огромная страница на 2 МиБ. Так как NT‑вариант выполняет запись напрямую в DRAM, большая сплошная страница на 2 МиБ стабильно оказывалась быстрее между прогонами, а режим разрозненных страниц давал случайные результаты.

То есть SIMD‑вариант не обогнал стандартную STOSQ. NT‑вариант SIMD побеждает стандартную STOSQ, но нестабильно: по сути, результат случаен и зависит от конкретного ядра. Как бы то ни было, SIMD может быть плохим решением вашей задачи, потому что...

Вам может и требоваться очистка массива

Поскольку у нас есть метод reset, то данные в нашем массиве по определению временные. Есть популярная техника, позволяющая избежать затратной очистки массива при работе со временными данными: добавление к элементам данных меток поколения. В этом искусственном примере массив очищается раз в 255 сбросов. Да, 255, а не 256: значение 0 использовать нельзя, поскольку его невозможно отличить от незаполненного элемента.

const maxGen = 255

type dataEntry struct {
	gen uint8 // метка поколения
	// ...
}

type block struct {
	// элементы, помеченные чем-то остальным, считаются устаревшими
	currentGen uint8
	data       [words]dataEntry
	// ...
}

func (b *block) reset() {
	if b.currentGen >= maxGen {
		// количество поколений закончилось, необходимо полностью очистить массив
		b.data = [words]dataEntry{}
		b.currentGen = 0
	}
	b.currentGen++
}

func (b *block) writeDataEntry(d *dataEntry) {
	d.gen = b.currentGen
	// ... полезная работа
}

func (b *block) processDataEntry(d *dataEntry) {
	// устарело? считаем пустым
	if d.gen != b.currentGen {
		return
	}
	// ... полезная работа
}

Почему же нам вообще может быть важен полосатый паттерн STOSQ? Если мы очищаем массив через каждые 255 раз, влияние оказывается ничтожным.

К сожалению, мы не всегда можем позволить себе использовать для поколений метку шириной в полный uint8. Здесь есть принципиальный компромисс. Добавление метки поколения или увеличение её размера неизбежно увеличивает размер массива данных. Если массив данных до этого целиком помещался в кэш L2, то после увеличения он может перестать в него помещаться, что приведёт к катастрофическому падению производительности.

В моём последнем проекте я мог позволить себе только 4 бита метки поколения, из‑за чего полная очистка выполнялась каждые 15 сбросов, а полосатый паттерн был вполне существенным.

Дополнение

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

Если вы всегда добавляете данные, индекса и в самом деле достаточно: нужно просто выполнять b.pos = 0 в reset, очищать ничего не нужно, а нам вообще не требуется метод reset. Метки поколения и очистка массива полезны только тогда, когда запись возможна в любое место массива.

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


Зависимое от выравнивания поведение при очистке массива стало для меня интересным открытием, а его исследование научило меня кое‑чему новому. Думаю, большинство Go‑разработчиков не слишком задумываются о выравнивании полей структур. Но я занимаюсь оптимизацией всего пару месяцев, и подобные случаи заставляют меня задуматься: нужно ли заранее знать обо всех этих подводных камнях, чтобы хорошо оптимизировать код? Что, если бы я так и не заметил этого поведения? Я бы упустил несколько процентов пропускной способности. Мы не добавляем просто так искусственные 4-байтовые поля в struct. На самом деле я обнаружил это, только когда добавлял новый механизм поиска совпадений в свою библиотеку brotli для Go, стараясь, чтобы линтер fieldalignment не выдавал предупреждений. Я переставил поля структуры местами и заметил, что производительность ухудшилась. Даже тогда я какое‑то время игнорировал эту проблему и отмахивался от неё, потому что думал, что это одна из тех странных проблем с выравниванием данных при генерации кода. Рад, что в итоге всё‑таки решил докопаться до первопричины. Теперь я знаю больше, чем раньше.

P. S. Весь код, относящийся к этой статье, можно найти в GitHub.