Самый быстрый способ сделать горизонтальную векторную сумму с плавающей точкой на x86 - PullRequest
38 голосов
/ 09 августа 2011

У вас есть вектор из трех (или четырех) поплавков.Какой самый быстрый способ их сложить?

Всегда ли SSE (movaps, shuffle, add, movd) всегда быстрее, чем x87?Стоят ли инструкции горизонтального добавления в SSE4.2?Сколько стоит перейти на FPU, затем на faddp, faddp?Какая самая быстрая конкретная последовательность команд?

«Постарайтесь упорядочить вещи так, чтобы вы могли суммировать четыре вектора за один раз», не будут приняты в качестве ответа.: -)

Ответы [ 4 ]

65 голосов
/ 08 февраля 2016

Вот некоторые версии, настроенные на основе руководства по микроархам Agner Fog руководство по микроархам и таблицы инструкций. Смотрите также тег вики. Они должны быть эффективными на любом процессоре, без каких-либо серьезных узких мест. (например, я избегал вещей, которые могли бы немного помочь одному уарху, но были бы медленными на другом уарше). Размер кода также минимизирован.

Общая идиома 2x hadd хороша только для размера кода, а не для скорости на любых существующих процессорах. Для этого есть варианты использования (см. Ниже), но это не один из них.

Я также включил версию AVX. Любой вид горизонтального сокращения с AVX / AVX2 должен начинаться с vextractf128 и «вертикальной» операции, чтобы уменьшить до одного вектора XMM (__m128).

См. Вывод asm из всего этого кода в проводнике компилятора Godbolt . См. Также мои улучшения в Библиотеке векторных классов Агнера Фога C ++ horizontal_add функций , ( нить доски сообщений и код на github ). Я использовал макросы CPP для выбора оптимальных тасов для размера кода для SSE2, SSE4 и AVX, а также для избежания movdqa, когда AVX недоступен.


Есть компромиссы для рассмотрения:

  • размер кода: чем меньше, тем лучше по причинам I-кэша L1 и для выборки кода с диска (меньшие двоичные файлы). Общий размер двоичного файла в основном имеет значение для решений компилятора, принимаемых неоднократно по всей программе. Если вы потрудитесь написать что-то вручную с помощью встроенных функций, стоит потратить несколько байтов кода, если это даст какое-либо ускорение для всей программы (будьте осторожны с микробенчмарками, которые делают развертывание хорошо выглядящим).
  • Размер uop-кэша: Часто более ценный, чем L1 I $. 4 инструкции по одной операции могут занимать меньше места, чем 2 haddps, поэтому это очень важно здесь.
  • задержка: иногда актуально
  • пропускная способность: обычно не имеет значения, горизонтальные суммы не должны быть в самой внутренней петле.
  • total uops fused-domain: Если окружающий код не является узким местом на том же порту, который использует hsum, это прокси для влияния hsum на пропускную способность всего этого.

Когда горизонтальное добавление нечасто :

ЦП без uop-кэша может предпочесть 2x haddps: когда он работает, он работает медленнее, но это не часто. Только две инструкции сводят к минимуму влияние на окружающий код (размер I $).

ЦП с кешем uop , вероятно, предпочтут что-то, что займет меньше мопов, даже если это больше инструкций / больше размер кода x86. Общее количество используемых строк кэша мопов - это то, что мы хотим минимизировать, что не так просто, как минимизация общего количества мопов (взятые ветви и границы 32B всегда начинают новую строку кэша мопов).

В любом случае, с учетом вышесказанного, горизонтальные суммы составляют лот , поэтому я попытаюсь тщательно составить несколько версий, которые хорошо компилируются. Не тестируется на каком-либо реальном оборудовании или даже тщательно проверяется. Могут быть ошибки в константах тасования или что-то в этом роде.


Если вы делаете резервную или базовую версию своего кода, помните, что только старые процессоры будут запускать его ; на более новых процессорах будет работать ваша версия AVX, или SSE4.1, или что-то еще.

Старые процессоры, такие как K8 и Core2 (merom) и более ранние, имеют только 64-битные тасовые блоки . Core2 имеет 128-битные исполнительные блоки для большинства команд, но не для случайных. (Pentium M и K8 обрабатывают все 128-битные векторные инструкции как две 64-битные половины).

Тасования, подобные movhlps, которые перемещают данные в 64-битных чанках (без тасования в 64-битных половинах), также бывают быстрыми.

