В какой ситуации инструкции по сборке AVX2 будут быстрее, чем индивидуальная загрузка данных?

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

void vectortest(double * a,double * b,unsigned int * ind,unsigned int N)
{
    int i;
    for(i=0;i<N;++i)
    {
        a[i]+=b[ind[i]];
    }
}

Я скомпилирую эту функцию с g++ -O3 -march = native. Теперь я реализую это в сборке тремя способами. Для простоты я предполагаю, что длина массивов N делится на четыре. Простая, не-векторизация:

align 4
global vectortest_asm
vectortest_asm:
        ;;  double * a = rdi                                                                                                                                                                                                                                   
        ;;  double * b = rsi                                                                                                                                                                                                                                   
        ;;  unsigned int * ind = rdx                                                                                                                                                                                                                           
        ;;  unsigned int N = rcx                                                                                                                                                                                                                               

        push rax
        xor rax,rax

loop:   sub rcx, 1
        mov eax, [rdx+rcx*4]    ;eax = ind[rcx]                                                                                                                                                                                                                
        vmovq xmm0, [rdi+rcx*8]         ;xmm0 = a[rcx]                                                                                                                                                                                                         
        vaddsd xmm0, [rsi+rax*8]        ;xmm1 += b[rax] ( and b[rax] = b[eax] = b[ind[rcx]])                                                                                                                                                                   
        vmovq [rdi+rcx*8], xmm0
        cmp rcx, 0
        jne loop

        pop rax

        ret

Петля, запрограммированную без команды сбора:

loop:   sub rcx, 4

        mov eax,[rdx+rcx*4]    ;first load the values from array b to xmm1-xmm4
        vmovq xmm1,[rsi+rax*8]
        mov eax,[rdx+rcx*4+4]
        vmovq xmm2,[rsi+rax*8]

        mov eax,[rdx+rcx*4+8]
        vmovq xmm3,[rsi+rax*8]
        mov eax,[rdx+rcx*4+12]
        vmovq xmm4,[rsi+rax*8]

        vmovlhps xmm1,xmm2     ;now collect them all to ymm1
        vmovlhps xmm3,xmm4
        vinsertf128 ymm1,ymm1,xmm3,1

        vaddpd ymm1, ymm1, [rdi+rcx*8]
        vmovupd [rdi+rcx*8], ymm1

        cmp rcx, 0
        jne loop

И, наконец, реализация с использованием vgatherdpd:

loop:   sub rcx, 4               
        vmovdqu xmm2,[rdx+4*rcx]           ;load the offsets from array ind to xmm2
        vpcmpeqw ymm3,ymm3                 ;set ymm3 to all ones, since it acts as the mask in vgatherdpd                                                                                                                                                                 
        vgatherdpd ymm1,[rsi+8*xmm2],ymm3  ;now gather the elements from array b to ymm1

        vaddpd ymm1, ymm1, [rdi+rcx*8]
        vmovupd [rdi+rcx*8], ymm1

        cmp rcx, 0
        jne loop

Я сравниваю эти функции на машине с процессором Haswell (Xeon E3-1245 v3). Некоторые типичные результаты (раз в секундах):

Array length 100, function called 100000000 times.
Gcc version: 6.67439
Nonvectorized assembly implementation: 6.64713
Vectorized without gather: 4.88616
Vectorized with gather: 9.32949

Array length 1000, function called 10000000 times.
Gcc version: 5.48479
Nonvectorized assembly implementation: 5.56681
Vectorized without gather: 4.70103
Vectorized with gather: 8.94149

Array length 10000, function called 1000000 times.
Gcc version: 7.35433
Nonvectorized assembly implementation: 7.66528
Vectorized without gather: 7.92428
Vectorized with gather: 8.873

gcc и несекторизованная версия сборки очень близки друг к другу. (Я также проверил вывод сборки gcc, который очень похож на мою кодированную вручную версию.) Векторизация дает некоторую выгоду для небольших массивов, но медленнее для больших массивов. Большой неожиданностью (по крайней мере для меня) является то, что версия, использующая vgatherpdp, настолько медленная. Итак, мой вопрос: почему? Я делаю что-то глупое здесь? Может ли кто-нибудь представить пример, когда команда сбора фактически даст преимущество в производительности за выполнение нескольких операций погрузки? Если нет, в чем смысл фактически иметь такую ​​инструкцию?

Код проверки, в комплекте с файлом make для g++ и nasm, доступен в https://github.com/vanhala/vectortest.git, если кто-то захочет попробовать это.

Ответ 1

К сожалению, собранные команды загрузки не особенно "умны" - они, похоже, генерируют один цикл шины для каждого элемента, независимо от адресов загрузки, поэтому, даже если у вас есть смежные элементы, по-видимому, нет внутренней логики для объединения нагрузок, Таким образом, с точки зрения эффективности собранная нагрузка не лучше, чем N скалярных нагрузок, за исключением того, что она использует только одну инструкцию.

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

Ответ 2

Более новые микроархитектуры изменили шансы на сбор инструкций. На процессоре Intel Xeon Gold 6138 с тактовой частотой 2,00 ГГц и микроархитектурой Skylake мы получаем следующие результаты:

9.383e+09 8.86e+08 2.777e+09 6.915e+09 7.793e+09 8.335e+09 5.386e+09 4.92e+08 6.649e+09 1.421e+09 2.362e+09 2.7e+07 8.69e+09 5.9e+07 7.763e+09 3.926e+09 5.4e+08 3.426e+09 9.172e+09 5.736e+09 
9.383e+09 8.86e+08 2.777e+09 6.915e+09 7.793e+09 8.335e+09 5.386e+09 4.92e+08 6.649e+09 1.421e+09 2.362e+09 2.7e+07 8.69e+09 5.9e+07 7.763e+09 3.926e+09 5.4e+08 3.426e+09 9.172e+09 5.736e+09 
9.383e+09 8.86e+08 2.777e+09 6.915e+09 7.793e+09 8.335e+09 5.386e+09 4.92e+08 6.649e+09 1.421e+09 2.362e+09 2.7e+07 8.69e+09 5.9e+07 7.763e+09 3.926e+09 5.4e+08 3.426e+09 9.172e+09 5.736e+09 
9.383e+09 8.86e+08 2.777e+09 6.915e+09 7.793e+09 8.335e+09 5.386e+09 4.92e+08 6.649e+09 1.421e+09 2.362e+09 2.7e+07 8.69e+09 5.9e+07 7.763e+09 3.926e+09 5.4e+08 3.426e+09 9.172e+09 5.736e+09 
Array length 10000, function called 1000000 times.
Gcc version: 6.32353
Nonvectorized assembly implementation: 6.36922
Vectorized without gather: 5.53553
Vectorized with gather: 4.50673

показ того, что собрания теперь могут стоить усилий.