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

1424

Я искал самый быстрый способ для 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который читается из командной строки. После этого мы выполняем итерацию по буферу и используем развернутую версию popcountвстроенной функции x86 для выполнения popcount. Чтобы получить более точный результат, мы делаем поп-счет 10000 раз. Мы измеряем время для попконта. В верхнем регистре переменная внутреннего цикла - это unsigned, в нижнем регистре - переменная внутреннего цикла uint64_t. Я думал, что это не должно иметь никакого значения, но дело обстоит наоборот.

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

Я скомпилирую это так (версия g ++: Ubuntu 4.8.2-19ubuntu1):

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

Вот результаты работы моего процессора Haswell Core i7-4770K с тактовой частотой 3,50 ГГц test 1(так, случайные данные 1 МБ):

  • без знака 41959360000 0,401554 с 26,113 ГБ / с
  • uint64_t 41959360000 0,759822 с 13,8003 ГБ / с

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

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

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

  • без знака 41959360000 0,398293 с 26,3267 ГБ / с
  • uint64_t 41959360000 0,680954 с, 15,3986 ГБ / с

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

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

в

uint64_t size = 1 << 20;

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

  • без знака 41959360000 0,509156 с 20,5944 ГБ / с
  • uint64_t 41959360000 0,508673 с 20,6139 ГБ / с

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

  • без знака 41959360000 0,677009 с 15,4884 ГБ / с
  • uint64_t 41959360000 0,676909 с 15,4906 ГБ / с

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

Я попросил коллегу с процессором Ivy Bridge скомпилировать мой тест. Он получил аналогичные результаты, так что, похоже, это не Haswell. Поскольку два компилятора дают здесь странные результаты, это также не является ошибкой компилятора. У нас здесь нет процессора AMD, поэтому мы могли тестировать только с Intel.

Больше безумия, пожалуйста!

Возьмите первый пример (тот, что с atol(argv[1])) и поместите staticперед переменной, то есть:

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

Вот мои результаты в g ++:

  • без знака 41959360000 0,396728 с 26,4306 ГБ / с
  • uint64_t 41959360000 0,509484 с 20,5811 ГБ / с

Ууу, еще одна альтернатива . У нас все еще есть быстрые 26 ГБ / с u32, но нам удалось получить u64как минимум от 13 ГБ / с до версии 20 ГБ / с! На компьютере моего коллеги u64версия стала даже быстрее, чем u32версия, что дало самый быстрый результат из всех. К сожалению, это работает только для g++, clang++кажется, не заботится static.

Мой вопрос

Можете ли вы объяснить эти результаты? Особенно:

  • Как может быть такая разница между u32и u64?
  • Как замена непостоянного постоянным размером буфера может привести к менее оптимальному коду ?
  • Как вставка staticключевого слова может сделать u64цикл быстрее? Даже быстрее, чем оригинальный код на компьютере моего коллеги!

Я знаю, что оптимизация - это сложная территория, однако я никогда не думал, что такие небольшие изменения могут привести к 100% -ной разнице во времени выполнения и что небольшие факторы, такие как постоянный размер буфера, могут снова полностью смешивать результаты. Конечно, я всегда хочу иметь версию, способную подсчитывать 26 ГБ / с. Единственный надежный способ, который я могу придумать, это скопировать и вставить сборку для этого случая и использовать встроенную сборку. Это единственный способ избавиться от компиляторов, которые, кажется, сходят с ума от небольших изменений. Что вы думаете? Есть ли другой способ надежно получить код с большей производительностью?

Разборка

Вот разборка для различных результатов:

Версия 26 ГБ / с из g ++ / u32 / неконстантного размера bufsize :

0x400af8:
lea 0x1(%rdx),%eax
popcnt (%rbx,%rax,8),%r9
lea 0x2(%rdx),%edi
popcnt (%rbx,%rcx,8),%rax
lea 0x3(%rdx),%esi
add %r9,%rax
popcnt (%rbx,%rdi,8),%rcx
add $0x4,%edx
add %rcx,%rax
popcnt (%rbx,%rsi,8),%rcx
add %rcx,%rax
mov %edx,%ecx
add %rax,%r14
cmp %rbp,%rcx
jb 0x400af8

