Замена 32-разрядного счетчика циклов на 64-разрядный вводит сумасшедшие отклонения производительности

1188

Я искал самый быстрый способ для больших массивов данных popcount. Я столкнулся с очень странным эффектом: изменение переменной цикла от unsigned до uint64_t привело к снижению производительности на 50% на моем ПК.

Контрольный показатель

#include <iostream>
#include <chrono>
#include <x86intrin.h>

int main(int argc, char* argv[]) {

    using namespace std;
    if (argc != 2) {
       cerr << "usage: array_size in MB" << endl;
       return -1;
    }

    uint64_t size = atol(argv[1])<<20;
    uint64_t* buffer = new uint64_t[size/8];
    char* charbuffer = reinterpret_cast<char*>(buffer);
    for (unsigned i=0; i<size; ++i)
        charbuffer[i] = rand()%256;

    uint64_t count,duration;
    chrono::time_point<chrono::system_clock> startP,endP;
    {
        startP = chrono::system_clock::now();
        count = 0;
        for( unsigned k = 0; k < 10000; k++){
            // Tight unrolled loop with unsigned
            for (unsigned i=0; i<size/8; i+=4) {
                count += _mm_popcnt_u64(buffer[i]);
                count += _mm_popcnt_u64(buffer[i+1]);
                count += _mm_popcnt_u64(buffer[i+2]);
                count += _mm_popcnt_u64(buffer[i+3]);
            }
        }
        endP = chrono::system_clock::now();
        duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
        cout << "unsigned\t" << count << '\t' << (duration/1.0E9) << " sec \t"
             << (10000.0*size)/(duration) << " GB/s" << endl;
    }
    {
        startP = chrono::system_clock::now();
        count=0;
        for( unsigned k = 0; k < 10000; k++){
            // Tight unrolled loop with uint64_t
            for (uint64_t i=0;i<size/8;i+=4) {
                count += _mm_popcnt_u64(buffer[i]);
                count += _mm_popcnt_u64(buffer[i+1]);
                count += _mm_popcnt_u64(buffer[i+2]);
                count += _mm_popcnt_u64(buffer[i+3]);
            }
        }
        endP = chrono::system_clock::now();
        duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
        cout << "uint64_t\t"  << count << '\t' << (duration/1.0E9) << " sec \t"
             << (10000.0*size)/(duration) << " GB/s" << endl;
    }

    free(charbuffer);
}

Как вы видите, мы создаем буфер случайных данных с размером x мегабайт, где x считывается из командной строки. Затем мы перебираем буфер и используем развернутую версию x86 popcount, чтобы выполнить popcount. Чтобы получить более точный результат, мы делаем 10000 раз. Мы измеряем время для popcount. В верхнем регистре внутренняя переменная цикла unsigned, в нижнем регистре внутренняя переменная цикла uint64_t. Я думал, что это не имеет значения, но дело обстоит наоборот.

Результаты (абсолютно безумные)

Я скомпилирую его следующим образом (версия g++: Ubuntu 4.8.2-19ubuntu1):

g++ -O3 -march=native -std=c++11 test.cpp -o test

Вот результаты на моем Haswell Core i7-4770K CPU @3.50 GHz, запуск test 1 (так что 1 случайные данные MB):

  • unsigned 41959360000 0.401554 sec 26.113 GB/s
  • uint64_t 41959360000 0,759822 сек 13.8003 GB/s

Как вы видите, пропускная способность версии uint64_t только наполовину - одна из версий unsigned! Проблема заключается в том, что генерируется другая сборка, но почему? Во-первых, я подумал о ошибке компилятора, поэтому я попробовал clang++ (Ubuntu Clang версия 3.4-1ubuntu3):

clang++ -O3 -march=native -std=c++11 teest.cpp -o test

Результат: test 1

  • unsigned 41959360000 0.398293 sec 26.3267 Гб/с
  • uint64_t 41959360000 0.680954 sec 15.3986 Гб/с

Итак, это почти тот же результат и по-прежнему странный. Но теперь это становится супер странным. Я заменяю размер буфера, который был прочитан с ввода с константой 1, поэтому я меняю:

uint64_t size = atol(argv[1]) << 20;

к

uint64_t size = 1 << 20;

Таким образом, компилятор теперь знает размер буфера во время компиляции. Возможно, он может добавить некоторые оптимизации! Вот цифры для g++:

  • unsigned 41959360000 0.509156 sec 20.5944 GB/s
  • uint64_t 41959360000 0.508673 sec 20.6139 GB/s

Теперь обе версии одинаково быстры. Однако unsigned получил еще медленнее! Он упал с 26 до 20 GB/s, таким образом, заменив непостоянное на постоянное значение, приведет к деоптимизации. Серьезно, я понятия не имею, что здесь происходит! Но теперь до clang++ с новой версией:

  • unsigned 41959360000 0.677009 sec 15.4884 GB/s
  • uint64_t 41959360000 0,676909 сек 15.4906 GB/s

