Векторизация непрямого доступа с помощью инструкций avx

Недавно я познакомился с векторными инструкциями (теоретически) и очень рад тому, как я могу использовать их для ускорения своих приложений.

Одна область, которую я хотел бы улучшить, — это очень горячий цикл:

__declspec(noinline) void pleaseVectorize(int* arr, int* someGlobalArray, int* output)
{
    for (int i = 0; i < 16; ++i)
    {
        auto someIndex = arr[i];
        output[i] = someGlobalArray[someIndex];
    }

    for (int i = 0; i < 16; ++i)
    {
         if (output[i] == 1)
         {
             return i;
         }
    }

    return -1;
}

Но, конечно, все 3 основных компилятора (msvc, gcc, clang) отказываются от векторизации этого. Я вроде как понимаю почему, но я хотел получить подтверждение.

Если бы мне пришлось векторизовать это вручную, это было бы:

(1) VectorLoad "arr", это вводит 16 4-байтовых целых чисел, скажем, в zmm0

(2) 16 загрузок памяти с адреса, на который указывает zmm0[0..3], в zmm1[0..3], загрузка с адреса, на который указывает zmm0[4..7], в zmm1[4..7], поэтому дальше и так далее

(3) сравнить zmm0 и zmm1

(4) вектор popcnt в вывод, чтобы узнать самый старший бит, и в основном разделить его на 8, чтобы получить совпадающий индекс

Во-первых, могут ли векторные инструкции делать такие вещи? Например, могут ли они выполнить эту операцию «сбора», то есть выполнить загрузку с адреса, указывающего на zmm0?

Вот что генерирует clang:

0000000000400530 <_Z5superPiS_S_>:
  400530:       48 63 07                movslq (%rdi),%rax
  400533:       8b 04 86                mov    (%rsi,%rax,4),%eax
  400536:       89 02                   mov    %eax,(%rdx)
  400538:       48 63 47 04             movslq 0x4(%rdi),%rax
  40053c:       8b 04 86                mov    (%rsi,%rax,4),%eax
  40053f:       89 42 04                mov    %eax,0x4(%rdx)
  400542:       48 63 47 08             movslq 0x8(%rdi),%rax
  400546:       8b 04 86                mov    (%rsi,%rax,4),%eax
  400549:       89 42 08                mov    %eax,0x8(%rdx)
  40054c:       48 63 47 0c             movslq 0xc(%rdi),%rax
  400550:       8b 04 86                mov    (%rsi,%rax,4),%eax
  400553:       89 42 0c                mov    %eax,0xc(%rdx)
  400556:       48 63 47 10             movslq 0x10(%rdi),%rax
  40055a:       8b 04 86                mov    (%rsi,%rax,4),%eax
  40055d:       89 42 10                mov    %eax,0x10(%rdx)
  400560:       48 63 47 14             movslq 0x14(%rdi),%rax
  400564:       8b 04 86                mov    (%rsi,%rax,4),%eax
  400567:       89 42 14                mov    %eax,0x14(%rdx)
  40056a:       48 63 47 18             movslq 0x18(%rdi),%rax
  40056e:       8b 04 86                mov    (%rsi,%rax,4),%eax
  400571:       89 42 18                mov    %eax,0x18(%rdx)
  400574:       48 63 47 1c             movslq 0x1c(%rdi),%rax
  400578:       8b 04 86                mov    (%rsi,%rax,4),%eax
  40057b:       89 42 1c                mov    %eax,0x1c(%rdx)
  40057e:       48 63 47 20             movslq 0x20(%rdi),%rax
  400582:       8b 04 86                mov    (%rsi,%rax,4),%eax
  400585:       89 42 20                mov    %eax,0x20(%rdx)
  400588:       48 63 47 24             movslq 0x24(%rdi),%rax
  40058c:       8b 04 86                mov    (%rsi,%rax,4),%eax
  40058f:       89 42 24                mov    %eax,0x24(%rdx)
  400592:       48 63 47 28             movslq 0x28(%rdi),%rax
  400596:       8b 04 86                mov    (%rsi,%rax,4),%eax
  400599:       89 42 28                mov    %eax,0x28(%rdx)
  40059c:       48 63 47 2c             movslq 0x2c(%rdi),%rax
  4005a0:       8b 04 86                mov    (%rsi,%rax,4),%eax
  4005a3:       89 42 2c                mov    %eax,0x2c(%rdx)
  4005a6:       48 63 47 30             movslq 0x30(%rdi),%rax
  4005aa:       8b 04 86                mov    (%rsi,%rax,4),%eax
  4005ad:       89 42 30                mov    %eax,0x30(%rdx)
  4005b0:       48 63 47 34             movslq 0x34(%rdi),%rax
  4005b4:       8b 04 86                mov    (%rsi,%rax,4),%eax
  4005b7:       89 42 34                mov    %eax,0x34(%rdx)
  4005ba:       48 63 47 38             movslq 0x38(%rdi),%rax
  4005be:       8b 04 86                mov    (%rsi,%rax,4),%eax
  4005c1:       89 42 38                mov    %eax,0x38(%rdx)
  4005c4:       48 63 47 3c             movslq 0x3c(%rdi),%rax
  4005c8:       8b 04 86                mov    (%rsi,%rax,4),%eax
  4005cb:       89 42 3c                mov    %eax,0x3c(%rdx)
  4005ce:       c3                      retq
  4005cf:       90                      nop