Версия 13 ГБ / с из g ++ / u64 / неконстантного размера bufsize :

0x400c00:
popcnt 0x8(%rbx,%rdx,8),%rcx
popcnt (%rbx,%rdx,8),%rax
add %rcx,%rax
popcnt 0x10(%rbx,%rdx,8),%rcx
add %rcx,%rax
popcnt 0x18(%rbx,%rdx,8),%rcx
add $0x4,%rdx
add %rcx,%rax
add %rax,%r12
cmp %rbp,%rdx
jb 0x400c00

Версия 15 ГБ / с из clang ++ / u64 / non-const bufsize :

0x400e50:
popcnt (%r15,%rcx,8),%rdx
add %rbx,%rdx
popcnt 0x8(%r15,%rcx,8),%rsi
add %rdx,%rsi
popcnt 0x10(%r15,%rcx,8),%rdx
add %rsi,%rdx
popcnt 0x18(%r15,%rcx,8),%rbx
add %rdx,%rbx
add $0x4,%rcx
cmp %rbp,%rcx
jb 0x400e50

Версия 20 ГБ / с из g ++ / u32 & u64 / const bufsize :

0x400a68:
popcnt (%rbx,%rdx,1),%rax
popcnt 0x8(%rbx,%rdx,1),%rcx
add %rax,%rcx
popcnt 0x10(%rbx,%rdx,1),%rax
add %rax,%rcx
popcnt 0x18(%rbx,%rdx,1),%rsi
add $0x20,%rdx
add %rsi,%rcx
add %rcx,%rbp
cmp $0x100000,%rdx
jne 0x400a68

Версия 15 ГБ / с из clang ++ / u32 & u64 / const bufsize :

0x400dd0:
popcnt (%r14,%rcx,8),%rdx
add %rbx,%rdx
popcnt 0x8(%r14,%rcx,8),%rsi
add %rdx,%rsi
popcnt 0x10(%r14,%rcx,8),%rdx
add %rsi,%rdx
popcnt 0x18(%r14,%rcx,8),%rbx
add %rdx,%rbx
add $0x4,%rcx
cmp $0x20000,%rcx
jb 0x400dd0

Интересно, что самая быстрая (26 ГБ / с) версия тоже самая длинная! Кажется, это единственное решение, которое использует lea. Некоторые версии используют jbдля перехода, другие используют jne. Но кроме этого все версии кажутся сопоставимыми. Я не понимаю, откуда может возникнуть разрыв в производительности на 100%, но я не слишком разбираюсь в расшифровке сборки. Самая медленная (13 ГБ / с) версия выглядит даже очень коротко и хорошо. Кто-нибудь может объяснить это?

Уроки выучены

Неважно, каким будет ответ на этот вопрос; Я узнал, что в действительно горячих циклах каждая деталь может иметь значение, даже детали, которые, кажется, не связаны с горячим кодом . Я никогда не задумывался о том, какой тип использовать для переменной цикла, но, как вы видите, такое незначительное изменение может иметь значение на 100% ! Даже тип хранилища буфера может иметь огромное значение, как мы видели при вставке staticключевого слова перед переменной размера! В будущем я всегда буду тестировать различные альтернативы на разных компиляторах, когда пишу действительно сжатые и горячие циклы, которые имеют решающее значение для производительности системы.

Интересно также то, что разница в производительности все еще так велика, хотя я уже развернул цикл четыре раза. Так что даже если вы развернетесь, вы все равно можете столкнуться с серьезными отклонениями производительности. Довольно интересно.

gexicide
источник
8
ТАК МНОГИЕ КОММЕНТАРИИ! Вы можете просматривать их в чате и даже оставить свой там, если хотите, но, пожалуйста, не добавляйте сюда больше!
Shog9
3
Также см. GCC Issue 62011, False Data Dependency в инструкции popcnt . Кто-то еще предоставил это, но это, кажется, было потеряно во время уборки.
jww
Я не могу сказать, но это одна из разборок для версии со статическим? Если нет, можете ли вы отредактировать пост и добавить его?
Келли С. Френч

Ответы:

1552

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

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

popcnt  src, dest