Подождите, что? Теперь обе версии упали до медленного числа в 15 бит/с. Таким образом, замена непостоянного на постоянное значение даже приводит к медленному коду в случаях для Clang!

Я попросил коллегу с CPU

  • 6
    ТАК МНОГИЕ КОММЕНТАРИИ! Вы можете просматривать их в чате и даже оставить свой там, если хотите, но, пожалуйста, не добавляйте сюда больше!
  • 2
    Также см. GCC Issue 62011, Ложные зависимости данных в инструкции popcnt . Кто-то еще предоставил это, но это, кажется, было потеряно во время уборки.
Показать ещё 1 комментарий
Теги:
optimization
performance
assembly
compiler-optimization

10 ответов

1474
Лучший ответ

Culprit: False Зависимость данных (и компилятор даже не знает об этом)

В процессорах Sandy/Ivy Bridge и Haswell инструкция:

popcnt  src, dest

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

Эта зависимость не просто удерживает 4 popcnt от одной итерации цикла. Он может переносить чередующиеся итерации цикла, что делает невозможным параллельное использование процессором разных итераций цикла.

unsigned vs. uint64_t и другие твики не влияют непосредственно на проблему. Но они влияют на распределитель регистров, который присваивает регистры переменным.

В вашем случае скорости являются прямым результатом того, что застряло в цепочке зависимостей (false) в зависимости от того, что решило сделать распределитель регистров.

  • 13 ГБ/с имеет цепочку: popcnt - add - popcnt - popcnt → следующая итерация
  • 15 ГБ/с имеет цепочку: popcnt - add - popcnt - add → следующая итерация
  • 20 ГБ/с имеет цепочку: popcnt - popcnt → следующая итерация
  • 26 ГБ/с имеет цепочку: popcnt - popcnt → следующая итерация

Разница между 20 ГБ/с и 26 ГБ/с кажется незначительным артефактом косвенной адресации. В любом случае, процессор начинает ударять по другим узким местам, как только вы достигнете этой скорости.


Чтобы проверить это, я использовал встроенную сборку, чтобы обойти компилятор и получить именно ту сборку, которую я хочу. Я также разделил переменную count, чтобы разбить все другие зависимости, которые могут испортить тесты.

Вот результаты:

Sandy Bridge Xeon @3.5 ГГц: (полный тестовый код можно найти внизу)

  • GCC 4.6.3: g++ popcnt.cpp -std=c++0x -O3 -save-temps -march=native
  • Ubuntu 12

Различные регистры: 18.6195 Гб/с

.L4:
    movq    (%rbx,%rax,8), %r8
    movq    8(%rbx,%rax,8), %r9
    movq    16(%rbx,%rax,8), %r10
    movq    24(%rbx,%rax,8), %r11
    addq    $4, %rax

    popcnt %r8, %r8
    add    %r8, %rdx
    popcnt %r9, %r9
    add    %r9, %rcx
    popcnt %r10, %r10
    add    %r10, %rdi
    popcnt %r11, %r11
    add    %r11, %rsi

    cmpq    $131072, %rax
    jne .L4

Тот же регистр: 8.49272 ГБ/с

.L9:
    movq    (%rbx,%rdx,8), %r9
    movq    8(%rbx,%rdx,8), %r10
    movq    16(%rbx,%rdx,8), %r11
    movq    24(%rbx,%rdx,8), %rbp
    addq    $4, %rdx

    # This time reuse "rax" for all the popcnts.
    popcnt %r9, %rax
    add    %rax, %rcx
    popcnt %r10, %rax
    add    %rax, %rsi
    popcnt %r11, %rax
    add    %rax, %r8
    popcnt %rbp, %rax
    add    %rax, %rdi

    cmpq    $131072, %rdx
    jne .L9

Тот же регистр со сломанной цепочкой: 17.8869 Гб/с

.L14:
    movq    (%rbx,%rdx,8), %r9
    movq    8(%rbx,%rdx,8), %r10
    movq    16(%rbx,%rdx,8), %r11
    movq    24(%rbx,%rdx,8), %rbp
    addq    $4, %rdx

    # Reuse "rax" for all the popcnts.
    xor    %rax, %rax    # Break the cross-iteration dependency by zeroing "rax".
    popcnt %r9, %rax
    add    %rax, %rcx
    popcnt %r10, %rax
    add    %rax, %rsi
    popcnt %r11, %rax
    add    %rax, %r8
    popcnt %rbp, %rax
    add    %rax, %rdi

    cmpq    $131072, %rdx
    jne .L14

Итак, что пошло не так с компилятором?

Кажется, что ни GCC, ни Visual Studio не знают, что popcnt имеет такую ​​ложную зависимость. Тем не менее, эти ложные зависимости не редкость. Это просто вопрос о том, знает ли компилятор об этом.

popcnt - не совсем самая используемая инструкция. Поэтому не удивительно, что крупный компилятор мог пропустить что-то вроде этого. Там также нет документации, где упоминается эта проблема. Если Intel не раскроет это, никто из них не узнает, пока кто-то не столкнется с ним случайно.

