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


1424

Я шукав найшвидший шлях до popcountвеликих масивів даних. У мене виник дуже дивний ефект: зміна змінної циклу з unsignedна uint64_tзменшення продуктивності на моєму ПК.

Орієнтир

#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 для виконання попконтану. Щоб отримати більш точний результат, ми робимо попконт в 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 скласти мій показник. Він отримав подібні результати, тому, здається, це не Хасвелл. Оскільки два компілятори дають тут дивні результати, це також не здається помилкою компілятора. У нас немає процесора AMD, тому ми могли тестувати лише Intel.

Більше божевілля, будь ласка!

Візьміть перший приклад (той, що має atol(argv[1])) і поставте a 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 / не-const 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 / non-const 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зміну , як ми бачили при вставці ключового слова перед змінною розміру! В майбутньому я завжди перевіряю різні альтернативи на різних компіляторах, коли пишу дійсно тісні та гарячі цикли, які мають вирішальне значення для продуктивності системи.

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


8
ТАКІ МНОГО КОМЕНТАРІВ! Ви можете переглядати їх у чаті і навіть залишати там своє, якщо хочете, але, будь ласка, більше не додайте сюди!
Shog9

3
Також див. GCC Issue 62011, Неправдива залежність даних у інструкції з popcnt . Хтось ще її надав, але, здається, він був загублений під час прибирань.
jww

Я не можу сказати, але це одна з розборок для версії зі статичним? Якщо ні, чи можете ви відредагувати публікацію та додати її?
Келлі С. Французька

Відповіді:


1552

Винуватець: помилкова залежність даних (а компілятор навіть не знає про це)

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

popcnt  src, dest

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

Skylake виправив це для lzcntтаtzcnt .
Cannon Lake (і Крижане озеро) виправили це за popcnt.
bsf/ bsrмають справжню вихідну залежність: вихід не змінений для вводу = 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 документує таку поведінку.

Імовірно, було якось незручно робити деякі Uops для цього блоку виконання залежно від результату, а інші - ні.

Схоже, процесори 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

3
Привіт, люди! Тут багато минулих коментарів; перш ніж залишати новий, перегляньте архів .
Shog9

1
@ JustinL.it виглядає так, що ця проблема виправлена ​​в Кланге станом на 7.0
Ден М.

@PeterCordes Я не думаю, що це блок виконання настільки, наскільки це планувальник. Саме планувальник відстежує залежності. І для цього інструкції групуються в ряд "класів інструкцій", кожен з яких обробляється однаково планувальником. Таким чином, всі 3-циклові "повільні" інструкції були кинуті в один і той же "клас" з метою планування інструкцій.
Містичний

@Mysticial: Ви все ще так думаєте? Це правдоподібно, але imul dst, src, immне має вивідної залежності, а також не повільне lea. Ні це не робить pdep, але це VEX, закодований двома вхідними операндами. Погоджено це не виконавчий блок сам по собі , що призводить до помилкового отду; це до етапу RAT та видачі / перейменування, оскільки він перейменовує операнди архітектури у фізичні регістри. Імовірно, йому потрібна таблиця загального коду -> схема залежності і вибір портів, а групування всіх Uops для одного блоку виконання разом спрощує цю таблицю. Ось що я мав на увазі більш докладно.
Пітер Кордес

Повідомте мене, якщо ви хочете, щоб я відредагував це у вашій відповіді, або ви хочете повернути це, щоб сказати щось на кшталт того, що ви спочатку сказали про планувальника. Той факт, що SKL скинув помилковий деп для lzcnt / tzcnt, але не popcnt, повинен щось нам сказати, але IDK що. Ще одна можлива ознака того, що це перейменування / пов'язане з RAT, - це те, що SKL засвоює режим індексованої адреси як джерело пам'яті для lzcnt / tzcnt, але не popcnt. Очевидно, що блок перейменування повинен створити Uops, який може представляти бек-енд.
Пітер Кордес

50

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

Я трохи замислився над збіркою:
просто візьміть 32-бітну версію, замініть всі 32-бітні інструкції / регістри на 64-бітну версію у внутрішній контур програми. Спостереження: код настільки ж швидкий, як і 32-бітна версія!