похоже, имеет ложную зависимость от регистра назначения dest. Несмотря на то, что инструкция только записывает в нее, инструкция будет ждать, пока не destбудет готова, прежде чем выполнить. Эта ложная зависимость (в настоящее время) задокументирована Intel как ошибка HSD146 (Haswell) и SKL029 (Skylake)

Скайлэйк исправил это для lzcntиtzcnt .
Озеро Кэннон (и Ледяное озеро) исправили это popcnt.
bsf/ bsrиметь истинную зависимость вывода: выходные данные не изменены для input = 0. (Но нельзя воспользоваться этим с помощью встроенных функций - только AMD это документирует, а компиляторы не раскрывают это.)

(Да, все эти инструкции выполняются на одном и том же исполнительном модуле ).


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

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

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

  • 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 и даже собственный ICC Intel, еще не знают о эта микроархитектурная ошибка и не будет генерировать код, который ее компенсирует.)

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

Можно предположить: она работает на тот же исполнительный блок , как bsf/ , bsrкоторые делают имеют выходную зависимость. ( Как POPCNT реализован в аппаратном обеспечении? ). Для этих инструкций Intel документирует целочисленный результат для input = 0 как «неопределенный» (с ZF = 1), но аппаратное обеспечение Intel фактически дает более сильную гарантию, чтобы не сломать старое программное обеспечение: вывод без изменений. AMD документирует это поведение.

Предположительно было как-то неудобно делать некоторые мопы для этого исполнительного модуля зависимыми от вывода, а другие нет.

Процессоры 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
Этот бенчмарк меняет количество popcnts, которые находятся в (ложной) цепочке зависимостей.

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
Mysticial
источник
3
Привет народ! Много прошлых комментариев здесь; Перед тем как оставить новый, просмотрите архив .
Shog9
1
@ JustinL.it похоже, что эта конкретная проблема исправлена ​​в Clang по состоянию на 7.0
Дэн М.
@PeterCordes Я не думаю, что это исполнительный модуль, а планировщик. Это планировщик, который отслеживает зависимости. И для этого инструкции группируются в несколько «классов команд», каждый из которых обрабатывается планировщиком одинаково. Таким образом, все 3-тактные «медленные» инструкции были брошены в один и тот же «класс» для целей планирования команд.
Мистик
@Mysticial: Ты все еще так думаешь? Это правдоподобно, но imul dst, src, immне имеет выходной зависимости и не имеет медленной lea. Ни один не делает pdep, но это VEX, закодированный с 2 ​​входными операндами. Согласовано это не исполнительный блок сам по себе , что приводит к ложному отду; это до этапа RAT и этапа выпуска / переименования, поскольку он переименовывает операнды архитектурного регистра в физические регистры. Предположительно, ему нужна таблица uop-code -> шаблон зависимостей и выбор портов, а группировка всех мопов для одного и того же исполнительного модуля вместе упрощает эту таблицу. Вот что я имел в виду более подробно.
Питер Кордес
Дайте мне знать, если вы хотите, чтобы я отредактировал это в вашем ответе, или если вы хотите вернуть его к тому, чтобы сказать что-то вроде того, что вы изначально сказали о планировщике. Тот факт, что SKL отбросил ложную отправку для lzcnt / tzcnt, но не для popcnt, должен сказать нам кое-что, но IDK что. Другой возможный признак того, что он связан с переименованием / RAT, заключается в том, что SKL раскрывает индексированный режим адресации как источник памяти для lzcnt / tzcnt, но не для popcnt. Очевидно, что модуль переименования должен создавать мопы, которые может представлять серверная часть.
Питер Кордес
50

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

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

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

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

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

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

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

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

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

лязг с 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

лязг с uint64_t size=1<<20;

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

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

  1. В обратном порядке, результат тот же, что исключает коэффициент кеширования.
  2. Иметь 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, который сделал бы его «быстрее».
user541686
2
@ Mehrdad: Я имею в виду прыжок между 12.9и 16.8, поэтому unsignedздесь быстрее. В моем тесте было противоположное, то есть 26 для unsigned, 15 дляuint64_t
гексицид
@gexicide Вы заметили разницу в адресации буфера [i]?
Немаскируемое прерывание
@ Calvin: Нет, что ты имеешь в виду?
гексицид
10

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

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

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

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

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

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

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