( Обновление: Начиная с версии 4.9.2, GCC знает об этой ложной зависимости и генерирует код, чтобы компенсировать его при оптимизации. Крупные компиляторы от других поставщиков, включая Clang, MSVC и даже Intel ICC, еще не знают об этом микроархитектурном erratum и не будут испускать код, который его компенсирует.)

Почему у процессора есть такая ложная зависимость?

Мы можем только догадываться, но, скорее всего, у Intel есть такая же обработка для множества инструкций с двумя операндами. Общие инструкции типа add, sub принимают два операнда, оба из которых являются входами. Так что Intel, вероятно, запустил popcnt в ту же категорию, чтобы упростить дизайн процессора.

Процессоры AMD, похоже, не имеют этой ложной зависимости.


Полный тестовый код приведен ниже для справки:

#include <iostream>
#include <chrono>
#include <x86intrin.h>

int main(int argc, char* argv[]) {

   using namespace std;
   uint64_t size=1<<20;

   uint64_t* buffer = new uint64_t[size/8];
   char* charbuffer=reinterpret_cast<char*>(buffer);
   for (unsigned i=0;i<size;++i) charbuffer[i]=rand()%256;

   uint64_t count,duration;
   chrono::time_point<chrono::system_clock> startP,endP;
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "popcnt %4, %4  \n\t"
                "add %4, %0     \n\t"
                "popcnt %5, %5  \n\t"
                "add %5, %1     \n\t"
                "popcnt %6, %6  \n\t"
                "add %6, %2     \n\t"
                "popcnt %7, %7  \n\t"
                "add %7, %3     \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "No Chain\t" << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "popcnt %4, %%rax   \n\t"
                "add %%rax, %0      \n\t"
                "popcnt %5, %%rax   \n\t"
                "add %%rax, %1      \n\t"
                "popcnt %6, %%rax   \n\t"
                "add %%rax, %2      \n\t"
                "popcnt %7, %%rax   \n\t"
                "add %%rax, %3      \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
                : "rax"
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "Chain 4   \t"  << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "xor %%rax, %%rax   \n\t"   // <--- Break the chain.
                "popcnt %4, %%rax   \n\t"
                "add %%rax, %0      \n\t"
                "popcnt %5, %%rax   \n\t"
                "add %%rax, %1      \n\t"
                "popcnt %6, %%rax   \n\t"
                "add %%rax, %2      \n\t"
                "popcnt %7, %%rax   \n\t"
                "add %%rax, %3      \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
                : "rax"
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "Broken Chain\t"  << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }

   free(charbuffer);
}

Не менее интересный бенчмарк можно найти здесь: http://pastebin.com/kbzgL8si
Этот критерий изменяет количество popcnt, которые находятся в цепочке зависимостей (false).

False Chain 0:  41959360000 0.57748 sec     18.1578 GB/s
False Chain 1:  41959360000 0.585398 sec    17.9122 GB/s
False Chain 2:  41959360000 0.645483 sec    16.2448 GB/s
False Chain 3:  41959360000 0.929718 sec    11.2784 GB/s
False Chain 4:  41959360000 1.23572 sec     8.48557 GB/s
  • 1
    Привет народ! Много прошлых комментариев здесь; Перед тем как оставить новый, просмотрите архив .
  • 4
    Это все еще воспроизводится в лязг на голове. Я подал ошибку: bugs.llvm.org/show_bug.cgi?id=34936 .
Показать ещё 1 комментарий
49

Я закодировал эквивалентную программу для экспериментов, и я могу подтвердить это странное поведение. Что еще, gcc считает, что 64-битное целое число (которое, вероятно, должно быть size_t в любом случае...) должно быть лучше, поскольку использование uint_fast32_t заставляет gcc использовать 64-разрядный uint.

Я немного сработал с сборкой:
Просто возьмите 32-битную версию, замените все 32-разрядные инструкции/регистры на 64-битную версию во внутреннем цикле программы popcount. Наблюдение: код так же быстро, как 32-битная версия!

Это, очевидно, хак, поскольку размер переменной на самом деле не 64-битный, так как другие части программы по-прежнему используют 32-битную версию, но до тех пор, пока внутренний цикл popcount доминирует над производительностью, это хороший старт.

Затем я скопировал код внутреннего цикла из 32-разрядной версии программы, взломал ее до 64 бит, переиграв регистры, чтобы заменить ее внутренним циклом 64-битной версии. Этот код также работает так же быстро, как 32-разрядная версия.

Я пришел к выводу, что это плохое планирование команд компилятором, а не фактическое преимущество скорости/задержки 32-битных инструкций.