Це, очевидно, хак, оскільки розмір змінної насправді не 64-бітний, так як в інших частинах програми все ще використовується 32-розрядна версія, але поки внутрішня петля переважає над продуктивністю, це хороший старт .

Потім я скопіював код внутрішнього циклу з 32-розрядної версії програми, зламав його до 64-бітової, зіткнувся з регістрами, щоб зробити це заміною внутрішнього циклу 64-бітної версії. Цей код також працює так само швидко, як і 32-розрядна версія.

Мій висновок полягає в тому, що це поганий графік планування інструкцій компілятором, а не фактична перевага швидкості / затримки 32-бітних інструкцій.

(Caveat: Я зламав збірку, міг щось зламати, не помічаючи. Я не думаю, що так.)


1
"Більше того, gcc вважає, що 64-розрядне ціле число […] буде кращим, оскільки використання uint_fast32_t призводить до того, що gcc використовує 64-бітну уті." На жаль, і на превеликий жаль, за цими типами не стоїть ні магія, ні глибока інтроспекція коду. Я ще не бачив, щоб вони були надані будь-яким іншим способом, ніж як єдиний typedefs для кожного можливого місця та кожної програми на всій платформі. За точним вибором типів, швидше за все, було задумано, але одне визначення для кожного з них не може підходити до будь-якої програми, яка коли-небудь буде. Деякі подальші читання: 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, зробивши це "швидшим".
користувач541686

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 біта ALU недостатньо розроблений.

Я особисто зіткнувся з дивним випадком, коли гаряча петля проходила значно повільніше на конкретному ядрі чотирьохядерного чіпа (якщо я пам’ятаю AMD). Ми фактично отримали кращі показники при обчисленні зменшення карти, вимкнувши це ядро.

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

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

Я знаю , що це не строгий аналіз, але це правдоподібне пояснення.


2
На жаль, з тих пір (Core 2?) Практично немає різниці в продуктивності між 32-бітними та 64-бітовими цілочисельними операціями, за винятком множення / ділення - яких у цьому коді немає.
Містичний

@Gene: Зауважте, що всі версії зберігають розмір в регістрі і ніколи не читають його зі стека в циклі. Таким чином, обчислення адреси не може бути в суміші, принаймні, не всередині циклу.
гексицид

@Gene: Цікаве пояснення справді! Але це не пояснює основні моменти WTF: те, що 64-бітний повільніше, ніж 32-бітний через трубопровідні стоянки - це одне. Але якщо це так, чи не повинна 64- бітна версія бути надійніше повільніше, ніж 32-бітна версія ? Натомість три різні компілятори випромінюють повільний код навіть для 32-бітної версії, коли використовують постійний розмір буфера компіляції за часом; зміна розміру буфера на статичний знову повністю змінює речі. На машині моїх колег (і у відповідь Калвіна) навіть був випадок, коли 64-бітна версія значно швидша! Здається, це абсолютно непередбачувано ..
гексицид

@ Містичний Це моя думка. Немає пікової різниці в продуктивності, коли для 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 = count, 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

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

Ви спробували перемістити крок зменшення за межами циклу? Зараз у вас залежність від даних, яка насправді не потрібна.

Спробуйте:

  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).
Nils Pipenbrinck

1
@BenVoigt: Він відповідає суворому зглаженню. void*і char*це два типи, які можуть бути псевдоніми, оскільки вони по суті вважаються "покажчиками на якийсь фрагмент пам'яті"! Ваша ідея щодо усунення залежності даних є приємною для оптимізації, але не відповідає на питання. І, як каже @NilsPipenbrinck, це, здається, нічого не змінить.
гексицид

@gexicide: суворе правило псевдонімусу не симетричне. Ви можете використовувати char*для доступу до T[]. Ви не можете безпечно використовувати a T*для доступу до char[], і ваш код, здається, робить це останнім.
Ben Voigt

