AVX2, что является наиболее эффективным способом для упаковки влево на основе маски?
Если у вас есть входной массив и выходной массив, но вы хотите только написать те элементы, которые передают определенное условие, что было бы самым эффективным способом сделать это в AVX2?
Я видел в SSE, где это было сделано следующим образом:
(От: https://deplinenoise.files.wordpress.com/2015/03/gdc2015_afredriksson_simd.pdf)
__m128i LeftPack_SSSE3(__m128 mask, __m128 val)
{
// Move 4 sign bits of mask to 4-bit integer value.
int mask = _mm_movemask_ps(mask);
// Select shuffle control data
__m128i shuf_ctrl = _mm_load_si128(&shufmasks[mask]);
// Permute to move valid values to front of SIMD register
__m128i packed = _mm_shuffle_epi8(_mm_castps_si128(val), shuf_ctrl);
return packed;
}
Это кажется прекрасным для SSE, который имеет ширину 4 и, следовательно, ему нужен только 16-разрядный LUT, но для AVX, ширина которого 8, LUT становится довольно большим (256 записей, каждый 32 байта или 8 тыс.).
Я удивлен, что у AVX нет инструкции для упрощения этого процесса, например, в масках с упаковкой.
Я думаю, что, немного перепутав, чтобы подсчитать количество знаковых битов, установленных слева, вы можете сгенерировать необходимую таблицу перестановок, а затем вызвать _mm256_permutevar8x32_ps. Но это также немало инструкций, которые я думаю.
Кто-нибудь знает какие-либо трюки, чтобы сделать это с помощью AVX2? Или что является наиболее эффективным методом?
Вот иллюстрация проблемы левой упаковки из приведенного выше документа:
Спасибо
Ответы
Ответ 1
AVX2 + BMI2. Смотрите мой другой ответ для AVX512. (Обновление: pdep
сохранен в 64-битных сборках.)
Мы можем использовать AVX2 vpermps
(_mm256_permutevar8x32_ps
) (или целочисленный эквивалент, vpermd
), чтобы сделать vpermd
через полосу.
Мы можем генерировать маски на лету, поскольку BMI2 pext
(Parallel Bits Extract) предоставляет нам побитовую версию необходимой нам операции.
Помните, что pdep
/pext
очень медленно работают на процессорах AMD, например, задержка цикла 6 моп /18 и пропускная способность на Ryzen. Эта реализация будет ужасно работать на AMD. Для AMD вам лучше всего использовать 128-битные векторы, использующие LUT pshufb
или vpermilps
, или некоторые из предложенных в комментариях предложений по переменному смещению AVX2, если в качестве входной маски используется векторная маска (а не уже вычисленная битовая маска из памяти). AMD до Zen2 в любом случае имеет только 128-битные векторные исполнительные блоки, а 256-битные тасовки пересекают полосы медленно. Таким образом, 128-битные векторы очень привлекательны для нынешней AMD.
Для целочисленных векторов с 32-разрядными или более широкими элементами: 1) _mm256_movemask_ps(_mm256_castsi256_ps(compare_mask))
.
Или 2) используйте _mm256_movemask_epi8
а затем измените первую константу PDEP с 0x0101010101010101 на 0x0F0F0F0F0F0F0F0F для разброса блоков из 4 смежных битов. Измените умножение на 0xFFU на extended_mask expanded_mask |= expanded_mask<<4;
extended_mask expanded_mask |= expanded_mask<<4;
или expanded_mask *= 0x11;
(Не испытано). В любом случае, используйте маску тасования с VPERMD вместо VPERMPS.
Для 64-битных целых или double
элементов все по-прежнему просто работает; Просто в маске сравнения всегда есть пары одинаковых 32-битных элементов, поэтому итоговый случайный порядок помещает обе половины каждого 64-битного элемента в нужное место. (Таким образом, вы все еще используете VPERMPS или VPERMD, потому что VPERMPD и VPERMQ доступны только с операндами непосредственного управления.)
Для 16-битных элементов вы можете адаптировать это с 128-битными векторами.
Алгоритм:
Начните с константы упакованных 3-битных индексов, где каждая позиция имеет свой собственный индекс. т.е. [ 7 6 5 4 3 2 1 0 ]
где каждый элемент имеет ширину 3 бита. 0b111'110'101'...'010'001'000
.
Используйте pext
чтобы извлечь нужные нам индексы в непрерывную последовательность внизу целочисленного регистра. Например, если нам нужны индексы 0 и 2, наша контрольная маска для pext
должна быть 0b000'...'111'000'111
. pext
будет захватывать группы индексов 010
и 000
которые совпадают с 1 битом в селекторе. Выбранные группы упаковываются в младшие биты вывода, поэтому на выходе будет 0b000'...'010'000
. (т.е. [... 2 0 ]
)
См. 0b111000111
код, чтобы узнать, как сгенерировать ввод 0b111000111
для pext
из маски входного вектора.
Теперь мы находимся в одной лодке со сжатым LUT: распаковываем до 8 упакованных индексов.
К тому времени, когда вы pdep
все кусочки, будет всего три pext
/pdep
s. Я работал в обратном направлении от того, что я хотел, так что, вероятно, легче всего понять это и в этом направлении. (то есть начните с линии тасования и оттуда работайте задом наперед.)
Мы можем упростить распаковку, если будем работать с индексами по одному на байт вместо упакованных 3-битных групп. Поскольку у нас есть 8 индексов, это возможно только с 64-битным кодом.
Смотрите эту и 32-битную версию в Godbolt Compiler Explorer. Я использовал #ifdef
поэтому он оптимально компилируется с -m64
или -m32
. GCC тратит некоторые инструкции, но Clang делает действительно хороший код.
#include <stdint.h>
#include <immintrin.h>
// Uses 64bit pdep / pext to save a step in unpacking.
__m256 compress256(__m256 src, unsigned int mask /* from movmskps */)
{
uint64_t expanded_mask = _pdep_u64(mask, 0x0101010101010101); // unpack each bit to a byte
expanded_mask *= 0xFF; // mask |= mask<<1 | mask<<2 | ... | mask<<7;
// ABC... -> AAAAAAAABBBBBBBBCCCCCCCC...: replicate each bit to fill its byte
const uint64_t identity_indices = 0x0706050403020100; // the identity shuffle for vpermps, packed to one index per byte
uint64_t wanted_indices = _pext_u64(identity_indices, expanded_mask);
__m128i bytevec = _mm_cvtsi64_si128(wanted_indices);
__m256i shufmask = _mm256_cvtepu8_epi32(bytevec);
return _mm256_permutevar8x32_ps(src, shufmask);
}
Это компилируется в код без загрузок из памяти, только с непосредственными константами. (Смотрите ссылку на Godbolt для этой и 32-битной версии).
# clang 3.7.1 -std=gnu++14 -O3 -march=haswell
mov eax, edi # just to zero extend: goes away when inlining
movabs rcx, 72340172838076673 # The constants are hoisted after inlining into a loop
pdep rax, rax, rcx # ABC -> 0000000A0000000B....
imul rax, rax, 255 # 0000000A0000000B.. -> AAAAAAAABBBBBBBB..
movabs rcx, 506097522914230528
pext rax, rcx, rax
vmovq xmm1, rax
vpmovzxbd ymm1, xmm1 # 3c latency since this is lane-crossing
vpermps ymm0, ymm1, ymm0
ret
Таким образом, согласно числам Агнера Тумана, это 6 мопов (не считая констант, или расширяющегося нулями mov, который исчезает при встраивании). На Intel Haswell это задержка 16c (1 для vmovq, 3 для каждого pdep/imul/pext/vpmovzx/vpermps). Там нет параллелизма на уровне команд. В цикле, где это не является частью переносимой в цикле зависимости (как, например, тот, который я включил в ссылку Godbolt), узкое место, как мы надеемся, просто пропускная способность, сохраняя сразу несколько итераций этого в полете.
Это может управлять пропускной способностью один на 3 цикла, узкое место на порту 1 для pdep/pext/imul. Конечно, с нагрузками/хранилищами и накладными расходами цикла (включая сравнение, movmsk и popcnt) общая пропускная способность uop может легко стать проблемой. (Например, цикл фильтра в моей ссылке на Godbolt равен 14 моп с clang, с -fno-unroll-loops
чтобы его было легче читать. Он может выдержать одну итерацию на 4c, не отставая от -fno-unroll-loops
интерфейса, если нам повезет, но я думаю, что clang не смог учесть ложную зависимость popcnt
от своего вывода, поэтому он будет узким местом на 3/5 задержки латентности функции compress256
.)
gcc умножает на 0xFF с несколькими инструкциями, используя сдвиг влево на 8 и sub
. Для этого требуются дополнительные инструкции mov
, но конечным результатом является умножение с задержкой 2. (Haswell обрабатывает mov
на этапе переименования регистра с нулевой задержкой.)
Поскольку все оборудование, поддерживающее AVX2, также поддерживает BMI2, вероятно, нет смысла предоставлять версию для AVX2 без BMI2.
Если вам нужно сделать это в очень длинном цикле, LUT, вероятно, того стоит, если начальные ошибки кэширования амортизируются в течение достаточного количества итераций с меньшими накладными расходами на простую распаковку записи LUT. Вам все еще нужно использовать movmskps
, поэтому вы можете открыть маску и использовать ее в качестве индекса LUT, но вы сохраните pdep/imul/pexp.
Вы можете распаковать записи LUT с той же самой целочисленной последовательностью, которую я использовал, но @Froglegs set1()
/vpsrlvd
/vpand
, вероятно, лучше, когда запись LUT начинается в памяти и не нуждается в целочисленных регистрах. (32-битная широковещательная загрузка не требует ALU-моп на процессорах Intel). Однако переменное смещение составляет 3 мопа на Haswell (но только 1 на Skylake).
Ответ 2
Если вы ориентируетесь на AMD Zen, этот метод может оказаться предпочтительным из-за очень медленного pdepand pext на ризене (18 циклов каждый).
Я придумал этот метод, который использует сжатую LUT, которая составляет 768 (заполнение +1) байтов вместо 8k. Это требует широковещательной передачи одного скалярного значения, которое затем сдвигается на разную величину в каждой полосе, а затем маскируется на младшие 3 бита, что обеспечивает 0-7 LUT.
Вот внутренняя версия, а также код для построения LUT.
//Generate Move mask via: _mm256_movemask_ps(_mm256_castsi256_ps(mask)); etc
__m256i MoveMaskToIndices(u32 moveMask) {
u8 *adr = g_pack_left_table_u8x3 + moveMask * 3;
__m256i indices = _mm256_set1_epi32(*reinterpret_cast<u32*>(adr));//lower 24 bits has our LUT
// __m256i m = _mm256_sllv_epi32(indices, _mm256_setr_epi32(29, 26, 23, 20, 17, 14, 11, 8));
//now shift it right to get 3 bits at bottom
//__m256i shufmask = _mm256_srli_epi32(m, 29);
//Simplified version suggested by wim
//shift each lane so desired 3 bits are a bottom
//There is leftover data in the lane, but _mm256_permutevar8x32_ps only examines the first 3 bits so this is ok
__m256i shufmask = _mm256_srlv_epi32 (indices, _mm256_setr_epi32(0, 3, 6, 9, 12, 15, 18, 21));
return shufmask;
}
u32 get_nth_bits(int a) {
u32 out = 0;
int c = 0;
for (int i = 0; i < 8; ++i) {
auto set = (a >> i) & 1;
if (set) {
out |= (i << (c * 3));
c++;
}
}
return out;
}
u8 g_pack_left_table_u8x3[256 * 3 + 1];
void BuildPackMask() {
for (int i = 0; i < 256; ++i) {
*reinterpret_cast<u32*>(&g_pack_left_table_u8x3[i * 3]) = get_nth_bits(i);
}
}
Вот сборка, сгенерированная MSVC:
lea ecx, DWORD PTR [rcx+rcx*2]
lea rax, OFFSET FLAT:unsigned char * g_pack_left_table_u8x3 ; g_pack_left_table_u8x3
vpbroadcastd ymm0, DWORD PTR [rcx+rax]
vpsrlvd ymm0, ymm0, YMMWORD PTR [email protected]000000
Ответ 3
Смотрите мой другой ответ для AVX2 + BMI2 без LUT.
Поскольку вы упоминаете о проблеме масштабируемости для AVX512: не беспокойтесь, есть инструкция AVX512F именно для этого:
VCOMPRESSPS
- Храните разреженные упакованные значения с плавающей запятой одинарной точности в плотной памяти. (Существуют также версии для двойных и 32- или 64-битных целочисленных элементов (vpcompressq
), но не для байта или слова (16-бит)). Это как BMI2 pdep
/pext
, но для векторных элементов вместо битов в целочисленном регистре.
Пункт назначения может быть векторным регистром или операндом памяти, в то время как источником является вектор и регистр маски. С регистром dest он может объединять или обнулять старшие биты. С помощью элемента dest памяти "в область памяти назначения записывается только непрерывный вектор".
Чтобы выяснить, как далеко продвинется указатель на следующий вектор, попкорн маску.
Допустим, вы хотите отфильтровать все, кроме значений> = 0 из массива:
#include <stdint.h>
#include <immintrin.h>
size_t filter_non_negative(float *__restrict__ dst, const float *__restrict__ src, size_t len) {
const float *endp = src+len;
float *dst_start = dst;
do {
__m512 sv = _mm512_loadu_ps(src);
__mmask16 keep = _mm512_cmp_ps_mask(sv, _mm512_setzero_ps(), _CMP_GE_OQ); // true for src >= 0.0, false for unordered and src < 0.0
_mm512_mask_compressstoreu_ps(dst, keep, sv); // clang is missing this intrinsic, which can't be emulated with a separate store
src += 16;
dst += _mm_popcnt_u64(keep); // popcnt_u64 instead of u32 helps gcc avoid a wasted movsx, but is potentially slower on some CPUs
} while (src < endp);
return dst - dst_start;
}
Это компилируется (с gcc4.9 или новее) в (
%23include+
//%23include+
size_t filter_non_negative(float+*__restrict__ dst, const float+*__restrict__ src, size_t len) {%0A++++const float+*endp+= src+len;%0A++++float+*dst_start+= dst;%0A++++do {%0A++++ __m512 sv = _mm512_loadu_ps(src);%0A++++ __mmask16+keep = _mm512_cmp_ps_mask(sv,+_mm512_setzero_ps(),+_CMP_GE_OQ)%3B++//true for src+>= 0.0, false for unordered and src+< 0.0%0A++++ _mm512_mask_compressstoreu_ps(dst,+keep, sv)%3B++//clang is missing this intrinsic, which can!'t be emulated with a separate store
%0A++++ src++= 16;%0A++++ dst += _mm_popcnt_u64(keep)%3B++//popcnt_u64 instead of u32 helps gcc avoid a wasted movsx, but is potentially slower on some CPUs%0A++++} while (src+< endp);%0A++++return dst - dst_start;
}
')),filterAsm:(commentOnly:!t,directives:!t,intel:!t,labels:!t),version:3 rel="nofollow noreferrer">Godbolt Compiler Explorer):
# Output from gcc6.1, with -O3 -march=haswell -mavx512f. Same with other gcc versions
lea rcx, [rsi+rdx*4] # endp
mov rax, rdi
vpxord zmm1, zmm1, zmm1 # vpxor xmm1, xmm1,xmm1 would save a byte, using VEX instead of EVEX
.L2:
vmovups zmm0, ZMMWORD PTR [rsi]
add rsi, 64
vcmpps k1, zmm0, zmm1, 29 # AVX512 compares have mask regs as a destination
kmovw edx, k1 # There are some insns to add/or/and mask regs, but not popcnt
movzx edx, dx # gcc is dumb and doesn't know that kmovw already zero-extends to fill the destination.
vcompressps ZMMWORD PTR [rax]{k1}, zmm0
popcnt rdx, rdx
## movsx rdx, edx # with _popcnt_u32, gcc is dumb. No casting can get gcc to do anything but sign-extend. You'd expect (unsigned) would mov to zero-extend, but no.
lea rax, [rax+rdx*4] # dst += ...
cmp rcx, rsi
ja .L2
sub rax, rdi
sar rax, 2 # address math -> element count
ret
Производительность: 256-битные векторы могут быть быстрее на Skylake-X/Cascade Lake
Теоретически цикл, который загружает растровое изображение и фильтрует один массив в другой, должен работать с 1 вектором на 3 такта в SKX/CSLX, независимо от ширины вектора, узким местом на порту 5. (kmovb/w/d/q k1, eax
работает на p5, а vcompressps
в память - это 2p5 + хранилище, согласно IACA и тестированию http://uops.info/).
@ZachB в комментариях сообщает, что на практике цикл с использованием ZMM _mm512_mask_compressstoreu_ps
немного медленнее, чем _mm256_mask_compressstoreu_ps
на реальном оборудовании CSLX. (Я не уверен, был ли это микробенчмарк, который позволил бы 256-битной версии выйти из "512-битного векторного режима" и повысить тактовую частоту, или был ли окружающий 512-битный код.)
Я подозреваю, что смещенные магазины наносят ущерб 512-битной версии. Вероятно, vcompressps
эффективно vcompressps
замаскированное 256- или 512-битное векторное хранилище, и если оно пересекает границу строки кэша, то оно должно выполнить дополнительную работу. Поскольку выходной указатель обычно не кратен 16 элементам, 512-битное хранилище полной строки почти всегда будет смещено.
По какой-то причине неправильно выровненные 512-битные хранилища могут быть хуже, чем 256-битные хранилища с разделением строк кэша, а также чаще встречаться; мы уже знаем, что 512-битная векторизация других вещей кажется более чувствительной к выравниванию. Это может быть просто из-за нехватки буферов с разделенной загрузкой, когда они происходят каждый раз, или, может быть, резервный механизм для обработки разбиений строки кэша менее эффективен для 512-битных векторов.
Было бы интересно vcompressps
в регистре с отдельными полными векторами, перекрывающими друг друга. Это, вероятно, тот же мопс, но в магазине может возникнуть микроплавкость, когда это отдельная инструкция. И если есть какая-то разница между магазинами в масках и перекрывающимися магазинами, это выявит это.
Другая идея, обсуждаемая в комментариях ниже, заключалась в использовании vpermt2ps
для создания полных векторов для выровненных хранилищ. Это было бы трудно сделать без ветвления, и ветвление, когда мы заполняем вектор, вероятно, будет неверно предсказано, если только у битовой маски нет довольно регулярного паттерна или больших серий all-0 и all-1.
Реализация без ответвлений с переносимой в цикле цепочкой зависимостей из 4 или 6 циклов по построенному вектору может быть возможной, с vpermt2ps
и смесью или чем-то, что может заменить его, когда он "заполнен". С выровненным вектором сохраняйте каждую итерацию, но перемещая выходной указатель, только если вектор заполнен.
Это, вероятно, медленнее, чем vcompressps с невыровненными хранилищами на текущих процессорах Intel.
Ответ 4
В случае, если кто-то заинтересован в этом, это решение для SSE2, которое использует инструкцию LUT вместо данных LUT, а также таблицу перехода. С AVX это потребует 256 случаев.
Каждый раз, когда вы вызываете LeftPack_SSE2
ниже, он использует по существу три команды: jmp, shufps, jmp. Пять из шестнадцати случаев не нуждаются в изменении вектора.
static inline __m128 LeftPack_SSE2(__m128 val, int mask) {
switch(mask) {
case 0:
case 1: return val;
case 2: return _mm_shuffle_ps(val,val,0x01);
case 3: return val;
case 4: return _mm_shuffle_ps(val,val,0x02);
case 5: return _mm_shuffle_ps(val,val,0x08);
case 6: return _mm_shuffle_ps(val,val,0x09);
case 7: return val;
case 8: return _mm_shuffle_ps(val,val,0x03);
case 9: return _mm_shuffle_ps(val,val,0x0c);
case 10: return _mm_shuffle_ps(val,val,0x0d);
case 11: return _mm_shuffle_ps(val,val,0x34);
case 12: return _mm_shuffle_ps(val,val,0x0e);
case 13: return _mm_shuffle_ps(val,val,0x38);
case 14: return _mm_shuffle_ps(val,val,0x39);
case 15: return val;
}
}
__m128 foo(__m128 val, __m128 maskv) {
int mask = _mm_movemask_ps(maskv);
return LeftPack_SSE2(val, mask);
}