(Предостережение: я взломал сборку, мог что-то сломать, не заметив. так думай.)

  • 0
    «Более того, gcc считает, что 64-битное целое число […] лучше, поскольку использование uint_fast32_t заставляет gcc использовать 64-битную uint». К сожалению, к моему сожалению, за этим нет никакого волшебства и глубокого самоанализа кода типы. Я еще не видел, чтобы они предоставили какой-либо другой способ, кроме как одно определение типа для каждого возможного места и каждой программы на всей платформе. Скорее всего, за точным выбором типов были поставлены некоторые соображения, но одно определение для каждого из них не может подходить для любого приложения, которое когда-либо будет. Некоторое дальнейшее чтение: stackoverflow.com/q/4116297 .
  • 0
    @Keno Это потому что sizeof(uint_fast32_t) должен быть определен. Если вы позволите этому не быть, вы можете сделать этот трюк, но это может быть достигнуто только с помощью расширения компилятора.
24

Это не ответ, но его трудно прочитать, если я поставлю результаты в комментарии.

Я получаю эти результаты с помощью Mac Pro (Westmere 6-Cores Xeon 3.33 GHz). Я скомпилировал его с clang -O3 -msse4 -lstdc++ a.cpp -o a (-O2 получить тот же результат).

clang с uint64_t size=atol(argv[1])<<20;

unsigned    41950110000 0.811198 sec    12.9263 GB/s
uint64_t    41950110000 0.622884 sec    16.8342 GB/s

clang с uint64_t size=1<<20;

unsigned    41950110000 0.623406 sec    16.8201 GB/s
uint64_t    41950110000 0.623685 sec    16.8126 GB/s

Я также попытался:

  • Отмените порядок тестирования, результат будет таким же, чтобы исключить фактор кеша.
  • Выполните оператор for в обратном порядке: for (uint64_t i=size/8;i>0;i-=4). Это дает тот же результат и доказывает, что компилятор достаточно умен, чтобы не разделить размер на 8 на каждой итерации (как и ожидалось).

Вот мое дикое предположение:

Коэффициент скорости входит в три части:

  • кеш кода: uint64_t версия имеет больший размер кода, но это не влияет на мой процессор Xeon. Это замедляет работу 64-разрядной версии.

  • Используемые инструкции. Обратите внимание не только на количество циклов, но и на буфер, с 32-битным и 64-разрядным индексом на двух версиях. Доступ к указателю с 64-битным смещением запрашивает выделенный 64-битный регистр и адресацию, в то время как вы можете использовать немедленное для 32-битного смещения. Это может сделать 32-разрядную версию быстрее.

  • Инструкции выдаются только в 64-битной компиляции (т.е. предварительной выборке). Это делает 64-бит быстрее.

Три фактора вместе совпадают с наблюдаемыми, казалось бы, противоречивыми результатами.

  • 4
    Интересно, вы можете добавить версию компилятора и флаги компилятора? Лучше всего то, что на вашей машине результаты оборачиваются, то есть использование u64 быстрее . До сих пор я никогда не думал о том, какой тип имеет моя переменная цикла, но мне кажется, что в следующий раз мне придется подумать дважды :).
  • 2
    @gexicide: я бы не назвал скачок с 16,8201 до 16,8126, который сделал бы его «быстрее».
Показать ещё 5 комментариев
10

Я попробовал это с Visual Studio 2013 Express, используя указатель вместо индекса, который немного ускорил процесс. Я подозреваю, что это потому, что адресация смещена + регистр, а не смещение + регистр + (регистр < 3). Код на С++.

   uint64_t* bfrend = buffer+(size/8);
   uint64_t* bfrptr;

// ...

   {
      startP = chrono::system_clock::now();
      count = 0;
      for (unsigned k = 0; k < 10000; k++){
         // Tight unrolled loop with uint64_t
         for (bfrptr = buffer; bfrptr < bfrend;){
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
         }
      }
      endP = chrono::system_clock::now();
      duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "uint64_t\t"  << count << '\t' << (duration/1.0E9) << " sec \t"
           << (10000.0*size)/(duration) << " GB/s" << endl;
   }

код сборки: r10 = bfrptr, r15 = bfrend, rsi = count, rdi = buffer, r13 = k:

$LL5@main:
        mov     r10, rdi
        cmp     rdi, r15
        jae     SHORT $LN4@main
        npad    4
$LL2@main:
        mov     rax, QWORD PTR [r10+24]
        mov     rcx, QWORD PTR [r10+16]
        mov     r8, QWORD PTR [r10+8]
        mov     r9, QWORD PTR [r10]
        popcnt  rdx, rax
        popcnt  rax, rcx
        add     rdx, rax
        popcnt  rax, r8
        add     r10, 32
        add     rdx, rax
        popcnt  rax, r9
        add     rsi, rax
        add     rsi, rdx
        cmp     r10, r15
        jb      SHORT $LL2@main
$LN4@main:
        dec     r13
        jne     SHORT $LL5@main
10

Я не могу дать авторитетный ответ, но даю обзор вероятной причины. Эта ссылка довольно ясно показывает, что для инструкций в теле вашего цикла существует соотношение между задержкой и пропускной способностью 3: 1. Он также показывает эффекты многократной отправки. Так как в современных процессорах x86 есть (дайте-или-принять) три целых единицы, в общем случае можно отправить три команды за цикл.