@BenVoigt: Тоді ви ніколи не можете зберегти mallocмасив нічого, як повертається malloc, void*і ви інтерпретуєте це як T[]. І я майже впевнений, що void*і char*в тій же семантиці, що стосується суворого псевдоніму. Однак, я здогадуюсь, це тут досить
офтопік

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висновок поділяється моїми поглядами. Я використовую деякі інші хитрощі ( ++ivs 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:

3
Це просто удача, що -funroll-loopsтрапляється зробити код, який не є вузьким місцем у ланцюзі залежностей, що переносяться циклом, створеним popcnt'false false dep. Використання старої версії компілятора, яка не знає про помилкову залежність, є ризиком. Без -funroll-loopsцього циклу gcc 4.8.5 буде вузьке місце затримки popcnt замість пропускної здатності, оскільки він враховуєтьсяrdx . Той самий код, складений gcc 4.9.3, додає xor edx,edxдо розриву ланцюга залежностей.
Пітер Кордес

3
Зі старими компіляторами ваш код все-таки буде вразливим до точно тієї ж зміни продуктивності, що і в ОП: начебто тривіальні зміни можуть зробити 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, щоб отримати найкраще можливе число пропускної здатності).

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

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

Однак у вашому випадку правильне написання коду усуне всі ці складності. Замість того, щоб накопичувати до однієї змінної підрахунку, просто накопичіть їх до різних (наприклад, count0, count1, ... count8) та підсумуйте їх у кінці. Або навіть створити масив підрахунків [8] і накопичити його елементи - можливо, він буде навіть векторизований і ви отримаєте набагато кращу пропускну здатність.

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

PPS Я чув нескінченні дебати про те, скільки часу має реально працювати. Більшість розумних людей навіть запитують, чому 10 секунд не 11 чи 12. Я повинен визнати, що це теоретично смішно. На практиці ви просто переходите та пробігаєте еталони сто разів поспіль і фіксуєте відхилення. Це IS смішно. Більшість людей все-таки змінюють джерело і запускають лавку після цього саме НАДЕЖДА для отримання нового запису продуктивності. Зробіть правильні речі правильно.

Не переконаний досі? Просто використовуйте вищевказану 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 -ГГц-? Q = i7-4870HQ

Це ядро ​​може працювати до 3,7 ГГц, а реальна максимальна швидкість - 29,6 Гб / с для його обладнання. То де ж ще 4 Гб / с? Можливо, він витрачається на логіку циклу та інший оточуючий код у межах кожної ітерації.

Тепер де ця помилкова залежність? апаратне забезпечення працює майже з максимальною швидкістю. Можливо, моя математика погана, трапляється іноді :)

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

У моєму 7960X перша версія (з одним виходом до cnt0) працює на швидкості 11 Мб / с, друга версія (з вихідними сигналами до cnt0, cnt1, cnt2 та cnt3) працює при 33 Мб / с. І можна сказати - вуаля! це вихідна залежність.

Гаразд, можливо, пункт, який я зробив, полягає в тому, що писати код не має сенсу писати подібний код, і це не проблема залежності виводу, а генерація німого коду. Ми не тестуємо обладнання, ми пишемо код для досягнення максимальної продуктивності. Можна очікувати, що HW OOO має перейменовувати та приховати ці "залежності від виходу", але, будь ласка, просто зробіть правильні речі правильно, і ви ніколи не зіткнетесь із будь-якою таємницею.

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;
}

Якщо ви проводите час в основних тактових циклах (замість секунд), на 1 секунду вистачає часу на крихітний цикл, пов'язаний з процесором. Навіть 100 м - це добре для пошуку великих відмінностей або перевірки лічильників парфу на загальне число. Особливо на Skylake, де апаратне управління P-state дозволяє збільшувати до максимальної тактової швидкості в мікросекундах після початку завантаження.
Пітер Кордес

clang може автоматично векторизуватися за __builtin_popcountlдопомогою AVX2 vpshufb, і для цього не потрібно декількох акумуляторів у джерелі C. Я не впевнений у цьому _mm_popcnt_u64; яка може автоматично автовекторизувати лише з AVX512-VPOPCNT. (Див. Підрахунок 1 біт (кількість населення) для великих даних за допомогою AVX-512 або AVX-2 /)
Пітер Кордес

Але в будь-якому випадку, перегляд посібника з оптимізації Intel не допоможе: як показує прийнята відповідь, проблема полягає в несподіваній залежності від виходу popcnt. Це зафіксовано в помилках Intel для деяких їхніх останніх мікроархітектур, але я думаю, що цього не було. Ваш аналіз ланцюга депілотів не вдасться, якщо є несподівані помилкові залежності, тому ця відповідь є корисною загальною порадою, але не застосовується тут.
Пітер Кордес

