Как эффективно конкатенировать два вектора с помощью AVX2? (перекрестная версия VPALIGNR)

Я реализовал встроенную функцию (_mm256_concat_epi16). Он объединяет два вектора AVX2, содержащих 16-битные значения. Он отлично работает для первых 8 номеров. Если я хочу использовать его для остальной части вектора, я должен изменить реализацию. Но лучше было бы использовать одну встроенную функцию в моей основной программе.

Вопрос: Есть ли лучшее решение, чем мое или любое предложение, чтобы сделать эту встроенную функцию более общей, которая работает с 16 значениями вместо моего решения, которое работает на 8 значений? Мое решение объединяет 2 вектора, но разрешено только 8 состояний из 16 возможных состояний.

** EDIT * Мое текущее решение для этого вопроса - использование функции выравнивания нагрузки, которая точно может считываться из любой части из памяти. Но, когда данные готовы в регистре, лучше использовать их повторно. Тем не менее, это может вызвать узкие места на порту 5, которые вызывают перетасовку, перестановку и т.д. Но пропускной способности может быть достаточно (еще не проверили).

#include <stdio.h>
#include <x86intrin.h>

inline _mm256_print_epi16(__m256i a, char* name){
    short temp[16], i;
    _mm256_storeu_si256((__m256i *) &temp[0], a);
    for(i=0; i<16; i++)
        printf("%s[%d]=%4d , ",name,i+1,temp[i]);
    printf("\n");
}

inline __m256i _mm256_concat_epi16(__m256i a, __m256i  b, const int indx){
    return _mm256_alignr_epi8(_mm256_permute2x128_si256(a,b,0x21),a,indx*2);
}

int main()
{
    __m256i a = _mm256_setr_epi16(101,102,103,104,105,106,107,108,109,1010,1011,1012,1013,1014,1015,1016);_mm256_print_epi16(a, "a");
    __m256i b = _mm256_setr_epi16(201,202,203,204,205,206,207,208,209,2010,2011,2012,2013,2014,2015,2016);_mm256_print_epi16(b, "b");

    _mm256_print_epi16(_mm256_concat_epi16(a,b,8), "c");//numbers: 0-8
    return 0;
}

Вывод:

// icc  -march=native -O3 -D _GNU_SOURCE -o "concat" "concat.c"
[[email protected] concatination]$ "./concat"
a[1]= 101 , a[2]= 102 , a[3]= 103 , a[4]= 104 , a[5]= 105 , a[6]= 106 , a[7]= 107 , a[8]= 108 , a[9]= 109 , a[10]=1010 , a[11]=1011 , a[12]=1012 , a[13]=1013 , a[14]=1014 , a[15]=1015 , a[16]=1016 , 
b[1]= 201 , b[2]= 202 , b[3]= 203 , b[4]= 204 , b[5]= 205 , b[6]= 206 , b[7]= 207 , b[8]= 208 , b[9]= 209 , b[10]=2010 , b[11]=2011 , b[12]=2012 , b[13]=2013 , b[14]=2014 , b[15]=2015 , b[16]=2016 , 
c[1]= 109 , c[2]=1010 , c[3]=1011 , c[4]=1012 , c[5]=1013 , c[6]=1014 , c[7]=1015 , c[8]=1016 , c[9]= 201 , c[10]= 202 , c[11]= 203 , c[12]= 204 , c[13]= 205 , c[14]= 206 , c[15]= 207 , c[16]= 208 , 

Ответ 1

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

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


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

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


Как отмечает Стивен Канон _mm_alignr_epi8 (PALIGNR) эквивалент в AVX2 (возможный дубликат этого), если вам нужно несколько разных окон смещения в одну конкатенацию двух векторов, тогда два магазина + повторные не согласованные нагрузки отличные. На процессорах Intel вы получаете пропускную способность в два с половиной часа для 256-битных невыровненных нагрузок, если они не пересекают границу линии кэша (так что alignas(64) ваш буфер).

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

Если латентность является проблемой, использование ALU shuffles может быть хорошим (особенно на Intel, где переходы с переходом через полосы не намного дороже, чем на полосе). Опять же, подумайте/определите, какие узкие места в вашей петле, или просто попробуйте сохранить/перезагрузить против ALU.


Стратегия тасования:

Ваша текущая функция может компилироваться только в том случае, если indx известно во время компиляции (потому что palignr требуется счетчик байтов как немедленный).

Как @Mohammad предложил, вы могли бы выбирать из разных тасований во время компиляции, в зависимости от значения indx. Кажется, он предлагал макрос CPP, но это было бы уродливо.

Намного проще просто использовать if(indx>=16) или что-то в этом роде, которое будет оптимизировано. (Вы можете сделать indx параметр шаблона, если компилятор отказался скомпилировать ваш код с видимым "переменным" числом сдвигов.) Agner Fog использует это в своем Vector Class Library (лицензия = GPL) для таких функций, как template <uint32_t d> static inline Vec8ui divide_by_ui(Vec8ui const & x).