Таким образом, между пиковым конвейером и множественной пропускной способностью и отказом от этих механизмов мы имеем шесть показателей производительности. Довольно хорошо известно, что сложность набора инструкций x86 делает его довольно простым для причудливого разлома. В приведенном выше документе есть отличный пример:

Производительность Pentium 4 для 64-битных сдвигов вправо очень плохая. 64-битная сдвиг влево, а также все 32-битные сдвиги имеют приемлемую производительность. Похоже, что путь данных от верхних 32 битов к нижнему 32-биту ALU плохо разработан.

Я лично столкнулся с странным случаем, когда горячая линия работала значительно медленнее на ядре четырехъядерного чипа (AMD, если я помню). На самом деле мы получили лучшую производительность на карте - уменьшите расчет, отключив это ядро.

Здесь я догадываюсь о конкуренции за целые единицы: что счетчик popcnt, счетчик циклов и вычислений адресов могут просто работать на полной скорости с помощью 32-разрядного счетчика, но 64-разрядный счетчик вызывает конкуренцию и конвейер киоски. Поскольку всего всего около 12 циклов, потенциально 4 цикла с несколькими диспетчерами, на выполнение каждого цикла, один стойло может разумно повлиять на время выполнения в 2 раза.

Изменение, вызванное использованием статической переменной, которое, как я предполагаю, просто вызывает незначительное переупорядочение инструкций, является еще одним ключом к тому, что 32-битный код находится в какой-то точке перерыва для конкуренции.

Я знаю, что это не тщательный анализ, но это правдоподобное объяснение.

  • 2
    К сожалению, с тех пор (Core 2?) Практически нет различий в производительности между 32-разрядными и 64-разрядными целочисленными операциями, за исключением умножения / деления, которых нет в этом коде.
  • 0
    @Gene: Обратите внимание, что все версии хранят размер в регистре и никогда не читают его из стека в цикле. Таким образом, вычисление адреса не может быть в миксе, по крайней мере, не внутри цикла.
Показать ещё 3 комментария
9

Вы пробовали передать -funroll-loops -fprefetch-loop-arrays в GCC?

Я получаю следующие результаты с этими дополнительными оптимизациями:

[1829] /tmp/so_25078285 $ cat /proc/cpuinfo |grep CPU|head -n1
model name      : Intel(R) Core(TM) i3-3225 CPU @ 3.30GHz
[1829] /tmp/so_25078285 $ g++ --version|head -n1
g++ (Ubuntu/Linaro 4.7.3-1ubuntu1) 4.7.3

[1829] /tmp/so_25078285 $ g++ -O3 -march=native -std=c++11 test.cpp -o test_o3
[1829] /tmp/so_25078285 $ g++ -O3 -march=native -funroll-loops -fprefetch-loop-arrays -std=c++11     test.cpp -o test_o3_unroll_loops__and__prefetch_loop_arrays

[1829] /tmp/so_25078285 $ ./test_o3 1
unsigned        41959360000     0.595 sec       17.6231 GB/s
uint64_t        41959360000     0.898626 sec    11.6687 GB/s

[1829] /tmp/so_25078285 $ ./test_o3_unroll_loops__and__prefetch_loop_arrays 1
unsigned        41959360000     0.618222 sec    16.9612 GB/s
uint64_t        41959360000     0.407304 sec    25.7443 GB/s
  • 3
    Но, тем не менее, ваши результаты совершенно странные (сначала без подписи, а затем быстрее до uint64_t), поскольку развертывание не решает основной проблемы ложной зависимости.
7

Вы пробовали переместить шаг сокращения за пределы цикла? Прямо сейчас у вас есть зависимость от данных, которая действительно не нужна.

Try:

  uint64_t subset_counts[4] = {};
  for( unsigned k = 0; k < 10000; k++){
     // Tight unrolled loop with unsigned
     unsigned i=0;
     while (i < size/8) {
        subset_counts[0] += _mm_popcnt_u64(buffer[i]);
        subset_counts[1] += _mm_popcnt_u64(buffer[i+1]);
        subset_counts[2] += _mm_popcnt_u64(buffer[i+2]);
        subset_counts[3] += _mm_popcnt_u64(buffer[i+3]);
        i += 4;
     }
  }
  count = subset_counts[0] + subset_counts[1] + subset_counts[2] + subset_counts[3];

У вас также есть странное сглаживание, что я не уверен, что он соответствует строгим правилам псевдонимов.

  • 2
    Это было первое, что я сделал после того, как прочитал вопрос. Разорвать цепочку зависимостей. Как оказалось, разница в производительности не меняется (по крайней мере, на моем компьютере - Intel Haswell с GCC 4.7.3).
  • 0
    @BenVoigt: это соответствует строгим псевдонимам. void* и char* - это два типа, которые могут быть псевдонимами, поскольку они по сути считаются "указателями на какой-то кусок памяти"! Ваша идея относительно удаления зависимости от данных хороша для оптимизации, но она не отвечает на вопрос. И, как говорит @NilsPipenbrinck, похоже, это ничего не меняет.