На старых процессорах с медленным перемешиванием :

  • movhlps (Merom: 1uop) значительно быстрее, чем shufps (Merom: 3uops). На Пентиуме-М дешевле movaps. Кроме того, он работает в домене FP на Core2, избегая задержек обхода из-за других перемешиваний.
  • unpcklpd быстрее, чем unpcklps.
  • pshufd медленно, pshuflw / pshufhw быстро (потому что они тасуют только 64-битную половину)
  • pshufb mm0 (MMX) быстро, pshufb xmm0 медленно.
  • haddps очень медленно (6 моп на Merom и Pentium M)
  • movshdup (Merom: 1uop) интересно : это единственный insop 1uop, который тасует в элементах 64b.

shufps на Core2 (включая Penryn) переносит данные в целочисленную область, вызывая задержку обхода, чтобы вернуть их к исполнительным блокам FP для addps, но movhlps полностью находится в области FP. shufpd также работает в домене с плавающей точкой.

movshdup работает в целочисленной области, но это только один моп.

AMD K10, Intel Core2 (Penryn / Wolfdale) и все последующие процессоры запускают все xmm-тасовки как один моп. (Но обратите внимание на задержку обхода с shufps на Пенрине, избегаемую с movhlps)


Без AVX, избегая напрасной потери movaps / movdqa инструкции требуют тщательного выбора шаффлов . Только несколько перемешиваний работают как копирование и перемешивание, а не как изменение места назначения. Перемешивания, которые объединяют данные из двух входов (например, unpck* или movhlps), могут использоваться с переменной tmp, которая больше не нужна, вместо _mm_movehl_ps(same,same).

Некоторые из них можно сделать быстрее (за исключением MOVAPS), но сделать их более уродливыми или менее «чистыми», взяв фиктивный аргумент для использования в качестве пункта назначения для начального перемешивания. Например:

// Use dummy = a recently-dead variable that vec depends on,
//  so it doesn't introduce a false dependency,
//  and the compiler probably still has it in a register
__m128d highhalf_pd(__m128d dummy, __m128d vec) {
#ifdef __AVX__
    // With 3-operand AVX instructions, don't create an extra dependency on something we don't need anymore.
    (void)dummy;
    return _mm_unpackhi_pd(vec, vec);
#else
    // Without AVX, we can save a MOVAPS with MOVHLPS into a dead register
    __m128 tmp = _mm_castpd_ps(dummy);
    __m128d high = _mm_castps_pd(_mm_movehl_ps(tmp, _mm_castpd_ps(vec)));
    return high;
#endif
}

SSE1 (он же SSE):

float hsum_ps_sse1(__m128 v) {                                  // v = [ D C | B A ]
    __m128 shuf   = _mm_shuffle_ps(v, v, _MM_SHUFFLE(2, 3, 0, 1));  // [ C D | A B ]
    __m128 sums   = _mm_add_ps(v, shuf);      // sums = [ D+C C+D | B+A A+B ]
    shuf          = _mm_movehl_ps(shuf, sums);      //  [   C   D | D+C C+D ]  // let the compiler avoid a mov by reusing shuf
    sums          = _mm_add_ss(sums, shuf);
    return    _mm_cvtss_f32(sums);
}
    # gcc 5.3 -O3:  looks optimal
    movaps  xmm1, xmm0     # I think one movaps is unavoidable, unless we have a 2nd register with known-safe floats in the upper 2 elements
    shufps  xmm1, xmm0, 177
    addps   xmm0, xmm1
    movhlps xmm1, xmm0     # note the reuse of shuf, avoiding a movaps
    addss   xmm0, xmm1

    # clang 3.7.1 -O3:  
    movaps  xmm1, xmm0
    shufps  xmm1, xmm1, 177
    addps   xmm1, xmm0
    movaps  xmm0, xmm1
    shufpd  xmm0, xmm0, 1
    addss   xmm0, xmm1

Я сообщил о лягушке о пессимизации шаффлов . Он имеет свое внутреннее представление для тасования и превращает его обратно в тасования gcc чаще использует инструкции, которые непосредственно соответствуют встроенному вами.

Часто clang работает лучше, чем gcc, в коде, где выбор инструкций не настраивается вручную, или постоянное распространение может упростить вещи, даже если внутренние значения оптимальны для непостоянного случая. В целом, хорошо, что компиляторы работают как настоящий компилятор для встроенных функций, а не просто как ассемблер. Компиляторы часто могут генерировать хороший ассм из скалярного C, который даже не пытается работать так, как это делал бы хороший ассемблер. В конечном итоге компиляторы будут воспринимать встроенные функции как еще один оператор Си как входные данные для оптимизатора.


SSE3

float hsum_ps_sse3(__m128 v) {
    __m128 shuf = _mm_movehdup_ps(v);        // broadcast elements 3,1 to 2,0
    __m128 sums = _mm_add_ps(v, shuf);
    shuf        = _mm_movehl_ps(shuf, sums); // high half -> low half
    sums        = _mm_add_ss(sums, shuf);
    return        _mm_cvtss_f32(sums);
}

    # gcc 5.3 -O3: perfectly optimal code
    movshdup    xmm1, xmm0
    addps       xmm0, xmm1
    movhlps     xmm1, xmm0
    addss       xmm0, xmm1