1
Ти мене жартуєш? Мені не потрібно «вірити» в речі, які я можу експериментально виміряти за допомогою лічильників продуктивності в рукописному циклі asm. Вони просто факти. Я перевірив, і Skylake виправив хибну залежність для lzcnt/ tzcnt, але не для popcnt. Див. Помилку Intel SKL029 в Intel.com на веб- сайті 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 за годину, з вузькими місцями на пропускну здатність попкнта та пропускну здатність гілки. Але насправді він працює лише в 1 на 3 тактові години, які не мають popcntзатримки для повторного перезапису EAX, хоча ви очікуєте, що це буде лише для запису. У вас є Skylake, тому ви можете спробувати його самостійно.
Пітер Кордес

-3

Гаразд, я хочу надати невелику відповідь на одне з підпитувань, які поставила ОП, які, схоже, не вирішуються в існуючих питаннях. Caveat, я не робив ані тестування, ані генерації коду, ані розбирання, просто хотів поділитися думкою для інших, щоб можливо пояснити.

Чому це static зміна продуктивності?

Рядок, про який йдеться: uint64_t size = atol(argv[1])<<20;

Коротка відповідь

Я роздивився б збірку, сформовану для доступу sizeта побачив, чи є додаткові кроки непрямості вказівника, які задіяні для нестатичної версії.

Довга відповідь

Оскільки є лише одна копія змінної, декларована вона staticчи ні, і розмір не змінюється, я вважаю, що різниця - це розташування пам'яті, яка використовується для резервної змінної разом із тим, де вона додатково використовується в коді вниз.

Добре, для початку очевидно, пам’ятайте, що всі локальні змінні (разом з параметрами) функції надають простір у стеку для використання в якості сховища. Тепер, очевидно, рамка стека для main () ніколи не очищається і генерується лише один раз. Гаразд, що з його виготовленнямstatic ? Ну, у цьому випадку компілятор знає, що резервує простір у глобальному просторі даних процесу, щоб місце не можна було очистити, видаливши рамку стека. Але все-таки у нас є лише одне місце розташування, і в чому різниця? Я підозрюю, що це стосується того, як посилаються на місця пам'яті на стеці.

Коли компілятор генерує таблицю символів, він просто робить запис для мітки разом із відповідними атрибутами, такими як розмір тощо. Він знає, що він повинен зарезервувати відповідний простір у пам'яті, але насправді не вибере це місце лише дещо пізніше у процес після аналізу життєдіяльності та, можливо, реєстрації розподілу. Як тоді лінкер знає, яку адресу надати машинному коду для остаточного коду складання? Він або знає кінцеве місце розташування, або знає, як прибути до місця. З стеком досить просто посилатися на місце, засноване на двох двох елементах, вказівник на рамку стека і потім зміщення у кадр. Це в основному тому, що лінкер не може знати розташування рамки стека перед виконанням.


2
Мені здається набагато більш імовірним те, що використання staticвипадків змінилося розподілу реєстру для функції таким чином, що вплинуло на помилкову залежність виводу від popcntпроцесорів Intel, на яких тестувалася ОП, із компілятором, який не знав їх уникати. (Оскільки ця вибоїна продуктивності в процесорах Intel ще не була виявлена.) Компілятор може зберігати staticлокальну змінну в реєстрі, як і автоматичну змінну пам’яті, але якщо вони не оптимізують, припускаючи, що вона mainпрацює лише один раз, це вплине на code-gen (оскільки значення встановлюється лише при першому дзвінку.)
Пітер Кордес

1
У будь-якому випадку різниця в продуктивності між режимами [RIP + rel32]та [rsp + 42]адресаціями для більшості випадків є досить незначною. cmp dword [RIP+rel32], immediateне може мікро-запобіжник в один навантаження + cmp взагалі, але я не думаю, що це буде фактором. Як я вже сказав, всередині циклів це, ймовірно, залишається в реєстрі в будь-якому випадку, але налаштування C ++ може означати різні варіанти компілятора.
Пітер Кордес
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.