Показать ещё 8 комментариев
5

TL; DR: Вместо этого используйте __builtin intrinsics.

Я смог сделать gcc 4.8.4 (и даже 4.7.3 на gcc.godbolt.org) генерировать оптимальный код для этого, используя __builtin_popcountll, который использует ту же инструкцию сборки, но не имеет этого ошибка ложной зависимости.

Я не уверен на 100% моего кода бенчмаркинга, но вывод objdump, похоже, разделяет мои взгляды. Я использую некоторые другие трюки (++i vs i++), чтобы сделать цикл компиляции для меня без инструкции movl (странное поведение, я должен сказать).

Результаты:

Count: 20318230000  Elapsed: 0.411156 seconds   Speed: 25.503118 GB/s

Код бенчмаркинга:

#include <stdint.h>
#include <stddef.h>
#include <time.h>
#include <stdio.h>
#include <stdlib.h>

uint64_t builtin_popcnt(const uint64_t* buf, size_t len){
  uint64_t cnt = 0;
  for(size_t i = 0; i < len; ++i){
    cnt += __builtin_popcountll(buf[i]);
  }
  return cnt;
}

int main(int argc, char** argv){
  if(argc != 2){
    printf("Usage: %s <buffer size in MB>\n", argv[0]);
    return -1;
  }
  uint64_t size = atol(argv[1]) << 20;
  uint64_t* buffer = (uint64_t*)malloc((size/8)*sizeof(*buffer));

  // Spoil copy-on-write memory allocation on *nix
  for (size_t i = 0; i < (size / 8); i++) {
    buffer[i] = random();
  }
  uint64_t count = 0;
  clock_t tic = clock();
  for(size_t i = 0; i < 10000; ++i){
    count += builtin_popcnt(buffer, size/8);
  }
  clock_t toc = clock();
  printf("Count: %lu\tElapsed: %f seconds\tSpeed: %f GB/s\n", count, (double)(toc - tic) / CLOCKS_PER_SEC, ((10000.0*size)/(((double)(toc - tic)*1e+9) / CLOCKS_PER_SEC)));
  return 0;
}

Параметры компиляции:

gcc --std=gnu99 -mpopcnt -O3 -funroll-loops -march=native bench.c -o bench

Версия GCC:

gcc (Ubuntu 4.8.4-2ubuntu1~14.04.1) 4.8.4

Версия ядра Linux:

3.19.0-58-generic

Информация о процессоре:

processor   : 0
vendor_id   : GenuineIntel
cpu family  : 6
model       : 70
model name  : Intel(R) Core(TM) i7-4870HQ CPU @ 2.50 GHz
stepping    : 1
microcode   : 0xf
cpu MHz     : 2494.226
cache size  : 6144 KB
physical id : 0
siblings    : 1
core id     : 0
cpu cores   : 1
apicid      : 0
initial apicid  : 0
fpu     : yes
fpu_exception   : yes
cpuid level : 13
wp      : yes
flags       : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx rdtscp lm constant_tsc nopl xtopology nonstop_tsc eagerfpu pni pclmulqdq ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm arat pln pts dtherm fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 invpcid xsaveopt
bugs        :
bogomips    : 4988.45
clflush size    : 64
cache_alignment : 64
address sizes   : 36 bits physical, 48 bits virtual
power management:
  • 2
    Это просто удача, что -funroll-loops код, который не является узким местом в цепочке зависимостей, popcnt созданной popcnt . Использование старой версии компилятора, которая не знает о ложной зависимости, является риском. Без -funroll-loops gcc 4.8.5 будет узким местом по латентности popcnt вместо пропускной способности, потому что он учитывается в rdx . Тот же код, скомпилированный gcc 4.9.3, добавляет xor edx,edx для разрыва цепочки зависимостей.
  • 3
    Со старыми компиляторами ваш код все еще был бы уязвим к точно такому изменению производительности, с которым сталкивался OP: казалось бы, тривиальные изменения могли сделать gcc чем-то медленным, потому что он не знал, что это вызовет проблему. Найти что-то, что работает в одном случае на старом компиляторе, не вопрос.
Показать ещё 1 комментарий
0

Хорошо, я хочу дать небольшой ответ на один из подвопросов, заданных ФП, которые, кажется, не рассматриваются в существующих вопросах. Предостережение: я не занимался ни тестированием, ни генерацией кода, ни дизассемблированием, просто хотел поделиться мыслью, которую другие могли бы разъяснить.

Почему static меняет производительность?

Рассматриваемая строка: uint64_t size = atol(argv[1])<<20;

Короткий ответ

Я бы посмотрел на сборку, сгенерированную для доступа к size и выяснил, есть ли дополнительные шаги по перенаправлению указателя, связанные с нестатической версией.

Длинный ответ

Поскольку существует только одна копия переменной, независимо от того, была ли она объявлена static или нет, а размер не изменился, я предполагаю, что различие заключается в расположении памяти, используемой для хранения переменной вместе с тем, где она используется в коде. дальше.

