256-битный код AVX работает немного хуже, чем эквивалентный 128-битный код SSSE3
Я пытаюсь написать очень эффективный код расстояния Хэмминга. Вдохновленный Войцех Muła это чрезвычайно умный SSE3 popcount реализация, я закодировал эквивалентное решение AVX2, на этот раз используя 256-битные регистры. l ожидал, по крайней мере, улучшения 30% -40% на основе удвоенного параллелизма вовлеченных операций, однако, к моему удивлению, код AVX2 немного медленнее (около 2%)!
может кто-нибудь просветить меня возможные причины, по которым я не получаю ожидаемого повышения производительности?
развернуто, 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 версия, последняя строка, в которой накапливается количество населения из 4 четырех слов, генерирует более сложный код, чем версия SSE3 (где только 2 четырех слов должны быть накоплены, чтобы получить количество населения), однако я все равно ожидаю более высокую пропускную способность.
код 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
параметры. В моем ограниченном понимании архитектуры процессора эта местность (повторное использование одних и тех же буферов памяти снова и снова) должна хорошо разогревать кэш процессора и не быть связана проблемой пропускной способности памяти, но, кроме возможной пропускной способности памяти, я не могу понять, почему нет улучшения производительности.
2 ответов
в дополнение к незначительным вопросам в комментариях (компиляция для /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 здесь). Урок состоит в том, чтобы убедиться, что вы действительно профилируете то, что вы думаете, что профилируете!
рекомендуется использовать обычный _mm_popcnt_u64
инструкция вместо взлома в SSE или AVX. Я тщательно протестировал все методы popcounting, включая версию SSE и AVX (что в конечном итоге привело к моему более или менее известному вопрос о popcount). _mm_popcnt_u64
значительно превосходит SSE и AVX, особенно когда вы используете компилятор, который предотвращает ошибку Intel popcount, обнаруженную в моем вопросе. Без ошибки мой Haswell может popcount 26 GB / s, который почти попадает пропускная способность шины.
Почему _mm_popcnt_u64
быстрее просто из-за того, что он popcounts 64 бит сразу (так уже 1/4 версии AVX), требуя только одной дешевой инструкции процессора. Это стоит всего несколько циклов (задержка 3, пропускная способность 1 для Intel). Даже если каждая инструкция AVX, которую вы используете, требует только одного цикла, вы все равно получите худшие результаты из-за сдвига количества инструкций, необходимых для popcounting 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.