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

Простой цикл суммирования массива из 100 миллионов float на одном ядре: скалярная версия выдаёт около 6.2 ГБ/с пропускной способности, векторизованная под AVX2 — около 25 ГБ/с. Разница в 4 раза, алгоритм не менялся ни на строку. Изменился только способ, которым процессор обрабатывает данные: вместо одного числа за такт — восемь. Это векторизация, и большую её часть компилятор делает сам — если вы ему не мешаете.
Ниже — разбор того, как SIMD-инструкции1 работают на уровне цикла, почему GCC и Clang иногда отказываются векторизовать очевидный код, как это увидеть в отчёте компилятора и в ассемблере, и на каких паттернах автовекторизация ломается. Речь про x86-64 с AVX2/AVX-512, GCC 13+ и Clang 17+. Ручной интринсик — отдельная большая тема, здесь трогаем только там, где автовекторизация сдаётся.
Что происходит на уровне цикла
Скалярный цикл суммирования обрабатывает по одному элементу за итерацию. Регистр xmm хранит одно значение, инструкция addss складывает два числа. Векторный регистр ymm в AVX2 — это 256 бит, то есть восемь float или четыре double. Инструкция vaddps складывает все восемь пар за один такт. AVX-512 расширяет регистр до 512 бит — шестнадцать float.
Компилятор берёт цикл, разбивает диапазон на блоки по ширине регистра, генерирует векторное тело и добавляет скалярный «хвост» на остаток, который не делится на ширину. Это и есть автовекторизация. Её включает -O2 в Clang и -O3 в GCC (в GCC на -O2 векторизатор циклов по умолчанию выключен для части случаев — проверяйте флаг -ftree-vectorize).
Векторизация — это не «сделать код быстрее вообще». Это заставить процессор считать несколько чисел одной инструкцией. Если данные не лежат так, чтобы их можно было загрузить пачкой, никакого ускорения не будет — будет медленнее из-за сборки вектора по частям.
Простой пример, который векторизуется
// GCC 13 / Clang 17, флаги: -O3 -march=native
// Сумма элементов массива float
float sum_array(const float* data, size_t n) {
float total = 0.0f;
for (size_t i = 0; i < n; ++i) {
total += data[i];
}
return total;
}
С флагом -ffast-math этот цикл векторизуется полностью: компилятор разворачивает его на несколько независимых аккумуляторов и складывает их в конце. Без -ffast-math он не векторизуется, и это не баг — об этом ниже.
Как увидеть, векторизовался ли цикл
Гадать не нужно. Оба компилятора умеют отчитываться.
- GCC: флаг
-fopt-info-vecпечатает, какие циклы векторизованы, а-fopt-info-vec-missed— какие компилятор пропустил и почему. Строка видаloop vectorizedв выводе — то, что вы ищете. - Clang: флаг
-Rpass=loop-vectorizeсообщает об успехах,-Rpass-missed=loop-vectorizeи-Rpass-analysis=loop-vectorize— о промахах с причиной. - Ассемблер:
-S -masm=intelи грепаете вывод наvaddps,vfmadd,vmovups. Если видите толькоaddss/mulssв горячем цикле — векторизации нет. Удобнее смотреть на godbolt.org: он подсвечивает соответствие строк исходника и ассемблера.
Начинайте всегда с отчёта компилятора, а не с профайлера. Профайлер покажет, что цикл горячий, но не скажет, что его можно было векторизовать и почему это не случилось.
Замеры: что реально даёт SIMD
Ниже — порядки величин, которые стоит ожидать, а не константы для вашей машины. Цифры зависят от процессора, размера данных относительно кешей и того, упирается ли задача в память или в вычисления. Меряйте у себя: соберите одну и ту же функцию с -O3 и с -O3 -fno-tree-vectorize (GCC) или -fno-vectorize (Clang) и сравните.
| Операция | Ограничение | Типичный выигрыш от AVX2 |
|---|---|---|
| Сумма/скалярное произведение больших массивов | Память (данные не в кеше) | Слабый: 1.2–2x, упор в пропускную способность RAM |
| Те же операции на данных в L1/L2 | Вычисления | Сильный: 4–8x |
| Поэлементная математика (exp, умножение) | Вычисления | 4–8x на подходящих типах |
| Цикл с ветвлением по данным | Предсказание переходов | Часто отрицательный без маскирования |
Главный вывод из таблицы: если задача упирается в память, а не в арифметику, векторизация почти ничего не даст — вы уже читаете данные так быстро, как позволяет шина. SIMD раскрывается там, где данные помещаются в кеш и на каждый загруженный байт приходится много вычислений.
Почему компилятор молчит
Самые частые причины, по которым очевидный на вид цикл остаётся скалярным.
1. Порядок операций с плавающей точкой
Сложение float не ассоциативно: (a + b) + c и a + (b + c) дают разный результат из-за округления. Векторизация суммы меняет порядок сложений, поэтому без разрешения компилятор её не делает. Разрешение даёт -ffast-math (или точечнее — -fassociative-math -fno-signed-zeros -fno-trapping-math). Цена: результаты перестают быть побитово воспроизводимыми, теряется корректная обработка NaN и бесконечностей. В финансах и научных расчётах с контролем точности это неприемлемо.
2. Возможный алиасинг указателей
Если функция принимает два указателя, компилятор обязан считать, что они могут указывать на пересекающиеся области. Тогда векторизация небезопасна — запись в один массив может изменить чтение из другого. Решение — __restrict:
// GCC / Clang: __restrict обещает, что out и in не пересекаются
void scale(float* __restrict out, const float* __restrict in,
float k, size_t n) {
for (size_t i = 0; i < n; ++i) {
out[i] = in[i] * k;
}
}
Без __restrict компилятор либо вставит проверку пересечения в рантайме (это делает Clang, добавляя две версии цикла), либо откажется векторизовать.
3. Зависимость между итерациями
Если итерация читает результат предыдущей, векторизация невозможна в принципе: data[i] = data[i-1] * 2 — каждая итерация ждёт предыдущую. Это не решается флагами, только переписыванием алгоритма, если оно вообще возможно.
4. Сложное управление внутри цикла
Вызов невстроенной функции, ветвление с побочными эффектами, break по условию из данных — всё это мешает. Компилятор умеет маскированные операции для простых if, но continue с ранним выходом обычно ломает векторизацию.
Где подход ломается
Даже когда цикл векторизовался, выигрыша может не быть или он окажется отрицательным.
- Невыровненные данные. AVX2 работает быстрее с данными, выровненными по 32 байтам. Компилятор вставляет скалярный пролог до достижения выравнивания. На коротких массивах пролог и эпилог съедают весь выигрыш векторного тела.
- Короткие циклы. Если
nменьше пары ширин регистра, накладные расходы на настройку векторного цикла превышают экономию. Дляn = 5векторизация бессмысленна. - Gather/scatter. Доступ по индексу (
data[index[i]]) требует инструкций сбора вектора из разных адресов. Они есть в AVX2, но медленные — часто медленнее скалярного кода. - Понижение частоты на AVX-512. На ряде серверных Intel широкие AVX-512-инструкции снижают тактовую частоту ядра, пока выполняются. Если векторизованный участок мал, а остальной код скалярный, общий результат может стать хуже. Это поведение зависит от микроархитектуры — проверяйте на своём процессоре.
- Смешение типов. Цикл, где
floatиdoubleперемешаны, вынуждает конвертации, которые дробят вектор.
Когда лезть в интринсики
Автовекторизация покрывает подавляющее большинство горячих циклов. Ручные интринсики (<immintrin.h>, функции вида _mm256_add_ps) оправданы, когда:
- Отчёт компилятора показывает промах, а причину устранить нельзя — например, нужен специфический паттерн перестановки данных, который автовекторизатор не находит.
- Нужна инструкция без прямого аналога в C++: горизонтальные суммы, специальные перемешивания, насыщающая арифметика.
- Вы пишете математическое ядро, которое будет жить годами и ради которого стоит зафиксировать инструкции руками.
Цена интринсиков — привязка к набору инструкций (код под AVX2 не запустится на процессоре без AVX2), нечитаемость и хрупкость при смене архитектуры. Прежде чем писать интринсик, добейтесь от компилятора векторизации через __restrict и -ffast-math там, где это допустимо по точности.
Что делать дальше
Порядок работы над горячим циклом: сначала измеряете профайлером, что он действительно горячий; затем смотрите отчёт компилятора -fopt-info-vec-missed или -Rpass-missed=loop-vectorize; устраняете причину промаха (__restrict, разрешение переупорядочивания сложений, разрыв зависимости); собираете две версии и сравниваете пропускную способность на реальных данных. Целевой показатель — не «векторизовалось», а гигабайты в секунду или GFLOPS на вашей нагрузке. Если задача упирается в память, переключайте внимание на укладку данных в кеш, а не на SIMD. Для глубокого погружения в модель памяти x86 и латентности инструкций держите под рукой руководства по оптимизации от Intel и таблицы латентностей Агнера Фога.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: GCC: Auto-vectorization in GCC (документация проекта), LLVM: Auto-Vectorization in LLVM
Частые вопросы
Нужен ли -ffast-math, чтобы цикл векторизовался?
У меня старый компилятор, отчёты -fopt-info-vec ничего не печатают. Что делать?
Векторизовалось, но быстрее не стало. Почему?
Стоит ли всегда собирать с -march=native?
Даёт ли AVX-512 вдвое больше скорости, чем AVX2?
Как разорвать зависимость между итерациями, чтобы цикл векторизовался?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.