Хорошо, чтобы начать с очевидного, помните, что всем локальным переменным (вместе с параметрами) функции предоставляется место в стеке для использования в качестве хранилища. Теперь очевидно, что стек стека для main() никогда не очищается и генерируется только один раз. Хорошо, а как насчет того, чтобы сделать его static? Что ж, в этом случае компилятор знает, что нужно зарезервировать пространство в глобальном пространстве данных процесса, поэтому его местоположение не может быть очищено удалением стекового фрейма. Но, тем не менее, у нас есть только одно местоположение, так в чем же разница? Я подозреваю, что это связано с тем, как ссылки на ячейки памяти в стеке упоминаются.

Когда компилятор генерирует таблицу символов, он просто делает запись для метки вместе с соответствующими атрибутами, такими как размер и т.д. Он знает, что он должен зарезервировать соответствующее пространство в памяти, но на самом деле не выбирает это местоположение, пока несколько позже в процесс после выполнения анализа живучести и, возможно, регистрации распределения. Как тогда компоновщик узнает, какой адрес предоставить машинному коду для кода окончательной сборки? Он либо знает конечное местоположение, либо знает, как добраться до места. Со стеком довольно просто сослаться на один из двух элементов на основе местоположения, указатель на кадр стека и затем смещение в кадре. Это в основном потому, что компоновщик не может знать местоположение стекового фрейма до времени выполнения.

  • 1
    Мне кажется гораздо более вероятным, что с помощью static произошло изменение распределения регистров для функции таким образом, что это повлияло на ложную зависимость вывода popcnt от процессоров Intel, на которых тестировался OP, с компилятором, который не знал, как их избежать. , (Поскольку этот пробел в производительности в процессорах Intel еще не был обнаружен.) Компилятор может хранить static локальную переменную в регистре, как автоматическая переменная хранения, но если они не оптимизируют, предполагая, что main запускается только один раз, тогда он повлияет на генерацию кода (поскольку значение устанавливается только при первом вызове.)
  • 1
    В любом случае, разница в производительности между режимами адресации [RIP + rel32] и [rsp + 42] в большинстве случаев незначительна. cmp dword [RIP+rel32], immediate не может cmp dword [RIP+rel32], immediate в одну нагрузку + cmp uop, но я не думаю, что это будет фактором. Как я уже сказал, внутри циклов он, вероятно, в любом случае остается в регистре, но настройка C ++ может означать различные варианты компиляции.
-4

Прежде всего, попытайтесь оценить пиковую производительность - изучите https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf В частности, Приложение С.

В вашем случае это таблица C-10, которая показывает, что инструкция POPCNT имеет задержку = 3 такта и пропускную способность = 1 такт. Пропускная способность показывает вашу максимальную скорость в тактах (умножьте на частоту ядра и 8 байтов в случае popcnt64, чтобы получить максимально возможное значение пропускной способности).

Теперь проверьте, что сделал компилятор, и суммируйте пропускную способность всех других инструкций в цикле. Это даст наилучшую оценку сгенерированного кода.

Наконец, посмотрите на зависимости данных между инструкциями в цикле, так как они будут вызывать большую задержку, а не пропускную способность, поэтому разделите инструкции одной итерации в цепочках потока данных и рассчитайте задержку между ними, а затем наивно извлекайте максимальную из них. это даст приблизительную оценку с учетом зависимостей потока данных.

Однако в вашем случае правильное написание кода устранит все эти сложности. Вместо того, чтобы накапливаться в одной переменной count, просто накапливайте в разные (например, count0, count1,... count8) и суммируйте их в конце. Или даже создайте массив count [8] и накапливайте его элементы - возможно, он будет даже векторизован, и вы получите намного лучшую пропускную способность.

PS и никогда не запускайте эталонный тест в течение секунды, сначала прогрейте ядро, затем выполните цикл в течение по крайней мере 10 секунд или лучше 100 секунд. в противном случае вы протестируете аппаратное обеспечение управления питанием и реализацию DVFS аппаратно :)

PPS Я слышал бесконечные споры о том, сколько времени должно действительно пройти тест. Самые умные люди даже спрашивают, почему 10 секунд, а не 11 или 12. Я должен признать, что это забавно в теории. На практике вы просто стоите и запускаете бенчмарк сто раз подряд и записываете отклонения. Это смешно. Большинство людей меняют источник и запускают Bench после этого ровно ОДИН РАЗ, чтобы получить новый рекорд производительности. Делай правильные вещи правильно.

