Перейти к содержанию

Векторные инструкции и SIMD

Что такое SIMD

SIMD (Single Instruction, Multiple Data) — архитектурный принцип, при котором одна инструкция обрабатывает сразу несколько элементов данных. В отличие от скалярных операций, обрабатывающих один элемент, SIMD-инструкции работают с «векторами» данных:

Скалярная инструкция:    c = a + b           (одна пара чисел)
Векторная инструкция:    c[0:7] = a[0:7] + b[0:7]   (8 пар одновременно)

Это позволяет достичь теоретического ускорения в N раз, где N — ширина вектора. На практике ускорение составляет 4–8x для compute-bound задач.

Расширения x86-64 для SIMD

В архитектуре x86-64 SIMD реализован через несколько расширений, появлявшихся постепенно:

Расширение Год Ширина регистра Элементы (float) Примеры функций
SSE 1999 128 бит 4 × float32 _mm_add_ps
SSE2 2001 128 бит 2 × float64 _mm_add_pd
AVX 2011 256 бит 8 × float32 _mm256_add_ps
AVX2 2013 256 бит 8 × int32 _mm256_add_epi32
AVX-512 2017 512 бит 16 × float32 _mm512_add_ps

Intel intrinsics — функции на C/C++, которые напрямую соответствуют SIMD-инструкциям процессора и позволяют использовать их без написания ассемблерного кода.

Векторные регистры

SIMD-инструкции используют отдельный набор регистров:

  • XMM0–XMM15 (128 бит) — для SSE/SSE2; расширяются до XMM0–XMM31 при использовании EVEX-кодировки AVX-512;
  • YMM0–YMM15 (256 бит) — для AVX (расширение XMM, нижние 128 бит = XMM); расширяются до YMM0–YMM31 с AVX-512;
  • ZMM0–ZMM31 (512 бит) — для AVX-512 (расширение YMM).
ZMM0  (512 бит)
└── YMM0  (256 бит, нижняя половина ZMM0)
    └── XMM0  (128 бит, нижняя половина YMM0)

Запись в XMM0 обнуляет верхние биты YMM0 (в режиме AVX). Запись в YMM0 обнуляет верхние биты ZMM0.

Использование AVX через intrinsics

Для использования intrinsics нужно подключить заголовочный файл:

#include <immintrin.h>

Пример: вычисление result[i] = a[i] * b[i] + c[i] для 8 элементов:

/* Без AVX: 8 итераций цикла */
float result[8];
for (int i = 0; i < 8; i++)
    result[i] = a[i] * b[i] + c[i];

/* С AVX FMA: 1 инструкция = 8 умножений + 8 сложений */
__m256 va     = _mm256_loadu_ps(a);           /* загрузить 8 float */
__m256 vb     = _mm256_loadu_ps(b);
__m256 vc     = _mm256_loadu_ps(c);
__m256 vres   = _mm256_fmadd_ps(va, vb, vc); /* va*vb + vc */
_mm256_storeu_ps(result, vres);               /* сохранить 8 float */

Пример: скалярное произведение с AVX

#include <immintrin.h>

/* Обычная версия */
float dot_product(const float *a, const float *b, int n) {
    float sum = 0.0f;
    for (int i = 0; i < n; i++)
        sum += a[i] * b[i];
    return sum;
}

/* AVX-версия (n должно быть кратно 8) */
float dot_product_avx(const float *a, const float *b, int n) {
    __m256 acc = _mm256_setzero_ps();   /* acc = [0,0,0,0,0,0,0,0] */

    for (int i = 0; i < n; i += 8) {
        __m256 va   = _mm256_loadu_ps(&a[i]);
        __m256 vb   = _mm256_loadu_ps(&b[i]);
        __m256 prod = _mm256_mul_ps(va, vb);
        acc         = _mm256_add_ps(acc, prod);
    }

    /* Горизонтальная редукция: сложить 8 элементов acc в один скаляр */
    __m128 lo  = _mm256_castps256_ps128(acc);
    __m128 hi  = _mm256_extractf128_ps(acc, 1);
    __m128 sum = _mm_add_ps(lo, hi);
    sum = _mm_hadd_ps(sum, sum);
    sum = _mm_hadd_ps(sum, sum);
    return _mm_cvtss_f32(sum);
}

Автовекторизация компилятором

Современные компиляторы умеют автоматически преобразовывать обычные циклы в SIMD-инструкции при наличии соответствующих флагов:

gcc -O2 -mavx2 -ftree-vectorize prog.c -o prog

Флаг -fopt-info-vec позволяет увидеть, какие циклы были векторизованы:

gcc -O2 -mavx2 -fopt-info-vec prog.c -o prog

Для успешной автовекторизации цикл должен: - не иметь зависимостей между итерациями; - иметь простой предсказуемый паттерн доступа к памяти; - работать с данными, выровненными по размеру вектора (желательно).

Выравнивание данных для SIMD

SIMD-инструкции работают быстрее, если данные выровнены по ширине вектора:

  • 16 байт (SSE): alignas(16) или _mm_load_ps вместо _mm_loadu_ps
  • 32 байта (AVX): alignas(32) или _mm256_load_ps вместо _mm256_loadu_ps
/* Выровненный массив — допускает использование load (не loadu) */
float arr[8] __attribute__((aligned(32)));

__m256 v = _mm256_load_ps(arr);    /* требует выравнивания по 32 байтам */
/* vs */
__m256 v = _mm256_loadu_ps(arr);   /* не требует, но может быть чуть медленнее */

На современных процессорах (Haswell и новее) разница между выровненной и невыровненной загрузкой минимальна, если данные не пересекают границу страницы.

Проверка поддержки AVX

grep -o 'avx[^ ]*' /proc/cpuinfo | sort -u
# avx, avx2, avx512f, avx512vl, ...

Не все процессоры поддерживают AVX-512. AVX2 поддерживается большинством x86-64 процессоров, выпущенных после 2013 года.

Связанные темы

Источники

  • Intel Intrinsics Guide (интерактивный справочник всех SIMD intrinsics): https://www.intel.com/content/www/us/en/docs/intrinsics-guide/
  • man 1 gcc — флаги -mavx2, -msse4.2, -ftree-vectorize
  • Agner Fog, "Optimizing software in C++", Chapter 13 — SIMD: https://agner.org/optimize/
  • grep avx /proc/cpuinfo — поддержка расширений на конкретном процессоре