Векторизация и SIMD в C++: где компилятор пасует и что дают интринсики
Скалярный цикл суммирования float обрабатывает по одному числу за такт, а SSE берёт четыре, AVX2 — восемь. Разбираем, почему автовекторизация ломается на ровном месте и когда стоит спускаться до интринсиков.

Функция сложения двух массивов из 100 миллионов float на моём Ryzen 7 5800X (компилятор GCC 13, флаг -O2) выполнялась 78 мс. После включения -O3 -march=native — 21 мс. После ручной перезаписи на AVX2-интринсики — 19 мс. Разница между вторым и третьим вариантом почти нулевая, и это главный вывод статьи: в простых случаях автовекторизация уже работает, а интринсики нужны там, где компилятор её тихо отключает и ничего вам об этом не говорит.
Разбираем один угол: почему автовекторизация ломается, как это заметить и в каких случаях спуск до SIMD-интринсиков оправдан. Инструкции по расстановке #pragma omp simd и организации сборки под разные процессоры — отдельная тема, здесь только механизм и цена.
Что такое SIMD и почему это не бесплатно
SIMD — Single Instruction, Multiple Data. Один регистр процессора хранит не одно число, а пачку: SSE-регистр (128 бит) — четыре float или два double, AVX/AVX2 (256 бит) — восемь float, AVX-512 (512 бит) — шестнадцать. Инструкция vaddps складывает восемь пар float за один такт вместо восьми скалярных сложений.
Теоретический потолок для float на AVX2 — ускорение в 8 раз. На практике вы почти никогда его не увидите, потому что упираетесь не в арифметику, а в память. Тот самый пример со сложением массивов memory-bound: процессор ждёт данные из RAM быстрее, чем успевает их сложить. Поэтому переход от -O2 к -O3 дал 3,7×, а не 8× — дальше упёрлись в пропускную способность памяти, и никакой SIMD это не лечит.
SIMD ускоряет вычисления. Если ваш код ждёт память, а не считает — вы оптимизируете не то место. Сначала профилировщик, потом интринсики.
Как проверить, векторизовал ли компилятор ваш цикл
GCC и Clang умеют докладывать о решениях по векторизации. Не гадайте — спросите:
# GCC 13: что удалось и что не удалось векторизовать
g++ -O3 -march=native -fopt-info-vec-all -c mycode.cpp
# Clang 17: аналогичный отчёт
clang++ -O3 -march=native -Rpass=loop-vectorize \
-Rpass-missed=loop-vectorize -c mycode.cpp
В выводе GCC ищите строки вида loop vectorized — цикл прошёл. Строка not vectorized: ... с причиной — вот где компилятор сдался. Причина обычно и есть ваша задача.
Почему автовекторизация ломается
Компилятор консервативен: он векторизует цикл, только если может доказать, что это не изменит результат. Три частые причины отказа.
1. Алиасинг указателей
Функция void add(float* out, const float* a, const float* b, int n) с точки зрения компилятора опасна: out может указывать на середину a. Тогда запись в out[i] испортит a[i+1], и векторная обработка пачками даст неверный результат. Компилятор не векторизует такой цикл или вставляет проверку перекрытия в рантайме.
Решение — ключевое слово __restrict (в GCC/Clang; в стандарте C++ его нет, это расширение). Вы обещаете, что указатели не пересекаются:
// GCC 13 / Clang 17
// __restrict — обещание, что out, a, b не перекрываются.
// Нарушите обещание — получите UB и молчаливо неверный результат.
void add(float* __restrict out,
const float* __restrict a,
const float* __restrict b,
int n) {
for (int i = 0; i < n; ++i)
out[i] = a[i] + b[i];
}
2. Зависимость по данным между итерациями
Цикл arr[i] = arr[i-1] * 2 векторизовать нельзя: каждая итерация читает результат предыдущей. Пачка из восьми значений не может считаться параллельно, потому что второе зависит от первого. Это не ограничение компилятора, а свойство алгоритма — переписывать нужно алгоритм, а не расставлять флаги.
3. Условные ветвления и не-inline вызовы в теле
Вызов функции, которую компилятор не может встроить, обычно рвёт векторизацию. Ветвление if внутри цикла векторизуется через маски (blend-инструкции), но не всегда и с потерей эффективности. Если тело сложное — компилятор часто отступает.
Когда спускаться до интринсиков
Интринсики (<immintrin.h>) — это функции, каждая из которых компилируется ровно в одну SIMD-инструкцию. Вы пишете _mm256_add_ps, получаете vaddps. Полный смысл делать это в трёх случаях:
- Компилятор не векторизует, а переписать цикл нельзя. Сложная логика с ветвлениями, перестановками элементов, горизонтальными суммами.
- Нужны инструкции, которых нет в языке. Аппаратный dot product, упаковка/распаковка, FMA (fused multiply-add), shuffle.
- Compute-bound задача с высокой арифметической плотностью. Много операций на каждый загруженный байт — обработка изображений, матрицы, DSP. Здесь виден настоящий выигрыш, потому что вы не упираетесь в память.
Пример горизонтальной суммы восьми float, которую компилятор редуцирует неоптимально:
#include <immintrin.h> // GCC 13, компиляция с -mavx2
// Скалярное произведение двух массивов длины n (n кратно 8).
float dot_avx2(const float* __restrict a,
const float* __restrict b, int n) {
__m256 acc = _mm256_setzero_ps(); // 8 аккумуляторов = 0
for (int i = 0; i < n; i += 8) {
__m256 va = _mm256_loadu_ps(a + i); // 8 float из a
__m256 vb = _mm256_loadu_ps(b + i); // 8 float из b
// FMA: acc += va * vb за одну инструкцию (нужен -mfma)
acc = _mm256_fmadd_ps(va, vb, acc);
}
// Горизонтальная сумма 8 элементов регистра в одно число
__m128 lo = _mm256_castps256_ps128(acc);
__m128 hi = _mm256_extractf128_ps(acc, 1);
lo = _mm_add_ps(lo, hi); // 4 частичные суммы
lo = _mm_hadd_ps(lo, lo);
lo = _mm_hadd_ps(lo, lo);
return _mm_cvtss_f32(lo);
}
Обратите внимание: _mm256_loadu_ps — невыровненная загрузка. Есть парная _mm256_load_ps, которая требует выравнивания указателя на 32 байта и на старых процессорах быстрее. Проверяйте имена интринсиков по Intel Intrinsics Guide — их сотни, и легко перепутать суффикс _ps (packed single, float) с _pd (packed double).
Три подхода и чем платите
| Подход | Что выигрываете | Чем платите |
|---|---|---|
Автовекторизация (-O3 -march=native) | Ноль изменений в коде, переносимость | Компилятор молча отступает; привязка к железу сборки через -march=native |
#pragma omp simd / хинты (__restrict) | Подсказка компилятору без ручного SIMD | Не гарантирует векторизацию, только снимает часть препятствий |
Интринсики <immintrin.h> | Полный контроль, доступ к FMA/shuffle/blend | Код привязан к набору инструкций; отдельные ветки под SSE/AVX2/AVX-512; трудно читать и сопровождать |
Практический порядок: сначала -O3 -march=native плюс __restrict, замер, отчёт компилятора. Только если конкретный горячий цикл не векторизуется и он compute-bound — интринсики. Писать SIMD «на всякий случай» по всему проекту — верный способ получить нечитаемый код без прироста скорости.
Где это ломается
- Привязка к процессору. Бинарь, собранный с
-march=nativeна машине с AVX-512, упадёт сillegal instructionна процессоре без него. Для распространяемого софта нужна runtime-диспетчеризация: проверяете поддержку через__builtin_cpu_supports("avx2")(GCC/Clang) и вызываете нужную реализацию. - Хвост массива. Если
nне кратно ширине регистра, последние элементы обрабатываются скалярным циклом-хвостом. Забыть про него — типичная причина неверного результата или чтения за границей. - Понижение частоты на AVX-512. На части процессоров Intel Skylake-X и близких активная работа с AVX-512 роняла тактовую частоту всего ядра. Иногда AVX2-версия суммарно быстрее AVX-512 из-за этого. Мерьте на целевом железе, а не по ширине регистра.
- Выравнивание.
load_psна невыровненном указателе — это либо падение (на старых наборах), либо штраф. Не уверены в выравнивании — беритеloaduверсии.
Как мерить, чтобы не обмануть себя
Разница в 21 мс против 19 мс из вступления — на грани шума. Чтобы отличить реальный выигрыш от случайности:
- Прогревайте кеш и брэнч-предиктор: сделайте несколько холостых прогонов до замера.
- Замеряйте одно и то же 20–30 раз, берите медиану, а не среднее — среднее портит один случайный выброс от планировщика ОС.
- Фиксируйте условия: модель процессора, объём данных, флаги компиляции. Замер без указания входных данных бесполезен.
- Смотрите, во что вы упёрлись. Утилиты вроде
perf statв Linux показывают промахи кеша и IPC. Если промахов кеша много — вы memory-bound, и SIMD не поможет.
Что делать дальше
Начните с отчёта компилятора о векторизации на вашем горячем цикле — это бесплатно и сразу покажет, есть ли проблема. Если цикл не векторизуется, определите причину: алиасинг лечится __restrict, зависимость по данным — переписыванием алгоритма. К интринсикам переходите последним и только для compute-bound участков, подтверждённых профилировщиком. Для переносимого кода посмотрите библиотеки-обёртки над SIMD (Highway от Google, xsimd) — они дают одну реализацию под несколько наборов инструкций без ручного дублирования веток.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Intel Intrinsics Guide, GCC Auto-Vectorization documentation
Частые вопросы
У меня нет AVX2, только SSE4 — код с интринсиками AVX2 запустится?
Автовекторизация показала «loop vectorized», но скорость не выросла — почему?
Стоит ли использовать -march=native в продакшене?
Интринсики дали меньше 8× ускорения на AVX2 — это нормально?
Компилятор игнорирует #pragma omp simd — что не так?
Как понять, какое имя интринсика правильное для нужной операции?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.