Ген
источник
2
К сожалению, с тех пор (Core 2?) Практически нет различий в производительности между 32-разрядными и 64-разрядными целочисленными операциями, за исключением умножения / деления, которых нет в этом коде.
Мистик
@Gene: Обратите внимание, что все версии хранят размер в регистре и никогда не читают его из стека в цикле. Таким образом, вычисление адреса не может быть в миксе, по крайней мере, не внутри цикла.
гексицид
@Gene: действительно интересное объяснение! Но это не объясняет основных моментов WTF: что 64-битная медленнее, чем 32-битная из-за остановок конвейера, это одно. Но если это так, разве 64-битная версия не должна быть надежнее медленной, чем 32- битная ? Вместо этого три разных компилятора выдают медленный код даже для 32-битной версии при использовании постоянного размера буфера компиляции; изменение размера буфера на статическое снова полностью меняет дело. Был даже случай на моей машине коллег (и в ответе Кальвина), где 64-битная версия значительно быстрее! Кажется, это абсолютно непредсказуемо.
Гексицид
@ Mystic Это моя точка зрения. Нет разницы в пиковой производительности, когда отсутствует конкуренция за IU, время шины и т. Д. Ссылка ясно показывает это. Конфликт делает все по-другому. Вот пример из литературы Intel Core: «Одна новая технология, включенная в проект, - это Macro-Ops Fusion, которая объединяет две инструкции x86 в одну микрооперацию. Например, общая последовательность кода, такая как сравнение, сопровождается условным переходом станет одной микрооперацией. К сожалению, эта технология не работает в 64-битном режиме ». Таким образом, мы имеем соотношение скорости исполнения 2: 1.
Джин
@gexicide Я понимаю, что вы говорите, но вы выводите больше, чем я имел в виду. Я говорю, что код, который работает быстрее всего, поддерживает конвейер и очереди отправки полными. Это состояние хрупкое. Незначительные изменения, такие как добавление 32 бит к общему потоку данных и переупорядочение команд, достаточны, чтобы сломать его. Короче говоря, утверждение ОП о том, что возиться и тестировать - единственный путь вперед, является правильным.
Джин
10

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

   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 = количество, rdi = буфер, 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
rcgldr
источник
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
Dangelov
источник
3
Но, тем не менее, ваши результаты выглядят совершенно странно (сначала без подписи, а затем быстрее с uint64_t), поскольку развертывание не решает основную проблему ложной зависимости.
гексицид
7

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

Пытаться:

  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).
Нильс Пипенбринк
1
@BenVoigt: он соответствует строгим псевдонимам. void*и char*есть два типа, которые могут быть псевдонимами, поскольку они по сути считаются «указателями на какой-то кусок памяти»! Ваша идея относительно удаления зависимости от данных хороша для оптимизации, но она не отвечает на вопрос. И, как говорит @NilsPipenbrinck, похоже, это ничего не меняет.
гексицид
@gexicide: строгое правило псевдонимов не является симметричным. Вы можете использовать char*для доступа T[]. Вы не можете безопасно использовать a T*для доступа к a char[], и ваш код, кажется, делает последнее.
Бен Фойгт
@BenVoigt: Тогда вы никогда не сможете сохранить mallocмассив чего-либо, так как malloc возвращает, void*и вы интерпретируете это как T[]. И я вполне уверен, что void*и char*имел ту же семантику в отношении строгого алиасинга. Тем не менее, я думаю, что это довольно оффтоп здесь :)
Gexicide
1
Лично я думаю, что правильный путьuint64_t* buffer = new uint64_t[size/8]; /* type is clearly uint64_t[] */ char* charbuffer=reinterpret_cast<char*>(buffer); /* aliasing a uint64_t[] with char* is safe */
Бен Фойгт
6

TL; DR: используйте __builtinвместо этого встроенные функции; они могут помочь.

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