Это имеет несколько преимуществ:

  • не требуется копий movaps для работы с деструктивными шаффлами (без AVX): пункт назначения movshdup xmm1, xmm2 только для записи, поэтому он создает tmp из мертвого регистра для нас. По этой же причине я использовал movehl_ps(tmp, sums) вместо movehl_ps(sums, sums).

  • маленький размер кода. Инструкции перетасовки малы: movhlps - 3 байта, movshdup - 4 байта (аналогично shufps). Прямой байт не требуется, поэтому в AVX vshufps равен 5 байтов, но vmovhlps и vmovshdup равны 4.

Я мог бы сохранить другой байт с помощью addps вместо addss. Поскольку это не будет использоваться во внутренних контурах, дополнительная энергия для переключения дополнительных транзисторов, вероятно, незначительна. Исключения FP из верхних 3 элементов не являются риском, потому что все элементы содержат действительные данные FP. Однако clang / LLVM на самом деле «понимает» тасования векторов и генерирует лучший код, если знает, что важен только младший элемент.

Как и в версии SSE1, добавление нечетных элементов к самим себе может вызвать исключения FP (например, переполнение), которые не произошли бы в противном случае, но это не должно быть проблемой. Денормалы медленные, но IIRC, дающий результат + Inf, не на большинстве уарчей.


SSE3, оптимизирующий под размер кода

Если основной проблемой является размер кода, две инструкции haddps (_mm_hadd_ps) помогут вам (ответ Пола Р.). Это также самый простой для ввода и запоминания. Это не быстро , хотя. Даже Intel Skylake по-прежнему декодирует каждый haddps до 3 мопов с задержкой в ​​6 циклов. Таким образом, несмотря на то, что он сохраняет байты машинного кода (I-кэш L1), он занимает больше места в более ценном uop-кэше. Реальные сценарии использования для haddps: проблемы транспонирования и суммирования или выполнения некоторого масштабирования на промежуточном этапе в этой реализации SSE atoi() .


AVX:

Эта версия сохраняет байт кода против Ответ Марата на вопрос AVX .

#ifdef __AVX__
float hsum256_ps_avx(__m256 v) {
    __m128 vlow  = _mm256_castps256_ps128(v);
    __m128 vhigh = _mm256_extractf128_ps(v, 1); // high 128
           vlow  = _mm_add_ps(vlow, vhigh);     // add the low 128
    return hsum_ps_sse3(vlow);         // and inline the sse3 version, which is optimal for AVX
    // (no wasted instructions, and all of them are the 4B minimum)
}
#endif

 vmovaps xmm1,xmm0               # huh, what the heck gcc?  Just extract to xmm1
 vextractf128 xmm0,ymm0,0x1
 vaddps xmm0,xmm1,xmm0
 vmovshdup xmm1,xmm0
 vaddps xmm0,xmm1,xmm0
 vmovhlps xmm1,xmm1,xmm0
 vaddss xmm0,xmm0,xmm1
 vzeroupper 
 ret

Двойная точность:

double hsum_pd_sse2(__m128d vd) {                      // v = [ B | A ]
    __m128 undef  = _mm_undefined_ps();                       // don't worry, we only use addSD, never touching the garbage bits with an FP add
    __m128 shuftmp= _mm_movehl_ps(undef, _mm_castpd_ps(vd));  // there is no movhlpd
    __m128d shuf  = _mm_castps_pd(shuftmp);
    return  _mm_cvtsd_f64(_mm_add_sd(vd, shuf));
}

# gcc 5.3.0 -O3
    pxor    xmm1, xmm1          # hopefully when inlined, gcc could pick a register it knew wouldn't cause a false dep problem, and avoid the zeroing
    movhlps xmm1, xmm0
    addsd   xmm0, xmm1


# clang 3.7.1 -O3 again doesn't use movhlps:
    xorpd   xmm2, xmm2          # with  #define _mm_undefined_ps _mm_setzero_ps
    movapd  xmm1, xmm0
    unpckhpd        xmm1, xmm2
    addsd   xmm1, xmm0
    movapd  xmm0, xmm1    # another clang bug: wrong choice of operand order


// This doesn't compile the way it's written
double hsum_pd_scalar_sse2(__m128d vd) {
    double tmp;
    _mm_storeh_pd(&tmp, vd);       // store the high half
    double lo = _mm_cvtsd_f64(vd); // cast the low half
    return lo+tmp;
}

    # gcc 5.3 -O3
    haddpd  xmm0, xmm0   # Lower latency but less throughput than storing to memory

    # ICC13
    movhpd    QWORD PTR [-8+rsp], xmm0    # only needs the store port, not the shuffle unit
    addsd     xmm0, QWORD PTR [-8+rsp]

