Предыдущая часть вызвала бурную дискуссию, в ходе которой выяснилось, что AVX/AVX2 на самом деле есть в десктопных CPU, нет только AVX512. Поэтому продолжаем знакомиться с SIMD, но уже с современной его частью — AVX. А так же разберём некоторые комментарии:
- медленнее ли
_mm256_load_si256
, чем прямое обращение к памяти? - влияет ли на скорость использование AVX команд над SSE регистрами?
- действительно ли так плохо использовать
_popcnt
?
Немного про AVX
AVX/AVX2 — это более мощная версия SSE, которая расширяет большинство 128 битных SSE операций до 256 бит, плюс приносит ряд новых инструкций.
Из тонкостей реализации можно выделить то, что на уровне ассемблера AVX использует 3 аргумента, что позволяет не разрушать данные в первых двух. SSE сохраняет результат в одном из аргументов.
Так же нужно учитывать, что при прямой адресации данные должны быть выровнены по 32 байта, в SSE выравнивание по 16.
Дополненная версия бенчмарка
Изменения:
- Количество элементов увеличено в 10 000 раз (до 10 240 000), чтобы гарантированно не вместиться в кэш процессора.
- Выравнивание изменено с 16 байт на 32 для поддержки AVX.
- Добавлены AVX реализации аналогичные SSE.
#include <benchmark/benchmark.h>
#include <x86intrin.h>
#include <cstring>
#define ARR_SIZE 10240000
#define VAL 50
static int16_t *getRandArr() {
auto res = new int16_t[ARR_SIZE];
for (int i = 0; i < ARR_SIZE; ++i) {
res[i] = static_cast<int16_t>(rand() % (VAL * 2));
}
return res;
}
static auto arr = getRandArr();
static int16_t *getAllignedArr() {
auto res = aligned_alloc(32, sizeof(int16_t) * ARR_SIZE);
memcpy(res, arr, sizeof(int16_t) * ARR_SIZE);
return static_cast<int16_t *>(res);
}
static auto allignedArr = getAllignedArr();
static void BM_Count(benchmark::State &state) {
for (auto _ : state) {
int64_t cnt = 0;
for (int i = 0; i < ARR_SIZE; ++i)
if (arr[i] == VAL)
++cnt;
benchmark::DoNotOptimize(cnt);
}
}
BENCHMARK(BM_Count);
static void BM_SSE_COUNT_SET_EPI(benchmark::State &state) {
for (auto _ : state) {
int64_t cnt = 0;
auto sseVal = _mm_set1_epi16(VAL);
for (int i = 0; i < ARR_SIZE; i += 8) {
cnt += _popcnt32(
_mm_movemask_epi8(
_mm_cmpeq_epi16(
sseVal,
_mm_set_epi16(arr[i + 7], arr[i + 6], arr[i + 5], arr[i + 4],
arr[i + 3], arr[i + 2], arr[i + 1], arr[i])
)
)
);
}
benchmark::DoNotOptimize(cnt >> 1);
}
}
BENCHMARK(BM_SSE_COUNT_SET_EPI);
static void BM_SSE_COUNT_LOADU(benchmark::State &state) {
for (auto _ : state) {
int64_t cnt = 0;
auto sseVal = _mm_set1_epi16(VAL);
for (int i = 0; i < ARR_SIZE; i += 8) {
cnt += _popcnt32(
_mm_movemask_epi8(
_mm_cmpeq_epi16(
sseVal,
_mm_loadu_si128((__m128i *) &arr[i])
)
)
);
}
benchmark::DoNotOptimize(cnt >> 1);
}
}
BENCHMARK(BM_SSE_COUNT_LOADU);
static void BM_SSE_COUNT_DIRECT(benchmark::State &state) {
for (auto _ : state) {
int64_t cnt = 0;
auto sseVal = _mm_set1_epi16(VAL);
for (int i = 0; i < ARR_SIZE; i += 8) {
cnt += _popcnt32(
_mm_movemask_epi8(
_mm_cmpeq_epi16(
sseVal,
*(__m128i *) &allignedArr[i]
)
)
);
}
benchmark::DoNotOptimize(cnt >> 1);
}
}
BENCHMARK(BM_SSE_COUNT_DIRECT);
#ifdef __AVX2__
static void BM_AVX_COUNT_LOADU(benchmark::State &state) {
for (auto _ : state) {
int64_t cnt = 0;
auto avxVal = _mm256_set1_epi16(VAL);
for (int i = 0; i < ARR_SIZE; i += 16) {
cnt += _popcnt32(
_mm256_movemask_epi8(
_mm256_cmpeq_epi16(
avxVal,
_mm256_loadu_si256((__m256i *) &arr[i])
)
)
);
}
benchmark::DoNotOptimize(cnt >> 1);
}
}
BENCHMARK(BM_AVX_COUNT_LOADU);
static void BM_AVX_COUNT_LOAD(benchmark::State &state) {
for (auto _ : state) {
int64_t cnt = 0;
auto avxVal = _mm256_set1_epi16(VAL);
for (int i = 0; i < ARR_SIZE; i += 16) {
cnt += _popcnt32(
_mm256_movemask_epi8(
_mm256_cmpeq_epi16(avxVal,
_mm256_load_si256((__m256i *) &allignedArr[i])
)
)
);
}
benchmark::DoNotOptimize(cnt >> 1);
}
}
BENCHMARK(BM_AVX_COUNT_LOAD);
static void BM_AVX_COUNT_DIRECT(benchmark::State &state) {
for (auto _ : state) {
int64_t cnt = 0;
auto avxVal = _mm256_set1_epi16(VAL);
for (int i = 0; i < ARR_SIZE; i += 16) {
cnt += _popcnt32(
_mm256_movemask_epi8(
_mm256_cmpeq_epi16(
avxVal,
*(__m256i *) &allignedArr[i]
)
)
);
}
benchmark::DoNotOptimize(cnt >> 1);
}
}
BENCHMARK(BM_AVX_COUNT_DIRECT);
#endif
BENCHMARK_MAIN();
Новые результаты выглядят так (-O0):
---------------------------------------------------------------------
Benchmark Time CPU Iterations
---------------------------------------------------------------------
BM_Count 17226622 ns 17062958 ns 41
BM_SSE_COUNT_SET_EPI 8901343 ns 8814845 ns 79
BM_SSE_COUNT_LOADU 3664778 ns 3664766 ns 185
BM_SSE_COUNT_DIRECT 3468436 ns 3468423 ns 202
BM_AVX_COUNT_LOADU 2090817 ns 2090796 ns 343
BM_AVX_COUNT_LOAD 1904424 ns 1904419 ns 364
BM_AVX_COUNT_DIRECT 1814875 ns 1814854 ns 385
Итого суммарное ускорение в 9+ раз, AVX ожидаемо быстрей SSE почти в 2 раза.
Медленнее ли _mm256_load_si256
, чем прямое обращение к памяти?
Однозначного ответа нет. С -O0
медленнее прямого обращения, но быстрее _mm256_loadu_si256
:
---------------------------------------------------------------------
Benchmark Time CPU Iterations
---------------------------------------------------------------------
BM_AVX_COUNT_LOADU 2090817 ns 2090796 ns 343
BM_AVX_COUNT_LOAD 1904424 ns 1904419 ns 364
BM_AVX_COUNT_DIRECT 1814875 ns 1814854 ns 385
С -O3
быстрее, чем прямое обращение к памяти, но всё ещё ожидаемо медленней _mm256_loadu_si256
.
---------------------------------------------------------------------
Benchmark Time CPU Iterations
---------------------------------------------------------------------
BM_AVX_COUNT_LOADU 992319 ns 992368 ns 701
BM_AVX_COUNT_LOAD 956120 ns 956166 ns 712
BM_AVX_COUNT_DIRECT 1027624 ns 1027674 ns 730
В продакшн коде всё-таки лучше использовать _mm256_load_si256
вместо прямого обращения, этот вариант компилятор умеет лучше оптимизировать.
Влияет ли на скорость использование AVX команд над SSE регистрами?
Короткий ответ — нет. Для эксперимента я собрал и запустил бенчмарк с -mavx2
и с -msse4.2
.
-mavx2
_popcnt32(_mm_movemask_epi8(_mm_cmpeq_epi16(...)))
превращается в
vpcmpeqw %xmm1,%xmm0,%xmm0
vpmovmskb %xmm0,%edx
popcnt %edx,%edx
Результаты:
------------------------------------------------------------
Benchmark Time CPU Iterations
------------------------------------------------------------
BM_SSE_COUNT_SET_EPI 9376699 ns 9376767 ns 75
BM_SSE_COUNT_LOADU 4425510 ns 4425560 ns 159
BM_SSE_COUNT_DIRECT 3938604 ns 3938648 ns 177
-msse4.2
_popcnt32(_mm_movemask_epi8(_mm_cmpeq_epi16(...)))
превращается в
pcmpeqw %xmm1,%xmm0
pmovmskb %xmm0,%edx
popcnt %edx,%edx
Результаты:
------------------------------------------------------------
Benchmark Time CPU Iterations
------------------------------------------------------------
BM_SSE_COUNT_SET_EPI 9309352 ns 9309375 ns 76
BM_SSE_COUNT_LOADU 4382183 ns 4382195 ns 159
BM_SSE_COUNT_DIRECT 3944579 ns 3944590 ns 176
bonus
AVX команды _popcnt32(_mm256_movemask_epi8(_mm256_cmpeq_epi16(...)))
превращаются в
vpcmpeqw %ymm1,%ymm0,%ymm0
vpmovmskb %ymm0,%edx
popcnt %edx,%edx
Действительно ли так плохо использовать _popcnt
?
В одном из комментариев Antervis написал:
А еще, ты несколько недоработал алгоритм. Зачем делать через movemask + popcnt? Для массивов не более 2^18 элементов можно сначала собирать поэлементную сумму:
auto cmp = _mm_cmpeq_epi16(sseVal, sseArr);
cmp = _mm_and_si128(cmp, _mm_set1_epi16(1));
sum = _mm_add_epi16(sum, cmp);
а потом, в конце цикла, сделать одно горизонтальное сложение (не забывая про переполнение).
Я сделал бенчмарк
static void BM_AVX_COUNT_DIRECT_WO_POPCNT(benchmark::State &state) {
auto avxVal1 = _mm256_set1_epi16(1);
for (auto _ : state) {
auto sum = _mm256_set1_epi16(0);
auto avxVal = _mm256_set1_epi16(VAL);
for (int i = 0; i < ARR_SIZE; i += 16) {
sum = _mm256_add_epi16(
sum,
_mm256_and_si256(
avxVal1,
_mm256_cmpeq_epi16(
avxVal,
*(__m256i *) &allignedArr[i])
)
);
}
auto arrSum = (uint16_t *) ∑
size_t cnt = 0;
for (int j = 0; j < 16; ++j)
cnt += arrSum[j];
benchmark::DoNotOptimize(cnt >> 1);
}
}
и он оказался медленней c -O0
:
---------------------------------------------------------------------
Benchmark Time CPU Iterations
---------------------------------------------------------------------
BM_AVX_COUNT_DIRECT 1814821 ns 1814785 ns 392
BM_AVX_COUNT_DIRECT_WO_POPCNT 2386289 ns 2386227 ns 287
и немного быстрее с -O3
:
---------------------------------------------------------------------
Benchmark Time CPU Iterations
---------------------------------------------------------------------
BM_AVX_COUNT_DIRECT 960941 ns 960924 ns 722
BM_AVX_COUNT_DIRECT_WO_POPCNT 948611 ns 948596 ns 732
Комментарии (14)
demp
19.02.2019 00:30Один программист по имени Wojciech Mula публикует статьи по практическому применению SIMD: http://0x80.pl/articles/index.html
Мне нравится его подход со сравнением разных реализаций для одной конкретной задачи.
khim
Вообще — откуда такой упор на -О0? Хотя бы -Og использовали…
svistunov Автор
Хотелось максимально полного контроля: что написал, то и получил.
old_bear
Для этого надо asm использовать. А то интринсики — это такое. Например, в произвольный момент времени (при использовании больше 6-8 SIMD-переменных) компилятор может напихать левых сохранений-восстановлений регистров через память.
Кстати, для последнего случая (с накоплением промежуточной суммы в SIMD-регистре) имеет смысл делать сравнение-накопление в двух регистрах по очереди, т.к. современные процессоры могут выполнять команды vpcmpeqw/vpand/vpaddw в количестве двух штук на такт (в отличии от vmovmskb, где только одну команду на такт), но с latency 1 такт. Соответственно, при исполнении через один регистр в теории теряется половина производительности. Правда на практике узким местом может быть уже чтение из памяти, но это надо на тесте проверять (и заодно и убедиться что компилятор не выкинул второй регистр, если тестовый код на интринсиках будет).
Кстати-2: можно заменить vpand с маской на vpsrlw на 15 разрядов. По производительности будет то же самое, но не нужно маску в отдельном регистре хранить. Пустячок, а приятно (и полезно в случае сложного кода, когда каждый регистр на счету).
BD9
Есть такие вещи как Register renaming, shadow register и "micro operations".
В AMD Zen сериях 1000 и 2000 инструкции AVX вроде как работают на 128 битных регистрах, т.ч. нужно делать два прохода.
Процессоры Intel снижают частоту при исполнении AVX.
Т.ч. всё сложно.
old_bear
Вещи конечно есть, но их невозможно контролировать и они разные в разных сериях процессоров. А вот явно заявленные регистры есть всегда.
Я имел мало опыта с АМД, но, судя по тестам Фога (некий известный в узких кругах Agner Fog) для Ryzen 7 1800X, немалое количество типовых операций делается с производительностью больше чем 1 регистр за такт, но с latency 1 такт, и для 256-разрядных регистров тоже. Т.е. 256-разрядные регистры имеет смысл использовать в любом случае, чтобы не терять производительность.
Для этого и нужны тесты. Но по моему опыту, весьма немаленькому, на Интеле AVX2 даёт выигрыш всегда (хотя в ядрах с архитектурой Haswell этот выигрыш может быть совсем незначительным). Всё сложно — это с AVX512. Вот там действительно частота нехило снижается и другие важные тонкости есть.
Кстати, я что-то ступил в предыдущем предложении по модификации алгоритма с vpand с маской на vpsrlw. В итоге ни одна из этих операций вообще не нужна. Достаточно сделать vpcmpeqw, а затем vpsubw, т.к. по результатам сравнения мы получаем либо 0, либо -1 (0xFFFF).
khim
А какая разница — vsubw или vandw? Всё равно если данных много переполнение будет…
old_bear
Разница в одну инструкцию: первом случае vpcmpeqw -> vpand -> vpaddw, а во втором только vpcmpeqw -> vpsubw. По идее, это само по себе может 30% выигрыша дать на больших массивах.
А переполнение легко контролируется дополнительной вложенностью цикла. Т.е. делается цикл продолжительностью ARR_SIZE >> 20 (для случая 16 аккумуляторов в SIMD-регистре, по 16 разрядов каждый), внутри него цикл на 0x100000. И «хвост» продолжительностью ARR_SIZE & 0xFFFFF (на который также идёт переход, если ARR_SIZE < 0x100000). После каждого «малого» цикла значения аккумуляторов добавляются в регистр общего назначения нужной разрядности.
khim
old_bear
Так то не я, а автор статьи предлагает. Я как раз предлагаю лишнюю маску убрать.
Можно и так, но один разряд аккумуляторов на знак уйдёт. Да и зачем лишние сложности в алгоритме, если можно просто поменять команду и сразу получать сумму с нужным знаком.
khim
Из моей практики «что написал, то получил» — это примерно -O2. На -O3 начинается разворот циклов и прочие чудеса, которые, действительно, делают программу мало похожей на исходник, но вот -O0 сравнивать по скорости уж как-то совсем бессмысленно: бесконечные пересылки данных занимают куда больше времени, чем осмысленная деятельность.
Минимум, который имеет смысл сравнивать по скорости — это -Og, как я сказал: пересылки данных убиваются, по возможности, но программа остаётся линейной и инструкции не переставляются…
IRainman
Что то мне подсказывает, что если просто включить -O2 или -O3 результат будет не сильно хуже. А если запустить профилирование то компилятор сгенерирует для выборки на N (где N явно большое) элементов даже более оптимальную реализацию чем можно написать руками.
Почему я так думаю, да потому что у вас тут ноль кода отвечающего за работу с кешем, загрузку из памяти, выделение памяти, переключение контекста и т. д. и т. п. Т.е. суть в том, что компилятор способен эффективно оптимизировать не только пользовательский код но и свою библиотеку и системные вызовы.
Собственно я не исключаю, что могу быть не прав. Но как то слабо верится, что до сих пор всё так плохо особенно если использовать профилирование.