person halivingston    schedule 22.06.2018    source источник


Ответы (1)


Ваше представление о том, как это могло бы работать, близко, за исключением того, что вам нужен bit-scan / find-first-set -бит (x86 BSF или TZCNT) растрового изображения сравнения, а не заполнение- count (количество установленных битов).

AVX2/AVX512 имеет vpgatherdd, который использует вектор подписанных 32-битных масштабированных индексов. Его едва ли стоит использовать на Haswell, он улучшен на Broadwell и очень хорош на Skylake. (http://agner.org/optimize/ и см. другие ссылки в вики тегов x86, например, руководство по оптимизации Intel, в котором есть раздел, посвященный сбору производительности). Сравнение SIMD и битсканирование очень дешевы по сравнению с ними; единый мооп и полностью конвейерный.


gcc8.1 может автоматически векторизовать вашу сборку, если она может доказать, что ваши входные данные не перекрываются вашим аргументом output функции. Иногда возможно после встраивания, но для не встраиваемой версии можно пообещать это с помощью int * __restrict output. Или если вы сделаете output локальным временным вместо функции arg. (Общее правило: сохранение с помощью указателя, отличного от _restrict, часто будет препятствовать автовекторизации, особенно если это char*, который может псевдонимить что угодно.)

gcc и clang никогда не векторизуют циклы поиска; только петли, для которых можно рассчитать количество поездок перед входом в петлю. Но ICC может; он выполняет скалярную сборку и сохраняет результат (даже если output[] является локальным, поэтому он не должен делать это как побочный эффект запуска функции), а затем использует SIMD-сравнение + битовое сканирование.

Если output[] является локальным, лучшей стратегией генерации кода, вероятно, будет сравнение во время сбора, поэтому раннее попадание пропускает остальные загрузки. Компиляторы, которые работают полностью скалярно (clang и MSVC), пропускают эту оптимизацию. На самом деле, они даже сохраняют в локальный массив, хотя clang в основном не перечитывает его (сохраняя результаты в регистрах). Написание исходного кода со сравнением внутри первого цикла помогло бы получить лучший скалярный код. (В зависимости от количества промахов в кеше при сборе по сравнению с неправильными предсказаниями ветвления при поиске без SIMD, скалярная стратегия может быть хорошей стратегией. Особенно, если попадание в первые несколько элементов является та же строка кеша, поэтому жесткое ограничение по-прежнему составляет 2 элемента, загружаемых за такт.Но использование широкой векторной загрузки для индексов для подачи сбора значительно снижает нагрузку на порт загрузки / доступ к кешу, если ваши данные в основном были горячими в кеше.)

Компилятор мог автоматически векторизовать __restrict версию вашего кода примерно так. (gcc управляет частью сбора, ICC управляет частью сравнения SIMD)

Вы можете сделать это самостоятельно с помощью встроенных функций:

;; Windows x64 calling convention: rcx,rdx, r8,r9
; but of course you'd actually inline this
; only uses ZMM16..31, so vzeroupper not required

vmovdqu32   zmm16, [rcx/arr]   ; You def. want to reach an alignment boundary if you can for ZMM loads, vmovdqa32 will enforce that

kxnorw      k1, k0,k0      ; k1 = -1.  k0 false dep is likely not a problem.
  ; optional: vpxord  xmm17, xmm17, xmm17   ; break merge-masking false dep
vpgatherdd  zmm17{k1}, [rdx + zmm16 * 4]    ; GlobalArray + scaled-vector-index
; sets k1 = 0 when done

vmovdqu32   [r8/output], zmm17

vpcmpd      k1, zmm17, zmm31, 0    ; 0->EQ.  Outside the loop, do zmm31=set1_epi32(1)
                                   ; k1 = compare bitmap
kortestw    k1, k1
jz         .not_found      ; early check for not-found

kmovw       edx, k1

           ; tzcnt doesn't have a false dep on the output on Skylake
           ; so no AVX512 CPUs need to worry about that HSW/BDW issue
tzcnt       eax, edx       ; bit-scan for the first (lowest-address) set element
                           ; input=0 produces output=32
      ; or avoid the branch and let 32 be the not-found return value.
      ; or do a branchless kortestw / cmov if -1 is directly useful without branching
ret

.not_found:
   mov eax, -1
   ret

Справочное руководство Intel с набором инструкций (извлечение HTML по адресу http://felixcloutier.com/x86/index.html) содержит собственные имена C/C++ для каждой инструкции или найдите их в https://software.intel.com/sites/landingpage/IntrinsicsGuide/

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

Все 4 x86 компиляторов производят аналогичный ассемблер на то, что я предложил (см. в Проводнике компилятора Godbolt), но, конечно, они должны фактически материализовать set1_epi32(1) векторную константу Clang фактически использует широковещательную нагрузку {1to16} из константы для сравнения: vpcmpeqd k0, zmm1, dword ptr [rip + .LCPI0_0]{1to16}. (Конечно, если они встроены в цикл, они сделают другой выбор.) Другие используют mov eax,1 / vpbroadcastd zmm0, eax.

#include <immintrin.h>

//__declspec(noinline)  // I *hope* this was just to see the stand-alone asm version
                        // but it means the output array can't optimize away at all

//static inline
int find_first_1(const int *__restrict arr, const int *__restrict someGlobalArray, __m512i *__restrict output)
{
    __m512i vindex = _mm512_load_si512(arr);
    __m512i gather = _mm512_i32gather_epi32(vindex, someGlobalArray, 4);  // indexing by 4-byte int
    *output = gather;  

    __mmask16 cmp = _mm512_cmpeq_epi32_mask(gather, _mm512_set1_epi32(1));
       // Intrinsics make masks freely convert to integer
       // even though it costs a `kmov` instruction either way.
    int onepos =  _tzcnt_u32(cmp);
    if (onepos >= 16){
        return -1;
    }
    return onepos;
}

gcc8.1 -O3 -march=skylake-avx512 имеет две избыточные инструкции mov eax, -1: одна для передачи kmov для сбора, другая для материала возвращаемого значения. Глупый компилятор должен оставить его и использовать другой регистр для 1.

Все они используют zmm0..15 и поэтому не могут избежать vzeroupper. (xmm16.31 недоступен с устаревшим SSE, поэтому on-skylake">проблема штрафа за переход SSE/AVX, которую решает vzeroupper, не существует, если единственными широкими векторными регистрами, которые вы используете, являются y/zmm16..31). У vzeroupper все еще могут быть крошечные возможные преимущества, такие как более дешевые переключатели контекста, когда известно, что верхние половины регистров ymm или zmm равны нулю (Полезно ли использовать VZEROUPPER, если ваша программа+библиотеки не содержат инструкций SSE?). Если вы все равно собираетесь его использовать, нет причин избегать xmm0..15.

О, и в соглашении о вызовах Windows xmm6..15 сохраняются при вызове. (Не ymm/zmm, а только младшие 128 бит), поэтому zmm16..31 — хороший выбор, если у вас закончились регистры xmm0..5.

Вау, спасибо! Я понятия не имею о 90% синтаксиса или о том, как начать, но я очень хочу понять это.

person Peter Cordes    schedule 22.06.2018
comment
Самое интересное будет сегодня вечером провести бенчмарк! - person halivingston; 22.06.2018
comment
@halivingston: Будет ли этот ответ более полезным для вас, если я переведу его на встроенные функции, такие как _1_ вместо asm? Вы показали вывод clang asm, поэтому я предположил, что вам будет удобно переводить asm на встроенные функции C++. (Возможно, это плохое предположение, если вы не знали, что в x86 есть команда сборки, упс :P). спрашивать. - person halivingston; 22.06.2018
comment
Я понимаю концепции, но мне нужно будет разобраться с синтаксисом. Однако, если вы сможете обновить этот ответ с помощью встроенных функций, я буду признателен за экономию времени! Вы направили меня на правильный курс, поэтому я принял ваш ответ независимо от того, обновите вы его или нет. - person Peter Cordes; 22.06.2018
comment
@halivingston: я написал встроенную версию, отчасти потому, что мне было любопытно, как именно она будет компилироваться с помощью 4 различных основных компиляторов x86. - person halivingston; 22.06.2018
comment
Благодаря тонну! Я скопирую вставку в MSVC и проверю его. Мое намерение состоит в том, чтобы держать его в горячем цикле по одним и тем же данным, чтобы я не получал время кеша и т. Д. - person Peter Cordes; 22.06.2018
comment
У меня другой вопрос..почему не использовали zmm16...так не надо было делать взероуппер? - person halivingston; 22.06.2018
comment
@halivingston: xmm16.31 недоступен с устаревшим SSE, поэтому проблема штрафа за переход SSE/AVX, которую решает _1_, не существует для этих регистров. Вероятно, разработчики компилятора не подумали об этой оптимизации. Я заметил это в написанном от руки комментарии или журнале изменений x265. Если вы когда-либо использовали подрегистры xmm или ymm, более короткая кодировка VEX хороша, но вы не можете использовать ее с xmm16..31. - person halivingston; 22.06.2018
comment
Меня не волнует устаревший SSE, я буду запускать его только на Skylake. а я вот думаю tzcnt хочет упасть в режим sse? find_first_1, вероятно, будет вызываться сотни раз, так что я думаю, что эта версия, которую генерирует компилятор, лучше? Извините, если я не имею никакого смысла, но мне интересно, будет ли больше производительности, если я напишу этот код от руки, как вы написали. - person Peter Cordes; 22.06.2018
comment
@халивингстон. _1_ — скалярная целочисленная инструкция; он не записывает регистр xmm, оставляя верхние дорожки без изменений; это делают только устаревшие инструкции SSE. Вы определенно хотите, чтобы ваш компилятор встраивал эту функцию, поэтому накладные расходы на вызов исчезают, и ему не нужно каждый раз _2_ + перезагружать векторные константы. _3_ выйдет из цикла после встраивания, поэтому на самом деле это не важно, я просто болтал об этом, потому что мы говорили о том, что компилятор должен будет сделать для не встроенной версии этого (который никогда не должен работать в реальной жизни .) - person halivingston; 22.06.2018
comment
@halivingston: Меня не волнует устаревший SSE, я буду запускать его только на Skylake. Вы перекомпилировали tzcnt, чтобы использовать инструкции VEX только для vzeroupper, vzeroupper и т. д.? Как насчет функций libc, таких как _4_? Если нет, то ваша программа, вероятно, выполняет некоторые инструкции, закодированные не VEX legacy-SSE. К счастью, компиляторы позаботятся об этом за вас, а _5_ стоит дешево на Skylake (всего 4 мопса), так что не беспокойтесь об этом. - person Peter Cordes; 22.06.2018
comment
Благодарю вас! Я проверял этот пост, и в нем есть что-то похожее, что мы делаем здесь. блоги .msdn.microsoft.com/vcblog/2017/07/11/ Векторные инструкции увлекательны, но я понял, что в своей области, которая является обычной разработкой приложений, я могу использовать их только в сравнениях (например, строки), массивы и мемкопий. Существуют ли другие логические конструкции, с которыми помогает векторизация? - person Peter Cordes; 22.06.2018
comment
@halivingston: один из моих любимых примеров того, для чего вы не ожидаете, что SIMD будет использоваться, — это самый быстрый способ получить IPv4-адрес из строки и Как реализовать atoi с помощью SIMD?. упакованное-сравните растровое изображение -> таблица поиска масок в случайном порядке — ловкий трюк. Вы можете использовать SIMD для левой упаковки (т.е. фильтрации) массива (для этого особенно хорош AVX512): AVX2, что является наиболее эффективным способом упаковки слева на основе маски? - person halivingston; 22.06.2018
comment
@halivingston: вы спрашивали ранее о рукописном asm: оставьте это компилятору, если вы не можете регулярно просматривать вывод компилятора и быть уверенным, что можете добиться большего успеха, основываясь на знании деталей микроархитектуры интересующих вас процессоров. (См. Почему этот код C++ быстрее моей рукописной сборки для проверки гипотезы Коллатца? пример новичка, пишущего asm, который медленнее чем режим отладки _1_, и ответ, показывающий случай, когда вы можете превзойти код _2_, если знаете, что делаете.) - person Peter Cordes; 22.06.2018
comment
Вывод компилятора для версии _8_. Обратите внимание, что gcc8.1 и ICC по умолчанию избегают 512-битных векторов при настройке Skylake-AVX512. 512-битные векторы могут ограничивать max-turbo и всегда отключать векторное ALU на порту 1, пока они находятся в конвейере, поэтому имеет смысл использовать AVX512 или AVX2 с 256-битными векторами в случае, если эта функция только маленькая часть большой программы. (Компиляторы не знают, что эта функция очень популярна в вашей программе. ) - person Peter Cordes; 22.06.2018