Получить сумму значений, хранящихся в __m256d с помощью SSE/AVX
Есть ли способ получить сумму значений, хранящихся в переменной __m256d? У меня есть этот код.
acc = _mm256_add_pd(acc, _mm256_mul_pd(row, vec));
//acc in this point contains {2.0, 8.0, 18.0, 32.0}
acc = _mm256_hadd_pd(acc, acc);
result[i] = ((double*)&acc)[0] + ((double*)&acc)[2];
Этот код работает, но я хочу заменить его инструкцией SSE/AVX.
3 ответа
Похоже, что вы делаете горизонтальную сумму для каждого элемента выходного массива. (Возможно, как часть матуля?) Это обычно не оптимально; попробуйте векторизовать 2-й цикл из внутреннего, чтобы вы могли производить result[i + 0..3]
в векторе и не нуждаются в горизонтальной сумме вообще.
Общие сведения о горизонтальном сокращении см. В разделе " Самый быстрый способ получения горизонтальной векторной суммы с плавающей запятой на x86: извлечение верхней половины и добавление к нижней половине". Повторяйте, пока не дойдете до 1 элемента.
Если вы используете это во внутреннем цикле, вы определенно не хотите использовать hadd(same,same)
, Это стоит 2 случайных шага вместо 1, если только ваш компилятор не спасет вас от самого себя. (И gcc/clang нет.) hadd
хорош для размера кода, но почти ничего, кроме случаев, когда вы можете использовать его с двумя разными входами.
Для AVX это означает, что единственная 256-битная операция, которая нам нужна, - это извлечение, которое быстро выполняется на AMD и Intel. Тогда все остальное 128-битное:
#include <immintrin.h>
double hsum_double_avx(__m256d v) {
__m128d vlow = _mm256_castpd256_pd128(v);
__m128d vhigh = _mm256_extractf128_pd(v, 1); // high 128
vlow = _mm_add_pd(vlow, vhigh); // reduce down to 128
__m128d high64 = _mm_unpackhi_pd(vlow, vlow);
return _mm_cvtsd_f64(_mm_add_sd(vlow, high64)); // reduce to scalar
}
Если вы хотите, чтобы результат транслировался на каждый элемент __m256
, вы бы использовали vshufpd
а также vperm2f128
поменять местами верхние / нижние половины (если настраивать на Intel). И использовать 256-битную FP добавить все время. Если вы заботитесь о Райзене, вы можете уменьшить его до 128, используйте _mm_shuffle_pd
поменять местами vinsertf128
чтобы получить 256-битный вектор. Или с AVX2, vbroadcastsd
на окончательный результат этого. Но это будет медленнее для Intel, чем оставаться 256-битным все время, все еще избегая vhaddpd
,
Составлено с gcc7.3 -O3 -march=haswell
на проводнике компилятора Godbolt
vmovapd xmm1, xmm0 # silly compiler, vextract to xmm1 instead
vextractf128 xmm0, ymm0, 0x1
vaddpd xmm0, xmm1, xmm0
vunpckhpd xmm1, xmm0, xmm0 # no wasted code bytes on an immediate for vpermilpd or vshufpd or anything
vaddsd xmm0, xmm0, xmm1 # scalar means we never raise FP exceptions for results we don't use
vzeroupper
ret
После встраивания (что вы определенно хотите, чтобы), vzeroupper
опускается до дна всей функции, и, надеюсь, vmovapd
оптимизирует прочь, с vextractf128
в другой регистр вместо уничтожения xmm0, который содержит _mm256_castpd256_pd128
результат.
На Рызене, согласно таблицам инструкций Агнера Фога, vextractf128
1 моп с задержкой 1 с и пропускной способностью 0,33 с.
Версия @PaulR, к сожалению, ужасна для AMD; это похоже на то, что вы можете найти в выходных данных библиотеки или компилятора Intel как "уродливая AMD" функция. (Я не думаю, что Пол сделал это специально, я просто указываю, как игнорирование процессоров AMD может привести к тому, что на них код будет работать медленнее.)
На ризене, vperm2f128
равно 8 моп, задержка 3 с и пропускная способность по одному на 3 с. vhaddpd ymm
равно 8 моп (против 6, которые вы можете ожидать), задержка 7c, один на пропускную способность 3c. Агнер говорит, что это инструкция "смешанного домена". И 256-битные операции всегда занимают не менее 2 мопов.
# Paul's version # Ryzen # Skylake
vhaddpd ymm0, ymm0, ymm0 # 8 uops # 3 uops
vperm2f128 ymm1, ymm0, ymm0, 49 # 8 uops # 1 uop
vaddpd ymm0, ymm0, ymm1 # 2 uops # 1 uop
# total uops: # 18 # 5
против
# my version with vmovapd optimized out: extract to a different reg
vextractf128 xmm1, ymm0, 0x1 # 1 uop # 1 uop
vaddpd xmm0, xmm1, xmm0 # 1 uop # 1 uop
vunpckhpd xmm1, xmm0, xmm0 # 1 uop # 1 uop
vaddsd xmm0, xmm0, xmm1 # 1 uop # 1 uop
# total uops: # 4 # 4
Общая пропускная способность UOP часто является узким местом в коде со смесью нагрузок, хранилищ и ALU, поэтому я ожидаю, что версия с 4 uop будет, по крайней мере, немного лучше для Intel, а также намного лучше для AMD. Он также должен выделять немного меньше тепла и, таким образом, позволять немного увеличить турбо / использовать меньше энергии аккумулятора. (Но, надеюсь, этот hsum является достаточно маленькой частью вашего общего цикла, поэтому это незначительно!)
Задержка тоже не хуже, так что на самом деле нет причин использовать неэффективный hadd
/ vpermf128
версия.
Вы можете сделать это так:
acc = _mm256_hadd_pd(acc, acc); // horizontal add top lane and bottom lane
acc = _mm256_add_pd(acc, _mm256_permute2f128_pd(acc, acc, 0x31)); // add lanes
result[i] = _mm256_cvtsd_f64(acc); // extract double
Примечание: если это находится в "горячей" (то есть критичной к производительности) части вашего кода (особенно если он работает на процессоре AMD), тогда вы можете вместо этого взглянуть на ответ Питера Кордеса о более эффективных реализациях.
В иclang
Типы SIMD являются встроенными векторными типами. Например:
# avxintrin.h
typedef double __m256d __attribute__((__vector_size__(32), __aligned__(32)));
Эти встроенные векторы поддерживают индексацию, поэтому вы можете написать их удобно и предоставить компилятору возможность сделать хороший код:
double hsum_double_avx2(__m256d v) {
return v[0] + v[1] + v[2] + v[3];
}
clang-14 -O3 -march=znver3 -ffast-math
генерирует ту же сборку , что и для встроенных функций Питера Кордеса:
# clang -O3 -ffast-math
hsum_double_avx2:
vextractf128 xmm1, ymm0, 1
vaddpd xmm0, xmm0, xmm1
vpermilpd xmm1, xmm0, 1 # xmm1 = xmm0[1,0]
vaddsd xmm0, xmm0, xmm1
vzeroupper
ret
К сожалениюgcc
делает намного хуже, что приводит к неоптимальным инструкциям, не используя преимущества свободы переассоциировать 3+
операций и с помощьюvhaddpd xmm
делатьv[0] + v[1]
часть, которая стоит 4 мкп на Zen 3. (Или 3 мкп на процессорах Intel, 2 тасовки + доп.)
конечно, необходимо, чтобы компилятор мог хорошо работать, если только вы не напишете его как(v[0]+v[2]) + (v[1]+v[3])
. При этом clang по-прежнему делает то же самое с-O3 -march=icelake-server
без-ffast-math
.
В идеале я хочу написать простой код, как я сделал выше, и позволить компилятору использовать модель затрат для конкретного ЦП, чтобы выдавать оптимальные инструкции в правильном порядке для этого конкретного ЦП.
Одна из причин заключается в том, что трудоемкая оптимальная версия с ручным кодированием для Haswell может оказаться неоптимальной для Zen3. В частности, для этой проблемы это не совсем так: начиная с сужения до 128 бит сvextractf128
+vaddpd
везде оптимально. Существуют незначительные различия в пропускной способности перемешивания на разных процессорах; например, Ice Lake и более поздние версии Intel могут работатьvshufps
на порту 1 или 5, но некоторые перетасовки, такие какvpermilps/pd
илиvunpckhpd
по-прежнему только на порту 5. Zen 3 (как и Zen 2 и 4) имеет хорошую пропускную способность для любого из этих перетасовок, поэтому asm clang оказывается там хорошим. Но жаль, чтоclang -march=icelake-server
все еще используетvpermilpd
В настоящее время частым вариантом использования являются вычисления в облаке с различными моделями и поколениями ЦП, компиляция кода на этом хосте с-march=native -mtune=native
для лучшей производительности.
Теоретически, если бы компиляторы были умнее, это оптимизировало бы короткие последовательности, подобные этой, до идеального ассемблера, а также сделало бы в целом хороший выбор для эвристик, таких как встраивание и развертывание. Обычно это лучший выбор для бинарного файла, который будет работать только на одной машине, но, как показывает здесь GCC, результаты часто далеки от оптимальных. К счастью, современные AMD и Intel в большинстве случаев не слишком отличаются друг от друга, имея разную пропускную способность для некоторых инструкций, но обычно выполняя одно и то же действие для одних и тех же инструкций.