Связано: Эмуляция сдвигов на 32 байта с AVX имеет ответ с различными стратегиями тасования в зависимости от количества сдвигов. Но он только пытается подражать сдвигу, а не конкат/переулок palignr.

vperm2i128 быстро работает на основных процессорах Intel (но все-таки перетаскивает переходы с задержкой в ​​3 с), но медленнее на Ryzen (8 часов с пропускной способностью 3 с /3 с). Если вы настроились на Ryzen, вам нужно использовать if() для определения комбинации vextracti128, чтобы получить высокую полосу и/или vinserti128 на низкой полосе. Вы также можете использовать отдельные сдвиги, а затем vpblendd результаты вместе.


Создание правильных перетасовки:

indx определяет, откуда должны появиться новые байты для каждой полосы. Пусть упрощается, рассматривая 64-битные элементы:

 hi |  lo
D C | B A    # a
H G | F E    # b

palignr(b,a i) forms (H G D C) >> i | (F E B A) >> i
But what we want is

D C | B A    # concatq(b,a,0): no-op.  return a;

E D | C B    # concatq(b,a,1):  applies to 16-bit element counts from 1..7
          low lane needs  hi(a).lo(a)
          high lane needs lo(b).hi(a)
        return palignr(swapmerge(a,b), a, 2*i).  (Where we use vperm2i128 to lane-swap+merge hi(a) and lo(b))
F E | D C    # concatq(b,a,2)
        special case of exactly half reg width: Just use vperm2i128.
        Or on Ryzen, `vextracti128` + `vinserti128`
G F | E D    # concatq(b,a,3): applies to 16-bit element counts from 9..15
        low  lane needs lo(b).hi(a)
        high lane needs hi(b).lo(b).  vperm2i128 -> palignr looks good
        return palignr(b, swapmerge(a,b), 2*i-16).

H G | F E    # concatq(b,a,4): no op: return b;

Интересно, что lo(b) | hi(a) используется в обоих случаях palignr. Мы никогда не нуждаемся в lo(a) | hi(b) в качестве входа palignr.

Эти заметки по дизайну приводят непосредственно к этой реализации:

// UNTESTED
// clang refuses to compile this, but gcc works.

// in many cases won't be faster than simply using unaligned loads.
static inline __m256i lanecrossing_alignr_epi16(__m256i a, __m256i  b, unsigned int count) {
#endif
   if (count == 0)
     return a;
   else if (count <= 7)
     return _mm256_alignr_epi8(_mm256_permute2x128_si256(a,b,0x21),a,count*2);
   else if (count == 8)
      return _mm256_permute2x128_si256(a,b,0x21);
   else if (count > 8 && count <= 15)
     // clang chokes on the negative shift count even when this branch is not taken
     return _mm256_alignr_epi8(b,_mm256_permute2x128_si256(a,b,0x21),count*2 - 16);
   else if (count == 16)
     return b;
   else
     assert(0 && "out-of-bounds shift count");

// can't get this to work without C++ constexpr :/
//   else
//     static_assert(count <= 16, "out-of-bounds shift count");
}

Я разместил в проводнике компилятора Godbolt с некоторыми функциями тестирования, которые встроили его с разными значениями постоянного сдвига. gcc6.3 компилирует его в

test_alignr0:
    ret            # a was already in ymm0
test_alignr3:
    vperm2i128      ymm1, ymm0, ymm1, 33   # replaces b
    vpalignr        ymm0, ymm1, ymm0, 6
    ret
test_alignr8:
    vperm2i128      ymm0, ymm0, ymm1, 33
    ret
test_alignr11:
    vperm2i128      ymm0, ymm0, ymm1, 33   # replaces a
    vpalignr        ymm0, ymm1, ymm0, 6
    ret
test_alignr16:
    vmovdqa ymm0, ymm1
    ret

clang дроссели на нем. Во-первых, он говорит error: argument should be a value from 0 to 255 для count*2 - 16 для подсчетов, которые не используют эту ветвь цепочки if/else.

Кроме того, он не может ждать и видеть, что счетчик alignr() заканчивается константой времени компиляции: error: argument to '__builtin_ia32_palignr256' must be a constant integer, даже когда он находится после вложения. Вы можете решить это на С++, сделав count параметр шаблона:

template<unsigned int count>
static inline __m256i lanecrossing_alignr_epi16(__m256i a, __m256i  b) {
   static_assert(count<=16, "out-of-bounds shift count");
   ...

В C вы можете сделать его макросом CPP вместо функции, чтобы справиться с этим.

Проблема count*2 - 16 сложнее решить для clang. Вы можете сделать часть подсчета сдвига имени макроса, например CONCAT256_EPI16_7. Вероятно, есть некоторые хитрости CPP, которые вы могли бы использовать для версий 1..7 и версий 9..15. (У Boost есть безумные взломы CPP.)


Кстати, ваша функция печати странная. Он вызывает первый элемент c[1] вместо c[0]. Векторные индексы начинаются с 0 для перетасовки, так что это действительно запутывает.