SIMD и векторные расширения
Предпосылки: бит, байт, числа с плавающей точкой (float — 32 бита, double — 64 бита), суперскалярное исполнение (несколько разных инструкций за такт), ABI и размещение данных (xmm-регистры, выравнивание), кеш-линия (64 байта — единица передачи между кешем и памятью).
← ABI и размещение данных | Атомарные инструкции →
Цикл умножает два массива по миллиону double:
for (int i = 0; i < N; i++)
c[i] = a[i] * b[i];Процессор обрабатывает один double за инструкцию mulsd, даже если все элементы считаются одинаково. Соблазнительный вывод: «включу компиляцию с -mavx2 -O3, и компилятор сам обработает 4 double за инструкцию — будет в четыре раза быстрее». На практике этот вывод срывается двумя способами. Во-первых, в определённых формах кода компилятор либо вообще не векторизует цикл, либо векторизует, но добавляет такую обвязку, что ускорение пропадает. Во-вторых, даже чистая векторизация на реальных данных почти никогда не даёт обещанных 4×. SIMD — это не «шире регистр, быстрее всё», а параллелизм со своими условиями применимости: компилятор должен согласиться, данные должны лежать правильно, алгоритм должен подходить.
Что значит «много данных за инструкцию»
Суперскалярный процессор уже извлекает параллелизм из потока инструкций: за один такт он запускает load, mul и store на разных исполнительных блоках. Это параллелизм инструкций — разные операции над разными данными. Потоки дают другой вид: одинаковый код выполняется на разных ядрах над разными данными. SIMD — третий вид, ортогональный обоим.
SIMD (Single Instruction, Multiple Data — одна инструкция, много данных) расширяет сам путь данных. Вместо 64-битного регистра, где лежит один double, процессор получает 256-битный регистр, где лежат четыре double подряд. Одна инструкция vmulpd прочитывает из двух таких регистров по четыре пары и записывает четыре произведения в третий. Суперскаляр запускает несколько инструкций за такт; SIMD одну инструкцию расширяет на несколько элементов. Одно другому не противоречит: современное ядро делает оба вида параллелизма одновременно.
Для цикла выше это означает: вместо N скалярных итераций — N / 4 широких. Идеальный потолок по ширине вектора — 4× с AVX (256 бит / 64 бита) или 8× с AVX-512 (512 бит). Потолок — не обещание; между ним и реальным выигрышем стоят три группы условий: компилятор должен согласиться векторизовать, данные должны лежать правильно, алгоритм должен подходить.
Почему компилятор осторожничает
Вынесем цикл в функцию:
void mul_arrays(double *a, double *b, double *c, int n) {
for (int i = 0; i < n; i++)
c[i] = a[i] * b[i];
}Компилируем с gcc -O3 -mavx2 -fopt-info-vec. Ожидание: компилятор увидит независимые итерации и сгенерирует vmulpd. Фактический вывод:
mul_arrays: loop vectorized
mul_arrays: versioned for vectorization because of possible aliasing
Компилятор векторизовал — но создал две версии цикла: скалярную и векторную. В машинном коде функция сначала проверяет, не перекрываются ли указатели a, b, c. Если проверка проходит, исполняется векторная ветка; если обнаружено перекрытие — скалярная. Откуда берётся это «возможное перекрытие» и почему компилятор не может его исключить сам?
Сигнатура double *a, double *b, double *c не запрещает вызывающему коду передать один и тот же указатель дважды или наложенные области. Если c == a + 1, запись c[i] меняет a[i+1], и следующая итерация прочитает уже другое значение. Порядок внутри вектора, где четыре элемента читаются и пишутся одновременно, даст другой результат, чем последовательный цикл. Компилятор этого знать не может — он видит только тело функции. Консервативное решение: runtime-проверка плюс скалярный fallback. Цена — раздувание кода, лишние такты на проверках при каждом вызове, а для вызывающего с реально перекрывающейся памятью — отсутствие ускорения вообще.
Обещание программиста снимает неопределённость:
void mul_arrays(double * restrict a, double * restrict b,
double * restrict c, int n) {
for (int i = 0; i < n; i++)
c[i] = a[i] * b[i];
}Квалификатор restrict (в C99) означает: «через этот указатель никто не обратится к тем же данным, что через другие». Компилятор верит на слово и генерирует один векторизованный цикл без проверки. Если программист солгал — поведение undefined, но это его проблема, не компилятора.
Pointer aliasing — только одна из причин отказа. Компилятор не векторизует цикл, если:
- Итерации зависимы:
a[i] = a[i-1] + 1— каждый шаг читает результат предыдущего. - Внутри цикла вызов функции, которую компилятор не может встроить — он не знает побочных эффектов.
- Условная логика с
breakилиreturnиз середины — векторизация ведёт все элементы синхронно. - Память невыровнена так, что компилятор не может это доказать, — тогда он вставляет пролог «дотащить до выравнивания скалярно, потом векторно».
Флаги -fopt-info-vec и -fopt-info-vec-missed печатают, что удалось векторизовать и где компилятор отступил. Это первая диагностика, когда ожидаемое ускорение не приходит.
Поколения векторных расширений
Идея «расширим путь данных» реализовалась постепенно. Каждое поколение добавляло ширину или типы данных. Ключевые точки:
| ISA | Платформа | Ширина | Типы | Появление |
|---|---|---|---|---|
| SSE2 | x86 | 128 бит | float, double, целые | 2001 (Pentium 4) |
| SSSE3, SSE4.1 | x86 | 128 бит | + горизонтальные операции, dot product | 2006–2008 |
| AVX | x86 | 256 бит | float, double | 2011 (Sandy Bridge) |
| AVX2 | x86 | 256 бит | + целые, gather | 2013 (Haswell) |
| AVX-512 | x86 | 512 бит | + mask-регистры | 2017 (Skylake-SP) |
| NEON | ARMv8 | 128 бит | все типы | обязательный с AArch64 |
| SVE | ARMv8 | 128–2048 бит | переменная ширина | 2016 (серверный ARM) |
XMM-регистры SSE (16 штук, 128 бит) — те самые, через которые ABI передаёт double-аргументы. AVX удлиняет их до YMM (256 бит): младшие 128 бит остаются xmm, старшие добавляются сверху. AVX-512 продолжает до ZMM (512 бит) и увеличивает регистровый файл до 32. Mask-регистры AVX-512 (k0–k7) позволяют маркировать, какие элементы вектора участвуют в операции, — без ветвлений. На них построена обработка «хвостов» массива, когда длина не кратна ширине вектора.
NEON у ARM концептуально похож на SSE2 (128 бит, обязателен в AArch64). SVE (Scalable Vector Extension) решает проблему иначе: ширина не зашита в ISA. Одна и та же программа работает на реализациях с 128, 256, 512 бит — процессор определяет ширину на исполнении. AWS Graviton3 использует 256-битный SVE; Apple M1/M2 имеют только NEON.
AVX-512 и фрагментация клиентских CPU
AVX-512 появился в серверных Xeon (Skylake-SP, 2017), затем в клиентских (Ice Lake, 2019). Гибридные процессоры Alder Lake (2021) принесли проблему: P-ядра имели 512-битные ALU, E-ядра — нет. В ранних прошивках AVX-512 можно было включить, отключив E-ядра через BIOS. Intel счёл это несовместимым с архитектурой и прошёл две стадии отключения: сначала microcode update (2022) запретил AVX-512 на уровне инструкций, потом новые ревизии Alder Lake выключили его физически — через пережигаемые фузы в кристалле. Raptor Lake (2022, 13-е поколение) и далее — AVX-512 отсутствует в P-ядрах клиентских CPU по дизайну. В серверных Xeon (Sapphire Rapids, Granite Rapids) AVX-512 сохранён.
На замену приходит AVX10 (анонс 2023): унифицированная ISA, способная жить на гибридных ядрах. Первая реализация, AVX10.1 в Granite Rapids (2024), поддерживает две максимальные ширины вектора — 256 и 512 бит — как отдельные уровни. В марте 2025 Intel выпустила AVX10 rev. 3.0, которая убрала 256-битный уровень как самостоятельную цель компиляции для будущих платформ, оставив 512-битный профиль как основную. Как это дойдёт до клиентских E-ядер, зависит от микроархитектуры следующего поколения (Nova Lake и далее) и пока не зафиксировано в общедоступных спецификациях. Для сегодняшнего кода это означает: писать под AVX-512 на клиентский CPU бессмысленно, целевая аудитория — серверные Xeon и AMD EPYC; на клиентах потолок — AVX2 с 256-битными YMM.
x86-специфичные подводные камни: throttling и gather
Frequency throttling. Активация тяжёлых 512-битных инструкций заставляет процессор снижать частоту: широкие ALU потребляют больше энергии, и тепловые ограничения не позволяют держать номинальные гигагерцы. Если векторизованная секция коротка, потеря частоты может съесть выигрыш от широкого вектора. На Ice Lake и новее throttling ослаблен, но не исчез.
Gather/scatter. Инструкции
vgatherdpd/vscatterdpdзагружают элементы по массиву индексов. На Skylake gather из 4 элементов внутри процессора разворачивается в серию отдельных обращений к памяти — по пропускной способности сопоставим с 4 скалярными load. Ощутимый выигрыш появляется только на AVX-512 с 8+ элементами за инструкцию.AVX ↔ SSE transition penalty. На архитектурах до Skylake переключение между 256-битными AVX и 128-битными SSE-инструкциями без
vzeroupperстоило ~70 тактов. Skylake штраф убрал, но компиляторы вставляютvzeroupperавтоматически при-mavx— эта мелочь упоминается только потому, что старые туториалы ей пугают.
Требования к данным: непрерывность и выравнивание
Одна SIMD-инструкция читает или пишет вектор как монолит: для AVX — 32 байта за раз. Чтобы это работало, элементы должны лежать подряд, и адрес начала должен быть выровнен по ширине вектора.
Непрерывность — про layout в памяти. Массив double arr[] ложится в память плотно: arr[0], arr[1], arr[2], arr[3] занимают 32 байта подряд, и одна vmovapd приносит их в YMM. Массив структур ломает эту плотность. Точка в 3D как struct { double x, y, z; } занимает 24 байта; координаты x соседних точек разделены 16 байтами чужих данных. Одна 32-байтовая загрузка захватит x, y, z первой точки плюс x второй — бесполезная мешанина.
Решение — SoA (Structure of Arrays): три отдельных массива double *x, *y, *z вместо одного массива структур. Координаты одного поля лежат плотно, и SIMD забирает сразу четыре. Переход от AoS к SoA — самая частая трансформация данных ради SIMD.
AoS: [x0 y0 z0 | x1 y1 z1 | x2 y2 z2 | x3 y3 z3]
^-- vmovapd возьмёт x0,y0,z0,x1 -- мусор
SoA: [x0 x1 x2 x3 | ... ] [y0 y1 y2 y3 | ... ] [z0 z1 z2 z3 | ... ]
^-- vmovapd возьмёт x0,x1,x2,x3 -- чистый вектор
Выравнивание — про адрес начала. vmovapd (aligned load) требует адрес, кратный 32 байтам. vmovupd (unaligned) работает по любому адресу и на Haswell и новее почти не штрафуется — пока загрузка не пересекает границу кеш-линии (64 байта). Пересечение превращает одну загрузку в два обращения к L1. Обычный malloc гарантирует 16-байтовое выравнивание; для 32-байтового (AVX) используется aligned_alloc(32, size) из C11 или _mm_malloc(size, 32) из Intel-заголовков.
Ручное управление: intrinsics
Если автовекторизация отказалась и переписать цикл невозможно, остаётся выписать SIMD вручную. Intrinsics — встроенные функции компилятора, каждая из которых отображается ровно на одну машинную инструкцию. Цикл умножения на AVX:
#include <immintrin.h>
void mul_arrays_avx(double * restrict a, double * restrict b,
double * restrict c, int n) {
for (int i = 0; i < n; i += 4) {
__m256d va = _mm256_load_pd(&a[i]); // vmovapd: 4 double из a
__m256d vb = _mm256_load_pd(&b[i]); // vmovapd: 4 double из b
__m256d vc = _mm256_mul_pd(va, vb); // vmulpd: 4 умножения
_mm256_store_pd(&c[i], vc); // vmovapd: 4 double в c
}
}Тип __m256d — 256-битный вектор из 4 double. Каждый intrinsic — одна инструкция: _mm256_load_pd → vmovapd, _mm256_mul_pd → vmulpd. Код становится явным, предсказуемым и привязанным к конкретной ширине. Для SSE2 нужен отдельный вариант с __m128d, для AVX-512 — с __m512d. Библиотеки-обёртки (Google Highway, std::experimental::simd) абстрагируют ширину за счёт слоя шаблонов.
FMA (Fused Multiply-Add) — отдельное расширение, доступное с Haswell (2013) и в NEON. Инструкция vfmadd213pd совмещает a * b + c в одну операцию. Для y[i] = a * x[i] + y[i] и матричного умножения FMA удваивает арифметическую пропускную способность.
Реальный потолок: почему не 4×, а 2–3×
Идеальный потолок AVX — 4× по ширине вектора. Реальный выигрыш на типичном коде — 2–3×. Разрыв создают три фактора.
Порты load/store. Skylake имеет 2 порта загрузки и 1 порт записи за такт. Цикл умножения на каждую vmulpd делает 2 загрузки и 1 запись — узким местом становится не умножение, а пропускная способность портов памяти, и ширина вектора её не меняет.
Пропускная способность памяти. Массив в L1d (32 КБ) обслуживается быстро; массив, не помещающийся в кеш, упирается в DDR5-5600 (~90 ГБ/с на двухканальной системе). Для цикла с 3 обращениями по 8 байт на элемент bandwidth-потолок = данные / 90 ГБ/с; ширина SIMD-регистра не влияет. Это частный случай общего наблюдения из иерархии памяти: вычислительная пропускная способность упирается в память задолго до теоретического потолка ALU.
Пролог, эпилог, ветвления версий. Компилятор вставляет скалярный пролог «добежать до выравнивания», основной векторный цикл и скалярный эпилог для «хвоста», не кратного 4. На коротких массивах (N < 32) эти обвязки съедают выигрыш.
Плюс один тактический эффект: throttling на AVX-512 снижает частоту на 5–15%. Широкий вектор сработал, но остальной код работает медленнее — суммарно может проигрывать AVX2.
Когда SIMD не помогает
SIMD ускоряет чистые циклы с независимыми итерациями над плотными данными. Вне этих условий выигрыш минимален или отсутствует.
Зависимые итерации. sum += a[i] — классическая редукция с loop-carried dependency: каждый шаг ждёт результата предыдущего. Прямая векторизация невозможна. Алгоритмическая перестройка (четыре независимых аккумулятора, объединяемых в конце) разбивает цепочку, и ускорение приближается к 4×.
Ветвления. if (a[i] > 0) c[i] = a[i] * b[i]; else c[i] = 0; SIMD обрабатывает обе ветки и маскирует результат — исполняются все, используется нужное. На AVX-512 с mask-регистрами это естественно; на AVX2 требует обходных манипуляций с масками. Производительность ветвистого SIMD-кода — 30–50% от цикла без ветвлений.
Случайный доступ. c[index[i]] = a[i] — gather/scatter. Адреса зависят от данных, одной инструкцией их не забрать. Даже на AVX-512 gather из 8 элементов часто сравним по времени с 8 скалярными загрузками.
Короткие массивы. Цикл на 10 элементов: накладные расходы на загрузку векторов, возможный пролог и эпилог, отсутствие достаточного объёма работы — скалярный код быстрее.
I/O-bound код. Если программа ждёт диск или сеть, узкое место не в CPU. Арифметика может быть бесконечно быстрой — общая скорость определяется I/O.
Портируемость. Код с AVX intrinsics не запустится на процессоре без AVX. Runtime dispatch (проверка через cpuid и переключение на подходящую ветку) решает проблему ценой сложности; библиотеки Highway и Abseil инкапсулируют эту механику.
Итог: SIMD даёт заявленные 2–4× не «автоматически при -mavx», а при совпадении условий — компилятор векторизовал (или программист выписал intrinsics), данные лежат плотно и выровнены, алгоритм допускает независимость итераций, массив достаточно длинный, и программа упирается в вычисления, а не в память или I/O. Если хотя бы одно условие не выполнено, результат ближе к 1×, чем к ширине вектора.
Sources
- Agner Fog, 2024, Instruction Tables — latency/throughput для SSE, AVX, AVX-512: https://www.agner.org/optimize/instruction_tables.pdf
- Agner Fog, 2024, The microarchitecture of Intel, AMD, and VIA CPUs — pipeline, SIMD execution units: https://www.agner.org/optimize/microarchitecture.pdf
- Intel, 2024, Intel 64 and IA-32 Architectures Optimization Reference Manual — AVX-512 throttling, alignment: https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
- Intel, 2024, Intel Intrinsics Guide — API intrinsics: https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html
- Intel, 2025, Intel AVX10 Architecture Specification — унифицированная ISA для P- и E-ядер: https://www.intel.com/content/www/us/en/developer/articles/technical/intel-avx10.html
- ARM, 2023, ARM Architecture Reference Manual for A-profile — NEON, SVE: https://developer.arm.com/documentation/ddi0487/latest
- GCC, 2026, Optimize Options — автовекторизация,
-ftree-vectorize,-fopt-info-vec-missed: https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html - LLVM, 2026, Auto-Vectorization in LLVM — Loop Vectorizer, SLP Vectorizer: https://llvm.org/docs/Vectorizers.html