Хранениев память и обратно избегает ALU UOP.Это хорошо, если давление порта перетасовки или ALU Uops в целом являются узким местом.(Обратите внимание, что ему не нужно sub rsp, 8 или что-либо еще, потому что x86-64 SysV ABI предоставляет красную зону, на которую обработчики сигналов не будут наступать.)

Некоторые люди сохраняют в массив и суммируютвсе элементы, но компиляторы обычно не понимают, что младший элемент массива все еще находится в регистре перед хранилищем.


Integer:

pshufd isудобное копирование и перемешивание.К сожалению, сдвиги битов и байтов на месте, и punpckhqdq помещает верхнюю половину получателя в нижнюю половину результата, в отличие от movhlps, который может извлечь верхнюю половину в другой регистр.

Использование movhlps для первого шага может быть полезно на некоторых процессорах, но только если у нас есть чистая рег.pshufd - это безопасный выбор, быстрый после всего, что есть после Merom.

int hsum_epi32_sse2(__m128i x) {
#ifdef __AVX__
    __m128i hi64  = _mm_unpackhi_epi64(x, x);           // 3-operand non-destructive AVX lets us save a byte without needing a mov
#else
    __m128i hi64  = _mm_shuffle_epi32(x, _MM_SHUFFLE(1, 0, 3, 2));
#endif
    __m128i sum64 = _mm_add_epi32(hi64, x);
    __m128i hi32  = _mm_shufflelo_epi16(sum64, _MM_SHUFFLE(1, 0, 3, 2));    // Swap the low two elements
    __m128i sum32 = _mm_add_epi32(sum64, hi32);
    return _mm_cvtsi128_si32(sum32);       // SSE2 movd
    //return _mm_extract_epi32(hl, 0);     // SSE4, even though it compiles to movd instead of a literal pextrd r32,xmm,0
}

    # gcc 5.3 -O3
    pshufd xmm1,xmm0,0x4e
    paddd  xmm0,xmm1
    pshuflw xmm1,xmm0,0x4e
    paddd  xmm0,xmm1
    movd   eax,xmm0

int hsum_epi32_ssse3_slow_smallcode(__m128i x){
    x = _mm_hadd_epi32(x, x);
    x = _mm_hadd_epi32(x, x);
    return _mm_cvtsi128_si32(x);
}

На некоторых процессорах безопасно использовать FP-тасовки для целочисленных данных.Я этого не делал, поскольку на современных процессорах, которые максимально сохраняют 1 или 2 байта кода, без увеличения скорости (кроме размера кода / эффектов выравнивания).

18 голосов
/ 09 января 2012

SSE2

Все четыре:

const __m128 t = _mm_add_ps(v, _mm_movehl_ps(v, v));
const __m128 sum = _mm_add_ss(t, _mm_shuffle_ps(t, t, 1));

r1 + r2 + r3:

const __m128 t1 = _mm_movehl_ps(v, v);
const __m128 t2 = _mm_add_ps(v, t1);
const __m128 sum = _mm_add_ss(t1, _mm_shuffle_ps(t2, t2, 1));

Я обнаружил, что их скорость примерно равна двойной HADDPS (но я не слишком точно измерял).

10 голосов
/ 09 августа 2011

Вы можете сделать это двумя HADDPS инструкциями в SSE3:

v = _mm_hadd_ps(v, v);
v = _mm_hadd_ps(v, v);

Это помещает сумму во все элементы.

3 голосов
/ 10 августа 2011

Я бы определенно попробовал SSE 4.2. Если вы делаете это несколько раз (я предполагаю, что это так, если производительность является проблемой), вы можете предварительно загрузить регистр с помощью (1,1,1,1), а затем сделать несколько точек 4 (my_vec (s), one_vec) в теме. Да, он излишне умножается, но в наши дни это довольно дешево, и в такой операции, вероятно, будут преобладать горизонтальные зависимости, которые могут быть более оптимизированы в новой функции точечного продукта SSE. Вы должны проверить, чтобы увидеть, превосходит ли он двойное горизонтальное добавление Paul R.

Я также предлагаю сравнить его с прямым скалярным (или скалярным SSE) кодом - как ни странно, он часто быстрее (обычно потому, что внутренне он сериализован, но плотно конвейеризован с использованием обхода регистра, где специальные горизонтальные инструкции могут быть не быстро скорректированы (пока) ) если вы не используете SIMT-подобный код, который звучит так, как будто вы этого не делаете (в противном случае вы бы сделали четыре продукта с точками).

...