Еще не убежден? Просто используйте вышеупомянутую C-версию теста assp1r1n3 (https://stackoverflow.com/questions/25078285/replacing-a-32-bit-loop-counter-with-64-bit-introduces-crazy-performance-deviati) и попробуйте 100 вместо 10000 в цикле повтора.

Мой 7960X показывает, с RETRY = 100:

Счетчик: 203182300 Прошло: 0.008385 секунд Скорость: 12.505379 ГБ/с

Количество: 203182300 Прошло: 0,011063 секунды Скорость: 9,478225 ГБ/с

Количество: 203182300 Прошло: 0,011188 секунд Скорость: 9,372327 ГБ/с

Счетчик: 203182300 Прошло: 0,010393 с. Скорость: 10,089252 ГБ/с

Количество: 203182300 Прошло: 0,009076 секунд Скорость: 11,553283 ГБ/с

с RETRY = 10000:

Счетчик: 20318230000 Прошло: 0,661791 сек. Скорость: 15,844519 ГБ/с

Количество: 20318230000 Прошло: 0,665422 секунд Скорость: 15,758060 ГБ/с

Счетчик: 20318230000 Прошло: 0,660983 секунды. Скорость: 15,863888 ГБ/с.

Счетчик: 20318230000 Прошло: 0,665337 секунд. Скорость: 15,760073 ГБ/с.

Счетчик: 20318230000 Прошло: 0,662138 секунд Скорость: 15,836215 ГБ/с

PPPS Наконец-то о "принятом ответе" и других тайнах ;-)

Давайте воспользуемся ответом assp1r1n3 - у него ядро 2,5 ГГц. POPCNT имеет 1 тактовый выход, его код использует 64-битное popcnt. Таким образом, для его настройки математика составляет 2,5 ГГц * 1 тактовая частота * 8 байт = 20 ГБ/с. Он видит скорость 25 Гбит/с, возможно, из-за турбонаддува до 3 ГГц.

Таким образом, перейдите на ark.intel.com и найдите i7-4870HQ: https://ark.intel.com/products/83504/Intel-Core-i7-4870HQ-Processor-6M-Cache-up-to-3-70 -GHz-? д = i7-4870HQ

Это ядро может работать до 3,7 ГГц, а реальная максимальная скорость его оборудования составляет 29,6 ГБ/с. Так где еще 4Гб/с? Возможно, он расходуется на логику цикла и другой окружающий код в каждой итерации.

Где эта ложная зависимость? аппаратное обеспечение работает почти с максимальной скоростью. Может быть, моя математика плохая, иногда это случается :)

PPPPPS Тем не менее, люди, предположившие, что HW errata является виновником, поэтому я следую предложению и создаю пример встроенного ассемблера, см. Ниже.

На моем 7960X первая версия (с одним выходом для cnt0) работает со скоростью 11 МБ/с, вторая версия (с выходом для cnt0, cnt1, cnt2 и cnt3) работает со скоростью 33 МБ/с. И можно сказать - вуаля! это выходная зависимость.

Хорошо, возможно, я подчеркнул, что не имеет смысла писать код, подобный этому, и это не проблема выходных зависимостей, а глупая генерация кода. Мы не тестируем аппаратное обеспечение, мы пишем код для максимальной производительности. Вы можете ожидать, что HW OOO будет переименовывать и скрывать эти "выходные зависимости", но, gash, просто делайте правильные вещи правильно, и вы никогда не столкнетесь с какой-либо загадкой.

uint64_t builtin_popcnt1a(const uint64_t* buf, size_t len) 
{
    uint64_t cnt0, cnt1, cnt2, cnt3;
    cnt0 = cnt1 = cnt2 = cnt3 = 0;
    uint64_t val = buf[0];
    #if 0
        __asm__ __volatile__ (
            "1:\n\t"
            "popcnt %2, %1\n\t"
            "popcnt %2, %1\n\t"
            "popcnt %2, %1\n\t"
            "popcnt %2, %1\n\t"
            "subq $4, %0\n\t"
            "jnz 1b\n\t"
        : "+q" (len), "=q" (cnt0)
        : "q" (val)
        :
        );
    #else
        __asm__ __volatile__ (
            "1:\n\t"
            "popcnt %5, %1\n\t"
            "popcnt %5, %2\n\t"
            "popcnt %5, %3\n\t"
            "popcnt %5, %4\n\t"
            "subq $4, %0\n\t"
            "jnz 1b\n\t"
        : "+q" (len), "=q" (cnt0), "=q" (cnt1), "=q" (cnt2), "=q" (cnt3)
        : "q" (val)
        :
        );
    #endif
    return cnt0;
}
  • 0
    Если вы синхронизируете такты ядра (а не секунды), 1 секунда - это достаточно времени для крошечного цикла, связанного с процессором. Даже 100 мс - это хорошо для нахождения основных различий или проверки счетчиков перфокарт на количество мопов. Особенно на Skylake, где аппаратное управление P-состоянием позволяет ему разогнаться до максимальной тактовой частоты в микросекундах после начала загрузки.
  • 0
    clang может автоматически векторизовать __builtin_popcountl с AVX2 vpshufb , и для этого не требуется нескольких аккумуляторов в источнике C. Я не уверен насчет _mm_popcnt_u64 ; это может только автоматически векторизовать с AVX512-VPOPCNT. (См. Подсчет 1 бита (подсчет населения) для больших данных с использованием AVX-512 или AVX-2 /)
Показать ещё 15 комментариев

Ещё вопросы

Сообщество Overcoder
Наверх
Меню