AVX 256-битный код, выполняющий несколько хуже, чем эквивалентный 128-битный код SSSE3
Я пытаюсь написать очень эффективный код Хэмминга. Вдохновленный Wojciech Muła чрезвычайно умный SSE3 popcount реализация, я закодировал эквивалентное решение AVX2, на этот раз используя 256-битные регистры. l ожидал улучшения на 30% -40% на основе удвоенного parallelism задействованных операций, однако, к моему удивлению, код AVX2 немного медленнее (около 2%)!
Может ли кто-нибудь просветить меня о возможных причинах, по которым я не ожидаю повышения производительности?
Unrolled, SSE3 Расстояние Хэмминга двух 64-байтовых блоков:
INT32 SSE_PopCount(const UINT32* __restrict pA, const UINT32* __restrict pB) {
__m128i paccum = _mm_setzero_si128();
__m128i a = _mm_loadu_si128 (reinterpret_cast<const __m128i*>(pA));
__m128i b = _mm_loadu_si128 (reinterpret_cast<const __m128i*>(pB));
__m128i err = _mm_xor_si128 (a, b);
__m128i lo = _mm_and_si128 (err, low_mask);
__m128i hi = _mm_srli_epi16 (err, 4);
hi = _mm_and_si128 (hi, low_mask);
__m128i popcnt1 = _mm_shuffle_epi8(lookup, lo);
__m128i popcnt2 = _mm_shuffle_epi8(lookup, hi);
paccum = _mm_add_epi8(paccum, popcnt1);
paccum = _mm_add_epi8(paccum, popcnt2);
a = _mm_loadu_si128 (reinterpret_cast<const __m128i*>(pA + 4));
b = _mm_loadu_si128 (reinterpret_cast<const __m128i*>(pB + 4));
err = _mm_xor_si128 (a, b);
lo = _mm_and_si128 (err, low_mask);
hi = _mm_srli_epi16 (err, 4);
hi = _mm_and_si128 (hi, low_mask);
popcnt1 = _mm_shuffle_epi8(lookup, lo);
popcnt2 = _mm_shuffle_epi8(lookup, hi);
paccum = _mm_add_epi8(paccum, popcnt1);
paccum = _mm_add_epi8(paccum, popcnt2);
a = _mm_loadu_si128 (reinterpret_cast<const __m128i*>(pA + 8));
b = _mm_loadu_si128 (reinterpret_cast<const __m128i*>(pB + 8));
err = _mm_xor_si128 (a, b);
lo = _mm_and_si128 (err, low_mask);
hi = _mm_srli_epi16 (err, 4);
hi = _mm_and_si128 (hi, low_mask);
popcnt1 = _mm_shuffle_epi8(lookup, lo);
popcnt2 = _mm_shuffle_epi8(lookup, hi);
paccum = _mm_add_epi8(paccum, popcnt1);
paccum = _mm_add_epi8(paccum, popcnt2);
a = _mm_loadu_si128 (reinterpret_cast<const __m128i*>(pA + 12));
b = _mm_loadu_si128 (reinterpret_cast<const __m128i*>(pB + 12));
err = _mm_xor_si128 (a, b);
lo = _mm_and_si128 (err, low_mask);
hi = _mm_srli_epi16 (err, 4);
hi = _mm_and_si128 (hi, low_mask);
popcnt1 = _mm_shuffle_epi8(lookup, lo);
popcnt2 = _mm_shuffle_epi8(lookup, hi);
paccum = _mm_add_epi8(paccum, popcnt1);
paccum = _mm_add_epi8(paccum, popcnt2);
paccum = _mm_sad_epu8(paccum, _mm_setzero_si128());
UINT64 result = paccum.m128i_u64[0] + paccum.m128i_u64[1];
return (INT32)result;
}
Ununrolled, эквивалентная версия с использованием 256-битных регистров AVX:
INT32 AVX_PopCount(const UINT32* __restrict pA, const UINT32* __restrict pB) {
__m256i paccum = _mm256_setzero_si256();
__m256i a = _mm256_loadu_si256 (reinterpret_cast<const __m256i*>(pA));
__m256i b = _mm256_loadu_si256 (reinterpret_cast<const __m256i*>(pB));
__m256i err = _mm256_xor_si256 (a, b);
__m256i lo = _mm256_and_si256 (err, low_mask256);
__m256i hi = _mm256_srli_epi16 (err, 4);
hi = _mm256_and_si256 (hi, low_mask256);
__m256i popcnt1 = _mm256_shuffle_epi8(lookup256, lo);
__m256i popcnt2 = _mm256_shuffle_epi8(lookup256, hi);
paccum = _mm256_add_epi8(paccum, popcnt1);
paccum = _mm256_add_epi8(paccum, popcnt2);
a = _mm256_loadu_si256 (reinterpret_cast<const __m256i*>(pA + 8));
b = _mm256_loadu_si256 (reinterpret_cast<const __m256i*>(pB + 8));
err = _mm256_xor_si256 (a, b);
lo = _mm256_and_si256 (err, low_mask256);
hi = _mm256_srli_epi16 (err, 4);
hi = _mm256_and_si256 (hi, low_mask256);
popcnt1 = _mm256_shuffle_epi8(lookup256, lo);
popcnt2 = _mm256_shuffle_epi8(lookup256, hi);
paccum = _mm256_add_epi8(paccum, popcnt1);
paccum = _mm256_add_epi8(paccum, popcnt2);
paccum = _mm256_sad_epu8(paccum, _mm256_setzero_si256());
UINT64 result = paccum.m256i_i64[0] + paccum.m256i_u64[1] + paccum.m256i_i64[2] + paccum.m256i_i64[3];
return (INT32)result;
}
Я уже проверил код выходной сборки, испускаемый компилятором, и он выглядит хорошо, с ожидаемым прямым переводом встроенной инструкции на машинную инструкцию. Единственное, что я заметил, это то, что в версии AVX2 последняя строка, в которой накапливается совокупность четырех четырехсловных слов, генерируется более сложный код, чем версия SSE3 (где нужно всего лишь накапливать только два квад-слова для получения подсчет населения), однако я бы все же ожидал более высокую пропускную способность.
Код AVX2, созданный для накопления четырехъядерных слов
vextractf128 xmm0, ymm2, 1
psrldq xmm0, 8
movd ecx, xmm2
movd eax, xmm0
vextractf128 xmm0, ymm2, 1
psrldq xmm2, 8
add eax, ecx
movd ecx, xmm0
add eax, ecx
movd ecx, xmm2
add eax, ecx
Код SSE3, созданный для накопления четырехъядерных слов
movd ecx, xmm2
psrldq xmm2, 8
movd eax, xmm2
add eax, ecx
Моя тестовая программа вызывается по 1 миллиону раз в каждой процедуре с разными входными значениями, но повторно использует два статических буфера для хранения данных параметров pA
и pB
. В моем ограниченном понимании архитектуры процессора эта локальность (повторное использование одних и тех же буферов памяти снова и снова) должна хорошо подогревать кэширование процессора и не зависеть от проблемы с пропускной способностью памяти, но, помимо возможности пропускной способности памяти, я не могу понять, почему нет улучшения производительности.
Процедура тестирования
int _tmain(int argc, _TCHAR* argv[]) {
lookup = _mm_setr_epi8(
/* 0 */ 0, /* 1 */ 1, /* 2 */ 1, /* 3 */ 2,
/* 4 */ 1, /* 5 */ 2, /* 6 */ 2, /* 7 */ 3,
/* 8 */ 1, /* 9 */ 2, /* a */ 2, /* b */ 3,
/* c */ 2, /* d */ 3, /* e */ 3, /* f */ 4
);
low_mask = _mm_set1_epi8(0xf);
lookup256 = _mm256_setr_epi8(
/* 0 */ 0, /* 1 */ 1, /* 2 */ 1, /* 3 */ 2,
/* 4 */ 1, /* 5 */ 2, /* 6 */ 2, /* 7 */ 3,
/* 8 */ 1, /* 9 */ 2, /* a */ 2, /* b */ 3,
/* c */ 2, /* d */ 3, /* e */ 3, /* f */ 4,
/* 0 */ 0, /* 1 */ 1, /* 2 */ 1, /* 3 */ 2,
/* 4 */ 1, /* 5 */ 2, /* 6 */ 2, /* 7 */ 3,
/* 8 */ 1, /* 9 */ 2, /* a */ 2, /* b */ 3,
/* c */ 2, /* d */ 3, /* e */ 3, /* f */ 4
);
low_mask256 = _mm256_set1_epi8(0xf);
std::default_random_engine generator;
generator.seed(37);
std::uniform_int_distribution<UINT32> distribution(0, ULONG_MAX);
auto dice = std::bind( distribution, generator);
UINT32 a[16];
UINT32 b[16];
int count;
count = 0;
{
cout << "AVX PopCount\r\n";
boost::timer::auto_cpu_timer t;
for( int i = 0; i < 1000000; i++ ) {
for( int j = 0; j < 16; j++ ) {
a[j] = dice();
b[j] = dice();
}
count+= AVX_PopCount(a, b);
}
}
cout << count << "\r\n";
std::default_random_engine generator2;
generator2.seed(37);
std::uniform_int_distribution<UINT32> distribution2(0, ULONG_MAX);
auto dice2 = std::bind( distribution2, generator2);
count = 0;
{
cout << "SSE PopCount\r\n";
boost::timer::auto_cpu_timer t;
for( int i = 0; i < 1000000; i++ ) {
for( int j = 0; j < 16; j++ ) {
a[j] = dice2();
b[j] = dice2();
}
count+= SSE_PopCount(a, b);
}
}
cout << count << "\r\n";
getch();
return 0;
}
Тест-машина - это Intel Corei7 4790, и я использую Visual Studio 2012 Pro.
Ответы
Ответ 1
В дополнение к незначительным проблемам в комментариях (компиляция для /arch:AVX
) основной проблемой является генерация случайных входных массивов на каждой итерации. Это ваше узкое место, поэтому ваш тест неэффективно оценивает ваши методы. Примечание. Я не использую boost, но GetTickCount
работает для этой цели. Рассмотрим просто:
int count;
count = 0;
{
cout << "AVX PopCount\r\n";
unsigned int Tick = GetTickCount();
for (int i = 0; i < 1000000; i++) {
for (int j = 0; j < 16; j++) {
a[j] = dice();
b[j] = dice();
}
count += AVX_PopCount(a, b);
}
Tick = GetTickCount() - Tick;
cout << Tick << "\r\n";
}
выводит результат:
AVX PopCount
2309
256002470
Итак, 2309ms для завершения... но что произойдет, если мы вообще избавимся от вашей обычной программы AVX? Просто введите входные массивы:
int count;
count = 0;
{
cout << "Just making arrays...\r\n";
unsigned int Tick = GetTickCount();
for (int i = 0; i < 1000000; i++) {
for (int j = 0; j < 16; j++) {
a[j] = dice();
b[j] = dice();
}
}
Tick = GetTickCount() - Tick;
cout << Tick << "\r\n";
}
выводит результат:
Простое создание массивов...
2246
Как насчет этого. Неудивительно, что на самом деле, поскольку вы генерируете 32 случайных числа, которые могут быть довольно дорогими, а затем выполняются только некоторые довольно быстрые математические данные и тасования.
Итак...
Теперь добавьте коэффициент из 100 итераций и выведите случайный генератор из замкнутой петли. Компиляция здесь с отключенными оптимизациями приведет ваш код как ожидалось и не отбросит "бесполезные" итерации - предположительно, код, который нам очень важен, уже (вручную) оптимизирован!
for (int j = 0; j < 16; j++) {
a[j] = dice();
b[j] = dice();
}
int count;
count = 0;
{
cout << "AVX PopCount\r\n";
unsigned int Tick = GetTickCount();
for (int i = 0; i < 100000000; i++) {
count += AVX_PopCount(a, b);
}
Tick = GetTickCount() - Tick;
cout << Tick << "\r\n";
}
cout << count << "\r\n";
count = 0;
{
cout << "SSE PopCount\r\n";
unsigned int Tick = GetTickCount();
for (int i = 0; i < 100000000; i++) {
count += SSE_PopCount(a, b);
}
Tick = GetTickCount() - Tick;
cout << Tick << "\r\n";
}
cout << count << "\r\n";
выводит результат:
AVX PopCount
3744
730196224
SSE PopCount
5616
730196224
Итак, поздравляю - вы можете похлопать себя по спине, ваша процедура AVX действительно примерно на треть быстрее, чем обычная SSE (протестирована на Haswell i7 здесь). Урок должен быть уверен, что вы на самом деле профилируете то, что, по вашему мнению, профилируете!
Ответ 2
Вам следует использовать обычную команду _mm_popcnt_u64
вместо того, чтобы взломать ее в SSE или AVX. Я тестировал все методы для полномасштабного заполнения, включая версию SSE и AVX (что в конечном итоге привело к моему более или менее известному вопросу о popcount). _mm_popcnt_u64
значительно превосходит SSE и AVX, особенно когда вы используете компилятор, который предотвращает ошибку popcount Intel, обнаруженную в моем вопросе. Без ошибки мой Хасуэлл может собрать 26 ГБ/с, что почти попадает в полосу пропускания шины.
Причина, по которой _mm_popcnt_u64
работает быстрее, - это просто из-за того, что она одновременно включает 64 бита (так уже 1/4 версии AVX), требуя только одной дешевой инструкции процессора. Он стоит всего несколько циклов (латентность 3, пропускная способность 1 для Intel). Даже если каждая инструкция AVX, которую вы используете, требует только одного цикла, вы все равно получите худшие результаты из-за сдвига количества инструкций, необходимых для заполнения 256 бит.
Попробуйте это, он должен быть самым быстрым:
int popcount256(const uint64_t* u){
return _mm_popcnt_u64(u[0]);
+ _mm_popcnt_u64(u[1]);
+ _mm_popcnt_u64(u[2]);
+ _mm_popcnt_u64(u[3]);
}
Я знаю, что это не отвечает на ваш основной вопрос, почему AVX работает медленнее, но поскольку ваша конечная цель - быстрый popcount, сравнение AVX ↔ SSE не имеет значения, поскольку оба уступают встроенному popcount.