Получить сумму значений, хранящихся в __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 в большинстве случаев не слишком отличаются друг от друга, имея разную пропускную способность для некоторых инструкций, но обычно выполняя одно и то же действие для одних и тех же инструкций.

Другие вопросы по тегам