Как 4 байта выравнивания ускоряют очистку массива на 49%
- пятница, 23 октября 2026 г. в 00:00:15

«П‑с‑с‑с, хочешь, чтобы функция очистки массива была на 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. Не воспроизводится.
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 (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).
Уверен, что следующий вопрос мне зададут много раз, так что стоит прояснить его сразу.
Или я слишком глупый, чтобы это понять, или в 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.