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ПлатформаШиринаТипыПоявление
SSE2x86128 битfloat, double, целые2001 (Pentium 4)
SSSE3, SSE4.1x86128 бит+ горизонтальные операции, dot product2006–2008
AVXx86256 битfloat, double2011 (Sandy Bridge)
AVX2x86256 бит+ целые, gather2013 (Haswell)
AVX-512x86512 бит+ mask-регистры2017 (Skylake-SP)
NEONARMv8128 битвсе типыобязательный с AArch64
SVEARMv8128–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.

Требования к данным: непрерывность и выравнивание

Одна 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_pdvmovapd, _mm256_mul_pdvmulpd. Код становится явным, предсказуемым и привязанным к конкретной ширине. Для 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


ABI и размещение данных | Атомарные инструкции