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.