Я не уверен на 100% в своем коде бенчмаркинга, но objdumpвывод, похоже, разделяет мои взгляды. Я использую некоторые другие приемы ( ++iпротив 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:
assp1r1n3
источник
3
Просто удача -funroll-loopsв том, что создается код, который не является узким местом в цепочке зависимостей, переносимых циклом, созданной popcntс помощью false dep. Использование старой версии компилятора, которая не знает о ложной зависимости, является риском. Без -funroll-loops, цикл gcc 4.8.5 будет узким местом по латентности popcnt вместо пропускной способности, потому что это считаетсяrdx . Тот же код, скомпилированный gcc 4.9.3, добавляет xor edx,edxразрыв цепочки зависимостей.
Питер Кордес
3
Со старыми компиляторами ваш код по-прежнему был бы уязвим к точно такой же вариации производительности, с которой сталкивался OP: казалось бы, тривиальные изменения могли сделать gcc чем-то медленным, потому что он не знал, что это вызовет проблему. Найти что-то, что работает в одном случае на старом компиляторе, не вопрос.
Питер Кордес
2
Для записи, x86intrin.h«s _mm_popcnt_*функции на GCC принудительно встраиваемыми оберток по всему__builtin_popcount* ; вставка должна сделать одно точно эквивалентным другому. Я очень сомневаюсь, что вы увидите разницу, которая может быть вызвана переключением между ними.
ShadowRanger
-2

Прежде всего, попытайтесь оценить максимальную производительность - изучите 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) и суммируйте их в конце. Или даже создать массив счетчиков [8] и накапливать его элементы - возможно, он будет даже векторизован, и вы получите намного лучшую пропускную способность.

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

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

Еще не убежден? Просто используйте вышеуказанную C-версию теста assp1r1n3 ( https://stackoverflow.com/a/37026212/9706746 ) и попробуйте 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;
}
Kovalex
источник
Если вы синхронизируете такты ядра (а не секунды), 1 секунда - это достаточно времени для крошечного цикла, связанного с процессором. Даже 100 мс - это хорошо для нахождения основных различий или проверки счетчиков перфокарт по количеству мопов. Особенно на Skylake, где аппаратное управление P-состоянием позволяет ему разгоняться до максимальной тактовой частоты в микросекундах после начала загрузки.
Питер Кордес
clang может автоматически векторизоваться __builtin_popcountlс AVX2 vpshufb, и для этого не требуется нескольких аккумуляторов в источнике C. Я не уверен в этом _mm_popcnt_u64; это может только автоматически векторизовать с AVX512-VPOPCNT. (См. Подсчет 1 бита (подсчет населения) для больших данных с использованием AVX-512 или AVX-2 /)
Питер Кордес,
Но в любом случае, просмотр руководства по оптимизации Intel не поможет: как показывает принятый ответ, проблема заключается в неожиданной зависимости выхода popcnt. Это задокументировано в ошибках Intel для некоторых из их недавних микроархитектур, но я думаю, что не было в то время. Ваш анализ деп-цепочки потерпит неудачу, если возникнут неожиданные ложные зависимости, поэтому этот ответ является хорошим общим советом, но не применим здесь.
Питер Кордес
1
Ты шутишь, что ли? Мне не нужно «верить» в то, что я могу экспериментально измерить с помощью счетчиков производительности в рукописном цикле asm. Это просто факты. Я проверил, и Скайлэйк исправил ложную зависимость для lzcnt/ tzcnt, но не для popcnt. См. Ошибку Intel SKL029 в intel.com/content/dam/www/public/us/en/documents/… . Кроме того, gcc.gnu.org/bugzilla/show_bug.cgi?id=62011 "разрешено исправлено", а не "недействительно". Нет никаких оснований утверждать, что в HW нет выходной зависимости.
Питер Кордес
1
Если вы сделаете простой цикл, такой как popcnt eax, edx/ dec ecx / jnz, вы ожидаете, что он будет работать со скоростью 1 за такт, с узкими местами по пропускной способности popcnt и пропускной способности по заданным ветвям. Но на самом деле он работает только с частотой 1 на 3 такта, что является узким местом для popcntзадержки при многократной перезаписи EAX, даже если вы ожидаете, что он будет только для записи. У вас есть Skylake, так что вы можете попробовать это сами.
Питер Кордес
-3

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

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

Строка, о которой идет речь: uint64_t size = atol(argv[1])<<20;

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

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

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

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

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

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

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